記錄一次禮物動效的設計與實現過程

實現禮物動效可以使用ViewGroup的方式也可以使用自定義View的方式。本文使用的是自定義View方式,不會討論關於ViewGroup的實現方式。

 

數據模型

數據源列表使用mList

  1. 數據源列表使用mList來表示, 代表接口返回的數據列表
  2. mList只有遍歷操作,選擇ArrayList實現

繪製數據源列表使用mPendingDanMuList

  1. 與數據源列表不同,繪製數據源列表存放的是用於繪製的數據,比如座標信息,調試信息等,當然它也包含來自數據列表的信息。其實繪製數據源列表就是根據mList生成的。
  2. 繪製數據源列表使用mPendingDanMuList來表示,我覺着mPendingDanMu這個詞語比較準確的說到了這是一個等待繪製的列表
  3. 用戶自己送的禮物要儘快顯示出來,哪怕輪播的隊列很長也要插隊儘快顯示出來。所以這種類型的彈幕會插入到mPendingDanMuList中。
  4. 所以mPendingDanMuList有偶爾的插入操作,主要是遍歷操作。所以這裏選擇的是ArrayList,而LinkedList是不合適的。

繪製列表有兩種設計方案,選擇方案二

方案一、使用繪製列表來管理彈幕的繪製

由於繪製列表只負責繪製屏幕中的彈幕,所以需要頻繁的add、remove,這將導致彈幕的卡頓。

方案二、使用head和tail下標來管理彈幕的繪製

使用int類型的head、tail就不存在頻繁add、remove的問題,就可以避免方案一的卡頓問題。head、tail其實就是指針,用來標誌處源數據列表中繪製的起、止位置,head和tail的差值即爲屏幕中的彈幕數size。而這個size就是onDraw時需要循環遍歷的次數。

繪製實體

就和bead對象一樣,裏面存放onDraw所需的參數。

重要的參數有:

  1. 繪製的文本、
  2. 彈幕的top、left值
  3. 以及彈幕的translateX、translateY值,
  4. 另外還有alpha值
  5. 軌道號

從設計角度,參考了源碼思想,彈幕的位置計算公式是:

X = left + translateX

Y = top + translateY

在初始化mPendingDanMuList時,只需要給top、left設置一個初始值,後續便不會改變它,只需要改變translateX、translateY即可。因爲繪製時用的是X、Y。

彈幕輪播時,只需要修改mPendingDanMuList中繪製實體的translateX、translateY即可,不需要修改top、left。

 

功能設計

一些基本的問題

什麼時候往繪製隊列裏面添加數據?

  1. 執行到0.0s時添加
  2. 執行到1.5s時添加
  3. 執行到3.0s時添加
  4. 執行到4.5s時添加
  5. 執行到4.8s時添加


什麼時候從繪製隊列裏面刪除數據

  1. 任何一條,執行到6.0s時刪除即可
  2. 但是這種描述只是產品層面的,而不是技術層面的
  3. 技術層面的刪除時機,下文分析


如何定義執行到某個特定的時間點呢?

  1. 根據動畫時間因子來決定。乾脆把動畫的初始值和終止值設置爲時間,ValueAnimator.ofFloat(0f, 6.0f);
  2. 後來感覺改成以秒爲單位,還不知乾脆改成以毫秒爲單位,ValueAnimator.ofFloat(0f, 6000f);

 

給彈幕定義一個完整的動畫

想彈幕這種複雜的動效,要學會化繁爲簡,找到規律。

  1. 化繁爲簡就是找出來有哪些種類的動畫,安卓無外乎那四種動畫。這裏只涉及位移動畫和alpha動畫。
  2. 找到規律就是要找出重複單元,把這個單元提取出來,剩下的無非是重複而已
  3. 從動效參數可以看出來,這個重複單元就是一個6.0秒的複合動畫
  4. 其他動畫無非是延遲1.5秒 + 重複上一個動畫而已
  5. 找到重複單元之後,就可以定義一個完整的動畫。根據時間因子來決定位移、alpha值的變化。這個定義是在AnimatorUpdateListener的回調onAnimationUpdate中,可以封裝成一個獨立的方法。

定義彈幕輪播

一個完整的動畫要6秒鐘,分成了如下節點,0秒(6.0秒)、1.5秒、3.0秒、4.5秒。

每個節點都要新出一個彈幕(如果有的話)

輪播時從第一個6.0秒結束,會有第一條彈幕完全飄出屏幕外,於此同時,也就是下個循環的0秒,又有一個新的彈幕出來。

 

定義用戶自己送的彈幕

用戶自己送的彈幕要儘快展示出來。

要展示出來就需要放入mPendingDanMuList中,並且計算好插入的下標才能正確的插入。

插入的下標計算規則:

  1. 如果繪製列表size小於4(mPendingDanMuList.size < 4),則插入隊尾
  2. 如果繪製列表size等於4,則 int insertIndex = (mHead + 4) % mPendingDanMuList.size();
  3. 爲什麼2的情況下要mHead + 4呢?這是爲了防止對屏幕中的彈幕造成干擾,所以插入的位置是在即將顯示的那個彈幕的前面。其實這裏改成int insertIndex = mTail % mPendingDanMuList.size();更合適。

用戶送的彈幕只需要插入到mPendingDanMuList,而不需要插入的mList中,因爲只是爲了展示。插入的mList中沒有意義,反而影響性能。

 

用戶自己送的彈幕要高亮顯示。高亮的效果是在彈幕上掃光。也就是在彈幕的bounds中加一個光線的位移動畫。這個動畫是無限輪播的,掃一次0.4秒。從左到右,依次重複。直到該彈幕從移動到屏幕之外爲止。

因爲是無限重複,那能滿足這個條件的觸發因子只有屬性動畫的時間因子了,因爲只有時間因子是一直在變化,且無限變化的。所以就用時間因子來計算。掃一次0.4秒,而時間因子t是 0秒 - 6.0秒的變化區間。怎麼計算某個時刻,光線的位移呢?

很簡單,t模上0.4秒就能把比例關係縮小到0秒 - 0.4秒的區間了。有了產生了光線的位移,掃光效果就容易了。不再贅述。

讓彈幕動起來

動畫的本質就是根據時間因子來控制位移、alpha等參數。下面分析如何實現這個動效。

從動效的效果來看,這是一個複合動畫。複合動畫的種類並不多有x、y軸的位移、alpha漸變。但是這卻是一個既可以上下位移又可以左右位移的隊列。對於這種隊列的動畫實現有兩種方案。

方案一、有多少條彈幕就做多少個屬性動畫

  1. 定義一個完整的動畫,根據時間因子來決定位移、alpha值的變化。這個定義是在AnimatorUpdateListener的回調onAnimationUpdate中,可以封裝成一個獨立的方法。
  2. 單獨爲每一個彈幕設置一個動畫,其他的彈幕無非就是一遍遍重複這個動畫而已
  3. 那假如一共有100條彈幕,那到底是設置100個屬性動畫呢,還是隻做4個(屏幕可見最大彈幕數)呢?
  4. 如果想把這個動畫做的簡單些,那就是做100個屬性動畫,可以用懶加載的方式,每次用到的時候纔會new一個。這就是方案一所說的內容。
  5. 但是從性能上考慮肯定是做4個屬性動畫更好,但是4個屬性動畫的方案較爲複雜,方案二中詳細說。
  6. 至於如何定義一個完整的動畫,下文詳細說
  7. 從動效參數可以看出,每個彈幕的動畫間隔是1.5秒
  8. 處理間隔問題,handler的postDelay是最直接的方案,每隔1.5秒就啓動一個動畫就可以讓隊列動起來了

方案二、四個彈幕四個屬性動畫

  1. 定義一個完整的動畫,根據時間因子來決定位移、alpha值的變化。這個定義是在AnimatorUpdateListener的回調onAnimationUpdate中,可以封裝成一個獨立的方法。
  2. 啓動四個動畫,可以使用AnimateSet來管理
  3. 四個動畫就有四個onAnimationUpdate回調,就有四份代碼,所以肯定要合併成一份。合併之後使用軌道號來做區分即可。
  4. 這個方案的難點在於隊列管理上,要處理好動畫的初始狀態、滾動、輪播。1.5秒的間隔加上輪播讓這種方案變的複雜。
  5. 初始狀態:使用延遲啓動的方法setStartDelay
  6. 滾動: 用輪播隊列來管理,只需要需改隊列中彈幕的translateX、translateY、alpha即可
  7. 輪播: 用輪播隊列來管理,這個隊列最大隻能放4個彈幕(屏幕可見的最大數),輪播隊列只是個概念,不一定要用list來實現,用兩個下標也可以。
  8. translateX、translateY、alpha的值是基於時間因子計算出來的,四個動畫能產生四個時間因子,因此要區分開誰是誰,這就要做好映射關係
  9. 映射關係可以用軌道號和view的成員變量x、y,alpha來描述
    • 定義四個軌道1、2、3、4
    • 軌道的主要作用就是保證動畫的間隔,因爲動畫之間相差1.5秒,這種方案下,在初始化階段就會給每個繪製實體設置一個軌道號。
    • 軌道號的計算方法很簡單:index % 4 + 1
    • 定義四個位移x、y,分別命名爲x1, x2, x3, x4; alpha與此類似
    • 四個動畫會驅動x、y的變化
    • 在每一幀刷新的時候,按照軌道找到對應的x、y、alpha值,進而繪製出動畫效果

方案三、四個彈幕共用一個屬性動畫

共用一個屬性動畫有明顯的好處,也有明顯的壞處。

好處就是一個動畫性能開銷小,設計上更加緊密。壞處就是更復雜。

我做這個需求嘗試了方案二和方案三。最終選擇了方案三。所以我這裏可以記錄更多關於這兩個方案的實現細節。

  1. 定義一個完整的動畫,根據時間因子來決定位移、alpha值的變化。這個定義是在AnimatorUpdateListener的回調onAnimationUpdate中,可以封裝成一個獨立的方法
  2. 引入軌道號的概念,用來區分四個彈幕,用來實現彈幕時間間隔的效果。
  3. 與方案二不同,軌道號不是在初始化繪製列表時確定的,而是顯示新彈幕前根據時間因子動態計算的。這樣做是爲了包含讓彈幕出來時有一個左右位移的動效。(每個彈幕一出來時要有個左右位移的動效)
  4. onAnimationUpdate中處理四個彈幕的位移、alpha值。
    1. 位移x要分軌道號單獨處理,四個成員變量
    2. 但是位移y就不需要了,因爲四個彈幕的位移y的偏移量一模一樣,所以簡化成了公用一個成員變量
  5. 初始狀態:使用延遲啓動的方法setStartDelay
  6. 滾動: 用輪播隊列來管理,只需要需改隊列中彈幕的translateX、translateY、alpha即可
  7. 輪播: 通過下標方式來管理繪製隊列。下標有兩個,一個表示頭Head,一個表示尾Tail
  8. 通過下標來實現輪播的具體步驟如下:
    1. Head、Tail的初始值都設置爲0
    2. 一個完整的動畫要6秒鐘,分成了如下節點,0秒(6.0秒)、1.5秒、3.0秒、4.5秒
    3. 每個節點都要新出一個彈幕(如果有的話),新出一個彈幕是通過Tail下標從mPendingDanMuList中取出的(當然要用模運算去取,而不是直接取),因此mTail要自增。
    4. 那輪播時從第一個6.0秒結束,會有第一條彈幕完全飄出屏幕外,這時要把它從繪製列表中移除。也就是mHead自增。6.0秒結束後,會有彈幕不斷地從列表中移除,對應着mHead自增。
    5. 判斷彈幕從列表中移除的時機要分情況。如果是第一個6.0秒,因爲此時還沒有彈幕完全飄出屏幕,則不需要從列表中移除。從第一個6.0秒之後纔會有彈幕飄出屏幕,也就是說移除發生在輪播時。但是這種情況,伴隨飄出屏幕的會有新的彈幕加入。也就是mHead自增和mTail自增同時發生。這一點很好理解,因爲只有這樣才能使得int size = mTail - mHead的值不變。除非是用戶發送了一個彈幕,需要插入纔有可能改變這個size(如果size已經是4,則無法改變)。
    6. 除了mHead自增和mTail自增同時發生的情況,在第一個6.0秒,則是隻有mTail自增。因爲0秒、1.5秒、3.0秒、4.5秒都有新加入的彈幕,所以mTail自增。但是沒有出屏幕的彈幕,所以mHead不自增。這樣在第一輪滾動結束,就可以根據mTail和mHead的差值來計算出屏幕中實際的彈幕數了。

 

再處理一下特殊時機

動畫一開始需要先顯示一個提示性的彈幕,要靜止3秒,且屏幕內就這一個。

3秒過後,該提示性的彈幕要向上位移直到移除屏幕爲止。

但是與此同時跟進它下面的彈幕要依次顯示出來,並且出來的時候是個疊加動畫:左右位移+上下位移+alpha。

提示性的彈幕的特殊之處就是它開始移動時,只有上下位移,沒有左右位移和alpha變化。

但是當它輪播時就和普通彈幕一樣了:左右位移+上下位移+alpha。

處理這種特殊時機,需要加一些標記爲,來區分是否是第一次播放。

 

經驗總結

開發中遇到一些比較費心思的問題,好在是投入了較多的耐心和時間,最後守得雲開見月明。其中一類問題是因爲對知識缺乏瞭解走的彎路,這類情況自己以後難免也會碰到,所以總結一下經驗。遇到了另一類問題是程序設計的問題。一開始悶着頭就開始寫代碼了,沒想清楚怎麼設計,要處理哪些情況,所以因爲考慮不周全,設計部完善而額外花了很多時間。所以這也是寫這篇文章的原因。其實面對複雜的功能,事先花時間把問題定義清楚確實可以提升效率。

設置Interpolator爲LinearInterpolator

一開始我的動畫沒有設置Interpolator,因爲我覺着默認的Interpolator就是LinearInterpolator,那還何必多此一舉呢。所以我就按照LinearInterpolator來思考問題的。結果出現了個奇葩問題(AccelerateDecelerateInterpolator效果的問題),我依然固執己見,根本就沒有懷疑到Interpolator頭上,把排查問題關注點都放到了代碼實現上。結果帶着找毛病的眼光review自己的代碼,一遍遍下來,簡直懷疑人生了,到底哪裏錯了呢,摸不着頭腦啊。這一晃一星期就過去了,整的我的心情也不咋滴啊。

後來,我還是發現瞭如下的事實:

安卓動畫默認的Interpolator是 AccelerateDecelerateInterpolator。

以下代碼來自Android API 26 (8.0)

    // The time interpolator to be used if none is set on the animation
    private static final TimeInterpolator sDefaultInterpolator =
            new AccelerateDecelerateInterpolator();

所以爲了實現自定義動畫效果,需要設置Interpolator爲LinearInterpolator,因爲只有這樣才能基於線性增長的時間因子來組織自己的動畫,如果使用AccelerateDecelerateInterpolator的話,產生的時間因子會是先增後減的,這樣會導致基於時間因子組織起來的動畫各個動作變形,效果上無法達到預期。



2020.06.26,端午節花了2天時間來解決這種問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章