“收藏按钮明明已经点亮,退出重进却没有保存;编辑框刚修改完配方名,列表又出现旧名字。”当配方卡片、筛选栏和编辑表单都堆在 Index.ets 里时,这两种问题通常混在一起。第三篇已经把 CoffeeNotes 的三条内置配方 latte / cold / pour、用户新建的 u_N 配方和收藏引用保存到 Preferences;真正该重构的,是谁有权修改数据,以及修改成功究竟意味着什么。如果每个子组件自己写文件,收藏和删除会发生竞态;如果子组件直接改父组件里的对象,又会遇到 ArkUI V1 对嵌套变更的观察边界。本篇不新增网络服务,而是把既有功能整理为可检验的单向提交链路。

咖啡手记单向数据流与组件拆分技术封面

一、先收紧状态的所有权,再谈组件复用

在第03篇的基础上,仍然约定 CoffeeRecipe { id, name, category, ratio, steps },其中 latte 是燕麦拿铁、cold 是柑橘冷萃、pour 是手冲浅烘。新建配方的编号从已保存序号继续递增,删除用户配方时连同相关收藏 ID 清理;内置配方不可编辑和删除。Preferences 文件名保留 coffee_notes,键名继续是 user_recipes_v1、favorite_ids_v1、recipe_seq_v1、selected_recipe_v1、active_filter_v1。升级后不能另开一套存储键,也不能因为组件拆分而改变已有用户数据的含义。

新的目录在 entry/src/main/ets/ 下分成 model/CoffeeRecipe.ets、components/CoffeeCard.ets、components/RecipeEditor.ets、data/CoffeeRepository.ets 与 pages/Index.ets。这一拆分有明确边界:卡片只显示内容并发出用户意图;编辑表单只管理输入展示及“保存”通知;页面维护完整的 recipes、favoriteIds、filter、selectedId 和保存状态;仓储只负责从 Preferences 恢复快照与持久化快照。把按钮的点击当成命令,不把它当成“数据已经保存成功”的证据。

白色 DevEco Studio 中的组件模块、父页代码与预览示意

ArkUI V1 的 @Prop 适合将父组件的数据单向传给展示型子组件。它会随父状态更新而更新,但子组件对 @Prop 的修改不会反写父值。@Link 则可以与父组件 @State 双向同步,本篇仅把它用于临时表单文本,不用它绑定整个业务配方集合。前者约束“卡片只读”,后者服务“输入即显示”;两种绑定各司其职。官方的 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-prop/@Prop 开发指南 与 状态管理概览 对这种同步差异有明确说明。

有一个实现细节值得坚持:子组件回调携带 id,而不是携带被局部改过的完整对象。父组件收到 id 后,从自己持有的最新 recipes 中重新查找记录,核实它确实存在,再构造下一份数组。这样可以避免编辑期间卡片持有旧对象、保存时又把旧对象重新覆盖到新数据上的问题。也不要在子组件里给函数回调加 @Prop;函数类型不属于 @Prop 支持的状态类型,普通回调属性足够。

二、展示组件只接收数据和事件

模型文件仍保留上一轮相同字段,保证仓储与页面使用同一种类型。下面的 CoffeeCard 没有任何持久化能力,也不知道当前筛选条件,更不会偷偷修改 recipe.steps。收藏切换、选中、编辑和删除分别回调父页。由于内置项不能编辑删除,相关按钮在卡片内部按 u_ 前缀决定是否展示;真正的权限检查仍在父页方法中再次执行,避免只依赖界面隐藏。

// model/CoffeeRecipe.ets
export interface CoffeeRecipe {
  id: string;
  name: string;
  category: string;
  ratio: string;
  steps: string[];
}

export const BUILTIN: CoffeeRecipe[] = [
  { id: 'latte', name: '燕麦拿铁', category: '热饮',
    ratio: '18g 咖啡粉 / 240ml 燕麦奶',
    steps: ['萃取浓缩咖啡', '加热燕麦奶', '融合后饮用'] },
  { id: 'cold', name: '柑橘冷萃', category: '冷饮',
    ratio: '100g 咖啡粉 / 800ml 水',
    steps: ['粗研磨咖啡豆', '冷藏浸泡', '过滤加入柑橘片'] },
  { id: 'pour', name: '手冲浅烘', category: '热饮',
    ratio: '15g 咖啡粉 / 250ml 水',
    steps: ['润湿滤纸', '分段注水', '移除滤杯'] }
];
// components/CoffeeCard.ets
import { CoffeeRecipe } from '../model/CoffeeRecipe';

@Component
export struct CoffeeCard {
  @Prop recipe: CoffeeRecipe;
  @Prop isFavorite: boolean = false;
  @Prop busy: boolean = false;
  onFavorite: (id: string) => void = (id: string) => {};
  onSelect: (id: string) => void = (id: string) => {};
  onEdit: (id: string) => void = (id: string) => {};
  onDelete: (id: string) => void = (id: string) => {};

  build() {
    Column({ space: 8 }) {
      Row() {
        Column({ space: 5 }) {
          Text(this.recipe.name).fontSize(19).fontWeight(FontWeight.Bold);
          Text(`${this.recipe.category} · ${this.recipe.ratio}`)
            .fontSize(12).fontColor('#7E6B60');
        }.layoutWeight(1).alignItems(HorizontalAlign.Start);
        Button(this.isFavorite ? '♥ 已收藏' : '♡ 收藏')
          .enabled(!this.busy).onClick(() => this.onFavorite(this.recipe.id));
      }
      Row({ space: 8 }) {
        Button('查看').enabled(!this.busy)
          .onClick(() => this.onSelect(this.recipe.id));
        if (this.recipe.id.startsWith('u_')) {
          Button('编辑').enabled(!this.busy)
            .onClick(() => this.onEdit(this.recipe.id));
          Button('删除').enabled(!this.busy)
            .onClick(() => this.onDelete(this.recipe.id));
        }
      }
    }.padding(15).width('100%').borderRadius(16).backgroundColor('#FFFFFF');
  }
}

这里使用 @Prop recipe 有一个附带的工程收益:卡片完全不用知道 Preferences 是否加载完成,它只看接收到的快照。父页在保存成功后把数组替换为新数组,父子同步自然触发;失败时父页不替换数组,卡片也不出现“先亮后灭”的假成功。对于深层嵌套或很大的对象,不应该把所有业务树都塞进 @Prop;本例的对象只有几个浅字段,一张卡片传一份配方,仍属于可控的结构。

编辑器使用 @Link 接收 nameInput、ratioInput、stepsInput 和 categoryInput 四个草稿字段,输入的即时更新不会写磁盘。只有点击保存时才由页面执行校验、生成新快照、持久化、再替换页面事实。表单仍然使用上一轮的“中文分号分隔步骤”规则,不在这一篇凭空加入新的富文本模型。

// components/RecipeEditor.ets
@Component
export struct RecipeEditor {
  @Link name: string;
  @Link ratio: string;
  @Link steps: string;
  @Link category: string;
  @Prop busy: boolean = false;
  @Prop editing: boolean = false;
  onSave: () => void = () => {};

  build() {
    Column({ space: 10 }) {
      Text(this.editing ? '编辑自建配方' : '添加自建配方')
        .fontSize(20).fontWeight(FontWeight.Bold);
      TextInput({ text: this.name, placeholder: '配方名称(1~24字)' })
        .onChange((v: string) => { this.name = v; });
      TextInput({ text: this.ratio, placeholder: '粉液比例' })
        .onChange((v: string) => { this.ratio = v; });
      TextInput({ text: this.steps, placeholder: '步骤以中文分号分隔' })
        .onChange((v: string) => { this.steps = v; });
      Row({ space: 12 }) {
        Button('热饮').backgroundColor(this.category === '热饮' ? '#A86A43' : '#B7A89F')
          .onClick(() => { this.category = '热饮'; });
        Button('冷饮').backgroundColor(this.category === '冷饮' ? '#A86A43' : '#B7A89F')
          .onClick(() => { this.category = '冷饮'; });
      }
      Button(this.editing ? '保存修改' : '创建配方')
        .enabled(!this.busy).onClick(() => this.onSave());
    }.padding(16).backgroundColor('#FFFFFF').borderRadius(16);
  }
}

三、在父页建立唯一的提交入口

上一个版本的 commit() 已经将 user_recipes_v1、收藏 ID、筛选和选中状态一起 putSync(),再调用 flush()。仓储依赖的 Preferences 开发指南 和 getHostContext 接口 均属于公开能力;getHostContext() 从 API 12 起提供,返回值可能是 undefined,因此构造仓储前必须做空值检查。重构时不应把这个顺序倒过来,也不应把 flush() 当作包含多键回滚保障的数据库事务。下面把仓储包装为一个独立对象,供页面统一使用;读取端会根据允许的结构清洗数据,写入端一次写入整份候选快照。页面的状态只有在 await save() 无异常后才改变。失败时需要重新进入恢复流程核对存储,因为若部分 putSync 已执行而 flush 失败,缓存对象可能不再与磁盘状态一致。

// data/CoffeeRepository.ets
import { preferences } from '@kit.ArkData';
import { CoffeeRecipe } from '../model/CoffeeRecipe';

export interface CoffeeSnapshot {
  items: CoffeeRecipe[];
  favoriteIds: string[];
  filter: string;
  selectedId: string;
  nextSeq: number;
}

export class CoffeeRepository {
  private store: preferences.Preferences;
  constructor(context: Context) {
    this.store = preferences.getPreferencesSync(context, { name: 'coffee_notes' });
  }
  load(): CoffeeSnapshot {
    const parsed = JSON.parse(String(this.store.getSync('user_recipes_v1', '[]'))) as CoffeeRecipe[];
    if (!Array.isArray(parsed)) { throw new Error('自建配方格式不正确'); }
    const ids: string[] = [];
    const items = parsed.filter((r: CoffeeRecipe) => {
      if (!r || typeof r.id !== 'string' || !/^u_[1-9][0-9]*$/.test(r.id) ||
        ids.includes(r.id) || typeof r.name !== 'string' || !r.name.trim() ||
        r.name.length > 24 || !['热饮', '冷饮'].includes(r.category) ||
        typeof r.ratio !== 'string' || r.ratio.length > 60 ||
        !Array.isArray(r.steps) || r.steps.length < 1 || r.steps.length > 5 ||
        !r.steps.every((s: string) => typeof s === 'string' && !!s.trim())) { return false; }
      ids.push(r.id); return true;
    });
    const allIds = ['latte', 'cold', 'pour', ...ids];
    const raw = JSON.parse(String(this.store.getSync('favorite_ids_v1', '[]'))) as string[];
    const favoriteIds = Array.isArray(raw) ? raw.filter((id: string, i: number) =>
      typeof id === 'string' && allIds.includes(id) && raw.indexOf(id) === i) : [];
    const savedFilter = String(this.store.getSync('active_filter_v1', '全部'));
    const filter = ['全部', '热饮', '冷饮'].includes(savedFilter) ? savedFilter : '全部';
    const selectedId = String(this.store.getSync('selected_recipe_v1', 'latte'));
    const value = Number(this.store.getSync('recipe_seq_v1', 1));
    const maxId = ids.reduce((n: number, id: string) => Math.max(n, Number(id.slice(2))), 0);
    return { items, favoriteIds, filter, selectedId: allIds.includes(selectedId) ? selectedId : 'latte',
      nextSeq: Math.max(Number.isSafeInteger(value) && value > 0 ? value : 1, maxId + 1) };
  }
  async save(data: CoffeeSnapshot): Promise<void> {
    this.store.putSync('user_recipes_v1', JSON.stringify(data.items));
    this.store.putSync('favorite_ids_v1', JSON.stringify(data.favoriteIds));
    this.store.putSync('recipe_seq_v1', data.nextSeq);
    this.store.putSync('selected_recipe_v1', data.selectedId);
    this.store.putSync('active_filter_v1', data.filter);
    await this.store.flush();
  }
}

父页面负责拼装快照。以下是与本篇 UI 直接相关的核心实现;CoffeeRepository 返回的是自建记录,合并内置项要由页面来做,不能把内置示例再次写入 Preferences。为了保持状态一致性,收藏、选择、筛选和新增都调用 commit()。保存失败时锁定写入并提示重新进入页面恢复;这比继续对可能不一致的 Preferences 缓存写入更稳妥。

// pages/Index.ets
import { CoffeeRecipe, BUILTIN } from '../model/CoffeeRecipe';
import { CoffeeRepository, CoffeeSnapshot } from '../data/CoffeeRepository';
import { CoffeeCard } from '../components/CoffeeCard';
import { RecipeEditor } from '../components/RecipeEditor';

@Entry
@Component
struct Index {
  @State recipes: CoffeeRecipe[] = [...BUILTIN];
  @State favoriteIds: string[] = [];
  @State filter: string = '全部';
  @State selectedId: string = 'latte';
  @State editingId: string = '';
  @State nameInput: string = '';
  @State ratioInput: string = '';
  @State stepsInput: string = '';
  @State categoryInput: string = '热饮';
  @State busy: boolean = false;
  @State message: string = '准备恢复数据';
  private repo: CoffeeRepository | null = null;
  private nextSeq: number = 1;

  aboutToAppear(): void {
    try {
      const context = this.getUIContext().getHostContext();
      if (!context) { throw new Error('上下文不可用'); }
      this.repo = new CoffeeRepository(context);
      const saved = this.repo.load();
      this.recipes = [...BUILTIN, ...saved.items];
      this.favoriteIds = saved.favoriteIds;
      this.filter = saved.filter;
      const candidates = this.visible(this.recipes, this.filter);
      this.selectedId = candidates.some((r: CoffeeRecipe) => r.id === saved.selectedId)
        ? saved.selectedId : (candidates.length ? candidates[0].id : '');
      this.nextSeq = saved.nextSeq;
      this.message = '数据已恢复';
    } catch (e) {
      this.repo = null; this.message = '恢复失败,请检查本地数据';
    }
  }
  private visible(items: CoffeeRecipe[], filter: string): CoffeeRecipe[] {
    return filter === '全部' ? items : items.filter((r: CoffeeRecipe) => r.category === filter);
  }
  private async commit(items: CoffeeRecipe[], favorites: string[],
    selected: string, filter: string, seq: number): Promise<boolean> {
    if (this.busy || !this.repo) { this.message = '数据尚不可保存'; return false; }
    const allowed = items.map((r: CoffeeRecipe) => r.id);
    const clean = favorites.filter((id: string, i: number) =>
      allowed.includes(id) && favorites.indexOf(id) === i);
    const draft: CoffeeSnapshot = {
      items: items.filter((r: CoffeeRecipe) => r.id.startsWith('u_')),
      favoriteIds: clean, filter, selectedId: selected, nextSeq: seq
    };
    this.busy = true;
    try {
      await this.repo.save(draft);
      this.recipes = items; this.favoriteIds = clean;
      this.selectedId = selected; this.filter = filter; this.nextSeq = seq;
      this.message = '修改已保存'; return true;
    } catch (e) {
      this.repo = null; this.message = '保存失败,请重新打开页面检查数据';
      return false;
    } finally { this.busy = false; }
  }
  private async toggleFavorite(id: string): Promise<void> {
    if (!this.recipes.some((r: CoffeeRecipe) => r.id === id)) { return; }
    const next = this.favoriteIds.includes(id) ? this.favoriteIds.filter((x: string) => x !== id)
      : [...this.favoriteIds, id];
    await this.commit(this.recipes, next, this.selectedId, this.filter, this.nextSeq);
  }
  private async select(id: string): Promise<void> {
    if (!this.recipes.some((r: CoffeeRecipe) => r.id === id)) { return; }
    await this.commit(this.recipes, this.favoriteIds, id, this.filter, this.nextSeq);
  }
  private startEdit(id: string): void {
    if (this.busy || !id.startsWith('u_')) { return; }
    const found = this.recipes.find((r: CoffeeRecipe) => r.id === id);
    if (!found) { return; }
    this.editingId = id; this.nameInput = found.name;
    this.ratioInput = found.ratio; this.stepsInput = found.steps.join(';');
    this.categoryInput = found.category;
  }
  private async remove(id: string): Promise<void> {
    if (!id.startsWith('u_') || !this.recipes.some((r: CoffeeRecipe) => r.id === id)) { return; }
    const items = this.recipes.filter((r: CoffeeRecipe) => r.id !== id);
    const candidates = this.visible(items, this.filter);
    const selected = this.selectedId === id ? (candidates.length ? candidates[0].id : '') : this.selectedId;
    const ok = await this.commit(items, this.favoriteIds.filter((x: string) => x !== id),
      selected, this.filter, this.nextSeq);
    if (ok && this.editingId === id) { this.editingId = ''; }
  }
  private async saveDraft(): Promise<void> {
    const name = this.nameInput.trim();
    const ratio = this.ratioInput.trim();
    const steps = this.stepsInput.split(';').map((s: string) => s.trim())
      .filter((s: string) => s.length > 0);
    if (!name || name.length > 24 || ratio.length > 60 || steps.length < 1 || steps.length > 5) {
      this.message = '名称、比例或步骤数量有误'; return;
    }
    const id = this.editingId || `u_${this.nextSeq}`;
    if (this.editingId && !this.recipes.some((r: CoffeeRecipe) => r.id === id && id.startsWith('u_'))) {
      this.message = '该自建配方已不存在'; return;
    }
    const recipe: CoffeeRecipe = { id, name, ratio, steps, category: this.categoryInput };
    const items = this.editingId ? this.recipes.map((r: CoffeeRecipe) => r.id === id ? recipe : r)
      : [...this.recipes, recipe];
    const selected = this.filter === '全部' || this.filter === recipe.category ? id : this.selectedId;
    if (await this.commit(items, this.favoriteIds, selected, this.filter,
      this.editingId ? this.nextSeq : this.nextSeq + 1)) {
      this.editingId = ''; this.nameInput = ''; this.ratioInput = ''; this.stepsInput = '';
    }
  }
  private async changeFilter(next: string): Promise<void> {
    if (!['全部', '热饮', '冷饮'].includes(next)) { return; }
    const visible = this.visible(this.recipes, next);
    const selected = visible.some((r: CoffeeRecipe) => r.id === this.selectedId)
      ? this.selectedId : (visible.length ? visible[0].id : '');
    await this.commit(this.recipes, this.favoriteIds, selected, next, this.nextSeq);
  }
  build() {
    Scroll() {
      Column({ space: 14 }) {
        Text('咖啡手记').fontSize(30).fontWeight(FontWeight.Bold).fontColor('#4B3125');
        Text('配方 · 收藏 · 轻量管理').fontSize(13).fontColor('#907C70');
        Row({ space: 8 }) {
          ForEach(['全部', '热饮', '冷饮'], (value: string) => {
            Button(value).backgroundColor(this.filter === value ? '#9A603E' : '#CAB8AA')
              .enabled(!this.busy).onClick(() => { this.changeFilter(value); });
          }, (value: string) => value);
        }
        ForEach(this.visible(this.recipes, this.filter), (item: CoffeeRecipe) => {
          CoffeeCard({ recipe: item, isFavorite: this.favoriteIds.includes(item.id),
            busy: this.busy,
            onFavorite: (id: string) => { this.toggleFavorite(id); },
            onSelect: (id: string) => { this.select(id); },
            onEdit: (id: string) => { this.startEdit(id); },
            onDelete: (id: string) => { this.remove(id); } });
        }, (item: CoffeeRecipe) => item.id);
        RecipeEditor({ name: $nameInput, ratio: $ratioInput, steps: $stepsInput,
          category: $categoryInput, busy: this.busy, editing: this.editingId.length > 0,
          onSave: () => { this.saveDraft(); } });
        Text(this.message).fontSize(12).fontColor('#8A5B46');
      }.padding(18).width('100%');
    }.height('100%').backgroundColor('#F8F1EB');
  }
}

咖啡手记组件拆分后的完整竖屏页面效果设计

“输入草稿—候选快照—持久化—页面更新”四阶段流程与失败分支

四、验证时不要只看按钮颜色

首先保留上一轮生成的 coffee_notes 数据,冷启动后检查原来的收藏、筛选、自建配方都能恢复。新增一份“蜂蜜肉桂拿铁”,类别为热饮、比例为“18g 咖啡粉 / 220ml 牛奶”,步骤写成“萃取咖啡;温热牛奶;撒入肉桂”。它应该以新的 u_N ID 出现在热饮筛选结果,点击收藏后页面显示“已收藏”;杀死进程再重开,配方和收藏同时恢复。这里的 N 以持久化序号为准,不能把截图中的某个固定序号当成永远正确。

接着在“编辑自建配方”中把名字改为“蜂蜜肉桂热拿铁”,观察卡片标题变化;点击删除后,卡片消失,收藏计数不再包含该 ID。如果用户正处在“冷饮”筛选,新增一份热饮不应该突然把页面跳去显示这份不符合筛选条件的记录。反过来,删除当前选中条目后必须按当前筛选条件寻找仍可展示的候选项,找不到时设为空选中并展示空态,而不能继续引用已删除 ID。

故障测试比正常流程更重要。把本地存储中 user_recipes_v1 修改成非数组 JSON,恢复逻辑应明确报错而不是写回一个“修复成功”的空数组;构造 flush() 抛出异常,commit() 应保留原页面数据、禁止接着写入,并要求重新核对存储状态。如果界面已经先把心形变红,再发起保存,就等于宣布了一个尚未确定的结果。按钮防连点也不是可选视觉效果:在第一次提交未结束前,再点击删除或收藏必须被 busy 阻止,避免两个候选快照以不同顺序写入。

还可用几组结构测试检验数据是否只来自一个源头:同名配方应允许但 ID 不同;切换筛选不改变 recipes 的集合;@Link 表单输入改变草稿但不改变卡片;按保存后卡片才改变;内置 latte 的编辑和删除回调即使被手工调用,也在父页权限判断处被拒绝。对大体量数据,要额外观察 @Prop 对复杂对象的复制成本;这里采用少量卡片和浅对象模型,并不表示相同方法天然适合几千条、层级很深的记录。

为了验证模块化是否真的降低复杂度,还应增加一次“从旧数据升级”的回归演练:先运行上一轮页面创建两份用户配方并收藏其中一份,再直接用新目录结构替换页面和组件,但保持应用数据目录不变。新版本启动后应读取到同样的收藏 ID、筛选类别与当前选中项。如果恢复后出现重复的内置配方,说明仓储把内置和用户条目混写了;如果 u_ 序号倒退,说明实体身份生成没有从旧数据恢复。这样的迁移测试针对真实结构变化,比新装环境下点击一次“创建”更能发现回归问题。

另一个需要交代的边界是“页面关闭时仍有保存操作”。当前示例在提交期间锁定按钮,但不应在 aboutToDisappear() 里无条件把尚未结束的异步操作视作成功。若业务要求切后台也必须完整保存,应进一步设计排队写入、失败重试和生命周期管理;如果页面销毁后用户立即重新进入,则应从仓储重新加载最终数据,而不是从上一页面的临时草稿复制一份。这也是把存储逻辑从 UI 中抽离的理由:数据提交能独立测试,UI 生命周期不再决定文件格式。

五、适用范围和下一步技术演进

这次重构的核心不是“把一个文件拆成四个就算架构升级”,而是将用户意图、草稿、待保存候选快照、磁盘完成状态、页面已提交状态清晰分开。CoffeeCard 和 RecipeEditor 没有各自的数据真相,Index 对可见数据负责,CoffeeRepository 对低频本地存储负责;写入失败的风险也因此有了唯一的处理位置。对于只有少量本地配方的单进程示例,Preferences 和 ArkUI V1 足够完成这个目标。

但这套方案不是数据库事务,也不解决多进程编辑冲突;未来加入云端同步、历史修订、批量导入或大量列表数据时,应进一步引入版本号、冲突处理与适合的数据存储层。第五篇会围绕搜索与列表渲染处理大量配方带来的更新开销,继续在已经清楚的单向提交边界之上做性能工作,而不是回到组件各自改状态的老路。

Logo

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

更多推荐