为什么我会专门写一篇关于 OpenHarmony 上 Flutter 列表的文章?因为前几天有个朋友跑来问我,说他用 Flutter 给 OpenHarmony 设备写了一个聊天界面,结果列表一长就卡得不行,掉帧、内存暴涨,一行代码没改,换到 Android 上却流畅得很。我让他把ListView换成ListView.builder试试,他半信半疑地改了之后,效果立竿见影。这件事给了我一个很深的感触:在 OpenHarmony 生态里,大量刚上手 Flutter 的开发者其实已经被各种环境配置、SDK 适配问题消磨了大量精力,真正到了写业务代码的时候,反而容易忽略像“构建器模式”这样基础却又生死攸关的性能细节。
这篇内容就是想把这些年我在 Flutter 跨端开发,尤其是适配 OpenHarmony 时积累的 ListView.builder 实战经验完整掏出来。不管你是刚搭好环境的新手,还是已经在鸿蒙设备上跑了几个页面的老手,只要你的项目里涉及长列表,这篇文章都值得你花十分钟读完。
OpenHarmony 对 Flutter 的支持已经走过了从“能跑”到“跑得好”的阶段,列表性能恰好是检验适配质量的试金石。我从最核心的原理讲起,再到手把手写代码、做优化、踩坑排查,一条龙讲透。保证你看完能直接在自己的项目里用起来,并且知道为什么这么写能快。
1. 内容整体设计与思路拆解
列表这个东西,看起来简单,其实就是ListView一包,itemBuilder一写,数据一塞,完事了。可真要处理几百上千条数据的时候,用错了构造方式,性能和体验完全是两个世界。
1.1 为什么 OpenHarmony 上必须重视构建器模式
先抛一个很多人忽视的事实:OpenHarmony 的 Flutter 运行时本质上依然走的是 Flutter 引擎做 UI 渲染,但它底下对接的是 OpenHarmony 的图形栈和系统能力。这意味着 Flutter 应用在 OpenHarmony 设备上的表现,一方面取决于 Flutter 引擎本身的调度,另一方面也取决于设备硬件和系统图形栈的适配程度。
在这样一条链路上,ListView.builder的“懒加载”特性就变得格外重要。它不会一次性把所有的 item Widget 都构建出来,而是只构建当前视口(Viewport)里能看到的那些元素,滚动出去的元素会被销毁或复用。对比一下ListView(children: [...])这种直接传列表的写法,它会在页面加载的一瞬间,把千条数据的 Widget 全部 build 一遍,哪怕这些 Widget 根本不在屏幕上。
我实测过一个场景:同样 500 条带图片的卡片数据,在 OpenHarmony 的 RK3568 开发板上跑,用构造函数直接传 children 的写法,首帧构建时间大约是 800ms 左右,而且在滚动时因为内存里堆积了大量不可见 Widget,GC 一触发就开始掉帧。换成ListView.builder之后,首帧构建时间直接掉到 300ms 以内,滚动也变得丝滑。
所以第一节课很简单:只要列表数据量可能超过一屏,无脑用ListView.builder。这不是什么花活,而是 Flutter 官方推荐的标准姿势,到了 OpenHarmony 上,更是必须的保命姿势。
1.2 ListView.builder 的适用边界和选择思路
当然,也不是说所有列表都得用ListView.builder,这个我们要心里有数。如果你确定列表只有三五个固定条目,而且永远不增长,那用ListView(children: [...])反而更直观。但当你有以下任何一条需求时,ListView.builder就是你的首选:
- 数据来自网络接口,数量不可控。
- 列表支持下拉刷新、上拉加载更多。
- 每一条 item 的布局结构相同,只是数据不同。
- 列表中的数据量可能超过 50 条。
这个选择思路背后其实是对内存和构建成本的权衡。在 OpenHarmony 这种多设备形态的生态里,你可能要跑在手机上,也可能跑在开发板上,甚至可能跑在带触屏的冰箱屏幕上,硬件差异极大。使用构建器模式,等于给你的列表加了一道保险,让它在低配设备上也不至于直接崩掉。
2. 核心细节解析与实操要点
讲完了大方向,我们进入正题,把ListView.builder的各种参数和行为彻底吃透。很多人用 builder 只会写itemCount和itemBuilder,但其实它还有不少关键参数,每个参数的取舍都会直接影响 OpenHarmony 端的体验。
2.1 核心参数逐个拆解:itemCount、itemBuilder 与 index 的底层逻辑
ListView.builder的核心是这两个参数:
ListView.builder( itemCount: items.length, itemBuilder: (BuildContext context, int index) { return ListTile( title: Text(items[index].title), ); }, )itemCount告诉 Flutter 一共有多少条数据,这个值必须和你真实的数据源长度一致。如果你加载了更多数据但忘了更新itemCount,列表不会自动多出来。如果你随口报一个大数字,超出数据源的范围,itemBuilder里就越界崩溃。这一点在动态加载数据的场景里特别容易出问题,我见过不少人下拉加载更多后崩溃,排查半天发现是itemCount没同步。
itemBuilder只在 item 即将出现在屏幕上时才会被调用。它的index参数是当前 item 在列表中的逻辑位置。注意这里有个细节:如果列表很长,用户快速滑动,同一时间可能有多个 builder 被并发调用。所以 builder 内部不要做耗时操作,不要在这里发网络请求,不要在这里做复杂的数据库查询。
还有一点很多人会忽略:在 OpenHarmony 上,Flutter 的itemBuilder是被频繁调用的,如果你在里面写了print日志,那滚动的时候日志会刷屏,直接拖慢 UI 线程。我见过有人把调试日志带到生产环境,在 OpenHarmony 低端设备上滚动卡成 PPT。这一点切记。
2.2 itemExtent、prototypeItem 和 addAutomaticKeepAlives 的隐藏作用
除了那两个必传参数,ListView.builder还有几个参数,用好了能显著提升滑动流畅度。
itemExtent表示每个 item 的固定高度。如果你的列表所有 item 高度都一样,强烈建议设置这个值。
ListView.builder( itemCount: items.length, itemExtent: 80, itemBuilder: (context, index) { return Container( height: 80, alignment: Alignment.centerLeft, child: Text(items[index].title), ); }, )为什么这个参数重要?因为当 Flutter 不知道 item 的确切高度时,它必须等 item 真正构建出来才能计算出滚动范围(Scrollable 的 extent)。而设置了itemExtent之后,Flutter 可以直接用固定高度来预估整个列表的长度,省掉了大量 item 的尺寸计算和布局工作。在 OpenHarmony 设备上,尤其是 GPU 性能不强的开发板上,这种省出来的布局时间会直接转化为更少的掉帧。
如果你的 item 高度虽然不一致,但整体结构类似,某个典型 item 可以作为基准,可以用prototypeItem参数:
ListView.builder( itemCount: items.length, prototypeItem: ListTile(title: Text('原型')), itemBuilder: (context, index) { return ListTile( title: Text(items[index].title), ); }, )prototypeItem不会显示出来,它只是用来给 Flutter 提供一个高度/尺寸的估计算法参考。这个值能显著减少滚动过程中的频繁测量。
另外,addAutomaticKeepAlives参数默认是 true,它会在 item 滚出屏幕后保留一份状态。如果你的 item 没有需要保持的滚动位置或者表单输入状态,可以显式设置成 false:
ListView.builder( itemCount: items.length, addAutomaticKeepAlives: false, itemBuilder: (context, index) { return MyItem(data: items[index]); }, )这样能让滚出屏幕的 item 更快地被垃圾回收,减少 OpenHarmony 端的内存压力。不过要注意,如果你的 item 里有视频播放或者输入框,需要保持状态,就别关这个。
2.3 item 内部布局的“轻量化”原则
在 OpenHarmony 设备上跑 Flutter,最大的敌人往往是 item 本身的构建成本。ListView.builder虽然帮你解决了“构建不可见 item”的问题,但如果你每条 item 都嵌套了十几层 Container、Padding、Align,builder 再高效也扛不住。
轻量化的核心原则:
- 能用
Container的color参数实现背景色就不要嵌套一个ColoredBox或者再去包一层。 - 能用
Text的maxLines和overflow实现截断就不要套Expanded去算高度。 - 图片用
cacheWidth参数限制解码尺寸,不要在 OpenHarmony 上直接加载 4K 原图塞进列表。
我之前调过一个 OpenHarmony 平板上的资讯列表,每条 item 有一张大图。图片组件没做任何尺寸压缩,滑起来又卡又费内存。后来给图片设置了cacheWidth: 200,瞬间就顺畅了。这个技巧在 Android 和 iOS 上也很管用,但在 OpenHarmony 上效果尤其明显,因为它的图片解码链路还在持续优化中,过大的图片开销会直接被 UI 线程感知到。
2.4 列表组件的内存占用控制
说句实话,OpenHarmony 上 Flutter 的内存管理比标准 Linux 桌面环境要严格得多。开发板动不动就是 2G、4G 内存,列表一旦把缓存堆起来,系统就会开始杀后台进程。除了前文提到的addAutomaticKeepAlives,还可以设置cacheExtent来调整预加载区域的大小。
cacheExtent默认是 250 逻辑像素,意思是视口上下方各 250 像素范围内,item 会被提前构建出来。如果内存吃紧,可以把它调小:
ListView.builder( itemCount: items.length, cacheExtent: 100, itemBuilder: (context, index) { return MyItem(data: items[index]); }, )调整后,列表滚动时 item 的构建时机更晚,但内存占用会降下来。噪音是滑动到边缘时可能会出现短暂的白屏。在 OpenHarmony 上,我建议先保持默认,实测开发板内存不够了再调小。这个参数属于经验型的调优项,没有绝对标准,需要根据你的设备配置和列表复杂度来权衡。
3. 实操过程与核心环节实现
上面把原理和关键点说透了,下面直接带大家走一遍完整流程。这一章我用一个“应用列表页”作为实战案例,数据源模拟从网络接口获取的 1000 条应用记录。整个过程分四步走:环境准备、数据模型定义、列表页实现、滚动性能优化。
3.1 OpenHarmony 上的 Flutter 环境准备与工程初始化
先说明一下,OpenHarmony 的 Flutter SDK 目前和上游 Flutter 是分开维护的,需要用 OpenHarmony 官方或者社区维护的 flutter 分支。具体来说,你需要下载支持 OpenHarmony 的 Flutter SDK 版本,并且配置好对应的 OpenHarmony SDK。用命令行走一遍流程:
# 假设你已经把 OpenHarmony 版 Flutter SDK 解压到 ~/flutter_ohos export PATH=$PATH:~/flutter_ohos/bin # 检查版本 flutter --version # 创建新项目 flutter create --org com.example --project-name app_list ohos_app_list注意,创建项目时--project-name不能使用大写字母,否则后续构建会报错。进入项目目录后,你需要检查pubspec.yaml,确认依赖里包含 OpenHarmony 适配相关的插件。一般情况下,只要 fluttter SDK 分支正确,flutter build hap就能直接产出 OpenHarmony 的安装包。
cd ohos_app_list flutter build hap --release构建完成之后,在entry/build/outputs下就能找到.hap安装包。这一步如果卡住,多数情况是 OpenHarmony SDK 路径配置不对,或者是 JDK 版本对不上。老规矩,先把flutter doctor跑一遍。
3.2 准备数据模型和模拟数据源
先定义一个应用信息的数据类:
class AppInfo { final String name; final String category; final int size; final bool isSystem; AppInfo({ required this.name, required this.category, required this.size, required this.isSystem, }); }然后造一份模拟数据。这里我用了一个简单的循环来模拟 1000 条数据:
List<AppInfo> generateApps() { final categories = ['游戏', '办公', '教育', '娱乐', '工具']; return List.generate(1000, (index) { return AppInfo( name: '应用${index + 1}', category: categories[index % categories.length], size: 10 + (index % 100), isSystem: index % 10 == 0, ); }); }注意List.generate本身也是“懒生成”的兄弟,它只生成一个定长的列表,并不会造成多大的性能开销。真正的关键在下一步,我们不会直接把这个 1000 长度的列表塞进ListView(children: ...)里,而是交给ListView.builder来处理。
3.3 用 ListView.builder 实现列表页完整代码
下面是完整的页面代码。我特意把 item 拆成了独立的 Widget,这样每个 item 的构建逻辑更清晰,也方便后续做性能优化。
import 'package:flutter/material.dart'; class AppListPage extends StatelessWidget { AppListPage({super.key}); final List<AppInfo> apps = generateApps(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('应用列表'), ), body: ListView.builder( itemCount: apps.length, itemExtent: 72, addAutomaticKeepAlives: false, itemBuilder: (context, index) { final app = apps[index]; return _AppListItem(app: app); }, ), ); } } class _AppListItem extends StatelessWidget { final AppInfo app; const _AppListItem({required this.app}); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: app.isSystem ? const Color(0xFFE8F0FE) : const Color(0xFFFFFFFF), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Container( width: 40, height: 40, color: Colors.blueAccent, child: const Icon(Icons.android, color: Colors.white), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( app.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Text( '${app.category} · ${app.size}MB', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: Colors.grey), ), ], ), ), const SizedBox(width: 8), const Icon(Icons.chevron_right, color: Colors.grey), ], ), ); } }这段代码有几个细节值得解释一下。
为什么给每个 item 设置margin而不是让列表整体加padding?因为itemExtent是包含 margin 在内的总高度。如果你把 margin 设在列表外层 padding 上,item 高度统一为 72,但 item 和 item 之间没有视觉间隔,看起来会非常挤。用 padding 构造 item 内容,用 margin 制造间隔,这样既保证了视觉间距,也保持了 item 高度整齐。
为什么图标用Icons.android?纯粹是为了演示,你可以换成自己的 App 图标或者网络图片。如果要用网络图片,务必配合缓存和尺寸裁剪,这一点前面已经强调过了。
为什么用Expanded包裹文字列而不是给文字一个固定宽度?因为列表宽度是有限的,当 item 内有多个元素时,需要让文字区域灵活伸缩,右侧箭头图标固定位置。这是 Flutter 布局的标准姿势。
3.4 下拉刷新与上拉加载更多相结合
实际开发中不会只有一个静态列表,通常要配合下拉刷新和上拉加载更多。这两个功能中,上拉加载更多的存在,使得itemCount会动态变化,更容易踩坑。完整实现靠一个带状态管理的页面会比较清晰,这里用StatefulWidget演示:
class AppListPage extends StatefulWidget { const AppListPage({super.key}); @override State<AppListPage> createState() => _AppListPageState(); } class _AppListPageState extends State<AppListPage> { final List<AppInfo> _apps = []; int _page = 0; bool _isLoading = false; @override void initState() { super.initState(); _loadMore(); } Future<void> _loadMore() async { if (_isLoading) return; setState(() => _isLoading = true); // 模拟网络请求延迟 await Future.delayed(const Duration(milliseconds: 500)); final more = List.generate( 20, (index) => AppInfo( name: '应用${_page * 20 + index + 1}', category: '分类', size: index, isSystem: false, ), ); setState(() { _apps.addAll(more); _page++; _isLoading = false; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('应用列表')), body: RefreshIndicator( onRefresh: () async { setState(() { _apps.clear(); _page = 0; }); await _loadMore(); }, child: ListView.builder( itemCount: _apps.length + (_isLoading ? 1 : 0), itemBuilder: (context, index) { if (index >= _apps.length) { return const Center( child: Padding( padding: EdgeInsets.all(16), child: CircularProgressIndicator(), ), ); } return _AppListItem(app: _apps[index]); }, ), ), ); } }这里itemCount被设计成_apps.length + (_isLoading ? 1 : 0),多出来的那一个索引就是加载中的转圈指示器。这样就实现了“滚动到底部加载更多”的效果。不过优化一下,上面的版本里还没有真正的触发机制——你需要在列表滚动到底部时主动调用_loadMore(),最自然的做法是监听ScrollController。
3.5 滚动到底部自动加载更多
给ListView.builder挂上ScrollController,然后监听它的position.extentAfter。当这个值小于某个阈值时,说明离底部很近了,就触发下一页加载:
final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); _scrollController.addListener(() { if (_scrollController.position.extentAfter < 200) { _loadMore(); } }); } @override void dispose() { _scrollController.dispose(); super.dispose(); } // build 方法中 body: RefreshIndicator( onRefresh: () async { ... }, child: ListView.builder( controller: _scrollController, itemCount: _apps.length + (_isLoading ? 1 : 0), itemBuilder: (context, index) { ... }, ), ),extentAfter < 200的写法比position.pixels >= maxScrollExtent更提前触发,用户体验更顺滑,不会出现“必须怼到底才开始转圈”的顿挫感。在 OpenHarmony 上,提前预加载对网络慢的场景尤其重要,否则用户滑到底部的瞬间才看到转圈,会很焦虑。
3.6 列表项点击跳转的正确姿势
列表页十有八九要处理点击事件。这里需要注意一个易错点:最好不要在_AppListItem内部直接处理Navigator.push,因为那样会让 item Widget 和路由导航耦合在一起,复用性变差。推荐做法是在父级页面使用onTap回调传入:
ListView.builder( itemCount: apps.length, itemBuilder: (context, index) { final app = apps[index]; return _AppListItem( app: app, onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => AppDetailPage(appId: app.name), ), ); }, ); }, ); class _AppListItem extends StatelessWidget { final AppInfo app; final VoidCallback? onTap; const _AppListItem({required this.app, this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container(...), ); } }有人会问:GestureDetector包一层会不会影响性能?不会。它只是加了一个命中测试,开销极小。倒是如果你用了InkWell或者Material组件,要多思考它们是否引入了额外的层级和绘制。在 OpenHarmony 上,我倾向于用GestureDetector+ 普通Container的组合,省掉不必要的阴影和材质效果。
4. 常见问题与排查技巧实录
不管代码写得多么小心,实际跑在 OpenHarmony 设备上总会有一些意外。这一章我把自己踩过、也帮人排查过的典型问题整理成了一份速查表,后面再针对几个高频问题详细拆解。
4.1 列表滑动卡顿掉帧,先查这三件事
如果ListView.builder已经用了,但滑动还是卡顿,我的排查顺序非常固定:
第一,查itemBuilder里面有没有耗时的同步操作。比如有人喜欢在 builder 里做jsonDecode、字符串加密、甚至图片解码,这些操作一旦同步执行会直接卡住 UI 线程。
第二,查 item 的布局复杂度,可以用 Flutter 自带的性能调试工具来看。运行应用时,把debugProfileBuildsEnabled打开:
void main() { debugProfileBuildsEnabled = true; runApp(const MyApp()); }然后用 DevTools 的 timeline 抓取帧数据,看看单帧构建耗时最高的 widget 是哪个。大多数情况都会定位到某个图片组件或者某个层级过深的容器。
第三,查是否有不必要的setState把整个列表页重建了。在StatefulWidget中,如果你在 item 的点击回调里直接调用根页面的setState,整个ListView.builder的 state 都会重建,虽然 builder 仍然只构建可见区域,但所有可见 item 的 build 方法都会重新执行。更高效的做法是把“选中态”放在 item 自己的 StatefulWidget 内部,或者用ChangeNotifier只通知对应的 item 重建。
4.2 列表项高度变化,itemExtent 没更新的怪问题
有一次我在 OpenHarmony 上做展开收起的列表,item 的高度会在 80 和 160 之间切换。我按惯性加了itemExtent: 80,结果展开后内容被裁剪了。排查了一会儿才反应过来:itemExtent是“定值”,它不会自动跟随 item 高度变化。
解决方案有三种,我按推荐程度排序:
一是干脆不设置itemExtent,让 Flutter 根据实际高度自动布局。代价是滚动范围计算更频繁,长列表性能略降。
二是用itemExtentCallback根据 index 动态返回高度:
ListView.builder( itemCount: items.length, itemExtentCallback: (index, maxWidth) { return index % 2 == 0 ? 80 : 160; }, itemBuilder: (context, index) { ... }, )注意这个回调在滑动过程中会被频繁触发,所以不要做耗时计算。
三是把“展开态”的数据单独处理,用对应高度值返回。最简单但不够优雅,不过实测有效。
这个坑之所以值得拿出来说,是因为它比崩溃更难发现,表现只是“内容被切了”,用户会以为是你 UI 写错了。
4.3 下拉刷新拉不动或者刷新后白屏
OpenHarmony 上RefreshIndicator偶发“拉不动”的问题,多半不是代码 bug,而是列表内容不满一屏。RefreshIndicator要求它的 child 可滚动,而当 item 数量过少时,ListView默认的滚动范围可能不够触发 pull-to-refresh。
解法是给ListView.builder设置physics: const AlwaysScrollableScrollPhysics():
child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, itemCount: apps.length, itemBuilder: (context, index) { ... }, ),刷新后白屏的问题,通常是你调用了_apps.clear()然后在setState里同步把itemCount变成了 0,导致列表先变空,再等数据加载。如果网络慢,用户看到的就是一片白。这时可以在清除数据后立即构建一个 loading 占位状态,或者不清空旧数据,等新数据回来再整体替换。
4.4 OpenHarmony 构建产物与 Flutter 版本的小坑
在写博客做演示时,我老是会碰到flutter build hap报错,提示The current configured Flutter SDK is not known to be fully supported。这个报错表面上是版本不匹配,实际上就是 OpenHarmony 分支的 Flutter SDK 和你的pubspec.yaml里 Flutter 版本约束对不上。
复现路径通常是:你先用flutter create创建了一个基于 OpenHarmony 分支默认版本的项目,然后手动改了pubspec.yaml里的 environment,或者引入了一个依赖高版本 Flutter 的包。改回去,或者升级到对应的 OpenHarmony 分支新版本,就能解决。建议直接用 OpenHarmony 分支默认生成的pubspec.yaml,不要刻意填写sdk: '>=3.0.0 <4.0.0'这种自己不确定的约束。
4.5 列表里图片加载 OOM 的应急处理
OpenHarmony 开发板内存小,列表里图片一多,最容易触发 OOM。除了前面说的cacheWidth,还有一招比较狠:给Image组件设置gaplessPlayback: true,并配合frameBuilder来做图片加载过渡。这样至少在图片换帧、加载失败时不会引起整棵 widget 树的不必要重建。
如果是网络图片,还要检查图片服务端是否支持动态裁剪。很多 CG 资源服务器都带?x-oss-process=image/resize,w_200这类的处理参数,你只需在 URL 拼上尺寸,客户端拿到的就是缩略图。这比客户端加载原图再裁剪要省得多。
5. 进阶玩法与实践心得
基础讲完了,再聊几个进阶玩法。这些内容不是必须的,但掌握了它们,你的列表体验还能再上一个台阶。
5.1 使用 ListView.separated 给列表加分割线
ListView.separated是ListView.builder的近亲,它在 item 之间自动插入分割线组件。如果你的列表只是要一个简单的Divider,直接用这个:
ListView.separated( itemCount: apps.length, separatorBuilder: (context, index) { return const Divider(height: 1, color: Colors.grey); }, itemBuilder: (context, index) { return _AppListItem(app: apps[index]); }, )index在separatorBuilder里表示的是“第几条 item 之后的分割线”,从 0 开始。它的实现原理其实还是从 itemCount 推导出一个两倍左右的条目列表,内部依然是懒加载的,所以性能上和ListView.builder没有本质区别。唯一要注意的是,separatorBuilder也会被频繁调用,所以不要在里面放复杂组件。
5.2 配合 CustomScrollView 实现大标题与列表联动
如果页面上除了列表,还有头部区域、搜索栏、tab 切换等,建议直接用CustomScrollView+SliverList,而不是用ListView包Column。一旦用Column,你就会把头部和列表绑死在一起,列表的可滚动区域被压缩得很难受,且无法实现头部缩回、停留等联动效果。
CustomScrollView( slivers: [ const SliverAppBar( title: Text('应用列表'), floating: true, ), SliverList.builder( itemCount: apps.length, itemBuilder: (context, index) { return _AppListItem(app: apps[index]); }, ), ], )SliverList.builder和ListView.builder本质是同一个构建器模式,只是一个服务于普通Scrollable,一个服务于CustomScrollView。它们的懒加载原理完全一致。
5.3 列表复用与 Key 的正确使用
这是很多人忽略的终极细节。在ListView.builder中,每个 item 的 Widget 在屏幕上滚动时会被回收复用。如果 item 内部有状态,需要保证状态的正确匹配,那就必须给 item 设置key。没有key,Flutter 只认 index,一旦列表中间插入了数据或者删除了数据,item 的状态就会错乱。
itemBuilder: (context, index) { final app = apps[index]; return _AppListItem( key: ValueKey(app.name), app: app, ); }这里用ValueKey(app.name)而不是ValueKey(index),是因为 index 会随着列表增删而变化,但应用名是稳定的。删除中间某条数据时,下方的 item 会正确保留自己的滚动位置和内部状态。
OpenHarmony 的 Flutter 分支在 widget 复用逻辑上跟原生 Flutter 保持一致,但因为它跑在非标准平台上,偶发过一些 key 相关的小问题。我的经验是:只要 key 设计正确,这类问题出现的概率就极低。
5.4 数据更新时的三种刷新粒度
最后分享一个关于“数据更新后如何刷新列表”的细腻经验。在ListView.builder场景下,有三种刷新粒度:
整体刷新:直接setState(() { _apps = newList; })。最简单,但会重建所有可见 item。数据量小没问题,数据量大时滚动位置可能跳变。
部分刷新:使用ListView.builder配GlobalKey或者拿到 item 对应的 element 后调用markNeedsBuild。这个方法比较绕,而且容易触碰 Flutter 内部 API,不推荐在业务里用。
增量刷新:配合StatefulBuilder或者单一的ValueNotifier,只更新变化的那几行。这在复杂场景里是终极手段,但实现起来对代码组织要求较高。
在 OpenHarmony 上,我大多数项目用第一种就够了。如果遇到很在意滚动位置跳变的问题,可以给ListView.builder设置合适的itemExtent,让滚动位置的计算更稳定,减少跳变概率。
我个人在实际操作中的体会是,列表优化永远是一个“桶效应”问题。ListView.builder解决了构建成本,但如果你不设置itemExtent,或者不控制 item 内部的复杂度,或者不处理图片解码,任何一个短板都会让整条链路的体验崩掉。真正好用的优化顺序是:先用 builder 模式搞定懒加载,再让 item 布局足够轻量,最后再谈微调cacheExtent和 key。按这个顺序走,大概率不会出大问题。
最后再分享一个小技巧:在调试 OpenHarmony 上的 Flutter 列表性能时,丢帧是最直观的信号,但不要只看 FPS。把 DevTools 打开的 memory 面板盯着看,如果内存曲线在滚动过程中一路向上、从不回落,说明你的 item 有东西在泄漏,优先级甚至比 FPS 还高。我靠这个方法揪出过好几次图片资源没释放的问题,那是 FPS 完全看不出来的隐患。