1. 技术与方案选型解析
做美食烹饪助手 App 这个项目时,我最先纠结的不是菜系分类怎么写,而是“到底要不要用 Flutter for OpenHarmony”。市面上主推的鸿蒙原生开发是 ArkTS 加 ArkUI,官方文档和社区资料都偏向这条路线。但我手里的产品本身已经有一套用 Flutter 写的菜谱浏览框架,如果单独为 OpenHarmony 再写一套 ArkTS 版本,等于要维护两套业务代码。所以我的选择很直接:用 Flutter for OpenHarmony 做跨端复用,这套美食 App 的核心页面、菜系分类逻辑、菜谱详情全部在 Flutter 层完成,只在必要的时候通过平台通道调 OpenHarmony 的原生能力。
1.1 为什么选择 Flutter for OpenHarmony 而非 ArkTS 重写
我没有全盘否定 ArkTS。如果你是从零开始做一个只面向 OpenHarmony 的小工具,ArkTS 的成熟度、官方支持力度确实更好。但美食烹饪助手这种内容型应用,页面结构复杂、列表多、筛选逻辑重,Flutter 的声明式 UI 和热重载能明显提高迭代效率。而且团队里已经有 Dart 和 Flutter 的积累,让成员去学 ArkTS 的成本比接入 OpenHarmony Flutter SDK 的成本高得多。
这里有个关键点:Flutter for OpenHarmony 并不是 Flutter 官方直接支持的 Target,它是由 OpenAtom 基金会和 OpenHarmony SIG 维护的移植版本,底层通过适配层把 Flutter Engine 接到 OpenHarmony 的图形栈和事件系统上。这意味着你不能直接拿 Flutter 官方 SDK 编译 OpenHarmony 应用,需要下载专门维护的 flutter_flutter 仓库,切换到 OpenHarmony 分支,然后配置对应的 ohos-sdk 路径。
从实际开发体验看,Dart 层代码的写法跟标准 Flutter 几乎没有区别,我写的菜系分类网格、状态管理、路由跳转,在 Android 和 OpenHarmony 上跑的是同一套逻辑。真正不同的是构建流程和打包产物,OpenHarmony 最终产出的是 hap 包,通过 hvigor 工具链构建,而不是 gradle 或 xcodebuild。
1.2 菜系分类功能在美食 App 里的业务定位
菜系分类不是简单的标签列表,它决定了一个美食 App 的内容组织方式。用户在烹饪助手类应用里的典型路径是:第1步确定今天想吃什么方向,第2步在对应菜系里找具体菜品,第3步进详情页看做法。所以菜系分类功能其实是整个 App 的导航中枢,它的体验好坏直接影响用户找菜效率。
我在设计这个功能时把菜系体系分成了三层。第一层是地域菜系,比如川菜、粤菜、湘菜、江浙菜;第二层是场景菜系,比如快手菜、家常菜、宴客菜、减脂餐;第三层是食材与技法,比如鸡肉类、牛肉类、炖菜、蒸菜。这个分类模型不是拍脑袋定的,我调研过市面上几款主流菜谱 App,它们的分类维度基本都能映射到这三个方向上。在地域菜系里,我保留了“全部”选项,这样用户进入分类页时不会因为默认选中某一个菜系而看不到全局内容。
分类体系里需要特别注意边界问题。比如麻婆豆腐,它既属于川菜,又在“下饭菜”这个场景里有很高的点击率。如果每个菜品只能归属一个分类,用户从场景维度就搜不到它了。我的做法是给每个菜品增加一个分类数组字段,主分类用于网格展示,次分类用于搜索和推荐匹配。这样菜系分类就不只是入口,还能反向喂给推荐系统做菜品相关性计算。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 菜系分类的数据模型与页面设计
2.1 分类数据模型的字段设计
菜系分类的数据结构我最终确定为一个嵌套模型。顶层是 CusineType,包含 id、name、icon、color、sortWeight 这几个字段。icon 用的是本地资源路径,color 用在分类卡片的背景色和选中态的指示条上。sortWeight 是排序权重,像川菜、粤菜这类高频菜系权重低排前面,小众菜系权重高排后面。
菜品本身的数据模型是 DishRecipe,包含普通字段比如 title、ingredients、steps、duration、difficulty,还有两个跟分类相关的核心字段:primaryCuisineId 和 cuisineTags。primaryCuisineId 表示菜品的归属主分类,cuisineTags 是一个列表,存这道菜涉及的所有分类标签。比如麻婆豆腐的 primaryCuisineId 指向“川菜”,cuisineTags 同时包含“川菜”“下饭菜”“豆制品”。
这套模型在实现分类筛选时非常顺畅。用户选中某个菜系 Tab,列表页直接用 primaryCuisineId 做主过滤条件,性能高;而搜索页或者“猜你喜欢”区块,直接用 cuisineTags 包含匹配,语义也更灵活。这个设计看起来很简单,但确实是从实际需求倒推出来的,最早我图省事只做了单分类字段,后来发现“红烧肉到底算家常菜还是荤菜”这类归属争议根本无法处理。
JSON 数据结构大致是长这样的。我没上服务端数据库交互,第一阶段为了快速验证功能,分类数据和菜谱数据全部本地化,打包成 assets 目录下的 json 文件随应用分发。
菜谱数据的 JSON 格式:
json复制{
"cuisineTypes": [
{"id": "chuan", "name": "川菜", "icon": "assets/icons/chuan.png", "color": "#D84315", "sortWeight": 1},
{"id": "yue", "name": "粤菜", "icon": "assets/icons/yue.png", "color": "#2E7D32", "sortWeight": 2}
],
"recipes": [
{
"id": "shui_zhu_niu_rou",
"title": "水煮牛肉",
"primaryCuisineId": "chuan",
"cuisineTags": ["chuan", "xia_fan_cai", "niu_rou"],
"ingredients": ["牛里脊", "豆芽", "干辣椒", "花椒"],
"steps": ["牛肉切片腌制", "炒底料加水烧开", "下牛肉煮至变色", "泼热油"],
"duration": 30,
"difficulty": 3
}
]
}
2.2 分类页的交互布局
分类页我采用了 TabBar 加 GridView 的组合。顶部横向 Tab 展示地域菜系,选中某个菜系后,下面的内容区展示该菜系的全部菜品卡片。每个菜品卡片用两列网格布局,卡片上展示菜品缩略图、名称、烹饪时长和难度星级。这个布局方案借鉴了主流外卖 App 和菜谱 App 的信息密度标准,两列网格在手机竖屏状态下既能展示足够多的菜品,又不会让单张卡片的信息过载。
值得说明的是,Tab 栏我没有放满全部菜系。美食 App 的菜系数量往往有十几个甚至更多,全部塞进横向 Tab 会导致一屏内条目过密,用户滑动找分类的成本反而上升。我做了个折中,Tab 栏只放 8 个高频菜系加一个“全部”,点击“全部”进入二级分类页,里面有按食材、技法和场景组织的完整分类树。
内容区的菜品卡片点击后跳转详情页,这个跳转用的就是 Flutter 的 Navigator。路由命名我统一管理在一个 routes.dart 文件里,避免硬编码字符串散落在各个页面。详情页复用了菜品的 JSON 数据,展示原料清单和分步做法,顶部还有收藏按钮,收藏状态我用一个全局的 FavoriteProvider 管理,这个后面在状态管理部分详细说。
3. 核心功能实现与状态管理
3.1 Provider 状态管理方案
项目里需要跨页面共享的数据主要有三个:当前选中的菜系 Tab、菜品列表的加载状态、用户收藏清单。这类数据如果用 setState 一层层回调传递,代码会很快失控。我的选择是引入 Provider 包做全局状态管理。
为什么选 Provider 而不是 Bloc 或者 Riverpod?原因很实际:团队里有人之前用过 Provider,学习曲线接近零,而菜系分类这个场景的状态流比较直接,没有复杂的异步事件驱动,Bloc 的优势发挥不出来,反而增加样板代码。Provider 的 ChangeNotifier 机制配合 Consumer 监听,足够覆盖需求。
我在项目中创建了 CategoryProvider 和 FavoriteProvider 两个模型。CategoryProvider 负责维护选中菜系 ID 和分类数据加载逻辑,FavoriteProvider 负责收藏列表的增删和查询。Provider 的注册放在 main.dart 的 MultiProvider 里,这样应用启动时所有页面都能拿到对应的状态对象。
Provider 注册代码:
dart复制void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CategoryProvider()),
ChangeNotifierProvider(create: (_) => FavoriteProvider()),
],
child: const CookingAssistantApp(),
),
);
}
3.2 菜谱数据加载与分类切换逻辑
分类页的数据加载我在 CategoryProvider 里封装了 loadRecipes 方法。这个方法通过 DefaultAssetBundle 读取 assets/data/recipes.json,然后用 dart:convert 的 jsonDecode 解析成 List。数据解析完成后按 primaryCuisineId 分组缓存,这样用户切换 Tab 时不需要重复解析 JSON,直接从内存 Map 里取。
分类切换的核心逻辑是 Provider 的 notifyListeners 调用。当用户在 Tab 栏点击某个菜系,CategoryProvider 里的 selectedCuisineId 被更新,同时触发 notifyListeners。页面上监听这个状态的 Consumer 会自动重建,内容区的 GridView 基于新的 selectedCuisineId 从缓存中过滤菜品。这里我加了简单的防抖处理,防止用户在 Tab 之间快速滑动时频繁刷新,实测对提升流畅度很有帮助。
3.3 分类页面的代码实现
分类页的代码结构分为三部分:顶部 Tab 栏、菜品网格区、加载状态占位。Tab 栏用的组件是 Flutter 自带的 TabBar 加 TabController,这个组合在标准 Flutter 里很成熟,OpenHarmony 移植版也完整支持,没有遇到兼容问题。
菜品网格我用 GridView.builder 构建。其实也可以用 GridView.count,但 builder 模式的好处是懒加载,只在滚动到可见区域时才构建卡片 Widget。当本地菜谱数据量大到上千条时,这种懒加载能让首帧渲染时间明显缩短。
还有一个体验细节是空列表的处理。如果某个菜系当前还没有菜品数据,网格区是一片空白的话,用户会误以为 App 卡死了。所以我加了一个空状态组件,展示一个简单的图标和“这个菜系还在收集菜谱中”的提示文案。这个小功能很容易被忽视,但对内容型应用的完整体验来说非常关键。
分类页核心代码:
dart复制class CuisineCategoryPage extends StatelessWidget {
const CuisineCategoryPage({super.key});
@override
Widget build(BuildContext context) {
return Consumer<CategoryProvider>(
builder: (context, provider, child) {
return Column(
children: [
TabBar(
controller: provider.tabController,
isScrollable: true,
tabs: provider.cuisineTypes
.map((type) => Tab(text: type.name))
.toList(),
),
Expanded(
child: TabBarView(
controller: provider.tabController,
children: provider.cuisineTypes.map((type) {
return DishGridView(
recipes: provider.getRecipesByCuisine(type.id),
);
}).toList(),
),
),
],
);
},
);
}
}
围绕这段代码有两个心得。第一,TabController 的创建最好放在 CategoryProvider 内部,不要在页面里 new TabController,否则状态切换时容易报 controller 已销毁的错误。第二,TabBarView 的子页面是懒加载的,只有滑动到对应 Tab 时才构建,但默认会预加载相邻页面,所以 getRecipesByCuisine 的返回不能为空,空列表也要返回一个空集合而不是 null。
3.4 收藏功能的联动
收藏功能进一步说明了 Provider 跨组件通信的价值。收藏按钮在菜品详情页,但收藏状态需要同步到分类页的菜品卡片上,同时“我的收藏”页面也要实时更新。这三个页面如果没有全局状态管理,数据同步会非常痛苦。
我的实现方案是 FavoriteProvider 维护一个 Set
如果要持久化收藏数据,我建议后续用 SharedPreferences 存一份 ID 列表,App 启动时加载。这个扩展点在代码里也预留了,FavoriteProvider 构造函数里接受一个初始集合,后续初始化时从本地存储读出来传入即可。
4. OpenHarmony 工程配置与打包适配
4.1 搭建 OpenHarmony Flutter 开发环境
这部分是整个项目里最容易被教程一笔带过但新手一定卡壳的地方。Flutter for OpenHarmony 的环境配置不是安装完 Flutter SDK 就能直接跑的,你需要从 OpenHarmony 官方维护的 flutter_flutter 仓库拉取代码,切换到和当前 OpenHarmony SDK 版本匹配的 OpenHarmony 发布分支。
环境配置的核心步骤大概是这样的。首先准备 OpenHarmony SDK,我用的 API 版本是基于 4.0 Release 的分支,对应的 SDK 通过 OpenHarmony 官方命令行工具安装。然后下载 flutter_flutter 仓库,配置 PATH 指向它的 bin 目录。接着用 flutter doctor 检查环境,重点关注 OpenHarmony 项的检测结果,如果提示找不到 SDK 路径,需要手动配置环境变量。
OpenHarmony 工程的初始化命令跟标准 Flutter 创建项目不同,这里用的是 flutter create --platforms ohos 参数。生成的工程目录里除了常规的 android、ios 目录,会多出一个 ohos 目录,它内部的结构是 OpenHarmony 标准的 module 结构,包含 hap 配置文件和 hvigor 脚本。
4.2 hap 包的构建与签名配置
OpenHarmony 应用的可分发产物是 hap 文件,构建工具是 hvigor。在 Flutter 工程里构建 hap 不需要单独敲 hvigor 命令,Flutter 的 build 工具做了封装,直接执行 flutter build hap 就会触发 hvigor 构建流程。
第一次构建时,最常遇到的坑是签名配置缺失。OpenHarmony 不像 Android 有默认的 debug 签名,要打可安装到真机的 hap 包,必须在 build-profile.json5 里配置签名材料。签名材料需要真机设备指纹,这个指纹可以通过 OpenHarmony 的命令行工具从设备获取,然后把设备指纹、证书、私钥信息填到工程配置里。调试阶段我建议配置自动签名,OpenHarmony 开发工具支持根据真机信息自动生成调试证书,比每次手动配置省很多事。
有个构建细节必须提醒:hap 打包后的体积控制。Flutter 引擎相关的 so 库会一起打进 hap 包里,一顿操作下来基础体积可能就有几十 MB。美食 App 里我为了避免包体过大,对图片资源做了压缩,本地 JSON 数据保持精简格式,不用冗余字段。
4.3 页面适配与真机调试
OpenHarmony 的屏幕适配逻辑和 Android 大体一致,都基于密度无关像素,但个别机型的系统字体缩放和行为有差异。我测试的是一台搭载 OpenHarmony 的开发板和平板设备。在开发板上验证时发现,Flutter 默认的字体渲染在低分辨率屏幕上会稍微发虚,需要把文本 scaleFactor 做微调,这个参数在 OpenHarmony 上默认值和 Flutter 标准版不完全一样。
真机调试的连接方式走的是 hdc 命令,它是 OpenHarmony 版的 adb。先把设备用 USB 接到电脑,开启开发者模式,然后通过 hdc list targets 确认设备被识别。之后就可以用 flutter run -d
5. 全流程踩坑记录与问题排查
5.1 高频报错与解决方案
开发过程中我整理了三个出现频率最高的问题,这些很大概率也是大家入坑时最先遇到的。
第一个问题是编译时报找不到 OpenHarmony SDK 路径。这个错误通常出现在从 Android 项目切换分支后,flutter config 还残留着旧的 SDK 配置。解决办法是重新执行 flutter config --ohos-sdk-path [安装目录],然后删掉工程里的 build 目录重新构建。我试过直接改配置文件不生效的情况,最保险的还是命令行工具有效地更新配置。
第二个问题是在真机上应用启动白屏。排查后发现跟渲染引擎有关。OpenHarmony 上的 Flutter 默认使用自研的渲染引擎,部分版本对某些 GPU 驱动存在兼容问题。解决办法是切换到 Impeller 渲染后端尝试,或者反过来如果 Impeller 有问题就回退到 Skia 后端。具体的切换方式是在 flutter run 时加 -enable-impeller 参数,或者默认开启后加 -no-enable-impeller 关闭。这个问题的表象是白屏,但原因却各不相同,我的实测结论是不同 OpenHarmony 版本的兼容表现有差异,必须实测确认。
第三个问题是网络请求失败。我的美食 App 用的是本地 JSON 数据,没有遇到这个问题,但测试阶段尝试接在线菜谱接口时,发现 OpenHarmony 平台默认禁止 HTTP 明文流量。解决方案是在工程的网络安全配置文件里添加对应域名的明文流量许可。这个限制跟 Android P 之后的默认策略是一致的。
5.2 常见问题速查表
为方便其他开发者排查,我把遇到的问题整理成了速查表。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 编译报错找不到 ohos sdk | 环境变量未配置或配置错误 | 重新配置 flutter config --ohos-sdk-path |
| 真机白屏 | 渲染引擎兼容性问题 | 切换 Skia 和 Impeller 后端测试 |
| 图片无法显示 | 资源路径未适配 OpenHarmony | 确认 assets 声明在 ohos 配置中 |
| Tab 栏切换闪退 | TabController 生命周期管理错误 | 将 controller 放到 Provider 中管理 |
| 中文文本显示为方块 | 系统字体缺失 | 在工程中嵌入中文字体文件 |
| hap 安装失败 | 签名配置缺失 | 配置自动签名或手动添加调试证书 |
5.3 排查工具与调试心得
Flutter 开发者的第一排查工具就是日志。OpenHarmony 上运行 Flutter 应用,Dart 层的 print 输出会在控制台显示,这和标准 Flutter 行为一致。但遇到引擎层或原生层的问题,Dart 日志就不够用了,需要结合 hdc 命令抓系统日志。常用的命令是 hdc shell hilog | grep flutter,这样可以筛选 Flutter 引擎相关日志,快速定位崩溃位置。
布局问题的排查我还发现了一个实用技巧。OpenHarmony 移植版保留了 Debug 模式下的 Widget 树检查能力,也就是 Flutter 的 debugDumpApp 和 debugDumpRenderTree。当页面出现元素错位、重叠、溢出这类问题时,在代码里临时调用 debugDumpRenderTree,控制台会输出完整的渲染树结构,每个元素的尺寸和约束条件一目了然。这个技术在标准 Flutter 社区里不算新鲜,但 OpenHarmony 的项目资料很少提到它,我确实靠它解决过一个卡片阴影在低配设备上渲染异常的问题。
我在实际开发中还有一个体会是:不要轻易升级 Flutter for OpenHarmony 的版本。不同于官方 Flutter 的稳定节奏,OpenHarmony 移植版的版本更新涉及到底层适配层的改动,有时候修好一个问题会引入新的兼容性缺陷。我固定在已验证通过的版本上做开发,除非有必须用到的新特性,否则不主动升级。
6. 从分类功能到完整 App 的体验优化
6.1 分类页交互细节的打磨
菜系分类功能做到能跑只是第一步,要让它好用,我额外打磨了几个交互细节。第一个是分类卡片上的角标设计。角标展示该菜系下的菜品数量,比如川菜后面加一个“128道菜”的小标签。这个设计对用户找菜有很强的引导作用,也形成了不同菜系之间的内容对比。
第二个是 Tab 切换时的选中态反馈。我在选中的 Tab 下面加了一条短横线指示器,颜色跟随对应菜系的主题色。这个效果在 Flutter 里可以通过 TabBar 的 indicatorColor 和 indicatorSize 参数实现。细节在于指示器宽度不要默认撑满整个 Tab,设置成 indicatorSize: TabBarIndicatorSize.label 会让视觉重心更精致。
第三个是搜索结果与菜系分类的联动。我在分类页顶部加了一个搜索入口,搜索结果的排序逻辑是:先精确匹配菜品名,再匹配分类标签,最后匹配食材清单。分类标签匹配能够保证“下一个饭”这类场景词也能搜出对应的菜,而食材清单匹配则是长尾支撑,利用 JSON 里的 ingredients 数组做模糊查询。
6.2 性能优化记录
美食 App 的页面结构不算复杂,但加载速度直接影响用户留存。我针对分类页做了两个性能优化。第一是图片懒加载。菜品缩略图是本地 Asset,但加载大量图片仍然消耗 IO 和内存,所以我在图片加载组件里加了一层缓存,同一个图片路径在内存中只保留一份有效资源。
第二是解析 JSON 的时机优化。最初的做法是在 App 启动时就加载并解析全部菜谱数据,菜品数量不多的时候没感觉,后来数据源扩展到 500 道菜时,启动时间明显变长。优化方案是延迟加载:App 启动只解析菜系列表,用户首次进入某个菜系 Tab 时才加载并缓存该菜系的菜品列表。由于 TabBarView 的预加载机制,用户滑动到相邻 Tab 时那一页的数据已经准备好了,几乎感知不到加载延迟。
6.3 OpenHarmony 平台特性的利用
既然是在 OpenHarmony 上运行,只把 Flutter 当跨端框架用有点浪费平台能力。我在分类功能这边做了一些平台特性的探索。比如利用 OpenHarmony 的原子化服务能力,把“每日推荐菜”做成了独立的服务卡片入口,用户不需要打开 App 就能在桌面上看到推荐的菜品分类和做法摘要。这个卡片的内容通过平台通道从 Flutter 层获取数据,再通过 OpenHarmony 的原生接口渲染到桌面上。
还有 OpenHarmony 的分布式特性,大屏和平板之间可以协同展示菜谱。当用户在平板上浏览菜系分类时,手机端可以同步显示当前菜品详情,方便用户一边看步骤一边操作。这个功能我目前只做了前期的技术验证,Flutter 层通过平台通道把当前浏览的菜品 ID 同步出去,OpenHarmony 原生端再接分布式协同框架。从验证结果看方案可行,后续产品化潜力很强。
7. 最终落地的经验与建议
7.1 工程组织与团队协作的复盘
项目开发到最后阶段,我复盘了工程组织的得失。最有价值的经验是,从一开始就把业务代码和平台适配代码做了严格分层。Flutter 层只写页面和业务逻辑,所有平台能力的调用封装在 services 目录下,每个服务接口都有 OpenHarmony 和 Android 两套实现。这个分层让团队里负责不同平台的成员能够并行工作,不用互相等代码。
具体到美食 App,我封装了三个平台服务:本地存储服务、桌面卡片服务、设备信息服务。这三个服务都有对应的抽象接口,Flutter 层只依赖抽象接口,平台实现通过 Provider 注入。这样做的好处很明显,后续如果要增加对其它 OpenHarmony 设备的适配,不需要动任何业务页面代码,只要新增平台实现类。
7.2 我的实操体会与扩展方向
这次用 Flutter 开发 OpenHarmony 美食 App 的实战,让我对这个技术方向有了更清晰的判断。Flutter for OpenHarmony 的成熟度比很多人想象中要高,核心列表渲染、状态管理、路由这些基础场景下几乎没有适配问题。但它毕竟不是 Flutter 官方的一等公民,一些边缘特性和插件生态确实不如 Android/iOS 丰富。做项目时提前调研目标功能是否在移植版上可用,比开发到一半再换方案要省力得多。
菜系分类功能做完后,这个美食 App 的底座就扎实了。后续我计划顺着三个方向扩展:给分类页接入真实的远程数据源,引入后端接口做动态分类和推荐;基于已打通的收藏数据做用户画像,增加“你可能喜欢的菜系”推荐模块;以及把已经验证过的桌面卡片方案做成完整的多端协同体验。特别是第二点,这次收藏功能的 Provider 状态管理为后续数据统计提供了清晰的埋点入口,不用再改页面结构。
最后再分享一个小技巧给准备上手的朋友:多利用 Flutter 热重载来调分类页的 UI 细节。选中态的指示器颜色、卡片间距、字体大小,这些参数在真机上通过热重载实时调整,比反反复复打包安装高效太多了。直到现在,我还保持着一个习惯——把项目跑在 OpenHarmony 真机上,边改代码边看效果,这种反馈速度是原生开发很难提供的。
