仿映客刷礼物效果---基本逻辑实现
最近做了个直播项目,需要用到弹幕和刷礼物。在网上找了许多开源代码,发现都不是很适合自己的项目需求,于是利用空余时间将两个功能都实现下,这里分享出来,供大家一起学习。(关于弹幕的实现,大家可以参考我前面写的一篇文章IOS 自定义弹幕实现)
在开始我的实现方案之前,大家可以先参看下这篇文章iOS 基于 IM 实现仿映客刷礼物连击效果,写得很好。Demo中关于礼物连乘的动画效果,就是引用其中。但这位大神所用的缓存逻辑特别复杂,所以在控制缓存的时候有些小BUG,为了弥补这个缺陷,于是我就开始了这篇文章。
实现功能
程序最终实现的效果图如下:

这里的礼物1、2、3、4四个按钮分别模拟四个人发的四种礼物,点击一次,就代表发送一条礼物消息,实现逻辑功能如下:
- 点击礼物1按钮,会出现一个倒计时按钮,并发送一条礼物消息
- 再次点击倒计时按钮,会再次发送一条礼物消息,礼物数量累加
- 在倒计时的时间内,倒计时按钮如果没有收到点击事件,倒计时按钮会隐藏,并且当前用来展示礼物动画的cell也会隐藏
- 如果当前全部的cell都在展示,这时你点击了其他类型的礼物按钮,这时的礼物消息会被缓存,等到当前礼物动画执行完时,再去执行缓存的
- 如果在短时间内多次点击连送按钮,连乘动画也会缓存
实现界面功能如下:
- 可自定义cell样式
- 可自定义cell的展示和隐藏动画
- 可监听cell的点击事件
下面是我实现这些功能的基本逻辑与实现代码。
基本逻辑
开始写代码之前,将功能的基本逻辑列出,这是个很好的习惯。特别是对于那些复杂的功能,这个习惯就显得尤为重要。
功能要求:收到消息展示动画、可连乘、可缓存
功能要求看起好像很简单,如果真的实现可就不是那么容易了。
具体逻辑如下:

- 收到一条礼物消息
- 检测当前是否有相同类型的礼物消息正在展示动画
- 有,将该消息加入到当前的动画组中
- 没有,检测当前是否有空闲的轨道用于展示动画
- 有,取出缓存中相同类型的消息,开始执行动画
- 没有,将当前消息加入到消息缓存中
- 连乘动画完成,如果3秒内没有收到新的同类消息,就执行隐藏动画
- 隐藏动画完成,再取缓存,开始下一个动画,直至无缓存为止
理清楚了逻辑之后,下面就是代码实现了,真正的痛苦现在才开始!
实现代码
这里我会根据逻辑顺序来介绍代码的实现。在收到一条消息之后,在外面只需调用PresentView对象的insertPresentMessages:接口方法,将消息插入进来,insertPresentMessages:接口实现代码如下:
- (void)insertPresentMessages:(NSArray<id<PresentModelAble>> *)models
{
NSArray *siftArray = [self checkElementOfModels:models];
if (!siftArray.count) return;
for (int index = 0; index < siftArray.count; index++) {
id<PresentModelAble> obj = models[index];
PresentViewCell *cell = [self examinePresentingCell:obj];
if (cell) {
[cell shakeAnimationWithNumber:1];
}else {
[self.dataCaches addObject:obj];//将当前消息加到缓存中
NSArray *cells = [self examinePresentViewCells];
if (cells.count) {
cell = cells.firstObject;
NSArray *objs = [self subarrayWithObj:obj];
__weak typeof(self) ws = self;
[cell showAnimationWithSender:[obj sender] giftName:[obj giftName] prepare:^{
if ([ws.delegate respondsToSelector:@selector(presentView:configCell:sender:giftName:)]) {
[ws.delegate presentView:ws configCell:cell sender:[obj sender] giftName:[obj giftName]];
}
} completion:^(BOOL finished) {
int index = 0;
while (index < objs.count) {
index++;
[cell shakeAnimationWithNumber:objs.count];
}
}];
}
}
}
}
这基本就是整个逻辑的实现了,看到这一堆的逻辑判断,是不是感觉头都大了。没关系,接下来我会对这个代码进行一步步的解析。
首先,调用了PresentView对象的checkElementOfModels:方法,这就是数据检测。
数据检测
数据检测就是对插入的模型数组进行过滤,因为我们需要通过这个模型来确定消息的类型(礼物类型是通过发送者和发送的礼物名来确定的),所以自定义的消息模型必须要遵守PresentModelAble协议,协议要求如下:
@required
@property (copy, nonatomic) NSString *sender;
@property (copy, nonatomic) NSString *giftName;
故检测数据,其实就是检测模型数组中的元素是否遵守了PresentModelAble协议,并剔除其中没有遵守协议的数据。具体实现代码如下:
- (NSArray *)checkElementOfModels:(NSArray<id<PresentModelAble>> *)models
{
NSMutableArray *siftArray = [NSMutableArray array];
for (id obj in models) {
if (![obj conformsToProtocol:@protocol(PresentModelAble)]) {
DebugLog(@"%@对象没有遵守PresentModelAble协议", obj);
}else {
[siftArray addObject:obj];
}
}
return siftArray;
}
选出合适的数据之后,就是遍历该数组,对每一个元素进行动画检测。
动画检测
动画检测就是检测当前是否有相同类型的消息正在展示动画,即流程2。这里是遍历当前所有的cell,如果cell上展示的消息类型与该消息类型一致,并且cell的动画正在执行,就返回该cell,否则返回nil。具体实现代码如下:
- (PresentViewCell *)examinePresentingCell:(id<PresentModelAble>)obj
{
for (PresentViewCell *cell in self.showCells) {
if ([cell.sender isEqualToString:[obj sender]] && [cell.giftName isEqualToString:[obj giftName]]) {
//当前正在展示动画
if (cell.state != AnimationStateNone) return cell;
}
}
return nil;
}
如果动画检测检测到匹配的cell,就在cell当前的动画队列上添加一个连乘动画(shakeAnimation)任务。
ShakeAnimation
连乘动画就是在当前礼物数量上做一个累加的动画,即流程3。连乘动画的具体实现,大家可以自行查看demo中PresentLable对象的startAnimationDuration:completion:方法。因为连乘动画执行完,三秒后没有收到新的任务就要开始cell的隐藏动画(hiddenAnimation)。所以开始连乘动画前,需要取消掉前面延时三秒执行的隐藏动画任务,然后重新开始延时,即流程7。具体实现代码如下:
- (void)shakeAnimationWithNumber:(NSInteger)number
{
[NSObject cancelPreviousPerformRequestsWithTarget:self];
__weak typeof(self) ws = self;
[self performSelector:@selector(hiddenAnimation) withObject:nil afterDelay:3.0];
_state = AnimationStateShaking;
self.shakeLable.text = [NSString stringWithFormat:@"X%ld", ++self.number];
[self.shakeLable startAnimationDuration:Duration completion:^(BOOL finish) {
if (number > 1) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[ws startShakeAnimationWithNumber:(number - 1) completion:block];
});
}else {
_state = AnimationStateShaked;
if (block) {
block(YES);
}
}
}];
}
HiddenAnimation
隐藏动画实现代码也非常简单,这里就不介绍了。但是需要注意的地方是,隐藏动画执行完成后需要将cell恢复到初始状态,保证cell在开始下一次展示动画之前不会因为状态的错误而导致流程判断出错。
如果动画检测没有检测到匹配的cell,就开始cell的检测
cell检测
cell检测就是检测当前是否有空闲的cell用于展示礼物消息动画,即流程4.这里只需要遍历所有的cell,判断cell的动画状态就可以了。具体实现代码如下:
- (NSArray<PresentViewCell *> *)examinePresentViewCells
{
NSMutableArray *freeCells = [NSMutableArray array];
for (PresentViewCell *cell in self.showCells) {
if (cell.state == AnimationStateNone) {
[freeCells addObject:cell];
}
}
return freeCells;
}
如果没有空闲的cell用于展示动画,就将当前消息加入到缓存中,即添加到dataCaches这个数组中,即流程6。
如果有空闲cell用于展示,就从空闲cell数组中取出一个cell,执行cell的展示动画(showAnimation),即流程5。
ShowAnimation
cell的展示动画,其接口如下:
/**
* 显示cell动画
*
* @param sender 发送者
* @param name 礼物名
* @param prepare 准备动画回调
* @param completion 动画完成回调
*/
- (void)showAnimationWithSender:(NSString *)sender
giftName:(NSString *)name
prepare:(void (^)(void))prepare
completion:(void (^)(BOOL finished))completion;
因为展示动画是用来展示礼物消息的动画,在展示之前需要知道展示的礼物消息的类型,所以需要传入sender和name两个参数。
展示动画完成之后,就需要从缓存中取出与该消息相同类型的消息,然后开始连乘动画,这些操作就可以早completion中完成。那prepare回调是干嘛呢?
其实这里还有一个问题:在开始cell的展示动画之前,我们就需要给这个cell设置需要展示的数据。可是cell是自定义的,这是根本无法拿到自定义的cell,也就无法给这个cell设置数据?
这里我的思路是:在开始cell的展示动画之前,就是prepare回调中,给外界一个代理通知,让外面来执行这个赋值操作。代理通知接口如下:
/**
* 礼物动画即将展示的时调用,根据礼物消息类型为自定义的cell设置对应的模型数据用于展示
*
* @param cell 用来展示动画的cell
* @param sender 礼物发送者
* @param name 礼物名
*/
- (void)presentView:(PresentView *)presentView
configCell:(PresentViewCell *)cell
sender:(NSString *)sender
giftName:(NSString *)name;
到这里,关于收到一条消息的流程就就全部处理完了。最后一步就是对缓存逻辑进行处理了,即上面的流程8。
缓存处理
缓存处理就是当一个礼物消息类型的动画处理完,即cell的隐藏动画执行完成,就要从缓存中取下一个类型的礼物消息,开始下一组动画,直至无缓存为止,即流程8。这里的隐藏动画回调是通过代理实现的,具体实现代码如下:
- (void)presentViewCell:(PresentViewCell *)cell operationQueueCompletionOfNumber:(NSInteger)number
{
if (self.dataCaches.count) {
id<PresentModelAble> obj = self.dataCaches.firstObject;
NSArray *objs = [self subarrayWithObj:obj];
__weak typeof(self) ws = self;
[cell showAnimationWithSender:[obj sender] giftName:[obj giftName] prepare:^{
if ([ws.delegate respondsToSelector:@selector(presentView:configCell:sender:giftName:)]) {
[ws.delegate presentView:ws configCell:cell sender:[obj sender] giftName:[obj giftName]];
}
} completion:^(BOOL finished) {
[cell shakeAnimationWithNumber:objs.count];
}];
// [self insertPresentMessages:self.dataCaches completion:self.completion];
}else {
[cell releaseVariable];
}
}
其实这里的处理就重复流程5。最后缓存处理完了就调用releaseVariable方法释放相关引用内存。
至此,整个刷礼物效果的基础逻辑就以实现了。这里就完了吗?当然,还没有!任何一个功能实现之后,没有经过反复的测试、修改、优化等流程的检验,就都不算完成。
后续
由于篇幅原因,关于这个刷礼物效果功能的优化与bug的修改,我会在下一篇文章进行说明。
最后奉上Demo(优化后)的下载地址