收藏夹是健康菜谱助手里用户操作最频繁的功能:看到想做的菜点一下收藏,做完取消收藏,每周还有一次整理收藏列表的整理场景。就是这么一个"增删查"三个字能说完的功能,我前后改了四版才觉得自己做对了。这篇把四版演进完整记录下来:第一版只顾功能,收藏状态偶尔不同步;第二版加了乐观更新,响应快了但引入了回滚问题;第三版理清了持久化时机,数据终于可靠;第四版补上动画与触觉反馈,体验才算完整。四个版本对应四个层面的认知升级,功能、响应、可靠、体验,一层都不能少。

收藏夹四版演进

一、第一版的状态不同步:两处真相引发的问题

第一版的收藏实现直接朴素:详情页一个收藏按钮,点击后把菜谱 ID 推进收藏数组,存进 Preferences;收藏页读同一个数组渲染。问题出在"两处真相":详情页组件自己维护了一份收藏状态的 @State,收藏页又从存储读了一份,两处各自更新、互不同步。用户在详情页取消收藏,回到收藏页列表还在;从收藏页删除,详情页按钮还是已收藏的高亮。复现路径稳定后排查了半天,发现根因是我把"是否收藏"这个全局状态放在了组件局部状态里。修正的方向也简单:收藏集合收敛成一个全局单例,所有页面读写同一份数据,组件只订阅不持有。

class FavoriteStore {
  private ids = new Set<string>()
  private listeners: ((ids: string[]) => void)[] = []
  has(id: string) { return this.ids.has(id) }
  add(id: string) { this.ids.add(id); this.emit() }
  remove(id: string) { this.ids.delete(id); this.emit() }
  list(): string[] { return [...this.ids].reverse() }
  subscribe(l: (ids: string[]) => void) { this.listeners.push(l); l(this.list()) }
  private emit() { this.listeners.forEach(l => l(this.list())) }
}
export const favoriteStore = new FavoriteStore()

二、第二版的乐观更新:响应快了,坑也来了

第二版引入乐观更新:点击收藏立即改 UI,持久化异步进行。手机端的存储写入虽然快,但 Preferences 的 flush 涉及磁盘 IO,同步等它会让点击到反馈之间多出可感知的延迟。乐观更新把延迟藏掉了,可新的问题随之而来:用户快速连点收藏取消收藏,两个异步写盘任务完成顺序和发起顺序不一致,存储里的最终状态和界面显示不一致。复现方式简单到残酷:连点二十次收藏按钮,概率性出现存储里多了或少了最后的状态。

修复方案是写入队列串行化:所有持久化任务进一个 Promise 链,保证完成顺序与发起顺序一致。界面继续乐观,存储保证有序,两全其美。这个队列后来变成了基础设施,购物清单、浏览历史全部复用。

class WriteQueue {
  private chain: Promise<void> = Promise.resolve()
  enqueue(task: () => Promise<void>): Promise<void> {
    const next = this.chain.then(task)
    this.chain = next.catch(() => {})
    return next
  }
}
// 点击处理:UI 先行,存储排队
onToggleFavorite(id: string) {
  if (favoriteStore.has(id)) favoriteStore.remove(id)
  else favoriteStore.add(id)
  writeQueue.enqueue(() => prefs.flushFavorites(favoriteStore.list()))
}

三、第三版持久化时机:三个钩子的分工

持久化时机在第三版被重新梳理。之前的做法是每次变更立即 flush,实测发现快速连续操作时磁盘写入频繁,虽然单次很快,但叠加起来是不必要的功耗。重新设计后拆成三个钩子:点击时只改内存并排队;页面隐藏时强制 flush 一次,保证用户切走后状态不丢;应用退出前(生命周期回调)再兜底 flush 一次。三个钩子的分工让磁盘写入的次数下降到原来的十分之一,而数据可靠性没有降低——因为页面隐藏这个钩子已经覆盖了绝大多数的"用户认为操作已完成"的时刻。测试方式也很直接:改一条数据立刻杀进程,重启后检查状态,三组场景各跑十次。

钩子时机可靠性保障
变更时进队列,由队列调度顺序一致
页面隐藏onPageHide 强制 flush切走不丢
应用退出生命周期兜底极端场景保险

持久化三个钩子

四、第四版:动画与触觉,收藏的仪式感

功能可靠之后,第四版专注于那一下点击的"爽感"。收藏按钮的点击动画做了三件小事:图标从描边到实心的过渡用 200 毫秒弹性曲线;收藏成功时按钮轻微放大回弹;配合系统触觉接口给一次轻震动。取消收藏则反向:实心变描边,无震动——加与减在体感上要有区别,用户的肌肉记忆才能分清。别小看这三件小事,改完之后应用内评分里"好用"相关的反馈多了几条,用户说不清哪里变了,但点赞变多了。动效的投入产出比,在收藏这种高频小操作上出奇地高。

// 收藏按钮的弹性回弹
animateTo({ duration: 200, curve: Curve.Friction }, () => { this.scale = 1.2 })
setTimeout(() => animateTo({ duration: 150, curve: Curve.Smooth }, () => { this.scale = 1.0 }), 200)

五、收藏页的列表动画:增删的视觉连续性

收藏页的列表动画是第四版的另一块。删除单个收藏时,期望被删行平滑收起、下方行顺势补位,用 List 的显式动画实现:删除前记录目标位置,数据删除后列表自动过渡。批量整理场景(一键清除全部)反而不用逐项动画——二十行依次收起要两秒,用户等不起,直接清空加一个整体的淡出更干脆。逐项动画给低频单删,整体动画给高频批量,动画的选择跟着操作频率走。

List() {
  ForEach(this.favorites, (item, i) => {
    ListItem() { FavoriteRow({ item }) }
      .transition(TransitionEffect.OPACITY.animation({ duration: 250 })
        .combine(TransitionEffect.translate({ y: 40 })))
  }, (item) => item.id)
}

收藏页动画

六、数据结构:为什么存 ID 列表而不是快照

收藏存储里存什么,是第四版时重新审视的决定。最初存的是菜谱完整快照,好处是收藏页不依赖主数据就能渲染;坏处是菜谱更新后(比如步骤修订),收藏里的旧快照永远不会更新,用户做菜时看到的是过期信息。改成只存 ID 列表后,收藏页渲染时按 ID 从主数据取最新内容,菜谱修订自动同步到收藏。代价是多一层取数逻辑和"菜谱被删除时收藏悬空"的新问题——悬空项的解法是渲染时过滤加一次启动时清理,把 ID 指向不存在的条目清出列表。快照与引用的取舍,本质是"一致性"和"独立性"的取舍,个人工具类应用一致性几乎总是更重要。

方案菜谱更新后数据量悬空风险
存快照收藏显示旧版大无
存 ID 引用自动同步最新小需清理

七、同步的边界:单机应用要不要云同步

收藏夹做完单机部分后,我认真评估过云同步:用户换手机时收藏能不能带走?结论是暂缓,理由有三:元服务的账号体系里已有系统级的云备份通道,应用层再自建一套会重复;收藏是低频变更数据,系统备份的粒度已经够用;自建同步要处理冲突合并、增量上传、离线队列,是一整套离线优先架构的投入,对当前用户规模不划算。评估留下了一个明确的触发条件:当应用出现跨设备协作场景(比如手机收藏、平板做菜)时,再启动自建同步。知道"什么时候不做"和知道"什么时候做"同样重要,这个评估结论写进了设计文档,防止未来的自己冲动开工。

八、测试与回归:四版的回归清单

四版演进沉淀出一份十项回归清单,每次动收藏相关代码跑一遍:单次增删、连点二十次、页面隐藏杀进程、应用退出重启、删除被收藏的菜谱后查看收藏、批量清空、两个页面同时订阅的一致性、动画中快速二次操作、存储满时的降级、版本升级后的旧数据兼容。清单里最有价值的两条是"删除被收藏菜谱"和"旧数据兼容"——它们都不是功能主路径,却各自贡献过一次线上问题。回归清单从第一版的四条长到十条,每一条后面都站着一个真实踩过的坑,这份清单就是收藏夹的历史。

九、总结:小功能做完的标准是什么

收藏夹四版改完,我对"小功能做完"的标准有了新的定义:状态有唯一真相、响应快且有序、持久化有时机、反馈有层次、边界想清楚了不做。五个要素齐了才叫做完,只把功能跑通只能叫"能演示"。这篇里的队列、钩子、清单后来都被别的功能复用,小功能的价值不止于它本身——把一个高频小功能做透,长出来的基础设施会反哺整个应用。收藏夹是健康菜谱助手里代码量最小的模块之一,却贡献了最多的可复用资产,这是写这篇复盘时最大的感受。

参考资料:

  • 华为开发者:Preferences 键值型数据存储
  • 华为开发者:List 列表与转场动画
  • 华为开发者:应用级状态管理 AppStorage

#鸿蒙 #HarmonyOS 7(API 26)

六、并发写的竞争与串行化队列

收藏数据被三个页面同时写入是常态:详情页在收藏、收藏页在整理、设置页在清空,三个 persist 同时到达。Preferences 本身是单文件存储,系统层有基础保护,但"读列表-追加-写回"的复合操作在并发下会互相覆盖:两个页面同时读到同一份旧列表,各自追加不同的 ID 后写回,后完成的把先完成的覆盖掉,用户丢失一次收藏。修复方案是把所有写盘任务收进串行队列:每个任务排队执行,完成顺序与发起顺序严格一致。队列实现不到十行,用一条 Promise 链把任务串起来,任何任务失败不影响后续执行。上线前压过一次并发场景:一秒内触发二十次收藏切换,串行化前丢三次写入,串行化后零丢失。并发问题在人工测试里几乎不可能复现,只有压测能暴露,这也是把它写成固定用例的原因。

class WriteQueue {
  private chain: Promise<void> = Promise.resolve()
  enqueue(task: () => Promise<void>): Promise<void> {
    const next = this.chain.then(task)
    this.chain = next.catch(() => {})
    return next
  }
}

七、缓存一致性与脏读防护

内存模型与磁盘存储之间的一致性靠一条铁律维持:内存是唯一读源,磁盘只是持久化镜像。任何页面要读收藏状态,只能读内存单例;写操作也只走统一入口——先改内存、再进队列落盘。最容易犯的错是某个页面图省事直接读存储文件,拿到的是队列尚未写完的旧值,产生脏读。为了让这条规则在代码结构层面强制生效,Preferences 实例被封装在收藏模块内部,不对外导出,想绕过入口都没有路径。规范长在结构里,才守得住;靠注释和自觉维持的约定,一个人员变动周期就会失效。

操作路径是否允许原因
页面读内存单例允许唯一读源,永远最新
页面直读存储文件禁止(不导出)可能读到队列未落盘的旧值
页面直写存储文件禁止(不导出)绕过队列,破坏顺序
统一入口写允许内存+队列+落盘一体

八、数据结构:存 ID 引用而不是完整快照

收藏列表里存什么,是第四版时重新审视的决定。最初存菜谱完整快照,好处是收藏页不依赖主数据就能渲染;代价是菜谱修订后,收藏里的旧快照永远不更新,用户照着过期步骤做菜,投诉就是这么来的。改成只存菜谱 ID 列表后,收藏页按 ID 从主数据取最新内容,菜谱任何修订自动反映到收藏视图。新代价是"菜谱被删除时收藏悬空"——解法是渲染时过滤悬空项,并在每次启动时做一次全量清理,把指向不存在条目的 ID 清出列表。快照与引用的取舍本质是一致性与独立性的取舍:个人工具类应用,一致性几乎总是更重要的一方。

九、回归清单:十条用例守住四个版本

四版演进沉淀出十项回归清单,每次动收藏相关代码从头跑一遍:单次增删的状态一致、连点二十次后存储与界面相符、页面隐藏时杀进程数据不丢、应用重启后状态还原、删除被收藏的菜谱后收藏页自动清理、批量清空一次成功、两个页面同时订阅时状态同步、动画播放中快速二次操作不错乱、存储空间不足时的降级提示、升级安装后旧数据格式兼容。清单里最容易忽视的两条——删除被收藏菜谱和旧数据兼容——恰好各贡献过一次线上问题。回归清单从四条长到十条,每一条背后都站着一个真实的坑,它既是质量屏障,也是这个功能的编年史。

十、复盘:小功能做完的完整标准

收藏夹做完后,我把"一个小功能做到什么程度才算做完"总结成五条:状态有唯一真相(单例收敛)、响应快且有序(乐观更新加串行队列)、持久化有时机(三钩子分工)、反馈有层次(动画与触觉区分加与减)、边界想清楚(不做云同步的条件写进文档)。五条全齐才算做完,只把功能跑通只能叫能演示。这个模块的代码量在应用里排倒数,却贡献了最多的可复用基础设施——写入队列、订阅模型、回归清单都被后面的功能直接拿走。把高频小功能做透,长出来的地基会托住整个应用,这是做收藏夹最大的收获,也是它值得四版迭代的原因。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐