送禮物作為觀眾打賞支持主播的一種方式, 也是直播應用的一大收入來源, 每個直播平臺都包含送禮這一功能, 并且都把禮物動畫效果做的特別炫酷. 如此的動畫效果再搭配美女或帥哥主播的一句"謝謝某某某送的大飛機~", 是不是想想都有點小激動, 感覺瞬間成為了全場的焦點?
本文主要敘述的就是大禮物動效的實現(xiàn). 全文共3000字左右, 大概閱讀時間為5~12分鐘.
先放上按序列幀播放方案實現(xiàn)的動畫引擎FXAnimationEngine, Demo中實現(xiàn)了直播間禮物隊列蝌箍、禮物配置折联、禮物列表, 另外還分別用動畫引擎與原生Core Animation去播放序列幀動畫以做比較.
然后國際慣例, 上兩張圖
一裸卫、直播應用禮物動畫的常見方案
僅個人了解, 實現(xiàn)iOS側(cè)動畫配置化常見方案有如下幾種:
iOS方案 | 優(yōu)點 | 缺點 |
---|---|---|
Core Animation(此處不計CAKeyFrameAnimation) | 效果流暢逼真 | 安卓需重新實現(xiàn); 配置化成本高, 需自定義模型、協(xié)議前标、轉(zhuǎn)換方法等(iOS側(cè)已有現(xiàn)成工具, 某幾家直播公司想必也有自己的動畫配置化工具); 不解決動態(tài)配置問題, 則只能隨包更新. |
序列幀播放(CAKeyframeAnimation坠韩、CADisplaylink、ImageView等) | 設(shè)計哥工具可直接導出動畫序列幀圖片, 簡單易用; 多平臺兼容 | 效果略差; 圖片幀數(shù)多易導致資源大 |
Cocos2d-x | 效果好; 多平臺兼容 | 學習成本; 相應動畫制作工具; 必須引入Cocos2d庫; |
Lottie | 橫跨三端, iOS, Android, React Native. 設(shè)計師可以完全按照自己的想法設(shè)計. 無需考慮實現(xiàn)這一塊. | 內(nèi)存占用? 作者本人尚未使用過, 不敢妄自評論 |
可以看出, 序列幀播放方案是其中最簡單易行的一個. 在我看來, 花椒直播用的即是這套方案, 他們每一個動畫, 都會對應一個配置文件config.ini及對應該動畫的所有序列幀圖片.
感興趣的朋友可以移至最后一部分禮物資源的下載策略炼列、資源目錄結(jié)構(gòu)等
相關(guān)內(nèi)容, 更建議嘗試去探索一下花椒只搁、映客等主流直播應用的bundle目錄以及document中的資源.
二、序列幀播放方案實踐
2.1 實現(xiàn)方式
序列幀播放動畫一方案的具體實現(xiàn)必須能夠滿足以下需求:
- 圖片展示: CALayer俭尖、UIImageView
- 按時間間隔逐幀播放: CAKeyframeAnimation氢惋、UIImageView、定時器類(CADisplayLink稽犁、NSTimer焰望、dispatch_source_t)+切換關(guān)鍵幀邏輯
- 提供所有序列幀播放完的事件: CAAnimationDelegate、CATransaction CompletionBlock已亥、定時器類+回調(diào)觸發(fā)邏輯
組合方式很多, 比如: CALayer+CAKeyframeAnimation+delegate, UIImageView+定時器, CALayer+定時器類等等.
我們先選定這一套組合進行實踐: CALayer+CAKeyframeAnimation+delegate
// 偽代碼
- (void)startAnimation {
UIImage *frame = [UIImage imageWithContentsOfFile:...];
NSArray<UIImage *> *frames = @[(id)frame.CGImage, ...];
CAKeyframeAnimation *keyframeAnim = ...;
keyframeAnim.contents = frames;
...
keyframeAnim.delegate = self;
[xxx.layer addAnimation:keyframeAnim forKey:@"xxx"];
}
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag {
// 觸發(fā)動畫播放結(jié)束(全部播放完熊赖、中途結(jié)束)回調(diào)
...
}
如果此處你已經(jīng)下載了Demo, 可以打開Debug Navigator(cmd+6
)簡單查看內(nèi)存增長或者留意Xcode Instrument-Allocations中VM:ImageIO_PNG_Data
一項, 就會看到有內(nèi)存增長波峰. 而且序列幀圖片越多, 波峰越明顯.
那么其他方案是否出現(xiàn)了相同的問題呢? 是的, 其他方案一樣會如此, 換成UIImageView自帶的animationImages來做序列幀播放或是其他組合方式, 也出現(xiàn)內(nèi)存激增的情況.
2.2 了解圖片加載
在我們搞清楚是什么導致內(nèi)存激增前, 我們先了解一下圖片從磁盤加載, 到寫入內(nèi)存, 最后顯示到屏幕上分別都發(fā)生了什么. 大致分為如下步驟:
- 為磁盤中的圖片創(chuàng)建映射
- IO操作讀取圖片數(shù)據(jù)流
- 圖片解碼位圖拷貝, 寫入內(nèi)存
- 硬件繪制渲染到屏幕
2.2.1 映射文件
當我們通過[UIImage imageWithContentsOfFile:]
從磁盤加載圖片數(shù)據(jù)流, 實際上只是為此圖片創(chuàng)建了一個文件映射數(shù)據(jù), 圖片文件既沒有真正被加載到內(nèi)存, 更沒有被解碼成位圖的形式可供Core Animation傳遞給底層硬件進行渲染, 故此時內(nèi)存并不會明顯增加, 也不會出現(xiàn)因為解碼操作導致CPU使用增加的情形. 但從網(wǎng)絡下載圖片數(shù)據(jù)不包含在內(nèi).
簡單提及一下映射文件:
A mapped file uses virtual memory techniques to avoid copying
pages of the file into memory until they are actually needed.
直譯就是一個映射文件借助虛擬內(nèi)存技術(shù)來避免當他們還沒有真正使用到時就被拷貝到內(nèi)存中.
下面來一組對照驗證一下:
對照組一
- (void)test1 {
UIImage *frame = [UIImage imageWithContentsOfFile:filePath];
// 確保超出局部作用域后, 依舊保持對這個Image對象的強引用
self.frame = frame;
}
// 待上方函數(shù)執(zhí)行完后, 再查看內(nèi)存使用情況
對照組二
- (void)test2 {
UIImage *frame = [UIImage imageWithContentsOfFile:filePath];
self.imageview.image = frame;
}
我們可以發(fā)現(xiàn)對照組二的內(nèi)存占用明顯比對照組一要多. 即通過imageWithContentsOfFile:
創(chuàng)建的UIImage對象后, 內(nèi)存并沒有明顯增長, 等我們將該UIImage對象賦值給UIImageView的image屬性后的某個時刻, 內(nèi)存才出現(xiàn)明顯增長.
此處再留幾個問題:
- 我們都知道
imageWithName:
方法加載的圖片, 會被系統(tǒng)緩存, 那么第一次通過該方法進行如上兩個對照組的實驗, 結(jié)果如何呢? - 通過
imageWithName:
方法第2、3..n次加載同名圖片時, 加載的圖片數(shù)據(jù)流會不會再次被解碼? 期間CPU占用有沒有增加? - 嘗試把創(chuàng)建的UIImage對象橋接賦值給CALayer的contents屬性, 結(jié)果如何?
2.2.2 淺談CALayer的隱式動畫及事務
從上一節(jié)中, 我們發(fā)現(xiàn)當給UIImageView的image屬性或CALayer的contents屬性賦值Image對象后的某一刻, 內(nèi)存和CPU占用才會出現(xiàn)明顯變化. 那是因為每一次Runloop循環(huán), Core Animation都會在其開始創(chuàng)建一個動畫事務, 在本次Runloop結(jié)束時才去執(zhí)行所有添加到該事務里的所有動畫操作. 此刻圖片才被解碼加載入內(nèi)存, 圖片數(shù)據(jù)會被解碼為渲染可用的bitmap數(shù)據(jù). 一些相關(guān)細節(jié)可看我另一篇分享.
2.3 解決內(nèi)存激增問題
當前我們面臨的問題是無論采用何種實現(xiàn)方案, 在執(zhí)行序列幀動畫時, 所有圖片都會被解碼成為位圖并載入內(nèi)存中.
2.3.1 解壓后的圖片所占內(nèi)存大小
圖片解碼后的格式為位圖形式. 位圖是由一組像素(pixel)組成的, 每一個像素就代表圖片中的一個點. 比如常見的JPEG, 以及PNG格式的圖片文件都是位圖圖片.
我們還需要知道, JPEG和PNG圖片實際上都是一種編碼/壓縮后的位圖格式, 它們是不能直接用來圖片渲染的, 所以得先對其壓縮的數(shù)據(jù)進行解碼/解壓縮操作.
那么一張解壓后的位圖其所占內(nèi)存大小怎么計算呢?
此處假設(shè)我們有一張32位的PNG格式圖片, 其像素格式為RGBA四部分組成, 每部分占8位, 該圖片尺寸為160px * 320px.
32位的圖片意味著其每個像素占32位, 即4個字節(jié).
又根據(jù)圖片尺寸計算出總像素數(shù)量為 160*320 個像素.
所以該圖片解碼后所占內(nèi)存大小就為 像素總數(shù) * 單位像素的字節(jié)數(shù)
即 (160*320) * 4 / 1024 = 200 KB.
所以可想而知, 假設(shè)一個序列幀動畫有80張圖片, 200 * 80 / 1024 = 15.625 mb, 就會占用15mb的內(nèi)存. 序列幀圖片越多, 占用內(nèi)存越大!
2.3.2 解決方案
那么有什么方法可以避免呢? 可否每次播放到哪一幀時就去加載那一幀的圖片, 即每次僅加載一張圖片到內(nèi)存中. 這樣當播放到下一張圖片時, 上一張圖片已無任何引用, 系統(tǒng)自然會對其進行釋放.
這就是最簡單可行的一套方案. 但是我們無法靠CAAnimation及其派生類CAKeyframeAnimation來實現(xiàn)這一方案, 因為所有的圖片都會解碼導致占用大量的內(nèi)存.
但我們可以通過CADisplayLink來實現(xiàn)該方案, 選CADisplayLink的原因是它比NSTimer精度要高很多, 正常情況下CADisplayLink的回調(diào)會在屏幕每次刷新時觸發(fā), 即一般1/60秒觸發(fā)一次, 適合用于做UI的重繪, 因此可以通過它來周期性的替換關(guān)鍵幀圖片, 從而達到播放動畫的效果. 那么具體怎么做呢?
在CADisplayLink的回調(diào)中獲取兩次屏幕刷新的間隔時間, 通過不斷的累加間隔時間來判斷總的時間是否已經(jīng)滿足下一幀的播放時刻, 如果大于下一幀的播放時刻就可以替換為下一幀圖片了, 直至最后一張關(guān)鍵幀也播放完成.
舉個例子, 我們要在1秒內(nèi)播放完一個含有5張關(guān)鍵幀圖片的動畫, 每張圖片的停留時間虑椎、切換時間如下圖2.3.2.a所示. 所以第0秒的時候就開始展示第一張關(guān)鍵幀, 直到1.0秒這一刻時, 動畫播放結(jié)束.
此外, 如果還需要進一步優(yōu)化, 我們可以加入圖片異步解碼震鹉、圖片預加載邏輯等方案.
異步圖片解碼, 圖片解碼是一項比較耗時、比較占CPU的操作, 對于未解碼的圖片, 系統(tǒng)一般會在主線程對其進行解碼, 所以可以通過在異步線程進行圖片強制解壓縮, 從而不占用UI線程. 關(guān)于圖片解碼的詳情, 強烈推薦談談 iOS 中圖片的解壓縮.
圖片預加載, 這個就是為了進一步節(jié)省上下文切換時間, 即前后兩張圖片切換的時間. 就是要做到當上一幀圖片播放完時, 我們不用等下一張圖片解碼完成后再進行圖片的切換, 而是可以直接從已解碼圖片的緩存隊列中取出直接進行切換. 預加載我個人覺得其實主要就是閾值的最優(yōu)選擇, 可參考預加載與智能預加載一文.
三捆姜、序列幀動畫引擎源代碼及Demo
FXAnimationEngine - Github跳轉(zhuǎn)
針對該Demo近期會另起一文特別介紹, 此處占坑, 等待跳轉(zhuǎn)鏈接
四足陨、禮物資源下載策略及資源目錄結(jié)構(gòu)
4.1 禮物資源下載策略
4.1.1 兩種方式比較
方式 | 基本思路 | 優(yōu)點 | 缺點 |
---|---|---|---|
整包更新 | 所有的動畫資源按目錄結(jié)構(gòu)進行壓縮, 客戶端通過比較資源包版本號發(fā)現(xiàn)有更新后, 僅需下載一個資源包壓縮文件, 并進行解壓替換即可 | 簡單易實現(xiàn), 客戶端每次僅需下載一個資源包 | 隨著資源包逐漸增大, 下載及解壓時間也會延長, 從而直接影響用戶體驗; 即使是僅是資源中的某個圖片發(fā)生改變, 客戶端都要重新下載整個資源包, 容錯率低且浪費流量 |
增量更新 | 每個動畫資源單獨壓縮并上傳CDN, 若客戶端發(fā)現(xiàn)資源版本號有變化, 再對服務器下發(fā)的資源列表跟本地資源列表求差集運算從而得出增量, 單個動畫資源的下載地址或者md5可作為唯一標識進行比較. 得出增量后, 客戶端再對每個增量資源包進行下載, 每下載完一個即可"投入使用" | 不怕資源變更頻繁; 僅需下載有新增或有變更的資源包, 更節(jié)省時間以及流量; | 邏輯略復雜于整包更新, 比如下載中途用戶把應殺掉, 下次需要找出未更新完的增量資源并繼續(xù)下載 |
4.1.2 資源更新流程
因?qū)ι霞夜镜拇a保密, 此處不上具體代碼
我們在上一小節(jié)中提及的兩種更新方式, 它們主要的不同的就在于"資源更新"這一步驟
圖 4.1.2.a 整包更新的流程圖
圖 4.1.2.b 增量更新的流程圖
不知道各位發(fā)現(xiàn)兩個流程共同之處沒? 它們都需要檢測資源版本號大小, 包括游戲補丁、熱更補丁這一步驟都必不可少. 相比于補丁類的, 資源更新不用太考慮灰度發(fā)布娇未、回滾機制等問題, 但還是依舊需要注意資源核對, 內(nèi)部測試, 以及日志監(jiān)控等保障, 我記得在前任公司就遇到了有的地區(qū)下載下來的資源包有問題, 所以不管是CDN的問題或資源本身有問題, 前端都需要為最壞的情況做好打算, 這才是萬全之策.
引用我上家公司, 我老大兼mentor, 達文哥, 告誡的一句箴言
不要相信后臺下發(fā)的數(shù)據(jù)都是正確的
大概意思如此, 原句沒背下來??, 這句話絕非不是指后臺同學不行, 或者甩鍋給后臺, 而是要prepare for the worst
.
前后端測試都是一家人, 遇到問題我們先看看是不是自己問題, 不要相互甩鍋..本是同根生相煎何太急, 如果有問題就一塊搓一頓, 一頓不行就再來一頓
4.2 資源目錄結(jié)構(gòu)設(shè)計
不管哪個直播平臺, 每個禮物都會對應一個邏輯id, 我們可以通過禮物的id作為該禮物的資源目錄名, 然后在該目錄內(nèi)在去劃分不同類型的圖片子目錄, 如下所示
- 10000 // 一級目錄, 禮物id
- - gift // 二級目錄, 小禮物序列幀圖片
- - giftlist // 二級目錄, 禮物列表序列幀圖片
- - giftanim // 二級目錄, 大動畫序列幀圖片
這只是其中的一種設(shè)計, 也有的平臺會采用如下形式, 所以主要還是看需求而定
- gift
- - 10000
- giftlist
- - 10000
- giftanim
- - 10000
此外, 有的平臺還會采用id_version, 即禮物id+禮物版本的形式來命名, 這樣可以方便配置使后臺可以靈活下發(fā)給前端具體要去播放哪個動畫的某個版本了
- 10000_11 // id為10000, 版本為11的禮物資源目錄
- 10000_12