ArkTS 动画控制实战:把 4-7-8 呼吸训练从 UI 中拆出去
1. 为什么要拆动画控制器
呼吸训练看起来只是几个文字变化:吸气、屏息、呼气。但如果直接在页面里写一堆setTimeout(),后期会遇到问题:
- 页面切换时定时器不容易清理。
- 多次启动会产生重复循环。
- UI 逻辑和节奏逻辑混在一起。
- 想复用到全屏呼吸页时很麻烦。
“律愈”把呼吸节奏封装为BreathGuideAnimator,页面只负责展示当前帧。
2. 帧模型
呼吸训练每个阶段可以抽象成一帧:
exportinterfaceBreathGuideFrame{label:string;hint:string;}exporttypeBreathGuideListener=(frame:BreathGuideFrame)=>void;label是主文案,例如“吸气”;hint是辅助文案,例如“4 秒 · 鼻腔缓慢充盈”。
3. 控制器结构
exportclassBreathGuideAnimator{privatelistener:BreathGuideListener;privatecycleId:number=0;privatetimerIds:number[]=[];constructor(listener:BreathGuideListener){this.listener=listener;}}这里有两个关键状态:
cycleId:用来识别当前循环,防止旧定时器继续回调。timerIds:保存所有定时器,停止时统一清理。
4. 发出一帧
privateemit(label:string,hint:string):void{this.listener({label,hint});}控制器不关心 UI 是 Text、Dialog 还是全屏遮罩,它只把当前状态通知出去。
5. 安排定时器
privateschedule(id:number,label:string,hint:string,delayMs:number,next:(()=>void)|null=null):void{consttimerId=setTimeout(():void=>{if(id!==this.cycleId){return;}this.emit(label,hint);if(next!==null){next();}},delayMs)asnumber;this.timerIds.push(timerId);}这段代码最重要的是:
if(id!==this.cycleId){return;}当用户切换页面或关闭呼吸训练后,旧定时器即使触发,也不会再更新 UI。
6. 4-7-8 节奏
启动逻辑如下:
start():void{this.stop();this.cycleId+=1;constid=this.cycleId;this.schedule(id,'吸气','4 秒 · 鼻腔缓慢充盈',0,():void=>{this.schedule(id,'屏息','7 秒 · 温和守中',4000,():void=>{this.schedule(id,'呼气','8 秒 · 放松肩背',7000,():void=>{this.start();});});});}流程是:
- 立即进入吸气。
- 4 秒后进入屏息。
- 7 秒后进入呼气。
- 8 秒后重新开始下一轮。
7. 停止逻辑
stop():void{this.cycleId+=1;for(consttimerIdofthis.timerIds){clearTimeout(timerId);}this.timerIds=[];}cycleId += 1和clearTimeout()是双保险:
- 已经注册但未触发的定时器会被清理。
- 即使某个定时器刚好触发,也会因为 id 不一致而退出。
8. 页面如何使用
在Index.ets中,页面保存当前帧:
@StateprivatebreathLabel:string='吸气';@StateprivatebreathHint:string='4-7-8 呼吸 · 跟随圆环';privatebreathAnimator:BreathGuideAnimator|null=null;创建控制器:
privateensureBreathAnimator():BreathGuideAnimator{if(this.breathAnimator===null){this.breathAnimator=newBreathGuideAnimator((frame:BreathGuideFrame):void=>{this.breathLabel=frame.label;this.breathHint=frame.hint;});}returnthis.breathAnimator;}启动和停止:
privatestartBreathGuide():void{this.ensureBreathAnimator().start();}privatestopBreathGuide():void{this.breathAnimator?.stop();}页面消失时释放:
aboutToDisappear():void{this.stopBreathGuide();this.breathAnimator=null;}9. 控制流程图
10. 这个写法的价值
这个控制器看起来很小,但它体现了一个重要思路:节奏逻辑不应该绑死在 UI 组件里。
这样做以后:
- 同一套呼吸节奏可以用于疗愈页和全屏呼吸弹层。
- 页面切换时能可靠停止。
- 想调整为 3-5-7 或其他节奏,只改控制器。
- UI 可以自由变化,不影响计时逻辑。
对于 HarmonyOS 应用来说,把这类“有生命周期、有定时器、有复用需求”的逻辑从页面中抽出去,是提升代码质量的有效办法。
9. 为什么 cycleId 是关键
只用 clearTimeout() 并不能覆盖所有边界情况。如果定时器刚好已经进入回调,清理可能来不及。所以项目用 cycleId 作为逻辑取消标记:
s if (id !== this.cycleId) { return; }
这是一种很常见的异步防抖思路:每一轮任务都有自己的 id,过期任务即使执行,也不能修改当前状态。
10. 呼吸训练和 UI 解耦
控制器只发出 frame:
s this.listener({ label, hint });
页面怎么展示完全由 UI 决定。可以是普通文本:
s Text(this.breathLabel) Text(this.breathHint)
也可以是全屏弹层、Canvas 圆环、卡片动画。这个设计让呼吸训练成为一个可复用服务。
11. 页面启动停止策略
在 Index.ets 中,Tab 切换时判断是否启动呼吸:
s .onChange((idx: number) => { this.selectedTabIndex = idx; if (idx === 1 || this.fullBreathOpen) { this.startBreathGuide(); } else { this.stopBreathGuide(); } })
这段代码很适合讲生命周期:用户在疗愈页或全屏呼吸页时才需要计时器,其他页面不需要。
12. 全屏呼吸层的产品意义
律愈不仅在疗愈页显示呼吸提示,还提供 openFullBreath():
s private openFullBreath(): void { this.fullBreathOpen = true; this.startBreathGuide(); }
这让呼吸训练从辅助信息变成独立功能。文章中可以结合配图说明:同一个控制器可以服务两个 UI 场景。
13. 可扩展节奏配置
如果要支持更多呼吸法,可以把 4-7-8 写成配置:
` s
interface BreathPhase {
label: string;
hint: string;
durationMs: number;
}
const phases: BreathPhase[] = [
{ label: ‘吸气’, hint: ‘鼻腔缓慢充盈’, durationMs: 4000 },
{ label: ‘屏息’, hint: ‘温和守中’, durationMs: 7000 },
{ label: ‘呼气’, hint: ‘放松肩背’, durationMs: 8000 }
];
`
这可以作为文章最后的进阶方向。
7000 },
{ label: ‘呼气’, hint: ‘放松肩背’, durationMs: 8000 }
];
`