前阵子接了个活儿,要把一套 Flutter 写的库存管理应用跑上鸿蒙平板。第一反应倒不是兴奋,而是心里打鼓:Flutter 真能上鸿蒙吗?等我把环境跑通、把业务里最复杂的 GridView 交互一个个搬过去之后才发现,这件事没想象中那么吓人,但坑是真的不少。这篇就把我这次“Flutter + 鸿蒙 + GridView 交互”的完整过程、踩坑记录和性能优化思路都写出来,给同样在做跨平台适配的朋友当个参考。
1. 先说结论:Flutter 跑鸿蒙的可用路线与我的选型理由
当时摆在面前的路有三条:纯 ArkTS/ArkUI 重写、WebView 套壳、用社区维护的 Flutter 鸿蒙适配 SDK。我自己做了个对比表,选型时一目了然:
| 路线 | 改造成本 | 性能 | 插件生态 | 对现有业务代码的复用度 |
|---|---|---|---|---|
| ArkTS 重写 | 极高,页面全部推倒重来 | 原生级 | 鸿蒙生态插件 | 几乎为零 |
| WebView 套壳 | 低,包一层壳就行 | 列表稍长就卡,交互动画差 | Web 生态 | 看 H5 覆盖率 |
| Flutter 适配 SDK | 中,主要改原生依赖和少量 UI | 接近原生 | Flutter 插件大部分可用 | Dart 层代码几乎全复用 |
我的结论很直接:ArkTS 重写成本太高,WebView 套壳又没法保证 GridView 这种高频滚动页面的体验,所以最终选了 Flutter 鸿蒙适配 SDK 这条路线。 核心诉求是“用最低改造成本把现有 GridView 和业务逻辑跑起来”,而 Flutter 的适配方案恰好能最大化复用 Dart 层代码。
这里要提醒一句:Flutter 官方主分支目前并没有把鸿蒙作为一等平台来支持,真正可用的是 OpenHarmony SIG 维护的 flutter_flutter 分支,以及配套的 flutter_ohos 引擎。选型的时候一定要确认版本分支和你的 OpenHarmony SDK 版本能对应上,否则后面 compile 的时候会冒出来一堆不明不白的错误。
另外一个比较容易忽略的点:插件兼容性。 你原来在 Android 上依赖的第三方库,如果涉及原生平台通道(比如高德地图、微信登录),在鸿蒙上大概率是要替换成鸿蒙版本的实现,或者自己写平台通道对接。我在这个项目里最典型的一个例子就是底部弹窗里的 TextField,Android 上好好的,跑到鸿蒙上键盘弹起来后整个布局被顶得乱七八糟,后面会细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境与工程改造:从 Android 工程到鸿蒙 hap 的迁移过程
2.1 环境准备与 SDK 切换
环境这块不复杂,但步骤容易漏。我的做法是:
- 安装 DevEco Studio,配好 OpenHarmony SDK。
- 把 Flutter SDK 从官方稳定版切到适配鸿蒙的 flutter_flutter 分支,仓库地址和分支名在 OpenHarmony SIG 的说明里有,照着来。
- 在环境变量里补充
OHOS_SDK_HOME、DEVECO_SDK_HOME这些路径,不然 hvigor 找不到 SDK。 - 用
hdc list targets确认能用命令行连上鸿蒙设备,这一步非常关键,后面所有安装调试都依赖它。
2.2 工程目录的变化
Flutter 工程里默认有 android/ 和 ios/ 目录,鸿蒙适配之后多了一个 ohos/ 目录。我看下来结构是模仿 Android 工程的,但有一些自己特有的文件:
entry/src/main/module.json5:相当于 Android 的 AndroidManifest,配置 abilities、页面入口、权限声明。build-profile.json5:相当于 build.gradle,配置签名、模块依赖。hvigorfile.ts:构建脚本入口。
从 Android 工程迁移到鸿蒙,我的步骤是:先把 ohos/ 目录用模板生成出来,再把原来 Android 里申请的那些权限(网络、存储、相机等)在 module.json5 里重新声明一遍,最后把 android/gradle.properties 里的一些构建参数手动映射到 hvigor 体系里。
2.3 集成过程中的两个大坑
第一个坑是 Flutter 引擎编译出来之后,页面白屏或者滚动无响应。 我第一次把 hap 装到真机上,应用能起来,但 GridView 一动不动。查了半天,发现问题是引擎跑在了错误的 surface 实现上,没有用 ohos 平台的渲染表面。解决方式就是确保编译用的 SDK 是鸿蒙适配分支,并且在构建时不要手动混入官方 Flutter 引擎产物。
第二个坑是 MissingPluginException。 原来在 Android 上用的一些插件在鸿蒙上没有原生实现,运行时直接抛异常。排查思路是先看 flutter logs,找到具体是哪个 channel 没有实现,然后去 pub.dev 找鸿蒙版本的替代品,或者从 OpenHarmony 的三方库索引里找适配过的插件。
提示:如果插件涉及的是很通用的能力,比如路径获取、网络状态、本地存储,通常都有适配版;但如果是很小众的商业 SDK,那就得做好自己写平台通道的准备。
3. GridView 网格布局:把静态网格在鸿蒙上排布得又快又稳
3.1 最基础的网格写法
GridView 在 Flutter 里的基础用法,不管 Android 还是鸿蒙都差不多。我这边项目里用的比较多的是动态数量模型,所以就以 GridView.builder 为基线:
dart复制GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.9,
),
itemCount: items.length,
itemBuilder: (context, index) {
return ItemCard(item: items[index]);
},
)
这套写法在鸿蒙上的表现跟 Android 基本一致。但有一点要注意:childAspectRatio 这个参数在平板和手机上差异很大。鸿蒙平板屏幕宽,如果你写死 4 列,每个格子会变得很大;如果不写死列数,用 SliverGridDelegateWithMaxCrossAxisExtent 按最大宽度自适应,反而更稳。
dart复制const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 160,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.9,
)
这个细节在鸿蒙上比 Android 更重要,因为鸿蒙设备形态很杂,有手机、平板、折叠屏,还有车机,一套代码要适配多种屏幕宽度,固定列数很容易翻车。
3.2 带分组标题的网格:用 CustomScrollView 组合
业务里还有个需求是“按分类展示商品”,类似电商首页。这时候不能再单独用 GridView,我把滚动视图换成了 CustomScrollView,用 SliverGrid 来承载网格,用 SliverPersistentHeader 来实现吸顶分类标题:
dart复制CustomScrollView(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _HeaderDelegate(title: '工具类'),
),
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 0.8,
),
delegate: SliverChildBuilderDelegate(
(context, index) => ItemCard(item: toolItems[index]),
childCount: toolItems.length,
),
),
SliverPersistentHeader(
pinned: true,
delegate: _HeaderDelegate(title: '耗材类'),
),
// ...
],
)
分组吸顶在 Android 上很好用,鸿蒙上逻辑也是一样的,但在开发阶段我发现一个细节:SliverPersistentHeader 的 delegate 里如果返回的 child 高度和 maxExtent 不一致,在鸿蒙的某些系统版本上会出现标题闪烁的问题。后来把 minExtent 和 maxExtent 都设成固定值,问题就消失了。
3.3 鸿蒙上的 UI 细节差异
这套代码如果是从 Android 直接搬过来的,视觉上会有几个地方跟鸿蒙的设计语言不太搭。最明显的是水波纹:Material 的 InkWell 水波纹效果在鸿蒙上虽然也能弹出来,但略显生硬,鸿蒙自己的触控反馈是偏轻盈的那种。我当时做的是把水波纹效果调淡、把 ripple 的持续时间缩短,整体手感会更贴近鸿蒙原生应用。
其次是字体渲染。鸿蒙系统的默认字体是HarmonyOS Sans,英文和数字的字重分布和 Android 的 Roboto 不一样。同样的 FontWeight.w500,在鸿蒙上看起来更细,列表里的价格、标题在视觉上会显得不够有层次。我的做法是全局定义了字体样式,对数字标题类文本统一往上升一档字重。
再有就是安全区。鸿蒙平板的底部手势条、部分机型顶部有挖孔,如果 GridView 直接做到 Scaffold 的 body 里还好,但如果是自定义弹窗、底部弹出层里的滚动区域,一定要包 SafeArea。否则 GridView 的最后一行会被手势条挡住,滑动删除的按钮也会被顶到屏幕外面。
4. 交互功能的实现细节:点击、长按、滑动删除在鸿蒙上的适配
4.1 点击反馈:InkWell 和 GestureDetector 的取舍
GridView 里的 item 点击,我一开始用的 InkWell,因为需要水波纹反馈。跑在鸿蒙上,水波纹外观虽然有点差异,但事件响应没问题。真正的问题是:如果你在 InkWell 外层再套 GestureDetector,并且同时监听 onTap 和 onLongPress,鸿蒙上的手势竞技场可能会发生奇怪的抢占。
我遇到的具体现象是:快速点击时偶尔会触发 onLongPress,长按时偶尔又触发 onTap。排查下来,还是两个手势控件嵌套导致的状态竞争。后面统一的做法是:能用 InkWell 就不用 GestureDetector 去包,一个控件尽量只承担一种手势职责。
dart复制InkWell(
onTap: () => viewModel.openDetail(item),
onLongPress: () => viewModel.enterEditMode(item),
child: ItemCard(item: item),
)
4.2 长按菜单与底部弹窗
长按弹出操作菜单,我用的 showModalBottomSheet。鸿蒙平板上跑这个组件,视觉效果其实很不错,圆角和动画都正常。但我踩了一个很实际的坑:弹窗里的滚动区域在键盘弹起时会覆盖输入框。
项目里有个场景是长按 GridView 里的某个卡片,弹窗里有个编辑框让用户改数量。Android 上系统会自动把 resizeToAvoidBottomInset 处理掉,但鸿蒙上键盘把弹窗内容顶没了。我的解决思路是不依赖系统自动调整,在弹窗内容外面手动包一层 AnimatedPadding,监听 MediaQuery.of(context).viewInsets.bottom:
dart复制AnimatedPadding(
duration: const Duration(milliseconds: 150),
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: BottomSheetContent(item: item),
)
改完之后,键盘弹出时弹窗会整体上移,输入框不会再被遮挡。
4.3 滑动删除:Dismissible 在鸿蒙上的表现
GridView 的 item 做滑动删除,我用的是 Dismissible。方向配置为从右往左:
dart复制Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
confirmDismiss: (_) => _showDeleteConfirm(item),
onDismissed: (_) => viewModel.removeItem(item.id),
background: _buildDeleteBackground(),
child: ItemCard(item: item),
)
在鸿蒙上,Dismissible 的基础滑动没问题,但有两个细节值得注意。
一是 卡片圆角背景在滑动时露白。 因为 Dismissible 的背景默认铺满整个 item 区域,如果你的 item 是圆角卡片,滑动过程中背景的四个角是直角,视觉上看起来就不太精致。解决方式是在 background 里也套一个同样圆角的 ClipRRect。
二是 滑动删除后,item 的 state 会错乱,尤其是这个 item 内部有多选状态、勾选状态的时候。这个问题在 Android 上也有,但鸿蒙上更明显。原因是 Dismissible 被移除后,GridView 的 item 复用了之前的状态。我的做法是保证 item 内部的子组件用一个稳定且唯一的 ValueKey,并且在 onDismissed 里立即同步数据源。
4.4 多选编辑模式的实现
这个交互是从长按卡片进入“编辑模式”,然后每个卡片左上角出现勾选圆圈,点击卡片切换选中态。代码上我用一个 Set<String> selectedIds 记录选中的 id:
dart复制Widget buildItem(BuildContext context, ItemEntity item) {
final selected = selectedIds.contains(item.id);
return GestureDetector(
onTap: () {
if (viewModel.isEditMode) {
setState(() {
selectedIds.contains(item.id)
? selectedIds.remove(item.id)
: selectedIds.add(item.id);
});
} else {
viewModel.openDetail(item);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? primaryColor : Colors.transparent,
width: 2,
),
),
child: Stack(
children: [
Positioned(
top: 6,
right: 6,
child: _CheckCircle(selected: selected),
),
// item 内容
],
),
),
);
}
这里要特别提醒:进入编辑模式时,如果当前 GridView 正在滚动,不要直接 setState 修改大量 item 的样式,否则会出现明显掉帧。 更稳的方案是先停止滚动,或者用一个 AnimationController 做整体过渡,而不是在 itemBuilder 里同时触发几百个 AnimatedContainer 同时动画。
5. 拖拽排序与懒加载:高交互场景下的 GridView 性能打磨
5.1 官方没有 ReorderableGridView,怎么办
Flutter 官方只提供了 ReorderableListView,并没有现成的 ReorderableGridView。但业务上确实需要长按拖动卡片换位置,这个在鸿蒙上我用了自研方案:LongPressDraggable 作为拖拽源,DragTarget 作为放置目标。
核心思路:
- 在 itemBuilder 里,用
LongPressDraggable包住卡片。 - 拖拽的数据源是 item 的 index。
- 每个卡片的 itemBuilder 里同时放一个
DragTarget,当拖拽的 index 进入某个卡片区域时,就调 swap 方法交换数据源里的位置。
dart复制LongPressDraggable<int>(
data: index,
feedback: Material(
color: Colors.transparent,
child: ItemCard(item: item, elevated: true),
),
childWhenDragging: Opacity(
opacity: 0.3,
child: ItemCard(item: item),
),
child: DragTarget<int>(
onWillAcceptWithDetails: (details) => details.data != index,
onAcceptWithDetails: (details) {
setState(() {
final oldIndex = details.data;
final newIndex = index;
items.insert(newIndex, items.removeAt(oldIndex));
});
},
builder: (context, candidateData, rejectedData) {
return ItemCard(item: item);
},
),
)
这套方案在 Android 上我用过很多次,鸿蒙上做了一次真机实测,长按延迟、拖拽跟手程度都符合预期。有一个小细节:feedback 里的卡片如果太大,拖拽时边缘会有一部分跑出屏幕,需要手动用 Transform.scale 把 feedback 缩小一点,手感更自然。
另外,如果要实现“跨区域拖拽”——比如从 GridView 里拖一个卡片到下方的收藏栏,思路是一样的:收藏栏整体包一个 DragTarget,onAccept 的时候把 item 从当前列表中移除并加入收藏集合。这个交互在鸿蒙平板上搭配分屏使用很流畅,用户感知很直接。
5.2 cacheExtent 和懒加载
GridView.builder 本身是按需构建,但在快速滑动时,如果预加载区域太小,新进入屏幕的 item 还没来得及构建就会出现白屏闪烁。鸿蒙设备上这个问题比 Android 更容易遇到,原因是大多数鸿蒙平板的屏幕尺寸更大,一屏能显示的格子数量更多,需要预加载的数据量也更大。
对我这个项目来说,直接调整 cacheExtent 是个成本最低的优化:
dart复制GridView.builder(
cacheExtent: 600,
// ...
)
cacheExtent 的单位是逻辑像素,默认值约 250。我调到 600 之后,快速滑动时的白屏闪烁明显消失。
另一个判断是不要在 itemBuilder 里直接做耗时操作。比如判断当前 item 是否是某个分组下的第一个、是否需要显示头部分隔线,这些逻辑尽量提前在数据层算好,以字段的形式传给 item 组件,而不是在 build 里做大量判断。
5.3 item 内部图片的加载优化
GridView 里的卡片几乎都带图片。如果把 Image.network 直接丢在 itemBuilder 里,在鸿蒙上的表现会比较“诚实”——滚动快一点,图片会闪、会有短暂的白块,甚至内存占用会直线上升。我在这里做了三件事:
- 图片组件统一换成
cached_network_image,用磁盘缓存兜底。 - 给图片加
loadingBuilder,加载过程中显示占位骨架,不要显示空白。 - 对网络图片做降采样,用
ResizeImage或者后端直接返回对应尺寸的缩略图,避免一个 2000px 的大图直接解码到内存里。
dart复制CachedNetworkImage(
imageUrl: item.coverUrl,
placeholder: (context, url) => const _Skeleton(),
errorWidget: (context, url, error) => const _ImageError(),
fadeInDuration: const Duration(milliseconds: 150),
)
这个改动带来的效果非常明显。同一台鸿蒙平板,优化前跑 200 个 item 的 Grid 上下滑动,肉眼可见掉帧;优化后 600 个 item 保持 55~60fps,内存占用也稳定了很多。
6. 真机压测复盘:从掉帧到流畅的排查思路
6.1 压测方案
这次适配的收尾阶段,我在两台鸿蒙设备上做了压测:一台是普通手机,一台是平板。测试场景是滚动一个 600 个 item 的 GridView,边滑边记录帧率,同时观察内存变化。测试手段用的是 DevEco Studio 自带的 Profiler 工具,以及 Flutter 自带的 debugPrint 看关键耗时。
6.2 问题与解决方案对照
我把这次压测遇到的问题整理成了表格,方便以后回看:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 快速滚动白屏闪烁 | cacheExtent 太小,预加载不足 | cacheExtent 调至 600 |
| 图片区域掉帧明显 | 大图直接解码,无缓存 | 使用 cached_network_image + 缩略图 |
| 长按拖拽时卡片闪烁 | feedback 卡片未加 Material 包裹 | feedback 外层包 Material |
| 内存持续上涨 | 页面销毁后图片缓存未释放 | 页面级 ImageCache 清理,避免全局无限缓存 |
| 分组标题闪烁 | SliverPersistentHeader 的 minExtent/maxExtent 不一致 | 固定高度,去掉动态计算 |
| 底部手势条遮挡最后一行 | 未处理系统安全区 | 底部加 SafeArea |
6.3 一次典型的掉帧排查链路
这里说一个值得分享的排查过程。当时在真机上滑动 GridView,帧率低于 40fps,用 Profiler 一眼看到 CPU 的 dart 线程占用极高,但 ui 线程不高。这种分布说明问题不在渲染,而在 Dart 侧的构建和布局。
我逐个 item 检查 build 耗时,最后发现罪魁祸首是 item 内部的一张背景图用了 DecorationImage,并且每次 build 都会重新创建一个 BoxFit.cover 的图片对象。虽然 DecorationImage 本身有相等性判断,但在列表大量复用 item 时,每次重新 build 依然会造成额外的图片解码和图层合成开销。改法很简单:把这张背景图抽成独立的 final 字段,或者用 const 构造,避免不必要的重复创建。
这次之后我总结了一条经验:在跨平台列表性能排查时,先把问题分成“Dart 构建耗时”和“原生渲染耗时”两个方向,再去看对应的线程指标,能少走很多弯路。 鸿蒙适配和 Android 原生的排查思路是相通的,难点在于你要先确认 Flutter 引擎跑的是哪条渲染链路。
回到这次项目整体,我最大的体会是:Flutter 上鸿蒙这件事,可复用度比想象中高很多,真正需要动手改的大部分集中在原生依赖、系统安全区和一些 UI 风格微调上。GridView 这套组件体系在鸿蒙上的表现,整体是让人放心的——只要你把图片缓存、cacheExtent、手势冲突这几件事处理干净,它不会成为拖后腿的部分。
最后再分享一个小技巧:开发阶段尽量准备一台配置不太好的鸿蒙设备,低端机上的卡顿和掉帧一定比高端机出现得更早,越早暴露问题,后面就越省事。别等到适配完成了才发现列表在低端机上根本跑不动,那时候改起来可比一开始就注意要痛苦得多。
