一杯手冲完成后,屏幕上显示“150秒结束”远远不够。页面退出、设备窗口变窄、重新打开记录页时,用户需要知道使用的是哪份配方、四段注水是否逐项确认、最终累计水量是多少,以及这条记录是否已经保存。前文已经建立brew-light-01的稳定配方身份、按时间戳恢复的冲泡会话,并把15克粉与240克水按1:16调整为18克和288克。最后一篇把这些工程事实装进一份不会被后续配方编辑改写的归档对象,同时让手机单栏与平板双栏显示同一条记录。

本篇所说的多设备布局,指使用同一个ArkUI页面模型适配不同大小的应用窗口,并不暗示两台物理设备的本地Preferences会自动同步。归档以本地应用空间为边界;若以后增加设备间传递,需要另外设计身份、冲突处理及用户授权。把这个边界说明白,才能保证浏览和保存的行为符合当前工程能力。

手冲会话归档与窗口自适应布局

一、归档记录与配方库保持独立

当前实例使用“晨光浅烘”配方IDbrew-light-01。原配方修订1包含15克与240克,第三篇将调整结果保存为配方修订2,其水量计划为72、144、240、288克,四段时长30、45、45、30秒。归档时将配方修订2的实际配置复制进会话快照,同时留存其来源修订1的关系。后续再次编辑配方,即便仍叫“晨光浅烘”,历史会话的粉量和目标水量也必须保持18克与288克,不被新值覆盖。

归档对象应独立记录每段的确认时刻以及用户确认的实际累计水量。步骤时间到零只代表该段计划时长结束,不能代替实际操作;如果某一段尚未人工确认,归档入口就不能写入“已完成”。例如用户在最后一段到期后还在等待滴滤,此时界面可提示检查,而不是自动把本次冲泡判为结束。

// entry/src/main/ets/model/BrewArchive.ets
export interface ArchivedStep {
  stepId: string;
  title: string;
  plannedDurationSec: number;
  plannedCumulativeG: number;
  actualCumulativeG: number;
  confirmedAtMs: number;
}
export interface BrewArchive {
  sessionId: string;
  recipeId: string;
  sourceRevision: number;
  recipeRevision: number;
  recipeName: string;
  coffeeG: number;
  targetWaterG: number;
  waterTempC: number;
  startedAtMs: number;
  completedAtMs: number;
  status: 'FINISHED';
  steps: ArchivedStep[];
}
export function plannedDuration(steps: ArchivedStep[]): number {
  return steps.reduce((sum: number, s: ArchivedStep) =>
    sum + s.plannedDurationSec, 0);
}

这份类型把“计划累计量”和“实际累计量”分为两个字段。普通手冲不要求用户每次都精确达到目标,若记录了287克的实际完成水量,应保留287克而不是自动写成288克;图表可以同时画出两条线。为了让本篇的示例更容易核算,后续界面使用四段均按计划确认的记录,累计72、144、240、288克。时间轴展示的是计划时长150秒,真实操作历时可以通过开始与完成时间另算,不应无条件宣称二者完全相等。

二、以已确认步骤构造不可变结果

进入归档逻辑前,读取计时会话的当前状态和启动时锁定的步骤快照,再取到用户逐段确认记录。两组数组必须用稳定的步骤ID匹配,不能按数组下标拼接;因为列表编辑或适配不同窗口时可能重排显示顺序。归档函数校验全部确认时间存在、累计值非负且不逆序,以及完成时刻不早于开始时刻。返回新对象以后,UI可以自由切换选中项,但不能在展示过程中直接修改归档数组。

// entry/src/main/ets/service/ArchiveBuilder.ets
import { BrewArchive, ArchivedStep } from '../model/BrewArchive';
export interface StepConfirmation { stepId: string; waterG: number; atMs: number }
export interface FrozenPlan {
  sessionId: string; recipeId: string; sourceRevision: number;
  recipeRevision: number; name: string; coffeeG: number;
  waterG: number; tempC: number; startedAtMs: number;
  steps: Array<{ id: string; title: string;
    durationSec: number; cumulativeWaterG: number }>;
}
export function buildArchive(plan: FrozenPlan,
  confirmations: StepConfirmation[], completedAtMs: number): BrewArchive {
  if (completedAtMs < plan.startedAtMs) throw new Error('完成时间错误');
  if (confirmations.length !== plan.steps.length) throw new Error('步骤未全部确认');
  let lastWater = 0;
  const steps: ArchivedStep[] = [];
  for (const step of plan.steps) {
    const entry = confirmations.find((c: StepConfirmation) => c.stepId === step.id);
    if (!entry || entry.atMs <= 0 || entry.waterG < lastWater) {
      throw new Error('步骤确认内容不完整');
    }
    if (steps.length > 0 && entry.atMs < steps[steps.length - 1].confirmedAtMs) {
      throw new Error('确认时间顺序错误');
    }
    steps.push({ stepId: step.id, title: step.title,
      plannedDurationSec: step.durationSec,
      plannedCumulativeG: step.cumulativeWaterG,
      actualCumulativeG: entry.waterG, confirmedAtMs: entry.atMs });
    lastWater = entry.waterG;
  }
  return { sessionId: plan.sessionId, recipeId: plan.recipeId,
    sourceRevision: plan.sourceRevision, recipeRevision: plan.recipeRevision,
    recipeName: plan.name, coffeeG: plan.coffeeG,
    targetWaterG: plan.waterG, waterTempC: plan.tempC,
    startedAtMs: plan.startedAtMs, completedAtMs,
    status: 'FINISHED', steps };
}

sessionId需要在本次冲泡开始时生成并保存,不能等进入归档页时按当前时间重新生成。再次点击保存应命中同一条记录,防止重复出现两杯相同的冲泡历史。对于一个步骤被重复确认的输入,还要在调用构造函数前按步骤ID拒绝或消解重复;这里的长度检查和查找步骤确认,主要保障调用方交付的标准化结果完整。若用户选择中断或放弃冲泡,应进入另一个明确的会话状态,而不是写入FINISHED归档。

三、落盘与恢复要有稳定主键

本地Preferences适合保存数量适中的结构化记录,归档可以序列化为字符串并使用sessionId构造键。保存前先读取同一键,若记录存在且内容一致,视为重复提交;若同一会话ID对应不同内容,应提示冲突,而非无声覆盖。保存异常时页面仍应保留当前待归档对象,允许手动重试,不应清空冲泡现场。使用Preferences的单进程读写时也要避免在多个页面并发修改同一条记录。

// entry/src/main/ets/service/BrewArchiveStore.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BrewArchive } from '../model/BrewArchive';
export class BrewArchiveStore {
  constructor(private context: common.UIAbilityContext) {}
  private key(sessionId: string): string { return 'record_' + sessionId; }
  async save(record: BrewArchive): Promise<void> {
    const p = await preferences.getPreferences(this.context, { name: 'brew_archives' });
    const key = this.key(record.sessionId);
    const previous = await p.get(key, '');
    const serialized = JSON.stringify(record);
    if (typeof previous === 'string' && previous.length > 0) {
      if (previous !== serialized) throw new Error('会话记录已归档且内容不同');
      return;
    }
    await p.put(key, serialized);
    await p.flush();
  }
  async get(sessionId: string): Promise<BrewArchive | null> {
    const p = await preferences.getPreferences(this.context, { name: 'brew_archives' });
    const raw = await p.get(this.key(sessionId), '');
    if (typeof raw !== 'string' || raw.length === 0) return null;
    const parsed = JSON.parse(raw) as BrewArchive;
    if (parsed.sessionId !== sessionId || parsed.status !== 'FINISHED') {
      throw new Error('归档内容与请求身份不匹配');
    }
    return parsed;
  }
}

这里把损坏内容和记录不存在区分开:不存在返回null,JSON损坏抛出异常,由页面提供修复或反馈入口。对更多历史记录,可以单独维护一个会话ID索引,但索引和正文不具备自动事务性;出现索引指向缺失记录时,应跳过损坏项并提供重新整理索引的方式。不能把“记录页显示了卡片”当成写盘完成的证据,成功提示必须放在flush返回之后。

DevEco Studio中的本地归档与平板双栏预览

四、手机与平板共用一份归档对象

手机版本优先展示配方参数、四段确认列表和累计水量趋势,必要时将详细元数据折叠;宽内容区则可左侧放配方身份与参数、右侧放步骤记录及趋势。适配以当前窗口宽度为依据,不读取设备型号做分支,也不在布局重新构建时生成新的sessionId。用户在手机上查看到第三段,再扩大窗口时,仍应保持当前选中步骤ID,并显示同一条归档对象。

// entry/src/main/ets/pages/ArchivePage.ets(核心视图结构)
import { BrewArchive, ArchivedStep } from '../model/BrewArchive';
@Entry
@Component
struct ArchivePage {
  @State private record: BrewArchive | null = null;
  @State private selectedStepId: string = 'bloom';
  @Builder private recipeColumn() {
    Column({ space: 12 }) {
      Text(this.record?.recipeName ?? '冲泡记录').fontSize(22)
      Text('配方身份 ' + (this.record?.recipeId ?? ''))
      Text('粉量 ' + (this.record?.coffeeG ?? 0) + '克')
      Text('目标水量 ' + (this.record?.targetWaterG ?? 0) + '克')
    }.padding(12)
  }
  @Builder private stepsColumn() {
    List({ space: 8 }) {
      ForEach(this.record?.steps ?? [], (step: ArchivedStep) => {
        ListItem() {
          Text(step.title + ' · 累计 ' + step.actualCumulativeG + '克')
            .fontColor(step.stepId === this.selectedStepId ? '#087F8C' : '#333333')
            .padding(14)
        }.onClick(() => { this.selectedStepId = step.stepId; })
      }, (step: ArchivedStep) => step.stepId)
    }
  }
  build() {
    GridRow({ columns: { sm: 4, md: 8, lg: 12 }, gutter: 12 }) {
      GridCol({ span: { sm: 4, md: 3, lg: 4 } }) { this.recipeColumn() }
      GridCol({ span: { sm: 4, md: 5, lg: 8 } }) { this.stepsColumn() }
    }.padding(12)
  }
}

归档读取动作应由页面控制层在进入页面后执行,完成数据校验再更新record;上面的视图不自行创建历史记录。图表也应以记录中的实际确认水量和确认时间为数据源,而不是直接读取当前配方库的最新步骤。用户将粉量再次改为20克时,归档的18克与288克仍可复查。手机视图和宽屏视图只是同一份数据的两种排列,不提供未经实现的跨设备同步承诺。

手机记录页中的四段确认与累计水量

五、检查一条记录是否真的闭环

这条示例记录的ID为brew-session-20261029-001,配方为brew-light-01,来源修订1、使用修订2;四个步骤按顺序确认。计划累计水量为72、144、240、288克,时间计划为30、45、45、30秒,总和150秒。归档视图应能根据这份数据画出逐段上升的趋势,而不是把每段增量当成纵轴累计值。审阅历史时还要核对保存时间、步骤ID和确认时间顺序。若只发现三个确认,或出现同一步骤的两次矛盾确认,应停在待处理状态并展示原因。

对恢复异常的排查可以按固定顺序进行:先核查会话ID是否一致,再核查JSON和修订号,随后验证全部步骤确认、目标水量和实际水量。最后才检查手机和平板布局是否因重排丢失选中步骤。尺寸变化本身不改变归档数据,页面再次出现也不启动已经完成的计时器。对于本地存储清理、应用卸载和不同设备的文件隔离,应在产品设计阶段明确归档保留与导出策略。

六、参考资料与系列结论

华为开发者的GridCol参考以及多端栅格规则提供响应式栅格布局依据;Preferences接口参考介绍本地键值存储及异步写盘;Navigation分栏开发适用于需要系统级主从导航的页面层级。

四篇连载最终形成一条一致的工程链:稳定配方ID建立界面身份,步骤快照保障后台计时恢复,比例缩放产生新的配方修订,完成确认后再生成不可变本地归档。布局、计时、编辑和记录各自承担明确职责,任何一个环节的UI重绘都不应该改写另一环节的业务事实。

Logo

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

更多推荐