从0开始写一个直播间的礼物系统

6,858 阅读14分钟
  • 前段时间公司APP要对直播间的礼物系统进行改版,由于以前直播的收入不在于礼物分成,所以以前的礼物系统是很简单的一个展示而已.为适应主流直播间的礼物效果,特由此改版!
  • 先奉上 GitHub

1. 所有直播间的礼物系统,第一步用户看到的无外乎都是礼物的列表界面

  • 纵观主流直播间的礼物列表应该都是使用UICollectionView实现的,所以我也不例外,下面就是各种撸代码.效果如下

  • 看着效果还不错吧.但是但是我突然发现一个问题.礼物展示的顺序跟我想要的顺序不一样,跟数据的排序也不一致.看图来说

  • 黄色的顺序是我们想要的顺序,但是现在顺序确是红色的.为什么呢?我们都知道collectionview的滚动方向是有layout控制的.代码如下
    UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
    layout.itemSize = CGSizeMake(itemW, itemH);
    layout.minimumLineSpacing = 0;
    layout.minimumInteritemSpacing = 0;
    layout.scrollDirection = UICollectionViewScrollDirectionHorizontal;

  • 看代码之后才明白,因为我们设置的滚动方向是横向滚动,所以系统会默认先把垂直方向的Item填充,然后再横向填充,这就不难解释为啥会是这种排序.如果换成垂直滚动呢?

  • 这样也不满足我们的需求,既然系统的不行,那么只有拿出独门武器,自定义一个flowlayout吧.让它按照我们的要求去滚动,去排序.
- (void)prepareLayout {
    //自定义layout都必须重写这个方法
    [super prepareLayout];
    
    //设置基本属性
    CGFloat itemW = SCREEN_WIDTH/4.0;
    CGFloat itemH = itemW*105/93.8;
    self.itemSize = CGSizeMake(itemW, itemH);
    self.minimumLineSpacing = 0;
    self.minimumInteritemSpacing = 0;
    self.scrollDirection = UICollectionViewScrollDirectionHorizontal;
    
    //刷新后清除所有已布局的属性 重新获取
    [self.cellAttributesArray removeAllObjects];
    
    NSInteger cellCount = [self.collectionView numberOfItemsInSection:0];
    for (NSInteger i = 0; i < cellCount; i++) {
        //取出每一个的Item的布局.重新赋值
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
        UICollectionViewLayoutAttributes *attibute = [self layoutAttributesForItemAtIndexPath:indexPath];
        NSInteger page = i / 8;//第几页
        NSInteger row = i % 4 + page*4;//第几列
        NSInteger col = i / 4 - page*2;//第几行
        attibute.frame = CGRectMake(row*itemW, col*itemH, itemW, itemH);
        //保存所有已经重新赋值的布局
        [self.cellAttributesArray addObject:attibute];
    }
}

- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect{
    //返回当前可见区域内的已经计算好的布局
    return self.cellAttributesArray;
}

  • 写出来之后心里沾沾自喜,这样应该可以实现了吧.看看效果吧

  • 应该可以看出来问题了吧,我选中的那个礼物第一页和第二页竟然都出现了,我明明设置了分页滚动的呀.查看层级结构如下

  • 原来是可爱的么么哒礼物被挤到外面了.由于没有设置弹簧的效果,所以没太注意少了一个礼物,那么原因呢? 想了好久才想起来是不是滚动的范围不够,导致么么哒不显示在界面中呢?又去扒了扒怎么设置自定义的layout的contentoffset.最终找到一个方法.
- (CGSize)collectionViewContentSize{
    
    NSInteger cellCount = [self.collectionView numberOfItemsInSection:0];
    NSInteger page = cellCount / 8 + 1;
    return CGSizeMake(SCREEN_WIDTH*page, 0);
}

  • 但是这样做真的可以么?看看效果吧

  • 到此为止基本实现了一个主流的礼物列表界面.关于礼物的点击逻辑看看代码就可以了.在此就不多啰嗦了.(详见代码 -- JPGiftView)

2. 点击发送之后的礼物动画效果展示

  • 最简单的实现就是创建一个View在点击发送后把当前选中的礼物信息传入这个展示礼物效果的view中,写一个位移的动画进行展示.如果连送,那么就在view展示之前计算好一共连击多少次礼物,然后直接展示x几.如图

  • 但是这样的弊端肯定是很多,比如我会将一个用户送其中一个礼物这样算成一个完整的实际的礼物.同一个用户送不同的礼物算是第二个完整的礼物.那么每一个完整的礼物都是唯一的存在.如果使用上面的逻辑来处理,那么你会发现出现各种让你忍俊不禁的bug,比如,不同礼物的累加,不同礼物会进行顶替正在展示的当前礼物.....
  • 既然知道了bug的存在,那么怎么解决呢?首先我脑海中第一个想到的就是强大的队列,一个苹果帮我们封装好的面向对象的类 -- NSOperationQueue .这样我们就可以将每一个完整的礼物当成一个操作 -- NSOperation .加入队列中,这样就会自动按照顺序去执行礼物的展示.道理和逻辑都想通了,怎么实现是需要好好斟酌下咯!
  • 俗话说代码是不会骗人的,当我将一个个操作加入到队列中的时候,又出bug.并没有按照我们设想的一个个按照排队的顺序去执行.(系统有个依赖方法,但是想了想不太能实现需求,也就没试)随后去Google了一下,才知道原来系统提供的API只能加入操作,并不能在上一个操作结束的时候再去执行下一个操作.如果需要按照顺序执行,就要自定义一个操作,然后在一个完整礼物礼物动画展示完成后结束当前操作,那么才会按顺序去执行下一个操作!
  • 具体的代码可见 JPGiftOperation类
  • 自定义操作的主要是改变操作的两个属性 下图所示,默认改为NO.使用@synthesize禁止系统的GET/SET,有开发者自己控制
  • 我们需要重写star方法来创建操作(礼物动画的展示)
- (void)start {
    
    if ([self isCancelled]) {
        _finished = YES;
        return;
    }
    
    _executing = YES;
    NSLog(@"当前队列-- %@",self.model.giftName);
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
    
        [self.giftShowView showGiftShowViewWithModel:self.model completeBlock:^(BOOL finished,NSString *giftKey) {
            self.finished = finished;
            if (self.opFinishedBlock) {
                self.opFinishedBlock(finished,giftKey);
            }
        }];
    }];
    
}

//当动画结束时 self.finished = YES; 然后手动触发KVO改变当前操作的状态
#pragma mark -  手动触发 KVO
- (void)setExecuting:(BOOL)executing
{
    [self willChangeValueForKey:@"isExecuting"];
    _executing = executing;
    [self didChangeValueForKey:@"isExecuting"];
}

- (void)setFinished:(BOOL)finished
{
    [self willChangeValueForKey:@"isFinished"];
    _finished = finished;
    [self didChangeValueForKey:@"isFinished"];
}

  • 这样在动画结束的时候,我们就能控制当前的操作也结束了.那么系统会自动去队列中执行下一个存在的操作.基本实现了队列的效果.

  • 实现了队列的效果后,那么下一步,如果用户对一个礼物进行连击操作.该怎么实现呢?看看现在的连击是什么效果吧

  • 这是什么鬼,这是连击么.
  • 看来我们需要一个管理类来管理礼物的展示逻辑,按照一定的规则创建操作,加入队列. 这样 JPGiftShowManager类应运而生.
  • 我们需要在拿到当前点击的礼物信息时,就可以判断这个礼物的具体该怎么展示,是排队等着展示还是在当前展示的礼物的连击,或者是排队等待展示的礼物的累加等情况,这样所有的逻辑都在这个管理类中实现,外部最少可以只需一句代码传入礼物的数据就可以完美的展示一个礼物的动效了.想想就是很好的.
  • 让我们写一个展示礼物的方法入口吧,单例就不说了.
/**
 送礼物
 
 @param backView 礼物动效展示父view
 @param giftModel 礼物的数据
 @param completeBlock 展示完毕回调
 */

- (void)showGiftViewWithBackView:(UIView *)backView
                            info:(JPGiftModel *)giftModel
                   completeBlock:(completeBlock)completeBlock;

  • 前面说过每一个完整的礼物就是一个唯一的存在,只有相同的完整礼物才会执行连击或者累加的操作.那么怎么区别唯一的礼物呢.我在礼物的Model中放了一个属性 giftKey 使用礼物名和礼物的ID进行拼接而成(我在实际项目中是使用用户的ID+礼物ID拼接,这样肯定可以保证唯一性)
/** 礼物操作的唯一Key */
@property(nonatomic,copy)NSString *giftKey;

//在.m中 自己写get方法
- (NSString *)giftKey {
    
    return [NSString stringWithFormat:@"%@%@",self.giftName,self.giftId];
}

  • 那么这样的话我们在管理类中还至少需要两个容器,来存储已经传进来的key和已经创建的操作.
/** 操作缓存 */
@property (nonatomic,strong) NSCache *operationCache;
/** 当前礼物的key */
@property(nonatomic,strong) NSString *curentGiftKey;

  • 最终的思路慢慢就确定了,当我们拿到一个新的礼物数据的时候,那么我们就要判断礼物的key是否与curentGiftKey相同,礼物的key对应的操作是否在operationCache中.
    if (self.curentGiftKey && [self.curentGiftKey isEqualToString:giftModel.giftKey]) {
        //有当前的礼物信息
        if ([self.operationCache objectForKey:giftModel.giftKey]) {
            //当前存在操作 那么就可以在当前操作上累加礼物 出现连击效果

        }else {
            //当前操作已结束 重新创建
            JPGiftOperation *operation = [JPGiftOperation addOperationWithView:showView OnView:backView Info:giftModel completeBlock:^(BOOL finished,NSString *giftKey) {
                if (self.finishedBlock) {
                    self.finishedBlock(finished);
                }
                //移除操作
                [self.operationCache removeObjectForKey:giftKey];
                //清空唯一key
                self.curentGiftKey = @"";
            }];
            //存储操作信息
            [self.operationCache setObject:operation forKey:giftModel.giftKey];
            //操作加入队列
            [queue addOperation:operation];
        }

    }else {
        //没有礼物的信息
        if ([self.operationCache objectForKey:giftModel.giftKey]) {
            //当前存在操作 说明是有礼物在排队等待展示
        }else {
        //当前第一次展示这个礼物
            JPGiftOperation *operation = [JPGiftOperation addOperationWithView:showView OnView:backView Info:giftModel completeBlock:^(BOOL finished,NSString *giftKey) {
                if (self.finishedBlock) {
                    self.finishedBlock(finished);
                }
                //移除操作
                [self.operationCache removeObjectForKey:giftKey];
                //清空唯一key
                [self.curentGiftKeys removeObject:giftKey];
            }];
            operation.model.defaultCount += giftModel.sendCount;
            //存储操作信息
            [self.operationCache setObject:operation forKey:giftModel.giftKey];
            //操作加入队列
            [queue addOperation:operation];
        }
    }

  • 可能有的同学疑问了,这个当前礼物的key--self.curentGiftKey怎么得来的呢? 请看这段代码
        [_giftShowView setShowViewKeyBlock:^(JPGiftModel *giftModel) {
            _curentGiftKey = giftModel.giftKey;
        }];

  • 我在操作的star方法调用礼物展示的动画的时候进行回调,判断条件当前第一次展示这个礼物,把key回调给管理类.
    if (self.showViewKeyBlock && self.currentGiftCount == 0) {
        self.showViewKeyBlock(giftModel);
    }

  • 这样我们就可以拿到当前展示的key了.通过判断是创建新的操作还是进行连击的逻辑.
  • 虽然逻辑已经有了,但是具体的怎么实现连击的效果呢?因为我们的动画我是在show完之后,使用dispatch_after进行隐藏并移除的.想要实现连击,首先就要先解决怎么在连击的过程中,不会让礼物展示的动画结束消失.所以我就想到应该在礼物累加的过程中取消这个延迟执行的方法,取消完之后在创建延迟执行的方法.这样每一次连击的时候等于是重新创建了这个隐藏动画的方法.
  • 最后查了资料使用dispatch_after还无法实现这个需求.找到了一个方法可以实现.只要当前展示的礼物的个数大于1了,就会去执行这个逻辑,取消-创建.如果就一个礼物那么就按照正常的逻辑取消动画.
if (self.currentGiftCount > 1) {
        [self p_SetAnimation:self.countLabel];
        [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(hiddenGiftShowView) object:nil];//可以取消成功。
        [self performSelector:@selector(hiddenGiftShowView) withObject:nil afterDelay:animationTime];
        
    }else {
        [self performSelector:@selector(hiddenGiftShowView) withObject:nil afterDelay:animationTime];
    }
  • 具体的连击代码是通过什么实现的呢?在展示礼物动画的view中有两个属性.一个传进来的用户当前点击所送的礼物总数(此处默认都是1),一个是当前展示的礼物总数.
/** 礼物数 */
@property(nonatomic,assign) NSInteger giftCount;
/** 当前礼物总数 */
@property(nonatomic,assign) NSInteger currentGiftCount;
  • 什么时候会发生连击效果和排队累加效果呢?
  • 连击效果 - 当前展示的self.curentGiftKey和拿到的新的礼物的key是一致的并且操作缓冲池中还存在当前key对应的操作.这样会发生连击效果.那么此时我们只需要给giftCount赋值用户选中的礼物数(当前默认都是一次送一个).
            JPGiftOperation *op = [self.operationCache objectForKey:giftModel.giftKey];
            op.giftShowView.giftCount = giftModel.sendCount;
            
            //限制一次礼物的连击最大值
            if (op.giftShowView.currentGiftCount >= giftMaxNum) {
                //移除操作
                [self.operationCache removeObjectForKey:giftModel.giftKey];
                //清空唯一key
                self.curentGiftKey = @"";
            }

  • 让我们看看赋值之后的具体操作,拿到传进来的当前的礼物点击数后累加到总礼物数上,然后赋值.是不是看到熟悉的代码.没看错,延迟隐藏的方法也是在这里控制的.这样就实现了连击的效果.
- (void)setGiftCount:(NSInteger)giftCount {
    
    _giftCount = giftCount;
    self.currentGiftCount += giftCount;
    self.countLabel.text = [NSString stringWithFormat:@"x %zd",self.currentGiftCount];
    NSLog(@"累计礼物数 %zd",self.currentGiftCount);
    if (self.currentGiftCount > 1) {
        [self p_SetAnimation:self.countLabel];
        [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(hiddenGiftShowView) object:nil];//可以取消成功。
        [self performSelector:@selector(hiddenGiftShowView) withObject:nil afterDelay:animationTime];
        
    }else {
        [self performSelector:@selector(hiddenGiftShowView) withObject:nil afterDelay:animationTime];
    }
}

  • 排队累加 - 在拿到当前用户点击的key之后与当前展示礼物的key比较不一样,但是这个点击的key对应的操作是存在的.那么就说明这个礼物正在等待展示,那么我们就要对这个没有展示的礼物进行累加.我称之为排队累加.
            JPGiftOperation *op = [self.operationCache objectForKey:giftModel.giftKey];
            op.model.defaultCount += giftModel.sendCount;
            //限制一次礼物的连击最大值
            if (op.model.defaultCount >= giftMaxNum) {
                //移除操作
                [self.operationCache removeObjectForKey:giftModel.giftKey];
                //清空唯一key
                self.curentGiftKey = @"";
            }

  • 不知道有没有注意到这两个逻辑处理的不一样.没看错,就是这两个属性,一个是赋值,一个累加赋值.defaultCount是我给每一个礼物默认的点击数0.只有点击之后才会进行累加.比如,送了一个累加之后defaultCount就是1,那么在我第一个展示的时候,礼物右边的数字就是defaultCount的数值.只有在连击的时候使用的self.currentGiftCount的数值.
op.giftShowView.giftCount = giftModel.sendCount;
op.model.defaultCount += giftModel.sendCount;
  • 回头看下那么判断逻辑那,在完全的第一次创建礼物展示时使用的也是defaultCount.
  • 最终在show的方法中还是调用了这个方法来展示动画
        self.currentGiftCount = 0;
        [self setGiftCount:giftModel.defaultCount];
  • 写到这里,让我们看看现在的效果吧.

  • 总算实现了.准备交工测试的时候,我们产品又加了一个需求(此处省略点字).让礼物第一次展示的时候放一个gif图.而且同一个礼物在连击的时候只展示一次.呀呀呀呀.
  • 这样就以为可以难倒我了么.嘿嘿,还记得前面的一个方法么,现在刚好可以用到了.刚好符合产品的需求,只在第一次展示当前礼物的时候回调.
    if (self.showViewKeyBlock && self.currentGiftCount == 0) {
        self.showViewKeyBlock(giftModel);
    }

  • 这样的话就要改变管理类的方法了,因为我们需要一个回调告诉控制器,我的礼物开始展示了,你赶紧给我展示gif.
/**
 送礼物

 @param backView 礼物需要展示的父view
 @param giftModel 礼物的数据
 @param completeBlock 回调
 */
- (void)showGiftViewWithBackView:(UIView *)backView
                            info:(JPGiftModel *)giftModel
                   completeBlock:(completeBlock)completeBlock
       completeShowGifImageBlock:(completeShowGifImageBlock)completeShowGifImageBlock;
  • 那么在回调的方法中我们就直接在调起这个回调剩下的就让控制器去处理吧.(各位同学可以酌情使用这个功能)
[_giftShowView setShowViewKeyBlock:^(JPGiftModel *giftModel) {
            _curentGiftKey = giftModel.giftKey;
            if (weakSelf.completeShowGifImageBlock) {
                weakSelf.completeShowGifImageBlock(giftModel);
            }
        }];
  • 下面看一个效果

  • 写到这里,其实这个功能已经实现了产品的所有需求.我们项目中使用的也是只是到这里的功能.
  • 但是我自己确在想了,现在主流的不都是支持同时显示两个礼物的信息么,那么该怎么实现呢.
  • 思考中...
  • 既然一个队列显示一个礼物,那么要显示2个或者更多是不是需要更多的队列去展示呢?那么就试一试吧.
  • 两个队列,两个可以展示动画的view,还有key不在是NSString ,变成一个数组,以便放下当前展示的两个礼物的key.
/** 队列 */
@property(nonatomic,strong) NSOperationQueue *giftQueue1;
@property(nonatomic,strong) NSOperationQueue *giftQueue2;
/** showgift */
@property(nonatomic,strong) JPGiftShowView *giftShowView1;
@property(nonatomic,strong) JPGiftShowView *giftShowView2;
/** 操作缓存 */
@property (nonatomic,strong) NSCache *operationCache;
/** 当前礼物的keys */
@property(nonatomic,strong) NSMutableArray *curentGiftKeys;
  • 只需要在创建操作加入队列的时候判断当前哪个队列中的操作数比较少,那么就将新创建的操作加入到这个队列中等待展示.全部流程代码如下.
- (void)showGiftViewWithBackView:(UIView *)backView info:(JPGiftModel *)giftModel completeBlock:(completeBlock)completeBlock completeShowGifImageBlock:(completeShowGifImageBlock)completeShowGifImageBlock {
    
    self.completeShowGifImageBlock = completeShowGifImageBlock;
    
    if (self.curentGiftKeys.count && [self.curentGiftKeys containsObject:giftModel.giftKey]) {
        //有当前的礼物信息
        if ([self.operationCache objectForKey:giftModel.giftKey]) {
            //当前存在操作
            JPGiftOperation *op = [self.operationCache objectForKey:giftModel.giftKey];
            op.giftShowView.giftCount = giftModel.sendCount;
            
            //限制一次礼物的连击最大值
            if (op.giftShowView.currentGiftCount >= giftMaxNum) {
                //移除操作
                [self.operationCache removeObjectForKey:giftModel.giftKey];
                //清空唯一key
                [self.curentGiftKeys removeObject:giftModel.giftKey];
            }

        }else {
            NSOperationQueue *queue;
            JPGiftShowView *showView;
            if (self.giftQueue1.operations.count <= self.giftQueue2.operations.count) {
                queue = self.giftQueue1;
                showView = self.giftShowView1;
            }else {
                queue = self.giftQueue2;
                showView = self.giftShowView2;
            }

            //当前操作已结束 重新创建
            JPGiftOperation *operation = [JPGiftOperation addOperationWithView:showView OnView:backView Info:giftModel completeBlock:^(BOOL finished,NSString *giftKey) {
                if (self.finishedBlock) {
                    self.finishedBlock(finished);
                }
                //移除操作
                [self.operationCache removeObjectForKey:giftKey];
                //清空唯一key
                [self.curentGiftKeys removeObject:giftKey];
            }];
            operation.model.defaultCount += giftModel.sendCount;
            //存储操作信息
            [self.operationCache setObject:operation forKey:giftModel.giftKey];
            //操作加入队列
            [queue addOperation:operation];
        }

    }else {
        //没有礼物的信息
        if ([self.operationCache objectForKey:giftModel.giftKey]) {
            //当前存在操作
            JPGiftOperation *op = [self.operationCache objectForKey:giftModel.giftKey];
            op.model.defaultCount += giftModel.sendCount;
            
            //限制一次礼物的连击最大值
            if (op.model.defaultCount >= giftMaxNum) {
                //移除操作
                [self.operationCache removeObjectForKey:giftModel.giftKey];
                //清空唯一key
                [self.curentGiftKeys removeObject:giftModel.giftKey];
            }

        }else {
            NSOperationQueue *queue;
            JPGiftShowView *showView;
            if (self.giftQueue1.operations.count <= self.giftQueue2.operations.count) {
                queue = self.giftQueue1;
                showView = self.giftShowView1;
            }else {
                queue = self.giftQueue2;
                showView = self.giftShowView2;
            }

            JPGiftOperation *operation = [JPGiftOperation addOperationWithView:showView OnView:backView Info:giftModel completeBlock:^(BOOL finished,NSString *giftKey) {
                if (self.finishedBlock) {
                    self.finishedBlock(finished);
                }
                //移除操作
                [self.operationCache removeObjectForKey:giftKey];
                //清空唯一key
                [self.curentGiftKeys removeObject:giftKey];
            }];
            operation.model.defaultCount += giftModel.sendCount;
            //存储操作信息
            [self.operationCache setObject:operation forKey:giftModel.giftKey];
            //操作加入队列
            [queue addOperation:operation];
        }
    }

  • 效果如下

  • 那么到这里,整个结束了.第一次写这么长的文章,还是技术方面.很多不足之处我自己都能感觉到.很多都描述不出来并且基础有点薄弱.很多地方不能特别肯定只能笨笨的去用代码实验.最终运气比较好,在工期内完成了这个改版.不足之处,请多多指教.
  • 送上GitHub地址 GitHub