独立开发专注 App:SwiftUI + ActivityKit + ImageRenderer 实战,把计时数据变成飞行护照

4 阅读5分钟

新用户打开统计页就走了

上线第一版的时候,统计页面对新用户来说是一片空白。没有任何数据,没有图表,就一个"暂无记录"的提示。我埋点看了下,这个页面的跳出率大概在 75% 左右——用户点进来一看什么都没有,直接退出了。

后来我加了一组 demo 数据做兜底,让新用户打开就能看到"这个 App 完整跑起来是什么样的"。改完之后统计页的跳出率降到 35% 左右,次日留存也从不到 20% 爬到了 30% 出头。数字不算漂亮,但对于一个没有任何推广的独立 App 来说,靠一个空状态优化拿到这个提升,挺值的。

这是我做声境护照(一个专注计时 App)学到的第一课:空状态是杀死新用户好奇心最快的方式。

这东西到底是什么

一句话说:番茄钟 + 飞行护照的外壳。每次专注是一段旅程,累计时长换算成飞行里程,连续打卡解锁等级称号,结束后生成一张可分享的战报卡片。

做这个的原因很直接——市面上的专注工具太"工具"了。计时结束弹个完成提示,然后没了。我想让"坚持"这件事有被看见的感觉,而不是只有一个冷冰冰的数字。

架构上的选择

SwiftUI 写的,状态管理没上 TCA,用了全局 AppStore 配合各模块独立的 ViewModel 结构体。统计模块的 ViewModel 长这样:

struct StatsSheetViewModel {
    let store: AppStore
    let rangeKey: StatsRangeKey

    private var logs: [FocusLog] {
        // 无记录时 fallback 到 demo 数据,解决空状态问题
        store.focusLogs.isEmpty ? StatsService.createDemoFocusLogs() : store.focusLogs
    }

    var stats: StatsData {
        StatsService.buildStatsData(
            focusLogs: logs,
            streakDays: store.streakDays,
            rangeKey: rangeKey,
            now: Date(),
            isDemo: store.focusLogs.isEmpty
        )
    }

    var insights: InsightBundle {
        InsightService.buildInsights(stats: stats)
    }
}

isDemo 这个标记很关键,统计页会根据它决定是否显示"这是示例数据"的提示条。不加这个用户可能以为系统帮他造了假数据,反而觉得奇怪。

远征系统:游戏化的数据模型

1.4 版本加了 Expedition 系统——把任务拆成章节,每个章节绑定一个声景城市,完成后拿里程奖励。数据结构是这样设计的:

enum ExpeditionMissionKind: String, Codable {
    case sessionCount      // 完成 N 次专注
    case focusMinutes      // 累计专注 N 分钟
    case deepFocusCount    // 深度专注(≥25min 不中断)N 次
}

struct ExpeditionChapterDefinition: Identifiable, Codable, Equatable {
    let id: String
    let sceneId: String       // 绑定的声景(比如"东京雨夜")
    let cityName: String
    let missions: [ExpeditionMissionDefinition]
}

struct ExpeditionChapterState: Identifiable, Codable, Equatable {
    let id: String
    var unlocked: Bool        // 上一章完成后解锁
    var completedAt: Date?
    var missionStates: [ExpeditionMissionState]
}

状态流转很简单:章节默认 locked → 前置章节 completedAt != nil 时 unlock → 章节内所有 mission 的 progress >= targetValue 时标记 complete → 触发下一章解锁。没做复杂的状态机,纯计算属性判断就够了。

纠结过要不要加排行榜、好友对战,最后砍掉了。原因是我观察到专注类 App 的用户不想社交。他们要的是"自己跟自己玩"的成就感。加排行榜反而制造焦虑,和 App 本身"帮你安静下来"的调性冲突。

灵动岛踩的坑:iOS 16 和 17 的差异

用 ActivityKit 做了专注中的实时活动。坑在暂停再恢复的时候。

iOS 16 上,你更新 ContentState 里的 endDate 后,系统的倒计时 Text(.timerInterval:) 会从新值开始重新倒数,但中间会有大概 0.3-0.5 秒的"闪跳"——先显示旧值再跳到新值。

iOS 17 修了这个闪跳问题,但引入了另一个行为:如果你在暂停期间频繁 update activity(比如每秒更新一次暂停时长),系统会静默忽略部分更新,大约 2-3 次 update 只有 1 次真正生效。

我的 workaround 是放弃用 endDate 驱动倒计时展示,改成在 ContentState 里加了 remainingSecondsisPaused 两个字段。暂停时只更新 isPaused = true 和冻结住的 remainingSeconds,不再更新 endDate。恢复时重新计算 endDate = Date().addingTimeInterval(remainingSeconds),一次性 update。这样两个系统版本上行为都稳定了。

分享卡片:试了三个方案

  1. 截图 —— 不同机型分辨率不一致,出来的图有的模糊有的被裁
  2. 后端渲染 —— 一个人维护渲染服务太重了
  3. SwiftUI + ImageRenderer —— 最后用了这个,体验还行

有个低级错误:第一版分享卡片上的时间,我直接用了 Date.description,结果分享到朋友圈显示的是 UTC 时间。被用户截图问"这时间是 bug 吗"。后来老老实实写了 ShareCardFormatter 统一格式化,所有对外展示的日期都走同一个出口。

会话结束后推「下一步建议」

专注结束后不只说"恭喜完成",还会根据当天剩余计划量和连续天数,推荐下一次该什么时候开始、用多长时间。我自己用下来,看到"今天还差 2 段"这种提示,确实比单纯"完成了!"更容易让我接着再来一段。

这个功能写起来不复杂,但对同一用户当天的二次打开率影响挺明显——加了这个推荐之后,日均每用户专注段数从 1.2 涨到 1.8 左右。

游戏化到底该做多重?

我目前的经验是:游戏化元素的开发时间不超过总功能的 25%,用户交互路径里游戏化入口不占主屏超过 1/3。

说白了,用户打开 App 第一眼看到的必须是"开始专注"这个核心动作,而不是"你的里程数"或者"今日任务"。远征系统、成就徽章这些东西放在二级页面,用户想看的时候能找到,但不会在主流程里造成干扰。

做专注工具这个品类竞争很激烈,Forest、潮汐都是成熟产品。我选的切入点是"让数据本身变成可分享的内容"。但这条线到底能走多远,说实话我还没有答案。如果你也在做工具类 App 的游戏化,你们是怎么划这条线的?你们的用户对游戏化元素是更积极还是更无感?