HarmonyOS手冲咖啡连载02:前后台计时漂移与恢复【共创稿事节】
上一版手冲工作台已经将“晨光浅烘”固定为brew-light-01,其四个步骤分别持续30、45、45、30秒。把配方展示做出来以后,最容易暴露的问题是计时:在页面可见时每秒减一,看起来能够正确倒数;用户查看另一页、切到后台或者系统延后回调之后,计时值可能停留在离开时的数字。再次回到冲泡页如果继续累加回调次数,剩余时长就与实际经过的时间发生偏差。
处理这类偏差不能依赖更频繁的刷新,而应改变时间的保存方式。当前步骤的截止时刻与暂停时剩余毫秒数是业务事实;页面上的倒计时只是由这些字段计算出的视图。计时周期可以驱动UI刷新,却不能成为计时结果的唯一来源。第二篇围绕时间戳、暂停恢复、进度确认和持久化,接上前文的配方ID与步骤ID。

一、为什么减法式倒数容易漂移
假设第一段注水的计划持续45秒,页面在09:41:00进入该阶段,09:41:18离开。使用remaining -= 1且依赖每秒一次回调的实现,在后台回调延后时可能无法知道准确经过了多久。回到页面的用户理应看到约27秒,而不是把离开前的数字原封不动继续播放。系统计时器文档明确指出,界面退到后台时定时器会被冻结,因此在应用架构上应把定时回调视为“刷新通知”,而不是可靠的真实时间轴。
冲泡还有一个常被忽略的特点:步骤到期并非意味着用户已经完成注水。倒计时达到零以后,界面应呈现“本步骤时间已到,等待确认”,而不是自动跳到下一段并假设水量已经达到目标。前文BrewStep中的累计水量因此仍是目标值,真实操作需要用户确认;计时状态与实际称重数值之间保持边界。
二、会话模型同时保存截止时刻和暂停余量
运行中的会话以deadlineAtMs标记本阶段预计结束的Unix毫秒时间。暂停后,系统保存remainingMs,把截止时刻清空。恢复时重新计算新的截止时刻。空闲、进行中、暂停、等待确认与完成是互不混淆的状态,避免把界面上显示零秒视为已经完成整杯冲泡。
// entry/src/main/ets/model/BrewTimerSession.ets
export type TimerState = 'IDLE' | 'RUNNING' | 'PAUSED' |
'WAITING_CONFIRM' | 'FINISHED';
export interface BrewTimerSession {
recipeId: string;
recipeRevision: number;
stepId: string;
stepIndex: number;
state: TimerState;
remainingMs: number;
deadlineAtMs: number;
savedAtMs: number;
}
export function remainingFor(session: BrewTimerSession, nowMs: number): number {
if (session.state === 'RUNNING') {
return Math.max(0, session.deadlineAtMs - nowMs);
}
return Math.max(0, session.remainingMs);
}
export function moveToPaused(session: BrewTimerSession, nowMs: number): BrewTimerSession {
return {
...session, state: 'PAUSED',
remainingMs: remainingFor(session, nowMs),
deadlineAtMs: 0, savedAtMs: nowMs
};
}
export function resumeAt(session: BrewTimerSession, nowMs: number): BrewTimerSession {
if (session.state !== 'PAUSED') return session;
return {
...session, state: 'RUNNING',
deadlineAtMs: nowMs + session.remainingMs, savedAtMs: nowMs
};
}
这里的Date.now()用于在页面重新可见时重算时间差。它是墙上时钟,用户修改系统时间可能影响结果,因此恢复过程还应识别不合理的时间跨度:例如保存的时间在当前时刻之后,或者从一次短时间冲泡跨越了很久。遇到这类情况不直接写入“已完成”,而是提示检查状态,并允许用户重新校准该步骤。若需要同一进程内更精确的间隔,可额外记录单调时钟作辅助判断,但落盘后的跨进程恢复仍需要可持久化的时间依据。
三、进入后台后从绝对时间重新计算
页面刷新使用节流后的间隔回调,刷新时取当前时间与截止时刻的差。间隔取250毫秒只影响画面的更新流畅度,不会影响结果是否正确。对停止刷新要进行清理,否则反复进入页面后可能并存多个间隔回调,造成重绘与资源占用。应用重新进入前台时,调用同一个refresh()逻辑即可恢复显示;无需把后台每秒发生的变化补成一大串回调。
// entry/src/main/ets/pages/BrewTimerPage.ets
import { BrewTimerSession, remainingFor } from '../model/BrewTimerSession';
@Entry
@Component
struct BrewTimerPage {
@State private session: BrewTimerSession = {
recipeId: 'brew-light-01', recipeRevision: 1,
stepId: 'pour-a', stepIndex: 1, state: 'IDLE',
remainingMs: 45000, deadlineAtMs: 0, savedAtMs: 0
};
@State private shownRemainingMs: number = 45000;
private ticker: number = -1;
private refresh(): void {
const left = remainingFor(this.session, Date.now());
this.shownRemainingMs = left;
if (left === 0 && this.session.state === 'RUNNING') {
this.session = { ...this.session, state: 'WAITING_CONFIRM',
remainingMs: 0, deadlineAtMs: 0 };
this.stopTicker();
}
}
private stopTicker(): void {
if (this.ticker !== -1) {
clearInterval(this.ticker);
this.ticker = -1;
}
}
private startCurrentStep(): void {
const now = Date.now();
this.session = { ...this.session, state: 'RUNNING',
remainingMs: 45000, deadlineAtMs: now + 45000, savedAtMs: now };
this.startTicker();
}
private startTicker(): void {
this.stopTicker();
this.refresh();
if (this.session.state === 'RUNNING') {
this.ticker = setInterval(() => this.refresh(), 250);
}
}
onPageShow(): void { this.startTicker(); }
onPageHide(): void { this.stopTicker(); }
aboutToDisappear(): void { this.stopTicker(); }
build() {
Column({ space: 20 }) {
Text('晨光浅烘 · 第一段注水').fontSize(23)
Text(Math.ceil(this.shownRemainingMs / 1000) + '秒')
.fontSize(44).fontWeight(FontWeight.Bold)
Text(this.session.state === 'WAITING_CONFIRM' ?
'时间已到,请确认实际操作' : '注水至累计120克')
Button('开始本步骤').onClick(() => this.startCurrentStep())
}.padding(24).width('100%')
}
}
代码里的初始会话处于空闲状态;点击开始后才生成当前步骤的截止时刻。真正进入页面时,应先载入保存的会话数据,再根据其状态选择恢复或创建当前步骤,不能把空闲对象当成已经启动的冲泡记录。onPageShow、onPageHide适合路由页面的可见性管理;如果计时组件放在Navigation子页,还要结合NavDestination的生命周期对可见性和当前活动页进行处理。生命周期回调负责启动、停止刷新,时间计算逻辑始终保持在模型层。

四、持久化写入的顺序关系
计时数据涉及页面中断,单靠@State内存值无法覆盖进程退出后的恢复。可以使用Preferences保存结构化会话字段。保存动作发生在开始、暂停、恢复、用户确认阶段结束等明确状态转移之后;不要每250毫秒写一次磁盘。写入前先生成新状态,写入成功后通知页面更新,这样存储失败时可以提示重试,不至于UI和落盘数据悄然分叉。
// entry/src/main/ets/service/TimerSessionStore.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BrewTimerSession } from '../model/BrewTimerSession';
export class TimerSessionStore {
constructor(private context: common.UIAbilityContext) {}
async save(session: BrewTimerSession): Promise<void> {
const store = await preferences.getPreferences(this.context, { name: 'brew_state' });
await store.put('active_session', JSON.stringify(session));
await store.flush();
}
async load(): Promise<BrewTimerSession | null> {
const store = await preferences.getPreferences(this.context, { name: 'brew_state' });
const value = await store.get('active_session', '');
if (typeof value !== 'string' || value.length === 0) return null;
try {
const session = JSON.parse(value) as BrewTimerSession;
if (!session.recipeId || !session.stepId || session.remainingMs < 0) return null;
return session;
} catch (_) {
return null;
}
}
}
恢复时应校验配方ID、配方修订号、步骤ID、状态枚举和数值范围。只要配方已经被删除,页面就应该提示原会话不可直接续用,而不是将其映射到列表第一项。对于运行中的会话,读取后调用remainingFor(session, Date.now())就能恢复时间;暂停中的会话返回保存的剩余毫秒数,不随现实时间前进。若跨天恢复仍在运行中,可以保持“时间已到,等待确认”,而不是默默推进多个冲泡步骤。
五、暂停、继续和确认下一段的原子更新
状态变化必须集中在一个函数里,防止两个按钮几乎同时触发时覆盖彼此。暂停的核心是锁定余量,继续的核心是生成新的截止时间;确认本段完成以后再选择下一步,并重新写入下一个步骤的时长。对于已到期状态,允许用户查看当前目标水量以及下一步说明,但是否进入下一步应以明确点击为准。配方内容在会话开始时取快照,不跟随编辑页动态修改。
import { BrewTimerSession, remainingFor, resumeAt,
moveToPaused } from '../model/BrewTimerSession';
export function nextState(session: BrewTimerSession,
action: 'PAUSE' | 'RESUME' | 'RECONCILE', now: number): BrewTimerSession {
if (action === 'PAUSE' && session.state === 'RUNNING') {
return moveToPaused(session, now);
}
if (action === 'RESUME' && session.state === 'PAUSED') {
return resumeAt(session, now);
}
if (action === 'RECONCILE' && session.state === 'RUNNING' &&
remainingFor(session, now) === 0) {
return { ...session, state: 'WAITING_CONFIRM',
remainingMs: 0, deadlineAtMs: 0, savedAtMs: now };
}
return session;
}
这段状态转移不修改步骤索引,避免外部代码误以为时间到期后自动完成了注水。确认下一步可以另设一个动作,检查当前状态确实为WAITING_CONFIRM、当前步骤ID与快照匹配,然后把stepIndex增加一并建立新阶段计时。若未来接入提醒能力,提醒触发时也应重新读取会话状态,不把提醒消息本身当成状态事实。

六、排错路径与适配细节
验证计时逻辑时,优先手工计算可复核的时间序列:第一段45秒,从09:41:00开始,18秒后离开,再过5秒返回,此时剩余应为22秒;如果进入后台的瞬间暂停,返回时才继续,则剩余应为27秒。两种产品语义都合理,但必须由明确的产品规则决定,不能混用。测试输入还包括提前返回、系统时间向前或向后调整、空会话、损坏的存储字符串、重复点击暂停以及两次页面进入产生的重复计时句柄。
业务日志推荐记录recipeId、stepId、state、deadlineAtMs和计算后的剩余毫秒数,不附带用户的自由文本。计时到零要防止重复弹出下一步按钮,写盘失败要提示用户保留当前页面并重试。较窄手机屏幕重点保证倒计时和操作按钮在大字号下仍可完整显示;平板并列展示配方与步骤时,左右两侧使用同一份会话对象,避免各自启动独立计时器。
七、参考资料与工程结论
华为开发者的定时器接口参考明确介绍页面退到后台时计时器冻结的行为;Preferences参考给出了获取存储实例及异步读写方式;NavDestination页面生命周期用于理解子页面显示与隐藏的处理时机。
至此,配方身份、步骤数据与冲泡时间建立了连续的工程接口。后续可以在不改变会话主键的前提下加入粉量变更与水量换算,并在手机和平板间保持一致的进度展示。可靠计时的关键不是每秒回调得多么整齐,而是任何时刻都能从业务状态独立算出正确的剩余时间,并把需要用户确认的操作与计时到期严格分开。
更多推荐


所有评论(0)