ListView Diff (动画更新UITableView/UICollectionView)

donggelaile· 2019-10-28

前言

在iOS开发中,我们刷新列表数据时往往会简单粗暴的调用reloadData,然而这样操作是没有任何动画的,且更新内容是全量更新。而目前开源的列表diff大多数为swift的,OC版本的很少。IGList通过Objective-C++实现了diff。笔者参考了其思想,简化了diff函数。实现了一个轻量的,简化版本的纯OC diff库。

1、什么是 ListViewDiff ?

简单来说,就是查找一个滑动列表(这里指UITableView和UICollectionView)在更新数据后与更新数据之前的变化,如同git/svn提交新版本时通过diff来查看改变了哪些数据

2、为什么要做ListViewDiff ?

1、平时我们想动画 删除/移动/新增 某个cell时,往往需要自己计算变更前后的数据,进而得出相关的indexPath,最终拼凑 deleteRowsAtIndexPaths,insertRowsAtIndexPaths等方法实现。这里的问题在于一旦计算错误将会崩溃。且每次均需要自己计算并维护。通过diff算法来变更数据则基本不必担心以上问题,你仅仅需要告知变化前后的数据源即可,算法内部会自动计算变更内容并拼凑动画函数。

2、通过diff算法计算后,可以获得你增删改了哪些数据,然后可以通过 performBatchUpdates 来进行UI的更新,进而替代reloadData方法。好处就是diff后的更新是有针对性的更新,并且自带动画。而reloadData为全量更新,无动画。

3、自己实现一个简易版的diff组件

1、首先,我们先仅考虑在相同的section内进行增删改操作。

事实上实际应用大部分情况下都是在操作同一段内数据,基本上也是够用的。另外,我们要保证数据源内不存在两个完全相同的数据。这样做的原因一是简化算法,二是最终的解法固定,即与想要的动画一致。

2、现在,我们定义一个协议方法:

- (NSString*)hdDiffIdentifier

数据源中的每个模型都遵循该协议,并返回自己唯一的id。假设我们拿到了两个数组

NSArray<id<HDListViewDifferProtocol>>*oldArr  NSArray<id<HDListViewDifferProtocol>>*newArr

两个数组分别对应变化前及变化后的数据源,我们要做的事则是根据变化前后的数据源找出真正发生变化的那几条数据。

3、查找过程

3.1 构建新旧数组index索引

这个过程其实就是将数组键值对反转。首先需要了解的就是数组其实也是一个特殊的字典,key即其数组中的index,value即元素。给你一个10个元素的数组,我向你要第5个位置的元素时,你可以用O(1)的速度返给我。但是我问你某某元素在第几个时,你只能遍历数组后返回其index。这里就是构造一个数组反转的字典,完成后则可以O(1)的回答第二个问题。 示例代码如下:

   NSMutableDictionary *oldIndexDic = @{}.mutableCopy;
   [oldArr enumerateObjectsUsingBlock:^(id<HDListViewDifferProtocol>  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
       NSString *beforeChangeID = HDGetDataIdentifier(obj);
       objc_setAssociatedObject(obj, &HDDiffObjOldIDKey, beforeChangeID, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
       if (oldIndexDic[beforeChangeID]) {
           //存在相同元素
           _isExistEqualItemInOldOrNewArr = YES;
           *stop = YES;
       }
       oldIndexDic[beforeChangeID] = @(idx);
   }];

    if (newArrGenerateCode) {
        newArr = newArrGenerateCode();
        _afterArr = newArr;
    }

   NSMutableDictionary *newIndexDic = @{}.mutableCopy;
   [newArr enumerateObjectsUsingBlock:^(id<HDListViewDifferProtocol>  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
       NSString *afterChangeID = HDGetDataIdentifier(obj);
       objc_setAssociatedObject(obj, &HDDiffObjNewIDKey, afterChangeID, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
       if (newIndexDic[afterChangeID]) {
           //存在相同元素
           _isExistEqualItemInOldOrNewArr = YES;
           *stop = YES;
       }
       newIndexDic[afterChangeID] = @(idx);
   }];

其中HDGetDataIdentifier是获取元素id的方法,newArrGenerateCode是外部传入的变更数组的block, _isExistEqualItemInOldOrNewArr代表数组中是否出现了相同的元素。这里使用了runtime动态添加属性的特性来保存数组内元素变化前后的模型的id

3.2 查找需要删除的数据

需要删除的数据即在oldArr中存在,而在newArr中不存在的元素。只需要遍历oldArr并判断每项是否在newIndexDic中即可,不存在则加入到删除集合中。代码如下:

   NSMutableArray *deleteIndexPaths = @[].mutableCopy;
   [oldArr enumerateObjectsUsingBlock:^(id<HDListViewDifferProtocol>  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
       NSIndexPath *oldIndexP = [NSIndexPath indexPathForItem:idx inSection:section];
       BOOL isExistInNewArr  = newIndexDic[objc_getAssociatedObject(obj, &HDDiffObjOldIDKey)] != nil;
       if (!isExistInNewArr) {
           [deleteIndexPaths addObject:oldIndexP];
       }
   }];

其中section是外部传入的,循环完后便得到了删除数组

3.3 查找需要插入的数据

这个也比较简单,只需要遍历新数组并查找每项是否在旧数组存在即可。在旧数组不存在则是新增的数据,需要放到插入数组中。

   NSMutableArray *insertIndexPaths = @[].mutableCopy;
   [newArr enumerateObjectsUsingBlock:^(id<HDListViewDifferProtocol>  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
       BOOL isExistInOldArr = oldIndexDic[objc_getAssociatedObject(obj, &HDDiffObjNewIDKey)] != nil;
       if (!isExistInOldArr) {
           [insertIndexPaths addObject:[NSIndexPath indexPathForItem:idx inSection:section]];
       }
   }];

3.4 查找需要 更新、移动的数据

这一步就稍微复杂了些,前面之所以先查找需要删除的集合是因为这里需要用到上面的结果。

首先,我们假设这块的实现如下:

   NSMutableArray *updateIndexPaths = @[].mutableCopy;
   NSMutableArray *moveItems = @[].mutableCopy;
   [oldArr enumerateObjectsUsingBlock:^(id<HDListViewDifferProtocol>  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {

       NSIndexPath *oldIndexP = [NSIndexPath indexPathForItem:idx inSection:section];
       NSIndexPath *newIndexP = nil;
       NSNumber *newIndex = newIndexDic[objc_getAssociatedObject(obj, &HDDiffObjOldIDKey)];

       if (!newIndex) {
           //需要删除的,前面已经计算了
       }
       else if ([newIndex integerValue] != idx){

           newIndexP = [NSIndexPath indexPathForItem:[newIndex integerValue] inSection:section];
           HDCollectionViewUpdateItem *item = [[HDCollectionViewUpdateItem alloc] initWithIndexPathBeforeUpdate:oldIndexP indexPathAfterUpdate:newIndexP];
           [moveItems addObject:item];

       }else{
           id<HDListViewDifferProtocol> newObj = newArr[[newIndex integerValue]];
           if (![HDGetDataIdentifier(obj) isEqualToString:HDGetDataIdentifier(newObj)]) {
               [updateIndexPaths addObject:oldIndexP];
           }
       }
   }];

需要move的数据,即同一元素在新旧数组中的位置不同的元素。

这里举个简单的🌰:假如我们有数组[1,2,3,4,5,6],然后我们删掉其中的元素3,此时数组变成了[1,2,4,5,6],经过 3.2 后,我们的deleteIndexPaths数组中会存放着需要删除的3的indexPath。在经过以上代码后,我们发现4,5,6都被找了出来。即先删除3,然后将 4,5,6都需要向前move一位才能变成 [1,2,4,5,6]。最终我们的 moveItems中会存放着 4,5,6的indexPath。然而,实际动画时中我们只需要 deleteItemsAtIndexPaths 3 的indexPath即可,后面的move全都是多余的,加上后在数据量大时反而影响刷新时的性能。以下的代码为优化后的,即过滤调了多余的move:

   NSMutableArray *updateIndexPaths = @[].mutableCopy;
   NSMutableArray *moveItems = @[].mutableCopy;
   [oldArr enumerateObjectsUsingBlock:^(id<HDListViewDifferProtocol>  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {

       //计算idx在经过 删除、插入后 新的位置
       //如果计算后的新位置与新数组位置一致,则该项不需要进行move操作
       NSInteger offsetIndex = idx - [self getDeleteOffset:deleteIndexPaths currentIdx:idx] + [self getInsertOffset:insertIndexPaths currentIdx:idx];

       NSIndexPath *oldIndexP = [NSIndexPath indexPathForItem:idx inSection:section];
       NSIndexPath *newIndexP = nil;
       NSNumber *newIndex = newIndexDic[objc_getAssociatedObject(obj, &HDDiffObjOldIDKey)];

       if (!newIndex) {
           //需要删除的,前面已经计算了
       }
       else if ([newIndex integerValue] != idx && [newIndex integerValue] != offsetIndex){

           newIndexP = [NSIndexPath indexPathForItem:[newIndex integerValue] inSection:section];
           HDCollectionViewUpdateItem *item = [[HDCollectionViewUpdateItem alloc] initWithIndexPathBeforeUpdate:oldIndexP indexPathAfterUpdate:newIndexP];
           [moveItems addObject:item];

       }else{
           id<HDListViewDifferProtocol> newObj = newArr[[newIndex integerValue]];
           if (![HDGetDataIdentifier(obj) isEqualToString:HDGetDataIdentifier(newObj)]) {
               [updateIndexPaths addObject:oldIndexP];
           }
       }
   }];

大意就是在每次delete一个元素时,其后的元素的index都要-1,而在某个位置insert一个元素之后,其后的元素的index都要+1。同时比对修正后的index来进行Move操作的过滤。其中getDeleteOffset是一个二分查找函数,getInsertOffset与其类似,这里贴上getDeleteOffset代码以供参考:

- (NSInteger)getDeleteOffset:(NSArray<NSIndexPath*>*)deleteIndexPaths currentIdx:(NSInteger)curIndex
{
    NSInteger start = 0, end = deleteIndexPaths.count-1;
    while (start<=end) {
        NSInteger mid = (start+end)/2;
        NSInteger midIndex = [deleteIndexPaths[mid] item];
        if (curIndex>midIndex) {
            if (start == mid) {
                start ++;
            }else{
                start = mid;
            }
        }else if (curIndex<midIndex){
            if (end == mid) {
                end --;
            }else{
                end = mid;
            }
        }else{
            break;
        }
    }

    return start;

}

前边的操作时间复杂度都是O(n),移动数组查找这里的循环内部又进行了二分查找,因此最终的时间复杂度大概为O(n)*log(n)。

3、最终效果

说了这么多,让我们来看看最终有啥效果吧。。。

说明 示例 说明 示例
UICollectionView UITableView

笔记将实现的内容单独封装成了一个diff组件,源码见这里

4、最后,搭一波小广告😁,搭配HDCollectionView疗效更好...

全面支持HDCollectionView (当然也支持任意UICollectionView及UITableView) 在HDCollectionView中使用diff时,你仅仅需要指定是否需要动画变更数据即可,接口将更加简单。

顺便放上部分HDCollectionView中的效果

说明 示例 说明 示例
中间对齐删除 瀑布流删除/交换
HDYogaFlowLayout删除/交换 横向滚动diff

共勉!