☰
ArkWeb 手记 11:Web 组件与原生动画的协同
2026/10/21 2:29:12 网站建设 项目流程

ArkWeb 手记 11:Web 组件与原生动画的协同

开场:两段动画对不上的现场

一位朋友发来一段录屏,画面是商品详情页。点"加入购物车",底部原生按钮先轻微下沉一下,接着 Web 区域里的卡片弹了一下,卡片右上角还有个 H5 画的对勾在转圈。单看每一段动画都挺顺,连起来就不对味:按钮已经弹回来了,卡片才开始动;换到另一台机器上反着来,卡片动画还没收尾,原生遮罩已经淡出,屏幕上闪一下白。

他问我的原话是:我把时长都改成 300 毫秒了,为什么还是错位?

问题不在时长,在时钟。Web 区域里的动画跑在浏览器内核的时间线上,ArkTS 侧的动画跑在方舟引擎的时间线上,两边各算各的。如果没有人做约定,就算把 duration 写成同一个数字,启动时刻也会差出几十到几百毫秒。人眼对这种错位很敏感,差不多超过一百毫秒就会觉得卡,或者觉得画面脏。

这篇就把协同这件事拆开讲:H5 动画有哪几种触发方式,ArkTS 怎么知道它开始了、结束了,三种同步方案各自适合什么场景,动画开始掉帧的时候又该按什么顺序查。

文中代码基于 HarmonyOS 7 的 ArkWeb 能力,可以直接放进 DevEco 工程里跑。

为什么 Web 和原生之间总差半拍

先把 Web 组件在 ArkWeb 里的定位说清楚。它是一个独立渲染的容器,里面的内容由浏览器内核负责排版和绘制,外面的 ArkTS 组件树由方舟引擎排版和绘制。两层各有各的合成与刷新节奏,中间靠一层桥接通道传数据。

这就带来三个天然的延迟来源。

第一个来源是驱动方式不同。H5 的动画通常由内核的动画调度器驱动,它跟着内核自己的刷新节拍走。ArkTS 的动画由框架统一编排,属性值变化后进入渲染管线。两条管子不共享起始信号,谁先谁后完全看代码的执行顺序。

第二个来源是桥接调用本身有成本。H5 里写一句 window.animBridge.end,这个调用要跨语言边界进到 ArkTS 侧,再回到 ArkTS 主线程上执行回调。单次成本不算大,但它是个异步动作,落在哪一帧不确定。指望用它做帧级对齐,会抖。

第三个来源是时钟基准不同。H5 里 performance.now 的零点一般是页面开始导航的时刻,ArkTS 侧拿到的 Date.now 是系统时间。把这两个数字直接相减,得到的差值没有任何意义。很多"我明明传了时间戳"的 bug,根子都在这里。

还有个常见的认知误区值得点出来:动画时长相等不等于动画同步。时长管的是"走完要多久",同步管的是"什么时候开始走、按谁的进度走"。两件事,得分开设计。

知识点一:Web 组件内的动画有哪几种触发方式

H5 里做动画,路子不止一条,而不同路子对"能不能被感知、能不能被外部控制"的答案完全不一样。先把四种常见写法摆出来。

<!-- anim.html --><divid="card"class="card">商品卡片</div><divid="badge"class="badge">已加入</div><style>.card{transition:transform 200ms ease-out;}.card.pop{transform:scale(1.06);}@keyframesspin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}.badge.spin{animation:spin 600ms linear 1;}</style><script>// 写法一:CSS transition,改类名就走functionpopByTransition(){document.getElementById('card').classList.add('pop');}// 写法二:CSS @keyframes,同样靠类名触发functionspinByKeyframes(){document.getElementById('badge').classList.add('spin');}// 写法三:Web Animations API,能拿到控制器对象functionpopByWAAPI(){constel=document.getElementById('card');constanim=el.animate([{transform:'scale(1)'},{transform:'scale(1.06)'},{transform:'scale(1)'}],{duration:300,easing:'ease-out',fill:'none'});anim.onfinish=()=>window.animBridge.end('card-pop');returnanim;}// 写法四:requestAnimationFrame 手写进度functionprogressByRAF(duration,onUpdate){conststart=Date.now();functionstep(){constp=Math.min((Date.now()-start)/duration,1);onUpdate(p);if(p<1)requestAnimationFrame(step);}requestAnimationFrame(step);}</script>

关键差别在最后两种。

写法一和写法二,动画的实际执行者是内核的样式系统。JS 只负责加个类名,加完就撒手。想知道它什么时候结束,只能挂 animationend 或 transitionend 事件。想在中途改进度,基本没戏,最多改 duration 让它跳一下。

写法三用 Web Animations API,el.animate 会返回一个 Animation 对象。这个对象身上有 currentTime,有 playState,有 pause 和 play,还有 onfinish 回调。也就是说,动画从"内核的私事"变成了"你手上的一根可读可写的进度条"。要跟原生对齐,优先用它。

写法四最土,也最自由。每一帧算一次进度,进度值归你管。代价是要自己处理启停和清理,忘了取消就会一直跑。

有个坑提一句:animationend 只在 CSS 动画上触发,transitond 对应 transitionend,而 Web Animations API 走的是 finished 承诺或 onfinish。三种事件的触发条件不一样,写回调的时候别混用,更别指望一个 animationend 能接住全部。另外把 animation-iteration-count 设成 infinite 的动画永远不会触发 animationend,需要中途停的时候记得显式取消。

知识点二:让 ArkTS 感知 H5 动画状态

现在换到 ArkTS 侧。它不知道 Web 区域里发生了什么,得让 H5 主动说。最顺手的工具是 javaScriptProxy,它往页面里注入一个全局对象,H5 直接像调本地函数一样调它。

先定义一个普通的类,方法签名就是 H5 能看到的接口。

// AnimBridge.etsexportclassAnimBridge{privateonStart:(name:string)=>void=()=>{};privateonEnd:(name:string)=>void=()=>{};bind(onStart:(name:string)=>void,onEnd:(name:string)=>void):void{this.onStart=onStart;this.onEnd=onEnd;}// H5 侧调用:window.animBridge.start('card-pop')start(name:string):void{this.onStart(name);}end(name:string):void{this.onEnd(name);}}

把它挂到 Web 组件上。

@Entry@Componentstruct DetailPage{controller:webview.WebviewController=newwebview.WebviewController();privatebridge:AnimBridge=newAnimBridge();@Stateh5Playing:boolean=false;aboutToAppear():void{this.bridge.bind((name:string)=>{this.h5Playing=true;console.info(`h5 anim start:${name}`);},(name:string)=>{this.h5Playing=false;console.info(`h5 anim end:${name}`);});}build(){Column(){Web({src:$rawfile('anim.html'),controller:this.controller}).javaScriptAccess(true).javaScriptProxy({object:this.bridge,name:'animBridge',methodList:['start','end'],controller:this.controller}).onPageEnd(()=>{// 页面脚本已就绪,做一次握手,告诉 H5 原生这边准备好了this.controller.runJavaScript('window.__nativeReady && window.__nativeReady()');})}}}

几个必须注意的点。

name 是注入到 window 上的属性名,H5 里就用这个名字访问,写成 animBridge 就得用 animBridge,大小写敏感。methodList 必须把你希望 H5 能调的方法列全,没列的调不通,报的是方法不存在,不是没权限,容易看歪。方法里改 @State 变量是允许的,框架会把它当成一次正常的状态更新排进渲染管线。

有了这条通道,ArkTS 就能维护一个和 H5 对齐的状态机。比如用一个 h5Playing 变量挡住用户连点,或者等 H5 报告结束后再启动原生收尾动画。这属于事件驱动的做法,够用,也最简单。

知识点三:反过来,用 runJavaScript 控制 H5

信息能出来,就能进去。ArkWeb 提供 runJavaScript 让 ArkTS 在页面上下文里执行一段脚本。它返回一个承诺,兑现出来的是字符串。

// 传参必须自己拼进脚本文本里,回值只有字符串asyncfunctionseekH5(controller:webview.WebviewController,progress:number):Promise<void>{constscript=`window.anim && window.anim.seekTo(${progress.toFixed(3)})`;awaitcontroller.runJavaScript(script);}// H5 返回结构化数据,靠 JSON 串过去asyncfunctionreadH5State(controller:webview.WebviewController):Promise<Record<string,number>>{consttext=awaitcontroller.runJavaScript('JSON.stringify(window.anim.state())');returnJSON.parse(text)asRecord<string,number>;}

执行结果是:第一段脚本在页面里把动画进度定位到指定位置,ArkTS 侧拿到一个空字符串。第二段脚本把 H5 的状态对象序列化成文本传回来,ArkTS 解析成对象。

三个容易栽跟头的地方。

时机。页面没加载完就调 runJavaScript,拿不到想要的结果,可能返回空串,也可能直接抛错。稳妥的做法是把它放在 onPageEnd 之后,或者由 H5 通过桥接主动报一个 ready 再开始。别在 aboutToAppear 里就急着调,那时候页面还是白屏。

序列化。runJavaScript 的返回值只有字符串。想让 H5 返回对象,必须让 H5 自己 JSON.stringify。反过来往 H5 传对象,也要在脚本里写 JSON.parse。常见的错误是把对象直接拼进脚本文本,结果页面里得到一串方括号,无声无息地失败。

转义。脚本本身是一段字符串,参数里如果带引号、换行、反斜杠,就得转义,否则语法错误。省事的办法是只用数字和布尔值做参数,字符串参数先过一遍 JSON.stringify 再拼进去,这样引号问题自动解决。

调试的时候,onConsole 是最好用的眼睛,H5 里所有 console.info 都能捞出来。

Web({src:$rawfile('anim.html'),controller:this.controller}).onConsole((event)=>{console.info(`[h5]${event.message.getMessage()}`);})

知识点四:三种同步方案怎么选

到这里工具齐了,关键是选方案。同步的本质是决定"谁说了算"。三种常见做法,各自有明确的适用边界。

方案一,事件对齐。两边各自演自己的,一方结束后通过桥接通知另一方开始。适合串行动画,比如 H5 弹窗缩放入场结束后,原生再播一个列表滑入。优点是实现最省事,动画各自的缓动曲线可以随便挑。缺点是总时长变成两段之和,节奏会显得慢,密集型交互里观感发闷。

方案二,ArkTS 主导。原生侧拿着总时间轴,按时间点用 runJavaScript 指挥 H5 走位,H5 自己不做自主播放,只提供定位能力。适合要求严格对齐的场景,比如拖拽进度条联动 H5 图表,或者手势跟着手指走。优点是对齐精度最高,随时可以暂停、反向、跳转。缺点是所有动画都得改成可定位的形式,H5 那边的 CSS 动画基本要重写。

方案三,共享时钟加进度插值。只定义一条逻辑时间轴和一个基准时刻,两边各自按同一个基准算进度,谁都不指挥谁。适合大面积联动,比如一个原生骨架屏和 H5 内容同时按同一个进度浮现。优点是桥接调用少,抗抖动能力强。缺点是需要一个可靠的时钟基准,而且两边都得支持按进度定位。

给个直观的判断口径:动画是前一段结束才轮到后一段,用方案一;动画需要跟着手指或需要精确到帧,用方案二;动画是大面积、同节奏的渐进变化,用方案三。

知识点五:把时间轴对齐做扎实

选了方案二或方案三,对齐的活儿才真正开始。核心就一件事:两边算进度时必须用同一个基准。

基准别用 performance.now 直接混算,因为它的零点在页面导航时刻,和 ArkTS 侧的时钟没有共同原点。做法是让 ArkTS 取一次 Date.now 作为基准,把数字传给 H5,H5 也用 Date.now 减基准算已经过去多久。

H5 侧先做一个可定位的动画对象,动画只创建一次,之后全靠 currentTime 走位。

// 动画只创建一次,冻在原地,等原生下指令window.anim=(function(){constel=document.getElementById('card');constanim=el.animate([{transform:'scale(1)'},{transform:'scale(1.06)'},{transform:'scale(1)'}],{duration:300,easing:'linear',fill:'both'});anim.pause();functionseekTo(progress){anim.currentTime=progress*300;}functionstate(){return{currentTime:anim.currentTime,playState:anim.playState};}return{seekTo:seekTo,state:state};})();

这里把缓动从 ease-out 换成了 linear,是有意的。按进度定位的时候,缓动应该由进度值本身承担,如果动画还自带非线性缓动,原生算出来的进度和屏幕上的位置就对不上,看上去就是"卡一下再冲一下"。

然后 ArkTS 侧发起一次带基准的启动。

privatestartSynced():void{constbase=Date.now();// 全局唯一的基准时刻this.playStart=base;animateTo({duration:300,curve:Curve.Linear},()=>{this.progress=1;// 原生进度也走同一条线性时间});// 把基准和时长交给 H5,让它自己按同一个时钟推进this.controller.runJavaScript(`window.anim.playFrom(${base}, 300)`);}

H5 侧接收基准,用 requestAnimationFrame 自己推进,只在结束时通知一次原生。

window.anim.playFrom=function(baseEpoch,duration){functionstep(){constp=Math.min((Date.now()-baseEpoch)/duration,1);window.anim.seekTo(p);if(p<1){requestAnimationFrame(step);}else{window.animBridge.end('card-pop');}}requestAnimationFrame(step);};

这段代码的执行结果是:从原生动画开始的那一刻算起,H5 每帧算一次自己的进度,两边共用同一个分母和同一个起点,误差只剩一帧的调度抖动,一个刷新周期以内。

如果换成每帧用定时器调一次 runJavaScript 去同步进度,性能会明显变差。每帧跨一次桥,桥接往返的抖动会直接变成动画的抖动。让 H5 自己按共享时钟跑,桥接只在开始和结束各用一次,抖动源就消掉了。这也是方案三比方案二更抗抖的原因。

还有个细节:启动顺序上要让 H5 先就绪。先等页面加载完、动画对象创建好,再取基准时刻,再开跑。如果反了,H5 那边还在初始化,原生动画已经跑掉三分之一,进度一对齐就会看到明显的跳变。

知识点六:性能取舍,动画该交给谁

同步做对了,还有性能这一关。协同场景里的性能问题,八成来自两个地方:属性选得不对,和桥接调得太频。

属性这块,规则很简单:只动 transform 和 opacity。这两个属性改变时不需要重新排版,可以交给合成层处理,走 GPU。宽度、高度、left、top、margin 这些属性一动,浏览器就得重新算布局再重绘,动画一复杂就直接掉帧。老代码里用 left 做位移的,改成 transform 平移,往往什么都不用调,帧率就上来了。

/* 会触发重排,动画一复杂就掉帧 */.card-move{transition:left 300ms linear;}/* 走合成层,只动图层位置 */.card-move{transition:transform 300ms linear;will-change:transform;}

will-change 是把双刃剑。它提示内核对元素做图层提升,动画期间更顺,但每个提升的图层都要额外显存。给一个两个元素加没问题,给列表里上百个元素都加,内存会炸,反而更卡。用完记得在动画结束后撤掉。

桥接这块,记住一条:runJavaScript 是昂贵操作。它要跨语言边界,要等目标线程有空,还得把结果序列化回来。一次调用的开销远大于一次纯 JS 计算。所以千万别在动画的每一帧里调它。需要每帧同步进度,就让 H5 自己在 rAF 里算,或者用共享时钟;只有在关键节点,比如开始、暂停、结束、越过某个阈值,才跨桥通知一次。

再补一个硬件层面的注意点。Web 区域和原生 UI 在同一个窗口里争 GPU 资源。同一屏上如果同时开着大面积的模糊、阴影、半透明叠加动画,再加 H5 里一个 canvas 满屏刷新,帧率一定会掉。这种情况下要么降低特效面积,要么把两段动画错开时间,别在同一时刻抢。

知识点七:掉帧排查的顺序

动画卡了,别急着改代码。按顺序缩小范围,比乱试快得多。

第一步,判断卡的是哪一侧。在 H5 里挂一个帧间隔监视,把超过阈值的长帧打到控制台,再用 onConsole 捞到 ArkTS 日志里看。

(functionwatchFrame(){letlast=performance.now();functiontick(now){constdelta=now-last;last=now;if(delta>24){console.info('[frame] '+delta.toFixed(1)+'ms');}requestAnimationFrame(tick);}requestAnimationFrame(tick);})();

如果控制台里长帧一条接一条,问题在 H5 侧;如果 H5 帧率很稳,原生那块却在抖,问题在 ArkTS 侧或者桥接。

第二步,把一侧的动画临时注释掉,看另一侧是否恢复。这是最省事的二分法,一次就能定位到具体是哪段代码在拖。

第三步,用 DevEco Profiler 抓 ArkTS 主线程。重点看桥接调用的密度,如果时间线上密密麻麻全是跨语言调用,那基本就是前面说的每帧调 runJavaScript,改成共享时钟即可。

第四步,检查动画期间有没有读取布局属性。动画回调里读 offsetWidth、getBoundingClientRect 这类值,会强制内核立刻结算布局,每帧一次就会形成读写交替的抖动。改成先读后写,把读取集中在一个批次里。

第五步,看有没有同步阻塞。一次传很大的 JSON 过桥,或者 H5 侧做了重计算,都会把主线程占住。把大对象拆开传,或者改成 H5 主动拉取。

几个反复被人踩的坑

在 onPageEnd 之前就调 runJavaScript,返回空串,然后怀疑是权限问题。实际是时机问题。

用 setTimeout 去对齐两边动画。定时器精度本身不保证,两个定时器又各有起点,误差会累积,来回切几次就散了。

H5 里用了 animation-iteration-count 为 infinite 的呼吸效果,切页面时忘了停。它在后台继续跑,白耗电还占合成资源,回到页面时会看到位置突然一跳。

同一个 Web 组件被重新加载后,桥接对象没重新注册,H5 调 animBridge 报未定义。页面级的状态要在 onPageEnd 里重建,不能假设它一直在。

把动画时长调成一样就以为同步了。时长只决定走完要多久,起点和进度基准才是同步的关键。

小结

回到开头那段录屏。按钮和卡片错位的根因不是时长,而是两条时间线各走各的。解决它需要三件事:让 H5 提供可读可写的动画进度,让 ArkTS 通过 javaScriptProxy 和 runJavaScript 与它双向通信,再让两边共用同一个时钟基准。方案怎么选看场景,串行动画用事件对齐,精确跟随用原生主导,大面积联动用共享时钟。性能上守住两条线,只动 transform 和 opacity,别在每帧里跨桥。

小任务:找一个你项目里 H5 和原生衔接的地方,把 H5 动画改成用 el.animate 创建并常驻,暴露一个 seekTo 方法,再从 ArkTS 侧传一个基准时刻进去,让 H5 自己按共享时钟推进。跑通之后,故意把基准时刻往后挪 80 毫秒,观察画面错位的样子,你对"同步"这件事的手感会立刻不一样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询