记录一次礼物动效的设计与实现过程

实现礼物动效可以使用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天时间来解决这种问题。

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