我把番茄钟做成了「护照 + 飞行里程」——一个独立开发者的专注App设计复盘

4 阅读5分钟

上架两周,下载量接近于零。

说实话有点难受,但我没后悔做这个东西。趁着还没人用,把做这款 App 的思路和一些技术取舍写下来,也算给自己一个交代。


为什么又做一个「番茄钟」

市面上专注计时 App 已经多到数不清了。我自己试过十几款,用下来的感受是:大多数要么是纯工具(极简到无聊),要么是功能堆砌(设置项多到焦虑)。

真正让我决定自己做的是这个观察:专注这件事天然有「仪式感」的需求,但几乎没有 App 认真设计这一块。你打开 App,点开始,盯着倒计时,然后……就结束了。整个过程像在完成一项行政任务。

我想做的是——让每次专注都变成一段有点意思的旅程,结束时有东西可以回看,有东西值得记录。

于是就有了「声境护照」这个名字和叙事框架:把专注时长换算成飞行里程,把历次专注记录变成护照上的盖章,配合不同城市的环境声景(东京雨夜、纽约地铁、北欧森林……),让「今天专注了 3 小时」这件事有点旅行感。


成长系统怎么设计的

游戏化这条路挺难走的。做轻了没感觉,做重了像强迫症打卡软件。

我最终保留了三层:里程积累 → 等级称号 → 探险章节解锁。每次专注结束,App 会生成一份「战报」,里面有当次时长、效率指数、获得的里程值,以及对连续专注天数的提示。

战报页里有一段「下一次建议」的逻辑,是我觉得比较有意思的设计。它不是简单提示「休息一下」,而是结合当天的计划完成情况、连续天数、历史最佳专注时段来给出推荐。后续逻辑就是把 baseDetailstreakHint 拼接后装进 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 版本,刚上架。下载量目前几乎可以忽略不计,推广还没怎么开始做。

大多数专注工具没有认真对待「记录感」和「仪式感」,我想试试这条路能走多远。探险章节目前只做了几个城市,声景库和章节解锁后续想做得更丰富一些。成长系统还比较薄,这是我自己最清楚的短板。

如果你也在做类似方向的东西,或者对游戏化专注工具有什么看法,评论区聊聊。