IGListKit中的diff算法详解

近期,我们项目里面引入了IGListKit的第三方库,它是对collectionView的一层封装,主要用于feed流的实现,它的其中一个优势就是刷新视图的时候并不是刷新的整个collectionView,而是通过diff算法算出新老数组的差异,根据这个差异collectionView进行部分更新,这个更新的逻辑在UICollectionView+IGListBatchUpdateData.m这个分类中,函数如下:

- (void)ig_applyBatchUpdateData:(IGListBatchUpdateData *)updateData {
    [self deleteItemsAtIndexPaths:updateData.deleteIndexPaths];
    [self insertItemsAtIndexPaths:updateData.insertIndexPaths];

    for (IGListMoveIndexPath *move in updateData.moveIndexPaths) {
        [self moveItemAtIndexPath:move.from toIndexPath:move.to];
    }

    for (IGListMoveIndex *move in updateData.moveSections) {
        [self moveSection:move.from toSection:move.to];
    }

    [self deleteSections:updateData.deleteSections];
    [self insertSections:updateData.insertSections];
}

这个函数会在-performBatchUpdates:completion:batchUpdatesBlock中被调用。可以看出,每次更新只会涉及到部分视图的插入、删除、移动,非常高效。下面分析这个diff算法是如何将这类差异算出来的。

前置工作

diff函数简化

整个diff算法相关的流程都放在IGListDiff.mm这个类里了,其核心的函数的声明如下:

static id IGListDiffing(BOOL returnIndexPaths,
                        NSInteger fromSection,
                        NSInteger toSection,
                        NSArray<id<IGListDiffable>> *oldArray,
                        NSArray<id<IGListDiffable>> *newArray,
                        IGListDiffOption option,
                        IGListExperiment experiments)

这个函数参数有点多,而实际上核心的两个参数是oldArraynewArrayreturnIndexPaths在一般情况下传NO,可以用NO代替,而fromSectiontoSection在分析算法中可以删掉(默认在同一个section上操作)option一般传IGListDiffEquality,因此可以用IGListDiffEquality代替,而experiments整个流程都没用到因此可以直接删除,经过一番代码替换/删除之后,这个函数的声明就简化成了

static id IGListDiffing(NSArray<id<IGListDiffable>> *oldArray,
                        NSArray<id<IGListDiffable>> *newArray)

相关函数/结构体/方法介绍

IGListIndexSetResult

这是函数的返回值(returnIndexPathsNO的时候)其结构如下

NS_SWIFT_NAME(ListIndexSetResult)
@interface IGListIndexSetResult : NSObject
///插入索引的集合(新数组的索引)
@property (nonatomic, strong, readonly) NSIndexSet *inserts;
///删除索引的集合(旧数组的索引)
@property (nonatomic, strong, readonly) NSIndexSet *deletes;
///更新索引的集合(旧数组的索引)
@property (nonatomic, strong, readonly) NSIndexSet *updates;
///移动索引的集合(from是旧数组的索引,to是新数组的索引)
@property (nonatomic, copy, readonly) NSArray<IGListMoveIndex *> *moves;
///是否改变过
@property (nonatomic, assign, readonly) BOOL hasChanges;

@end

NS_ASSUME_NONNULL_END

最后返回的结果需要给insertsdeletesupdatesmoves赋值返回(初始化方法在IGListIndexSetResultInternal.h里面)

IGListMoveIndex

封装一个移动的操作,其结构如下:

NS_ASSUME_NONNULL_BEGIN

NS_SWIFT_NAME(ListMoveIndex)
@interface IGListMoveIndex : NSObject

@property (nonatomic, assign, readonly) NSInteger from;

@property (nonatomic, assign, readonly) NSInteger to;

@end

专门的初始化方法在IGListMoveIndexInternal.h里面

IGListDiffable

一个协议,数组里的对象都需要遵循这个协议才能有效地使用diff函数

NS_SWIFT_NAME(ListDiffable)
@protocol IGListDiffable

//返回对象唯一id,在diff算法中以它作为元素存入哈希表的key
- (nonnull id<NSObject>)diffIdentifier;

//判断两个对象是否相等,在diff算法用这个方法判断两个对象是否是同一个对象
- (BOOL)isEqualToDiffableObject:(nullable id<IGListDiffable>)object;

@end

在IGListKit中,NSStringNSNumber默认遵循了这个协议

IGListEntry

diff算法中用于标记元素状态的结构体

struct IGListEntry {
    该元素在旧数组中出现的次数
    NSInteger oldCounter = 0;
    该元素在新数组中出现的次数
    NSInteger newCounter = 0;
    存放元素在旧数组中的索引,在算法中,可以保证栈顶是较小的索引
    stack<NSInteger> oldIndexes;
    这个元素是否需要更新
    BOOL updated = NO;
};

IGListRecord

封装entry和它所在的索引,主要用于插入和删除(如果index有值,则代表该元素需要插入或者删除,否则为NSNotFound

struct IGListRecord {
    IGListEntry *entry;
    mutable NSInteger index;
    
    IGListRecord() {
        entry = NULL;
        index = NSNotFound;
    }
};

其它工具函数

还有其它的一些函数,在section/useIndexPath这些参数去掉之后,变得没那么复杂了,下面统一列出来

///取元素在哈希表中的key,这里取的就是diffIdentifier
static id<NSObject> IGListTableKey(__unsafe_unretained id<IGListDiffable> object) {
    id<NSObject> key = [object diffIdentifier];
    NSCAssert(key != nil, @"Cannot use a nil key for the diffIdentifier of object %@", object);
    return key;
}

///判断两个值是否相等,这个函数在建无序哈希表的时候用到
struct IGListEqualID {
    bool operator()(const id a, const id b) const {
        return (a == b) || [a isEqual: b];
    }
};

///求元素的哈希值,这个函数在建无序哈希表的时候用到
struct IGListHashID {
    size_t operator()(const id o) const {
        return (size_t)[o hash];
    }
};

///给集合增加索引
static void addIndexToCollection( __unsafe_unretained id collection,NSInteger index) {
    [collection addIndex:index];
};

///向哈希表增加索引
static void addIndexToMap( NSInteger index, __unsafe_unretained id<IGListDiffable> object, __unsafe_unretained NSMapTable *map) {
    id value;
    value = @(index);

    [map setObject:value forKey:[object diffIdentifier]];
}

IGListDiffing函数的算法流程

下面开始逐步剖析IGListDiffing这个函数

变量的声明

    const NSInteger newCount = newArray.count;
    const NSInteger oldCount = oldArray.count;
    
    NSMapTable *oldMap = [NSMapTable strongToStrongObjectsMapTable];
    NSMapTable *newMap = [NSMapTable strongToStrongObjectsMapTable];
    
    unordered_map<id<NSObject>, IGListEntry, IGListHashID, IGListEqualID> table;

newCountoldCount方便后面使用,table是后面初始化的哈希表,为了方便讲解把它挪到前面来,它以diffIdentifier为键,entry为值,其查找复杂度为o(1)。而oldMapnewMap并不参与这个diff算法,它们到最后就是已数组的index为key,数组的元素为值的哈希表而已。不过因为优化算法(减少循环的次数)而把它的初始化操作写到diff算法的循环里面。把初始化操作拎出来就是

     for (NSInteger i = 0; i < oldCount; i++) {
        addIndexToMap(i, oldArray[i], oldMap);
    }
    for (NSInteger i = 0; i < newCount; i++) {
        addIndexToMap( i, newArray[i], newMap);
    }

处理特殊情况

如果newCountoldCount为0,则可以判断为删除所有旧元素或者增加所有新元素,就不需要走diff算法了

    if (newCount == 0) {
            [oldArray enumerateObjectsUsingBlock:^(id<IGListDiffable> obj, NSUInteger idx, BOOL *stop) {
                addIndexToMap( idx, obj, oldMap);
            }];
            return [[IGListIndexSetResult alloc] initWithInserts:[NSIndexSet new]
                                                         deletes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, oldCount)]
                                                         updates:[NSIndexSet new]
                                                           moves:[NSArray new]
                                                     oldIndexMap:oldMap
                                                     newIndexMap:newMap];
        
    }
    
    if (oldCount == 0) {
            [newArray enumerateObjectsUsingBlock:^(id<IGListDiffable> obj, NSUInteger idx, BOOL *stop) {
                addIndexToMap(idx, obj, newMap);
            }];
            return [[IGListIndexSetResult alloc] initWithInserts:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, newCount)]
                                                         deletes:[NSIndexSet new]
                                                         updates:[NSIndexSet new]
                                                           moves:[NSArray new]
                                                     oldIndexMap:oldMap
                                                     newIndexMap:newMap];
        
    }

diff算法Step1

遍历新数组,为每个新数组的元素创建一个entry,并增加entrynewCounter

    vector<IGListRecord> newResultsArray(newCount);
    for (NSInteger i = 0; i < newCount; i++) {
        id<NSObject> key = IGListTableKey(newArray[i]);
        IGListEntry &entry = table[key];
        entry.newCounter++;
        
        //增加NSNotFound是为了防止oldIndexed为空,NSNotFound相当于栈底的标志位
        entry.oldIndexes.push(NSNotFound);
        
        
        newResultsArray[i].entry = &entry;
    }

需要注意的是IGListEntry &entry = table[key]这句代码返回的是entry的地址(如果没有table里没有这个key就创建),如果数组中有相同的key的时候,newResultsArray存放的索引中的entry会指向同一个地址。

这一步过后,会建立一个用于存放IGListRecordnewResultsArray,每个IGListRecord的index仍未NSNotFoundentry为新创建的IGListEntry,其newCounter都是大于0的。

diff算法Step2

遍历旧数组,为每个旧数组的元素创建entry,并增加它们的oldCounter,将对应的索引压入oldIndexes栈中。

    vector<IGListRecord> oldResultsArray(oldCount);
    for (NSInteger i = oldCount - 1; i >= 0; i--) {
        id<NSObject> key = IGListTableKey(oldArray[i]);
        IGListEntry &entry = table[key];
        entry.oldCounter++;
        
        // 将i入栈
        entry.oldIndexes.push(i);
        
        oldResultsArray[i].entry = &entry;
    }

这里的循环采用倒序的方式,在多个key相同的时候,oldIndexes会有一系列的索引压栈,倒序就会确保栈顶的索引是最小的。

这一步过后,会建立一个用于存放IGListRecordoldResultsArray,每个IGListRecord的index仍未NSNotFound,对于oldResultsArraynewResultsArray其中的entry,分三种情况:

  • 该元素只有新数组有,则entrynewCounter>0,oldCounter=0,oldIndexes栈顶为NSNotFound
  • 该元素只有旧数组有,则entrynewCounter=0,oldCounter>0,oldIndexes栈顶不为NSNotFound,而是元素在旧数组中的最小索引
  • 该元素新旧数组有,则entrynewCounter>0,oldCounter>0,oldIndexes栈顶不为NSNotFound,而是元素在旧数组中的最小索引,而oldResultsArraynewResultsArray都指向同一个entry

diff算法Step3

遍历新数组,新旧数组都出现的元素,其IGListRecord的index会赋上其在新/旧数组的索引

    for (NSInteger i = 0; i < newCount; i++) {
        IGListEntry *entry = newResultsArray[i].entry;
        NSCAssert(!entry->oldIndexes.empty(), @"Old indexes is empty while iterating new item %li. Should have NSNotFound", (long)i);
        ///拿到oldIndexes的栈顶,也就是拿到改元素在oldArray的第一个索引,然后pop出来
        const NSInteger originalIndex = entry->oldIndexes.top();
        entry->oldIndexes.pop();
        
        if (originalIndex < oldCount) {
            const id<IGListDiffable> n = newArray[i];
            const id<IGListDiffable> o = oldArray[originalIndex];
            if (n != o && ![n isEqualToDiffableObject:o]) {
            //标记为update的条件,只有在key相同且n和o不一样且isEqualToDiffableObject不相同的时候
            //才会走进这个条件
              entry->updated = YES;
            }
        }
        //给两边的index赋上对应的索引,如果originalIndex是NSNotFound,则不会走到这个条件
        if (originalIndex != NSNotFound
            && entry->newCounter > 0
            && entry->oldCounter > 0) {
            
            newResultsArray[i].index = originalIndex;
            oldResultsArray[originalIndex].index = i;
        }
    }

PS: entry->updated = YES这个条件很难触发,而且触发了也没看出什么作用,在前面的- (void)ig_applyBatchUpdateData:(IGListBatchUpdateData *)updateData中,是没有reload这个操作的,究其原因,在前面_flushCollectionView的方法里面为了规避一个bug而将update的操作统一换成delete和insert了。

这一步主要的作用在于最后,这一步过后,如果一个元素两边的数组都存在,newResultsArray中对应的元素的index就会指向该元素在oldArray中的索引,oldResultsArray对应的元素的index就会指向该元素在newArray中的索引。这个赋值主要是用于统计移动元素的操作。

如果newArrayoldArray中又相同的元素,且出现了数次会怎么样呢?在实际的IGListKit的使用中一般会规避这种情况。如果真的发生了,分析这一步的算法不难发现:该元素在oldArray中的第i次出现的索引会跟在newArray中的第i次出现的索引相匹配,这种算法得出来的结果并不是最佳的,这个在后面讲。

diff算法Step4

接下来就是增删改查数组的生成了,为了优化算法,IGListKit把这些算法都放在两个循环里,这里为了方便理解将其拆开。

首先,定义对应的数组

    id mInserts, mMoves, mUpdates, mDeletes;

    mInserts = [NSMutableIndexSet new];
    mUpdates = [NSMutableIndexSet new];
    mDeletes = [NSMutableIndexSet new];
    mMoves = [NSMutableArray<IGListMoveIndex *> new];

delete数组的生成

    for (NSInteger i = 0; i < oldCount; i++) {
        const IGListRecord record = oldResultsArray[i];
        if (record.index == NSNotFound) {
            addIndexToCollection( mDeletes, i);
        }
    }

很好理解,通过上面的操作,如果oldResultsArrayindex还是NSNotFound,则说明newArray中没有这个元素,就代表需要删除。

insert数组的生成

    for (NSInteger i = 0; i < newCount; i++) {
        const IGListRecord record = newResultsArray[i];
        if (record.index == NSNotFound) {
            addIndexToCollection(mInserts, i);
        } 
    }

这个也很好理解,通过上面的操作,如果newResultsArrayindex还是NSNotFound,则说明oldArray中没有这个元素,就代表需要添加。

update数组的生成

    for (NSInteger i = 0; i < newCount; i++) {
        const IGListRecord record = newResultsArray[i];
        const NSInteger oldIndex = record.index;
        if (record.index == NSNotFound) {
        } else {
            if (record.entry->updated) {
                addIndexToCollection( mUpdates, oldIndex);
            }
        }
    }

之前已经标记过update的,就表示需要update。之所以是这个oldIndex应该是跟collectionViewbadgeUpdate的规则有关,后面会将update替换成insert和delete。

moves数组生成

moves数组的核心实现如下:

        id move;
        move = [[IGListMoveIndex alloc] initWithFrom:oldIndex to:newIndex];          
        [mMoves addObject:move];

之前的算法中,oldIndexnewIndex都已经得出了,可以直接使用,但是,在一些情况里面,我们是不需要move操作的。比如:

oldArray = @[@"1",@"2",@"3"];
newArray = @[@"2",@"3"];

这个情况我们只需执行一次delete操作就可以从oldArray变到newArray了,同理,有些情况下只需要insert操作就行了,对于此,IGListKit引入了runningOffset,整体算法如下

    vector<NSInteger> deleteOffsets(oldCount), insertOffsets(newCount);
    NSInteger runningOffset = 0;
    for (NSInteger i = 0; i < oldCount; i++) {
        deleteOffsets[i] = runningOffset;
        //如果需要删除,则runningOffset++
        if (record.index == NSNotFound) {
            runningOffset++;
        }
    }
    runningOffset = 0;
    
    for (NSInteger i = 0; i < newCount; i++) {
        insertOffsets[i] = runningOffset;
        如果需要插入,则runningOffset++
        if (record.index == NSNotFound) {
            runningOffset++;
        }
    }
    for (NSInteger i = 0; i < newCount; i++) {
        const IGListRecord record = newResultsArray[i];
        const NSInteger oldIndex = record.index;
        if (record.index == NSNotFound) {
        } else {
            //对应插入的偏移量
            const NSInteger insertOffset = insertOffsets[i];
          //对应删除的偏移量
            const NSInteger deleteOffset = deleteOffsets[oldIndex];
            if ((oldIndex - deleteOffset + insertOffset) != i) {
                id move;
                move = [[IGListMoveIndex alloc] initWithFrom:oldIndex to:i];         
                [mMoves addObject:move];
            }
        }
    }

大意就是,如果前面出现的删除,则后面元素的位置都是要往左移,如果前面出现了插入,后面元素的位置都是要往右移,oldIndex - deleteOffset + insertOffset是执行了删除,插入后元素的最新位置,如果它与i相等,则没必要move了。

函数返回

    return [[IGListIndexSetResult alloc] initWithInserts:mInserts
                                                     deletes:mDeletes
                                                     updates:mUpdates
                                                       moves:mMoves
                                                 oldIndexMap:oldMap
                                                 newIndexMap:newMap];

算完diff之后,每个数组的元素都有值了,便可以封装IGListIndexSetResult返回了。

数组含有多个相同元素的情况

前面说过,如果newArrayoldArray中又相同的元素,且出现了数次,该元素在oldArray中的第i次出现的索引会跟在newArray中的第i次出现的索引相匹配。这种匹配方式并不是最佳的,举个例子:

oldArray = @[@"2",@"3",@"1"];
newArray = @[@"1"@"2",@"1"];

肉眼看,oldArray只需delete @"3",insert @"1"到索引为0的位置就变成了newArray了,而这个diff算法则需要个操作(@"2"从0移到1,@"1"从索引2移到0,删除@"3",插入@"1"到索引2)这是因为oldArray中的索引2跟newArray中的索引0匹配了,导致了@"1"进行不必要的移动。

实际开发中,我们也很少出现这种情况,IGListKit也不鼓励这种情况出现(会作去重且assert掉)

总结

diff算法是一个非常高效的算法,如果不把关键的代码抽出来,IGListDiffing只是进行了5次for循环而已,时间复杂度和空间复杂度都是o(n)。在前面3次循环中将元素的状态都标记出来,后面两次循环计算出数组从旧到新所需的操作。IGListKit使用它进行collectionView的部分更新,也提升了app的性能。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,509评论 6 504
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,806评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,875评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,441评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,488评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,365评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,190评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,062评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,500评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,706评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,834评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,559评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,167评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,779评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,912评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,958评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,779评论 2 354

推荐阅读更多精彩内容

  • 总结了一些开发中常用的函数: usleep() //函数延迟代码执行若干微秒。 unpack() //函数从二进制...
    ADL2022阅读 454评论 0 3
  • PHP常用函数大全 usleep() 函数延迟代码执行若干微秒。 unpack() 函数从二进制字符串对数据进行解...
    上街买菜丶迷倒老太阅读 1,369评论 0 20
  • 基础篇NumPy的主要对象是同种元素的多维数组。这是一个所有的元素都是一种类型、通过一个正整数元组索引的元素表格(...
    oyan99阅读 5,128评论 0 18
  • 一、基本数据类型 注释 单行注释:// 区域注释:/* */ 文档注释:/** */ 数值 对于byte类型而言...
    龙猫小爷阅读 4,261评论 0 16
  • 苹果在WWDC2019的session中公开了iOS13一些新的系统API, 其中对于非常稳定的UITableVi...
    A大阅读 2,717评论 0 5