记录下通用多级列表的实现过程

30 阅读13分钟

1.想法的产生

由于在社区业务,需要在段评页面将一级评论和二级回复在同一个页面展示,这样就形成了一个二级列表页面。这里以小红书的评论页面为例:

截屏 2026-05-07 10.14.55.png

可以从上图看出,这是个二级列表页面。我希望能够建立一个通用的多级列表并且尽量使业务方只需关注UI的实现,并且仅需提供一些少量的通用字段(符合Protocol)。另外一个方面是在工作中使用的多级评论列表组件存在如下两个问题:

  1. 仅支持二级,不支持多级
  2. 与评论业务强绑定,不支持扩展其它业务

为此想写一个支持多级列表的组件,并且方便各个业务接入。

2.实现过程

2.1. 协议的定义

多级列表从数据结构上来看,就是森林,可以简单表示为如下图所示:

已生成图像 1.png

上面提到如何使得业务方能够接入多级列表组件,业务方仅仅需要提供少量的字段,也即仅需符合协议MLListItemProtocol。业务方仅仅需要提供如下三个字段:

@protocol MLListItemProtocol <IGListDiffable>

@property (nonatomic, nullable, strong) NSMutableArray<id<MLListItemProtocol>> *children;

@property (nonatomic, assign) NSInteger totalChildrenCount;

@property (nonatomic, assign) NSInteger visibleChildrenCount;
  1. children array 表示当前父节点已经获取的children,仅仅表示当前已经获取到的children的内容,可以添加内容到该数组(比如从网络获取)。
  2. totalChildrenCount 当前父节点总的children数量,totalChildrenCount >= children.count。
  3. visibleChildrenCount 当前父节点可见的children的数量 children.count >= visibleChildrenCount。

2.2 多级列表的结构

在二级评论业务使用了 LGListKit 框架,为此多级列表组件也基于此实现。

业务方仅需提供如下数据对象:rootObjects 是一个多级嵌套结构,表示多级列表的的嵌套。

NSArray<id<MLListItemProtocol>> *rootObjects

在多级列表differable objects由当前所有可见的节点组成。由于每一个节点都有可能有子节点,并且按照顺序展示。为此需要将这个root objects进行flatten,也就是把嵌套数组打平成一个一维的数组(只包含当前可见的对象),作为diffable objects给IGListKit。为此的设计思路是:一个visible item对应于一个section这样做有如下原因

  • IGListKit 框架建议这么做
  • 针对每一个不同层级的visible item,可以独立控制
  • 方便每一个parent item可以额外引入UI元素,比如footer。官方的footer只能针对section,但是对于多级的列表,官方不支持section嵌套
@property (nonatomic, nullable, strong, readonly) NSArray<MLFlattenedItemModel *> *visibleItems;
- (nonnull NSArray<id<IGListDiffable>> *)objectsForListAdapter:(nonnull IGListAdapter *)listAdapter {
    // IGListKit consumes the flattened projection, not the original tree.
    return self.visibleItems ?: @[];
}

在多级列表需要支持footer,比如展开和折叠功能。为此对于业务方提供的root objects需要封装成多级列表内部使用的数据结构 MLFlattenedItemModel。

@interface MLFlattenedItemModel : NSObject<IGListDiffable>

/// Parent flattened normal row. Root rows have no parent.

@property (nonatomic, nullable, strong) MLFlattenedItemModel *parent;

/// Business model backing this flattened row.

@property (nonatomic, strong) id<MLListItemProtocol> differableObject;

/// Whether this model represents the normal row or the footer row.

@property (nonatomic, assign) MLFlattenedItemType type;

/// Zero-based tree depth. Root items are level `0`.

@property (nonatomic, assign) NSInteger level;

/// Snapshot of the backing item's visible child count at creation time.

@property (nonatomic, assign) NSInteger visibleChildrenCount;

/// Snapshot of the backing item's total child count at creation time.

@property (nonatomic, assign) NSInteger totalChildrenCount;

/// Remaining hidden child count derived from total and visible child counts.

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

/// UI-facing state used by business cells and footer cells.

/// Setting this property triggers `statusDidChangeHandler` when the value

/// actually changes.

@property (nonatomic, assign) MLFlattenedItemStatus status;

在业务方提供的数据结构之下,differable objects的类型为MLFlattenedItemModel新增了如下几个关键字段

  • type 表示当前节点是正常的cell还是footer。
  • status 表示当前节点的状态,比如是完全展开还是部分展开,是折叠动画还是展开动画状态等。
  • parent 表示当前节点的父节点。
  • level 表示当前节点的层级序号,控制UI,左边距的参考。
  • diffableObject 表示业务方提供的数据。

2.3 flatten 过程

在对业务方提供的root objects进行打平,在这个过程中,根据visible count字段,只展示当前children数组前visible count个对象的内容,如果支持footer,则添加footer。 这是一个广度优先搜索遍历的过程,把当前需要展示的内容,依次添加到visible items中。

- (NSArray<MLFlattenedItemModel *> *)visibleItemsForItems:(NSArray<id<MLListItemProtocol>> *)items level:(NSInteger)level {

    NSAssert(level >= 0, @"Flatten level must be non-negative.");

    NSMutableArray<MLFlattenedItemModel *> *visibleItems = [NSMutableArray array];

    for (id<MLListItemProtocol> item in items) {

        [self appendVisibleItemsForObject:item parent:nil level:level toArray:visibleItems];

    }

    return [visibleItems copy];

}
- (void)appendVisibleItemsForObject:(id<MLListItemProtocol>)object

                             parent:(MLFlattenedItemModel *)parent

                              level:(NSInteger)level

                            toArray:(NSMutableArray<MLFlattenedItemModel *> *)visibleItems {

    NSParameterAssert(object);

    NSParameterAssert(visibleItems);

    NSAssert(level >= 0, @"Flatten level must be non-negative.");

    NSAssert(object.visibleChildrenCount >= 0, @"visibleChildrenCount must be non-negative.");

    NSAssert(object.totalChildrenCount >= 0, @"totalChildrenCount must be non-negative.");

  


    // A business item always generates a normal row. It may also generate a

    // footer row after its currently visible descendants.

    MLFlattenedItemModel *cellItem = [self flattenedItemModelWithObject:object                                                                  parent:parent                                                                   level:level                                                                    type:MLFlattenedItemTypeNormal];

    [visibleItems addObject:cellItem];

    

    NSInteger visibleChildrenCount = MIN(object.children.count, object.visibleChildrenCount);

    NSArray<id<MLListItemProtocol>> *visibleChildren = [object.children subarrayWithRange:NSMakeRange(0, visibleChildrenCount)];

    for (id<MLListItemProtocol> child in visibleChildren) {

        [self appendVisibleItemsForObject:child parent:cellItem level:level + 1 toArray:visibleItems];

    }

    

    if (self.params.usesFooter && object.totalChildrenCount > 0) {

        MLFlattenedItemModel *footerItem = [self flattenedItemModelWithObject:object                                                                        parent:cellItem                                                                         level:level                                                                          type:MLFlattenedItemTypeFooter];

        [visibleItems addObject:footerItem];

    }

}

首次初始化列表的调用过程如下图所示:

image.png

核心关系可以理解为:

  • 业务方:提供树形数据、响应点击、决定业务行为
  • Manager:统一对外 API,连接业务和 IGListKit
  • FlattenService:只负责 tree -> visible flat list
  • IGListAdapter:只关心 flattened items 的 diff 和 UI 更新

2.4 增量打平 VS 全量打平

在上述我们知道,提供给 IGListKit 是一个一维的数组。那么再对列表进行相应的操作,删除和添加。均需要对这个一维数组进行操作。这就涉及到两种方式:

  1. 业务方对diffable objects进行更改,然后进行全量打平,也即重新生成包含visible items的一维数组。
  2. 组件内对diffable objects进行更改,业务方只提供变更的objects,然后进行增量打平,只针对变更的diffable objects进行打平,对visible items数组内的元素进行操作,比如移动,插入等操作。

多级列表采用的是方式2,具体有如下原因:

  • 针对diffable objects的增删操作由组件完成,避免业务方误操作。
  • 对visible items数组进行增量更新,避免全量更显带来的耗时。删除和移动数组的操作耗时低于全量更新。

3.性能评估

测试手机为真机iPhone 17 Pro。

3.1 展开

下图表示在不同的数量级的展开的行增量和全量打平的对比,增量稳定快约 1.4x-1.5x,但不是数量级差距。

image.png

3.2 折叠

下图表示在不同的数量级的折叠的行增量和全量打平的对比,折叠场景里增量稳定快接近 2x。原因是全量折叠会重新创建折叠后的所有可见 MLFlattenedItemModel;增量折叠虽然会 copy 当前 expanded array 并 remove range,但主要复用已有 model,只替换 parent/footer,分配对象少很多。

image.png

3.3 根节点头部插入

这里增量大约 1.94x-1.99x,接近 2 倍。

原因是全量要处理最终全部行:比如最高档是 50,000 + 50,000 = 100,000 行,所有 100,000 行都会重新 flatten 并创建 model。

增量走 insertRootItems:atIndex:,只为新插入的 50,000 行创建 flattened model,原来的 50,000 个 visibleItems model 会复用。不过它仍然要做这些事:copy 当前 visibleItems、copy/插入 rootItems、把新 flattened rows 插到 visibleItems 头部并移动旧元素、最后再 copy 回不可变数组。所以它不是无限快,最终表现接近 2 倍。

image.png

3.4 子节点尾部插入

这里增量只有 1.29x-1.37x,明显低于 root insert。

原因是 child insert 走 insertItems:toParentItem:position:,除了创建新增 child 的 flattened rows,还要多做几次线性查找:找 parent normal model、找 parent footer model、替换 parent normal、替换 parent footer。测试数据里 parent 放在大量 root leaf 之后,所以这些查找基本要扫过前面的现有可见行。

也就是说,child insert 的增量路径虽然避免了重建所有旧行,但它为了定位父节点和 footer,付出了多次 O(现有可见行数) 的扫描成本,所以收益被吃掉了一部分。

image.png

3.5 根节点删除

这里增量最快,约 3.81x-4.02x。

全量删除 root 后仍然要重新 flatten 剩下的所有行。比如从 100,000 行删掉 50,000 行,最终还有 50,000 行,全量仍要为这 50,000 行重新创建 model。

增量走 deleteVisibleChildenItemsForRootModel:,删除的是一个连续 visible range。测试里被删 root 在头部,定位成本很低,后面主要是 removeObjectsInRange 和数组 copy,几乎不需要创建新 model,所以比全量优势最大。

image.png

3.6 子节点删除

这里增量约 2.05x-2.16x,比 root delete 慢一些。

原因和 child insert 类似:删除本身是连续区间,增量很占优;但 child delete 还要处理父节点状态,比如更新 children、visibleChildrenCount、totalChildrenCount,并替换 parent normal/footer model。替换时又会走线性查找,所以有额外扫描成本。

所以它比 root delete 慢,但因为删除路径基本不需要为大量行创建新 model,仍然比全量快 2 倍左右。

image.png

4. 与现存的开源多级列表组件对比

目前存在的 RATreeViewTreeTableView 组件,均支持多级列表。对比图如下所示:

image.png

相比于其它两个组件,KKMultiLevelList拥有如下几个优势:

  1. 复杂数据变更时不用依赖调用方维护正确的父子index,复杂增量更新效率和稳定性一般不如KKMultiLevelList使用的IGListKit方案。
  2. KKMultiLevelList 先维护平铺数据,再交给 IGListKit 做 diff 更新;不是靠整表 reloadData,也不是业务层手算所有 row。它的接口就是围绕增量行为设计的:append、insert、delete、collapse 都是单独 API。
  3. KKMultiLevelList支持可见节点的展示。

5. 更新

5.1 2026-05-12

5.1.1 visibleChildrenCount由组件内部管理

前面提到业务方仅仅需要提供少量的字段,也即仅需符合协议MLListItemProtocol

    @protocol MLListItemProtocol <IGListDiffable>

    @property (nonatomic, nullable, strong) NSMutableArray<id<MLListItemProtocol>> *children;

    @property (nonatomic, assign) NSInteger totalChildrenCount;

    @property (nonatomic, assign) NSInteger visibleChildrenCount;

最新的定义为:

    @protocol MLListItemProtocol <IGListDiffable>

    @property (nonatomic, nullable, strong) NSMutableArray<id<MLListItemProtocol>> *children;

    @property (nonatomic, assign) NSInteger totalChildrenCount;

业务方不再管理visibleChildrenCount,其值由组件内部管理,主要考虑如下两点:

  1. visibleChildrenCount是UI状态相关,业务模型持有该属性。
  2. visibleChildrenCount的更改应由组件,而不是由业务决定,业务只需定义相关参数。

对于预期希望能够初始默认展示多少可见children,通过MLListFlattenParams的defaultVisibleChildrenCount和defaultVisibleChildrenCountProvider实现

/// Default number of visible children for newly seen nodes.

///

/// The default value is `0`. Set this to a positive value to use the same

/// initial count for every node.

@property (nonatomic, assign) NSInteger defaultVisibleChildrenCount;

/// Per-node initial visible child count.

///

/// When provided, this block takes precedence over `defaultVisibleChildrenCount`

/// and can return a different initial count for each node.

@property (nonatomic, nullable, copy) MLListDefaultVisibleChildrenCountProvider defaultVisibleChildrenCountProvider;
typedef NSInteger (^MLListDefaultVisibleChildrenCountProvider)(id<MLListItemProtocol> item,
                                                               NSInteger level,
                                                               id<MLListItemProtocol> _Nullable parentItem);

业务方可以控制这两个参数来控制children展示的数量。 可以参考如下代码:

MLListFlattenParams *params = [[MLListFlattenParams alloc] init];

    params.expandBatchCount = kDemoExpandItemsPerStep;

    params.defaultVisibleChildrenCountProvider = ^NSInteger(id<MLListItemProtocol> item,
                                                            __unused NSInteger level,
                                                            __unused id<MLListItemProtocol> parentItem) {

        MLDemoListItem *demoItem = (MLDemoListItem *)item;

        if (![demoItem isKindOfClass:MLDemoListItem.class]) {
            return 0;
        }
        return demoItem.initialVisibleChildrenCount;
    };

5.1.2 model状态的存储与恢复

在组件内部,新建了MLListFlattenedItemModel对业务方提供的数据封装。但是由于业务方是不存在visibleChildrenCount和state等属性字段,也就是意味着,内部使用的字段,一旦操作performUpdate等方法,所有现存的状态都不能够恢复。所以需要一种机制能够将当前model存储起来,当用户新建model时,能够恢复model的状态,示意图可如下图所示:

image.png

5.1.3 stale model的解决

stale model 的根因是:MLFlattenedItemModel 不是状态源,它只是某一次 flatten 后生成的 UI 快照。

所以只要发生这些操作:

  • reload / setRootItems
  • 展开更多 折叠 插入 删除 替换
  • footer / cell 快照

当前 visibleItems 里的 MLFlattenedItemModel 就可能已经换成了新对象。但旧的 model 仍可能被这些地方持有:

  • cell section controller
  • 异步 block 业务回调
  • 点击事件传进来的参数

这时旧 model 就是 stale model。

如果直接拿旧 model 操作,会有几个问题:

  1. 旧 model 已经不在 visibleItems 里,找 index 会失败
  2. 旧 model.parent / itemState / level 可能已经过期
  3. 如果 rootItems 被新对象替换,但 diffIdentifier 一样,旧 model 还指向旧业务对象
  4. displayStatus 回调可能 reload 一个已经不在列表里的 model

改进核心是:外部传进来的 model 一律先映射成当前 visibleItems 里的最新 model。

主要改动思路是:新建 visibleModelByKey 和 visibleIndexByKey 两个map做映射。

visibleModelByKey[type + diffIdentifier] -> current MLFlattenedItemModel 
visibleIndexByKey[type + diffIdentifier] -> current index

也就是不再依赖旧 model 的对象地址,而是用稳定的:

diffIdentifier + type

找到当前列表里真正有效的 model。

现在这些路径都会处理 stale model:

append 展开更多 collapse 折叠 delete 删除 insert 到 parent displayStatus 变化 reload

比如用户拿着旧 footer model 调用展开时,内部会先做:

model = [self visibleModelMatchingModel:model];

如果能找到,就用当前 model 继续展开;如果找不到,说明这个节点已经不在可见列表里,就直接忽略,避免错误更新。

另外还做了两个保护:

  1. 替换或删除旧 model 时,把旧 model 的 displayStatusDidChangeHandler 清掉
  2. displayStatus 回调触发 reload 时,也先映射到当前 visible model

这样即使 cell 或延迟 block 还持有旧 model,它也不会继续驱动 UI 更新。

总结一下就是:

问题:外部可能拿旧 MLFlattenedItemModel 操作 改进:用 diffIdentifier + type 映射到当前 visibleItems 中的新 model 收益:避免旧快照污染当前数据,展开/折叠/删除/reload 都更稳定。

5.1.3 visibleModelByKey 和 visibleIndexByKey 的更新方式

对于上述提到的 visibleModelByKey 和 visibleIndexByKey 的更新有两种方式增量和全量,在不同的场景下需要不同的更新策略,主要考量map的操作次数。更新函数签名如下:

- (void)commitVisibleItems:(NSArray<MLFlattenedItemModel *> *)visibleItems
     replacingVisibleRange:(NSRange)range
                 withItems:(NSArray<MLFlattenedItemModel *> *)replacementItems

它的核心作用是:当 visible 列表已经被插入、删除、替换后,统一更新:

_visibleItems visibleIndexByKey visibleModelByKey

三个参数含义:

  • visibleItems 新的完整 visibleItems
  • range 旧 visibleItems 中被替换/删除的位置范围
  • replacementItems 新插入或替换进去的 flattened models`

比如:

插入 1 个: range = {index, 0} replacementItems.count = 1

删除 1 个: range = {index, 1} replacementItems.count = 0

替换 1 个: range = {index, 1} replacementItems.count = 1

折叠删除一批: range = {childrenStart, childrenCount} replacementItems.count = 0

它里面做两件事:

  1. 先判断这次是走增量维护,还是全量 rebuild。
NSUInteger changedCount = range.length + replacementItems.count; 
NSUInteger indexShiftCount = delta == 0 ? 0 : suffixCount; 
NSUInteger incrementalWork = indexShiftCount + changedCount * 2; 
NSUInteger rebuildWork = visibleItems.count * 2; 
BOOL shouldRebuildLookupTables = incrementalWork >= rebuildWork;

2.根据结果更新 map。

如果走 rebuild:

_visibleItems = [visibleItems copy]; [self rebuildVisibleItemLookupTables];

如果走增量:

  1. 从两张 map 里移除旧 range 中的 model removeLookupEntryForModel
  2. 如果插入/删除导致后续 index 变化,平移后缀 index shiftLookupIndexesStartingAtIndex
  3. 把 replacementItems 加进两张 map addLookupEntryForModel
  4. 最后提交新的 _visibleItems _visibleItems = [visibleItems copy];

从实验结果来看增量更新和全量更新对比,采用这种比较操作次数对比工作量的方式是可取的。具体后续如果有更优方案再来做比较。

6. 总结

KKMultiLevelList 更适合做“复杂业务列表里的多级数据展开与 diff 更新底座,具体使用例子和代码见:KKMultiLevelList