“把烤箱预设从十二分钟改成十五分钟”,听起来只是修改一个数字;可如果用户正让烤箱倒计时,这个动作究竟是修改下一次的默认值,还是把已经运行的计时延长三分钟?KitchenPulse 前四篇已经实现双工位状态机、前后台恢复、Preferences 检查点及完成通知尝试。本轮不再重复定时器逻辑,而是单独设计一份可编辑的预设配置:编辑、确认、持久化以及对运行中任务的影响,全部采用明确的业务约定。

KitchenPulse 自定义预设工程封面

一、一个分钟数,其实属于两份状态

应用保留两个工位 ID:hob 对应“灶台·焖煮”,oven 对应“烤箱·烘焙”。原有 TimerLane 中的 remainingSeconds 和 deadlineMs 描述当前正在执行的计时,status 限定 idle/running/paused/done。今天新增的 TimerPreset 只描述下一次启动时默认多少分钟。用户在设置页保存“烤箱 15 分钟”后,已运行的烤箱仍按之前计算的截止时刻结束;只有下一次明确点击“重新开始”,才会读取新的预设。

这个约定避免了一类非常隐蔽的错误:设置页做了 lane.remainingSeconds = newMinutes * 60,但忘记一起更新 deadlineMs,结果页面短暂显示十五分钟,下一秒又被运行模型纠正回原来的秒数。更糟的是,在通知管理层可能使用旧截止时刻作为完成事件键的情况下,直接修改运行中工位的截止时间会改变事件身份,引发重复提醒或漏提醒。预设不是任务快照,任务快照也不是用户偏好设置,即便二者都恰好包含“秒数”字段,也不能直接引用同一个可变对象。

产品上把规则说清楚:预设支持整数 1~90 分钟;输入框允许用户暂时清空以便编辑,但保存时不能为空;一条预设由稳定工位 ID、显示名称和 minutes 构成;保存后展示“下次启动生效”,不改变正在运行或暂停的任务。若用户想改变当前计时,需要额外的“停止后重新开始”操作,而不是通过改设置偷偷影响业务事实。

二、先做纯校验,再触发磁盘写入

TextInput 的 InputType.Number 可以提供数字输入体验,inputFilter 也能挡住部分非法键入,但它们都是交互帮助,不是业务可靠性边界。粘贴内容、外部恢复或以后从云端同步配置时,数据仍须经过同一条校验函数。为避免小数、负数、前后空格与超过安全整数的转换问题,本篇先按字符串处理,只有完全满足十进制数字格式后才转成数字。

// entry/src/main/ets/model/TimerPreset.ets
export interface TimerPreset {
  laneId: string;
  label: string;
  minutes: number;
}

export function parseMinutes(raw: string): number {
  const value: string = raw.trim();
  if (!/^[0-9]{1,3}$/.test(value)) {
    throw new Error('请输入1到90的整数分钟');
  }
  const minutes: number = Number(value);
  if (!Number.isInteger(minutes) || minutes < 1 || minutes > 90) {
    throw new Error('分钟数必须在1到90之间');
  }
  return minutes;
}

export function secondsFor(preset: TimerPreset): number {
  return preset.minutes * 60;
}

只允许一到三位数字并不意味着“001”必须报错,这由产品输入规则决定。本篇采用解析后归一化为整数的策略,输入 008 保存为 8;如果产品要求禁止前导零,则修改正则而非改动多个按钮的点击逻辑。单位一律在数据层统一:预设存整数分钟,运行模型存整数秒,时间比较用 deadlineMs 毫秒时间戳。不要在界面某个组件里将 8 当秒,另一个组件又乘 60000。

保存函数应当先完成校验,再使用 Preferences 写入和 flush();只有持久化确认成功,才让页面的已保存预设取代旧值。仍沿用 ArkData Kit 的用户首选项,并新建单独文件 kitchenpulse_presets、键 preset_v1,与上一轮的 kitchenpulse_notice 和计时检查点互不覆盖。小容量配置适合 Preferences;它并非事务订单库,也不承担云端跨设备一致性职责。

// entry/src/main/ets/common/PresetRepository.ets
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { TimerPreset } from '../model/TimerPreset';

export class PresetRepository {
  private context: common.UIAbilityContext;
  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }

  async save(rows: TimerPreset[]): Promise<void> {
    const prefs = await preferences.getPreferences(this.context,
      { name: 'kitchenpulse_presets' });
    const payload: string = JSON.stringify({ version: 1, rows: rows });
    await prefs.put('preset_v1', payload);
    await prefs.flush();
  }

  async load(): Promise<TimerPreset[]> {
    const prefs = await preferences.getPreferences(this.context,
      { name: 'kitchenpulse_presets' });
    const raw: string = String(await prefs.get('preset_v1', ''));
    if (!raw) { return [
      { laneId: 'hob', label: '灶台·焖煮', minutes: 8 },
      { laneId: 'oven', label: '烤箱·烘焙', minutes: 12 }
    ]; }
    const value = JSON.parse(raw) as { version: number, rows: TimerPreset[] };
    if (value.version !== 1 || !Array.isArray(value.rows)) {
      throw new Error('预设格式或版本不受支持');
    }
    // 后文的 validateRows 负责 ID 唯一性与分钟范围二次核验。
    return value.rows;
  }
}
// entry/src/main/ets/model/TimerPreset.ets
export function validateRows(rows: TimerPreset[]): TimerPreset[] {
  if (rows.length !== 2) { throw new Error('工位配置数量错误'); }
  const ids: Set<string> = new Set<string>();
  for (const row of rows) {
    if ((row.laneId !== 'hob' && row.laneId !== 'oven') ||
      ids.has(row.laneId) || !Number.isInteger(row.minutes) ||
      row.minutes < 1 || row.minutes > 90) {
      throw new Error('预设数据包含非法值或重复工位');
    }
    ids.add(row.laneId);
  }
  return rows;
}

如果历史安装版本曾把所有预设只保存在一个数字数组里,未来升级不能直接按数组下标推断新工位。更稳妥的处理方法是为快照附上 version,旧版数据进入单独的迁移函数,迁移成功后再写入新版记录。这里的两个工位具有不随名称改变的 laneId,因此即使将“灶台·焖煮”更名为“汤锅”,存储关系仍可被识别。无法识别的格式应提示用户进行修复或确认重置,而不是在错误分支默默写出默认数据。

生产工程的 load() 调用方必须再运行 validateRows():限定 ID 只能是 hob、oven,每个 ID 仅出现一次、分钟为 1~90 的整数,缺项按规则处理。JSON.parse 的类型断言不构成运行时验证,不能因为数据来自应用沙箱就无条件相信它。格式异常时应保留“恢复失败”的可见提示,不要在下一次点击保存前悄悄把损坏文件覆盖。正式项目还应加入写入串行化;两个编辑页同时写同一个 Preferences 文件时,简单的“最后完成者获胜”不等于用户最后一次确认的意图。

DevEco Studio 预设编辑页与实际代码对应的开发示意

三、界面上同时出现草稿、已保存值和运行态

设置页最少需要三份独立状态:minuteText 是用户正在编辑的字符串,允许暂时为空;savedMinutes 是最后一次确认落盘的整数;saving 记录写入中的按钮状态。编辑框不直接绑定 TimerLane.remainingSeconds。点击“保存预设”后先执行 parseMinutes,再提交 Repository。若写盘失败,保留输入草稿、展示“保存失败,可重试”,原来的已保存值不变。

// entry/src/main/ets/pages/PresetPage.ets 核心片段
@Entry
@Component
struct PresetPage {
  @State minuteText: string = '15';
  @State savedMinutes: number = 12;
  @State saving: boolean = false;
  @State errorText: string = '';
  private repo?: PresetRepository;

  private async saveOven(): Promise<void> {
    if (this.saving || !this.repo) { return; }
    let next: number;
    try { next = parseMinutes(this.minuteText); }
    catch (e) { this.errorText = String(e); return; }
    this.saving = true;
    try {
      await this.repo.save([
        { laneId: 'hob', label: '灶台·焖煮', minutes: 8 },
        { laneId: 'oven', label: '烤箱·烘焙', minutes: next }
      ]);
      this.savedMinutes = next;
      this.errorText = '';
    } catch (e) {
      this.errorText = '保存失败,请稍后重试';
    } finally { this.saving = false; }
  }

  build() {
    Column({ space: 16 }) {
      Text('自定义计时预设').fontSize(24)
      TextInput({ text: this.minuteText, placeholder: '1—90分钟' })
        .type(InputType.Number)
        .onChange((value: string) => { this.minuteText = value; })
      Button(this.saving ? '保存中…' : '保存预设')
        .enabled(!this.saving).onClick(() => { this.saveOven(); })
      Text(this.errorText || `当前保存:${this.savedMinutes}分钟`)
    }.padding(24)
  }
}

这段节选展示的 repo 由实际 Stage 页面通过宿主 UIAbilityContext 在 aboutToAppear() 初始化;其中灶台的 8 分钟是示例中的已保存值,正式实现应从仓储取出两个工位并只替换被编辑的那条,而不是每次保存烤箱就强行重置灶台。这个边界值得在开发时写成单独的 updatePreset(rows, laneId, minutes) 函数,避免用户连续编辑两个工位后前一次的修改丢失。图片中的设置页只展示已保存值与草稿,不宣称已连接真实厨房设备。

四、下一次开始才读取新的预设

应用启动时先恢复 TimerLane 的检查点,再读取 TimerPreset。如果 oven.status === 'running',保持先前的 deadlineMs、remainingSeconds 及原有事件键;预设更新只改变“下一次启动”按钮准备使用的默认秒数。如果工位处于 paused,也不要强制改变当前暂停任务,因为用户可能打算稍后继续。只有 idle 或 done 状态下用户明确点击“重新开始”,才执行 secondsFor(preset),再生成全新的截止时刻。

// 当前任务重新开始的业务边界
export function restartLane(lane: TimerLane,
  preset: TimerPreset, nowMs: number): TimerLane {
  if (lane.id !== preset.laneId) { throw new Error('工位不匹配'); }
  if (lane.status === 'running' || lane.status === 'paused') {
    throw new Error('请先停止当前计时,再重新开始');
  }
  const total: number = secondsFor(preset);
  return { ...lane,
    presetSeconds: total,
    remainingSeconds: total,
    deadlineMs: nowMs + total * 1000,
    status: 'running' };
}

restartLane 是纯函数,不访问 Preferences、不发布通知,也不直接触发 UI 更新。调用者得到新的工位对象后,沿用第三篇的检查点存储和第四篇的通知策略。它通过工位 ID 关系保证“烤箱预设不能应用到灶台”。若现在是下午三点,新的 15 分钟计时结束于十五分钟后,这里使用的是当前数值 nowMs 的加法,而不是本地格式化时间字符串;因此不会由于页面日期格式或时区文案发生误算。

预设配置、运行快照和重新开始的状态边界

还有一组用户体验约束常被忽略。用户从编辑页返回计时页面时,不需要重新创建两个工位对象,更不需要清除已保存在检查点里的任务;设置成功提示只能说明默认预设已经确认保存,而不是当前剩余时间发生变化。反之,若编辑页因进程回收被重建,最先执行的是读取仓储、校验预设结构,再把已确认值填入草稿;不能单纯相信组件初始常量。工位状态为完成时,用户选择新预设并点击重新开始,系统必须产生新一轮事件身份,上一轮的已通知标记继续保留用于去重。另需给数值和错误提示提供可访问的文字标签,避免只靠颜色区分“输入错误”和“已保存”。这些细节并未增加新的系统 API,却决定两份状态能否长期稳定共存。

五、测试重点是拒绝错误而不是点通按钮

最基本的测试矩阵包括空字符串、非数字、0、91、008、1、90;预期只有合法数字进入 save(),且保存后统一转成整数。再构造 flush() 抛错,确保 savedMinutes 不变、按钮恢复可用、草稿保留。把烤箱设为 running,修改预设为 15 分钟,预期正在计时的截止时刻保持不变;工位处于 paused 时同样不能自动重算。停止并重新开始后,才产生新的 deadlineMs。两个工位连续编辑时要验证未被编辑的另一条预设保持原值。

设置页在启动后读存储也存在时序问题:恢复尚未完成时不要把默认输入框当作用户的旧值;读盘失败不要立即允许保存空默认值,否则可能误覆盖历史数据。对于将来引入多窗口设置的场景,应该再加仓储侧序列化队列或版本冲突判断,本轮单页面的 saving 只能防止本页面重复点击。对极短计时还要考虑是否允许通知授权交互遮住关键操作,预设编辑过程中不应触发计时和通知能力。

预设输入校验与保存失败处理检查表

与前几篇的系统接口相比,本篇故意把“很简单的数字输入”做成一个独立边界,因为数字一旦进入业务模型,就会间接影响运行时间和完成事件。测试可以从 ArkTS 的纯函数开始,再在 DevEco 的 Local Test 或 Instrument Test 环境验证 Preferences 和页面事件;未经实际设备执行,不应填入性能耗时、编译成功记录或通知送达率。对日常产品而言,用户保存了什么与当前任务正在做什么是两种不同事实,分开建模反而减少意外。

最后一篇将把双工位计时、检查点恢复、完成通知和预设管理串成一条完整的回归验收路线,专门处理进程重建与异常恢复等边界,而不再添加彼此不相干的视觉功能。KitchenPulse 的核心价值始终是可以解释、可以恢复、可以拒绝错误的计时语义。

官方资料:TextInput 输入框、Preferences 存储接口、ArkTS 自动化测试框架、Stage UIAbility 生命周期。

Logo

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

更多推荐