概述

为了帮助开发者解决在应用中在线短视频快速切换时容易出现快速切换播放时延过长的问题,将提供对应场景的解决方案。

该解决方案使用:

  • 视频播放框架AVPlayer和滑块视图容器Swiper进行短视频滑动轮播切换。
  • 绘制组件XComponent的Surface类型动态渲染视频流。
  • 使用LazyForEach进行数据懒加载,设置cachedCount属性来指定缓存数量,同时搭配组件复用能力以达到高性能效果,(在冷启动过程中创建一个AVPlayer并进行数据初始化到prepared阶段,在轮播过程中,每次异步创建一个播放器为下一个视频播放做准备)。

最终实现短视频快速切换起播时延达到≤230ms的效果。

说明

如果开发者使用自研播放器引擎而非AVPlayer,也可以参考该解决方案思路实现优化。

效果展示

图1 在线短视频滑动切换效果图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

场景说明

适用范围

适用于应用中在线短视频快速切换,容易出现快速切换播放起播慢体验不佳的场景。

场景体验指标

起播时延计时标准

1、以用户滑动屏幕后抬手,手指离屏时刻为起点,以视频第二帧画面显示时刻为终点(不是封面帧)。

2、转场动画时长建议设置300ms。

3、在动画开始时使用预先准备的播放器起播,起播时延控制在230ms内。

描述 应用内滑动视频,新视频起播时延应≤230ms。
类型 规则
适用设备 手机、折叠屏、平板
说明

场景分析

典型场景及优化方案

典型场景描述

短视频:以小于5分钟的短视频为例进行说明

  1. 应用内滑动视频,新视频起播时延≤230ms(不包含滑动动画效果耗时)。
  2. 起点时间:滑动离手;时间终点:视频内容开始播放,画面发生变化。

场景优化方案

AVPlayer:

  1. 数据懒加载

    在线短视频预加载,冷启动时创建第一个播放器,播放当前视频时预加载下一个播放视频(预加载下一个视频的时候会使用户的流量消耗增加,需要开发者自行决策),绘制组件XComponent的Surface类型将视频流进行动态渲染、使用LazyForEach进行数据懒加载,设置cachedCount属性来指定缓存数量,同时搭配组件复用能力以达到高性能效果。

  2. 异步在线视频预加载

    在轮播过程中,对下一个视频提前进入AVPlayer的prepared状态。

  3. 在线视频播放预接力

    滑动过程中手指离开屏幕,此时滑动动效开始播放,在动效开始时就可以调用AVPlayer的play方法进行播放。

三方自研播放器:

  1. 数据懒加载

    在线短视频预加载,冷启动时创建第一个播放器,播放当前视频时预加载下一个播放视频(预加载下一个视频的时候会使用户的流量消耗增加,需要开发者自行决策),绘制组件XComponent的Surface类型将视频流进行动态渲染、使用LazyForEach进行数据懒加载,设置cachedCount属性来指定缓存数量,同时搭配组件复用能力以达到高性能效果。

  2. 异步在线视频预加载

    在轮播过程中,对下一个视频提前初始化播放器所需内容(视频源下载、AudioRender初始化、解码器初始化等),并对视频提前预解析首帧画面。

  3. 在线视频播放预接力

    滑动过程中手指离开屏幕,此时滑动动效开始播放,在动效开始时就可以调用播放引擎进行播放。 为了保证用户的起播体验,在前几帧画面送显时应优先送显,而不是等AudioRender写入音频数据才送显,因为音频硬件时延比显示时延大。播放起始几帧建议不要做强音画同步,而是采用慢追帧策略进行同步,视频帧稍微增大送显间隔,直到完成音画同步。

场景实现

场景整体介绍

基于AVPlayer实现了在线流媒体的短视频流畅播放和控制功能。基于对应的播放器,使用滑块视图容器Swiper进行短视频滑动轮播切换、绘制组件XComponent的Surface类型将视频流进行动态渲染、懒加载,最终实现短视频快速切换,实现起播≤230ms,提供开发者解决此类问题的方案。

图2 功能时序图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在线短视频快速切换

图3 实现流程图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

关键点

AVPlayer

AVPlayer可以将Audio/Video媒体资源(比如mp4/mp3/mkv/mpeg-ts等)转码为可供渲染的图像和可听见的音频模拟信号,并通过输出设备进行播放。

LazyForEach数据懒加载

LazyForEach懒加载可以通过设置cachedCount属性来指定缓存数量(目前设置为3),同时搭配组件复用能力以达到高性能效果。

SurfaceID每次都会创建,不共用SurfaceID,AVPlayer也会同时创建, 不共用AVPlayer,进而将提前加载好的视频(prepared阶段)放到缓存池中。

在通过Swiper切换时,会根据当前轮询滑动的窗口索引index到缓存池中找到对应的视频(prepared阶段),直接进行播放,从而能提高切换性能。

图4 视频懒加载示意图
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

异步视频预加载

异步视频预加载:在Swiper轮播过程中,在播放当前视频时,提前加载好下一个视频,在缓存中同时存在多个播放器实例,根据视频当前的索引来确定使用缓存中的哪个播放器来播放,从而达到流畅切换的效果。

(1)本地播放一个短视频的耗时。

图5 单视频加载示意图
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

(2)播放视频A的时候,提前预加载视频B。在切换短视频时,可以马上开始播放已预加载完成的视频B,从而减少了切换时间,提高了切换性能。

图6 异步视频预加载示意图
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

视频播放预启动接力

为了进一步提升滑动播放体验,在动效开始时就开始播放,做到动效和播放并行进行:

(1)在收到AnimationStart回调时开始播放,而不是动效结束再播放;

(2)不要用默认的弹簧曲线(弹簧动效有560ms,视频窗口在400ms左右已经完全铺开了,最后150ms位移随时间变化较小),可以把curve改成Curve.Ease, duration改成300(视APP UX确定);

视频播放预启动接力:类似于4*100接力赛,想要尽快完成接力赛,当第一个选手快到达终点时,第二个选手就提前起跑并且和第一个选手完美完成接力棒,从而减少整个接力赛过程中的时间。短视频切换也是如此,如下图所示:

图7 视频播放预启动接力 示意图
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

关键代码片段

  1. 初始化AVPlayer播放器。
async initAVPlayer() {
  hilog.info(0x0000, TAG, `createAVPlayer begin. curIndex: ${this.curIndex}, index: ${this.index}.`);
  media.createAVPlayer().then((video: media.AVPlayer) => {
    if (video === undefined) {
      hilog.error(0x0000, TAG, `createAVPlayer fail.`);
      return;
    }
    this.avPlayer = video;
    this.setAVPlayerCallback(this.avPlayer);
    // set url into state initialized.
    if (typeof this.curSource === 'string') {
      this.avPlayer.url = this.curSource;
      hilog.info(0x0000, TAG,
        `AVPlayer state idle. source: ${this.curSource}, curIndex: ${this.curIndex}, index: ${this.index}.`);
    } else {
      this.avPlayer.fdSrc = this.curSource;
    }
    hilog.info(0x0000, TAG, `createAVPlayer success. curIndex: ${this.curIndex}, index: ${this.index}.`);
  })
}
  1. 设置业务需要的监听事件。
setAVPlayerCallback(avPlayer: media.AVPlayer) {
  avPlayer.on('timeUpdate', (time: number) => {
    if (!this.isSliderMoving) {
      this.currentTime = Math.floor(time * this.durationTime / this.duration);
      this.currentStringTime = secondToTime(Math.floor(time / 1000));
    }
  })

  avPlayer.on('videoSizeChange', (width: number, height: number) => {
    this.videoHeight = height;
    this.videoWidth = width;
    this.autoVideoSize();
  })

  //error listen, when the avPlayer is error, to load reset interface
  avPlayer.on('error', (err: BusinessError) => {
    hilog.error(0x0000, TAG, `Invoke avPlayer failed, code is ${err.code}, message is ${err.message}.` +
      `----state: ${avPlayer.state}, curIndex: ${this.curIndex}, index: ${this.index}.`);
    avPlayer.reset();
  })

  this.setAVPlayerStateListen(avPlayer);
}
  1. 设置状态机变化回调函数。
setAVPlayerStateListen(avPlayer: media.AVPlayer) {
  avPlayer.on('stateChange', async (state: string) => {
    switch (state) {
      case 'idle':
        hilog.info(0x0000, TAG, `AVPlayer state idle called. curIndex: ${this.curIndex}, index: ${this.index}.`);
        break;
      case 'initialized':
        hilog.info(0x0000, TAG,
          `AVPlayer state initialized called. curIndex: ${this.curIndex}, index: ${this.index}.`);
        avPlayer.surfaceId = this.surfaceID;
        avPlayer.prepare();
        break;
      case 'prepared':
        hilog.info(0x0000, TAG, `AVPlayer state prepared called. curIndex: ${this.curIndex}, index: ${this.index}.`);
        avPlayer.audioInterruptMode = audio.InterruptMode.INDEPENDENT_MODE;
        this.flag = true;
        avPlayer.loop = true;
        this.duration = avPlayer.duration;
        this.durationTime = Math.floor(this.duration / 1000);
        this.durationStringTime = secondToTime(this.durationTime);
        if (this.firstFlag && this.index === 0 && this.isPageShow) {
          avPlayer.play();
          this.firstFlag = false;
        }
        break;
      case 'playing':
        hilog.info(0x0000, TAG,
          `AVPlayer state playing called. curIndex: ${this.curIndex}, index: ${this.index},
          source: ${this.curSource}.`);
        this.isPlaying = true;
        break;
      case 'paused':
        hilog.info(0x0000, TAG, `AVPlayer state paused called. curIndex: ${this.curIndex}, index: ${this.index}.`);
        break;
      case 'stopped':
        hilog.info(0x0000, TAG, `AVPlayer state stopped called. curIndex: ${this.curIndex}, index: ${this.index}.`);
        break;
      case 'completed':
        hilog.info(0x0000, TAG, `AVPlayer state completed called. curIndex: ${this.curIndex}, index: ${this.index}.`);
        this.isPlaying = false;
        break;
      case 'released':
        hilog.info(0x0000, TAG, `AVPlayer state released called. curIndex: ${this.curIndex}, index: ${this.index}.`);
        break;
      case 'error':
        hilog.error(0x0000, TAG, `AVPlayer state error called. curIndex: ${this.curIndex}, index: ${this.index}.`);
        avPlayer.reset();
        break;
      default:
        hilog.error(0x0000, TAG, `AVPlayer state unknown called. curIndex: ${this.curIndex}, index: ${this.index}.`);
        break;
    }
  })
}
  1. 视频轮播:使用Swiper组件进行视频轮播,设置cachedCount(2)缓存视频数量。
Swiper(this.swiperController) {
  LazyForEach(new AVDataSource(Const.VIDEO_SOURCE), (item: string, index: number) => {
    VideoPlayView({
      curSource: item,
      curIndex: this.curIndex,
      index: index,
      firstFlag: this.firstFlag,
      isPageShow: this.isPageShow,
      foldStatus: this.foldStatus
    })
  }, (item: string, index: number) => JSON.stringify(item) + index)
}
.cachedCount(this.firstFlag ? 0 : 2)
.width('100%')
.height('100%')
.vertical(true)
.loop(true)
.curve(Curve.Ease)
.duration(300)
.indicator(false)
.backgroundColor(Color.Black)
.onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {
  hilog.info(0x0000, TAG, `onGestureSwipe index: ${index}, extraInfo: ${extraInfo}.`);
})
.onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => {
  hilog.info(0x0000, TAG,
    `onAnimationStart index: ${index}, targetIndex: ${targetIndex}, extraInfo: ${extraInfo}.`);
  this.curIndex = targetIndex;
})
.onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => {
  hilog.info(0x0000, TAG, `onAnimationEnd index: ${index}, extraInfo: ${extraInfo}.`);
})
  1. 窗口设置:设置XComponent组件用于视频流渲染,获取并设置SurfaceID,用于设置显示画面,在onLoad时异步创建并初始化AVPlayer播放器。
XComponent({
  id: 'player',
  type: XComponentType.SURFACE,
  controller: this.xComponentController
})
  .width(this.XComponentWidth)
  .height(this.XComponentHeight)
  .onLoad(async () => {
    this.surfaceID = this.xComponentController.getXComponentSurfaceId();
    hilog.info(0x0000, TAG,
      `surfaceID: ${this.surfaceID}, curIndex: ${this.curIndex}, index: ${this.index}.`);
    this.initAVPlayer();
  })
  1. 视频播放设置:监听Swiper轮播的this.curIndex值,在视频缓存流中跟this.index进行比较,从而判断视频流中哪个播放,其余的均暂停。
onIndexChange() {
  hilog.info(0x0000, TAG,
    `enter onIndexChange. curIndex: ${this.curIndex}, index: ${this.index}, isPageShow: ${this.isPageShow}.`);
  if (this.curIndex !== this.index) {
    pauseVideo(this.avPlayer, this.curIndex, this.index);
    this.isPlaying = false;
    this.trackThicknessSize = Const.TRACK_SIZE_MIN;
  } else {
    hilog.info(0x0000, TAG,
      `enter indexChange play. curIndex: ${this.curIndex}, index: ${this.index}, isPageShow: ${this.isPageShow}.`);
    if (this.flag === true) {
      playVideo(this.avPlayer, this.curIndex, this.index);
      this.isPlaying = true;
      this.trackThicknessSize = Const.TRACK_SIZE_MIN;
    } else {
      let countNum = 0;
      let intervalFlag = setInterval(() => {
        countNum++;
        if (this.curIndex !== this.index) {
          hilog.info(0x0000, TAG, `enter indexChange play error, clearIntreval. flag: ${this.flag},
          curIndex: ${this.curIndex}, index: ${this.index}.`);
          clearInterval(intervalFlag);
        }
        if (this.flag === true && this.isPageShow) {
          countNum = 0;
          playVideo(this.avPlayer, this.curIndex, this.index);
          this.isPlaying = true;
          this.trackThicknessSize = Const.TRACK_SIZE_MIN;
          clearInterval(intervalFlag);
        } else {
          hilog.info(0x0000, TAG, `enter indexChange play error, clearIntreval. countNum: ${countNum},
           flag: ${this.flag}, curIndex: ${this.curIndex}, index: ${this.index}.`);
          if (countNum > 15) {
            hilog.info(0x0000, TAG,
              `enter indexChange play error, reinit initAVPlayer. countNum: ${countNum}, flag: ${this.flag},
              curIndex: ${this.curIndex}, index: ${this.index}.`);
            countNum = 0;
            this.initAVPlayer();
          }
        }
      }, 100);
    }
  }
}
  1. 设置AVPlayer监听关闭并释放资源。
export function releaseVideo(avPlayer: media.AVPlayer | undefined, curIndex: number, index: number) {
  if (avPlayer === undefined) {
    hilog.error(0x0000, TAG, `releaseVideo error! avPlayer undefined! curIndex: ${curIndex}, index: ${index}.`);
    return;
  }
  hilog.info(0x0000, TAG, `releaseVideo. state:${avPlayer.state}, curIndex: ${curIndex}, index: ${index}.`);
  avPlayer.off('timeUpdate');
  avPlayer.off('seekDone');
  avPlayer.off('speedDone');
  avPlayer.off('error');
  avPlayer.off('stateChange');
  avPlayer.release();
}

总结

本文介绍了数据懒加载、异步在线视频预加载以及在线视频播放预接力等优化方案,可以帮助开发者解决快速切换播放时延过长的问题。

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐