这个东西是什么
我做了一个专注计时工具,叫「声境护照」。说白了就是番茄钟,但我给它套了一层旅行叙事——每次专注是一段「飞行」,累计时长换算成里程,连续打卡天数决定你的旅行者等级。结束后会生成一张战报卡片,长得像登机牌。
想法的来源很简单:我自己用过不下十个专注 App,计时功能都差不多,但没有一个让我有动力「回来看看」。记录在那儿,就是一堆数字。我想试试,如果把这些数字变成某种叙事,事情会不会不一样。
核心玩法拆解
整个 App 围绕三件事转:
声景 + 计时。 你选一个场景,比如「东京雨夜」或者「冰岛极光」,App 播放对应的环境音,同时倒计时。这部分技术上没什么花的,AVAudioSession 配合 ActivityKit 做灵动岛倒计时。
会话战报。 每次专注结束,App 会算出一个效率指数、成长经验值,加上连续天数和里程,打包成一张可分享的卡片。这张卡片是我花时间最多的地方。
成长系统。 里程累计到一定数值解锁新等级称号,连续天数会影响推荐策略。
数据卡片的渲染,踩了不少坑
分享卡片这个功能,我大概重写了三次。
第一版用 SwiftUI 的 ImageRenderer,直接把视图渲染成图片。效果不错,但在部分机型上字体渲染有偏移,尤其是动态字体大小开得比较大的用户。
第二版我改成了 UIGraphicsImageRenderer,手动布局。可控性强了,但代码量翻了三倍,维护起来很痛苦。
最后回到 SwiftUI 方案,用固定尺寸的容器规避动态字体问题。说实话有点妥协,但对一个独立项目来说,能用就行。
日期格式化这种小事也有讲究,我在 ShareCardFormatter 里统一管理格式:
enum ShareCardFormatter {
private static let sessionDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy/MM/dd HH:mm"
return formatter
}()
static func sessionDate(_ date: Date) -> String {
sessionDateFormatter.string(from: date)
}
}
看着简单,但如果不集中管理,卡片上的日期格式和战报里的日期格式很容易打架。早期我就犯过这个错,用户截图发给我说「这两个地方日期长得不一样」,挺尴尬的。
「下一次建议」这个功能的决策逻辑
每次专注结束后,App 会给一个「下一次建议」,包括推荐时间、推荐时长、推荐场景。这个功能我纠结了很久要不要做,因为做得不好就是垃圾建议,反而扣分。
最后的方案比较保守,根据三个维度给建议:今天还差多少段达成日计划、当前任务有没有完成、连续打卡天数处于哪个阶段。
func nextActionAdvice(taskCompleted: Bool) -> SessionNextActionAdvice {
let remainingTodayPlan = max(0,
store.weeklyPlanTodayTargetSegments - store.weeklyPlanTodayActualSegments)
let streakHint: String
if store.streakDays >= 5 {
streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
} else if store.streakDays >= 2 {
streakHint = "再坚持 1-2 天可进入稳定习惯区。"
} else {
streakHint = "建议先连续 3 天完成每日最小闭环。"
}
// ...
}
连续 5 天以上的用户,措辞偏向「保持节奏」;刚开始的用户,措辞偏向「先坚持 3 天」。这个分层很简单,但我觉得比千篇一律的「加油」有用。
远征系统:轻度游戏化的边界在哪
App 里有一个「远征」模块,每个章节绑定一个城市场景,里面有若干任务(完成 N 次专注、累计 N 分钟深度专注等),完成后奖励里程。
数据模型大概是这样:ExpeditionChapterDefinition 定义章节内容,ExpeditionChapterState 记录用户进度,ExpeditionProgress 汇总全局里程和当前章节。
做这个功能的时候我反复问自己一个问题:游戏化到什么程度算合适?
我试过加排行榜,做了原型之后删了。原因是专注这件事太私人了,排行榜会让人焦虑,跟产品调性冲突。
也试过加成就徽章的稀有度系统,最后简化成了纯里程累计。因为我发现系统越复杂,用户越搞不清楚「我到底该干嘛」。一个专注工具,打开就应该能开始专注,不是先研究半天规则。
灵动岛适配
这个功能对专注类 App 来说挺重要。用户开始计时后切到别的 App,灵动岛上能看到剩余时间和进度。
我用 ActivityKit 的 ActivityAttributes 定义了场景名称、目标分钟数、主题色,ContentState 里放剩余秒数和进度百分比。暂停状态也做了区分,灵动岛上会显示不同的视觉样式。
这部分如果你也在做类似功能,有个小建议:progress 用 Double 传过去,别在 Widget 端算。Widget 的刷新时机你控不住,算出来的值经常跟主 App 对不上。
Demo 数据的处理
一个小细节:如果用户还没有任何专注记录,统计页面和战报页面不能是空的。我在 StatsService 里做了一个 createDemoFocusLogs() 方法,生成一组示意数据,让用户知道「用起来之后这里会长什么样」。
每个 ViewModel 里都有这个判断逻辑:
store.focusLogs.isEmpty ? StatsService.createDemoFocusLogs() : store.focusLogs
写到第四个 ViewModel 的时候我觉得这行代码重复太多了,考虑过抽成 computed property 放到 AppStore 上。但后来想了想,每个页面用 demo 数据的场景可能不一样(比如有的页面可能只需要最近 3 天的 demo),就先留着了。
对了,Demo 数据的存在还有一个好处:App Store 审核的时候,审核员打开就能看到数据长什么样,不用真的专注 25 分钟。之前有一个版本因为审核员觉得「功能不完整」被拒了,加了 demo 数据之后再也没遇到过。
目前的状态和一些反思
说实话,这个 App 目前下载量很小。作为一个新上线不久的独立项目,我对这个结果有预期。专注工具这个品类太卷了,Forest、潮汐这些产品已经把用户心智占得差不多了。
我现在觉得「声景 + 旅行叙事」这个差异点,对已经在用其他专注工具的人来说,切换成本太高;对没有专注习惯的人来说,又不够刚需。这是一个蛮尴尬的位置。
接下来我想试的方向是把分享卡片做得更有传播力。目前卡片的设计偏数据展示,我在考虑加一些更有情绪感的元素——比如把「连续 7 天」变成「7 天环球旅行」,配上对应城市的插画。
如果你也在做独立 App,或者对专注类产品的设计有什么想法,评论区聊聊。