资讯详情

资讯详情

Flutter for OpenHarmony 美食推荐菜谱实战:从环境搭建到推荐策略落地

最近我把手头一个 Flutter for OpenHarmony 项目往前推进了一大步做的是美食烹饪助手 App重点实现了首页的推荐菜谱功能。整个功能从数据设计、推荐逻辑到界面呈现完整跑通了一版。这篇就把实战过程从头捋一遍环境怎么搭、推荐策略怎么定、核心代码怎么写、期间踩了哪些坑希望能帮到正在折腾 Flutter 跨端开发往 OpenHarmony 上迁移的朋友尤其是想实现类似推荐列表功能的人。先说下背景美食烹饪助手 App 的定位很直接帮用户解决今天吃什么和这道菜怎么做两个问题。推荐菜谱是打开 App 后的第一屏这一屏做得好不好直接影响用户愿不愿意继续用。我这一版没有上什么高深的推荐算法而是用一套可解释、可调整的规则评分方案先把体验跑顺后续再逐步优化。1. 项目背景与技术选型1.1 为什么选择 Flutter for OpenHarmonyOpenHarmony 这几年在开源生态里发展很快各种设备都在接入对开发者来说代码能覆盖的设备种类越多投入产出比就越高。但有个现实问题OpenHarmony 原生开发用的是 ArkUI 那套声明式语法跟 Android 的 View 体系、iOS 的 UIKit 完全不是一回事。如果团队已经有 Flutter 技术积累直接切到原生 UI 框架等于把业务层重写一遍成本很高。Flutter for OpenHarmony 解决的就是这个痛点。它是基于 Flutter 框架适配 OpenHarmony 平台的发行版本让 Flutter 的 Dart 代码可以直接编译、运行在 OpenHarmony 设备上。对我这种有 Flutter 项目经验的人来说业务层代码基本不用动主要精力花在环境搭建和工程适配性价比很高。我在选型前也对比过其他方案比如用 Web 技术套壳虽然也能跑但性能和交互体验还是比不过 Flutter 这种自绘引擎。还有一个重要的点Flutter 的渲染是自己用 Skia 引擎画出来的跨平台 UI 一致性非常好。同一套代码在 Android、iOS、OpenHarmony 上渲染出来的界面几乎一样这对 App 多端统一体验来说太关键了。1.2 技术方案对比与选型逻辑做技术选型时我把几个候选方案放在一起比了一下方案迁移成本UI 一致性生态复用性能表现推荐场景ArkUI 原生开发高业务代码基本重写高一般需重新找组件库好只专注 OpenHarmony 单平台Web 套壳方案低前端代码复用一般受 WebView 限制高前端生态中等复杂交互略卡对性能不敏感的简单页面Flutter for OpenHarmony低Dart 代码复用高自绘引擎保证一致性高Flutter 社区资源丰富好接近原生已有 Flutter 项目、追求多端一致我最后选了 Flutter for OpenHarmony。这个选择的核心逻辑是Flutter 已经把渲染、手势、动画、组件体系这些底层能力封装得足够好我只需要关注业务逻辑本身。而且社区里有大量现成的包可以直接用不用从零造轮子。当然也要如实说Flutter for OpenHarmony 目前还不是所有 Flutter 插件都完全兼容个别依赖原生能力的包需要自己适配这个后面会细讲。1.3 美食烹饪助手的功能全景整个 App 的功能规划我这版主要聚焦在几条主链路上首页推荐用户打开 App 看到的第一个页面展示一组经过评分排序的菜谱卡片支持按分类筛选和下拉刷新菜谱详情点进菜谱卡片后可以看到食材清单、烹饪步骤、耗时、难度等信息收藏管理用户可以把喜欢的菜谱收进收藏夹这个操作同时也会反向影响推荐结果分类浏览按家常菜、汤羹、主食、甜点等一级分类浏览菜谱库推荐菜谱功能是整个 App 的流量入口也是我投入最多精力去打磨的部分。它的核心体验目标有三个推荐结果要有人味不能每次打开都一样、要能反映用户的偏好变化、加载要够快不让人等。2. 环境搭建与工程创建2.1 安装 Flutter for OpenHarmony 环境环境搭建是 Flutter for OpenHarmony 开发里第一个劝退点因为它的步骤比普通 Flutter 开发多几步而且版本适配信息比较分散。我把自己实操通过的一套流程写在这里。第一步是准备基础工具链。OpenHarmony 的构建依赖 Node.js、命令行工具 hdc、以及对应的 SDK这些都需要提前装好。SDK 版本我建议直接用 API 10 或更高低版本的话有些新特性用不了后续还要来回升级比较烦。第二步是关键Flutter for OpenHarmony 不能用官方发布的普通 Flutter SDK要从 OpenHarmony 社区维护的发行版仓库里下载专用 SDK。我当时就在这吃过亏先装了普通 Flutter然后怎么配都报找不到 OHOS 平台。拿到正确的 SDK 后把它解压到一个专门的目录比如~/flutter_ohos_sdk然后把bin路径写进环境变量export FLUTTER_OHOS/Users/me/flutter_ohos_sdk export PATH$FLUTTER_OHOS/bin:$PATH这里有个小细节值得注意由于两个 Flutter 版本的命令都是flutter如果你电脑上还装了原生 Flutter建议用完就切环境或者直接在项目里用绝对路径调用不然容易搞混版本。第三步是安装配套 IDE 里的 OpenHarmony 插件。新建工程和跑模拟器的时候会用到装好后 IDE 里会出现 OpenHarmony 相关的设备管理和工程模板入口。2.2 创建工程并配置 OHOS 平台支撑环境就绪后创建项目其实很简单flutter create food_cooking_assistant cd food_cooking_assistant flutter create . --platformsohos第一条命令生成标准 Flutter 工程骨架第二条命令为当前工程追加 OpenHarmony 平台支持。执行完后工程目录里会多出一个ohos文件夹里面就是 OpenHarmony 侧的壳工程配置包括module.json5这类应用配置文件以及EntryAbility入口类。这里我提一个很多人容易忽略的点生成完工程后最好去ohos目录下检查一下默认的包名和应用名称。默认生成的 bundleName 可能带com.example之类的占位符发布到不同环境时要改成自己应用的标识不然后面签名、安装环节容易出问题。另外ohos侧的能力声明也需要根据 App 需求补齐。我的美食助手需要联网加载推荐数据那时候就要在配置里申请网络权限{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }忽略权限的话页面加载时网络请求会静默失败排查起来还挺费劲的。2.3 模拟器与真机运行验证工程配置好后连接 OpenHarmony 设备。模拟器测试用起来最方便在 IDE 里启动模拟器后用flutter devices应该能看到对应的设备 IDflutter devices flutter run -d device-id如果是真机需要用 USB 连接并确保开发者模式已开启。连接后用hdc list targets检查设备是否被识别这个命令类似移动开发里常见的adb devices。确认设备在线后再执行flutter run。首次构建会非常漫长因为要拉取大量依赖并完成原生侧编译。我当时第一次跑起来大概用了十几分钟中间一度以为卡死了。这里有两个提速技巧一是把项目里的依赖仓库地址换成国内可快速访问的镜像速度会有明显提升二是构建过程中不要频繁中断中断一次后面重新来更费时间。跑起来之后Flutter 的热重载机制在 OpenHarmony 上大部分场景是生效的。不过实测下来偶尔会出现热重载不生效的情况比如修改了原生侧配置或者新增了资源文件。我的习惯是优先用热重载刷新 UI一旦发现改动没生效就重启flutter run不要反复尝试浪费时间。3. 推荐菜谱功能设计与数据层3.1 推荐策略的取舍与设计推荐功能听起来很高级但第一版我刻意没有引入复杂的机器学习模型。原因很简单冷启动阶段没有足够的用户行为数据模型也喂不饱而且这版 App 的菜谱库规模有限用规则评分的方式完全能给出不错的结果还更容易解释和调试。我的推荐策略核心是加权评分 多样性打散。加权评分就是给每个菜谱算一个综合分综合分由几个维度加权求和得到热度分基础烹饪次数越高说明越多人做加分口碑分用户评分越高加分新鲜度分新收录的菜谱获得一定的加权避免老菜谱永远霸榜个性化分根据用户收藏的菜品分类对对应分类的菜谱加权难度调节默认偏向家常难度刚上手的用户不做太复杂的菜光有评分还不够。如果只按分数排序首页可能出现一堆红烧肉、一堆土豆丝观感很差。所以我在排序后做了一步多样性打散同一分类下最多连续出现两个菜谱保证首页信息流内容丰富。这一点看上去简单但对推荐体验的改善非常明显。另外我还留了随机种子这个参数用户每次下拉刷新时部分菜谱的分数会叠加一个较小的随机扰动这样用户多次刷新不至于每次都看到一模一样的列表。3.2 菜谱数据模型设计数据是一切功能的基础。我定义了一个Recipe模型来承载菜谱信息字段设计上覆盖了推荐、展示、详情三个环节的需要enum RecipeCategory { home, soup, staple, dessert, seafood } class Recipe { final String id; final String name; final RecipeCategory category; final ListString ingredients; final ListString steps; final int cookMinutes; final int difficulty; final double rating; final int cookCount; final DateTime createdAt; Recipe({ required this.id, required this.name, required this.category, required this.ingredients, required this.steps, required this.cookMinutes, required this.difficulty, required this.rating, required this.cookCount, required this.createdAt, }); }首次做这个模型时我一度漏掉了cookCount和createdAt这两个字段导致推荐引擎里最基础的热度分和新鲜度分没法计算。后来补测试的时候才发现所以提醒大家像推荐这种依赖排序的功能数据模型一定要先想清楚哪些字段会被排序规则用到再动手建表或写 JSON。3.3 本地数据资源与图片管理这版 App 的数据源我用的是打包进工程的本地 JSON菜谱图片则走本地资源目录。这样做的好处是减少运行时网络依赖也让首版功能更容易调试。JSON 文件放在assets/data/recipes.json。图片我单独建了一个assets/images/recipes/目录文件名用菜谱 ID 关联比如recipe_001.png这样加载图片时可以直接拼路径。资源目录配置要在 Flutter 的pubspec.yaml里声明flutter: uses-material-design: true assets: - assets/data/recipes.json - assets/images/recipes/这里有个常见的坑资源路径一旦写错运行时不报编译错误而是直接白屏或图片空白。所以每次新增资源后我建议先通过flutter run跑一遍确认资源能被加载再做业务开发。4. 推荐菜谱核心实现4.1 推荐引擎的评分逻辑实现推荐引擎我单独封装成了一个类RecommendationEngine核心逻辑就是遍历菜谱集合、逐个计算综合分、按分数排序、再做多样性打散。直接贴关键代码class RecommendationEngine { final UserProfile userProfile; RecommendationEngine({required this.userProfile}); ListRecipe recommend(ListRecipe allRecipes, {int pageSize 10, Random? seed}) { final Random random seed ?? Random(); final List_ScoredRecipe scored []; for (final recipe in allRecipes) { double score 0; // 热度分cookCount 每 10 次加 0.5 分 score recipe.cookCount / 10 * 0.5; // 口碑分rating 最高 5 分加上基础权重 score recipe.rating * 1.5; // 新鲜度分按创建日期衰减越近的菜谱加分越多 final dayDiff DateTime.now().difference(recipe.createdAt).inDays; score (30 - dayDiff).clamp(0, 30) * 0.1; // 个性化分用户收藏分类中的菜谱加权 20% if (userProfile.favoriteCategories.contains(recipe.category)) { score * 1.2; } // 随机扰动±5% 的随机偏移保证刷新后列表有变化 score * 1 (random.nextDouble() - 0.5) * 0.1; scored.add(_ScoredRecipe(recipe: recipe, score: score)); } scored.sort((a, b) b.score.compareTo(a.score)); final ListRecipe result []; final MapRecipeCategory, int categoryCount {}; for (final item in scored) { final category item.recipe.category; final currentCount categoryCount[category] ?? 0; // 同一分类最多连续 2 个超出则跳过当前候选 if (currentCount 2 result.isNotEmpty) { final lastCategory result.last.category; if (lastCategory category) { continue; } } result.add(item.recipe); categoryCount[category] currentCount 1; if (result.length pageSize) break; } return result; } }这段代码里评分公式里每个系数都是我试过几轮之后确定的。比如rating * 1.5这个权重一开始我设成了 2.5结果分数被口碑完全主导新菜几乎没有出头机会调成 1.5 后口碑、热度、新鲜度才比较均衡。多样性打散那里我用了同分类最多连续两个的规则复杂度不高但从实际观感看列表的丰富程度提升非常明显。有条件的场景还可以做更细粒度的分桶打散但首版没有必要。4.2 首页推荐列表 UI 搭建推荐结果的展示我采用了下拉刷新 列表卡片的形式。首页顶部放一个横向滚动的分类筛选栏下面跟着推荐卡片列表。UI 层我用的是 Flutter 标准的StatefulWidgetFutureBuilder组合class RecommendPage extends StatefulWidget { override _RecommendPageState createState() _RecommendPageState(); } class _RecommendPageState extends StateRecommendPage { final _engine RecommendationEngine(userProfile: UserProfile()); late FutureListRecipe _future; override void initState() { super.initState(); _future _loadRecommendList(); } FutureListRecipe _loadRecommendList() async { final allRecipes await RecipeRepository.loadAll(); return _engine.recommend(allRecipes, pageSize: 10, seed: Random()); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(今日推荐)), body: FutureBuilderListRecipe( future: _future, builder: (context, snapshot) { if (snapshot.connectionState ! ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return Center(child: Text(加载失败${snapshot.error})); } return RefreshIndicator( onRefresh: () async { setState(() { _future _loadRecommendList(); }); await _future; }, child: ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { return RecipeCard(recipe: snapshot.data![index]); }, ), ); }, ), ); } }RefreshIndicator是 Flutter 自带的 Material 风格下拉刷新组件在 OpenHarmony 上也能正常使用这点让我省了不少功夫。下拉刷新的回调里我重新调了一次_loadRecommendList()因为每次会传一个新的Random()种子所以刷新后列表会出现一定程度的重新排序用户每次下拉都有新内容。4.3 菜谱卡片与详情页联动菜谱卡片RecipeCard我做成了一张信息密度适中的卡片左侧是菜谱缩略图右侧上方是菜名中间是一行小标签展示分类、耗时、难度下方是评分和收藏按钮。点击卡片整体区域可以跳转到详情页。卡片跳转详情页的核心代码如下class RecipeCard extends StatelessWidget { final Recipe recipe; const RecipeCard({super.key, required this.recipe}); override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { Navigator.of(context).push( MaterialPageRoute( builder: (context) RecipeDetailPage(recipe: recipe), ), ); }, child: Row( children: [ Image.asset( assets/images/recipes/${recipe.id}.png, width: 110, height: 110, fit: BoxFit.cover, ), Expanded( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(recipe.name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 6), Text(${recipe.categoryLabel} · ${recipe.cookMinutes}分钟 · ${难 * recipe.difficulty}), const SizedBox(height: 6), Row( children: [ const Icon(Icons.star, color: Colors.orange, size: 18), Text(${recipe.rating} 分), const SizedBox(width: 12), Text(${recipe.cookCount} 人做过), ], ), ], ), ), ), ], ), ), ); } }这段代码看起来中规中矩但实际开发中有两个细节容易翻车。一个是Image.asset的路径字符串拼错前面说的资源问题会在这一刻集中爆发。另一个是InkWell点击水波效果在 OpenHarmony 上默认是正常的但如果外层Card的clipBehavior没设置好水波会溢出圆角区域观感很丑。这两处都是我实测中调整过的点。4.4 分类筛选与收藏联动分类筛选我给首页顶部加了一条横向TabBar可选全部、家常菜、汤羹、主食、甜点、海鲜。选中的分类会传给推荐引擎在评分前先做一次候选集过滤这样用户切分类时看到的就是该分类下的推荐结果。收藏功能的联动逻辑在详情页触发。用户点击收藏后我会把菜谱所属的RecipeCategory写入UserProfile.favoriteCategories然后在下一次推荐计算时这个分类的菜谱就会获得 1.2 倍加权。这个设计的背后逻辑很直接用户收藏某种类型的菜侧面反映了他想多看这类菜推荐系统应当快速感知到这个信号。收藏联动这块要特别注意状态刷新时机。我第一版实现里用户从详情页返回首页后列表不会自动刷新用户看到的结果和收藏前完全一样体验很分裂。后来我在详情页返回时加了一个回调通知首页收到通知后重新拉取推荐列表才把这个问题解决Navigator.of(context).push(...).then((needRefresh) { if (needRefresh true) { setState(() { _future _loadRecommendList(); }); } });5. 常见问题与避坑指南5.1 编译运行阶段的高频问题Flutter for OpenHarmony 目前还不算非常成熟编译运行期遇到的问题比成熟平台多一些。我把高频问题和处理方案整理成了表格方便直接对照问题现象可能原因排查与处理提示找不到 OHOS SDK环境变量未配置或路径错误检查LOCAL_HOS_SDK等环境变量是否指向 SDK 安装目录flutter devices看不到 OpenHarmony 设备hdc 服务未启动或驱动问题执行hdc list targets重启 hdc 服务后重试首次构建很慢或超时依赖下载慢为依赖仓库配置国内镜像或使用预缓存热重载修改 UI 不生效原生侧配置或资源文件变更手动重启flutter run别死磕热重载运行时报错找不到ohos目录工程还没执行flutter create . --platformsohos在项目根目录执行该命令等待完成这里面最折磨人的是环境变量问题。OpenHarmony 的 SDK 查找逻辑跟 Android 的ANDROID_HOME类似但支持的环境变量名不太一样而且不同版本还可能有差异。我折腾了很久才发现是当时电脑里同时存在多套 SDK 环境变量路径互相覆盖导致。建议这类问题优先用命令行flutter doctor检查环境完整性它会把关键的环境问题直接列出来。5.2 图片资源与内存问题推荐列表页面最常见的问题是图片加载导致的卡顿和内存上涨。刚开始我用Image.asset直接加载所有卡片图片页面滚动到后面帧率明显下降内存峰值也比预期高不少。排查后发现两个根因一是图片本身分辨率偏高直接用原图渲染浪费内存二是列表页一次性加载过多图片没有做缓存复用。我的优化方案有三点图片统一压缩到 1x 尺寸长边控制在 400 像素以内满足卡片展示就够使用cacheWidth参数让 Flutter 在解码时按需縮放避免大图占内存在列表滚动场景用ListView.builder做懒加载只在即将进入可视区域时才构建卡片Image.asset( assets/images/recipes/${recipe.id}.png, width: 110, height: 110, fit: BoxFit.cover, cacheWidth: 220, )cacheWidth是 Flutter 提供的一个非常实用的参数。它告诉图片解码器我只需要 220 像素宽的图解码后的图片在内存里的占用会小很多。实测下来这个参数配合懒加载列表滚动流畅度提升明显。5.3 状态更新与交互细节推荐列表这类功能状态更新的时机把握很关键。一个典型的问题是快速下拉刷新时旧数据还没有返回用户又触发了一次刷新就会出现竞态条件最终页面可能显示旧请求的数据。我处理的方法是给每次刷新请求加一个自增序号只在最新请求返回时更新 UIint _requestSeq 0; Futurevoid _refresh() async { final seq _requestSeq; final recipes await _loadRecommendList(); if (seq _requestSeq mounted) { setState(() _recipes recipes); } }另外一个交互细节是空状态处理。当某个分类下暂时没有菜谱时我给了一个友好的空页面提示配了一句这个分类正在准备中换个分类看看吧避免用户面对一片空白不知所措。这个细节虽然很简单但对完成度观感的影响很大。6. 一些我个人的心得体会这版 Flutter for OpenHarmony 推荐菜谱功能做下来我最深的感受是跨端框架的价值在于让业务逻辑复用但平台的差异依然存在尤其是环境配置和底层能力适配这两个环节需要足够的耐心去试错。推荐功能本身反而是最有迹可循的部分用一套清晰的评分规则就能把体验做到及格以上。如果后面要继续扩展我建议可以往这几个方向推进一是把推荐日志接进来记录用户看到哪些菜、点了哪些菜用真实行为数据反哺评分权重二是把今日推荐升级成根据时段推荐比如早上推粥品、晚上推快手菜三是尝试引入更丰富的用户画像字段。这个项目现在跑在模拟器上效果很稳下一步我打算放到真机上继续打磨实际体验到时候再分享更多细节。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →