最近上线了一个叫「声境护照」的 iOS App,做的事情说起来很简单:番茄钟 + 环境音 + 数据可视化。但我想聊的不是功能本身,而是做这个 App 过程中一些有意思的设计决策------尤其是「把专注数据包装成旅行叙事」这条路到底值不值得走。
从一个具体的厌倦感出发
我用过很多专注类 App,Forest、潮汐、番茄ToDo,都挺好用。但用着用着有个感受:完成了专注,然后呢?数字加一,然后就没了。
说实话,这种「完成即消失」的感觉有点可惜。你花了 25 分钟认真写东西,这件事值得被记住。所以我想做一个让每次专注都留下「印记」的工具。
护照的比喻就是从这里来的------每次专注是一次起飞,声景是目的地,累计时长变成飞行里程,连续打卡天数是你的「航班记录」。
成长系统的数据结构设计
游戏化成长系统是这个 App 最核心的部分,我在 ExpeditionModels.swift 里把整个探险体系建模成章节 + 任务的结构:
swift
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 次
}
每个「城市章节」绑定一个声景 ID,完成章节里的任务才能解锁下一个城市。这样声音选择就不只是 UI 装饰,而是有推进感的目标。
ExpeditionMissionKind 只有三种,我故意控制得很少。试了几个方案,加过「连续打卡天数」「特定时段专注」等类型,最后都删了------任务类型越多,用户反而不知道该干什么。
会话战报的「下一步建议」逻辑
每次专注结束会弹出一张战报,这个战报除了展示当次数据,还会给出下一次专注的建议。这个逻辑在 SessionReportSheetViewModel 里:
swift
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 天完成每日最小闭环。"
}
// ...
}
这里有个取舍:建议文案是写死的字符串模板,不是 AI 生成的。我考虑过接 LLM,但一来成本不好控制,二来我发现这类「行为引导」场景其实不需要千变万化的文案,固定的几条反而更有仪式感,用户知道这是 App 在认真跟踪自己的状态。
连续天数的分层(1天 / 2-4天 / 5天以上)是我根据习惯养成的一般规律拍的,不是什么严格实验得出的结论。连续 3 天是个心理门槛,5 天以上用户大概率已经进入节奏了,策略应该从「建立习惯」切换到「维持节奏」。
分享卡片:让专注数据变成内容
这是我觉得最值得展开讲的部分。
专注类 App 的自然增长渠道几乎只有两条:AppStore 搜索,和用户分享。Forest 靠的是「种了一棵树」的视觉,潮汐靠的是精美的音景截图。我的切入点是「数据卡片」------把当次战报或周回顾渲染成一张可以直接发朋友圈的图片。
ShareCardFormatter 负责格式化卡片里的时间信息,战报卡片、成就徽章卡片、周回顾卡片用的日期格式各不相同(yyyy/MM/dd HH:mm vs yyyy/MM/dd),看起来细节,但如果格式乱掉整张卡片的质感就垮了。
卡片设计我做了三个版本,第一版太「仪表盘」,数字密密麻麻;第二版太「极简」,信息量不够,朋友看不出你做了什么;第三版找到了平衡------突出时长和等级称号,次要展示声景和任务名,底部放一行小字的里程数。
StatsService 和 GrowthService 的分层
统计相关的逻辑我拆成了两个 Service:
StatsService:纯数据聚合,负责按时间范围汇总FocusLog,输出StatsDataGrowthService:负责把FocusLog转换成GrowthProfile,计算等级、经验值、称号
这两个 Service 都是无状态的纯函数风格,输入 logs 数组输出结果,在多个 ViewModel(StatsSheetViewModel、ProfileSheetViewModel、WeekReviewSheetViewModel)里复用。
有一个小设计:当 focusLogs 为空时,会调用 StatsService.createDemoFocusLogs() 生成演示数据。新用户第一次打开统计页不会看到空白界面,而是看到一个「如果你用了两周会是什么样子」的预览。这个 onboarding 细节我觉得挺重要------空页面对新用户很劝退。
现在的状态和一些遗憾
App 刚上线 1.3 版本,下载量还很少,老实说基本还在 0 起步阶段。
有几个功能是做到一半放在 _disabled_features 目录里的------统计报告、周回顾、分享卡片这些模块代码都写完了,但 UI 打磨还不够,我没有在 1.3 开放。这种「功能写完了但藏起来」的状态有点难受,但比发出去然后体验很差要好。
声景库目前内容量不够丰富,「东京雨夜」「咖啡馆白噪音」这类场景音是有的,但城市章节太少,探险系统的推进感不强。这是接下来要重点补的。
还有一个我没想清楚的问题:护照 + 飞行里程这套叙事对喜欢旅行的用户很有共鸣,但对完全不在意这个比喻的用户来说可能显得有点奇怪。这个产品定位的边界到底在哪,我还在摸索。
如果你也在做类似的「工具 + 游戏化」方向的 iOS App,或者对专注类产品有什么看法,欢迎在评论区聊聊------我对「游戏化到底会不会让用户厌倦」这个问题挺好奇的,想听不同角度的判断。