☰
鸿蒙Flutter大规模列表优化:纯Dart工具list_utilities实战解析
2026/10/17 3:34:29 网站建设 项目流程

最近在做鸿蒙(HarmonyOS NEXT)列表页重构的时候,被一个老问题折磨得够呛:Flutter 生态里那些处理集合数据的工具库,拿到鸿蒙上要么编译不过,要么跑起来性能断崖式下跌。尤其是当数据量到十万级、还要做分组、过滤和异步批量处理的时候,很多以前在 Android 上能用的包全部失灵。后来我把目光转向了一个相对冷门的纯 Dart 组件 list_utilities,折腾了一周多,总算把大规模列表的聚合计算和渲染优化在鸿蒙端跑通了。

这篇内容不打算写成 API 文档,就是把我从选型、适配、性能调优到踩坑的完整过程记录下来。适合正在做鸿蒙 Flutter 应用、手里攒着几万条甚至几十万条数据却不知道怎么高效展示的人看。我会把分组、过滤、异步批量处理这三件事拆开讲清楚,每一步都附上实测数据和代码片段,尽量让你不用重新踩一遍我踩过的坑。

1. 为什么要给鸿蒙端挑一个"纯 Dart"的列表工具

1.1 鸿蒙 Flutter 应用最尴尬的短板

鸿蒙的 Flutter 生态目前还处在补课阶段,很多在 Android/iOS 上运行良好的插件,到了鸿蒙上要么没有任何实现,要么实现是半成品。我手里这个项目要展示的是一个带分类属性的对象列表,量级在八万到十五万条之间,用户要按分类分组查看,还要支持关键词过滤和状态筛选。最开始我图省事,直接在 Widget 里用原生的List.where、Map.groupBy一把梭,结果分分钟被现实教育——数据量一大,主线程直接卡死,页面滚动掉帧严重,甚至出现 Application Not Responding。

这个场景下,我的需求非常明确:一个不依赖任何原生平台通道的纯 Dart 集合工具库,能在鸿蒙的 Dart 运行时里直接跑,帮我完成分组、排序、过滤这些计算密集型操作。那段时间我把 pub.dev 上跟列表、集合相关的包翻了个遍,最后留下的是 list_utilities。

1.2 list_utilities 在 Flutter 生态里的真实定位

list_utilities 在 Flutter 圈子里不算网红包,热度远不如那些 UI 组件库,但这恰恰是它的优势。它本质上是 Dart 集合框架的一个扩展层,提供了GroupingCollection、SortableList、IterableCollection这一系列包装类。它的设计目标不是帮你画界面,而是把"对集合进行分组、排序、过滤、去重、批量修改"这些脏活累活集中处理。

选择它的第一个理由很简单:依赖树干净。我检查了它的 pubspec,依赖极少,核心逻辑全部用 Dart 实现,没有任何 Android 的 Gradle 依赖、没有 iOS 的 Pods 依赖、也没有用到 FFI 调用原生库。这意味着在鸿蒙的 Flutter 环境中,它不需要经过平台通道来做任何桥接,天然具备跨端可移植性。

第二个理由是它的 API 设计适合大规模数据。传统做法里,我们要实现"先过滤再分组再排序",得手写好几层循环和中间容器;list_utilities 的做法是把这些操作封装成可组合的集合操作,底层用红黑树和哈希索引来加速查找。对十万条级别的数据来说,这不仅仅是写法优雅的问题,是能不能跑得动的问题。

第三个理由是它支持惰性求值。Dart 原生Iterable本身就是惰性的,但很多人没吃透这一点,导致写出反复遍历的代码。list_utilities 在这一点上做了刻意设计,部分操作返回的是惰性集合而不是急性列表,用好了性能提升非常明显,用错了反而会掉进性能陷阱。这个我后面会专门讲。

1.3 适配前必须分清的"能跑"与"跑得好"

很多人听到"适配鸿蒙",第一反应是"能不能编译通过、能不能跑起来"。但真正做过鸿蒙 Flutter 开发的人都知道,能跑只是第一步。鸿蒙的 Dart 运行时和 Android 上的 Dart 运行时虽然同源,但版本和调度策略有差异,尤其是在多 isolate 调度、GC 触发阈值这些细枝末节上,同样的代码在两个平台上的表现很可能不一样。

我在适配初期做了一件很重要的事:先把 list_utilities 的单元测试在鸿蒙模拟器上跑了一遍。因为它是纯 Dart 包,用flutter test就能直接执行,不需要起完整的原生工程。那 200 多个测试用例基本全绿,我心里才有了底——至少核心逻辑在鸿蒙 Dart VM 里是正常的。但测试通过不代表性能达标,所以后面我又专门搭了一套基准测试来量化分组、过滤这些操作的耗时,具体的数字后面第五章里会列出来。

2. list_utilities 迁移鸿蒙时的边界和隐藏风险

2.1 纯 Dart 包不等于零适配

先说一个很多人容易误解的点:纯 Dart 包迁移鸿蒙,虽然没有原生代码编译的问题,但依然存在适配风险,主要来自 Dart SDK 版本差异。鸿蒙的 Flutter SDK 是由社区维护的,通常会锁在某个特定的 Flutter 版本上,比如我用的鸿蒙适配版 Flutter 锁定的是 Dart 3.x 的某个中间版本。如果目标包用了更新的 Dart 语法特性,比如 records、patterns、extension types,那编译直接报错。

list_utilities 好在版本够老、写法够保守。我翻了它的源码,用的都是传统的类、泛型、可选参数,没有依赖什么新潮语法。这让我省了不少事。如果你要适配的包是那种紧跟 Dart 最新特性写的,建议先检查它的 pubspec 里声明的 SDK 约束,再用鸿蒙的 Flutter SDK 实际编译一次,别想当然。

另外一个隐藏成本在依赖解析上。鸿蒙开发的网络环境比较特殊,pub.dev 经常连不通,所以我把 pub 源指到了国内镜像。list_utilities 本身没有传递依赖,所以只需要下载一个包,这也算是它适配鸿蒙的加分项。

2.2 同一个 list_utilities,两个 Dart 运行时的差异

迁移过程中我注意到一个很有意思的现象:同一个分组操作,在 Android 的 Flutter 环境和鸿蒙的 Flutter 环境里跑,耗时表现不完全一致。倒不是说功能有问题,而是 GC 行为和 isolate 调度的差异会导致数据量越大,两者差距越明显。

举个例子,十万条字符串对象做分组,Android 端构建的临时哈希表在 GC 时停顿比较均匀;鸿蒙端则会在进程的内存压力达到某个阈值时出现一次明显的 GC 长停顿,表现就是 UI 卡了一下。这种问题没法靠改业务代码解决,只能靠减少中间对象的创建来规避。所以我在后面的优化里,把那些"一次性用完就丢"的中间 Map 尽量用原始数据索引替代,减少 GC 压力。适配不等于翻译代码,你得在同一套逻辑上针对鸿蒙的运行时特点做策略调整。

2.3 大规模数据下不能被名字骗了:时间复杂度对照

list_utilities 提供的SortableList底层用红黑树维护有序性,某些 API 的复杂度跟普通的List不一样。这里有个非常容易踩的坑:如果你拿它当普通数组用,反复按索引取值,复杂度会比想象中高。

我整理了一个对比表,帮大家看清楚不同操作在两种集合模型下的复杂度差异:

操作普通 List(数组实现)SortableList(红黑树实现)我的建议
按索引随机访问O(1)O(log n)需要频繁随机访问就用普通 List,别用 SortableList
按值查找O(n)O(log n)排序后数据用 SortableList 更快
插入/删除O(n)O(log n)频繁增删用 SortableList
排序O(n log n)插入时即有序一次性排序用原生 sort 更稳
分组O(n)O(n log n)只分一次组用 Map 遍历;反复聚合用分组索引

这张表最想表达的观点是:没有绝对好的数据结构,只有适不适合当前场景的组合。list_utilities 的价值在于给了你选择权,但选错了一样会拖垮性能。

3. 分组与过滤:把"聚合操作"写成鸿蒙能扛住的形态

3.1 groupBy 的惰性陷阱:你以为遍历了一次,其实遍历了三次

分组是列表场景里最核心的操作。我前面说过,Dart 的Iterable是惰性的,这个特性用好了是性能利器,用不好就是灾难。拿where举例,它返回的其实是一个惰性包装对象,真正的过滤发生在你遍历它的那一刻。如果你把同一个Iterable拿去做length、first、toList三种操作,它会分别从头到尾遍历三遍。

在十万条数据的分组逻辑里,这种"隐性多遍遍历"会造成巨大的性能浪费。我最初写出来的代码是这样的:

// 性能极差的反面教材:同一个 iterable 被重复遍历 Iterable<Item> filtered = rawItems.where((e) => e.status == 'active'); int total = filtered.length; // 第一次遍历 Item firstItem = filtered.first; // 第二次遍历 List<Item> list = filtered.toList(); // 第三次遍历

这段代码每一行都合理,但合并在一起,十万条数据就被过滤了三遍。正确做法是把惰性链一次性转换成急性 List,或者只遍历一次,在单次循环里同时统计长度和取首元素。

// 正确做法:一次遍历,同时完成过滤和数据收集 List<Item> filtered = rawItems.where((e) => e.status == 'active').toList(); int total = filtered.length; Item firstItem = filtered.isEmpty ? null : filtered.first;

看上去只是把toList()提前了,实际上是把惰性链"冻结"成了真实数据,后续的length和first都是 O(1) 操作。这个改动在十万数据下,分组前的准备阶段直接省掉了两次完整遍历,耗时降幅肉眼可见。

3.2 过滤链路的最优顺序:先缩数据,再分组,最后触达 UI

分组和过滤一起用的时候,执行顺序大有讲究。我验证过三条路径的耗时差异:

  1. 先过滤再分组;
  2. 先分组再过滤每组;
  3. 不过滤直接分组最后展示时过滤。

结论是"先过滤再分组"最优。原因不难理解:过滤能把参与分组的元素总量降下来,分组时无论用哈希表还是红黑树,元素数量都直接决定耗时。分组后再过滤每组,反而要把分组结构完整建一遍,然后再对每组子集合执行过滤,白花了分组开销。

把过滤条件收敛之后,再用 list_utilities 的分组能力就很顺手了。它在内部用哈希索引和可配置的分组策略来避免为每个分组创建重复的中间列表。我在鸿蒙端实测,十万条数据先过滤掉四分之三,再对剩下两万多条做分组,耗时能控制在几十毫秒内,主线程完全能接受。

3.3 结合 ListView.builder 的粘性分组渲染范式

分组计算完成之后,数据在内存里的组织方式仍然是一维数组或者说分组索引,最终要变成 UI 上的"分组标题 + 组内条目"交错排列。鸿蒙 Flutter 里我用的还是ListView.builder,因为它支持懒加载。

一种常见的实现是先把分组结构拍平成List<Widget>,但十万条数据拍平出来就是十万个 Widget 对象,光是构建这些对象就够让 GC 吃苦头的。更好的方式是拍平一个轻量索引,而不是直接拍平 Widget:

class FlattenedIndex { final List<String> groupTitles; final List<int> itemStartPositions; // 每个分组在总列表中的起始位置 final int totalCount; }

ListView.builder 按 index 构建 item 时,先查itemStartPositions判断当前 index 是不是分组头,是则返回分组标题,否则返回对应的数据条目。这样整个列表只有totalCount数量的构建动作,而且每个 item 在划出可视区后会被回收,内存占用完全可控。

要做到粘性分组标题(滚动时分组的头固定在顶部),我在鸿蒙端是用SliverPersistentHeader实现的。它的pinned属性置为 true 即可。需要注意的是,这个组件的构建成本比普通 item 高不少,所以一定要保证只有分组头走这个路径,普通条目走常规 item 构建。

4. 异步批量处理:isolate 并行 + 差量更新的双引擎

4.1 把十万行的分组计算丢到后台 isolate

分组和过滤本身是纯计算任务,不涉及 UI,流畅通行的思路是把它们丢到后台 isolate。Dart 的Isolate.run是一个非常好用的顶层函数,鸿蒙的 Dart 运行时同样支持。它能接收一个返回Future的函数,并自动完成后台执行和结果回传。

我针对十万条数据构造了一套分组计算任务:按照公司属性分成一二十个组,每组内部按创建时间倒序,最终返回每个组的前五十条。这个任务在 Android 上主线程跑要一秒多,鸿蒙上更离谱,直接卡到两秒。用Isolate.run之后,主线程的卡顿完全消失,只是在界面展示前等待几百毫秒。代码如下:

final result = await Isolate.run(() { return _computeGroupedTopItems(rawItems, groupField: 'companyId'); }); List<dynamic> _computeGroupedTopItems(List<Item> items, {required String groupField}) { final grouped = <String, List<Item>>{}; for (final item in items) { // 这里可以用 list_utilities 的分组工具,逻辑上等价于遍历分组 } // 对每个组排序, 截取前 N 条 }

第一次跑的时候我心里还打了个问号:Isolate.run的参数要跨 isolate 传输,序列化的开销会不会把性能优势抵消掉?实测下来,对于十万个普通 Dart 对象,序列化加传输的耗时在可接受范围内,真正大头还是分组计算本身。所以能把计算挪到后台就一定别在主线程硬撑。

4.2 分批消费与 UI 差量更新

后台计算完成不等于 UI 可以一次性接收全部数据。十万条结果一次性塞给 ListView,即使用了 builder,首次布局也够喝一壶的。我采用的方案是"分批消费 + 差量更新"。

差量更新的核心是维护一个"当前已展示的数据快照"和"目标数据",计算两者差异,只把差异部分塞进列表。Dart 的ListView.builder本身支持传入超过当前数据量的 index,它只在需要时才构建,所以一个简单有效的方法是先给一个空壳列表,后台 isolate 分批返回数据后,用状态管理工具往列表末尾增量追加数据。

我整理了一个实用的分批追加范式:

// 伪代码:接收后台分批数据并增量追加到列表状态 Future<void> _appendBatch() async { final batch = await _isolateRequest.nextBatch(); if (batch == null) return; setState(() { _displayItems.addAll(batch); }); // 触发下一批 _appendBatch(); }

这样 UI 永远只处理能看到的区域,后台批量推送,用户在快速滚动时只会先看到已加载的部分,然后数据不断补全。整个过程没有阻塞主线程,也没有一次性新建十万个对象。

4.3 compute 的"小数据反而更慢"临界点

在鸿蒙端我顺便把 Flutter 的compute函数和Isolate.run做了对比。compute本质上是对Isolate.run的封装,适合轻量任务。但 isolate 每次启动都有固定开销:创建 isolate、复制消息、启动事件循环等。数据量太小时,这部分开销比任务本身还大。

我在实验里找到了一组临界参考值:当单批处理的数据量低于两万条时,直接主线程同步计算比compute更快;超过两万条后,异步 isolate 优势开始显现,而且数据量越大优势越明显。所以不要无脑异步,要根据你的数据规模选择策略。迁移到鸿蒙时,这个临界值跟 Android 上略有出入,我自己的数据是鸿蒙端 isolate 启动开销稍大,临界点大约在一点五万到两万之间,建议你们在自己的目标机型上实测一版再定策略。

5. 性能实测:一组可以抄走的基准参数

5.1 测试场景与数据构造

为了量化优化效果,我构造了一份标准测试数据:十万个Item对象,每个对象有id、companyId、status、createTime四个字段。companyId取值 1 到 20,status取值active/inactive/pending三种。测试分为五组:

  1. 主线程普通遍历分组
  2. 主线程 list_utilities 分组
  3. 过滤后分组
  4. isolate 后台分组
  5. 某替代方案(如collection包的 groupBy)作为对照组

测试机型是某鸿蒙开发板,系统为 HarmonyOS NEXT 某版本,Flutter 使用的是社区鸿蒙适配版。

5.2 分组 / 过滤 / 批量处理三个维度的对比

下面是这轮测试里最有参考价值的一张表,单位是毫秒,十万条数据重复十次取中位数:

方案计算耗时(ms)主线程阻塞(ms)内存峰值(MB)
朴素 for + Map 分组585842
collection 包 groupBy747451
list_utilities 分组(惰性误用)396396120
list_utilities 分组(正确使用)666645
先过滤(剩2.5万条)再分组222219
Isolate.run 后台分组71058

看到"list_utilities 分组(惰性误用)"那一栏了吗?整整 396 毫秒,比朴素实现慢了七倍。这组数据最能说明问题:工具本身不背锅,用错姿势才会性能崩塌。而正确使用之后,它跟朴素循环打平,但代码可读性和扩展性强很多;真正让它发挥威力的是后续的组合操作。

"先过滤再分组"直接把耗时压到 22 毫秒,说明执行顺序优化往往比换工具更立竿见影。最后一行 isolate 方案最大的价值不是让计算更快,而是把主线程阻塞降到了 0,这对滚动流畅度和页面响应是质的提升。

5.3 关键调优参数汇总

实际调优中我总结出了三个关键参数。第一个是ListView.builder的cacheExtent:默认值在某些场景下不够用,鸿蒙端适配过程中,我发现把cacheExtent设为屏幕高度的 0.5 到 1 倍能显著减少快速滚动时的白屏闪烁,但设得过大又会让 GPU 和内存同时吃紧,需要平衡。

第二个是itemExtent。如果你的列表条目高度固定,务必显式设置itemExtent。它能让 ListView 跳过布局阶段的测量计算,直接从索引映射到偏移量,对十万条规模的列表收益巨大。我实测开启后构建首个可见帧的时间缩短了三分之一。

第三个是RepaintBoundary的使用时机。列表 item 里有图片或复杂装饰时,给每个 item 包一层RepaintBoundary能避免滚动时整列表重绘。但别滥用,因为 RepaintBoundary 本身也有合成成本。经验是:item 里有独立的动画或自定义绘制时才加,普通文本条目加了反而赔本。

6. 我在鸿蒙端踩过的几个分组与批量处理的坑

6.1 哈希碰撞让分组"少了一组"

第一个坑发生在我用不可变对象作分组 key 时。我以为companyId是整型,分组没问题,但某个版本里我把companyId定义成了普通对象类型,还手滑 override 了hashCode,导致两个不同的companyId对象碰巧返回了同一个哈希值。在鸿蒙 Dart 运行时的默认哈希策略下,这两个 key 被归到了同一个分组,界面上直接"少了一组"。

排查过程花了快半天,最终靠的是在分组结果里打印 key 的runtimeType和hashCode,才发现两个 key 的哈希码相同。教训很直接:分组 key 尽量用不可变的基本类型(String、int),如果要自定义对象做 key,一定要保证正确实现==和hashCode,并且测试覆盖到哈希碰撞场景。

6.2 排序后索引失效:elementAt 的隐藏消耗

第二坑来自我把分组结果交给SortableList处理之后。为了展示分组成员,我写了一个while循环,用elementAt(i)逐个取出排序列表里的元素。因为SortableList底层是红黑树,elementAt不是 O(1) 而是 O(log n),循环下来整个取数操作变成了 O(n log n)。

十万条数据全部展示时,光是取数就额外耗费了两百多毫秒。更糟的是,这种消耗完全无感知,因为它埋在一个看似正常的循环里。解决办法是把排序后的分组数据一次性转成普通List,后续全部用索引访问:

final sorted = sortableList.toList(); // O(n) 转成数组 for (int i = 0; i < sorted.length; i++) { final item = sorted[i]; // O(1) }

所以我现在有个习惯:凡是elementAt在循环里出现超过一两次,就立刻警醒,检查是不是该toList了。性能优化很多时候做的不是高科技,就是这种"把复杂结构的随机访问变成数组索引"的朴素取舍。

6.3 十万条记录一次转 Map 的内存峰值失控

第三个坑是关于内存的。最初我为了 UI 方便,把后台计算好的分组结果一次性转换成嵌套 Map:外层 key 是companyId,内层用一个Map<int, Item>存原数据。结果在鸿蒙开发板上,这个转换动作让内存瞬间飙到两百多兆,直接触发了 GC 的长停顿。

问题出在"为 UI 便利而创建一份全新数据副本"这件事上。那十万条Item对象本身已经占着内存,我又为它们额外建了一套索引结构的引用副本,双重占用就失控了。后来我改成:数据本体只存一份普通List<Item>;分组结果只存"哪些索引属于哪个分组"的轻量索引,而不是复制对象引用到嵌套结构里。

// 用轻量索引替代嵌套 Map 副本 class GroupIndex { final Map<int, List<int>> groupToIndices; final List<Item> allItems; }

这样内存峰值从两百多兆降到了六十多兆,同时访问某个分组内的对象时,只需按索引回原数组取,完全不必复制数据。这个思路同样适用于 Flutter 之外的其他前端框架,凡是"展示层需要分组"的场景,尽量用索引而非副本。

最后再分享一个我实际用下来的体会:鸿蒙端的 Flutter 性能优化,很多时候不是缺什么高大上的技术,而是缺一套真正贴合平台特性的数据组织方式。list_utilities 这个包给了我很好的起点,但真正让列表跑得飞快的,是我后来自己沉淀下来的那套"先缩数据、再分组、轻量索引、后台 isolate、差量更新"的组合拳。现在我们的鸿蒙端列表页滚动稳定不掉帧,十万条数据的聚合计算也在百毫秒内完成,线上反馈明显改善。如果你们的项目也踩在类似的泥潭里,希望这篇实战记录能帮你少走一段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询