起因
去年有段时间工作压力特别大,开会前手心出汗,晚上躺床上脑子停不下来。试了几个呼吸训练的 App,要么就是一个动画让你跟着吸气呼气,没什么体系;要么就是订阅制,一年两三百,说实话有点离谱。
我就想,这东西逻辑不复杂,自己做一个得了。
于是就有了「呼吸视界」——一个带结构化课程系统的呼吸训练 App。上架后陆续收到一些正向反馈,有用户说"可以跟随练习呼吸,保持稳定的心情",算是验证了方向没错。
我想做的不只是一个计时器
市面上大部分呼吸 App 的核心交互就是:选一个模式 → 跟着圆圈吸气呼气 → 结束。
我觉得这不够。呼吸训练跟健身一样,单次做没什么用,得坚持,得有计划。所以我从一开始就决定做两件事:
- 内置多种科学呼吸法(4-7-8、盒式呼吸等),每种有不同的节奏参数
- 做一套课程进度系统,让用户能跟着一个计划走,而不是每次打开都在"自由练习"
第二点是我花时间最多的地方。
课程进度系统的设计
说白了就是两层结构:课程定义是静态的,进度记录是动态的。
课程定义这边,我用一个树状结构来描述:
struct ProgramDefinition: Codable {
let id: String // 比如 "beginner_calm"
let title: String
let nodes: [ProgramNode]
}
struct ProgramNode: Codable {
let nodeId: String // 比如 "day1_box"
let dayIndex: Int
let techniqueId: String // 关联到具体呼吸法
let targetCycles: Int // 这节课要完成几轮
}
进度记录这边,每次练习结束写一条 Session:
struct BreathingSession: Codable {
let id: UUID
let techniqueId: String
let startTime: Date
let duration: TimeInterval
let completedCycles: Int
let programNodeId: String? // 非空就说明是课程练习
}
课程进度的计算逻辑就是:拿到某个 Program 的所有 ProgramNode,去 Session 列表里按 programNodeId 匹配,看哪些 node 被完成了。两边通过 nodeId 关联,课程内容更新了,已完成的记录不受影响。
这个方案是重构过一次才定下来的。最早我想用一个扁平数组存所有训练记录,然后根据记录反推进度。后来发现课程结构一改,历史数据就对不上了——比如我把某个课程从 7 天改成 10 天,旧的进度百分比直接算错。分开存之后这个问题就没了。
本地持久化踩的坑
所有训练数据都存在本地,没接任何后端。原因很简单:呼吸训练数据属于健康隐私,我不想碰用户的这些信息,也不想维护服务器。
存储方案我试了三种,真正踩了坑的是 Core Data。
最开始用 UserDefaults,数据量一大就不合适了。然后切到 Core Data,写好了 Model,也做了 Migration。问题出在我给 Session 实体加了一个 programNodeId 字段之后——lightweight migration 处理新增字段没问题,但我同时改了一个字段的类型(把 duration 从 Int32 改成 Double),这就触发不了自动迁移了。结果就是老用户更新版本后一打开 App 直接崩溃,NSPersistentStore 抛了个 incompatible model 的异常。
我本地测试没复现是因为每次 clean build 都会删掉旧数据库。这种问题只有真机上带着旧版数据升级才会出。
改肯定能改,写 custom migration mapping model 就行。但我一个人维护,后面还可能继续改 schema,每次都写迁移映射太累了。想了想,这个 App 的数据量级根本不需要 Core Data 这么重的方案。
最后换成了 Codable + FileManager,JSON 文件存在 Documents 目录。实测 2000 条 Session 记录的 JSON 文件大约 200KB,iPhone 12 上全量加载到内存耗时约 3ms,完全够用。schema 变了就在 Codable 的 init(from:) 里做兼容,比 Core Data Migration 简单太多。
关于数据备份,这块我得坦诚说:当前版本没有做 iCloud 同步。JSON 文件在 App 的 Documents 目录里,iCloud 整机备份可以覆盖到,但如果用户换机时没走 iCloud 备份,数据就丢了。我在考虑把存储路径迁移到 iCloud Documents 容器里,但 iCloud 文件同步的冲突处理又是一个坑,还没动手。这算是目前的一个已知缺陷。
呼吸动画的节奏控制
4-7-8 呼吸法的节奏是:吸气 4 秒 → 屏息 7 秒 → 呼气 8 秒。盒式呼吸是 4-4-4-4。这些节奏参数不同,但动画驱动逻辑应该是同一套。
我抽象了一个呼吸阶段枚举和时间配置:
enum BreathPhase: String { case inhale, holdIn, exhale, holdOut }
struct BreathPattern {
let phases: [(phase: BreathPhase, duration: TimeInterval)]
static let boxBreathing = BreathPattern(phases: [
(.inhale, 4), (.holdIn, 4), (.exhale, 4), (.holdOut, 4)
])
static let breathing478 = BreathPattern(phases: [
(.inhale, 4), (.holdIn, 7), (.exhale, 8)
])
}
动画驱动用的是 SwiftUI 的 withAnimation(.easeInOut(duration:)),配合一个 Timer.publish 来推进阶段。每个阶段开始时,根据 duration 触发一次带对应时长的 withAnimation,驱动圆环的缩放和透明度变化。阶段结束时 Timer fire,切到下一个阶段。
这个方案看着简单,但之前走了一个大弯路。
最早我给每种呼吸法写了一套独立的 Timer 逻辑,到第三种的时候代码已经乱得不行了。最致命的 bug 是 App 切到后台再回来,节奏会错乱——Timer 在后台被系统挂起,回到前台后积攒的 fire 事件一股脑涌出来,动画直接跳帧,吸气和呼气的视觉指示跟实际节奏完全对不上。
修复方案是在 scenePhase 变化时记录时间戳,回到前台后算一下过去了多久,跳过已经错过的阶段,从正确的位置重新开始。这个逻辑在每种呼吸法里都要写一遍的话根本维护不了,所以我把所有 Timer 逻辑统一收到一个 BreathingEngine 里,这才稳定下来。
全删重写的那天有点难受,但重写完之后新增呼吸法就是加一个 BreathPattern 静态属性,不用改任何 UI 代码。
国际化这事儿比想象中琐碎
App 做了中英双语。我写了一个脚本专门检查两个语言文件的 key 是否对齐,防止漏翻译。
每次加新文案就跑一遍,缺了哪个 key 直接打印出来。这个脚本大概帮我抓到过七八次遗漏,比人工对照两个文件靠谱多了。
产品调性上的克制
做这个 App 我给自己定了一个原则:不搞花哨的东西。
没有社交功能,没有排行榜,没有成就徽章弹窗。打开就是安静的界面,选一个练习,开始呼吸。练完了能看到自己的历史记录和课程进度,仅此而已。
有朋友看了说"你加个打卡分享到朋友圈的功能啊"。我想了想,没加。这个 App 的场景是用户焦虑、失眠、需要放松的时候打开的,弹一个分享弹窗出来太违和了。
这也意味着自然传播很弱。这是取舍。
回头看
这个项目从立项到上架花了两个多月业余时间。技术上没什么高难度的东西,踩的坑主要集中在"觉得简单就随便写,后来不得不推翻重来"这件事上。Core Data 的 migration 坑让我学到了一个教训:独立开发选技术方案,维护成本比功能丰富度重要得多。
对了,最近在纠结一个事:要不要把 Codable + JSON 文件的方案迁移到 SwiftData?SwiftData 的 API 确实清爽很多,但我担心又踩一遍 schema migration 的坑。你们本地存储选 SwiftData 还是自己封装 Codable?有没有人在生产环境用 SwiftData 跑过 migration 的,体验怎么样?