手冲咖啡的记录页很容易从一个参数卡片开始:粉量、总水量、水温与冲泡时间排成一列,点击开始按钮进入下一页。真正把它扩展到手机、折叠屏和平板时,问题不在于把卡片放大,而在于配方被选中后,步骤究竟跟随哪一条记录变化。列表重新排序、切换筛选条件或者横竖屏改变时,如果以列表下标保存选择,右侧步骤就可能突然跳到另一套参数。

这套手冲工作台使用一个稳定的配方标识连接列表、详细参数和步骤进度。手机上按照“选配方—看步骤”的阅读顺序排列;较宽的内容区中把列表与步骤并排放置,但选中的业务对象仍是一条配方,而不是某个视图位置。第一篇先建立能够支撑后续计时、配方缩放和跨尺寸恢复的数据基础。

手冲配方与双栏步骤安排

一、用稳定配方ID分离业务数据与视图状态

以“晨光浅烘”为例,粉量15克、目标水量240克、水温92℃、粉水比1:16,冲泡分为闷蒸30秒、第一段45秒、第二段45秒和收尾30秒,总时间150秒。四个步骤的目标水量分别为60、120、200、240克,这里的目标值是到该步骤末尾的累计注水量,不能把120克解释为第二段再加入120克。只要字段语义混乱,后续计时页的提示就会出现重复注水。

另外准备“坚果中烘”作为第二套配方,两者必须有不同的ID;显示名称允许修改,ID不跟随名称变化。模型层还记录步骤的稳定ID与顺序,这样以后插入一个预浸步骤时,已有的完成记录可以通过步骤ID寻找对应项。数据约束在进入页面以前检查,避免UI里到处重复判断空值或水量倒退。

// entry/src/main/ets/model/BrewRecipe.ets
export interface BrewStep {
  id: string;
  title: string;
  durationSec: number;
  cumulativeWaterG: number;
  instruction: string;
}
export interface BrewRecipe {
  id: string;
  name: string;
  coffeeG: number;
  waterG: number;
  waterTempC: number;
  steps: BrewStep[];
}
export const RECIPES: BrewRecipe[] = [
  {
    id: 'brew-light-01', name: '晨光浅烘', coffeeG: 15,
    waterG: 240, waterTempC: 92,
    steps: [
      { id: 'bloom', title: '闷蒸', durationSec: 30,
        cumulativeWaterG: 60, instruction: '少量注水,均匀浸润咖啡粉' },
      { id: 'pour-a', title: '第一段', durationSec: 45,
        cumulativeWaterG: 120, instruction: '缓慢注水至累计120克' },
      { id: 'pour-b', title: '第二段', durationSec: 45,
        cumulativeWaterG: 200, instruction: '继续注水至累计200克' },
      { id: 'finish', title: '收尾', durationSec: 30,
        cumulativeWaterG: 240, instruction: '注水至240克并等待滴滤完成' }
    ]
  },
  {
    id: 'brew-nut-02', name: '坚果中烘', coffeeG: 18,
    waterG: 270, waterTempC: 90,
    steps: [
      { id: 'bloom', title: '闷蒸', durationSec: 35,
        cumulativeWaterG: 54, instruction: '湿润全部粉层' },
      { id: 'main', title: '主体注水', durationSec: 85,
        cumulativeWaterG: 220, instruction: '保持均匀水流' },
      { id: 'finish', title: '收尾', durationSec: 35,
        cumulativeWaterG: 270, instruction: '达到目标总水量' }
    ]
  }
];

这些类型均是应用自定义业务模型,不依赖具体设备能力。后续需要接入本地保存时,可以把配方及其版本转换为结构化数据;步骤的显示顺序可以改变,但不能把步骤的数组索引当成业务主键。粉量与水量使用克作统一单位,避免同一个字段在不同组件里分别解释为毫升和克。若产品面向高精度称重,还需要明确小数舍入规则,这一决定应留在业务计算层。

二、把异常参数拦在列表入口

配方库既可能来自预置数据,也可能来自用户保存。加载以后先检查正数、步骤时长、累计水量是否递增,以及最终目标是否与配方总水量一致。不能因为UI仍能显示卡片就认为数据有意义。特别是出现零粉量时,粉水比会遇到无效除数;出现负持续时间时,第二篇的剩余时间也会失去合理范围。

// entry/src/main/ets/service/RecipeValidator.ets
import { BrewRecipe } from '../model/BrewRecipe';
export interface RecipeCheck { valid: boolean; problems: string[] }
export function inspectRecipe(recipe: BrewRecipe): RecipeCheck {
  const problems: string[] = [];
  if (!recipe.id.trim()) problems.push('缺少配方ID');
  if (recipe.coffeeG <= 0 || recipe.waterG <= 0) problems.push('粉水量必须为正');
  if (recipe.waterTempC < 60 || recipe.waterTempC > 100) problems.push('水温超出配置范围');
  if (recipe.steps.length === 0) problems.push('冲泡步骤为空');
  let previous = 0;
  const ids: Set<string> = new Set<string>();
  for (const step of recipe.steps) {
    if (ids.has(step.id)) problems.push('步骤ID重复');
    ids.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 { valid: problems.length === 0, problems };
}
export function totalDurationSec(recipe: BrewRecipe): number {
  return recipe.steps.reduce((sum: number, step) => sum + step.durationSec, 0);
}

校验不能偷偷修改原始配方,例如把不合法的水温强制重设为92℃。这会让用户看到的数值与其保存的数值脱节。更合适的交互是展示具体字段错误,禁止进入计时状态,并提供修改入口。对厂商预置配方则在加载阶段提前检查,一旦发现异常,可以将错误归入配方清单加载状态,避免整页空白。

DevEco Studio中的配方模型与响应布局

三、用一份选中ID驱动窄屏与宽屏

宽屏分栏无需为左右两列维护两套选中状态。列表点击后更新selectedId,明细区域根据ID从配方数组取得数据。布局断点决定展示位置,不能成为数据来源。采用GridRow和GridCol配置多尺寸栅格时,窄布局让两块内容按顺序堆叠,较宽布局使用3/5或4/8列;即使布局重新建立,稳定ID仍然留在组件状态中。

// entry/src/main/ets/pages/RecipeDeskPage.ets
import { BrewRecipe, BrewStep, RECIPES } from '../model/BrewRecipe';
@Entry
@Component
struct RecipeDeskPage {
  @State private recipes: BrewRecipe[] = RECIPES;
  @State private selectedId: string = 'brew-light-01';

  private current(): BrewRecipe {
    return this.recipes.find((r: BrewRecipe) => r.id === this.selectedId)
      ?? this.recipes[0];
  }
  @Builder
  private recipeList() {
    List({ space: 10 }) {
      ForEach(this.recipes, (recipe: BrewRecipe) => {
        ListItem() {
          Text(recipe.name + ' · ' + recipe.coffeeG + '克')
            .fontSize(18)
            .fontColor(recipe.id === this.selectedId ? '#087F8C' : '#222222')
            .padding(16)
        }
        .onClick(() => { this.selectedId = recipe.id; })
      }, (recipe: BrewRecipe) => recipe.id)
    }
  }
  @Builder
  private recipeDetail() {
    Column({ space: 12 }) {
      Text(this.current().name).fontSize(24).fontWeight(FontWeight.Bold)
      Text('粉量 ' + this.current().coffeeG + '克 · 水量 ' +
        this.current().waterG + '克 · 水温 ' + this.current().waterTempC + '℃')
      ForEach(this.current().steps, (step: BrewStep) => {
        Text(step.title + ' · ' + step.durationSec + '秒 · 累计' +
          step.cumulativeWaterG + '克').padding(12)
      }, (step) => step.id)
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
  }
  build() {
    GridRow({ columns: { sm: 4, md: 8, lg: 12 }, gutter: 12 }) {
      GridCol({ span: { sm: 4, md: 3, lg: 4 } }) { this.recipeList() }
      GridCol({ span: { sm: 4, md: 5, lg: 8 } }) { this.recipeDetail() }
    }
    .padding(16)
  }
}

上面的详情Builder保持简单,是为了说明业务状态和展示边界。实际可将列表行与步骤行拆成独立组件,避免长列表每次选择时都进行无关渲染。若要支持“一级列表留在左侧,二级内容推入右侧”的系统导航方式,可以用Navigation与NavPathStack替换手写的宽屏栅格;模式随宽高比改变时依旧传递稳定配方ID。两种方案不要同时驱动不同的选中状态,否则返回页面时容易出现不一致。

四、明确步骤进度与导航位置的区别

当前正在查看“第一段”不等于这一步已经完成。浏览状态可以由focusedStepId控制;正式开始冲泡后,再由独立的进度对象记录哪一步待操作、当前计时会话以及步骤是否得到用户确认。这里先提供纯函数,用于统计每个步骤累计时间和本次计划的注水增量,不把注水增量写成累计量。

import { BrewRecipe } from '../model/BrewRecipe';
export interface StepSummary {
  stepId: string;
  elapsedEndSec: number;
  addWaterG: number;
}
export function planSteps(recipe: BrewRecipe): StepSummary[] {
  const result: StepSummary[] = [];
  let seconds = 0;
  let lastWater = 0;
  for (const step of recipe.steps) {
    seconds += step.durationSec;
    result.push({
      stepId: step.id,
      elapsedEndSec: seconds,
      addWaterG: step.cumulativeWaterG - lastWater
    });
    lastWater = step.cumulativeWaterG;
  }
  return result;
}

对于晨光浅烘,计算得到四个注水增量是60、60、80、40克,累计结束时间依次为30、75、120、150秒。这样第二篇的计时页面即使在中间阶段重新出现,也能通过配方ID、步骤ID和会话状态判断应该显示什么,而不是根据页面重新创建的顺序猜测。数据变更时要给配方增加业务修订号;进行中的会话应持有启动时的步骤快照,避免用户修改配方后造成计时规则突然改变。

手机单栏中的冲泡参数与步骤

五、检查路径与工程排错关注点

工程结构建议将model/BrewRecipe.ets放置数据类型和预置配方,将service/RecipeValidator.ets放置数据规则,pages/RecipeDeskPage.ets组织布局,复杂步骤卡片再放入view/。选择逻辑中记录selectedId、显示配方数量以及每次布局变化后的选中值即可,避免在日志里大量输出用户备注。检查操作顺序应覆盖切换配方、改变筛选、窄屏转宽屏、返回页面以及原配方被删除的情况。对于已删除ID,优先提示“当前配方已移除”,再由用户选择替代项,不应暗中切换到数组第一条造成冲泡参数错用。

在窄内容区重点关注字号缩放、卡片内换行和按钮可触及范围;在宽内容区关注列表与步骤区的独立滚动,以及右侧详情过窄导致的单位断行。GridRow的列数属于界面布局配置,配方字段仍然采用同一份数据。步骤是否完成必须由明确的交互确认,导航展开、折叠或滑动不应该改变完成状态。

六、选择恢复时还要防止旧数据覆盖新状态

如果配方列表从本地异步读取,应为每次请求保留请求序号。用户已经选择“坚果中烘”,较早请求才返回时,不能再把旧结果中的第一条设为选中对象。可在加载完成后优先检查现有selectedId在新列表是否仍存在;存在则保留,不存在则显示明确的“配方已不存在”提示,再要求用户重新选择。对于临时的空列表,也要区分加载中、加载失败和真实无数据三个状态。这样布局重新构建与异步数据更新就不会互相争夺业务选择权。

持续编辑的配方还需要保留修订号。开始冲泡时记录配方修订号和步骤快照,完成后以相同版本写入本次冲泡记录。用户在另一页面调整水温或累计水量时,新参数应影响下一次冲泡,不能静默改动进行中的步骤。这个约束会在后续配方比例换算中继续复用。

七、参考资料与下一步

华为开发者文档中的Navigation分栏开发介绍单栏与分栏模式;GridCol参考给出了不同栅格尺寸的列配置。选择哪一种布局容器,应以信息层级是否需要系统导航为依据。

这一版工作台的核心结果,是业务ID、步骤顺序、累计注水量与响应布局之间有了清晰分工。下一篇将处理150秒冲泡流程的前后台计时恢复:即使页面离开时定时回调暂停,界面返回时也能依据开始时间重新计算剩余时长,同时保留每步需要人为确认的动作。

Logo

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

更多推荐