用户练了 6 分钟被叫走,这次算完成吗?为了这一个问题,我把整个课程进度系统改了三版,存储方案也跟着推翻重来了一次。
为什么要做「课程进度系统」
市面上的呼吸类 App 基本都是「打开 → 选一个练习 → 跟着做 → 关掉」,整个流程就是一次性消费。我自己用过几款,坚持不下去——不是因为呼吸练习没用,而是每次打开都要重新「选题」,完全没有连续感。
4-7-8 呼吸法、盒式呼吸这些技巧,单次做完确实能平静下来,但真正改变焦虑阈值需要几周持续练习。「记录一次练习」和「引导用户完成一套系统训练计划」是两件完全不同的事,后者需要维护一个有状态的进度模型。
于是 呼吸视界 做了一套结构化课程进度系统,这篇文章就聊聊这个系统怎么来的,以及中间踩过的坑。
存储方案选了三次
第一版用 UserDefaults 存个简单的「第几天」计数器——太脆,用户换手机或者清数据就全没了。
第二版改成 iCloud 实时同步——测试时我自己坐飞机,打开 App 转圈 30 秒没加载出来,才意识到这个方案根本不可用。在没网的场景里,呼吸练习恰恰是最刚需的。
第三版落在「本地持久化为主,iCloud 同步做成用户手动触发、默认关闭」。本地随时可读,不依赖网络;想备份的用户可以在设置里主动开启同步。这个组合不算漂亮,但解决了实际问题。
本地存储用 Core Data,核心实体是 ProgramProgressRecord:
// completedNodeIDsData 是手动序列化的 Data 字段
// 存入时:JSONEncoder().encode([String]) -> Data
// 读出时:JSONDecoder().decode([String].self, from: data)
class ProgramProgressRecord: NSManagedObject {
@NSManaged var programID: String
@NSManaged var currentNodeIndex: Int32
@NSManaged var completedNodeIDsData: Data // JSON-encoded [String]
@NSManaged var lastPracticeDate: Date?
@NSManaged var totalSessionCount: Int32
}
completedNodeIDs 用数组而不是 Set,是因为需要保留完成顺序——用户回看历史时,按时间线展示比乱序更有意义。有经验的读者可能会问「为什么不用 to-many relationship」——试过,节点数量少(一般不超过 30),关系表反而增加查询复杂度,直接 JSON 够用。
「算不算完成」这件事
呼吸练习有个特点:中途退出大概率是被打断了(来电话、突然有事),这次练习本身算不算完成?
第一版:只要退出就不算——用户反馈太严苛,练了 6 分钟被叫走,下次打开进度没动,挺打击积极性的。
第二版:只要开始就算——统计数据好看了,但课程进度推进得过快,用户其实没练够就「过关」了,感觉在骗自己。
第三版:完成阈值定在 80%。完成超过 80% 的节点时长,这次会话记入 completedNodeIDs;低于 80% 的,记录会话时长但不推进课程进度。
80% 这个数字说实话是拍的。现在每次看到这个数字都有点心虚,不知道有多少用户是因为它卡在某节课过不去,然后默默流失了,我根本不知道。
呼吸动画帧率的坑
呼吸引导动画需要跟随「吸气 → 屏气 → 呼气」节奏做缩放,4-7-8 就是吸气 4 秒、屏气 7 秒、呼气 8 秒。最初用 Timer 驱动,在老设备上帧率会掉,呼吸圈缩放出现肉眼可见的卡顿。
换成 CADisplayLink 之后,把动画逻辑从「每隔 X 秒触发」改成「每帧根据时间戳计算当前进度」:
@objc func displayLinkTick(_ link: CADisplayLink) {
let elapsed = link.timestamp - phaseStartTimestamp
let progress = min(elapsed / currentPhaseDuration, 1.0)
let scale = interpolateScale(for: currentPhase, progress: progress)
breathCircleView.transform = CGAffineTransform(scaleX: scale, y: scale)
if progress >= 1.0 { advanceToNextPhase() }
}
动画流畅度和设备帧率绑定,ProMotion 屏 120fps,老设备 60fps,不靠 Timer 精度说话。
进度页面的一个小决定
课程进度页面我没有做百分比进度条,只显示「第 X 节 / 共 X 节」加节点列表。
进度条会让人看到「还差这么多」,节点列表让你看到「已经完成了这些」——同样的信息,心理感受不一样。推送提醒也没做,连续天数统计做得很轻,在那里但不主动戳你。
目前 App Store 评分 5 分,有用户说「可以跟随练习呼吸,保持稳定的心情」。下载量还不高,但反馈方向对了。
我现在最想搞清楚的是,这套进度设计是否真的降低了完成焦虑,还是我在自我感觉良好——毕竟 80% 阈值是拍的,进度条也是我「觉得」应该这样,没有任何 A/B 数据支撑。
有没有人在健康类 App 里试过「streak 冻结」机制——允许用户偶尔断签而不清零连续天数?实际效果怎么样,会不会反而让用户觉得「反正可以冻结」就更容易放弃?