番茄钟最大的问题:关掉之后你完全不记得自己专注过
这是我做「声境护照」的出发点。市面上的计时工具太多了,倒计时结束,弹个通知,然后呢?没了。你昨天专注了多久、这周状态怎么样、有没有比上周进步——大部分 App 不关心这些,用户自然也不关心。
我想做的是:让每次专注变成一段「飞行旅程」,累积里程、升级等级、生成可分享的战报卡片。让"我今天专注了 50 分钟"这件事有重量感,值得被记录。
目前 1.4 版本,刚上架不久,下载量还很少。但技术实现上有些东西值得拿出来聊,特别是成长系统的建模、会话结束后的智能推荐、以及灵动岛踩的坑。
成长系统:怎么让一个计时器有「养成感」
核心输入就两样:历史专注记录 [FocusLog] 和连续天数 streakDays。每次专注结束后根据时长算经验值,累积到阈值就升级,解锁新称号。
听起来简单,但经验阈值的曲线调了三版。
最早用线性增长(每级多 100 经验),问题是前几级太慢,用户第一天可能只专注一两次,完全没有正反馈。第二版改成固定值(每级都是 200),又太无脑,后期没有成就感。最后定的方案是前 5 级很快(50/80/120/160/200),之后按 1.3 倍递增。
改完之后,测试期间 5 个内测用户里有 3 个在第一天就升到了 2 级,第三天有人到了 4 级。之前线性方案,同样的使用频率,第三天大部分人还卡在 2 级。虽然样本很小,但体感上确实对了——让用户在前三天密集获得正反馈,之后慢慢拉长间隔。
会话结束后的「下一步建议」
这是我比较满意的功能。专注结束后不只说"恭喜完成",而是给出下一次建议——什么时间、多长、用什么声景、做什么任务。
func nextActionAdvice(taskCompleted: Bool) -> SessionNextActionAdvice {
let nextTime = FocusStartupUseCase.nextFocusTimeText(logs: store.focusLogs)
let remainingTodayPlan = max(0, store.weeklyPlanTodayTargetSegments - store.weeklyPlanTodayActualSegments)
let baseDetail: String
if remainingTodayPlan > 0 {
baseDetail = "今天还差 \(remainingTodayPlan) 段达成日计划,建议按推荐模式补齐。"
} else if taskCompleted {
baseDetail = "当前航标已完成,建议切换到下一航标并保持节奏。"
} else {
baseDetail = "当前航标已推进,下一段建议按同样时长继续完成。"
}
let streakHint: String
if store.streakDays >= 5 {
streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
} else if store.streakDays >= 2 {
streakHint = "再坚持 1-2 天可进入稳定习惯区。"
} else {
streakHint = "建议先连续 3 天完成每日最小闭环。"
}
// ...
}
大部分计时 App 结束就结束了,用户得自己决定"接下来干嘛"。我把这个决策成本降到零——看一眼建议,觉得合理就直接开始下一段。连续天数的提示做了三个档位,文案看着是小事,但对习惯养成来说是有用的心理暗示。
远征系统的数据建模
除了日常计时,我还做了「远征」模式——多日挑战,每个章节绑定一个城市声景,里面有若干任务,全部达成解锁下一章。
struct ExpeditionMissionDefinition: Identifiable, Codable, Equatable {
let id: String
let title: String
let kind: ExpeditionMissionKind // .sessionCount / .focusMinutes / .deepFocusCount
let targetValue: Int
let rewardMiles: Int
}
struct ExpeditionChapterDefinition: Identifiable, Codable, Equatable {
let id: String
let sceneId: String
let cityName: String
let missions: [ExpeditionMissionDefinition]
}
用枚举区分任务类型,新增类型只要加一个 case。章节状态和任务状态分开存(ExpeditionChapterState / ExpeditionMissionState),每次专注结束统一算 progress 检查是否触发完成。后面要加"累计连续天数"类型的任务,扩展枚举和计算逻辑就行,不用动结构。
灵动岛踩坑:ActivityKit 的状态同步问题
专注进行中用了 Live Activity 做灵动岛展示。FocusActivityAttributes 存声景名称、目标时长等静态信息,ContentState 存剩余秒数、进度百分比、是否暂停。
踩的最大坑是状态不同步。具体表现:用户暂停专注后锁屏,过一会儿再看灵动岛,显示的还是暂停前的倒计时数字在继续跑。原因是 ActivityKit 的 update 调用有频率限制——系统不保证每次 update 都立刻生效,特别是 App 在后台的时候,update 可能被延迟甚至丢弃。
我试过用 AlertConfiguration 触发强制刷新,效果不稳定。最后的方案比较暴力:在 ContentState 里传 endDate(预计结束时间点)而不是 remainingSeconds(剩余秒数),让灵动岛的 UI 层自己用 Text(timerInterval:) 做本地倒计时。这样即使 update 被延迟,显示的时间也是对的。暂停的时候传一个特殊状态 isPaused: true,UI 层冻结显示。
这个改法解决了 90% 的同步问题。剩下 10% 是 App 被系统杀掉后 Live Activity 还在屏幕上但没人更新它的情况,我在 applicationWillEnterForeground 里加了一次强制同步,检查当前 Activity 状态和 App 内状态是否一致,不一致就 update 或 end。
砍掉的功能
- 社交排行榜——做了一半砍了。独立开发没精力维护后端实时排行,而且我自己也不喜欢被排名绑架的感觉。
- AI 声景推荐——试了根据时间和历史偏好推荐,效果很一般。用户就固定用那两三个声景,推荐反而增加选择负担。
- 完整任务管理——做专注的人不缺 todo 工具,他们缺的是"开始做"的动力。所以任务只保留了几个模板(英语阅读、论文写作、编程训练),选一个直接开始。
一个还没想清楚的问题
连续天数断了怎么处理?我现在是直接归零,说实话有点残忍。连续 15 天突然断一天,从 0 开始,心理打击太大了。
我考虑过两个方案:一是「冻结卡」,每周给一次免断机会;二是「衰减制」,断一天不归零而是减少一定比例(比如减 30%),连续断才归零。衰减制更符合真实的习惯建设逻辑——偶尔一天没做不代表习惯崩了。但实现起来等级和里程的计算会变复杂,而且用户理解成本也高。
有做过类似产品的朋友,你们是怎么处理这个问题的?归零、衰减、还是其他方式?