独立开发呼吸训练 App:状态机驱动动画与设计约束的工程实践

7 阅读5分钟

起因

去年有段时间焦虑得厉害,开会前心跳加速,睡前脑子停不下来。试了好几个冥想类 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 定义(比如 SPACERADIUSSHADOWMOTION),哪个文件漏了直接报错。这种小事人眼 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 踩过坑的?