把专注计时做成飞行护照:聊聊 iOS 游戏化成长系统的实现

4 阅读6分钟

起因

去年底我想解决一个自己的问题:番茄钟用了很多,但每次专注结束后那个"叮"一响就完了,没有任何值得回味的东西。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 上小号字体明显模糊,像是被降了一档分辨率。排查了一圈发现是 ImageRendererscale 属性在某些设备上没有正确取到屏幕 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。没有指数曲线那种前期爽感,但至少不会出现"永远升不了级"的绝望区间。

说实话我对这个方案也不太满意。想问问同样在做游戏化工具的同行:你们的经验值曲线是怎么设计的?有没有什么既能保证前期正反馈又不至于后期崩盘的思路?