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> *oldArray,
                        NSArray> *newArray,
                        IGListDiffOption option,
                        IGListExperiment experiments)

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

static id IGListDiffing(NSArray> *oldArray,
                        NSArray> *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 *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)diffIdentifier;

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

@end

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

IGListEntry

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

struct IGListEntry {
    该元素在旧数组中出现的次数
    NSInteger oldCounter = 0;
    该元素在新数组中出现的次数
    NSInteger newCounter = 0;
    存放元素在旧数组中的索引,在算法中,可以保证栈顶是较小的索引
    stack 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 IGListTableKey(__unsafe_unretained id object) {
    id 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 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, 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 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 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 newResultsArray(newCount);
    for (NSInteger i = 0; i < newCount; i++) {
        id 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 oldResultsArray(oldCount);
    for (NSInteger i = oldCount - 1; i >= 0; i--) {
        id 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 n = newArray[i];
            const id 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 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 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的性能。

你可能感兴趣的:(IGListKit中的diff算法详解)