HarmonyOS 端侧应用实战:中式美食如何把资源、路由、存储和视频拆菜串成一条工程链路

做一个菜谱类应用,表面看是页面和列表,往深一点看,其实是一个很典型的端侧软件工程问题。
中式美食这个项目里,有首页推荐、菜品列表、菜品详情、收藏笔记、最近浏览、视频拆菜、个人厨房这些页面。每个页面单独写出来并不难,难的是页面之间要能稳定流转,数据要能留下来,图片资源不能乱,用户编辑过的内容不能因为切换页面或重启应用就丢掉。
这也是我把这篇投到嵌入式全栈活动里的原因。它不是硬件驱动文章,但它解决的是端侧应用里很真实的一组问题:资源怎么被系统识别,状态怎么在页面之间传递,本地数据怎么落盘,复杂编辑流程怎么拆成可验证的小状态。

一、先说项目环境

项目 说明
应用 中式美食
平台 HarmonyOS NEXT / HarmonyOS 6
工具 DevEco Studio 6.0.0 Release
SDK HarmonyOS SDK API 20
语言与框架 ArkTS / ArkUI V2 / Stage 模型
核心能力 资源注册、路由状态、本地持久化、视频拆菜编辑

这篇不是把某个 demo 跑通,而是从已上架应用里抽出几条稳定链路来看。我的判断标准也很简单:用户点了什么、页面怎么变、数据怎么存、再次打开还能不能恢复。

二、图片资源不能靠字符串临时拼

菜谱应用最容易踩的坑是图片。很多人一开始会想:数据里存一个图片名,然后运行时拼成资源路径。

typescript 复制代码
const imageName: string = 'dish_d001';
Image($r('app.media.' + imageName));

这个思路在普通 Web 图片路径里很常见,但在 HarmonyOS 资源体系里不合适。$r('app.media.xxx') 更像编译期资源引用,不应该依赖运行时字符串乱拼。
中式美食的做法是先做一个图片资源注册表:

typescript 复制代码
const REG: Record<string, Resource> = {
  'dish_d001': $r('app.media.dish_d001'),
  'dish_d002': $r('app.media.dish_d002'),
  'home_card_casserole': $r('app.media.home_card_casserole')
};

export function getImage(key: string): ResourceStr | undefined {
  if (!key) return undefined;

  if (key.startsWith('file://') || key.startsWith('/data/') || key.startsWith('datashare://')) {
    return key;
  }

  return REG[key];
}

这样数据层只保存稳定的图片 key,组件层只管调用 getImage()。至于图片来自内置资源,还是后续用户上传的文件路径,统一在这个入口里处理。
这个改动看起来不大,但收益很明显:DishCardDishTileCuisineDishTile 都可以复用同一套图片逻辑。某条数据缺图时,页面走兜底,不会出现有的页面正常、有的页面空白的情况。

三、路由状态要有一条清楚的主线

中式美食有五个一级入口:首页、分类、收藏、课堂、我的。每个入口下面又会进详情页、筛选页、笔记页、视频拆菜页。
如果所有页面都靠临时字符串跳转,越写越容易乱。尤其是底部 Tab 和二级页面共存时,最容易出现两个问题:

问题 结果
Tab 状态和二级页状态混在一起 返回时不知道该回首页还是回上一级
路由参数散落在页面里 同一个菜品详情页从不同入口进来表现不一致

所以项目里把路由拆成三层:

typescript 复制代码
export enum MainTab {
  Home = 'home',
  Category = 'category',
  Favorite = 'favorite',
  Course = 'course',
  Profile = 'profile'
}

export interface NavState {
  currentTab: MainTab;
  selectedDishId?: string;
  selectedCategoryId?: string;
}

页面不直接猜当前状态,而是通过统一状态去判断:现在在哪个 Tab,下一级页面带了什么参数,返回时应该退到哪里。
这个设计的好处不是"代码更优雅"这么空,而是用户体验更稳定。比如用户从搜索结果进入详情,再收藏一道菜,返回时应该回到搜索结果,而不是突然跳回首页。路由状态清楚以后,这类问题更容易验收。

四、本地持久化要按业务仓储拆开

收藏、笔记、最近浏览都要落在本地。最简单的写法是页面直接读写 Preferences,但这样很快会把页面写脏。
中式美食把本地存储拆成三层:

层级 职责
PrefsStore 只负责 Preferences 的读写
AppStorageDao 负责 JSON 序列化、反序列化和 key 管理
Repository 负责收藏、笔记、最近浏览的业务规则

例如最近浏览不是简单追加,它要去重、置顶、限制最大数量:

typescript 复制代码
export class ViewedRepository {
  addViewed(dishId: string): void {
    const oldList: string[] = this.list();
    const nextList: string[] = [dishId, ...oldList.filter((id) => id !== dishId)].slice(0, 100);
    this.dao.setStringArray('viewed_dishes', nextList);
  }
}

这个规则如果写在页面里,详情页、首页、个人页都会复制一份。写进 Repository 后,页面只表达"用户看过这道菜",仓储自己处理去重和数量上限。
我验收这块时主要看六件事:

步骤 操作 预期
1 收藏一道菜 收藏仓储写入 id
2 重启应用 收藏状态可以恢复
3 重复浏览同一道菜 最近浏览只保留一条并移动到顶部
4 连续写入 105 条浏览记录 只保留最新 100 条
5 新增一条笔记 笔记列表和我的页面统计同步
6 删除本地资产 页面统计回到 0,不残留旧状态

五、视频拆菜不是结果页,而是编辑流程

视频拆菜页最容易做成一个"识别结果展示页"。但真正给用户用时,识别结果一定会有不确定、不完整、不符合个人习惯的地方。
所以中式美食把视频拆菜做成草稿编辑流程。核心模型不是最终菜谱,而是一份可以被人工校对的 VideoRecipeInsight

typescript 复制代码
export type VideoInsightStatus = 'draft' | 'confirmed';
export type VideoFactorStatus = 'detected' | 'uncertain' | 'edited';
export type VideoFactorKind = 'ingredient' | 'step' | 'heat' | 'cue' | 'risk';

export interface VideoFactor {
  id: string;
  kind: VideoFactorKind;
  text: string;
  timeLabel: string;
  status: VideoFactorStatus;
}

这里的 factor 可以是食材、步骤、火候、提示,也可以是风险提醒。每一条都有自己的状态。
这样做有一个很实际的好处:页面不需要把一大段识别文字重新解析来解析去。用户改了某一条,就把这一条标成 edited;用户确认整份草稿,就把草稿状态改成 confirmed

六、这条链路怎么复现

如果要复现这套端侧链路,我建议按下面的顺序检查,不要一上来就看代码:

  1. 打开首页,确认推荐卡片图片正常显示。
  2. 进入列表页,确认不同卡片组件图片高度稳定。
  3. 进入详情页,收藏一道菜,再返回收藏页检查状态。
  4. 重启应用,确认收藏、最近浏览和笔记仍然存在。
  5. 打开视频拆菜页,输入一段做菜文字生成草稿。
  6. 修改某条食材或步骤,确认状态从 detected 变成 edited
  7. 保存草稿,再从我的厨房入口重新打开。
    这套验收比"页面能打开"更有意义。因为端侧应用的问题通常不是第一眼看不到,而是切页面、重启、重复操作以后才暴露。

七、踩坑和取舍

第一,资源要早一点收口。图片数量少的时候,手写判断看不出问题;图片一多,多个组件就会开始分叉。
第二,路由不要只看跳转成功。真正要看的是返回、Tab 切换、参数恢复,这些才是用户每天会碰到的场景。
第三,持久化不要直接塞进页面。页面越干净,后面做测试、做状态恢复越容易。
第四,视频拆菜这种功能不要假设识别结果天然正确。只要给用户编辑空间,就必须给每条内容设计状态。

小结

中式美食这个项目给我的一个感受是:端侧应用的复杂度不是来自某一个大功能,而是来自很多小状态要稳定地连在一起。
图片资源、路由、本地存储、视频拆菜看起来是四件事,但放到真实应用里,它们最后都会落到同一个问题上:用户做过的操作,应用能不能记住;用户下一次回来,页面能不能给出一致的结果。
把这些链路拆清楚,才算真正把一个 HarmonyOS 应用从"能跑"推进到"能长期用"。

相关推荐
爸爸6191 小时前
07 ArkTS 基础类型与接口定义:从 TypeScript 到鸿蒙的类型升级
javascript·华为·typescript·harmonyos·鸿蒙系统
绝世番茄2 小时前
鸿蒙 HarmonyOS ArkTS 音乐播放器界面布局深度学习指南
深度学习·华为·list·harmonyos·鸿蒙
不才难以繁此生3 小时前
HarmonyOS 实战|中式美食视频拆菜编辑面板:因子状态、草稿卡片与底部操作条
harmonyos·arkts·中式美食·arkui v2·视频拆菜
在书中成长3 小时前
HarmonyOS 小游戏《对战五子棋》开发第18篇 - 棋盘设计
算法·harmonyos
m0_749690233 小时前
HarmonyOS 本地备份与系统备份:BackupExtensionAbility、快照导出和恢复
harmonyos
心中有国也有家3 小时前
AtomGit Flutter 鸿蒙客户端:情绪日记的时间线实现
学习·flutter·华为·harmonyos
澹然疏离4 小时前
HarmonyOS应用《民族图鉴》开发第33篇:StorageService——本地存储服务封装
harmonyos
Nturmoils4 小时前
Agent 不只是聊天框:用 ArkUI 做一个鸿蒙任务工作台
harmonyos
GitCode官方4 小时前
开源鸿蒙跨平台直播| RN 三方库开源鸿蒙标准化适配实战分享
华为·开源·harmonyos·atomgit