内容卡片上的一个动效,真正难维护的地方往往不是“播放”按钮,而是它被快速切换、页面退出或资源加载失败之后还能不能收住。假设用户先打开卡片 A,又立即切换到卡片 B,A 的资源加载却晚于 B 返回。直接把库调用写在页面点击事件里,就可能看到 B 标题下面还在播放 A 动效。更麻烦的是,一次初始化失败会不会让下一次重试陷入不可用状态?这些问题要在接入第三方库之前说清楚。

上一阶段已经确定内容卡片工程的依赖准入规则,并约定 EffectDriver 只暴露 start(resourceId): Promise<void> 和 stop(): void,CardEffectController 保存当前资源。第二阶段继续沿用这两个业务接口,不改变 resourceId 的含义,也不将第三方包的私有类型传入页面。本篇改造的是调用边界、快速切换和失败恢复:让业务层始终知道一件动效到底处于什么状态。

HarmonyOS 三方动效适配技术封面

把控制器和第三方回调隔开

一种常见写法是在按钮回调里直接 await thirdParty.start(),取消时另找一处执行 stop()。这会让页面必须认识第三方对象的创建方式、错误码和销毁顺序。只要包升级,很多组件都要跟着改。这里改成三层:页面只接触 CardEffectController;控制器处理选择、取消、错误状态和请求先后;驱动实现负责把 start/stop 映射到经过审核的具体库。库是否支持异步加载、是否可以重复初始化、停止操作是否幂等,都应在驱动层确认,而不是由 UI 根据猜测补齐。

这个区分还有测试收益:控制器可以注入一个确定的本地驱动,不用让自动化测试真的依赖动画资源或网络下载。它测试的是我们自己的状态契约;真正三方库的兼容性,另在目标 SDK、设备和构建模式下完成集成测试。两种测试解决不同问题,不应拿前者代替后者。

应用层、控制层与驱动层的接口边界

接口仍放在 entry/src/main/ets/effect/EffectDriver.ets。把驱动工厂与库的具体导入限定在 adapters/ 目录,可以让页面既不硬编码包名,也不会被某个库特有的回调参数污染。以下 CallbackEffectDriver 是应用自己的适配器:构造时接受经过实际 SDK 文档核实的实现回调,而不是把某个不存在的 playAnimation() 当作三方库 API。它可用于真实包的桥接,也便于先运行一个无外部依赖的本地替身。

// entry/src/main/ets/effect/EffectDriver.ets
export interface EffectDriver {
  start(resourceId: string): Promise<void>;
  stop(): void;
}

export interface EffectHooks {
  open: (resourceId: string) => Promise<void>;
  close: () => void;
}

export class CallbackEffectDriver implements EffectDriver {
  private hooks: EffectHooks;

  constructor(hooks: EffectHooks) {
    this.hooks = hooks;
  }

  async start(resourceId: string): Promise<void> {
    await this.hooks.open(resourceId);
  }

  stop(): void {
    this.hooks.close();
  }
}

为什么单纯的 await 仍会让旧请求覆盖新请求

await 只保证同一个异步函数中的先后,不会自动取消其他点击触发的函数。如果 A 和 B 的两个 start() 并行,谁最后返回就可能最后操作底层播放器。控制器必须决定请求次序。这次选择串行调用 + 意图序号,而不是要求所有第三方实现都支持取消 Promise。tail 把实际驱动操作排列在一条队列上;epoch 标识用户最新意图。旧请求即使返回,也没有资格把旧资源写回 activeResource。当 close() 发生在加载中,它立即尝试停止,同时让加载完成的旧请求再补一次停止,防止延迟回调重新点亮界面。

// entry/src/main/ets/effect/CardEffectController.ets
import { EffectDriver } from './EffectDriver';

export type EffectStatus = 'idle' | 'loading' | 'playing' | 'failed';

export class CardEffectController {
  private driver: EffectDriver;
  private tail: Promise<void> = Promise.resolve();
  private epoch: number = 0;
  private disposed: boolean = false;
  private activeResource: string = '';
  private status: EffectStatus = 'idle';
  private failure: string = '';

  constructor(driver: EffectDriver) { this.driver = driver; }

  current(): string { return this.activeResource; }
  currentStatus(): EffectStatus { return this.status; }
  lastFailure(): string { return this.failure; }

  show(resourceId: string): Promise<void> {
    const resource: string = resourceId.trim();
    if (resource.length === 0) {
      return Promise.reject(new Error('动效资源标识不能为空'));
    }
    if (this.disposed) {
      return Promise.reject(new Error('页面已释放动效控制器'));
    }
    const requestId: number = ++this.epoch;
    this.status = 'loading';
    this.failure = '';

    const task: Promise<void> = this.tail.then(async () => {
      if (this.disposed || requestId !== this.epoch) { return; }
      try {
        this.driver.stop(); // 关闭旧效果后,才打开新资源
        await this.driver.start(resource);
        if (this.disposed || requestId !== this.epoch) {
          this.driver.stop();
          return;
        }
        this.activeResource = resource;
        this.status = 'playing';
      } catch (error) {
        try { this.driver.stop(); } catch (_) { /* 留待驱动层记录 */ }
        if (requestId === this.epoch && !this.disposed) {
          this.activeResource = '';
          this.status = 'failed';
          this.failure = '资源启动失败,请检查资源与适配器状态';
        }
        throw error;
      }
    });
    // 吞掉队尾的拒绝只为保证下一个请求能继续入队;
    // 当前调用的 task 仍会把错误返回给调用者。
    this.tail = task.then(() => {}, () => {});
    return task;
  }

  close(): void {
    ++this.epoch;
    this.activeResource = '';
    this.status = 'idle';
    this.failure = '';
    this.driver.stop();
  }

  dispose(): void {
    if (this.disposed) { return; }
    this.disposed = true;
    this.close();
  }
}

这个版本没有声称驱动的 stop() 一定无异常。如果具体库的关闭动作可能抛出错误,需要在其 EffectDriver 实现内部统一消化、记录并标记资源不可复用,确保业务接口遵守“可重复关闭”的约定。控制器中的 close() 是同步接口,保留了第一篇的调用习惯;它不等待第三方异步清理,因此不能把它解释成“底层硬件资源已经同步释放”。如果实际包提供异步释放,下一版应把这一差异封装在驱动内部,或明确修改接口契约并同步调整调用方。

使用可控替身验证四种边界

真正接入库之前,可以编写一个不包含第三方导入的驱动替身。它只保存资源标识并允许注入一次失败,目的在于验证控制器的错误传播和恢复逻辑。示例采用普通 ArkTS 类,不调用不存在的 HarmonyOS 系统接口,也不需要新增权限;它适合放在单元测试工程或开发调试入口,而不是直接作为正式动效驱动。

// entry/src/main/ets/effect/PreviewEffectDriver.ets
import { EffectDriver } from './EffectDriver';

export class PreviewEffectDriver implements EffectDriver {
  private active: string = '';
  private failNext: boolean = false;

  injectFailure(): void { this.failNext = true; }
  resource(): string { return this.active; }

  async start(resourceId: string): Promise<void> {
    if (this.failNext) {
      this.failNext = false;
      throw new Error('预设的资源加载失败');
    }
    this.active = resourceId;
  }

  stop(): void { this.active = ''; }
}

四个检查点足以暴露最常见的状态缺陷。首先正常 show('card-a'),控制器的 current() 与替身的 resource() 均应为 card-a。接着调用 injectFailure() 再切换 card-b,上层要收到异常,currentStatus() 应变为 failed,不能继续显示 A 正在播放。第三次调用 show('card-c') 时,前一次拒绝不能阻断 tail,恢复后的状态应是 playing。最后在一次慢速 start 期间调用 close(),待 Promise 完成后不得出现旧资源重新播放。慢速场景可用手动控制的 Promise 替身实现,绝不能把真实耗时数字凭空写进测试结论。

快速切换、失败重试与取消请求的状态轨迹

页面对错误也应有区分:空资源标识属于输入问题,适配器抛出的加载错误属于执行问题,页面销毁后的请求属于生命周期问题。建议 UI 根据 currentStatus() 绘制“正在准备”“正在播放”“加载失败,可重试”“未选择动效”四种可访问的文案,不要只用转圈图标来表示所有阶段。如果上层需要每次状态立即推送到 ArkUI,可在控制器外包一层 @Observed 状态模型或事件回调,而不是让控制器反向依赖某个页面组件。

另一个边界是同一页面多张卡片的资源竞争。如果底层库在一个组件容器内只允许保留一个动画实例,控制器就应属于那个容器,而不是每个卡片各有一个全局播放器。如果底层支持多实例,则可以为每张卡片独立构造驱动,并在页面离开时统一逐个释放。两种策略不能混用:业务上看起来都是“播放卡片”,资源所有权却完全不同。优先把所有权写入驱动实现的构造和释放约定,再决定实例存放位置。

还要避免把重试做成无条件循环。若错误来自无效资源、许可证限制或缺少目标 SDK 能力,立刻重试十次不会改善结果;若属于可恢复的临时 I/O 故障,可以由上层提供一次明确的“重试”操作。控制器不自动推测错误类别,实际三方适配层应根据官方错误码生成稳定的项目错误类型,再由业务决定是否可以重试。这样既保留了错误细节,也不会在页面里泄露库的内部异常对象。

交付时还要核查包和资源的责任边界

第二篇延续前一篇的准入信息:approvedVersion、licenseReviewed、sdkCompatible、hasOwner、needsUnexpectedPermission 仍作为部署前的业务审批字段;oh-package.json5、锁文件和适配器代码须放在同一次变更审查中。实际对接某个三方动画包时,要把真实包的对象生命周期、资源装载路径、错误码及停止语义填进 EffectHooks,再在目标 SDK、设备与发布构建模式下验证。示例的 CallbackEffectDriver 只负责隔离,不能单独证明任何指定包已经通过兼容测试。

排查顺序也可以固定:包是否解析到批准版本,工程是否编译到适配层,resourceId 是否对应存在的资源,驱动开始前旧资源是否关闭,异常是否沿 Promise 返回,以及页面退出后有没有悬挂回调。分清“依赖管理失败”和“资源运行失败”,日志才不会把所有错误压成一句“动画加载失败”。

本轮把“有一个抽象接口”变成了可检查的请求序列与恢复规则。接下来第三篇继续查构建链、锁文件与运行时边界:先定位是包、编译配置还是驱动契约出了问题,再讨论升级和回退,而不是在 UI 里不断加 try/catch。

官方资料:添加依赖项、ohpm list、ArkTS 概述。

Logo

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

更多推荐