“晨光浅烘”的基础配方是15克咖啡粉、240克目标水量、92℃水温,闷蒸、第一段、第二段与收尾分别持续30、45、45、30秒。上一阶段已经解决页面退后台后计时回调延迟的问题:会话通过截止时间和暂停余量恢复,步骤到期后仍等待人工确认。本篇把工作台推进到配方调整:用户将粉量改为18克,希望保持1:16的比例,同时把原来的四段累计水量换算成新目标。难点不是把240乘以1.2,而是确保每段取整之后仍递增、末段准确到达288克,并且正在进行的冲泡不被新数值改写。

调整粉量后重算累计注水量

一、比例调整必须分开配方修订和正在冲泡的会话

配方ID仍为brew-light-01,显示名称“晨光浅烘”不变。原配方修订号为1,新配置准备保存为修订2。两者共享业务身份,但不能共享一个可随意修改的对象引用。已经开始的冲泡会话,应持有启动时的粉量、水量、时长和各阶段目标快照;即便另一个页面把新粉量改为18克,运行中计时器也必须沿用它启动时的配方版本。由此区分三个概念:配方ID识别哪一种咖啡,修订号识别哪份配置,冲泡会话ID识别哪次实际操作。

交互层的粉量输入只是草稿。输入“18”时先计算预览,只有用户选择保存新修订,才把它写入配方库。若校验失败,显示问题字段并留在草稿状态,不能把错误数字交给正在显示倒计时的步骤页。用户切换配方时,草稿可以丢弃或明确提示保存,但不能把上一条配方的调整结果带到另一条配方。

二、以整数十分之一克管理舍入误差

工程计算可以约定显示精度为0.1克,把每一段的目标存储为十分之一克的整数。先确认新粉量为正、目标总水量在可接受范围,再按原累计水量在总水量中所占比例重新分配。由于最后一段始终被强制设置为目标总量,舍入误差不会被不断累加到结尾。中间阶段按比例计算之后,仍需检查是否严格递增;当目标水量过小,多个阶段可能取整到同一个值,此时应报告配方无法按当前步数执行,而不是悄悄删除某个步骤。

// entry/src/main/ets/service/RecipeScaler.ets
import { BrewRecipe, BrewStep } from '../model/BrewRecipe';
export interface ScaleResult { recipe: BrewRecipe; incrementsG: number[] }
function tenth(valueG: number): number { return Math.round(valueG * 10); }
function grams(value10: number): number { return value10 / 10; }
export function scaleRecipe(source: BrewRecipe, newCoffeeG: number): ScaleResult {
  if (!Number.isFinite(newCoffeeG) || newCoffeeG <= 0) {
    throw new Error('粉量必须为有效正数');
  }
  const target10 = Math.round(tenth(source.waterG) * newCoffeeG / source.coffeeG);
  if (source.coffeeG <= 0 || target10 < source.steps.length) {
    throw new Error('目标水量不足以分配全部步骤');
  }
  let previous10 = 0;
  const steps: BrewStep[] = [];
  const incrementsG: number[] = [];
  for (let i = 0; i < source.steps.length; i++) {
    const original = source.steps[i];
    const target = i === source.steps.length - 1 ? target10 :
      Math.round(tenth(original.cumulativeWaterG) * target10 / tenth(source.waterG));
    if (target <= previous10 || target > target10) {
      throw new Error('累计注水量取整后不再递增');
    }
    incrementsG.push(grams(target - previous10));
    steps.push({ ...original, cumulativeWaterG: grams(target) });
    previous10 = target;
  }
  return { recipe: { ...source, coffeeG: newCoffeeG,
    waterG: grams(target10), steps }, incrementsG };
}

这个函数对原对象不做原地修改,返回新对象及每阶段增量。对于15克改18克,系数为1.2,累计目标依次为72、144、240、288克;对应的本段增量是72、72、96、48克。前三段持续时长仍为30、45、45秒,末段30秒,总计划时长保持150秒。这里调整的是粉量与水量,水温仍为92℃,并未把时间和水温也盲目乘以1.2。若后续提供时间调整入口,需要另设独立的规则和版本字段。

三、把结果校验写在保存入口,而非卡片组件中

当用户连续拖动粉量控制条时,页面会生成多个临时结果。校验逻辑应在业务层复用,特别是最后一段是否等于总量、段间增量是否均为正,以及粉水比是否遵循用户所选规则。校验不负责自行修复非法草稿,否则用户会看到保存后的数字与自己调整前的预览不同。交互可以把异常信息直接附在对应输入区域,而不必关闭整个配方工作台。

// entry/src/main/ets/service/ScaledRecipeValidator.ets
import { BrewRecipe } from '../model/BrewRecipe';
export function validateScaledRecipe(recipe: BrewRecipe): string[] {
  const problems: string[] = [];
  if (recipe.coffeeG <= 0 || recipe.waterG <= 0) problems.push('粉水量错误');
  if (recipe.steps.length === 0) problems.push('没有注水步骤');
  let previous = 0;
  const seen: Set<string> = new Set<string>();
  for (const step of recipe.steps) {
    if (seen.has(step.id)) problems.push('步骤身份重复');
    seen.add(step.id);
    if (step.durationSec <= 0) problems.push('步骤时长错误');
    if (step.cumulativeWaterG <= previous) problems.push('累计量没有递增');
    previous = step.cumulativeWaterG;
  }
  if (Math.abs(previous - recipe.waterG) > 0.001) {
    problems.push('末段水量与总目标不一致');
  }
  return problems;
}

列表展示时区分“累计目标”和“本段需要加水”,不能把第二阶段的144克当成这一段独立需要注入的量。对称重读数也要保留单位:本文数据全部按克计算,水的毫升数不能在代码中当作克直接替换。界面可将数字保留一位小数,但存储和比较要使用同一精度,避免“累计288.0”与“目标288”因为文本格式不同被判为异常。

DevEco Studio中的比例换算与逐段目标

四、保存修订时让页面状态和持久化顺序一致

本地配方数量不大,可以用Preferences存储序列化后的配方对象和修订号。读写过程要有明确的忙碌状态;用户在保存请求未结束时重复点击,不能生成多个相互覆盖的修订。保存前读取当前修订并比较预期修订,若其他入口已更新配方,则提示用户重新检查本次草稿。Preferences提供本地键值持久化,但并不自动实现跨进程事务,因此这个修订比较只用于本应用单进程页面的写入协调,不能被解释为远程并发数据库的锁。

// entry/src/main/ets/service/RecipeRevisionStore.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BrewRecipe } from '../model/BrewRecipe';
export interface RevisionedRecipe { revision: number; data: BrewRecipe }
export class RecipeRevisionStore {
  constructor(private context: common.UIAbilityContext) {}
  async save(id: string, expectedRevision: number,
    next: BrewRecipe): Promise<RevisionedRecipe> {
    const p = await preferences.getPreferences(this.context, { name: 'brew_recipes' });
    const raw = await p.get(id, '');
    const old: RevisionedRecipe | null = typeof raw === 'string' && raw.length > 0 ?
      JSON.parse(raw) as RevisionedRecipe : null;
    const actual = old ? old.revision : 0;
    if (actual !== expectedRevision) throw new Error('配方修订已变化,请重新检查');
    const updated: RevisionedRecipe = { revision: actual + 1, data: next };
    await p.put(id, JSON.stringify(updated));
    await p.flush();
    return updated;
  }
}

实际工程还要在外层捕获损坏的JSON、存储空间不足与写入异常。读取失败时不要回退写入空对象覆盖旧记录,而应提示检查或重新载入。调用方应在await save成功返回后再把页面上的“已保存修订”标识更新为2,并解除保存按钮的禁用状态;失败则保留用户草稿以便调整重试。任何版本切换都不应更改已经启动的冲泡会话快照。

五、页面只承载预览状态,不承担重算规则

计算和校验放在独立服务以后,页面只需要管理输入框、当前预览和保存按钮。配方列表与右侧明细沿用上一篇的稳定ID;在宽屏上左右并列,手机窄屏则上下堆叠,不会因为组件重新创建而把新配方错误地套用到另一条数据。用户返回配方页时,应先检查当前ID是否仍存在、修订是否变化,然后决定是否恢复草稿。

// entry/src/main/ets/pages/RecipeScalePage.ets(页面中的核心交互)
import { common } from '@kit.AbilityKit';
import { BrewRecipe, RECIPES } from '../model/BrewRecipe';
import { scaleRecipe, ScaleResult } from '../service/RecipeScaler';
import { validateScaledRecipe } from '../service/ScaledRecipeValidator';
import { RecipeRevisionStore } from '../service/RecipeRevisionStore';
@Entry
@Component
struct RecipeScalePage {
  @State private gramsInput: string = '18';
  @State private message: string = '';
  @State private busy: boolean = false;
  @State private preview: BrewRecipe = RECIPES[0];
  private source: BrewRecipe = RECIPES[0];
  aboutToAppear(): void { this.updatePreview(); }
  private updatePreview(): void {
    try {
      const result: ScaleResult = scaleRecipe(this.source, Number(this.gramsInput));
      const problems = validateScaledRecipe(result.recipe);
      this.message = problems.join(';');
      if (problems.length === 0) this.preview = result.recipe;
    } catch (error) { this.message = String(error); }
  }
  private async saveRevision(): Promise<void> {
    if (this.busy || this.message.length > 0) return;
    this.busy = true;
    try {
      const context = getContext(this) as common.UIAbilityContext;
      const store = new RecipeRevisionStore(context);
      const revision = await store.save(this.source.id, 1, this.preview);
      this.message = '已保存修订 ' + revision.revision;
    } catch (error) { this.message = '保存未完成:' + String(error); }
    finally { this.busy = false; }
  }
  build() {
    Column({ space: 12 }) {
      Text('晨光浅烘 · 粉水比例缩放').fontSize(23)
      TextInput({ text: this.gramsInput, placeholder: '输入新粉量(克)' })
        .onChange((v: string) => { this.gramsInput = v; this.updatePreview(); })
      Text('目标水量 ' + this.preview.waterG + '克')
      Text(this.message).fontColor(Color.Gray)
      Button('保存为新修订')
        .enabled(!this.busy && this.message.length === 0)
        .onClick(() => { void this.saveRevision(); })
    }.padding(16)
  }
}

示例从预置配方集中取得有效的初始对象,保存入口利用Preferences读写服务。后续接入异步配方仓库时,需等数据加载与修订校验完成后才允许修改;也要在保存成功后及时刷新基础修订号,否则连续点击第二次保存会被旧修订拦截。输入校验失败、保存忙碌与修订冲突应当分别提示,避免用户把存储异常理解成粉量设置错误。

手机上的缩放前后比较与累计注水计划

六、排错顺序与继续扩展的边界

排错先检查基础配方是否正确,再检查换算系数和每步累计值,随后确认增量之和等于目标水量。以本篇数值核算:72+72+96+48=288克;如果从第三步反推增量,必须使用240减144而不是240减72。输入15克应该保持原值;输入18克得288克;输入极小正数可能因0.1克精度造成重复目标,需要明确报告而非继续执行。对大量重复编辑,还要检查每次保存后修订号是否严格递增。

尺寸变化时主要检查配方ID、草稿是否保存以及编辑焦点,而非重新运行一次正在冲泡的倒计时。已有会话持有独立快照,返回页面时应继续按上一篇的截止时刻计算剩余时间。这一原则让配方编辑与执行现场既能关联同一业务身份,也能各自保持可复核的记录。

七、参考资料与技术结论

华为开发者的Preferences接口参考给出了异步getPreferences、put和flush的使用方式;ArkTS简介说明类型和语言能力;GridCol参考可用于保留不同窗口宽度下的配方编辑布局。

比例缩放的可靠结果不是几个新的漂亮数字,而是一份独立修订:原配方可追溯,新配方累计目标和增量可核算,运行中的会话不会突然被改写。下一篇将把配方修订、冲泡步骤确认与多设备布局整合到完整的本地归档记录中。

Logo

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

更多推荐