一、那张“偶尔加速”的动图没有坏

FramePulseLab 原本只是一个动效素材验收页:设计同学把 metro_signals.gif 放进 resources/rawfile,开发侧显示帧数、原始时长和当前生命周期,确认无误后再把素材交给业务页面。文件是 720×720、48 帧,桌面浏览器里一圈约 2.2 秒;可在真机上连续切两次后台再回来,指示灯会明显变快,第三次进入页面时内存也不再回落。

最初我把问题归咎于 GIF 本身。HiLog 却给了另一组证据:解码只发生一次,createPixelMapList() 返回 48 个 PixelMap;异常发生后 onPageShow 的次数是 3,而播放控制器的活动实例从 1 变成了 2。更容易忽略的是源文件的延时表里有 11 帧小于 20 ms,最短甚至为 0 ms。浏览器替素材做了容错,Demo 却把这些值原样交给 ImageAnimator,于是“素材时序不稳定”和“前后台重复启动”叠在一起,看上去像解码器随机加速。

本次任务固定为 GIF-0012。最终验收口径也提前写死:48 帧,原始总时长 1860 ms,规范化后 2240 ms,11 个延时被钳制;处置类型中 restore background 为 7 帧、restore previous 为 2 帧;解码 186 ms,峰值 108.4 MB;任意次数前后台切换后 activeController 必须始终为 1。

二、先把解码、播放和页面生命周期拆开

这个问题不适合用一个 @State playing 糊住。解码资源、组件状态和页面可见性是三条不同的时间线:ImageSource 持有输入数据,PixelMap 列表持有解码结果,ImageAnimator 只消费帧信息;页面进入后台时应该暂停,不该再次解码;页面真正离开时才停止并释放所有 PixelMap。

我给 GifPlaybackPage 定义了七个状态:IDLE → DECODING → READY → PLAYING ↔ PAUSED → STOPPED → RELEASED。READY 以前不允许触发播放,RELEASED 以后任何异步回调都不得回写 UI。另有一个自增的 loadGeneration:用户快速离开再回来时,旧解码任务即使晚到,也只能释放自己的结果,不能覆盖新页面。

项目目录保持很小,但职责必须清楚:

FramePulseLab/
└── entry/src/main/
    ├── ets/entryability/EntryAbility.ets
    ├── ets/pages/GifPlaybackPage.ets
    ├── ets/media/GifFrameRepository.ets
    ├── ets/media/DelayPolicy.ets
    └── resources/rawfile/metro_signals.gif

GifFrameRepository 只管 Image Kit 对象的获得和释放;DelayPolicy 只处理时序;页面只决定何时把 AnimationStatus 设为 Running 或 Paused。这样复现日志能明确回答:错在解码、元数据,还是生命周期,而不是所有异常都叫“播放失败”。

三、一次读取三份证据,拒绝只看 PixelMap

第一段代码解决的是“画面能播,但我们不知道它按什么节奏播”。Image Kit 对动图不仅能给出 PixelMap 列表,还能读取逐帧延时和处置类型。三者必须来自同一个 ImageSource,并在返回后校验长度;只要有一项不是 48,就不能进入 READY。

import { image } from '@kit.ImageKit';
import { resourceManager } from '@kit.LocalizationKit';

export interface GifFrameSet {
  maps: image.PixelMap[];
  delays: number[];
  disposals: number[];
  decodeMs: number;
}

export class GifFrameRepository {
  private source?: image.ImageSource;

  async load(rm: resourceManager.ResourceManager): Promise<GifFrameSet> {
    const started = Date.now();
    const bytes = await rm.getRawFileContent('metro_signals.gif');
    this.source = image.createImageSource(bytes.buffer);
    const [maps, delays, disposals] = await Promise.all([
      this.source.createPixelMapList({ editable: false }),
      this.source.getDelayTimeList(),
      this.source.getDisposalTypeList()
    ]);
    if (maps.length !== delays.length || maps.length !== disposals.length) {
      maps.forEach((item: image.PixelMap) => item.release());
      throw new Error(`FRAME_META_MISMATCH maps=${maps.length} delay=${delays.length}`);
    }
    return { maps, delays, disposals, decodeMs: Date.now() - started };
  }

  release(): void {
    this.source?.release();
    this.source = undefined;
  }
}

这里特意不在仓库里保存 PixelMap 数组。所有权随 GifFrameSet 转交给页面控制器,仓库只保留 ImageSource。这样页面销毁时能明确释放 48 个 PixelMap,再释放 ImageSource,不会出现两个对象都以为对方负责回收的模糊地带。

Promise.all 也不是为了炫技。延时表、处置表和图像帧属于同一个快照,串行读取会延长 DECODING 窗口,也让快速退页更容易撞上旧回调。易错点是 rawfile 的 Uint8Array 生命周期:创建 ImageSource 后不能复用并修改底层 buffer;另一个边界是超大动图,全帧解码会线性增加内存,本 Demo 的 108.4 MB 已经接近我设定的 120 MB 警戒线,因此生产页还要限制分辨率与帧数。

四、延时修正不是统一改成 40 ms

源延时表为毫秒。metro_signals.gif 中 0 ms 和 10 ms 并不是“应该极速播放”,而是导出工具留下的含糊值。若把所有帧统一成 40 ms,节奏停顿会消失;若完全信任源值,又会让不同运行环境表现不同。我采用的策略是只钳制小于 20 ms 的值到 40 ms,保留其余延时,且把修改数量写入审计结果。

第二段代码同时解释处置类型。createPixelMapList() 已经返回可用于显示的帧,业务层不应再次手工清背景,否则会把解码器已经合成的画面清两遍。处置表在这里是诊断信息:确认素材确实含局部更新帧,并把 7 个背景恢复与 2 个前态恢复呈现在页面上,帮助定位拖影究竟来自素材还是播放器。

import { image } from '@kit.ImageKit';

export interface FrameAudit {
  frames: ImageFrameInfo[];
  rawTotalMs: number;
  normalizedTotalMs: number;
  clampedCount: number;
  restoreBackground: number;
  restorePrevious: number;
}

export function buildFrameAudit(
  maps: image.PixelMap[], delays: number[], disposals: number[]
): FrameAudit {
  let clampedCount = 0;
  const normalized = delays.map((value: number) => {
    if (value < 20) {
      clampedCount++;
      return 40;
    }
    return value;
  });
  return {
    frames: maps.map((src: image.PixelMap, index: number) => ({
      src, width: 720, height: 720, top: 0, left: 0,
      duration: normalized[index]
    })),
    rawTotalMs: delays.reduce((sum: number, value: number) => sum + value, 0),
    normalizedTotalMs: normalized.reduce((sum: number, value: number) => sum + value, 0),
    clampedCount,
    restoreBackground: disposals.filter((value: number) => value === 2).length,
    restorePrevious: disposals.filter((value: number) => value === 3).length
  };
}

这段逻辑的边界也很明确:20/40 ms 是本项目策略,不是 GIF 标准常量;遇到长停顿帧不会缩短,遇到负数或数组越界则直接拒绝素材。规范化后的 2240 ms 是 48 帧 duration 之和,ImageAnimator 以逐帧 duration 为准,因此外层不再设置一个相互竞争的总 duration。

五、前后台恢复只改状态,不再创建播放器

真正导致倍速的代码曾写在 onPageShow:每次可见都重新构造一次帧数组,并把状态从 Initial 推到 Running。组件本身还存在,于是旧实例没有停止,新状态又触发了启动。修复后的原则是“解码只属于页面实例,恢复只属于状态迁移”。

第三段代码用 loadGeneration 拦截过期结果,并用 activeController 做运行期断言。onPageHide 只暂停;aboutToDisappear 才停止、清空 ImageFrameInfo 引用、逐个释放 PixelMap,再释放 ImageSource。重复调用 disposeFrames() 是安全的,因为数组先被交换为空数组。

@Entry
@Component
struct GifPlaybackPage {
  @State phase: string = 'IDLE';
  @State animatorState: AnimationStatus = AnimationStatus.Initial;
  @State frames: ImageFrameInfo[] = [];
  @State taskId: string = 'GIF-0012';
  private maps: image.PixelMap[] = [];
  private repo: GifFrameRepository = new GifFrameRepository();
  private loadGeneration: number = 0;
  private activeController: number = 0;

  async aboutToAppear(): Promise<void> {
    const generation = ++this.loadGeneration;
    this.phase = 'DECODING';
    const set = await this.repo.load(getContext(this).resourceManager);
    if (generation !== this.loadGeneration) {
      set.maps.forEach((item: image.PixelMap) => item.release());
      return;
    }
    const audit = buildFrameAudit(set.maps, set.delays, set.disposals);
    this.maps = set.maps;
    this.frames = audit.frames;
    this.activeController = 1;
    this.phase = 'PLAYING';
    this.animatorState = AnimationStatus.Running;
    console.info(`GIF-0012 READY frames=48 decode=${set.decodeMs}ms normalized=2240ms`);
  }

  onPageHide(): void {
    if (this.phase === 'PLAYING') {
      this.animatorState = AnimationStatus.Paused;
      this.phase = 'PAUSED';
    }
  }

  onPageShow(): void {
    if (this.phase === 'PAUSED' && this.activeController === 1) {
      this.animatorState = AnimationStatus.Running;
      this.phase = 'PLAYING';
    }
  }

  aboutToDisappear(): void {
    ++this.loadGeneration;
    this.animatorState = AnimationStatus.Stopped;
    this.frames = [];
    const releasing = this.maps.splice(0);
    releasing.forEach((item: image.PixelMap) => item.release());
    this.repo.release();
    this.activeController = 0;
    this.phase = 'RELEASED';
  }

  build() {
    Column() {
      ImageAnimator().images(this.frames).state(this.animatorState).iterations(-1)
      Text(`任务 ${this.taskId} · ${this.phase}`)
    }
  }
}

需要注意 aboutToAppear() 里的异步操作不会因为页面退出自动取消,所以代次检查必须发生在 await 之后。另一个坑是先释放 PixelMap 再清空组件引用:渲染线程可能仍持有最后一帧。这里先把状态设为 Stopped,再让 frames=[] 触发引用断开,最后释放资源,顺序不能颠倒。

六、把异常恢复写成可重复的实验

我没有用“肉眼看起来正常”作为结论,而是做了三组固定动作。第一组冷启动后连续播放 20 圈,归一化周期稳定在 2240 ms 左右;第二组在第 31 帧切到后台,停留 5 秒再返回,组件从 PAUSED 回到 PLAYING,没有从第 0 帧额外创建播放链;第三组连续执行三次“进入详情—返回—再次进入”,PixelMap 数量按 48→0→48→0 变化,内存能回到基线附近。

最终页面展示任务 GIF-0012、素材 metro_signals.gif、48 帧、720×720、原始 1860 ms、规范化 2240 ms、钳制 11 帧、处置统计 7/2、解码 186 ms、峰值 108.4 MB。当前帧是 31/48,生命周期经历 onPageHide → onPageShow,恢复计数 3,活动控制器仍是 1,状态为 PLAYING_STABLE。

这里的 PLAYING_STABLE 是验收页的派生标签:只有内部 phase=PLAYING、activeController=1 且延时审计通过时才显示;它不是额外的 ImageAnimator 枚举值。IDE 中看到的 phase='PLAYING' 与模拟器上的绿色稳定态因此属于同一时刻的两层状态,而不是两套不一致的状态机。

HiLog 的关键行也与页面一一对应:

GIF-0012 DECODE frames=48 size=720x720 cost=186ms peak=108.4MB
GIF-0012 TIMING raw=1860ms normalized=2240ms clamped=11
GIF-0012 DISPOSAL background=7 previous=2
GIF-0012 RESUME count=3 activeController=1 frame=31/48
GIF-0012 STATE PLAYING_STABLE

七、这次真正修掉的是所有权

表面上这是一个动图速度问题,根因却是三个所有权没有说清:延时由谁解释、播放状态由谁推进、PixelMap 由谁释放。把它们拆开后,ImageAnimator 只负责显示,Image Kit 只负责解码与元数据,页面控制器负责生命周期;任何异常都能落到一条确定的状态迁移和一行日志上。

这套写法也有边界。全帧 PixelMap 不适合数百帧、2K 分辨率的长动图;超过 120 MB 预算时应改为视频资源、Native 流式解码或在入库阶段压缩。处置类型是诊断信息,不应在 createPixelMapList() 之后再自行合成。最后,延时钳制必须由产品接受,因为它改变了素材时间轴;若帧时序具有业务含义,就应该拒绝素材而不是自动修正。

但对资源验收页而言,这次结果足够明确:同一份 metro_signals.gif 在 3 次前后台恢复后不再加速,活动控制器始终只有一个,页面离开后 48 个 PixelMap 全部释放。比“换个组件试试”更重要的是,我们终于能解释每一帧为什么在那个时刻出现,也能证明它何时被回收。

Logo

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

更多推荐