ArkData + HarmonyOS 专注目标:周计划配置与统计口径隔离(连载05/06)【共创稿事节】
专注记录做得越详细,越容易产生一个看似简单的产品争论:用户设定“每周完成 8 次专注”,统计页到底应该显示 8 次、8 天,还是 8 × 25 分钟?这三项都能画成漂亮的进度条,但表达的是不同承诺。FocusGarden(专注花园)上一期已经按日归组 SessionEntry,这次不再复制一份统计结果,而是给用户增加一个可以保存和修改的每周完成次数目标,让统计值与目标值独立变化。
沿用 focusgarden_timer_v2 Preferences 文件和会话键 session_bundle_v3。原记录有 id、startedAtMs、endedAtMs、plannedSec、label,仅对已完成会话计数;plannedSec 是计划时长,不是传感器测量出的真实投入时长。新版本只增加 weekly_goal_v1 一个键,保存目标次数、版本与修改时间;不反写历史记录,不改动计时器里的 deadlineMs,也不新建第二套“已完成次数总表”。目标用于自我管理,不构成医学或心理评估。

一、别把“目标变化”伪装成“历史变化”
假设过去七个自然日里用户完成 5 次专注,目标设成 8 次,进度条可显示 5/8。当用户把目标改为 6 次,显示立刻变成 5/6,但历史会话没有增加;如果此时把两组数字一起保存成一个“进度对象”,很容易把进度当成事实缓存,并在跨日或新增记录后变旧。我们的数据关系应固定为:sessions → completedCount,goal → weeklyTarget,两者只在展示层汇合。
这里需要提前限定“周”的含义:本篇用最近七个本地自然日(含今天)作为滚动时间窗,而不是周一至周日;它与上一期七日柱状统计一致。采用会话 endedAtMs 的本地日期作为归属,因为这里展示的是完成时刻,而非开始时刻。跨零点完成的会话只计入结束日;今天跨时区或手动调整时区后,日期边界可能改变,统计必须重新计算,不能缓存原来的本地日期字符串。
// entry/src/main/ets/common/WeeklyGoal.ets
export interface WeeklyGoal {
schema: number;
weeklyTarget: number;
updatedAtMs: number;
}
export function validateGoal(target: number): number {
if (!Number.isInteger(target) || target < 1 || target > 30) {
throw new Error('周目标须为 1 至 30 次的整数');
}
return target;
}
export function decodeGoal(raw: string): WeeklyGoal {
const parsed: WeeklyGoal = JSON.parse(raw) as WeeklyGoal;
if (parsed.schema !== 1 ||
!Number.isFinite(parsed.updatedAtMs)) {
throw new Error('目标版本不受支持');
}
validateGoal(parsed.weeklyTarget);
return parsed;
}
这里也要避免把“次数目标”写成带时长的名称,例如把 8 次换算成 200 分钟。只有每次会话计划时长完全相同、从未中断,才能在数学上做这样的换算;但我们的历史数据并不承诺这两个条件。产品希望展示分钟数时,应先定义“计划分钟”与“实际完成分钟”的口径,并让用户能看到差异来源。不同指标可以并列,却不能互相冒充。
上限 30 次是这个自律工具的交互约束,而不是 ArkData API 的容量限制。不同产品可以重新定义它,但应该把验证写在业务层,避免滑块和输入框走两套规则。旧设备首次打开没有 weekly_goal_v1 时,使用产品默认值 8,不意味着写入了一次真实用户设置;不合法的旧记录应提示用户重新选择,而不是悄悄把损坏值重置并覆盖。
二、用一个独立键持久化目标,避免重写历史
Preferences 存小型设置很合适。与其每次修改目标都重写几十条 SessionEntry,不如将目标放在独立键中。由于应用已经通过 @kit.ArkData 获取 Preferences 实例,本篇创建一个 WeeklyGoalStore 包装读写;put() 只修改偏好对象,flush() 返回后才更新页面上的“已保存”提示。连续拖动滑块的中间值不必每次立即写盘,应该只在用户点击“保存目标”时执行一次提交。
// entry/src/main/ets/common/WeeklyGoalStore.ets
import { preferences } from '@kit.ArkData';
import { WeeklyGoal, decodeGoal, validateGoal } from './WeeklyGoal';
export class WeeklyGoalStore {
constructor(private store: preferences.Preferences) {}
async load(): Promise<WeeklyGoal> {
const value = await this.store.get('weekly_goal_v1', '');
if (typeof value !== 'string') { throw new Error('周目标类型错误'); }
if (value === '') {
return { schema: 1, weeklyTarget: 8, updatedAtMs: 0 };
}
return decodeGoal(value);
}
async save(target: number): Promise<WeeklyGoal> {
const next: WeeklyGoal = { schema: 1,
weeklyTarget: validateGoal(target), updatedAtMs: Date.now() };
await this.store.put('weekly_goal_v1', JSON.stringify(next));
await this.store.flush();
return next;
}
}
这段类不负责创建 Preferences 或处理页面生命周期。入口页面沿用已有的 store,初始化时传入 new WeeklyGoalStore(store) 即可。尤其不要为每个列表项创建一个 Preferences 对象,也不要同时用多进程修改相同文件;官方 Preferences 参考 明确说明线程与进程边界及持久化方式。单次保存失败应让 UI 继续显示当前草稿值,并把状态改成“保存失败,可重试”;不能根据滑块的位置推断磁盘里已有新的目标。
三、统计只用已完成记录,进度允许超过目标
为了让周目标可以被验证,先把计算移到纯函数里。这个函数只接受已完成会话列表、现在的本地时刻和目标次数;页面不需要在每次滑动目标时改写源数据。对于完成次数超出目标的情况,数字照实显示 10/8,而进度条视觉上封顶为 100%。这是视觉控件的边界,不代表丢掉两条真实会话。
// entry/src/main/ets/common/WeeklyProgress.ets
import { validateGoal } from './WeeklyGoal';
export interface CompletedSession {
id: string;
endedAtMs: number;
}
export interface WeeklyProgress {
completed: number;
target: number;
ratio: number;
}
export function countRollingDays(items: CompletedSession[],
nowMs: number, target: number): WeeklyProgress {
const today: Date = new Date(nowMs);
const start: Date = new Date(today.getFullYear(),
today.getMonth(), today.getDate() - 6);
const tomorrow: Date = new Date(today.getFullYear(),
today.getMonth(), today.getDate() + 1);
const unique: Set<string> = new Set<string>();
for (const item of items) {
if (Number.isFinite(item.endedAtMs) &&
item.endedAtMs >= start.getTime() &&
item.endedAtMs < tomorrow.getTime()) {
unique.add(item.id);
}
}
const count: number = unique.size;
return { completed: count, target,
ratio: Math.min(100, (count / validateGoal(target)) * 100) };
}
函数用本地日期构造午夜边界,不靠 7 * 24 * 3600 * 1000 毫秒回退,因为某些地区夏令时调整后,自然日未必总长 24 小时。Set 用原始会话 id 去重:同一会话即使被重复读取,也只应算一次。它并不负责判断任意会话是否“真实完成”,这一前提仍来自上一篇保存完成记录的业务逻辑。需要注意从旧 SessionEntry 映射到 CompletedSession 时,不应把“中止”“暂停”记录误传进来。

四、目标页面的视觉反馈不是一张新报表
在现有“七日专注统计”页面下方,新增一张“本周目标”卡片,顶部显示目标和完成次数,中间用 Progress({ value: ratio, total: 100, type: ProgressType.Linear }) 呈现比例,底部通过 Slider({ value: draftTarget, min: 1, max: 30, step: 1 }) 调整草稿值,再由按钮调用 save()。Slider 发生 onChange 时只更新 @State draftTarget,不能直接把动画过程的每个中间值都写入 Preferences。保存前后分别显示“尚未保存”和“目标已保存”,让用户知道哪一个数字可以在重启后恢复。
// WeeklyGoalPage.ets:现有统计页面中的目标卡片核心区域
@State private weeklyTarget: number = 8;
@State private draftTarget: number = 8;
@State private completedCount: number = 5;
@State private saving: boolean = false;
@Builder GoalCard() {
Column({ space: 12 }) {
Text(`本周目标 ${this.completedCount} / ${this.weeklyTarget} 次`)
.fontSize(22).fontWeight(FontWeight.Bold)
Progress({ value: Math.min(100,
this.completedCount / this.weeklyTarget * 100),
total: 100, type: ProgressType.Linear })
.color('#298C74').width('100%')
Text(`调整为 ${this.draftTarget} 次`)
Slider({ value: this.draftTarget, min: 1, max: 30, step: 1 })
.onChange((value: number) => { this.draftTarget = Math.round(value); })
Button(this.saving ? '保存中' : '保存目标')
.enabled(!this.saving)
.onClick(() => { this.persistDraftGoal(); })
}.padding(20).backgroundColor('#FFFFFF').borderRadius(18)
}
persistDraftGoal() 是页面中调用 WeeklyGoalStore.save(this.draftTarget) 的异步方法,关键约束是保存中的按钮不可重入。可以将它写在同一页面组件中:
private goalStore: WeeklyGoalStore | null = null;
@State private saveTip: string = '目标尚未保存';
private async persistDraftGoal(): Promise<void> {
if (this.saving || this.goalStore === null) { return; }
this.saving = true;
try {
const next: WeeklyGoal = await this.goalStore.save(this.draftTarget);
this.weeklyTarget = next.weeklyTarget;
this.saveTip = '目标已保存';
} catch (_) {
this.saveTip = '保存失败,请重试';
} finally {
this.saving = false;
}
}
这里的 goalStore 必须在现有 Preferences 初始化完成后被赋值,首次 load() 也需要 try/catch:一旦旧数据损坏,允许继续查看历史会话,但暂时禁用目标保存直到用户确认重置。不要在 aboutToAppear() 中一边异步读旧设置一边允许保存,因为读取可能晚于点击,反向覆盖用户刚选择的数值。成功时才更新 weeklyTarget 并显示“已保存”,异常时保持旧值并提示存储状态需要核对;如果 put() 已更新缓存但 flush() 失败,不能把旧屏幕数值当成磁盘一定回滚的证据,需要重新读取设置或执行恢复策略。这一行为与“预览滑块修改”的体验恰好不同。示例将业务层和控件层分开讲,是为了不把大量生命周期逻辑混进一张页面截图。Progress 和 Slider 的参数可对照华为 Progress 与 Slider API 文档。

测试时先使用固定的五条已完成会话检查 5/8;改成 6 后应显示 5/6,原会话 JSON 不变。再加入三条不同 id 的已完成记录,应显示 8/6,进度条封顶而次数不截断;插入重复 id 不应增加分子。第三步把设备日期切到跨周边界附近,核查“最近七个自然日”的滚动范围。在测试环境中还应故意将会话结束时刻构造在凌晨零点前后,确保“周目标”的分子与七日列表一致。同一条记录不能一处归到昨天、一处归到今天;为此统计入口应复用上一期的日期归属函数,而不是让两个页面各自写一段格式化字符串的逻辑。最后人为令 flush() 失败,界面草稿仍可修改,但“已保存目标”必须保持旧值。这样验证的是计算边界与存储协议,不是凭空报告设备的性能数据。

五、可编辑的目标应比漂亮的进度条更可靠
这次设计的核心不是新增一条绿色进度线,而是把“我想完成多少次”和“我实际完成过多少次”拆成两条数据路径。目标可以频繁修改,历史却不能被它反向污染;时间窗可以重新计算,记录身份不能跟着 UI 排序变化。下一期将围绕设置迁移、记录清理和数据恢复收尾,让 FocusGarden 从计时器、历史统计到目标配置形成一条能升级、能排错的本地数据路径。
更多推荐


所有评论(0)