“取消”按钮关闭了编辑区,订单却已经变成大杯。这个问题无需追查动画,也与按钮是否响应无关:编辑区与正式订单指向同一个对象,用户每次选择都已经写入正式值。要让取消真正生效,必须先改变数据关系,让未确认的修改有自己的存放位置。

调饮台正式订单与编辑草稿技术封面

一、先识别隐藏在赋值里的共享引用

上一阶段的调饮台采用即时修改,点击杯型就写入订单,这与当时的交互目标一致。现在产品需要用户连续修改杯型、冷热和加料,最后一次确认。新的需求不是给旧页面加两个按钮,而是把“点击即生效”改成“编辑后提交”。模型字段可以保持不变,生效边界必须移动。

下面的赋值是这次重构要移除的模式。它没有复制订单,只给同一个对象增加了另一个名字。即使两处都带有状态装饰器,共享对象也不会自动变成两份数据;观察机制负责通知变化,不负责替用户撤销变化。

// 需要替换的写法:两个变量共享同一个对象。
this.draft = this.order;
this.draft.size = '大杯';
// 此时 this.order.size 也已经变成“大杯”。

本篇仍使用 MixLab 的 API 12 普通页面。保留第01篇 common/DrinkOrder.ets,第02篇 components/SizeSelector.ets,以及第03篇 common/Pricing.ets。新增的只有明确的复制函数和入口页编辑流程,不引入额外权限或持久化。这里说的确认,是确认当前内存订单,不代表已经向门店提交或付款。

复制边界取决于数据结构。当前订单的字段都是字符串、数字和布尔值,逐字段赋值即可形成独立快照。如果以后增加数组形式的加料、嵌套配方或地址对象,复制最外层就不够;每个允许独立编辑的子对象都需要对应的复制策略。不能把今天的浅层模型经验直接推广成所有业务都只复制一层。

二、用显式复制保留类实例的能力

在第01篇的 DrinkOrder.ets 末尾追加以下函数,保留原有模型、目录和操作函数。返回值通过 new DrinkOrder() 创建,再把本次需要保留的字段逐一写入。这样既保留了类实例,也让新增字段时的复制遗漏能够通过代码评审发现。

// 追加到 common/DrinkOrder.ets
export function cloneOrder(source: DrinkOrder): DrinkOrder {
  const target: DrinkOrder = new DrinkOrder();
  target.drinkId = source.drinkId;
  target.drinkName = source.drinkName;
  target.basePriceFen = source.basePriceFen;
  target.size = source.size;
  target.sugar = source.sugar;
  target.iced = source.iced;
  target.pearls = source.pearls;
  target.quantity = source.quantity;
  return target;
}

不使用序列化再解析来代替这个函数。官方特别说明,@ObservedV2 对象经由 JSON 处理后不能自动恢复为具有观察能力的类实例,序列化字段也有相关实现行为。对于需要持久化的数据,应定义独立的数据格式并通过构造过程还原模型;为了八个字段的编辑草稿引入一轮 JSON 转换,既绕远路又隐藏了类型边界。官方实例与序列化说明可用于核对这一点。

显式复制也有成本:每增加一个字段,需要检查复制函数、验证逻辑和界面是否一起更新。这个成本不是缺陷的全部,它让数据协议变化变得可见。若字段越来越多,可以围绕稳定的子模型组织复制函数,但仍应先定义哪些内容属于草稿,哪些是外部实时状态。

例如门店是否营业不适合随着订单一起复制,因为草稿建立之后营业状态仍可能改变;饮品选择和用户备注则可以属于编辑快照。把外部约束也冻进草稿,会让“确认”看上去通过校验,实际却使用了过时规则。因此确认时要读取当前约束,草稿只承载用户这一次编辑的意图。

复制函数还有一个可以直接验证的性质:两份订单的字段值在复制时应当相等,对象身份应当不同。随后修改目标的杯型,来源必须保持原值。这个检查比比较界面截图更早发现共享问题。若未来加入加料数组,还应修改数组中的某个元素继续检查,否则只证明外层对象不同,不能证明内部数据已经隔离。

是否允许用户在没有修改时点击确认,可以另行设计。当前流程允许确认相同内容,它只替换内存订单,没有昂贵的外部动作。如果需要显示“尚无改动”,可以按业务字段比较草稿与正式订单;不要直接比较两个对象是否相等,因为草稿刚创建时对象身份已经不同。也不宜依赖序列化文本的顺序判断业务相等,显式比较需要的字段更稳定。

三、把进入、确认和取消写成三个入口

页面分别持有 order 和 draft。开始编辑时复制正式订单;确认时先校验草稿,再复制成新的正式订单;取消时关闭编辑并丢弃草稿。确认时再次复制有必要:如果直接把草稿引用交给正式订单,后面某段尚未结束的草稿逻辑仍可能继续改动它。

// pages/Index.ets
import { DrinkOrder, cloneOrder } from '../common/DrinkOrder';
import { SizeSelector } from '../components/SizeSelector';
import { Quote, buildQuote, money } from '../common/Pricing';

@Entry
@ComponentV2
struct Index {
  @Local order: DrinkOrder = new DrinkOrder();
  @Local draft: DrinkOrder = new DrinkOrder();
  @Local editing: boolean = false;
  @Local message: string = '尚未开始编辑';

  @Computed
  get orderQuote(): Quote { return buildQuote(this.order); }

  @Computed
  get draftQuote(): Quote { return buildQuote(this.draft); }

  beginEdit(): void {
    this.draft = cloneOrder(this.order);
    this.editing = true;
    this.message = '修改将在确认后生效';
  }

  confirmEdit(): void {
    if (!this.editing) { return; }
    const checked: Quote = buildQuote(this.draft);
    if (checked.error.length > 0) {
      this.message = checked.error;
      return;
    }
    this.order = cloneOrder(this.draft);
    this.editing = false;
    this.draft = new DrinkOrder();
    this.message = '规格已更新';
  }

  cancelEdit(): void {
    this.editing = false;
    this.draft = new DrinkOrder();
    this.message = '已取消,正式订单保持原样';
  }

  build() {
    Scroll() {
      Column({ space: 18 }) {
        Text('调饮台').fontSize(28).fontWeight(FontWeight.Bold)
        Text('正式订单与编辑草稿分开保存').fontColor('#727A84')
        Column({ space: 10 }) {
          Text('已确认订单').fontSize(18)
          Text(`${this.order.drinkName} · ${this.order.size}`)
          Text(`${this.order.iced ? '冰饮' : '热饮'} · ${this.order.pearls ? '加珍珠' : '不加料'}`)
          Text(`合计 ${money(this.orderQuote.totalFen)}`)
        }.width('100%').padding(18).backgroundColor('#FFFFFF').borderRadius(18)
        if (this.editing) {
          SizeSelector({ size: this.draft.size, onSelect: (next: string) => {
            if (next === '中杯' || next === '大杯') { this.draft.size = next; }
          } })
          Column({ space: 12 }) {
            Text('编辑草稿').fontSize(18)
            Button(this.draft.iced ? '冰饮' : '热饮')
              .onClick(() => { this.draft.iced = !this.draft.iced; })
            Button(this.draft.pearls ? '已加珍珠' : '不加珍珠')
              .onClick(() => { this.draft.pearls = !this.draft.pearls; })
            Text(`草稿合计 ${money(this.draftQuote.totalFen)}`)
            Text(this.message).fontColor('#8A6324')
          }.width('100%').padding(18).backgroundColor('#FFFBF0').borderRadius(18)
          Row({ space: 12 }) {
            Button('取消编辑').layoutWeight(1).onClick(() => this.cancelEdit())
            Button('确认修改').layoutWeight(1).onClick(() => this.confirmEdit())
          }.width('100%')
        } else {
          Text(this.message)
          Button('编辑规格').width('100%').backgroundColor('#176B5B')
            .onClick(() => this.beginEdit())
        }
      }.padding(24).width('100%')
    }.width('100%').height('100%').backgroundColor('#F4F6F3')
  }
}

正式订单未变、草稿已选大杯加珍珠的界面效果

图中正式订单仍是中杯、不加料的一杯桂花乌龙,合计十八元;草稿改为大杯加珍珠,草稿合计二十三元。两组金额不同不是同步错误,而是界面在表达尚未提交的修改。确认后正式订单才进入新状态,取消后则回到此前确认的规格。

确认入口调用普通计价函数,针对当前草稿立即校验。展示依旧使用计算属性,但业务提交不应假定所有展示缓存已经在某个点击语句之间更新完毕。用纯函数重新检查当前输入,能够把正确性放在显式步骤上,而不是依赖画面刷新时机。

四、撤销是否成功,要检查对象与状态两条线

验证可以从最容易失败的路径开始:进入编辑,选择大杯,再点击取消。检查正式摘要、正式金额和下一次进入编辑时的初始值,三者都应该仍是中杯。只看取消后编辑区消失,不能证明订单没有被改;再次进入编辑,才能发现草稿是否错误复用了上次未确认的内容。

第二条路径是确认后继续编辑。先确认大杯,再进入编辑把它改为中杯并取消,正式订单应保持大杯。这能检查开始编辑是否从最新正式订单创建快照,而不是永远从默认值创建。第三条路径是确认后继续修改旧草稿引用,正式订单不应再变化;这一点针对确认时的第二次复制。

正式订单与草稿的状态流转

出现回退错误时,先检查引用关系,再检查事件绑定。常见遗漏是杯型已经写入 draft,加料按钮却仍然改 order,因此只有一部分修改能取消。另一类问题是确认函数复制了部分字段,杯型保存成功、冷热却丢失。逐字段核对变化路径比增加统一刷新函数更直接。

本例的确认校验只使用既有计价规则,目的是闭合这次草稿流程。实际产品还应验证饮品是否可售、冷热组合是否支持等当前约束。发现错误时保留编辑区和用户选择,让用户可以修正;若直接把草稿清空再提示错误,就会把一次校验失败变成输入丢失。

还要检查确认按钮的重复触发。当前确认过程全部同步,第一次确认结束后编辑状态被关闭,第二次调用会在入口返回,因此不会重复应用草稿。如果未来确认包含异步保存,应增加保存中状态并防止重复提交;成功之后再关闭编辑,失败时保留草稿和原因。不能把同步示例里的赋值顺序直接搬到网络保存流程中。

取消与恢复默认也不是同一操作。取消回到本次编辑前的正式值,恢复默认则主动构造默认配置;用户曾经确认过大杯时,这两个结果显然不同。界面若需要同时提供它们,恢复默认只能作用于当前草稿,并继续等待确认。否则用户只是在编辑区尝试默认选项,却意外覆盖了正式订单。

调试这种问题时,可以给正式摘要临时加一个观察位置,再逐个触发草稿控件,检查它是否提前变化。观察位置应读取正式订单,不能读取一份重新拼装的字符串缓存,否则缓存可能掩盖底层已经发生的写入。发现某个字段提前改变,就回到对应事件绑定查找目标对象,定位通常比检查整页生命周期更快。

五、哪些变化还需要额外策略

草稿存在期间,正式订单如果可能被另一个入口修改,单纯复制还不够。可以为正式订单维护版本,在开始编辑时记住版本,确认时比较;不一致时要求重新载入或执行明确的字段合并。这里的页面只有一个正式写入入口,因此没有加入未被使用的版本字段,但扩展到多窗口或远端协作时必须补上这一层。

页面离开时如何处理草稿也属于产品选择。直接丢弃、弹出放弃确认、自动保存草稿是三种不同语义,不能仅靠组件销毁回调随意决定。若需要跨进程恢复,应保存明确的数据格式,再恢复为新模型实例;内存里的对象隔离不能代替磁盘持久化,也不能保证应用被系统终止之后继续存在。

编辑区与正式摘要同时出现时,文案必须清楚区分“已确认”与“草稿”。如果两个区域都叫订单详情,用户很容易误以为上方没有刷新。这里用标题、背景和确认按钮共同说明状态,把内部的数据边界翻译成可理解的操作反馈。这一步不只是视觉优化,也是减少误提交的交互设计。

这次重构保留了原来的模型、选择器和计价规则,只移动了写入目标与生效时机。下一篇把相同的发布边界用于异步搜索:输入变化可以立即发生,结果却必须等到确认它仍属于当前请求之后才能进入页面。草稿隔离对象,搜索隔离时序,两者都在回答“哪一次变化有资格成为当前状态”。

官方参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-observedv2-and-trace/@ObservedV2与@Trace、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-local/@Local组件内部状态。

Logo

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

更多推荐