起因
去年有段时间焦虑得厉害,开会前心跳加速,睡前脑子停不下来。试了好几个冥想类 App,要么太重(动辄几百 MB),要么上来就让我买年费会员。我就想,呼吸训练这事儿,核心逻辑其实不复杂,为什么不自己做一个?
于是就有了「呼吸视界」。目前 App Store 上 12 个评分,均为 5 星。有用户反馈说"可以跟随练习呼吸,保持稳定的心情",说明方向没偏。
今天主要聊三个我觉得有意思的工程实践:状态机驱动呼吸动画节奏、设计 Token 约束 UI 一致性、以及国际化 key 对齐的自动校验脚本。
状态机驱动呼吸动画
呼吸 App 最核心的体验是节奏感。吸气4秒、屏息7秒、呼气8秒——每个阶段时长不同,视觉反馈要精确跟随。
我试了三种方案走到最终版:
- 第一版用
UIView.animate链式调用,阶段切换时偶尔跳帧,而且嵌套 completion 写到第三层就没法维护了 - 第二版换
CADisplayLink自己驱动帧更新,可控性好了但状态散落在各种变量里,加个"暂停"功能就得到处打补丁 - 最后用状态机的思路重写,把呼吸周期拆成 phase 枚举,timer 只管读当前 phase 的 duration 然后倒计时,切换逻辑集中在一个地方
核心调度逻辑大概是这样:
enum BreathPhase: CaseIterable {
case inhale, hold, exhale, rest
func duration(for pattern: BreathPattern) -> TimeInterval {
switch self {
case .inhale: return pattern.inhaleDuration
case .hold: return pattern.holdDuration
case .exhale: return pattern.exhaleDuration
case .rest: return pattern.restDuration
}
}
}
func advancePhase() {
let allPhases = BreathPhase.allCases
currentPhaseIndex = (currentPhaseIndex + 1) % allPhases.count
let phase = allPhases[currentPhaseIndex]
let duration = phase.duration(for: currentPattern)
startAnimation(for: phase, duration: duration)
scheduleTimer(after: duration) { [weak self] in
self?.advancePhase()
}
}
advancePhase() 每次被调用时做两件事:启动当前阶段对应的视觉动画,同时设一个 timer 等 duration 到了自动跳下一阶段。状态机是个环——rest 之后回到 inhale,一直循环到用户主动停止。
这样做的好处是加新呼吸模式只需要配一组 BreathPattern(定义各阶段时长),不用碰动画和调度逻辑。4-7-8、盒式呼吸、均匀呼吸,全是数据配置的差异。
结构化课程的进度系统
市面上大多数呼吸 App 都是"选个模式,开始,结束"。我觉得不够——呼吸训练跟健身一样需要循序渐进。一个新手直接上 4-7-8 会很难受。
所以我做了课程进度系统,用户跟随一个多天计划,从简单节奏逐步提升。数据模型长这样:
struct ProgramProgressRecord {
let programId: String
let totalDays: Int
var currentDay: Int
var completedSessions: [SessionRecord]
let startDate: Date
var lastPracticeDate: Date?
var completionRate: Double {
guard totalDays > 0 else { return 0 }
return Double(currentDay) / Double(totalDays)
}
}
数据全部存在本地——呼吸训练数据算健康数据,我不想碰隐私这根线,也养不起一个后端。
设计系统:一个人也要有约束
独立开发很容易 UI 写着写着就散了——这边圆角12,那边圆角16,间距全凭感觉。
我在早期就定了一套 Design Token,改一个值全局生效。没有设计师帮 review 的时候,靠系统约束自己比靠自觉靠谱得多。后面加新页面速度明显快了,视觉一致性也好。
我还写了个构建检查脚本,确保核心组件都引用了统一的 Token 定义(比如 SPACE、RADIUS、SHADOW、MOTION),哪个文件漏了直接报错。这种小事人眼 review 容易漏,交给 CI 最省心。
国际化 key 对齐校验
做了中英文双语之后,最容易出的 bug 不是翻译质量,而是漏 key。英文加了个新文案,中文忘了补,上线之后某个场景直接显示 key 名。
我写了个构建前脚本来做自动校验,核心就是正则提取 key + 集合差集:
def load_keys(path: Path) -> Set[str]:
content = path.read_text(encoding="utf-8", errors="ignore")
return set(re.findall(r'"([^"]+)"\s*:', content))
en_keys = load_keys(strings_en_path)
zh_keys = load_keys(strings_zh_path)
missing_in_en = sorted(zh_keys - en_keys)
missing_in_zh = sorted(en_keys - zh_keys)
if missing_in_en or missing_in_zh:
print(f"missing in en: {missing_in_en}")
print(f"missing in zh: {missing_in_zh}")
sys.exit(1) # 阻断构建
原理很朴素:用正则把两个语言文件里形如 "key": 的 key 提出来,分别装进 set,做差集。有差异就非零退出,CI 不给过。这种"笨办法"反而最管用,上线后再没出过漏翻译的问题。
现状和一个数据结构问题
坦白说这个 App 目前下载量很低。呼吸训练品类竞争激烈,独立开发者想突围确实难。
技术上我比较满意的是状态机那套方案,扩展性确实好。但产品层面还在摸索——课程进度系统目前用的是扁平的 completedSessions 数组序列化成 JSON 文件存本地。天数多了之后每次读写要反序列化整个数组,不太优雅。
我目前在纠结几个方案:
- Core Data:成熟稳定,但对这么轻量的数据模型来说有点重了,而且 Core Data 的模板代码写起来说实话有点烦
- SwiftData:API 用起来舒服得多,声明式写法跟 SwiftUI 搭配很自然。但最低要求 iOS 17,我现在支持到 iOS 16,砍掉这部分用户心里没底
- 继续 JSON 文件:加个缓存层,只在有变更时写磁盘。说白了就是"够用就行"
我现在倾向 SwiftData,等明年 iOS 16 占比再低一些就切。想问问各位做工具类 App 的,你们的"多天计划/课程进度"数据层选的什么方案?有没有用 SwiftData 踩过坑的?