HarmonyOS开发中的视频播放:从AVPlayer到播放器封装的全链路实战
HarmonyOS开发中的视频播放:从AVPlayer到播放器封装的全链路实战
核心要点:掌握HarmonyOS视频播放的核心API——AVPlayer与Video组件,理解播放状态机的流转逻辑,学会封装一个生产级可复用的视频播放器。
一、背景与动机
你有没有这样的经历——周末窝在沙发上刷短视频,手指一划,视频"嗖"地就开始播放了,丝滑得像抹了黄油。但当你自己动手写一个视频播放器时,才发现事情远没那么简单:视频加载不出来、播放到一半突然黑屏、切后台回来声音还在播画面却卡住了……
视频播放,看似简单,实则暗藏玄机。它涉及媒体文件的解封装、解码、渲染、音频输出等多个环节,任何一个环节出问题,用户体验就会"翻车"。
HarmonyOS为我们提供了两套视频播放方案:底层AVPlayer和高层Video组件。前者灵活强大,适合做定制化播放器;后者开箱即用,适合快速集成。但无论选哪个,理解播放状态机都是绕不过去的坎儿。
今天这篇文章,我们就从零开始,把视频播放这件事彻底搞明白。
二、核心原理
2.1 视频播放的技术链路
一段视频从文件到屏幕,要经历这样的旅程:
flowchart LR
classDef primary fill:#4FC3F7,stroke:#0288D1,color:#000
classDef warning fill:#FFB74D,stroke:#F57C00,color:#000
classDef error fill:#EF5350,stroke:#C62828,color:#fff
classDef info fill:#81C784,stroke:#388E3C,color:#000
classDef purple fill:#CE93D8,stroke:#7B1FA2,color:#000
A[视频文件/流]:::primary --> B[解封装 Demux]:::warning
B --> C[视频解码 Decode]:::error
B --> D[音频解码 Decode]:::error
C --> E[视频渲染 Surface]:::info
D --> F[音频输出 AudioRenderer]:::purple
E --> G[屏幕显示]:::primary
F --> H[扬声器/耳机]:::primary
简单来说:解封装把MP4/MKV等容器格式拆成视频流和音频流,解码把压缩的数据还原成原始画面和声音,渲染和输出则把画面送到屏幕、声音送到扬声器。
2.2 AVPlayer播放状态机
AVPlayer是HarmonyOS提供的底层播放器,它的核心是状态机。理解状态机,就理解了播放器的"灵魂"。
stateDiagram-v2
classDef primaryStyle fill:#4FC3F7,stroke:#0288D1,color:#000
classDef warningStyle fill:#FFB74D,stroke:#F57C00,color:#000
classDef errorStyle fill:#EF5350,stroke:#C62828,color:#fff
classDef infoStyle fill:#81C784,stroke:#388E3C,color:#000
classDef purpleStyle fill:#CE93D8,stroke:#7B1FA2,color:#000
[*] --> idle : 创建AVPlayer
idle --> initialized : setSource()
initialized --> prepared : prepare()
prepared --> playing : play()
prepared --> paused : pause()
playing --> paused : pause()
paused --> playing : play()
playing --> completed : 自然播完
completed --> playing : play() 重播
completed --> stopped : stop()
prepared --> stopped : stop()
playing --> stopped : stop()
paused --> stopped : stop()
stopped --> prepared : prepare() 重新准备
stopped --> idle : reset()
initialized --> idle : reset()
any --> error : 出错
error --> idle : reset()
关键状态说明:
| 状态 | 含义 | 可执行操作 |
|---|---|---|
| idle | 空闲,刚创建 | setSource() |
| initialized | 已设置数据源 | prepare() |
| prepared | 准备就绪 | play()、pause()、stop()、seek() |
| playing | 播放中 | pause()、stop()、seek() |
| paused | 暂停 | play()、stop()、seek() |
| completed | 播放完成 | play()(重播)、stop()、seek() |
| stopped | 停止 | prepare()、reset() |
| error | 错误 | reset() |
⚠️ 核心规则:状态机是严格的,不能跳步!你必须从idle → initialized → prepared → playing,不能直接从idle跳到playing。违反状态转换规则会导致错误或无效操作。
2.3 Video组件 vs AVPlayer
| 特性 | Video组件 | AVPlayer |
|---|---|---|
| 抽象层级 | 高层UI组件 | 底层媒体引擎 |
| 使用难度 | 简单,声明式 | 较复杂,需管理状态机 |
| 自定义能力 | 有限 | 完全可控 |
| Surface管理 | 自动 | 需手动绑定XComponent |
| 适用场景 | 快速集成、简单播放 | 自定义播放器、直播、画中画 |
| 性能控制 | 较弱 | 精细可控 |
三、代码实战
3.1 基础实战:使用Video组件快速播放视频
Video组件是最简单的视频播放方案,适合快速集成场景:
// VideoComponentDemo.ets
// 使用Video组件实现基础视频播放
@Entry
@Component
struct VideoComponentDemo {
// 视频控制器,用于控制播放、暂停、进度跳转
private videoController: VideoController = new VideoController()
// 播放状态
@State isPlaying: boolean = false
@State currentTime: number = 0
@State duration: number = 0
build() {
Column() {
// 视频播放区域
Video({
src: $rawfile('sample.mp4'), // rawfile目录下的视频文件
controller: this.videoController
})
.width('100%')
.height(280)
.autoPlay(false) // 不自动播放,等用户点击
.muted(false) // 不静音
.loop(false) // 不循环播放
.controls(true) // 显示默认控制栏
.objectFit(ImageFit.Contain) // 视频缩放模式
.onPrepared(() => {
console.info('[Video] 准备完成,可以播放了')
})
.onPlaying(() => {
this.isPlaying = true
console.info('[Video] 开始播放')
})
.onPause(() => {
this.isPlaying = false
console.info('[Video] 暂停播放')
})
.onFinish(() => {
this.isPlaying = false
console.info('[Video] 播放完成')
})
.onError(() => {
console.error('[Video] 播放出错')
})
.onTimeUpdate((time: number) => {
this.currentTime = time // 更新当前播放进度
})
.onDurationChange((duration: number) => {
this.duration = duration // 获取视频总时长
})
// 自定义控制按钮
Row() {
Button(this.isPlaying ? '暂停' : '播放')
.onClick(() => {
if (this.isPlaying) {
this.videoController.pause()
} else {
this.videoController.start()
}
})
Button('快进10s')
.onClick(() => {
// 跳转到当前进度+10秒的位置
this.videoController.setCurrentTime(this.currentTime + 10000)
})
Button('从头播放')
.onClick(() => {
this.videoController.setCurrentTime(0)
this.videoController.start()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.padding(16)
// 进度显示
Text(`播放进度: ${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}`)
.fontSize(14)
.fontColor('#999999')
.margin({ top: 8 })
}
.width('100%')
.height('100%')
.backgroundColor('#1a1a2e')
}
// 格式化时间(毫秒 → mm:ss)
private formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
}
3.2 进阶实战:使用AVPlayer构建可控播放器
AVPlayer提供了更底层的控制能力,适合需要深度定制的场景:
// AVPlayerDemo.ets
// 使用AVPlayer实现完全可控的视频播放器
import { media } from '@kit.MediaKit'
import { common } from '@kit.AbilityKit'
@Entry
@Component
struct AVPlayerDemo {
// AVPlayer实例
private avPlayer: media.AVPlayer | null = null
// XComponent控制器,用于获取Surface
private xComponentController: XComponentController = new XComponentController()
// 播放状态
@State playerState: string = 'idle'
@State isPlaying: boolean = false
@State currentTime: number = 0
@State duration: number = 0
@State volume: number = 1.0
@State speed: number = 1.0
@State errorMessage: string = ''
// 播放速度选项
private speedOptions: number[] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
aboutToAppear() {
this.initAVPlayer()
}
aboutToDisappear() {
this.releasePlayer()
}
// 初始化AVPlayer
private async initAVPlayer() {
try {
// 第一步:创建AVPlayer实例
this.avPlayer = await media.createAVPlayer()
// 第二步:监听状态变化(核心!)
this.avPlayer.on('stateChange', (state: string) => {
this.playerState = state
console.info(`[AVPlayer] 状态变化: ${state}`)
switch (state) {
case 'initialized':
// initialized状态下需要设置Surface,然后prepare
this.setSurfaceAndPrepare()
break
case 'prepared':
// 准备完成,可以获取duration等信息
console.info('[AVPlayer] 准备完成')
break
case 'completed':
this.isPlaying = false
console.info('[AVPlayer] 播放完成')
break
case 'stopped':
this.isPlaying = false
console.info('[AVPlayer] 已停止')
break
}
})
// 第三步:监听其他事件
this.avPlayer.on('error', (err) => {
this.errorMessage = `播放错误: ${err.message}`
console.error(`[AVPlayer] 错误: ${JSON.stringify(err)}`)
})
this.avPlayer.on('timeUpdate', (time: number) => {
this.currentTime = time
})
this.avPlayer.on('durationUpdate', (duration: number) => {
this.duration = duration
})
// 第四步:设置数据源
const context = getContext(this) as common.UIAbilityContext
const fdPath = `fd://1` // 实际项目中需要通过fs.open获取fd
// 也可以用rawfile路径
const rawfilePath = context.resourceDir + '/rawfile/sample.mp4'
this.avPlayer.url = rawfilePath
} catch (err) {
this.errorMessage = `初始化失败: ${err}`
console.error(`[AVPlayer] 初始化失败: ${err}`)
}
}
// 设置Surface并准备播放
private async setSurfaceAndPrepare() {
if (!this.avPlayer) return
try {
// 从XComponent获取Surface ID
const surfaceId = this.xComponentController.getXComponentSurfaceId()
this.avPlayer.surfaceId = surfaceId
// 调用prepare进入prepared状态
await this.avPlayer.prepare()
} catch (err) {
console.error(`[AVPlayer] 准备失败: ${err}`)
}
}
// 释放播放器资源
private async releasePlayer() {
if (this.avPlayer) {
try {
await this.avPlayer.release()
this.avPlayer = null
console.info('[AVPlayer] 资源已释放')
} catch (err) {
console.error(`[AVPlayer] 释放失败: ${err}`)
}
}
}
build() {
Column() {
// 视频渲染区域 - 使用XComponent提供Surface
XComponent({
id: 'videoSurface',
type: XComponentType.SURFACE,
controller: this.xComponentController
})
.width('100%')
.height(280)
.onLoad(() => {
console.info('[XComponent] Surface加载完成')
})
// 状态显示
Text(`当前状态: ${this.playerState}`)
.fontSize(12)
.fontColor('#aaaaaa')
.margin({ top: 8 })
// 错误提示
if (this.errorMessage) {
Text(this.errorMessage)
.fontSize(12)
.fontColor('#EF5350')
.margin({ top: 4 })
}
// 播放控制按钮
Row() {
Button('播放')
.enabled(this.playerState === 'prepared' || this.playerState === 'paused' || this.playerState === 'completed')
.onClick(() => {
this.avPlayer?.play()
this.isPlaying = true
})
Button('暂停')
.enabled(this.playerState === 'playing')
.onClick(() => {
this.avPlayer?.pause()
this.isPlaying = false
})
Button('停止')
.enabled(this.playerState === 'playing' || this.playerState === 'paused')
.onClick(() => {
this.avPlayer?.stop()
})
Button('重置')
.enabled(this.playerState === 'stopped' || this.playerState === 'error')
.onClick(() => {
this.avPlayer?.reset()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.padding(16)
// 进度条
Row() {
Text(this.formatTime(this.currentTime))
.fontSize(12)
.fontColor('#ffffff')
Slider({
value: this.currentTime,
min: 0,
max: this.duration > 0 ? this.duration : 1,
step: 1000
})
.width('60%')
.onChange((value: number) => {
if (this.avPlayer && this.playerState === 'playing') {
this.avPlayer.seek(value)
}
})
Text(this.formatTime(this.duration))
.fontSize(12)
.fontColor('#ffffff')
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ left: 16, right: 16 })
// 音量控制
Row() {
Text('音量:')
.fontSize(14)
.fontColor('#ffffff')
Slider({
value: this.volume * 100,
min: 0,
max: 100
})
.width('50%')
.onChange((value: number) => {
this.volume = value / 100
if (this.avPlayer) {
this.avPlayer.setVolume(this.volume)
}
})
Text(`${Math.round(this.volume * 100)}%`)
.fontSize(14)
.fontColor('#ffffff')
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding(16)
// 播放速度选择
Row() {
Text('倍速:')
.fontSize(14)
.fontColor('#ffffff')
ForEach(this.speedOptions, (speed: number) => {
Button(`${speed}x`)
.fontSize(12)
.height(32)
.backgroundColor(this.speed === speed ? '#4FC3F7' : '#333333')
.onClick(() => {
this.speed = speed
if (this.avPlayer) {
this.avPlayer.setSpeed(speed as media.PlaybackSpeed)
}
})
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.padding(16)
}
.width('100%')
.height('100%')
.backgroundColor('#1a1a2e')
}
// 格式化时间
private formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
}
3.3 高级实战:封装生产级视频播放器组件
在实际项目中,我们不可能每次都从头写播放逻辑。封装一个可复用的播放器组件是必须的:
// VideoPlayerKit.ets
// 生产级视频播放器封装组件
import { media } from '@kit.MediaKit'
import { common } from '@kit.AbilityKit'
// 播放器状态枚举
export enum PlayerState {
IDLE = 'idle',
INITIALIZING = 'initializing',
INITIALIZED = 'initialized',
PREPARING = 'preparing',
PREPARED = 'prepared',
PLAYING = 'playing',
PAUSED = 'paused',
COMPLETED = 'completed',
STOPPED = 'stopped',
RELEASED = 'released',
ERROR = 'error'
}
// 播放器配置接口
export interface VideoPlayerConfig {
autoPlay?: boolean // 是否自动播放,默认false
loop?: boolean // 是否循环播放,默认false
muted?: boolean // 是否静音,默认false
volume?: number // 音量 0~1,默认1
speed?: number // 播放速度,默认1
startTime?: number // 起始播放位置(毫秒),默认0
}
// 播放器事件回调接口
export interface VideoPlayerCallbacks {
onStateChange?: (state: PlayerState) => void
onTimeUpdate?: (currentTime: number) => void
onDurationUpdate?: (duration: number) => void
onError?: (error: string) => void
onBufferingUpdate?: (percent: number) => void
onCompletion?: () => void
}
// 封装的视频播放器管理类
export class VideoPlayerManager {
private avPlayer: media.AVPlayer | null = null
private state: PlayerState = PlayerState.IDLE
private config: VideoPlayerConfig = {}
private callbacks: VideoPlayerCallbacks = {}
private surfaceId: string = ''
private _duration: number = 0
private _currentTime: number = 0
// 获取当前状态
get currentState(): PlayerState {
return this.state
}
// 获取总时长
get duration(): number {
return this._duration
}
// 获取当前播放时间
get currentTime(): number {
return this._currentTime
}
// 设置事件回调
setCallbacks(callbacks: VideoPlayerCallbacks) {
this.callbacks = callbacks
}
// 初始化播放器
async init(surfaceId: string, config?: VideoPlayerConfig): Promise<void> {
this.surfaceId = surfaceId
this.config = { autoPlay: false, loop: false, muted: false, volume: 1, speed: 1, ...config }
try {
this.updateState(PlayerState.INITIALIZING)
this.avPlayer = await media.createAVPlayer()
this.registerListeners()
this.updateState(PlayerState.IDLE)
} catch (err) {
this.updateState(PlayerState.ERROR)
this.callbacks.onError?.(`初始化失败: ${err}`)
}
}
// 设置数据源并准备
async setSourceAndPrepare(url: string): Promise<void> {
if (!this.avPlayer) {
this.callbacks.onError?.('播放器未初始化')
return
}
try {
// 设置数据源
this.avPlayer.url = url
// 注意:stateChange回调中会处理后续流程
} catch (err) {
this.updateState(PlayerState.ERROR)
this.callbacks.onError?.(`设置数据源失败: ${err}`)
}
}
// 播放
async play(): Promise<void> {
if (!this.avPlayer) return
try {
await this.avPlayer.play()
} catch (err) {
this.callbacks.onError?.(`播放失败: ${err}`)
}
}
// 暂停
async pause(): Promise<void> {
if (!this.avPlayer) return
try {
await this.avPlayer.pause()
} catch (err) {
this.callbacks.onError?.(`暂停失败: ${err}`)
}
}
// 跳转
async seek(timeMs: number): Promise<void> {
if (!this.avPlayer) return
try {
await this.avPlayer.seek(timeMs, media.SeekMode.SEEK_PREV_SYNC)
} catch (err) {
this.callbacks.onError?.(`跳转失败: ${err}`)
}
}
// 停止
async stop(): Promise<void> {
if (!this.avPlayer) return
try {
await this.avPlayer.stop()
} catch (err) {
this.callbacks.onError?.(`停止失败: ${err}`)
}
}
// 设置音量
async setVolume(volume: number): Promise<void> {
if (!this.avPlayer) return
try {
await this.avPlayer.setVolume(Math.max(0, Math.min(1, volume)))
} catch (err) {
this.callbacks.onError?.(`设置音量失败: ${err}`)
}
}
// 设置播放速度
async setSpeed(speed: number): Promise<void> {
if (!this.avPlayer) return
try {
await this.avPlayer.setSpeed(speed as media.PlaybackSpeed)
} catch (err) {
this.callbacks.onError?.(`设置速度失败: ${err}`)
}
}
// 释放资源
async release(): Promise<void> {
if (!this.avPlayer) return
try {
await this.avPlayer.release()
this.avPlayer = null
this.updateState(PlayerState.RELEASED)
} catch (err) {
this.callbacks.onError?.(`释放失败: ${err}`)
}
}
// 注册AVPlayer事件监听
private registerListeners() {
if (!this.avPlayer) return
// 状态变化监听(核心)
this.avPlayer.on('stateChange', async (state: string) => {
console.info(`[VideoPlayerManager] 状态: ${state}`)
switch (state) {
case 'initialized':
// 设置Surface并准备
if (this.avPlayer && this.surfaceId) {
this.avPlayer.surfaceId = this.surfaceId
await this.avPlayer.prepare()
}
break
case 'prepared':
this.updateState(PlayerState.PREPARED)
// 应用配置
this.applyConfig()
// 自动播放
if (this.config.autoPlay) {
await this.avPlayer?.play()
}
break
case 'playing':
this.updateState(PlayerState.PLAYING)
break
case 'paused':
this.updateState(PlayerState.PAUSED)
break
case 'completed':
this.updateState(PlayerState.COMPLETED)
// 循环播放
if (this.config.loop && this.avPlayer) {
await this.avPlayer.seek(0)
await this.avPlayer.play()
} else {
this.callbacks.onCompletion?.()
}
break
case 'stopped':
this.updateState(PlayerState.STOPPED)
break
case 'error':
this.updateState(PlayerState.ERROR)
break
}
})
// 时间更新
this.avPlayer.on('timeUpdate', (time: number) => {
this._currentTime = time
this.callbacks.onTimeUpdate?.(time)
})
// 时长更新
this.avPlayer.on('durationUpdate', (duration: number) => {
this._duration = duration
this.callbacks.onDurationUpdate?.(duration)
})
// 错误
this.avPlayer.on('error', (err) => {
this.updateState(PlayerState.ERROR)
this.callbacks.onError?.(err.message || '未知播放错误')
})
// 缓冲更新
this.avPlayer.on('bufferingUpdate', (infoType: media.BufferingInfoType, value: number) => {
if (infoType === media.BufferingInfoType.BUFFERING_PERCENT) {
this.callbacks.onBufferingUpdate?.(value)
}
})
}
// 应用播放配置
private applyConfig() {
if (!this.avPlayer) return
if (this.config.muted !== undefined) {
this.avPlayer.setVolume(this.config.muted ? 0 : (this.config.volume ?? 1))
}
if (this.config.volume !== undefined && !this.config.muted) {
this.avPlayer.setVolume(this.config.volume)
}
if (this.config.speed !== undefined) {
this.avPlayer.setSpeed(this.config.speed as media.PlaybackSpeed)
}
if (this.config.startTime && this.config.startTime > 0) {
this.avPlayer.seek(this.config.startTime)
}
}
// 更新内部状态并通知外部
private updateState(state: PlayerState) {
this.state = state
this.callbacks.onStateChange?.(state)
}
}
// ====== 使用封装播放器的UI组件 ======
@Entry
@Component
struct VideoPlayerKitDemo {
private xComponentController: XComponentController = new XComponentController()
private playerManager: VideoPlayerManager = new VideoPlayerManager()
@State playerState: PlayerState = PlayerState.IDLE
@State currentTime: number = 0
@State duration: number = 0
@State bufferingPercent: number = 100
@State showControls: boolean = true
aboutToAppear() {
// 配置回调
this.playerManager.setCallbacks({
onStateChange: (state: PlayerState) => {
this.playerState = state
},
onTimeUpdate: (time: number) => {
this.currentTime = time
},
onDurationUpdate: (duration: number) => {
this.duration = duration
},
onError: (error: string) => {
console.error(`[播放器错误] ${error}`)
},
onBufferingUpdate: (percent: number) => {
this.bufferingPercent = percent
},
onCompletion: () => {
console.info('[播放器] 播放完成')
}
})
}
aboutToDisappear() {
this.playerManager.release()
}
build() {
Stack() {
// 视频渲染区域
XComponent({
id: 'videoSurface',
type: XComponentType.SURFACE,
controller: this.xComponentController
})
.width('100%')
.height('100%')
.onLoad(async () => {
const surfaceId = this.xComponentController.getXComponentSurfaceId()
await this.playerManager.init(surfaceId, { autoPlay: true })
// 设置视频源(实际项目中替换为真实路径)
const context = getContext(this) as common.UIAbilityContext
await this.playerManager.setSourceAndPrepare(
context.resourceDir + '/rawfile/sample.mp4'
)
})
// 缓冲指示器
if (this.bufferingPercent < 100 && this.playerState === PlayerState.PLAYING) {
LoadingProgress()
.width(48)
.height(48)
.color('#4FC3F7')
}
// 控制层(点击显示/隐藏)
Column() {
// 顶部信息栏
Row() {
Text('视频播放器')
.fontSize(16)
.fontColor('#ffffff')
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding(16)
Blank()
// 底部控制栏
Column() {
// 进度条
Row() {
Text(this.formatTime(this.currentTime))
.fontSize(12)
.fontColor('#ffffff')
.width(50)
Progress({
value: this.currentTime,
total: this.duration > 0 ? this.duration : 1,
type: ProgressType.Linear
})
.width('60%')
.color('#4FC3F7')
.backgroundColor('#333333')
Text(this.formatTime(this.duration))
.fontSize(12)
.fontColor('#ffffff')
.width(50)
}
.width('100%')
.justifyContent(FlexAlign.Center)
// 播放控制按钮
Row() {
Button('⏮')
.onClick(() => this.playerManager.seek(Math.max(0, this.currentTime - 10000)))
Button(this.playerState === PlayerState.PLAYING ? '⏸' : '▶')
.onClick(() => {
if (this.playerState === PlayerState.PLAYING) {
this.playerManager.pause()
} else {
this.playerManager.play()
}
})
Button('⏭')
.onClick(() => this.playerManager.seek(Math.min(this.duration, this.currentTime + 10000)))
}
.justifyContent(FlexAlign.SpaceEvenly)
.width('100%')
.padding(8)
}
.width('100%')
.padding(16)
.backgroundColor('rgba(0,0,0,0.6)')
}
.width('100%')
.height('100%')
.visibility(this.showControls ? Visibility.Visible : Visibility.Hidden)
}
.width('100%')
.height(300)
.backgroundColor('#000000')
.onClick(() => {
this.showControls = !this.showControls
})
}
private formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
}
四、踩坑与注意事项
4.1 Surface绑定时机
坑:在AVPlayer进入initialized状态之前设置surfaceId无效,甚至可能导致崩溃。
解:必须在stateChange回调中,当状态变为initialized时再设置surfaceId并调用prepare()。不要在创建AVPlayer后立即设置。
// ❌ 错误做法
this.avPlayer = await media.createAVPlayer()
this.avPlayer.surfaceId = surfaceId // 此时还在idle状态,设置无效!
this.avPlayer.url = videoUrl
// ✅ 正确做法
this.avPlayer.on('stateChange', (state) => {
if (state === 'initialized') {
this.avPlayer.surfaceId = surfaceId // 在initialized状态设置
this.avPlayer.prepare()
}
})
this.avPlayer.url = videoUrl
4.2 资源释放顺序
坑:页面退出时忘记释放AVPlayer,导致内存泄漏和后台持续播放。
解:在aboutToDisappear中调用release(),且要先stop再release。
aboutToDisappear() {
if (this.avPlayer) {
// 先停止再释放
if (this.playerState === 'playing' || this.playerState === 'paused') {
this.avPlayer.stop()
}
this.avPlayer.release()
this.avPlayer = null
}
}
4.3 网络视频的权限配置
坑:播放网络视频时,没有配置网络权限,导致加载失败。
解:在module.json5中添加网络权限:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
4.4 seek的精度问题
坑:调用seek()后,实际跳转的位置可能和目标位置有偏差,因为视频的关键帧间隔不是1秒。
解:使用SeekMode控制跳转策略。SEEK_PREV_SYNC跳到目标位置之前最近的关键帧,SEEK_NEXT_SYNC跳到之后最近的关键帧。如果需要精确跳转,需要确保视频的关键帧间隔足够小。
4.5 后台播放处理
坑:应用切到后台后,视频播放可能被系统暂停。
解:如果需要后台继续播放音频,需要申请长任务权限(ohos.permission.KEEP_BACKGROUND_RUNNING),并在Ability中配置backgroundModes。
五、HarmonyOS 6适配
5.1 API变更
| 变更项 | HarmonyOS 5 | HarmonyOS 6 |
|---|---|---|
| AVPlayer创建 | media.createAVPlayer() |
新增media.createAVPlayerSync()同步创建 |
| HDR播放 | 不支持 | 新增HDR视频播放支持 |
| 画中画 | 需手动实现 | 新增@ohos.multimedia.pipWindow原生画中画API |
| 视频解码 | 仅硬解 | 新增软件解码回退机制 |
5.2 迁移指南
// HarmonyOS 6 新增的画中画支持
import { pipWindow } from '@kit.MediaKit'
// 创建画中画控制器
let pipController: pipWindow.PipController | null = null
async function enterPipMode(avPlayer: media.AVPlayer) {
try {
// HarmonyOS 6 原生画中画
pipController = await pipWindow.createPipController({
componentId: 'videoSurface' // XComponent的ID
})
await pipController.startPip()
} catch (err) {
console.error(`进入画中画失败: ${err}`)
}
}
5.3 性能提升
HarmonyOS 6对AVPlayer做了底层优化:
- 启动速度:冷启动播放延迟降低约30%
- 内存占用:优化了解码缓冲区管理,内存峰值降低约20%
- HDR支持:新增对HDR10/HDR10+/Dolby Vision的播放支持
六、总结
mindmap
root((视频播放))
核心API
Video组件
声明式、开箱即用
适合简单场景
自动管理Surface
AVPlayer
底层、完全可控
需手动管理状态机
适合定制播放器
状态机
idle → initialized → prepared
prepared → playing ↔ paused
playing → completed
any → stopped → idle
any → error → idle
关键要点
Surface绑定时机
资源及时释放
网络权限配置
seek精度控制
后台播放处理
封装实践
VideoPlayerManager
状态枚举管理
配置接口抽象
回调事件统一
一句话总结:视频播放的核心是理解状态机——先初始化、再设源、再准备、再播放,每一步都不能跳。封装播放器时,把状态管理和事件回调抽离出来,就能得到一个干净、可复用的播放器组件。
记住三个关键:
- 状态机是灵魂——理解每个状态的含义和合法转换
- Surface是桥梁——AVPlayer必须绑定XComponent的Surface才能渲染画面
- 释放是底线——页面退出时务必释放播放器资源
- 点赞
- 收藏
- 关注作者
评论(0)