刚把这个“随手小灶”App 的菜谱详情页从零跑通的时候,我第一反应不是发朋友圈庆祝,而是翻了一遍我在 OpenHarmony 社区群里攒了半年的聊天记录——说实话,问“Flutter 能不能跑 OpenHarmony”的人越来越多,但真正把完整项目流程分享出来的少之又少。这篇博文就想当那个“少之又少”。我会围绕最近做的一个美食烹饪助手 App,重点拆解菜谱详情展示的实现过程,包括技术选型、数据层设计、页面 UI 搭建、组件通信和真机调试,全程用真实踩坑经验说话,适合正在研究 Flutter for OpenHarmony 的移动端开发者、准备接入开源鸿蒙生态的团队,以及想抄一套可用方案的独立开发者。
1. 项目背景:为什么是 Flutter + OpenHarmony 做菜谱 App
1.1 选型思考:跨端成本与团队技能栈
先说结论:如果团队里没有专职的 OpenHarmony 原生开发,又不想被 Android、iOS 两套代码拖死,用 Flutter 做 OpenHarmony 应用是目前性价比最高的一条路。
我当时接手这个美食项目时,团队情况很现实:两个后端转前端的同学,Dart 基础不错,但没人写过 Java 鸿蒙应用。一开始我还认真评估过用 ArkTS 重写一套,毕竟 OpenHarmony 官方推荐的声明式 UI 是 ArkTS,性能理论上最贴近系统底层。但看完详情页要做的功能列表,我果断扔掉了这个念头——常规的列表、标签、轮播、弹出层,ArkTS 都能做,但团队学习成本摆在那里,至少要多花两周时间把 ArkTS 的状态管理、组件生命周期、路由机制全部走通,而 Flutter 这边大家本来就熟,直接换一套编译目标而已。
另一个决定性因素是 Flutter 的 UI 表达力。菜谱详情页这种内容是典型的重视觉场景:封面大图、食材表格、步骤时间轴、计时器交互,每一项都需要高频次调整布局。Flutter 的组件体系和 Hot Reload 在这种场景下的开发效率是碾压级的。我在项目里调一个步骤卡片的内边距,改完代码直接热重载看效果,整个调整过程不到 20 秒,这在原生开发里很难做到。
当然,Flutter for OpenHarmony 不是没有代价。它本质上依赖 OpenHarmony-SIG 维护的 flutter_flutter 适配分支,社区版本跟 Flutter 官方主线的版本更新会慢半拍,遇到插件兼容问题也只能自己啃源码。一句话总结我的选型判断:成熟团队要抢时间、要一套代码多端复用,直接上 Flutter;如果项目对系统 API 的依赖特别深,比如要做摄像头采集、底层硬件控制,那还是得老老实实评估 ArkTS 或混合开发。
1.2 菜谱详情页的需求拆解
说句实话,菜谱 App 的功能看多了你会发现,详情页才是决定用户留不留得住的命门。列表页大家做得都差不多,但详情页一旦信息乱、加载慢、图片崩,一顿饭的兴致直接没了。所以动手前我先把详情页的需求列成了清单,避免写代码的时候被零散想法带偏。
核心信息展示方面,一道菜必须讲清楚这几个维度:封面图、菜名、烹饪时长、难度、份量、主料/辅料清单、分步骤做法、小贴士。其中步骤部分不能只是一堆文字堆在那里,我要做成时间轴样式,步骤编号、操作说明、单步耗时、成品缩略图都要有。考虑到很多人做菜的时候手上全是油,整个页面的按钮热区必须做得足够大,文字也支持跟随系统字体缩放。
交互功能方面,详情页至少要承载收藏/取消收藏、分享、评分、烹饪计时器这几个动作。收藏状态需要跟收藏列表页保持同步,计时器在用户退到后台继续计时,这些都是容易踩坑的地方,后面章节我会一个一个展开讲。
数据与性能要求就更直接了:首屏图片不能等全部加载完才显示,文字内容要优先渲染,滚动跟手不卡顿,页面销毁不能崩溃。这些指标跟 Android 端调优起来其实没有本质区别,但 OpenHarmony 设备型号杂、屏幕尺寸差异大,适配范围要提前规划好。我后来把需求整理成表格挂在项目看板上,每个功能点对应一个验收标准,后面开发才没有返工。
2. 工程搭建:让 Flutter 在 OpenHarmony 上跑起来
2.1 环境准备与版本对照
这一节是给第一次接 OpenHarmony 的 Flutter 开发者看的“省流版”环境指南。我踩过的最大坑就是版本匹配,所以先把版本对照表放出来,你直接照着配,能省一整天时间。
我当时的本机环境是:DevEco Studio 4.0 Release、OpenHarmony SDK 3.2 Release、Flutter 使用来自 OpenHarmony-SIG 的 flutter_flutter 仓库的openharmony-3.2-branch分支,Dart 版本跟随该分支自带。这里特别强调一下,千万别用官方 flutter 主干 SDK 去跑 OpenHarmony 编译,会直接报错找不到 ohos 平台配置,整个命令行只能干瞪眼。
环境配置的步骤其实不复杂:
- 从 Gitee 克隆 OpenHarmony-SIG/flutter_flutter,切换到对应分支
- 把仓库里的
flutter脚本路径加入 PATH,并确认flutter doctor能识别 ohos 工具链 - 安装 DevEco Studio,配置好 OpenHarmony SDK 路径,创建空的 OpenHarmony 工程,后续 Flutter 产物会以模块方式塞进这个工程
- 在项目里执行
flutter create --platforms=ohos .生成 ohos 目录,或者直接通过适配分支的 build 命令生成 hap 产物
我在配置过程中第一次跑flutter doctor时,工具链识别出了 Android SDK 但完全不认 ohos,问题出在环境变量DEVECO_SDK_HOME没指向 OpenHarmony SDK 目录。这个环境变量是适配版 Flutter 特有的,官方文档写得比较隐晦,我折腾了快一个小时才定位到。配置完成后,正常编译一次大概要两三分钟,比 Android 首次跑 Gradle 快不少。
提示:版本匹配是 OpenHarmony 适配路上的头号坑。建议记录你的 OpenHarmony SDK API 版本和 Flutter 分支的对应关系,别拿到什么版本都往上怼。
2.2 工程结构设计与依赖引入
工程结构我这里采用的是 feature-first 的模块化方式,跟菜谱业务强相关的代码全部按功能拆包,公用的组件和工具独立放一层。这样做的原因很简单:详情页绝对不是孤立页面,首页、分类、搜索都会跳过来,以后还要加购物车、厨房笔记,不把边界划清楚,后面改一个入口就得全局翻代码。
实际的目录结构长这样:
lib/ ├── main.dart ├── core/ │ ├── network/ │ ├── utils/ │ └── theme/ ├── features/ │ ├── home/ │ ├── recipe/ │ │ ├── models/ │ │ ├── providers/ │ │ ├── pages/ │ │ └── widgets/ │ ├── favorites/ │ └── settings/ └── shared/ └── widgets/依赖管理方面,我核心就选了四个包:provider做状态管理、cached_network_image做网络图片缓存、dio做网络请求、intl做时间格式化。像国际化、手势缩放这类需求,能不用第三方就尽量不用,因为在 OpenHarmony 上不是所有 Flutter 插件都有对应实现。我试过一个挺流行的毛玻璃效果插件,Android 端正常,但里面依赖了 Android 特有的PlatformView逻辑,跑到 OpenHarmony 上直接编译不过,最后只能自己用半透明遮罩模拟。所以依赖引入原则就一句话:能用官方组件解决的绝不多引包,多一个插件就多一个适配风险点。
2.3 接入 OpenHarmony 工程的两种方式
跑通 Flutter 和 OpenHarmony 的工程联调,我试过两条路,各有优劣。
第一种方式是把 Flutter 作为独立模块嵌入 OpenHarmony 工程。先创建 OpenHarmony 原生工程,再把 Flutter 生成的 ohos 目录作为模块引入,通过 DevEco Studio 统一编译和运行。这种方式的优点是调试链路完整,DevEco 的日志、断点、性能分析工具都能直接用,真机部署也方便。缺点是每次 Flutter 代码改动后要先构建产物,再回到 DevEco 里刷新工程,双工具切换比较烦。
第二种方式是用适配版 Flutter 直接构建 hap 包,然后通过命令行工具部署到设备或模拟器。这种方式更接近 Flutter Android 的体验,命令行一条龙很快,但日志查看就不如在 IDE 里方便,出错信息也要自己捞。
我的实际建议是:开发阶段用第一种方式,跑通主流程;到了 UI 调优阶段切到第二种方式,用 Hot Reload 快速调样式;上机自测回归再回到第一种方式,毕竟 DevEco 的调试体验最稳。这个工作流相当于把两种方式都用上,虽然听起来有点折腾,但实际用下来效率最高。
注意:在 OpenHarmony 工程里引入 Flutter 模块后,记得检查
module.json5里有没有申请必要的权限。菜谱 App 如果不涉及网络图片之外的能力,一般只需要网络权限;但如果你做分享功能想截屏,就需要额外申请媒体权限,漏掉的话运行时直接抛异常。
3. 数据层与状态管理:菜谱数据怎么组织最舒服
3.1 菜谱数据模型设计
数据模型是整个详情页的地基。我的原则是:前端模型只放 UI 展示需要的数据,网络返回的额外字段能扔就扔,避免把 JSON 原封不动塞进状态里,后面每个页面都要为没用的字段负责。
Recipe模型我设计了五个核心字段组,对应详情页的五大区块:基本信息(id、封面图 URL、标题、简介)、烹饪参数(时长、难度、份量)、食材清单(主料和辅料分开存储)、步骤列表(有序数组,每个步骤包含序号、说明、预计耗时、可选成品图 URL)、补充信息(小贴士、热量标签)。模型实现用的是手写fromJson/toJson,没用 json_serializable 生成器。
这个模型设计的关键点有两个。第一,食材清单里的每一条,除了名称和数量,我还存了一个isKeyIngredient标志位,用来在主料区做高亮展示,这个字段后端接口本来没有,是前端根据食材重要性规则算出来的。第二,步骤数组使用普通List<Step>而不是 Map,因为步骤是有强顺序的,数组天然保证顺序,也方便后面做步骤编号、计时、跳转定位。
对应 JSON 大概长这样:
{ "id": "RECIPE_001", "title": "番茄炖牛腩", "coverUrl": "https://.../cover.jpg", "durationMin": 90, "difficulty": "medium", "servings": "3-4人份", "ingredients": [ {"name": "牛腩", "amount": "800克", "isKeyIngredient": true}, {"name": "番茄", "amount": "4个", "isKeyIngredient": true}, {"name": "洋葱", "amount": "1个", "isKeyIngredient": false} ], "steps": [ {"seq": 1, "text": "牛腩冷水下锅焯水", "durationMin": 5, "imageUrl": null}, {"seq": 2, "text": "番茄去皮切块", "durationMin": 10, "imageUrl": null} ], "tips": "番茄炒出沙后加牛腩,汤汁更浓郁" }3.2 列表到详情的导航传参:直接传对象还是传 id
这算是我最想分享的一个设计决策。网上很多 Flutter 教程写详情页跳转,都是Navigator.push直接把列表页的整个对象传过去,看起来很方便,但生产环境里这么做会埋一个隐患——详情页拿到的是列表页的静态快照,一旦数据在服务端更新,或者用户从收藏列表、搜索历史多个入口进来,看到的详情内容可能就不是最新的。
我最后选择的是“只传 id,详情页自己拉数据”的方案。列表页跳转时只把recipeId通过构造参数传过去,详情页的Provider从仓库层按 id 拉取完整数据并缓存。这样不管用户从哪个入口进来,详情页都是同一套数据加载逻辑,后续做下拉刷新、收藏状态同步也顺理成章。
代价是详情页多了一次网络加载状态的处理。为此我设计了loading、loaded、error三种状态,用Provider里的一个枚举统一管理,页面根据状态决定是显示骨架屏还是错误重试按钮。首访问时会有几百毫秒的白屏,我用骨架屏撑住了体验,比直接传对象省下来的维护成本划算得多。
经验:如果你做的是纯前端 Demo,直接传对象没毛病,代码还简单;但项目要长期维护、多入口跳转,传 id 拉数据才是正解。这个取舍越早做越好,后期改成本很高。
3.3 Provider 管理详情页状态的实战姿势
项目里状态管理我选了provider,而不是 getx、riverpod 这些,原因很实际:团队最熟、社区资料最多、OpenHarmony 适配版本上没有任何 feature 需要特殊处理。菜谱详情页的状态不算复杂,用ChangeNotifier完全够用。
详情页的 Provider 我是这样设计的:RecipeDetailProvider负责承载当前菜谱数据、加载状态、步骤完成的标记、收藏状态、评分;FavoritesProvider负责维护整个 App 的收藏列表 id 集合。两个 Provider 的分层逻辑很明确——详情页自己的临时状态用局部 Provider,跨页面要共享的收藏状态用全局 Provider。
Provider 的使用就两个关键点:少放全局、分层清晰。详情页内部的加载状态、评分草稿这类东西,一辈子跟页面共存亡,放进全局只是白白增加重建范围;收藏状态因为收藏列表页、详情页、首页卡片都要读,就必须全局单例。什么时候Provider.of<T>(context)会导致 widget 重建?只要你监听的那个值变了,所有依赖它的 widget 都会通过Consumer或context.watch触发重建。所以我把详情页拆成了多个小的Consumer,比如仅评分组件监听评分值,仅收藏按钮监听收藏状态,这样改一个局部状态不会让整棵页面子树全部重建,滚动性能也好。
具体代码结构大概是:
class RecipeDetailProvider extends ChangeNotifier { RecipeDetailState _state = RecipeDetailState.loading; Recipe? _recipe; final Set<int> _completedSteps = {}; double _rating = 0.0; bool _isFavorite = false; final FoodRepository _repository; Future<void> loadRecipe(String id) async { _state = RecipeDetailState.loading; notifyListeners(); try { _recipe = await _repository.fetchRecipeById(id); _state = RecipeDetailState.loaded; notifyListeners(); } catch (e) { _state = RecipeDetailState.error; notifyListeners(); } } }页面里的订阅代码不需要太花哨,Consumer<RecipeDetailProvider>包住对应区域就够了。后面收藏列表要跟详情页同步,我也用的同一个全局 Provider 实例,在详情页写收藏操作,收藏页通过context.watch<FavoritesProvider>()自动刷新,这是我目前觉得最舒服的写法。
4. 详情页 UI 构建实录
4.1 骨架搭建:SliverAppBar + CustomScrollView
详情页的骨架我放弃了传统的Scaffold + SingleChildScrollView + Column组合,改用了CustomScrollView + SliverAppBar + SliverToBoxAdapter。原因很简单:封面大图要能展开收起,滚动时标题栏要渐变吸顶,这套交互动效用普通 Column 根本做不优雅。
SliverAppBar给整个页面带来的手感提升是非常直观的。初始状态下,封面图完整露出,标题悬浮在图上;往下滚动时封面图跟随收起,最终变形成一个半透明导航栏,露出 “番茄炖牛腩” 的菜名。细节处理上,flexibleSpace区域我放的不只是封面图,还在底部压了一层从透明到黑色的渐变遮罩,保证白色文字在任何背景图上都能看清。
页面剩余内容放在了SliverToBoxAdapter里,内部再按区块拆成独立的 widget。信息区、食材区、步骤区、小贴士区各自独立,好处有两个:一是代码好维护,改一个区块不会碰其他区块;二是配合下一节说的性能优化,可以按区块做局部刷新,避免整页失效重绘。
有一点要提醒:SliverAppBar在 OpenHarmony 设备上如果pinned设为 true,吸顶时 subtitle 和背景渐变色的衔接偶尔会出现 1px 的视觉缝隙。我实测下来,给导航栏背景加一个跟页面底色一致的 0.5 高度Container兜底就能解决,这种小瑕疵通常只在真机上暴露,模拟器上很难发现。
4.2 食材清单与步骤时间轴实现
食材清单在视觉上是详情页最容易做得难看的部分。我见过很多 App 直接把后端返回的数组渲染成一长列,主料辅料混在一起,用户得自己数着哪一行是主料。我的做法是分成“主料”和“辅料”两张卡片,主料卡片做浅色背景,每行用Row左对齐;辅料卡片用紧凑的双列布局,节省垂直空间。
食材项的Row左侧放食材名,右侧放用量,中间用虚线分隔线撑开,视觉上很干净。这里有一个我研究过的小细节:用量文本我用的TextOverflow.ellipsis,但实际菜谱数据里“800 克”“3-4 人份”这种字段不会太长,真正会溢出的情况发生在系统字体放大 1.5 倍以上,所以这个兜底一定要加,不然无障碍用户一放大字体页面就直接炸布局。
步骤时间轴是我的得意之选。每个步骤生成一个垂直条目:左侧是圆形序号,带一条贯穿的虚线连到下一步;中间是操作说明文本;右侧是预计耗时,用浅色标签显示“约 5 分钟”。用户点一下步骤,序号圆点会变成高亮色,并打上完成标记。同时圆形序号支持点击跳转定位,如果步骤附带了过程图,点击缩略图还能放大预览。时间轴的实现不复杂,就是Column里依次叠加Row,背景用CustomPaint画一条虚线,但视觉效果比普通列表好了不止一个档次。
4.3 图片加载与渲染引擎选择
图片加载这块是 OpenHarmony 上踩坑比较多的区域,值得单独拿出来讲。网络图片来源基本都是服务端原图,动不动就是单边 2000 像素的大图,直接在Image.network里展示,Android 上可能还能勉强撑住,但在部分内存预算紧的开发板上很容易直接 OOM。我最后用的是cached_network_image插件,配合CacheWidth参数让图片解码时直接把宽度压到设备需要的尺寸,内存占用能少差不多一半。
另一个渲染层面的选择是引擎。当时 OpenHarmony 适配版 Flutter 使用的还是 Skia 渲染引擎,而 Flutter 官方主线从 3.10 开始逐步转向 Impeller,主要是为了解决 Skia 在动画、模糊场景下的顿挫问题。我开发的页面里有一个封面图淡入淡出的过渡动画,在低端设备上明显能感觉到 Skia 渲染帧率不稳,后来我把动画改成简单的透明度插值并降低了首帧复杂度,体感就舒服多了。这里给个结论:别指望适配版立刻跟进 Impeller,优化自己的图片尺寸和动画复杂度才是当前最可控的手段。
提到图片,顺便说一句
CachedNetworkImage的磁盘缓存目录在 OpenHarmony 上的路径跟 Android 不一样,但插件封装好了,开发时不需要关心。唯一要注意的是清理缓存功能要自己写,别直接拿 Android 平台的path_provider路径去删,会产生一致性隐患。
4.4 字体缩放与无障碍的小细节
做菜场景里的用户画像,中年人比例比一般 App 高得多,视力条件和手指灵活度都各不相同,所以字体缩放和无障碍我从一开始就当核心需求做,不是最后补的。
Flutter 的MediaQuery.textScaler在 OpenHarmony 适配版里是正常生效的,所以只要代码里不用固定fontSize硬编码,系统字体一放大,页面上所有文本都会跟着变大。但问题往往出在组件尺寸上:文本变大了,按钮没有跟着变大,或者两个步骤文本之间重叠了。我的解决方法是把关键点击区域的高度用ConstraintBox里的最小尺寸去约束,同时在文字缩放超过 1.3 倍时切换为更紧凑的列表布局。
无障碍语义这块,我给步骤序号、收藏按钮、计时器都加了Semantics标签,这样屏幕阅读器能读出“第二步,牛腩冷水下锅焯水,预计五分钟”这种完整信息。之前有次自测,我用系统 TalkBack 朗读详情页,发现在连续朗读步骤时标点符号会干扰断句,后来在文案里统一加了句号分隔。这些细节点很小,但对真实用户的帮助非常大。
5. 组件通信与收藏状态同步实战
5.1 组件通信的四种姿势对比
“Flutter 组件通信怎么选”是群里被问烂的问题,我这次项目里正好四种方式都用上了,直接拿菜谱场景说人话。最朴素的姿势是构造函数传参,父组件把数据直接塞给子组件,适合静态展示型组件,比如StepItem(step: step),简单直接,没有任何学习成本。第二种是回调函数,子组件需要把事件抛给父组件时用,比如步骤卡片点击事件通过onStepTap回调交给页面容器处理,适合单向事件流。
第三种是InheritedWidget/Provider,适合数据要跨多层或跨页面共享的场景,典型例子就是收藏状态。第四种是事件总线,适合完全解耦的跨模块通信,比如用户在详情页完成了收藏操作,同时要通知首页的推荐位刷新“猜你喜欢”的权重,这种情况下用EventBus发一个FavoriteChangedEvent,首页监听后主动拉新数据。我在项目里做了对比:
| 通信方式 | 适用场景 | 缺点 |
|---|---|---|
| 构造参数传值 | 父传子、静态数据 | 多层传递繁琐 |
| 回调函数 | 子传父、单向事件 | 嵌套过深易混乱 |
| Provider | 跨层共享、跨页同步 | 状态粒度要设计好 |
| 事件总线 | 完全解耦模块通信 | 全局事件难排查 |
我的原则是能用几十行代码解决的通信就不上全局总线,别为了架构而架构。详情页这种规模,Provider 就覆盖了绝大部分场景。
5.2 收藏功能:跨页面状态同步的实现
收藏功能是这个 App 里最能体现状态同步价值的地方。详情页要点亮收藏按钮,收藏列表页要管理收藏条目,首页卡片要判断是否显示收藏角标,这三个地方读的是同一份收藏状态,实际体验必须同步,不能详情页收藏了,首页角的图标还在那儿灰着。
我的实现是全局维护一个FavoritesProvider,挂在MultiProvider顶层。这个 Provider 里维护一个Set<String> _favoriteIds,并提供toggleFavorite(String id)、isFavorite(String id)两个方法。详情页的收藏按钮只调用context.read<FavoritesProvider>().toggleFavorite(recipeId),然后Consumer<FavoritesProvider>会自动重建按钮图标。收藏列表页因为用context.watch<FavoritesProvider>()监听收藏集合,增删的瞬间列表就会自动刷新。整个过程完全不需要手动发事件通知。
这里要提到一个我用过但后来抛弃的方案:收藏状态不全局化,而是放在详情页 Provider 里,收藏发生后再用事件总线通知收藏列表页刷新。问题是启动收藏列表页的时候,它要先查本地缓存才能知道自己收藏过什么,再加上网络往返,响应速度肉眼可见地慢。后来改成全局 Set 走了内存缓存,收藏列表页打开即时有数据,体验直接提升一个档位。如果你正在写类似功能,收藏这种轻量但高频读写的状态,直接全局化就好,别绕弯子。
经验:跨页同步要先判断数据源头在哪。菜谱内容是服务端数据,用 id 拉最新没问题;收藏状态是用户本地行为,全局内存缓存反而是最合理的,谁读都一致,不需要请求服务端。
5.3 计时器与弹出层的小技巧
菜谱详情页里“开始烹饪计时”这个交互,我一开始以为就是放个Timer.periodic计数,结果在 OpenHarmony 真机上遇到了麻烦。App 切后台锁屏,Timer继续走了但 UI 不会刷新,回到前台时秒数拉了一大截;更严重的是一不小心在页面销毁时没有cancel定时器,Dart 虚拟机直接打出 unhandled exception,日志开头那串e/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)]我第一次看到还以为是引擎崩了,后来一查才知道是 Dart 侧未捕获异常的统一出口。
计时器方案我后来收敛成两步:Timer.periodic只负责前台 UI 刷新的秒数跳动,真正的计时基准始终用DateTime.now()做差值计算。这样回前台时重新计算差值,秒数不会跳变,也免去了后台计时不准的问题。页面销毁时,在dispose里无条件cancel定时器,并且给计时回调加一个mounted判断,双保险杜绝回调访问已销毁状态的问题。
弹出层这里也提一个细节:步骤弹出详情页的底部弹出层,我用了showModalBottomSheet,在 OpenHarmony 上如果弹出层里有图片,弹层打开时背景页面可能出现白屏闪烁。这是适配版里比较常见的问题,我的解决办法是在弹层打开前先预加载弹层里的图片到内存缓存,这样弹出时不再触发首次解码。实测闪烁基本消失,你可以直接套用。
6. 真机调试中的常见问题与性能优化
6.1 常见崩溃与异常排查
这节把我在详情页从开发到真机自测过程中,实际遇到并解决的核心异常清单整理出来。排在第一的就是前面说的dart_vm_initializer.cc(41)未捕获异常,这个错误本身不是 bug,而是 bug 的出口,关键是看它后面跟的完整堆栈。例如有一次详情页加载失败,我直接跳转了一个不存在的路由名称,Navigator 抛了异常但页面没有 catch,日志就落在了这个错误里。解决办法是给路由跳转统一封装一层guard,捕获异常并做 toast 提示,不让它冒泡到引擎层。
第二个高频问题跟图片解码相关:CachedNetworkImage加载一张超大尺寸图,在低内存开发板上偶发崩溃或黑屏。崩溃的直接表现是 Flutter 的 Skia 引擎报OOM或者干脆无响应。我的处理是在列表和详情页统一使用ResizeImage约束解码尺寸,同时给详情页的封面单独设置cacheWidth,等于把图片解码前的尺寸直接压到需要的宽度,内存压力顿时小了很多。这招 Android 端同样适用,属于通用经验。
第三个坑比较隐蔽:在 OpenHarmony 模拟器里执行flutter logs拿日志会偶发丢行,特别是在频繁滚动页面时报错时,日志中间会断。排查方法是用 DevEco Studio 连接真机抓完整 hilog,然后用hilog | grep flutter过滤 Flutter 引擎的输出。换了工具后我几次定位疑难 bug 都顺利多了。
6.2 列表滚动性能优化实录
详情页主要是单页滚动,但步骤多时整个滚动链路的性能还是要抠一抠的。最直接的痛点是步骤图片在滚动进入视野时触发解码,导致掉帧卡顿。我给步骤图加了cached_network_image的预缓存逻辑,进入详情页后立刻把步骤列表里所有图片的 URL 提交给CachedNetworkImageProvider进行后台预取,滚动时图片基本已经在磁盘缓存里了,解码开销小很多。
第二个重点是避免不必要的重建。详情页里有大量页内状态,比如步骤完成标记、评分、收藏按钮,如果整个详情页是被一个大Consumer包住的,任何状态变化都会导致整页CustomScrollView重建,滑动时就容易有迟滞感。我把页面拆成多个小范围Consumer,每个Consumer只监听它负责的 Provider key,其他区域完全不感知变化。实测滑动流畅度明显改善,滚动帧率稳定多了。这个优化思路对你自己的 Flutter 项目同样有效,记住一句话:状态对象越大,监听范围就要越小。
第三个容易被忽略的点是RepaintBoundary。我把封面图、食材卡片、步骤时间轴这三个相对独立的绘制区域分别包上RepaintBoundary,这样这些区域单独重绘时不会拖累整页渲染。虽然 RepaintBoundary 本身有开销,但在滚动场景里收益大于成本。
6.3 多设备适配的经验
OpenHarmony 设备的碎片化比 Android 还夸张,不同厂商的开发板、手机、平板屏幕规格差异很大。详情页适配我遇到的最典型表现是:在一个 6.7 英寸的测试机上布局正常,换到某款竖屏比例比较奇怪的开发板上,封面图拉伸变形,步骤文本间距也乱了。
原因是封面图高度我固定成了MediaQuery.of(context).size.height / 2.8,这个比例在普通屏上没问题,但在超长屏上导致图片高度过大,文案区域被挤下去。后来我改用AspectRatio将封面图锁定在 3:2 的宽高比,再配合BoxFit.cover裁切,不管屏幕多长多短,封面比例始终稳定。食材卡片和步骤卡片的宽度也统一改为相对屏幕宽度的百分比约束,而不是像素值硬编码。
字体的多设备适配前面说了是跟随系统缩放,这里我再补充一个点:平板上做菜时,用户往往会把设备立在支架上看,页面底部如果放操作按钮,容易被设备底部手势条遮挡。我给详情页底部操作栏加了SafeArea和viewPadding的合并处理,保证按钮始终在安全区域内。这个坑在模拟器上基本不会出现,但真机横屏、手势导航场景一开就露馅。
多设备适配还有一个值得注意的问题:覆盖图动画。步骤完成时序号圆点会有个缩放动画,在低端设备上动画会显得发钝。我把动画时长从固定的 250ms 改成MediaQuery.disableAnimations为 true 时直接跳到终态,既保住了动画体验,又照顾了低性能设备和无障碍用户。
最后聊点我这次的亲身感受
整个详情页从搭环境到真机稳定跑通,我大概花了一周多的时间,其中近一半都消耗在环境配置和图片相关问题上。这也是我写这篇复盘的主要原因——Flutter for OpenHarmony 的资料远不如 Android 丰富,很多坑都得靠自己在真机上撞。如果你的项目也在做类似的详情页,我建议你把“图片尺寸压缩、State 监听范围控制、页面销毁时清理定时器”这三件事放在最早优先级处理,这三点做好了,后面基本能少踩一半的坑。另外,有条件的话一定要尽早借一台真实的 OpenHarmony 设备来测试,模拟器能验证逻辑,但画布渲染、内存表现、手势交互,只有真机不会骗你。