欢迎加入CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter

做 Flutter 页面时,我见过一种挺常见的写法:接口返回多少条数据,就在 Column 里生成多少个卡片。数据只有十几条时几乎看不出问题,到了几百条,首屏开始变慢;如果每一项还有图片、阴影和状态,手机甚至会出现明显停顿。

这次我新建了一个「任务流水台」Demo,不接网络,也不加第三方插件,只生成 1000 条固定数据。页面保留两种实现,可以在「全部构建」和「按需构建」之间切换。我想回答的不是“ListView.builder 更好”这句背过很多遍的话,而是它在鸿蒙 7.0.0 真机上到底少构建了多少 Widget、首帧差多少、滚动差多少、内存又差多少。

完整项目地址:https://atomgit.com/oh-flutter/flutter_ohos_list_performance_demo

一、实测环境和测量方式

项目实测版本
Flutter3.44.9+ohos-0.0.1-canary1
Dart3.12.2
DevTools2.57.0
DevEco Studio26.0.0 Release
HarmonyOS / OpenHarmony7.0.0.105(API 26)
构建模式profile
数据量1000 条

我的电脑上还留着旧版 Flutter,所以没有直接相信终端里的 flutter。这次所有命令都明确使用 3.44.9 SDK,避免项目悄悄被旧工具链构建。

export PATH=/Users/von/Developer/flutter-oh-3.44.9/bin:$PATH
hash -r
flutter --version
flutter devices

在这里插入图片描述

图 1:实际使用的是 Flutter 3.44.9 OHOS canary1,Flutter 工具也能识别 API 26 的 ohos-arm64 真机。

测量时我采用三组数据:页面首帧回调记录耗时和构建项数;FrameTiming 统计自动滚动过程中的慢帧;HiDumper 读取进程 PSS 和内存分项。为了避免先打开一种模式再切换另一种造成内存残留,两组内存数据都通过 --dart-define 独立构建、重新安装并冷启动后采集。

二、先看那种容易出问题的写法

“全部构建”模式使用 SingleChildScrollView + Column

SingleChildScrollView(
  controller: _scrollController,
  child: Column(
    children: [
      for (var index = 0; index < 1000; index++)
        _buildTaskCard(index),
    ],
  ),
)

这段代码的意思很直白:先把 1000 个 TaskCard 全部创建出来,再交给滚动容器。用户现在只能看到前四条,剩下九百多条暂时没有任何用处,但它们已经参加了构建和布局。

我给 _buildTaskCard 加了一个计数器,并在首帧完成后停止计时。profile 模式冷启动的结果是:首帧构建 1000 项,耗时 618.933 ms

在这里插入图片描述

图 2:真机冷启动直接进入全部构建模式,首帧一次创建了 1000 项。

这个数字并不是 Flutter 框架的通用跑分,它会随设备温度、后台任务和卡片复杂度变化。不过同一台手机、同一份 UI、同样 1000 条数据下,它可以作为两种实现的横向对照。

三、换成按需构建

“按需构建”使用 ListView.builderitemBuilder 只在列表需要某个索引时才创建对应卡片:

ListView.builder(
  controller: _scrollController,
  itemCount: 1000,
  itemExtent: 104,
  scrollCacheExtent: const ScrollCacheExtent.pixels(320),
  itemBuilder: (context, index) => _buildTaskCard(index),
)

冷启动时真机实际创建了 8 项,首帧耗时 39.877 ms。页面能看到四条,其余几条是滚动区域提前准备的缓存。与其一次准备 1000 条,不如只准备屏幕附近确实快要用到的内容。

在这里插入图片描述

图 3:按需构建的首帧只创建 8 项,页面内容已经完整可用。

这里还用到了 Flutter 3.44.9 的新接口。旧代码常写 cacheExtent: 320,但在当前 SDK 中它已经被标为弃用,分析器会提示改用 scrollCacheExtent。新写法不再只接收一个数字,而是使用 ScrollCacheExtent

const ScrollCacheExtent.pixels(320)

如果希望缓存范围跟随屏幕尺寸,也可以用 ScrollCacheExtent.viewport(1.0),表示按一个视口高度计算。像本文这种行高固定、只想预取少量卡片的情况,像素值更方便控制。

四、为什么还要写 itemExtent

每条任务的高度都是 104 逻辑像素,所以我直接给 ListView.builder 设置了 itemExtent: 104。这样列表知道每一项有多高,计算第 998 条所在的位置时不用先把前面所有卡片测一遍。

不过这项优化有前提:列表项高度必须真的固定。如果文字可能换成三行、图片比例不一致,硬填一个 itemExtent 反而会把内容裁掉。这种情况下可以统一卡片约束,或者不填写,让布局系统根据真实内容测量。性能优化不能靠猜,先保证页面正确,再减少能确定的工作量。

五、滚动测试不只靠眼睛看

为了让两种模式的滚动过程可以重复,我在右下角加了速度表按钮。点击后,列表用同样的 1.5 秒动画从当前一端滚到另一端,同时通过 SchedulerBinding.addTimingsCallback 收集 FrameTiming

final timings = <FrameTiming>[];
void collectTimings(List<FrameTiming> values) => timings.addAll(values);

SchedulerBinding.instance.addTimingsCallback(collectTimings);
await _scrollController.animateTo(
  target,
  duration: const Duration(milliseconds: 1500),
  curve: Curves.easeInOutCubic,
);
SchedulerBinding.instance.removeTimingsCallback(collectTimings);

final slowFrames = timings
    .where((timing) => timing.totalSpan > const Duration(milliseconds: 16))
    .length;

这次采样中,按需构建收到了 126 个帧数据,其中 7 帧超过 16 ms。列表顺利滚到第 1000 条,首屏仍然只构建了 8 项。

在这里插入图片描述

图 4:按需构建从首项自动滚到末项,慢帧为 7 / 126

全部构建模式收到 32 个帧数据,32 帧都超过 16 ms。实际看手机时,滚动也明显没有按需构建顺。

在这里插入图片描述

图 5:同样的自动滚动动作下,全部构建模式记录为 32 / 32 个慢帧。

这里需要说明两点。第一,16 ms 是本文为了便于比较设置的判断线,不代表所有刷新率设备都只看这个值。第二,这个 Demo 里的耗时采集包含页面构建和下一帧调度,不等同于系统应用启动耗时。它适合比较同一程序中的两种实现,不适合拿去和别人的 App 横向排名。

六、HiDumper 看到的内存差异

Flutter 页面内部的 Widget 数量能说明问题,但我还想看看系统进程实际占了多少内存。鸿蒙侧可以先用 pidof 找到进程,再让 HiDumper 输出 PSS:

hdc shell "hidumper --mem \
  $(pidof com.xjcyber.flutter_ohos_list_performance_demo) \
  --show-dmabuf"

我分别将 LIST_MODE 固定成 lazyeager,每次重新生成 profile HAP、覆盖安装、冷启动,等待页面稳定后采样。结果如下:

指标按需构建全部构建差值
PSS Total182712 kB311056 kB128344 kB
Native Heap PSS37691 kB88662 kB50971 kB
Dart Heap PSS212 kB664 kB452 kB

在这里插入图片描述

图 6:独立冷启动采样中,全部构建的 PSS 比按需构建高约 125.3 MiB。

PSS 的差距不全是 Dart 对象本身。1000 个列表项还会带来更多布局对象、渲染对象和原生侧内存,因此只盯着 dart heap 会低估影响。系统总量用 HiDumper 看,Dart 对象增长再用 DevTools Memory 继续追,这两种工具解决的是不同层面的问题。

七、测试和构建也要过一遍

我写了四个 Widget 测试:默认是否进入按需模式、首屏是否没有第 1000 项、切到全部构建后是否出现 1000 个 TaskCard、完成任务和滚动测速入口能否正常工作。

flutter analyze
flutter test
flutter build hap --profile

在这里插入图片描述

图 7:静态检查无问题,四个 Widget 测试通过,profile 签名 HAP 构建成功。

真机日志和页面上的数字也能互相核对:

在这里插入图片描述

图 8:HiLog 中的首帧构建项数、耗时和滚动慢帧与真机页面一致。

八、这次测试给我的实际结论

这次差距大,并不是因为用了什么复杂算法,恰恰是因为 Column 做了太多眼前用不到的工作。1000 条数据全部创建时,首帧从约 40 ms 增加到约 619 ms,PSS 多出约 125.3 MiB,滚动测试也出现了更明显的慢帧。

换成 ListView.builder 是最关键的一步,itemExtentscrollCacheExtent 是在列表结构正确之后的进一步优化。实际项目里我会按这个顺序处理:先确认是否按需构建,再检查是否能提供固定高度,最后根据真机滚动情况调整缓存范围,而不是一开始就堆很多参数。

如果列表仍然卡顿,下一步应该检查单个卡片是不是太重,例如图片解码尺寸是否过大、阴影和裁剪是否过多、状态更新是否让整页重建。本文没有把这些问题混在一起,是为了先把“创建 1000 项”和“只创建可见项”这一个变量测清楚。

欢迎加入CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐