实现礼物动效可以使用ViewGroup的方式也可以使用自定义View的方式。本文使用的是自定义View方式,不会讨论关于ViewGroup的实现方式。
数据模型
数据源列表使用mList
- 数据源列表使用mList来表示, 代表接口返回的数据列表
- mList只有遍历操作,选择ArrayList实现
绘制数据源列表使用mPendingDanMuList
- 与数据源列表不同,绘制数据源列表存放的是用于绘制的数据,比如坐标信息,调试信息等,当然它也包含来自数据列表的信息。其实绘制数据源列表就是根据mList生成的。
- 绘制数据源列表使用mPendingDanMuList来表示,我觉着mPendingDanMu这个词语比较准确的说到了这是一个等待绘制的列表
- 用户自己送的礼物要尽快显示出来,哪怕轮播的队列很长也要插队尽快显示出来。所以这种类型的弹幕会插入到mPendingDanMuList中。
- 所以mPendingDanMuList有偶尔的插入操作,主要是遍历操作。所以这里选择的是ArrayList,而LinkedList是不合适的。
绘制列表有两种设计方案,选择方案二
方案一、使用绘制列表来管理弹幕的绘制
由于绘制列表只负责绘制屏幕中的弹幕,所以需要频繁的add、remove,这将导致弹幕的卡顿。
方案二、使用head和tail下标来管理弹幕的绘制
使用int类型的head、tail就不存在频繁add、remove的问题,就可以避免方案一的卡顿问题。head、tail其实就是指针,用来标志处源数据列表中绘制的起、止位置,head和tail的差值即为屏幕中的弹幕数size。而这个size就是onDraw时需要循环遍历的次数。
绘制实体
就和bead对象一样,里面存放onDraw所需的参数。
重要的参数有:
- 绘制的文本、
- 弹幕的top、left值
- 以及弹幕的translateX、translateY值,
- 另外还有alpha值
- 轨道号
从设计角度,参考了源码思想,弹幕的位置计算公式是:
X = left + translateX
Y = top + translateY
在初始化mPendingDanMuList时,只需要给top、left设置一个初始值,后续便不会改变它,只需要改变translateX、translateY即可。因为绘制时用的是X、Y。
弹幕轮播时,只需要修改mPendingDanMuList中绘制实体的translateX、translateY即可,不需要修改top、left。
功能设计
一些基本的问题
什么时候往绘制队列里面添加数据?
- 执行到0.0s时添加
- 执行到1.5s时添加
- 执行到3.0s时添加
- 执行到4.5s时添加
- 执行到4.8s时添加
什么时候从绘制队列里面删除数据
- 任何一条,执行到6.0s时删除即可
- 但是这种描述只是产品层面的,而不是技术层面的
- 技术层面的删除时机,下文分析
如何定义执行到某个特定的时间点呢?
- 根据动画时间因子来决定。干脆把动画的初始值和终止值设置为时间,ValueAnimator.ofFloat(0f, 6.0f);
- 后来感觉改成以秒为单位,还不知干脆改成以毫秒为单位,ValueAnimator.ofFloat(0f, 6000f);
给弹幕定义一个完整的动画
想弹幕这种复杂的动效,要学会化繁为简,找到规律。
- 化繁为简就是找出来有哪些种类的动画,安卓无外乎那四种动画。这里只涉及位移动画和alpha动画。
- 找到规律就是要找出重复单元,把这个单元提取出来,剩下的无非是重复而已
- 从动效参数可以看出来,这个重复单元就是一个6.0秒的复合动画
- 其他动画无非是延迟1.5秒 + 重复上一个动画而已
- 找到重复单元之后,就可以定义一个完整的动画。根据时间因子来决定位移、alpha值的变化。这个定义是在AnimatorUpdateListener的回调onAnimationUpdate中,可以封装成一个独立的方法。
定义弹幕轮播
一个完整的动画要6秒钟,分成了如下节点,0秒(6.0秒)、1.5秒、3.0秒、4.5秒。
每个节点都要新出一个弹幕(如果有的话)
轮播时从第一个6.0秒结束,会有第一条弹幕完全飘出屏幕外,于此同时,也就是下个循环的0秒,又有一个新的弹幕出来。
定义用户自己送的弹幕
用户自己送的弹幕要尽快展示出来。
要展示出来就需要放入mPendingDanMuList中,并且计算好插入的下标才能正确的插入。
插入的下标计算规则:
- 如果绘制列表size小于4(mPendingDanMuList.size < 4),则插入队尾
- 如果绘制列表size等于4,则 int insertIndex = (mHead + 4) % mPendingDanMuList.size();
- 为什么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渐变。但是这却是一个既可以上下位移又可以左右位移的队列。对于这种队列的动画实现有两种方案。
方案一、有多少条弹幕就做多少个属性动画
- 定义一个完整的动画,根据时间因子来决定位移、alpha值的变化。这个定义是在AnimatorUpdateListener的回调onAnimationUpdate中,可以封装成一个独立的方法。
- 单独为每一个弹幕设置一个动画,其他的弹幕无非就是一遍遍重复这个动画而已
- 那假如一共有100条弹幕,那到底是设置100个属性动画呢,还是只做4个(屏幕可见最大弹幕数)呢?
- 如果想把这个动画做的简单些,那就是做100个属性动画,可以用懒加载的方式,每次用到的时候才会new一个。这就是方案一所说的内容。
- 但是从性能上考虑肯定是做4个属性动画更好,但是4个属性动画的方案较为复杂,方案二中详细说。
- 至于如何定义一个完整的动画,下文详细说
- 从动效参数可以看出,每个弹幕的动画间隔是1.5秒
- 处理间隔问题,handler的postDelay是最直接的方案,每隔1.5秒就启动一个动画就可以让队列动起来了
方案二、四个弹幕四个属性动画
- 定义一个完整的动画,根据时间因子来决定位移、alpha值的变化。这个定义是在AnimatorUpdateListener的回调onAnimationUpdate中,可以封装成一个独立的方法。
- 启动四个动画,可以使用AnimateSet来管理
- 四个动画就有四个onAnimationUpdate回调,就有四份代码,所以肯定要合并成一份。合并之后使用轨道号来做区分即可。
- 这个方案的难点在于队列管理上,要处理好动画的初始状态、滚动、轮播。1.5秒的间隔加上轮播让这种方案变的复杂。
- 初始状态:使用延迟启动的方法setStartDelay
- 滚动: 用轮播队列来管理,只需要需改队列中弹幕的translateX、translateY、alpha即可
- 轮播: 用轮播队列来管理,这个队列最大只能放4个弹幕(屏幕可见的最大数),轮播队列只是个概念,不一定要用list来实现,用两个下标也可以。
- translateX、translateY、alpha的值是基于时间因子计算出来的,四个动画能产生四个时间因子,因此要区分开谁是谁,这就要做好映射关系
- 映射关系可以用轨道号和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值,进而绘制出动画效果
方案三、四个弹幕共用一个属性动画
共用一个属性动画有明显的好处,也有明显的坏处。
好处就是一个动画性能开销小,设计上更加紧密。坏处就是更复杂。
我做这个需求尝试了方案二和方案三。最终选择了方案三。所以我这里可以记录更多关于这两个方案的实现细节。
- 定义一个完整的动画,根据时间因子来决定位移、alpha值的变化。这个定义是在AnimatorUpdateListener的回调onAnimationUpdate中,可以封装成一个独立的方法
- 引入轨道号的概念,用来区分四个弹幕,用来实现弹幕时间间隔的效果。
- 与方案二不同,轨道号不是在初始化绘制列表时确定的,而是显示新弹幕前根据时间因子动态计算的。这样做是为了包含让弹幕出来时有一个左右位移的动效。(每个弹幕一出来时要有个左右位移的动效)
- onAnimationUpdate中处理四个弹幕的位移、alpha值。
- 位移x要分轨道号单独处理,四个成员变量
- 但是位移y就不需要了,因为四个弹幕的位移y的偏移量一模一样,所以简化成了公用一个成员变量
- 初始状态:使用延迟启动的方法setStartDelay
- 滚动: 用轮播队列来管理,只需要需改队列中弹幕的translateX、translateY、alpha即可
- 轮播: 通过下标方式来管理绘制队列。下标有两个,一个表示头Head,一个表示尾Tail
- 通过下标来实现轮播的具体步骤如下:
- Head、Tail的初始值都设置为0
- 一个完整的动画要6秒钟,分成了如下节点,0秒(6.0秒)、1.5秒、3.0秒、4.5秒
- 每个节点都要新出一个弹幕(如果有的话),新出一个弹幕是通过Tail下标从mPendingDanMuList中取出的(当然要用模运算去取,而不是直接取),因此mTail要自增。
- 那轮播时从第一个6.0秒结束,会有第一条弹幕完全飘出屏幕外,这时要把它从绘制列表中移除。也就是mHead自增。6.0秒结束后,会有弹幕不断地从列表中移除,对应着mHead自增。
- 判断弹幕从列表中移除的时机要分情况。如果是第一个6.0秒,因为此时还没有弹幕完全飘出屏幕,则不需要从列表中移除。从第一个6.0秒之后才会有弹幕飘出屏幕,也就是说移除发生在轮播时。但是这种情况,伴随飘出屏幕的会有新的弹幕加入。也就是mHead自增和mTail自增同时发生。这一点很好理解,因为只有这样才能使得int size = mTail - mHead的值不变。除非是用户发送了一个弹幕,需要插入才有可能改变这个size(如果size已经是4,则无法改变)。
- 除了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天时间来解决这种问题。