天天看點

iOS 用戶端動圖優化實踐

作者:閃念基因

GIF 和 Animated WebP 是網際網路上最主流的動圖格式, 但是在 iOS 開發中, 原生的 UIImage 并不直接支援 GIF 以及 Animated WebP 的展示, 是以有了各種優秀的第三方開源方案, 例如 SDWebImage 以及 YYImage 等. 這篇文章将以 QQ 音樂 iOS 端優化動圖的實踐為基礎, 來介紹不同方案的思路以及優劣, 并給出優化的方案.

1. 端内動圖展示的問題以及優化結果

長期以來, 部分機型浏覽 Q 音的圖文流時很容易閃退, 端内其他業務也存在不少動圖相關的崩潰上報記錄.

崩潰的原因是, 端内加載圖檔時會在異步線程提前解碼, 短時間内解碼大量動圖幀會快速消耗掉可用記憶體, 在觸發系統的 MemoryWarning 通知之前就直接導緻 NSMallocException(Failed to grow buffer) 崩潰, 也很容易觸發 OOM.

我們經過兩個月灰階上線了動圖的逐幀解碼方案, 并封裝為圖檔的通用加載元件 QMAnimatedImageView, 優化帶來如下改善:

  1. 解決展示動圖頻繁崩潰的問題, 包括 OOM / NSMallocException / CPU 負載過高等.
  2. 在每一幀都解碼情況下, 優化前後首幀加載時長持平.
  3. 圖檔記憶體命中率由 65% 上升至 76%.
  4. 相比主流開源方案 YYAnimatedImageView 以及 SDAnimatedImageView, CPU 占用更少, 記憶體使用更少, 并且有更好的流暢度.
  5. 封裝成通用圖檔加載方案, 支援動圖靜态圖複用, 支援 GIF/Animated WebP/APNG 動圖格式.

2. iOS 展示動圖的方法

首先介紹幾種常用的展示動圖的方法:

2.1 使用 ImageIO.framework 來展示動圖

如上面所說, UIImage 不直接支援展示動圖, 直接用 [UIImage imageNamed:] 以及 [UIImage imageWithData:] 方法加載動圖檔案, 隻會得到一張靜态圖. 使用原生 API 展示 GIF 需要使用 ImageIO.framework 來從 data 中解析出每一幀, 同時通過 UIImageView 的 animationImages 屬性來達成動畫的支援. 示例代碼如下:

// 1. 産生 CGImageSourceRef
    CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);


    int count = CGImageSourceGetCount(source);
    NSMutableArray *images = [NSMutableArray array];
    for (int i = 0; i < count; i++)
    {
        // 2. 周遊得到每一幀
        CGImageRef image = CGImageSourceCreateImageAtIndex(source, i, NULL);
        [images addObject:[UIImage imageWithCGImage:image scale:[UIScreen mainScreen].scale orientation:UIImageOrientationUp]];
        // 3. 注意釋放, 可以在這兒加一個 autorelease
        CGImageRelease(image);
    }
    // CGImageSourceRef 也要釋放
    CFRelease(source);


    UIImageView *imageView = [[UIImageView alloc] init];
    // 4. 通過 animationImages 設定動畫
    imageView.animationImages = images;
    // 5. 擷取每一幀的處理略複雜, 先按照每一幀 100ms 吧
    imageView.animationDuration = 0.1 * count;
    // 6. 啟動動畫
    [imageView startAnimating];           

2.2 FLAnimatedImage

FLAnimatedImage 是 FlipBoard 早期開源的動圖加載庫, 實作思路是一個典型的消費者-生産者模型.

  • FLAnimatedImageView 是消費者, 通過 CADisplayerLink 定時展示目前幀;
  • FLAnimatedImage 是生産者, 在異步線程通過 CGImageSourceCreateImageAtIndex 拿到幀并解碼, 同時緩存幀資料;
  • 在 CADisplayerLink 觸發時展示對應的幀即可.
  • 但是這個庫畢竟太老了, 性能表現不及 YYAnimatedImageView, 先不做參考.

2.3 YYAnimatedImageView 的實作與不足

YYAnimatedImageView 的實作與 FLAnimatedImage 類似:

  • 使用 YYImageCoder 來做動圖幀的解析, 支援多種格式;
  • 在 YYAnimatedImageView 中使用信号量優化幀的讀取和異步解碼, 并使用 NSDictionary 做幀緩存,
  • 用CADisplayLink 來做動畫的展示, 同時添加幀解碼任務.

YYImageDecoder 的實作

YYImageDecoder 負責将圖檔或者動圖幀解析為 YYImageFrame 對象, 支援 APNG 以及 WebP, 解碼的簡略流程如下:

  1. YYImageDecoder 初始化時會判斷圖檔的類型, 并生成每一幀的資訊.
    1. 一般圖檔使用 [self _updateSourceImageIO] 解碼.
      1. 初始化 CGImageSourceRef, 讀出 frameCount,
      2. 周遊每一幀, 擷取幀寬高/時長/方向等資訊.
    2. WebP 使用 [self _updateSourceWebP]解碼, 依托于 WebP.framework 解析了相關資訊.
    3. APNG 使用 [self _updateSourceAPNG] 解碼.
      1. 使用 _updateSourceImageIO 構造第一幀,
      2. 又調用了 yy_png_info_create 方法從源檔案中解析了 APNG 相關的參數.
  2. 使用 frameAtIndex:decodeForDisplay: 擷取對應幀.
    1. 首先使用_newUnblendedImageAtIndex:extendToCanvas:decoded:擷取幀的 CGImageRef, 這一步驟還是使用 CGImageSourceCreateImageAtIndex 擷取對應幀,
    2. 然後使用 YYCGImageCreateDecodedCopy()解碼, 預設使用 CGContextDrawImage & CGBitmapContextCreateImage 這一組方法來解碼.

下面是 YYAnimatedImageView 解碼并展示圖檔的流程:

iOS 用戶端動圖優化實踐

YYAnimatedImageView 無法滿足業務需求的地方

YYAnimatedImageView 在同時展示大量動圖的場景無法滿足業務需求:

  1. YYAnimatedImageView 使用 NSDictionary 來管理幀緩存, iOS 系統在記憶體緊張時會對 NSDictionary 做壓縮, 進而産生額外的 CPU 消耗, 根據 WWDC iOS Memory Deep Dive[1]所述, 應盡量使用 NSCache 來做緩存;
  2. View 直接綁定幀緩存, 在快速滑動場景, View 不斷加載新的動圖, 會直接釋放已解碼的幀, 重新解碼新圖檔的每一幀, 導緻 CPU 負載過高, 在圖文流中快速滑動或者來回滑動很容易崩潰.

2.4 SDWebImage 各版本的使用簡介

上面說的兩個第三方庫都支援本地加載檔案, 不直接支援線上加載, 其中 YYAnimatedImageView 配合 YYWebImage 可以簡單實作線上加載, 但是使用體驗不及 SDWebImage.

先用一張流程圖簡單介紹下 SDWebImage3 的圖檔加載流程, 後續版本基本延續了這個思路:

iOS 用戶端動圖優化實踐
  • SDWebImage 早期也內建了 FLAnimatedImageView 用于線上加載 GIF, 後來通過[UIImage animatedImageWithImages: duration:] 直接解析 GIF 為 _UIAnimatedImage (UIImage 的私有子類)
  • SDWebImage4 計算了幀時長的最大公約數來批量增加同一幀, 以實作不同幀展示不同的時長, 解決動圖展示失真的問題.
  • SDWebImage5 引入SDAnimatedImageView, 一如 SDWebImage 簡潔的接口, 可以直接使用SDWebImageMatchAnimatedImageClass options 來加載動圖, 代碼如下:
SDAnimatedImageView *imageView = [SDAnimatedImageView new];
[imageView sd_setImageWithURL:[NSURL URLWithString:url]
             placeholderImage:nil
                      options:SDWebImageMatchAnimatedImageClass];           

但是要注意的是, 通過上述方法, 圖檔被加載到了記憶體緩存, 那麼圖檔的執行個體是一個SDAnimatedImage對象, 用其他 UIImageView 加載該 url 命中記憶體緩存, 展示在頁面上隻是一張靜态圖.

SDAnimatedImageView 通過 SDAnimatedImagePlayer 來實作動圖的展示.

  1. 調用setImage:時會初始化新的 player.
  2. 使用 SDDisplayLink (對CADisplayLink 的封裝, 同時支援 iOS/tvOS/macOS/watchOS) 來展示對應幀.
  3. 使用NSOperationQueue在背景線程進行解碼, 然後存儲在player的frameBuffer中作為緩存.
  4. 總結下來思路跟 YYAnimatedImageView 差不多.

3. Q音 iOS 端加載動圖的思路以及問題

我們項目中圖檔加載是由早期的 SDWebImage 衍變而來, 後來随着業務不斷發展, 加入了異步解碼/下載下傳統計/改用端内網絡元件等邏輯.

使用這套方案加載動圖有如下三個問題:

  1. 當且僅當所有幀圖檔都加載完畢時,才能夠顯示, 特别是在做異步解碼的時候, 會導緻動圖首幀加載時長較長.
  2. 不同幀的展示時長一樣,使得動圖失真. (最大公約數方案可解決)
  3. 在背景線程解析出所有幀, 此時如果對幀不做解碼會造成卡頓, 但是做異步解碼, 小記憶體的機型會直接記憶體暴漲導緻崩潰, 是以線上上隻能灰階開啟.

基于上述的問題, 應該将逐幀加載思路應用到端内, 在動圖加載到記憶體時, 隻從二進制資料中解碼第一幀; 然後在 CADisplayLink 觸發時解析目前需要展示的幀, 同時合理地使用幀緩存, 避免上述 YYAnimatedImageView 不斷加載動圖導緻的問題.

4. Q音 iOS 端動圖加載優化實踐

Q 音 iOS 端的圖檔異步加載流程與上述 SDWebImage 加載流程相似, 解碼流程會有一些不同, Q 音圖檔解碼流程圖如下:

iOS 用戶端動圖優化實踐

下面針對存在的問題逐一優化:

4.1 解碼每一幀導緻首幀加載太慢

怎麼基于異步加載架構實作動圖的逐幀加載呢?

4.1.1 動圖逐幀加載方案

目前的圖檔加載流程的主要痛點是, 動圖直接周遊并解碼了每一幀, 一瞬間占用大量 CPU 以及記憶體.

優化思路如下:

  1. 在解碼之前封裝動圖為一個 QMAnimatedWebImage(UIImage 子類)并隻解碼第一幀,
  2. 交給 QMAnimatedImageView(UIImageView 的子類)直接展示,
  3. 在 QMAnimatedImageView 中添加 CADisplayLink 定時展示對應幀,
  4. 啟動一個任務隊列, 異步解碼即将展示的幀, 放在 QMAnimatedImageView 的緩存區中.
  5. 這樣實作一個既支援異步加載又能逐幀解碼動圖元件, 下圖是動圖解碼優化的流程, 紅色字是逐幀加載的改造.
iOS 用戶端動圖優化實踐

4.1.2 首幀耗時

改造完之後, 需要驗證逐幀加載方案是否會在首幀加載上有所改善.

根據線上統計資料, 對于優化前是否解碼, 以及優化後的逐幀解碼三個方案, 首幀加載平均資料如下:

iOS 用戶端動圖優化實踐

相比于預先全部解碼, 逐幀解碼的首幀耗時降低了一半; 在灰階期間, 動圖首幀加載平均耗時都在 25ms 上下波動, 逐幀解碼對整體資料無明顯影響.

iOS 用戶端動圖優化實踐

4.2 動圖失真的問題

由于 QMAnimatedImageView 是通過 CADisplayLink 來驅動幀的展示, 在距離上一幀時間間隔超過幀時長時候才會展示下一幀, 自然解決了動圖失真的問題, 同時也能避免像 SDWebImage4 那樣去算每一幀的最大公約數.

4.3 解碼幀導緻記憶體暴漲的問題

提前異步解碼圖檔是常見的優化思路, 解碼後的 CGRasterData 被緩存在記憶體中, 等到主線程渲染圖檔時不再解碼, 以解決系統隐式解碼導緻的卡頓.

但是在動圖場景, 連續解碼動圖會快速消耗記憶體, 記憶體不足導緻動圖緩存命中率降低, 新的動圖觸發解碼又會進一步消耗 CPU, MemoryWarning 觸發之前就發生了崩潰; CPU 和記憶體互相擠兌, 難以優化.

iOS 用戶端動圖優化實踐

YYAnimatedImageView 隻解碼第一幀, 并保留動圖的 NSData, 在背景線程解碼幀. 但即使這樣, 不斷加載動圖時, 低端機上依舊有性能問題. 在使用者快速滑動或是資料重新整理的場景, YYAnimatedImageView 會丢棄前一張圖的所有幀資料, 下次展示這張圖又會從頭解碼, 造成額外的 CPU 消耗, 在此繼續做如下優化.

4.3.1 NSDictionary 幀緩存改為 NSCache

YYAnimatedImageView 采用 NSDictionary 來緩存解碼幀, 但是 iOS 系統在記憶體緊張時會對 NSDictionary 做壓縮, 進而産生額外的 CPU 消耗, 并且釋放幀緩存依賴于 MemoryWarning 通知. 而 NSCache 更适合用于緩存開銷較大的資料, 并且是線程安全的, 系統會自動根據記憶體使用情況以及cost 直接移除緩存, 在此次優化中, 解碼幀使用 NSCache 來緩存.

iOS 用戶端動圖優化實踐

4.3.2 解綁 View 與幀緩存, 優化快速滑動場景的 CPU 高負載問題

YYAnimatedImageView 中幀緩存是直接被 View 持有的, 導緻 View 切換圖檔時候, 之前的幀緩存都被釋放掉, 在 Cell 複用的場景下, YYAnimatedImageView 會不斷解析圖檔導緻 CPU 消耗過高.

iOS 用戶端動圖優化實踐

在此次優化中, QMAnimatedImageView 不直接持有幀緩存, 而是通過 QMAnimatedWebImage 存儲幀緩存, 如果動圖被 SDImageCache 從記憶體釋放掉, QMAnimatedWebImage 也會清掉幀緩存, 在 Cell 複用場景, 幀緩存隻要被解碼過就不會重複執行解碼, 動圖隻要不被從記憶體緩存釋放, 幀緩存就不會被清空.

iOS 用戶端動圖優化實踐

4.3.3 下采樣, 時間換空間

在實際開發中, 經常會有圖檔尺寸遠大于顯示區域的情況, 動圖中記憶體浪費非常恐怖. 這個情況可以使用下采樣(降采樣) Downsampling 技術來減少解碼後的記憶體.

像下面這個挂件, 哪怕按照 3X 螢幕也隻需要 175*105 的尺寸就夠了, 但是下發的圖檔卻是 500*300. 在通過線上監控排查這些 case 的同時, 開啟下采樣能即時解決記憶體消耗的問題.

iOS 用戶端動圖優化實踐

下采樣參照 WWDC Image and Graphics Best Practices[2] 提供的代碼實作即可:

func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
    let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
    
    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
    let downsampleOptions =
        [kCGImageSourceCreateThumbnailFromImageAlways: true,
         kCGImageSourceShouldCacheImmediately: true,
         kCGImageSourceCreateThumbnailWithTransform: true,
         kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as  CFDictionary
    let downsampledImage =
        CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
    return UIImage(cgImage: downsampledImage)
}           

QMAnimatedImageView 提供了下采樣接口, 開啟設定後, 如果能夠省一半以上的記憶體, 動圖幀就會被自動壓縮為适應螢幕的尺寸.

4.3.4 在解碼失敗的時候嘗試手動釋放記憶體

在 App 運作中, 部分 API 如果無法申請到記憶體會發生 NSMallocException 崩潰, 崩潰描述為”Failed to grow buffer”. 圖檔一般是記憶體消耗的大戶, 是以可以在圖檔解碼失敗時, 主動嘗試釋放圖檔記憶體緩存, 正在使用的圖檔不會被釋放, 未被使用的圖檔先釋放掉以騰出記憶體, 進而規避記憶體不足造成崩潰.

4.4 其他優化措施

4.4.1 滑動場景下不執行解碼任務, 降低 CPU 負載

在快速滑動的場景, CPU 一般都是比較繁忙的, 是以可以在滑動時不生成幀解碼任務進而降低 CPU 壓力, QMAnimatedImageView 也提供了接口屏蔽這一功能.

4.4.2 SDImageCache 設定

SDImageCache 提供了最大緩存的選項maxMemoryCost, 但是我們之前沒有自行設定, SDWebImage 就會盡可能的去占用記憶體, 在 MemoryWarning 時釋放記憶體緩存, 記憶體曲線會如同山峰一樣變化, 在危險邊緣不斷試探.

iOS 用戶端動圖優化實踐

而在此次優化中, 我将 maxMemoryCost 值設定成最大可用記憶體的 30%(線上 ABT 得出), 記憶體曲線就會很平緩, 能有效減少 OOM.

iOS 用戶端動圖優化實踐

最大可用記憶體的計算代碼如下:

// 擷取程序可用記憶體 機關 (Byte)
- (int64_t)memoryUsageLimitByByte
{
    int64_t memoryLimit = 0;
    // 擷取目前記憶體使用資料
    if (@available(iOS 13.0, *))
    {
        task_vm_info_data_t vmInfo;
        mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
        kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vmInfo, &count);
        if (kr == KERN_SUCCESS)
        {


            // 間接擷取一下目前程序可用的最大記憶體上限
            // iOS13+可以這樣計算:目前程序占用記憶體+還可以使用的記憶體=上限值
            int64_t memoryCanBeUse = (int64_t)(os_proc_available_memory());
            if (memoryCanBeUse > 0)
            {
                int64_t memoryUsed = (int64_t)(vmInfo.phys_footprint);
                memoryLimit = memoryUsed + memoryCanBeUse;
            }
        }
    }


    if (memoryLimit <= 0)
    {
        NSLog(@"擷取可用記憶體失敗, 使用實體記憶體作為程序可用記憶體");
        int64_t deviceMemory = [NSProcessInfo processInfo].physicalMemory;
        memoryLimit = deviceMemory * 0.55;
    }


    if (memoryLimit <= 0)
    {
        NSLog(@"擷取實體記憶體失敗, 使用可用記憶體作為程序可用記憶體");
        // 這個值一般要小很多, 上面都擷取不到才使用
        mach_port_t host_port = mach_host_self();
        mach_msg_type_number_t host_size = sizeof(vm_statistics_data_t) / sizeof(integer_t);
        vm_size_t page_size;
        vm_statistics_data_t vm_stat;
        kern_return_t kr;


        kr = host_page_size(host_port, &page_size);
        if (kr == KERN_SUCCESS)
        {
            kr = host_statistics(host_port, HOST_VM_INFO, (host_info_t)&vm_stat, &host_size);
            if (kr == KERN_SUCCESS)
            {
                memoryLimit = vm_stat.free_count * page_size;
            }
        }
    }
    return memoryLimit;
}           

手動設定 maxMemoryCost 後有降低圖檔記憶體緩存命中率的風險, 我也做了相關統計, 随着灰階比例的提升以及更多業務切換到 QMAnimatedImageView ,記憶體緩存命中率實際是逐漸上升的(同時 MemoryWarning 觸發次數也在下降).

iOS 用戶端動圖優化實踐

4.4.3 做成圖檔通用加載方案

考慮到很多場景是靜态圖和動圖混用的, 在下載下傳完成之前, 程式并不知道 url 是不是動圖, QMAnimatedImageView 做了下載下傳後檢查檔案類型和幀數的邏輯, 根據圖檔的實際類型來開啟逐幀加載, 同時支援 GIF/Animated WebP/APNG 三種動圖格式, 在可能加載動圖的場景均可直接使用.

5. 對比各種開源方案

改造完成後, 新的方案性能是不是要優于主流的方案呢?

我準備了一個較為極限的場景, 構造一個動圖流, 每一個 Cell 包含三至九張動圖, 同屏約有 20 個動圖在展示, 總共使用的動圖數量 200+, 測試動圖流從上劃到下後再上下來回滑動, 時長 2 分鐘, 每個場景重複 3 次平均值作為結果.

考慮到線上崩潰主要是 3x 分辨率的 3G 記憶體機型, 使用 iPhone 7 Plus 作為測試裝置.

資料采集使用 Instrument 工具檢視記憶體占用, 使用 PerfDog 測試幀數以及卡頓, 同時對比 UIImageView / SDAnimatedImageView / YYAnimatedImageView 以及 QMAnimatedImageView 的表現, 結果如下:

名額 UIImageView SDAnimatedImageView YYAnimatedImageView QMAnimatedImageView
記憶體峰值 1.9G 1.1G 1.3G 0.8G
觸發記憶體緊張次數/min 12 0.67
fps 平均值 52 36 57 58.3
卡頓次數/10min 38 107 23
嚴重卡頓次數/10min 25 9.8 15
卡頓時長占比 12% 1.9% 0.9% 0%
App CPU 平均負載 48% 43% 81% 27%
是否崩潰 測試 5~40 秒崩潰 測試 1~2 分鐘會崩潰

總結:

  • 直接用 UIImageView, 幾乎是不可用的, 雖然幀數還不錯, 但是非常卡, 具體幀數與卡頓的關系可以參考文章 APP&遊戲需要關注Jank卡頓及卡頓率嗎[3].
  • SDAnimatedImageView CPU 占用相對比較低, 但是幀數隻有 40 幀上下, 雖然嚴重卡頓次數較少, 但是體驗下來還是很卡.
  • YYAnimatedImageView 的記憶體以及 CPU 占用都是比較高的, 在使用一分鐘後容易觸發崩潰, 滑動過程中也有少量卡頓, 另外由于 YYImageCache 的排程非常保守, 導緻動圖加載速度明顯比 SDWebImage 慢.
  • 而 QMAnimatedImageView 全程無卡頓, CPU 占用一直維持在較低水準, 記憶體達到設定上限後便不再增長, 在資源排程上達到更好的平衡點.

6. 思路總結

主要優化手段以及目的:

  1. 使用動圖逐幀加載的方案, 避免在動圖展示之前就全部解碼消耗太多記憶體, 并提升首幀耗時.
  2. 使用 Image 綁定幀緩存, 避免 YYAnimatedImageView 方案在滑動場景中不斷加載新的動圖并清空緩存導緻一直在做解碼, 進而引起 CPU 負載過高.
  3. 設定 SDImageCache 的記憶體緩存門檻值, 避免 CPU 負載較高時 MemoryWarning 未及時觸發, 導緻 MallocException 崩潰.
  4. 使用 NSCache 代替 NSDictionary 做幀緩存, 避免系統壓縮記憶體時帶來額外 CPU 消耗, 并由系統自動釋放幀緩存.
  5. 在記憶體不足導緻解碼失敗時主動釋放 SDImageCache 的 memoryCache, 避免其他業務申請不到記憶體導緻崩潰.
  6. 設定開啟圖檔下采樣, 以合理使用記憶體.
  7. 在主線程滑動時, 暫停解碼新的幀, 避免快速滑動場景浪費 CPU 資源.
  8. 完成圖檔通用加載元件, 在動圖靜态圖複用的場景可以直接使用 QMAnimatedImageView, 元件不會造成額外性能消耗.

7. 參考資料

[1]. iOS Memory Deep Dive

https://developer.apple.com/videos/play/wwdc2018/416/

[2]. Image and Graphics Best Practices

https://developer.apple.com/videos/play/wwdc2018/219/

[3]. APP&遊戲需要關注Jank卡頓及卡頓率嗎

https://bbs.perfdog.qq.com/article-detail.html?id=6

作者:wyanwan

來源:微信公衆号:騰訊音樂技術團隊

出處:https://mp.weixin.qq.com/s/MW14R1JfXRmQvgN2NNi3iA

繼續閱讀