直播应用送礼大动画实现
送礼物作为观众打赏支持主播的一种方式, 也是直播应用的一大收入来源, 每个直播平台都包含送礼这一功能, 并且都把礼物动画效果做的特别炫酷. 如此的动画效果再搭配美女或帅哥主播的一句"谢谢某某某送的大飞机~", 是不是想想都有点小激动, 感觉瞬间成为了全场的焦点?
本文主要叙述的就是大礼物动效的实现. 全文共3000字左右, 大概阅读时间为5~12分钟.
先放上按序列帧播放方案实现的动画引擎FXAnimationEngine, Demo中实现了直播间礼物队列、礼物配置、礼物列表, 另外还分别用动画引擎与原生Core Animation去播放序列帧动画以做比较.
然后国际惯例, 上两张图
梦幻城堡 天使一、直播应用礼物动画的常见方案
仅个人了解, 实现iOS侧动画配置化常见方案有如下几种:
iOS方案 | 优点 | 缺点 |
---|---|---|
Core Animation(此处不计CAKeyFrameAnimation) | 效果流畅逼真 | 安卓需重新实现; 配置化成本高, 需自定义模型、协议、转换方法等(iOS侧已有现成工具, 某几家直播公司想必也有自己的动画配置化工具); 不解决动态配置问题, 则只能随包更新. |
序列帧播放(CAKeyframeAnimation、CADisplaylink、ImageView等) | 设计哥工具可直接导出动画序列帧图片, 简单易用; 多平台兼容 | 效果略差; 图片帧数多易导致资源大 |
Cocos2d-x | 效果好; 多平台兼容 | 学习成本; 相应动画制作工具; 必须引入Cocos2d库; |
Lottie | 横跨三端, iOS, Android, React Native. 设计师可以完全按照自己的想法设计. 无需考虑实现这一块. | 内存占用? 作者本人尚未使用过, 不敢妄自评论 |
可以看出, 序列帧播放方案是其中最简单易行的一个. 在我看来, 花椒直播用的即是这套方案, 他们每一个动画, 都会对应一个配置文件config.ini及对应该动画的所有序列帧图片.
感兴趣的朋友可以移至最后一部分礼物资源的下载策略、资源目录结构等
相关内容, 更建议尝试去探索一下花椒、映客等主流直播应用的bundle目录以及document中的资源.
二、序列帧播放方案实践
2.1 实现方式
序列帧播放动画一方案的具体实现必须能够满足以下需求:
- 图片展示: CALayer、UIImageView
- 按时间间隔逐帧播放: CAKeyframeAnimation、UIImageView、定时器类(CADisplayLink、NSTimer、dispatch_source_t)+切换关键帧逻辑
- 提供所有序列帧播放完的事件: CAAnimationDelegate、CATransaction CompletionBlock、定时器类+回调触发逻辑
组合方式很多, 比如: 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 {
// 触发动画播放结束(全部播放完、中途结束)回调
...
}
如果此处你已经下载了Demo, 可以打开Debug Navigator(cmd+6
)简单查看内存增长或者留意Xcode Instrument-Allocations中VM:ImageIO_PNG_Data
一项, 就会看到有内存增长波峰. 而且序列帧图片越多, 波峰越明显.
那么其他方案是否出现了相同的问题呢? 是的, 其他方案一样会如此, 换成UIImageView自带的animationImages来做序列帧播放或是其他组合方式, 也出现内存激增的情况.
2.2 了解图片加载
在我们搞清楚是什么导致内存激增前, 我们先了解一下图片从磁盘加载, 到写入内存, 最后显示到屏幕上分别都发生了什么. 大致分为如下步骤:
- 为磁盘中的图片创建映射
- IO操作读取图片数据流
- 图片解码位图拷贝, 写入内存
- 硬件绘制渲染到屏幕
2.2.1 映射文件
当我们通过[UIImage imageWithContentsOfFile:]
从磁盘加载图片数据流, 实际上只是为此图片创建了一个文件映射数据, 图片文件既没有真正被加载到内存, 更没有被解码成位图的形式可供Core Animation传递给底层硬件进行渲染, 故此时内存并不会明显增加, 也不会出现因为解码操作导致CPU使用增加的情形. 但从网络下载图片数据不包含在内.
简单提及一下映射文件:
A mapped file uses virtual memory techniques to avoid copying
pages of the file into memory until they are actually needed.
直译就是一个映射文件借助虚拟内存技术来避免当他们还没有真正使用到时就被拷贝到内存中.
下面来一组对照验证一下:
对照组一
- (void)test1 {
UIImage *frame = [UIImage imageWithContentsOfFile:filePath];
// 确保超出局部作用域后, 依旧保持对这个Image对象的强引用
self.frame = frame;
}
// 待上方函数执行完后, 再查看内存使用情况
对照组二
- (void)test2 {
UIImage *frame = [UIImage imageWithContentsOfFile:filePath];
self.imageview.image = frame;
}
我们可以发现对照组二的内存占用明显比对照组一要多. 即通过imageWithContentsOfFile:
创建的UIImage对象后, 内存并没有明显增长, 等我们将该UIImage对象赋值给UIImageView的image属性后的某个时刻, 内存才出现明显增长.
此处再留几个问题:
- 我们都知道
imageWithName:
方法加载的图片, 会被系统缓存, 那么第一次通过该方法进行如上两个对照组的实验, 结果如何呢? - 通过
imageWithName:
方法第2、3..n次加载同名图片时, 加载的图片数据流会不会再次被解码? 期间CPU占用有没有增加? - 尝试把创建的UIImage对象桥接赋值给CALayer的contents属性, 结果如何?
2.2.2 浅谈CALayer的隐式动画及事务
从上一节中, 我们发现当给UIImageView的image属性或CALayer的contents属性赋值Image对象后的某一刻, 内存和CPU占用才会出现明显变化. 那是因为每一次Runloop循环, Core Animation都会在其开始创建一个动画事务, 在本次Runloop结束时才去执行所有添加到该事务里的所有动画操作. 此刻图片才被解码加载入内存, 图片数据会被解码为渲染可用的bitmap数据. 一些相关细节可看我另一篇分享.
2.3 解决内存激增问题
当前我们面临的问题是无论采用何种实现方案, 在执行序列帧动画时, 所有图片都会被解码成为位图并载入内存中.
2.3.1 解压后的图片所占内存大小
图片解码后的格式为位图形式. 位图是由一组像素(pixel)组成的, 每一个像素就代表图片中的一个点. 比如常见的JPEG, 以及PNG格式的图片文件都是位图图片.
我们还需要知道, JPEG和PNG图片实际上都是一种编码/压缩后的位图格式, 它们是不能直接用来图片渲染的, 所以得先对其压缩的数据进行解码/解压缩操作.
那么一张解压后的位图其所占内存大小怎么计算呢?
此处假设我们有一张32位的PNG格式图片, 其像素格式为RGBA四部分组成, 每部分占8位, 该图片尺寸为160px * 320px.
32位的图片意味着其每个像素占32位, 即4个字节.
又根据图片尺寸计算出总像素数量为 160*320 个像素.
所以该图片解码后所占内存大小就为 像素总数 * 单位像素的字节数
即 (160*320) * 4 / 1024 = 200 KB.
所以可想而知, 假设一个序列帧动画有80张图片, 200 * 80 / 1024 = 15.625 mb, 就会占用15mb的内存. 序列帧图片越多, 占用内存越大!
2.3.2 解决方案
那么有什么方法可以避免呢? 可否每次播放到哪一帧时就去加载那一帧的图片, 即每次仅加载一张图片到内存中. 这样当播放到下一张图片时, 上一张图片已无任何引用, 系统自然会对其进行释放.
这就是最简单可行的一套方案. 但是我们无法靠CAAnimation及其派生类CAKeyframeAnimation来实现这一方案, 因为所有的图片都会解码导致占用大量的内存.
但我们可以通过CADisplayLink来实现该方案, 选CADisplayLink的原因是它比NSTimer精度要高很多, 正常情况下CADisplayLink的回调会在屏幕每次刷新时触发, 即一般1/60秒触发一次, 适合用于做UI的重绘, 因此可以通过它来周期性的替换关键帧图片, 从而达到播放动画的效果. 那么具体怎么做呢?
在CADisplayLink的回调中获取两次屏幕刷新的间隔时间, 通过不断的累加间隔时间来判断总的时间是否已经满足下一帧的播放时刻, 如果大于下一帧的播放时刻就可以替换为下一帧图片了, 直至最后一张关键帧也播放完成.
举个例子, 我们要在1秒内播放完一个含有5张关键帧图片的动画, 每张图片的停留时间、切换时间如下图2.3.2.a所示. 所以第0秒的时候就开始展示第一张关键帧, 直到1.0秒这一刻时, 动画播放结束.
图 2.3.2.a此外, 如果还需要进一步优化, 我们可以加入图片异步解码、图片预加载逻辑等方案.
-
异步图片解码, 图片解码是一项比较耗时、比较占CPU的操作, 对于未解码的图片, 系统一般会在主线程对其进行解码, 所以可以通过在异步线程进行图片强制解压缩, 从而不占用UI线程. 关于图片解码的详情, 强烈推荐谈谈 iOS 中图片的解压缩.
-
图片预加载, 这个就是为了进一步节省上下文切换时间, 即前后两张图片切换的时间. 就是要做到当上一帧图片播放完时, 我们不用等下一张图片解码完成后再进行图片的切换, 而是可以直接从已解码图片的缓存队列中取出直接进行切换. 预加载我个人觉得其实主要就是阈值的最优选择, 可参考预加载与智能预加载一文.
三、序列帧动画引擎源代码及Demo
针对该Demo近期会另起一文特别介绍, 此处占坑, 等待跳转链接
四、礼物资源下载策略及资源目录结构
4.1 礼物资源下载策略
4.1.1 两种方式比较
方式 | 基本思路 | 优点 | 缺点 |
---|---|---|---|
整包更新 | 所有的动画资源按目录结构进行压缩, 客户端通过比较资源包版本号发现有更新后, 仅需下载一个资源包压缩文件, 并进行解压替换即可 | 简单易实现, 客户端每次仅需下载一个资源包 | 随着资源包逐渐增大, 下载及解压时间也会延长, 从而直接影响用户体验; 即使是仅是资源中的某个图片发生改变, 客户端都要重新下载整个资源包, 容错率低且浪费流量 |
增量更新 | 每个动画资源单独压缩并上传CDN, 若客户端发现资源版本号有变化, 再对服务器下发的资源列表跟本地资源列表求差集运算从而得出增量, 单个动画资源的下载地址或者md5可作为唯一标识进行比较. 得出增量后, 客户端再对每个增量资源包进行下载, 每下载完一个即可"投入使用" | 不怕资源变更频繁; 仅需下载有新增或有变更的资源包, 更节省时间以及流量; | 逻辑略复杂于整包更新, 比如下载中途用户把应杀掉, 下次需要找出未更新完的增量资源并继续下载 |
4.1.2 资源更新流程
因对上家公司的代码保密, 此处不上具体代码
我们在上一小节中提及的两种更新方式, 它们主要的不同的就在于"资源更新"这一步骤
图 4.1.2.a 整包更新的流程图
图 4.1.2.b 增量更新的流程图
增量更新流程图.png不知道各位发现两个流程共同之处没? 它们都需要检测资源版本号大小, 包括游戏补丁、热更补丁这一步骤都必不可少. 相比于补丁类的, 资源更新不用太考虑灰度发布、回滚机制等问题, 但还是依旧需要注意资源核对, 内部测试, 以及日志监控等保障, 我记得在前任公司就遇到了有的地区下载下来的资源包有问题, 所以不管是CDN的问题或资源本身有问题, 前端都需要为最坏的情况做好打算, 这才是万全之策.
引用我上家公司, 我老大兼mentor, 达文哥, 告诫的一句箴言
不要相信后台下发的数据都是正确的
大概意思如此, 原句没背下来😂, 这句话绝非不是指后台同学不行, 或者甩锅给后台, 而是要prepare for the worst
.
前后端测试都是一家人, 遇到问题我们先看看是不是自己问题, 不要相互甩锅..本是同根生相煎何太急, 如果有问题就一块搓一顿, 一顿不行就再来一顿
4.2 资源目录结构设计
不管哪个直播平台, 每个礼物都会对应一个逻辑id, 我们可以通过礼物的id作为该礼物的资源目录名, 然后在该目录内在去划分不同类型的图片子目录, 如下所示
- 10000 // 一级目录, 礼物id
- - gift // 二级目录, 小礼物序列帧图片
- - giftlist // 二级目录, 礼物列表序列帧图片
- - giftanim // 二级目录, 大动画序列帧图片
这只是其中的一种设计, 也有的平台会采用如下形式, 所以主要还是看需求而定
- gift
- - 10000
- giftlist
- - 10000
- giftanim
- - 10000
此外, 有的平台还会采用id_version, 即礼物id+礼物版本的形式来命名, 这样可以方便配置使后台可以灵活下发给前端具体要去播放哪个动画的某个版本了
- 10000_11 // id为10000, 版本为11的礼物资源目录
- 10000_12