起因

去年有段时间压力很大,朋友推荐我试 4-7-8 呼吸法,试了几次确实有用。市面上找了一圈呼吸类App,没找到满意的,就自己动手做了一个——「呼吸视界」,支持多种呼吸模式和结构化训练计划,目前 iOS 端迭代到 1.9,后来又移植了鸿蒙版。

这篇主要聊技术实现和踩坑,产品功能不多说了。

iOS 端:呼吸动画为什么选 CADisplayLink

呼吸引导的核心就是一个随节奏缩放的圆圈。听起来简单,但有个关键约束:吸气、屏息、呼气的时长是用户可配的(比如吸气 4 秒或 6 秒),动画参数是动态的。

我试过三个方案:

  • UIView.animate — completion 回调嵌套三四层之后代码没法看,而且中途切换模式时取消动画的时机很难控制
    • Lottie — 改动画时长要重新算 speed,多个阶段衔接起来很别扭
    • CADisplayLink — 最后选了这个,自己算进度,灵活度最高
      核心实现大概长这样:
private var animationToken: UUID = UUID()

func startPhaseAnimation(phase: BreathPhase) {
    let token = UUID()
        self.animationToken = token
            let startTime = CACurrentMediaTime()
                let duration = phase.duration
    displayLink = CADisplayLink(target: self, selector: #selector(tick))
        displayLink?.add(to: .main, forMode: .common)
    onTick = {
            guard self.animationToken == token else {
                        self.displayLink?.invalidate()
                                    return
                                            }
                                                    let elapsed = CACurrentMediaTime() - startTime
                                                            let progress = min(elapsed / duration, 1.0)
                                                                    self.circleScale = phase.startScale + (phase.targetScale - phase.startScale) * easeInOut(progress)
                                                                            if progress >= 1.0 {
                                                                                        self.displayLink?.invalidate()
                                                                                                    self.advanceToNextPhase()
                                                                                                            }
                                                                                                                }
                                                                                                                }
                                                                                                                ```
这里的 `animationToken` 是个 UUID,每次开始新阶段就换一个。这样快速切换呼吸模式的时候,旧的 tick 回调检测到 token 不匹配就直接 invalidate,不会出现两个阶段的动画叠在一起。这个小技巧后来在鸿蒙端也用上了。

## 移植鸿蒙:ArkUI 的 animateTo 有个坑

今年初试着把 App 移植到 HarmonyOS NEXTArkTS 的声明式语法跟 SwiftUI 思路很像,上手不难。呼吸动画这块,鸿蒙提供了 `animateTo`,一开始写起来很舒服:

```arkts
animateTo({
  duration: this.currentPhase.durationMs,
    curve: Curve.EaseInOut,
      onFinish: () => {
          if (this.phaseToken !== expectedToken) return
              this.advanceToNextPhase()
                }
                }, () => {
                  this.circleScale = this.currentPhase.targetScale
                    this.circleOpacity = this.currentPhase.targetOpacity
                    })
                    ```
但跑起来就出问题了——快速切换呼吸模式时,上一个 `animateTo` 的 `onFinish` 照样会触发。跟 iOS 端遇到的一模一样的问题。解决方式也一样,加了个 phaseToken(上面代码已经加了那行 guard),onFinish 里先比对 token 再执行后续逻辑。

说实话这个问题我在 HarmonyOS 文档里没找到说明,是自己撞上去才知道的。如果你也在用 `animateTo` 做多阶段动画衔接,记得处理这个边界情况。

## 性能降级:低端机掉帧怎么办

鸿蒙端我封装了一套性能日志,代码里标记为 BF_PERF,在每帧 tick 里记录 fps、frameMs、当前渲染模式(GPU/Canvas)这些指标,写到 hilog 里。

有了数据之后发现问题了:某些低端设备上 GPU 渲染会间歇性掉到 35fps 以下。呼吸引导动画一卡,用户体验直接崩。

我加了个自适应降级逻辑:连续 8 帧 fps 低于 40,就自动从 GPU 渲染切到 Canvas 2D 模式。为什么是 8 帧?60fps 下 8 帧大约 130ms,人眼刚好能感知到卡顿,但还没到"明显不流畅"的程度。设太小容易误触发(偶尔一两帧抖动很正常),设太大用户已经感觉到卡了才降级就晚了。

Canvas 模式视觉效果差一点(少了一些模糊和阴影),但帧率能稳定在 55+ fps。恢复条件是连续 60 帧不丢帧再切回 GPU——60 帧就是稳定跑了整整一秒,说明设备当前负载已经降下来了,可以尝试恢复高质量渲染。如果恢复之后又掉帧,8 帧之内就会再次降级,不会来回抖。

这套降级逻辑配合 hilog 分析脚本,基本能覆盖我手头能测到的设备。

## 双端 i18n 翻车事故

App 支持中英文。我在两端都维护了独立的字符串文件,鸿蒙端是 `strings_app_en.ets` 和 `strings_app_zh-Hans.ets` 这种结构。

有一次加了个新的课程完成提示,英文 key 写了,中文忘了。结果发版之后有用户截图给我看——界面上赫然显示着 `program_complete_toast` 这个 key 名。

说实话有点难受。

后来我写了个检查脚本塞进 CI,原理很朴素——正则提取两个文件的 key 集合做差集:

```python
def load_keys(path: Path) -> Set[str]:
    content = path.read_text(encoding="utf-8")
        return set(re.findall(r'"([^"]+)"\s*:', content))
en_keys = load_keys(Path("strings_app_en.ets"))
zh_keys = load_keys(Path("strings_app_zh-Hans.ets"))
missing_zh = sorted(en_keys - zh_keys)
missing_en = sorted(zh_keys - en_keys)
if missing_zh or missing_en:
    print(f"missing zh: {missing_zh}")
        print(f"missing en: {missing_en}")
            sys.exit(1)
            ```
就这么几行,加了之后再也没翻过车。这种事情靠人肉 review 迟早会漏,自动化才靠谱。

## 一些真实数字

- 2024 年上架 App Store,当前版本 1.9
- - App Store 评分 5.0(样本量小,就几条评价,别当权威数据看)
- - 日活大概在两位数,累计下载 400+
- - 鸿蒙版同步维护
下载量很小。呼吸训练这个品类靠搜索驱动,没什么自然传播。但我自己每天睡前都在用,这个App对我来说是个真实的工具,不是纯粹的练手项目。

## 双端开发的取舍

有人问我为什么不用跨平台方案。我认真考虑过 KMP,但呼吸训练这个场景 UI 层占了 70% 以上的工作量,能共享的业务逻辑很少。与其花时间折腾跨平台桥接,不如各端各写,反正核心逻辑就那么几百行。

还有一个体会:鸿蒙端 API 变化比较快,文档和实际行为偶尔对不上。碰到问题多翻华为开发者论坛,比看文档有用。

---

抛个问题:你们在鸿蒙端做多阶段动画衔接的时候用的什么方案?我这个 token 比对的方式能用,但总觉得有点土。有没有比手动管 token 更干净的取消机制?评论区聊聊。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐