上架两周,下载量接近于零。
说实话有点难受,但我没后悔做这个东西。趁着还没人用,把做这款 App 的思路和一些技术取舍写下来,也算给自己一个交代。
为什么又做一个「番茄钟」
市面上专注计时 App 已经多到数不清了。我自己试过十几款,用下来的感受是:大多数要么是纯工具(极简到无聊),要么是功能堆砌(设置项多到焦虑)。
真正让我决定自己做的是这个观察:专注这件事天然有「仪式感」的需求,但几乎没有 App 认真设计这一块。你打开 App,点开始,盯着倒计时,然后……就结束了。整个过程像在完成一项行政任务。
我想做的是——让每次专注都变成一段有点意思的旅程,结束时有东西可以回看,有东西值得记录。
于是就有了「声境护照」这个名字和叙事框架:把专注时长换算成飞行里程,把历次专注记录变成护照上的盖章,配合不同城市的环境声景(东京雨夜、纽约地铁、北欧森林……),让「今天专注了 3 小时」这件事有点旅行感。
成长系统怎么设计的
游戏化这条路挺难走的。做轻了没感觉,做重了像强迫症打卡软件。
我最终保留了三层:里程积累 → 等级称号 → 探险章节解锁。每次专注结束,App 会生成一份「战报」,里面有当次时长、效率指数、获得的里程值,以及对连续专注天数的提示。
战报页里有一段「下一次建议」的逻辑,是我觉得比较有意思的设计。它不是简单提示「休息一下」,而是结合当天的计划完成情况、连续天数、历史最佳专注时段来给出推荐。后续逻辑就是把 baseDetail 和 streakHint 拼接后装进 SessionNextActionAdvice 返回:
func nextActionAdvice(taskCompleted: Bool) -> SessionNextActionAdvice {
let nextTime = FocusStartupUseCase.nextFocusTimeText(logs: store.focusLogs)
let remaining = max(0, store.weeklyPlanTodayTargetSegments
- store.weeklyPlanTodayActualSegments)
let baseDetail: String
if remaining > 0 {
baseDetail = "今天还差 \(remaining) 段达成日计划,建议按推荐模式补齐。"
} else if taskCompleted {
baseDetail = "当前航标已完成,建议切换到下一航标并保持节奏。"
} else {
baseDetail = "当前航标已推进,下一段建议按同样时长继续完成。"
}
let streakHint = store.streakDays >= 5
? "你已连续 \(store.streakDays) 天,重点是稳定复用。"
: store.streakDays >= 2 ? "再坚持 1-2 天可进入稳定习惯区。"
: "建议先连续 3 天完成每日最小闭环。"
return SessionNextActionAdvice(
title: "下一次建议",
detail: "\(baseDetail) \(streakHint)",
recommendedTimeText: nextTime,
recommendedMinutesText: "\(store.startupRecommendation.minutes) 分钟",
recommendedSceneText: store.startupRecommendation.title,
recommendedTaskText: store.startupRecommendation.suggestedTaskTitle
)
}
连续 7 天之后看到「你已连续 7 天,重点是稳定复用」,比看到一个数字「7」要有意思一点。
探险任务系统的数据结构
「护照 + 里程」这套叙事框架,底层是一套探险章节系统。每个章节对应一座城市和声景,包含若干任务(比如「完成 5 次专注」「累计深度专注 120 分钟」),全部完成后解锁下一个城市。
数据结构上我做了定义和状态的分离,ExpeditionChapterDefinition 存静态配置,ExpeditionChapterState 存用户进度:
struct ExpeditionChapterDefinition: Identifiable, Codable, Equatable {
let id: String
let sceneId: String
let cityName: String
let tagline: String
let bonusBounces: Int
let missions: [ExpeditionMissionDefinition]
}
struct ExpeditionChapterState: Identifiable, Codable, Equatable {
let id: String
var unlocked: Bool
var completedAt: Date?
var missionStates: [ExpeditionMissionState]
}
这样做的好处是,后续要更新章节内容(加城市、改任务难度)不需要动用户数据,只改定义层就行。早期我试过把两者合并成一个结构,结果版本更新时迁移逻辑写得很痛苦,后来就拆开了。
分享卡片这件事
这是我花时间比较多的功能,也是我觉得目前做得还不够好的地方。
App 能生成三种分享卡片:单次战报、周回顾、成就徽章。设计的出发点是想解决一个真实问题:Keep 的卡片视觉化的是运动数据(卡路里、心率),Strava 视觉化的是路线地图,但专注这件事两样都没有——没有轨迹,没有生理数据,凭什么让人觉得「值得晒」?我用城市名 + 里程数来替代,「今天在东京雨夜飞行了 240 里程」,让数据有了一点地理感和叙事感,这才是可以分享的载体。
目前卡片的渲染是纯 SwiftUI 截图,日期格式化用了一个专门的 ShareCardFormatter,session 卡和 badge 卡的日期格式不一样(一个带时分,一个只到日期),这个细节调了好几遍才觉得对。
有点可惜的是,卡片样式目前只有一套,不同声景的配色没有做联动。这是下个版本想补的。
统计模块的一个取舍
做统计的时候遇到一个问题:新用户没有数据,打开统计页面一片空白,体验很差。
最终的处理方式是在 ViewModel 层判断:如果 focusLogs 为空,就用 StatsService.createDemoFocusLogs() 生成演示数据填充,并在 UI 上打一个「演示数据」的角标。
中间试过两个方案,都放弃了。一是做引导页教学,做到第 3 页的时候自己都觉得不对——测试了几次,根本没有人会把引导页看完再去点功能,基本在第 2 页就划走了,等于白做。二是本地预置几条假数据、用户真实专注后再删掉,但删的时机很难定:是第一次专注完成后删?还是累计达到 3 次后删?每个节点都会引发数据跳变,统计图表会出现一个明显的「断崖」,反而更奇怪。
反正最后还是选了最直接的 demo 数据方案:
private var logs: [FocusLog] {
store.focusLogs.isEmpty
? StatsService.createDemoFocusLogs()
: store.focusLogs
}
用户第一次打开看到有数据的统计页,比看到空页面更能理解这个功能是干什么的。
当前状态
1.3 版本,刚上架。下载量目前几乎可以忽略不计,推广还没怎么开始做。
大多数专注工具没有认真对待「记录感」和「仪式感」,我想试试这条路能走多远。探险章节目前只做了几个城市,声景库和章节解锁后续想做得更丰富一些。成长系统还比较薄,这是我自己最清楚的短板。
如果你也在做类似方向的东西,或者对游戏化专注工具有什么看法,评论区聊聊。