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

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

html 复制代码
<!-- anim.html -->
<div id="card" class="card">商品卡片</div>
<div id="badge" class="badge">已加入</div>

<style>
  .card { transition: transform 200ms ease-out; }
  .card.pop { transform: scale(1.06); }

  @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
  .badge.spin { animation: spin 600ms linear 1; }
</style>

<script>
  // 写法一:CSS transition,改类名就走
  function popByTransition() {
    document.getElementById('card').classList.add('pop');
  }

  // 写法二:CSS @keyframes,同样靠类名触发
  function spinByKeyframes() {
    document.getElementById('badge').classList.add('spin');
  }

  // 写法三:Web Animations API,能拿到控制器对象
  function popByWAAPI() {
    const el = document.getElementById('card');
    const anim = 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');
    return anim;
  }

  // 写法四:requestAnimationFrame 手写进度
  function progressByRAF(duration, onUpdate) {
    const start = Date.now();
    function step() {
      const p = 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 能看到的接口。

typescript 复制代码
// AnimBridge.ets
export class AnimBridge {
  private onStart: (name: string) => void = () => {};
  private onEnd: (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 组件上。

typescript 复制代码
@Entry
@Component
struct DetailPage {
  controller: webview.WebviewController = new webview.WebviewController();
  private bridge: AnimBridge = new AnimBridge();
  @State h5Playing: 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 在页面上下文里执行一段脚本。它返回一个承诺,兑现出来的是字符串。

typescript 复制代码
// 传参必须自己拼进脚本文本里,回值只有字符串
async function seekH5(controller: webview.WebviewController, progress: number): Promise<void> {
  const script = `window.anim && window.anim.seekTo(${progress.toFixed(3)})`;
  await controller.runJavaScript(script);
}

// H5 返回结构化数据,靠 JSON 串过去
async function readH5State(
  controller: webview.WebviewController
): Promise<Record<string, number>> {
  const text = await controller.runJavaScript('JSON.stringify(window.anim.state())');
  return JSON.parse(text) as Record<string, number>;
}

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

三个容易栽跟头的地方。

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

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

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

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

typescript 复制代码
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 走位。

javascript 复制代码
// 动画只创建一次,冻在原地,等原生下指令
window.anim = (function () {
  const el = document.getElementById('card');
  const anim = el.animate(
    [{ transform: 'scale(1)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' }],
    { duration: 300, easing: 'linear', fill: 'both' }
  );
  anim.pause();

  function seekTo(progress) {
    anim.currentTime = progress * 300;
  }

  function state() {
    return { currentTime: anim.currentTime, playState: anim.playState };
  }

  return { seekTo: seekTo, state: state };
})();

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

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

typescript 复制代码
private startSynced(): void {
  const base = 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 自己推进,只在结束时通知一次原生。

javascript 复制代码
window.anim.playFrom = function (baseEpoch, duration) {
  function step() {
    const p = 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 平移,往往什么都不用调,帧率就上来了。

css 复制代码
/* 会触发重排,动画一复杂就掉帧 */
.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 日志里看。

javascript 复制代码
(function watchFrame() {
  let last = performance.now();
  function tick(now) {
    const delta = 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 毫秒,观察画面错位的样子,你对"同步"这件事的手感会立刻不一样。

相关推荐
贾伟康4 小时前
HarmonyOS API 26 HMAF Skill 端侧 A2A 消息去重与上下文最小化
harmonyos·arkts·skill·a2a·hmaf
贾伟康4 小时前
HarmonyOS API 26 HMAF Skill 多 Skill 冲突仲裁与可恢复执行
harmonyos·arkts·智能体·skill·hmaf
钉先生贾5 小时前
毕业项目:从上游更新到回归发布的完整维护闭环
华为·harmonyos
行者-全栈开发5 小时前
【鸿蒙心迹】权限申请全流程——从声明到用户授权的完整链路(HarmonyOS 7.x)
华为·harmonyos
KevinChaos1 天前
阳台小花园开发手记 01|HarmonyOS 7.0 互动卡片:小花园为何要两套界面
华为·harmonyos
Ranger09291 天前
下一代鸿蒙打包工具链:arkdown
harmonyos
程序猿追1 天前
一个工程面向手机与二合一:HarmonyOS 6 部署配置和本地手记
华为·智能手机·harmonyos
行者-全栈开发2 天前
【鸿蒙心迹】深色模式适配全链路——从配置到自定义(HarmonyOS 7.x)
华为·harmonyos
行者-全栈开发2 天前
【鸿蒙心迹】国际化 i18n 与多语言资源管理实战(HarmonyOS 7.x)
ui·华为·重构·国际化·harmonyos·多语言
OH_TPC2 天前
HarmonyOS APP开发---"云客服"远程视频客服App,需要用到这个库
harmonyos