1# 在线短视频流畅切换 2 3## 简介 4为了帮助开发者解决在应用中在线短视频快速切换时容易出现快速切换播放时延过长的问题,将提供对应场景的解决方案。 5 6该解决方案使用: 7- 视频播放框架AVPlayer和滑块视图容器Swiper进行短视频滑动轮播切换。 8- 绘制组件XComponent的Surface类型动态渲染视频流。 9- 使用LazyForEach进行数据懒加载,设置cachedCount属性来指定缓存数量,同时搭配组件复用能力以达到高性能效果,(在冷启动过程中创建一个AVPlayer并进行数据初始化到prepared阶段,在轮播过程中,每次异步创建一个播放器为下一个视频播放做准备)。 10 11最终实现短视频快速切换起播时延达到≤230ms的效果。 12 13**如果开发者使用自研播放器引擎而非AVPlayer,也可以参考该解决方案思路达成最佳实践**。 14 15## 效果展示 16 17在线短视频滑动切换 18 19 20 21## 场景说明 22 23### 适用范围 24 25适用于应用中在线短视频快速切换,容易出现快速切换播放起播慢体验不佳的场景。 26 27### 场景体验指标 28 29起播时延计时标准 30 311. 以用户滑动屏幕后抬手,手指离屏时刻为起点,以视频第二帧画面显示时刻为终点(不是封面帧)。 32 332. 转场动画时长建议设置300ms。 34 353. 在动画开始时使用预先准备的播放器起播,起播时延控制在230ms内。 36 37| **描述** | **类型** | **适用设备** | **说明** | 38|----------|---------------|----------|--------| 39| 应用内滑动视频,新视频起播时延应≤230ms。 | 规则 | 手机、折叠屏、平板 | 无 | 40 41## 场景分析 42### 典型场景及优化方案 43 44**典型场景描述** 45 46短视频:以小于5分钟的短视频为例进行说明 47 481. 应用内滑动视频,新视频起播时延≤230ms(不包含滑动动画效果耗时)。 492. 起点时间:滑动离手;时间终点:视频内容开始播放,画面发生变化。 50 51**场景优化方案** 52 53AVPlayer: 54 551. 数据懒加载 56 57 在线短视频预加载,冷启动时创建第一个播放器,播放当前视频时预加载下一个播放视频,绘制组件XComponent的Surface类型将视频流进行动态渲染、使用LazyForEach进行数据懒加载,设置cachedCount属性来指定缓存数量,同时搭配组件复用能力以达到高性能效果。 582. 异步在线视频预加载 59 60 在轮播过程中,对下一个视频提前进入AVPlayer的prepared状态。 613. 在线视频播放预接力 62 63 滑动过程中手指离开屏幕,此时滑动动效开始播放,在动效开始时就可以调用AVPlayer的play方法进行播放。 64 65三方自研播放器: 66 671. 数据懒加载 68 69 在线短视频预加载,冷启动时创建第一个播放器,播放当前视频时预加载下一个播放视频,绘制组件XComponent的Surface类型将视频流进行动态渲染、使用LazyForEach进行数据懒加载,设置cachedCount属性来指定缓存数量,同时搭配组件复用能力以达到高性能效果。 702. 异步在线视频预加载 71 72 在轮播过程中,对下一个视频提前初始化播放器所需内容(视频源下载、AudioRender初始化、解码器初始化等),并对视频提前预解析首帧画面。 733. 在线视频播放预接力 74 75 滑动过程中手指离开屏幕,此时滑动动效开始播放,在动效开始时就可以调用播放引擎进行播放。 76 为了保证用户的起播体验,在前几帧画面送显时应优先送显,而不是等AudioRender写入音频数据才送显,因为音频硬件时延比显示时延大。播放起始几帧建议不要做强音画同步,而是采用慢追帧策略进行同步,视频帧稍微增大送显间隔,直到完成音画同步。 77 78## 场景实现 79 80### 场景整体介绍 81基于AVPlayer实现了在线流媒体的短视频流畅播放和控制功能。基于对应的播放器,使用滑块视图容器Swiper进行短视频滑动轮播切换、绘制组件XComponent的Surface类型将视频流进行动态渲染、懒加载,最终实现短视频快速切换,实现起播≤230ms,提供开发者解决此类问题的方案。 82 83**功能时序图** 84 85 86 87 88### 在线短视频快速切换 89**实现流程图** 90 91 92 93**关键点** 94 95**AVPlayer** 96 97AVPlayer可以将Audio/Video媒体资源(比如mp4/mp3/mkv/mpeg-ts等)转码为可供渲染的图像和可听见的音频模拟信号,并通过输出设备进行播放。 98 99**LazyForEach数据懒加载** 100 101LazyForEach数据懒加载可以通过设置cachedCount属性来指定缓存数量(目前设置为3),同时搭配组件复用能力以达到高性能效果。SurfaceID每次都会创建,不共用SurfaceID,AVPlayer也会同时创建,不共用AVPlayer,进而将提前加载好的视频(prepared阶段)放到缓存池中。 102在通过Swiper切换时,会根据当前轮询滑动的窗口索引index到缓存池中找到对应的视频(prepared阶段),直接进行播放,从而提升切换性能。 103 104 105 106**异步视频预加载** 107 108异步视频预加载:在Swiper轮播过程中,在播放当前视频时,提前加载好下一个视频,在缓存中同时存在多个播放器实例,根据视频当前的索引来确定使用缓存中的哪个播放器来播放,从而达到流畅切换的效果。 109 110(1)本地播放一个短视频的耗时。 111 112 113 114(2)播放视频A的时候,提前预加载视频B。在切换短视频时,可以马上开始播放已预加载完成的视频B,从而减少了切换时间,提高了切换性能。 115 116 117 118 119**视频播放预启动能力** 120 121为了进一步提升滑动播放体验,在动效开始时就开始播放,做到动效和播放并行进行: 122 123(1)在收到AnimationStart回调时开始播放,而不是动效结束再播放; 124 125(2)不要用默认的弹簧曲线(弹簧动效有560ms,视频窗口在400ms左右已经全面铺开了,最后150ms位移随时间变化较小),可以把curve改成Curve.Ease,duration改为300ms(视APP UX确定); 126 127视频播放预启动接力:类似于4*100接力赛,想要尽快完成接力赛,当第一个选手快到达终点时,第二个选手就提前起跑并且和第一个选手完美完成接力棒,从而减少整个接力赛过程中的时间。短视频切换也是如此,如下图所示: 128 129 130 131**关键代码片段** 132 1331. 初始化AVPlayer播放器。 134 ```typescript 135 async initAVPlayer() { 136 Logger.info(TAG, 'createAVPlayer begin'); 137 media.createAVPlayer().then((video: media.AVPlayer) => { 138 if (video !== null) { 139 this.avPlayer = video; 140 this.setAVPlayerCallback(this.avPlayer); 141 // 设置播放源,使其进入initialized状态 142 if (typeof this.curSource === 'string') { 143 this.avPlayer.url = this.curSource; 144 } else { 145 this.avPlayer.fdSrc = this.curSource; 146 } 147 Loggor.info(TAG, 'createAVPlayer success'); 148 } else { 149 Loggor.error(TAG, 'createAVPlayer fail'); 150 } 151 }).catch((error: BusinessError) => { 152 Logger.error(TAG, `AVPlayer catchCallback,error message:${error.message}`); 153 }) 154 } 155 ``` 1562. 设置业务需要的监听事件。 157 ```typescript 158 setAVPlayerCallback(avPlayer: media.avPlayer) { 159 // 用于进度条,监听进度条当前位置,刷新当前时间 160 avPlayer.on('timeUpdate', (time: number) => { 161 if (!this.isSliderMoving) { 162 this.currentTime = Math.floor(time * this.durationTime / this.duration); 163 this.currentStringTime = secondToTime(Math.floor((time / CommConstants.SECOND_TO_MS))); 164 } 165 }) 166 // 适配一多,根据屏幕尺寸的变化同步更新视频的长宽 167 avPlayer.on('videoSizeChange', (width: number, height: number) => { 168 this.viewHeight = height; 169 this.viewWidth = width; 170 this.autoVideoSize(); 171 }) 172 // 必要事件,监听播放器的错误信息 173 avPlayer.on('error', (error: BusinessError) => { 174 Logger.error(TAG, 175 `Invoke avPlayer failed, code is ${error.code},message is ${error.message}` + `---state:${avPlayer.state}`); 176 avPlayer.reset(); 177 }) 178 this.setAVPlayerStateListen(avPlayer); 179 } 180 ``` 1813. 设置状态机变化回调函数。 182 ```typescript 183 setAVPlayerStateListen(avPlayer: media.AVPlayer) { 184 avPlayer.on('stateChange', async (state: string) => { 185 switch (state) { 186 case 'idle': // 成功调用reset接口后触发该状态机上报 187 Logger.info(TAG, 'AVPlayer state idle called.' + `this.curIndex:${this.curIndex}` + `this.index:${this.index}`); 188 break; 189 case 'initialized': // avplayer 设置播放源后触发该状态上报 190 Logger.info(TAG, 191 'AVPlayer state initialized called.' + `this.curIndex:${this.curIndex}` + `this.index:${this.index}`); 192 avPlayer.surfaceId = this.surfaceID; 193 avPlayer.prepare(); 194 break; 195 case 'prepared': // prepare调用成功后上报该状态机 196 Logger.info(TAG, 197 'AVPlayer state prepared called.' + `this.curIndex:${this.curIndex}` + `this.index:${this.index}`); 198 avPlayer.audioInterruptMode = audio.InterruptMode.INDEPENDENT_MODE; // 避免同时出现两个视频的声音 199 this.flag = true; 200 avPlayer.loop = true; 201 this.duration = avPlayer.duration; 202 this.durationTime = Math.floor(this.duration / CommConstants.SECOND_TO_MS); 203 this.currentStringTime = secondToTime(this.durationTime); 204 if (this.firstFlag && this.index === 0 && this.isPageShow) { 205 avPlayer.play(); // 应用启动后的第一个视频启动播放 206 this.firstFlag = false; 207 } 208 break; 209 case 'completed': // 播放结束后触发该状态机上报 210 Logger.info(TAG, 211 'AVPlayer state completed called.' + `this.curIndex:${this.curIndex}` + `this.index:${this.index}`); 212 this.isPlaying = false; 213 break; 214 case 'playing': // play成功调用后触发该状态机上报 215 Logger.info(TAG, 216 'AVPlayer state playing called.' + `this.curIndex:${this.curIndex}` + `this.index:${this.index}` + 217 `source:${this.curSource}`); 218 this.isPlaying = true; 219 break; 220 case 'paused': // pause成功调用后触发该状态机上报 221 Logger.info(TAG, 222 'AVPlayer state paused called.' + `this.curIndex:${this.curIndex}` + `this.index:${this.index}`); 223 break; 224 case 'stopped': // stop接口成功调用后触发该状态机上报 225 Logger.info(TAG, 226 'AVPlayer state stopped called.' + `this.curIndex:${this.curIndex}` + `this.index:${this.index}`); 227 break; 228 case 'released': 229 Logger.info(TAG, 230 'AVPlayer state released called.' + `this.curIndex:${this.curIndex}` + `this.index:${this.index}`); 231 break; 232 case 'error': 233 Logger.info(TAG, 234 'AVPlayer state released called.' + `this.curIndex:${this.curIndex}` + `this.index:${this.index}`); 235 avPlayer.reset(); 236 break; 237 default: 238 Logger.info(TAG, 'AVPlayer state unknown called.' + state); 239 break; 240 } 241 }) 242 } 243 ``` 2444. 视频轮播:使用Swiper组件进行视频轮播,设置cachedCount(3)缓存视频数量。 245 246 ```typescript 247 build() { 248 Swiper(this.swiperController) { 249 LazyForEach(new MyDataSource(this.sources), (item: string, index: number) => { 250 VideoPlayer({ 251 curSource: item, 252 curIndex: this.curIndex, 253 index: index, 254 firstFlag: this.firstFlag, 255 isPageShow: this.isPageShow, 256 foldStatus: this.foldStatus 257 }) 258 }, (item: string, index: number) => JSON.stringify(item) + index) 259 } 260 .cachedCount(3) // 缓存视频数量 261 .width(CommComstants.WIDTH_FULL_PERCENT) 262 .height(CommComstants.HEIGHT_FULL_PERCENT) 263 .vertical(true) 264 .loop(true) 265 .curve(Curve.Ease) 266 .duration(CommComstants.DURATION_TIME) 267 .indicator(false) 268 .backgroundColor(Color.Black) 269 .onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => { 270 Logger.info(TAG, `onGestureSwipe index:${index}}`); 271 }) 272 .onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => { 273 this.curIndex = targetIndex; // 优化点:视频播放和动画启动同步进行,覆盖动画效果 274 }) 275 .onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => { 276 Logger.info(TAG, `onAnimationEnd index:${index}}`); 277 }) 278 } 279 ``` 280 2815. 窗口设置:设置XComponent组件用于视频流渲染,获取并设置SurfaceID,用户设置显示画面,在onLoad时异步创建并初始化AVPlayer播放器。 282 283 ```typescript 284 XComponent({ 285 id: 'XComponent', 286 type: XComponentType.SURFACE, 287 controller: this.xComponentController 288 }) 289 .width(this.XComponentWidth) 290 .height(this.XComponentHeight) 291 .onLoad(async () => { 292 this.surfaceID = this.xComponentController.getXComponentSurfaceId(); 293 this.initAVPlayer(); // 优化点:创建AVPlayer的播放器放入到缓存池中,不可共用播放器。 294 }) 295 ``` 296 2976. 视频播放设置:监听Swiper轮播的this.curIndex值,在视频缓存流中跟this.index进行比较,从而判断视频流中哪个播放,其余的均暂停。 298 299 ```typescript 300 onIndexChange() { 301 if (this.curIndex !== this.index) { 302 pauseVideo(this.avPlayer, this.curIndex, this.index); 303 this.isPlaying = false; 304 this.trackThicknessSize = CommConstants.TRACK_SIZE_MIN; 305 } else { 306 if (this.flag === true) { 307 playVideo(this.avPlayer, this.curIndex, this.index); 308 this.isPlaying = true; 309 this.trackThicknessSize = CommConstants.TRACK_SIZE_MIN; 310 } else { 311 let countNum = 0; 312 let interValFlag = setInterval(() => { 313 countNum++; 314 // 此处有必要再次判断索引,否则会出现索引错乱导致播放异常 315 if (this.curIndex !== this.index) { 316 clearInterval(interValFlag); 317 } 318 if (this.flag === true && this.isPageShow) { 319 countNum = 0; 320 playVideo(this.avPlayer, this.curIndex, this.index); 321 this.isPlaying = true; 322 this.trackThicknessSize = CommConstants.TRACK_SIZE_MIN; 323 clearInterval(interValFlag); 324 } else { 325 if (countNum > 15) { 326 countNum = 0; 327 this.initAVPlayer(); 328 } 329 } 330 }, 100); 331 } 332 } 333 } 334 ``` 335 3367. 设置AVPlayer监听关闭并释放资源。 337 338 ```typescript 339 export function releaseVideo(avPlayer: media.AVPlayer | undefined, curIndex: number, index: number) { 340 if (avPlayer) { 341 Logger.info(TAG, 'releaseVideo:' + `state:${avPlayer.state}` + `curIndex:${curIndex},index:${index}`); 342 avPlayer.off('timeUpdate'); 343 avPlayer.off('seekDone'); 344 avPlayer.off('speedDone'); 345 avPlayer.off('error'); 346 avPlayer.off('stateChange'); 347 avPlayer.release(); 348 } 349 } 350 aboutToDisappear(): void { 351 releaseVideo(this.avPlayer, this.curIndex, this.index); 352 } 353 ``` 354 355## 参考文档 356 357[使用AVPlayer开发音频播放功能(ArkTS)](../media/media/using-avplayer-for-playback.md) 358 359[LazyForEach:数据懒加载](../quick-start/arkts-rendering-control-lazyforeach.md) 360 361[容器组件:Swiper](../reference/apis-arkui/arkui-ts/ts-container-swiper.md) 362 363[基础组件:Slider](../reference/apis-arkui/arkui-ts/ts-basic-components-slider.md)