做了一个把专注计时包装成「飞行护照」的 iOS App,聊聊里面的设计取舍

5 阅读6分钟

这个东西是什么

我做了一个专注计时工具,叫「声境护照」。说白了就是番茄钟,但我给它套了一层旅行叙事——每次专注是一段「飞行」,累计时长换算成里程,连续打卡天数决定你的旅行者等级。结束后会生成一张战报卡片,长得像登机牌。

想法的来源很简单:我自己用过不下十个专注 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,灵动岛上能看到剩余时间和进度。

我用 ActivityKitActivityAttributes 定义了场景名称、目标分钟数、主题色,ContentState 里放剩余秒数和进度百分比。暂停状态也做了区分,灵动岛上会显示不同的视觉样式。

这部分如果你也在做类似功能,有个小建议:progressDouble 传过去,别在 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,或者对专注类产品的设计有什么想法,评论区聊聊。