起因
去年底我想解决一个自己的问题:番茄钟用了很多,但每次专注结束后那个"叮"一响就完了,没有任何值得回味的东西。25 分钟的专注和 5 分钟的专注,在记录层面看起来没什么区别——都是日历上一个小点。
我想让每次专注变成一段可以「收藏」的经历,于是开始做一个把专注数据包装成飞行里程 + 护照盖章的 App,最后做出来的东西叫「声境护照」。核心体验是:你每次专注就像完成了一段旅程,App 会给你生成一张战报卡片,告诉你飞了多远、效率如何、经验值涨了多少。
架构上的几个选择
整个 App 用 SwiftUI 写的,状态管理走的是一个全局 AppStore,各个 ViewModel 都是轻量 struct,直接从 store 取数据做计算。
我一开始纠结过要不要用 TCA 或者 MVVM-C,后来想了想,这个 App 的页面层级不深,用一个中心化的 store 配合独立的 ViewModel struct 反而更清晰。每个 sheet 对应一个 ViewModel,职责很明确。
比如专注结束后的战报页,除了展示成长数据,还会根据用户状态推荐下一次专注的时间和声景:
func nextActionAdvice(taskCompleted: Bool) -> SessionNextActionAdvice {
let nextTime = FocusStartupUseCase.nextFocusTimeText(logs: store.focusLogs)
let remainingTodayPlan = max(0, store.weeklyPlanTodayTargetSegments - store.weeklyPlanTodayActualSegments)
// 推荐策略:
// 今日剩余段数 > 0 → 建议按推荐模式补齐
// 连续天数 >= 5 → 提示"重点是稳定复用",不加压
// 连续天数 < 3 → 鼓励先完成3天最小闭环
// 当前任务已完成 → 建议切换下一航标
let streakHint: String
if store.streakDays >= 5 {
streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
} else if store.streakDays >= 2 {
streakHint = "再坚持 1-2 天可进入稳定习惯区。"
} else {
streakHint = "建议先连续 3 天完成每日最小闭环。"
}
// ...组装返回
}
这个推荐逻辑不复杂,但实际效果还行——连续天数高的用户不会被催促,刚开始用的人会收到温和的鼓励。比"恭喜完成!明天继续加油!"这种万年不变的文案有用得多。
远征系统的设计
我在 App 里做了一个叫 Expedition(远征)的模块。每个章节对应一个城市声景,里面有几个任务(完成 N 次专注、累计 N 分钟深度专注等),全部完成解锁下一章节,同时获得里程奖励。
数据结构长这样:
struct ExpeditionChapterDefinition: Identifiable, Codable, Equatable {
let id: String
let sceneId: String
let cityName: String
let tagline: String
let bonusBounces: Int
let missions: [ExpeditionMissionDefinition]
}
enum ExpeditionMissionKind: String, Codable {
case sessionCount // 完成N次专注
case focusMinutes // 累计N分钟
case deepFocusCount // N次深度专注(不中断)
}
任务类型只有三种。我试过加更多类型比如"连续天数"、"特定时段完成"、"使用指定声景",但内测的时候让 3 个朋友试用,他们的反馈出奇一致:打开远征页面不知道自己该干嘛,任务描述看了两遍才懂。砍到三种之后,每次打开一眼就能看懂进度条走到哪了。
这里有个 trade-off 我现在还在犹豫:任务种类少意味着后期章节设计会同质化。目前 6 个章节基本就是数值递增(从"完成 3 次专注"到"完成 15 次专注"),玩法层面没什么新鲜感。但如果加新种类又回到"看不懂"的问题。我暂时的做法是把变化放在叙事层面——每个章节的城市故事不同,任务结构保持一致。
战报卡片渲染踩坑
这事儿踩了不少坑。
最初我用 SwiftUI 的 ImageRenderer 把视图直接渲染成图片。在 iPhone 14 Pro 上效果还行,但在 iPhone SE 2 + iOS 16.1 上小号字体明显模糊,像是被降了一档分辨率。排查了一圈发现是 ImageRenderer 的 scale 属性在某些设备上没有正确取到屏幕 scale,手动设成 UIScreen.main.scale 之后好了一些,但在 iOS 16.0 上还是偶尔出问题。
最后改成了用 UIGraphicsImageRenderer 配合 UIHostingController 的方案——把 SwiftUI 视图塞进一个 hosting controller,layout 之后直接截图。稳定性好了很多,代价是代码丑了不少。
一个对新用户很重要的小设计
当 store.focusLogs 为空时(用户刚装完 App 还没有任何专注记录),各个 ViewModel 会自动切换到 StatsService.createDemoFocusLogs() 生成的示例数据。
这样用户第一次打开统计页、周报页,看到的不是一片空白,而是有数据的示例状态,能立刻理解这个页面是干嘛的。等用户有了真实数据,demo 自动消失。
这个决策有争议——有人会觉得"假数据"误导用户。我的判断是:工具类 App 如果新用户打开看到空白页,流失率会非常高。放一个明确标注为"示例"的演示状态,比空白好。
灵动岛和技术 trade-off
我用 ActivityKit 做了专注进行中的灵动岛展示,ContentState 实时更新剩余秒数和进度百分比。
灵动岛这个功能开发成本比我预期高。主要痛点不是 API 本身,而是调试——模拟器上的灵动岛展示和真机差异很大,锁屏状态下的布局还要单独写一套。我花了大概 4 天在这上面。
做完之后我反思过值不值:灵动岛的实际使用率很难统计(没有好的埋点方式),而且只有 iPhone 14 Pro 及以上机型支持。如果重新来一次,我可能会把这 4 天投入在完善分享卡片的模板数量上,对拉新的帮助可能更直接。
经验值曲线的纠结
成长系统里有个经验值和等级的设计。我一开始用的是指数曲线——每升一级需要的经验是前一级的 1.5 倍。好处是前期升级飞快,用户前 3 天就能到 3-4 级,正反馈很强。
坏处是后期膨胀太快。到 15 级之后,一级需要的经验值大概相当于前两周的总量。我自己测试到这个阶段就明显感觉"反正升不了级了",动力骤降。
现在改成了分段线性:1-10 级每级 +200 经验,10-20 级每级 +400 经验,20 级以上每级 +600。没有指数曲线那种前期爽感,但至少不会出现"永远升不了级"的绝望区间。
说实话我对这个方案也不太满意。想问问同样在做游戏化工具的同行:你们的经验值曲线是怎么设计的?有没有什么既能保证前期正反馈又不至于后期崩盘的思路?