HarmonyOS AVPlayer 起播优化:状态机、首帧测量与资源释放

视频按钮点下去后,什么时候才算“起播完成”?prepare() 返回只表示资源准备完成,不代表画面已经出现在屏幕上;play() 调用成功也不等于第一帧已经送到显示模块。若计时终点选错,日志显示 120 ms,用户却仍盯着黑屏。

AVPlayer 优化的第一步不是增加预加载,而是把创建、监听、资源设置、准备、播放、首帧和释放放回正确状态。本文实现一个状态驱动的播放器会话,并分别记录初始化、准备、播放请求和 startRenderFrame,让起播问题有清晰证据。

请添加图片描述

一、先把“起播耗时”拆成四段

一个网络视频从点击到画面出现,至少包含:

阶段起点终点说明
资源初始化设置 URLinitialized播放器识别资源入口
准备调用 prepareprepared获取播放所需资源
播放启动调用 playplaying状态进入播放
首帧链路用户点击或开始加载startRenderFrame首帧送往显示模块

官方说明中,startRenderFrame 表示播放服务开始向显示模块发送第一帧,最终视觉呈现仍会受显示渲染影响。因此它是比 prepared 更接近用户感受的指标,但不是“像素已经亮起”的绝对证明。

二、网络播放先声明INTERNET权限

{
  "module": {
    // 网络媒体URL需要网络访问能力。
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

本地文件和网络 URL 的失败原因不同。网络视频还会受到域名解析、TLS、首包、带宽、服务端 Range 支持和媒体容器影响,不能把所有慢都归到播放器对象创建。

三、严格遵守AVPlayer状态边界

AVPlayerState 包括:

idle -> initialized -> prepared -> playing
                            |          |
                            v          v
                          paused    completed

prepared/playing/paused/completed -> stopped
initialized/prepared/playing/paused/completed/stopped/error -> reset -> idle
任意非 released 状态 -> release -> released

关键限制是:

  • URL、fdSrcdataSrc 只能在 idle 设置;
  • surfaceId 首次要在 initialized 设置;
  • prepare() 只能在 initialized 调用;
  • play() 只能在 preparedpausedcompleted 调用;
  • pause() 只能在 playing 调用;
  • release() 可在除 released 外的状态调用。

状态不是日志字段,而是每个操作的前置条件。

四、创建后立即注册核心监听

在设置资源之前注册 stateChangeerror,才能看到完整状态变化。首帧与缓冲事件也应提前注册。

import { media } from '@kit.MediaKit'
import { BusinessError } from '@kit.BasicServicesKit'

class PlayerSession {
  private player?: media.AVPlayer
  private currentState: media.AVPlayerState = 'idle'
  private surfaceId: string = ''
  private autoPlay: boolean = false
  private loadStartAt: number = 0

  async create(): Promise<void> {
    if (this.player && this.currentState !== 'released') {
      return
    }
    this.player = await media.createAVPlayer()
    this.registerListeners(this.player)
    this.currentState = this.player.state
  }
}

不要在列表重建时为每个可见组件无条件创建播放器。单视频页面通常由页面级会话持有;短视频连续切换若采用多实例预准备,也要设置固定池大小和回收策略。

五、所有事件回调都保存稳定引用

private readonly stateChangeCallback = (
  state: media.AVPlayerState,
  reason: media.StateChangeReason
): void => {
  this.currentState = state
  this.handleStateChange(state).catch((error: BusinessError) => {
    this.onFailure(error)
  })
}

private readonly errorCallback = (error: BusinessError): void => {
  this.onFailure(error)
}

private readonly firstFrameCallback = (): void => {
  if (this.loadStartAt > 0) {
    const cost = Date.now() - this.loadStartAt
    this.reportFirstFrame(cost)
    this.loadStartAt = 0
  }
}

private readonly bufferingCallback = (
  type: media.BufferingInfoType,
  value: number
): void => {
  this.reportBuffering(type, value)
}

稳定引用既便于 off(),也避免页面每次重建创建一套无法对应关闭的新函数。

六、集中注册,不让业务页面散落监听

private registerListeners(player: media.AVPlayer): void {
  player.on('stateChange', this.stateChangeCallback)
  player.on('error', this.errorCallback)
  player.on('startRenderFrame', this.firstFrameCallback)
  player.on('bufferingUpdate', this.bufferingCallback)
}

bufferingUpdate 主要用于网络播放。它适合记录首缓冲、缓冲百分比等上下文,帮助区分网络等待与解码、渲染阶段问题;不要在回调里输出大量高频日志或直接刷新复杂界面。

请添加图片描述

七、加载资源前先回到idle

设置新 URL 时,播放器必须处于 idle。已有资源先 reset(),不要直接覆盖:

async load(
  url: string,
  surfaceId: string,
  autoPlay: boolean = true
): Promise<void> {
  await this.create()
  const player = this.requirePlayer()

  if (this.currentState !== 'idle') {
    if (this.currentState === 'released') {
      throw new Error('Player has been released')
    }
    await player.reset()
  }

  this.surfaceId = surfaceId
  this.autoPlay = autoPlay
  this.loadStartAt = Date.now()
  player.url = url
}

private requirePlayer(): media.AVPlayer {
  if (!this.player) {
    throw new Error('Player is not created')
  }
  return this.player
}

reset() 完成后回到 idle,这时才能设置新资源。不要用固定 200 ms 延时等待 reset 或 initialized;状态变化和 Promise 才是正确同步点。

八、由stateChange推进surface、prepare和play

URL 设置后播放器进入 initialized。视频 surfaceId 应在此时设置,然后调用 prepare();进入 prepared 后再决定是否播放。

private async handleStateChange(
  state: media.AVPlayerState
): Promise<void> {
  const player = this.requirePlayer()

  if (state === 'initialized') {
    if (this.surfaceId.length === 0) {
      throw new Error('Video surface is not ready')
    }
    player.surfaceId = this.surfaceId
    await player.prepare()
    return
  }

  if (state === 'prepared' && this.autoPlay) {
    await player.play()
    return
  }

  if (state === 'error') {
    this.autoPlay = false
  }
}

状态回调可能由用户操作或系统触发,处理函数应可重复进入并尽量保持分支短小。复杂业务通知放到独立服务,不要让状态机回调变成长串页面逻辑。

九、Surface必须先于视频准备可用

视频显示区域通常来自 XComponent 的 Surface。页面应先取得 surfaceId,再允许加载视频:

@State private surfaceReady: boolean = false
private surfaceId: string = ''
private xComponentController: XComponentController =
  new XComponentController()

XComponent({
  id: 'video_surface',
  type: XComponentType.SURFACE,
  controller: this.xComponentController
})
  .onLoad(() => {
    this.surfaceId =
      this.xComponentController.getXComponentSurfaceId()
    this.surfaceReady = this.surfaceId.length > 0
  })

用户点击播放时先判断 surfaceReady。如果 URL 已经让播放器进入 initialized,Surface 却还没创建,后续准备就会落入不完整链路。页面离开或 Surface 销毁后,也不能继续把旧 ID 交给新播放器。

十、首帧计时要从用户动作开始

如果用户点击播放后才开始建播放器,起点应放在点击时;如果列表已经预准备,只测 play() 到首帧会得到另一种指标。两种都可以,但必须命名清楚。

interface StartupTrace {
  requestAt: number
  initializedAt: number
  preparedAt: number
  playingAt: number
  firstFrameAt: number
}

function emptyTrace(): StartupTrace {
  return {
    requestAt: 0,
    initializedAt: 0,
    preparedAt: 0,
    playingAt: 0,
    firstFrameAt: 0
  }
}

在状态回调中填入对应时间,首帧回调填最后一项。最终同时上报“请求到 prepared”“请求到 playing”“请求到 startRenderFrame”,不要只保留一个总数。

十一、把准备慢和首帧慢分开处理

现象优先方向
initialized 很慢URL、网络连接、资源入口
prepare 很慢首包、容器解析、编解码资源
prepared 到 playing 慢状态调用时机、业务等待、音频焦点
playing 到首帧慢解码、Surface、显示链路
首帧后频繁卡顿带宽、缓冲、码率和持续解码

如果准备阶段占主导,盲目优化 ArkUI 播放按钮没有帮助;如果 startRenderFrame 很快但画面仍迟迟不可见,应继续检查 Surface 尺寸、遮挡、透明度和显示服务侧表现。

十二、缓冲事件只记录必要摘要

interface BufferSnapshot {
  type: media.BufferingInfoType
  value: number
  at: number
}

class BufferHistory {
  private readonly values: BufferSnapshot[] = []

  append(type: media.BufferingInfoType, value: number): void {
    if (this.values.length >= 30) {
      this.values.shift()
    }
    this.values.push({ type, value, at: Date.now() })
  }

  snapshot(): BufferSnapshot[] {
    return this.values.slice()
  }
}

有上限的历史足以还原起播阶段,不会让长视频播放几小时后积累无限事件。正式上报可在播放稳定、错误或释放时汇总一次。

请添加图片描述

十三、暂停、停止和切源不是同一个动作

async pause(): Promise<void> {
  const player = this.requirePlayer()
  if (this.currentState === 'playing') {
    await player.pause()
  }
}

async stop(): Promise<void> {
  const player = this.requirePlayer()
  if (['prepared', 'playing', 'paused', 'completed']
    .includes(this.currentState)) {
    await player.stop()
  }
}

暂停用于短时继续,停止后如要设置新 URL,仍要 reset 回到 idle。切源时直接给 url 赋新值违反状态前置条件,是 5400102 Operation not allowed 的常见来源。

十四、释放要覆盖正常、异常和页面退出

async release(): Promise<void> {
  const player = this.player
  if (!player || this.currentState === 'released') {
    return
  }

  this.autoPlay = false
  player.off('stateChange', this.stateChangeCallback)
  player.off('error', this.errorCallback)
  player.off('startRenderFrame', this.firstFrameCallback)
  player.off('bufferingUpdate', this.bufferingCallback)

  await player.release()
  this.currentState = 'released'
  this.player = undefined
  this.surfaceId = ''
  this.loadStartAt = 0
}

页面 aboutToDisappear() 可以触发释放,但如果播放器由应用级服务持有,就应由服务自己的引用和会话策略决定。所有权必须唯一:页面和全局服务不能同时认为“应该由我释放”。

十五、错误后选择reset还是release

进入 error 后,官方建议调用 reset()release()。选择依据是后续是否还要复用实例:

  1. 可恢复网络错误,用户准备重试:reset 后重新设置资源;
  2. 不支持格式或业务不再播放:release 释放资源;
  3. Surface 已销毁且页面退出:直接 release;
  4. 连续错误超过策略上限:停止自动重试,展示明确错误。

自动重试必须有次数、间隔和用户退出条件。无上限 reset → load → error 循环会同时消耗网络、解码和日志资源。

十六、短视频预准备必须控制播放器池

官方接口说明提到,频繁切换短视频时可创建多个 AVPlayer,提前准备下一条内容。但“多个”不等于每条视频永久一个实例。实践中应限制:当前播放、前一条候选、后一条候选,或依据设备资源设定更小池。

interface PlayerSlot {
  index: number
  session: PlayerSession
  lastUsedAt: number
}

function chooseEviction(slots: PlayerSlot[]): PlayerSlot | undefined {
  return slots
    .slice()
    .sort((a, b) => a.lastUsedAt - b.lastUsedAt)[0]
}

回收时先停止业务回调,再 release;用户快速滑动时取消过期的自动播放意图,避免“旧视频后准备完成却抢占当前 Surface”。

十七、固定条件比较起播方案

设备与系统版本:
构建类型:Release
视频URL、容器、编码、分辨率、码率:
网络条件:同一Wi-Fi或固定网络环境
是否预创建播放器:
是否预准备下一条:
请求到initialized:
请求到prepared:
请求到playing:
请求到startRenderFrame:
首帧后缓冲次数:
播放器实例峰值:
页面退出后资源是否释放:

每个方案重复多轮,区分首次播放和缓存后的播放。只比较一次最快结果,会把网络波动和系统热态误当作代码收益。

十八、发布前逐项确认

  • 网络播放已声明 INTERNET 权限;
  • stateChange、error、startRenderFrame 在设置资源前注册;
  • URL 只在 idle 设置;
  • Surface ID 首次在 initialized 设置;
  • prepare 只在 initialized 调用;
  • play 只在 prepared、paused 或 completed 调用;
  • 切源先 reset,不用固定延时猜状态;
  • 起播日志同时保留 initialized、prepared、playing 和首帧节点;
  • 缓冲历史有容量上限;
  • 错误重试有次数和退出条件;
  • 多实例预准备有固定池和回收策略;
  • 页面、服务和 Surface 的资源所有权唯一;
  • 正常退出、错误和页面销毁都能到达 release。

十九、用状态证据替代起播猜测

AVPlayer 起播优化本质上是状态机工程。先让每个操作发生在允许状态,再用 startRenderFrame 建立接近用户感受的计时终点,最后保证 reset、切源和 release 都有唯一责任方。

当 initialized、prepared、playing、首帧和缓冲被分别记录后,团队就能判断时间究竟花在网络、资源准备、解码还是 Surface,而不是用“播放器有点慢”概括所有问题。只有在这条链路稳定后,预创建和多实例预准备才值得加入。

AVPlayer资料索引

Logo

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

更多推荐