鸿蒙实战:用 want.param 实现视频播放器跨端迁移续播
文章摘要: 本文介绍了HarmonyOS视频跨设备续播的实现方案,重点讲解了如何利用want.param实现播放状态的无缝迁移。主要内容包括: 方案选择:针对在线视频场景(数据量<100KB),推荐使用轻量级的want.param方案 实现步骤:配置应用接续能力、保存播放状态、恢复状态并自动定位进度 关键技术:通过AVPlayer控制器管理播放状态,使用分布式能力实现跨设备数据传输 环境要求
附完整源码:VideoContinuationDemo
从手机到平板,视频进度、音量、暂停状态一键迁移,告别手动拖进度条。
一、需求背景
你在客厅用手机看一部在线电影,看到 20 分 35 秒时手机要没电了。你拿起平板,希望它能从刚才的进度继续播放,而不是重新打开应用、找到视频、再拖到 20:35。
这就是跨端迁移的典型场景——视频跨设备续播。HarmonyOS 提供了应用接续能力,让应用可以在不同设备间无缝转移状态。
本篇内容要分享的不是如何做一个视频播放器,而是跨端迁移的核心实现。两台设备无缝衔接播放进度、音量和暂停状态,其实流程是固定的,很简单。
我们采用最轻量的 want.param 方案,代码量少、无需处理大文件,非常适合在线视频类应用。关于写播放器过程中遇到问题也会做分享。
本文你将学到:
- 如何配置应用支持接续
- 如何在源端保存播放状态
- 如何在目标端恢复状态并自动 seek
- 如何实时保存进度以备迁移
- 如何开启快速拉起,减少等待时间
- 完整可运行的项目代码
二、方案决策:为什么用 want.param?
HarmonyOS 应用接续有两种主流数据迁移方式:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 在线视频(URL + 进度 + 音量) 音乐播放(歌曲ID + 进度) |
want.param |
数据量小(<100KB),实现简单 |
| 本地视频文件(MP4)+ 字幕 | 分布式对象 + 文件资产 | 支持大文件同步 |
决策树:
是否需要迁移本地文件?
├─ 是 → 使用分布式对象(后续可拓展)
└─ 否 → 数据总量 <100KB?
├─ 是 → 使用 want.param
└─ 否 → 使用分布式对象
对于在线视频,迁移数据仅包括:
- 视频 URL(~100 字节)
- 播放进度(8 字节)
- 暂停/播放状态(1 字节)
- 音量(4 字节)
总大小远小于 100KB,want.param 是最优解。
三、环境要求与使用前提
在开始之前,请确保满足以下所有条件,否则接续图标不会出现或迁移失败:
| 条件 | 说明 |
|---|---|
| SDK要求 | HarmonyOS API 12 及以上 |
| 设备要求 | 双端设备均为 HarmonyOS 5.0+ |
| 账号要求 | 双端设备登录同一华为账号 |
| 网络要求 | 双端设备打开 WLAN 和蓝牙开关 |
| 系统设置 | 双端设备在“设置 > 多设备协同”中开启 “接续” 功能 |
| 应用安装 | 双端设备均需安装同一应用(签名一致) |
| 数据大小 | want.param 传输的数据必须 < 100KB(本方案满足) |
| 模拟器 | 应用接续功能不支持模拟器,必须在真机上测试 |
自 API12 起,无需申请
ohos.permission.DISTRIBUTED_DATASYNC权限。本应用最低支持 API12,因此忽略权限申请步骤。
四、项目结构
VideoContinuationDemo/
├── entry/
│ ├── src/
│ │ ├── main/
│ │ │ ├── ets/
│ │ │ │ ├── entryability/
│ │ │ │ │ └── EntryAbility.ets # 迁移续接重点都在这
│ │ │ │ ├── pages/
│ │ │ │ │ └── Index.ets # 播放页面
│ │ │ │ ├── model/
│ │ │ │ │ └── VideoData.ets # 视频数据模型
│ │ │ │ ├── controller/
│ │ │ │ │ └── AvPlayerController.ets # 播放控制器
│ │ │ │ ├── utils/
│ │ │ │ │ └── Logger.ets # 日志工具
│ │ │ │ └── constants/
│ │ │ │ └── CommonConstants.ets # 常量
│ │ │ └── resources/
│ │ └── module.json5
│ └── build-profile.json5
├── AppScope/
│ ├── app.json5
│ └── resources/
└── build-profile.json5
五、核心代码实现
5.1 常量与日志工具
constants/CommonConstants.ets
export class CommonConstants {
static readonly CONTINUE_DATA_KEY = 'video_play_state';
static readonly DEFAULT_VOLUME = 10;
// 请替换成你自己的视频链接
static readonly DEFAULT_VIDEO_URL = 'https://media.w3.org/2010/05/sintel/trailer.mp4';
}
utils/Logger.ets(hilog 封装,略)
5.2 数据模型
model/VideoData.ets
export interface PlayState {
videoUrl: string; // 在线视频地址
positionMs: number; // 播放进度
isPaused: boolean; // true=暂停,false=播放中
volume: number; // 音量 0-15
}
5.3 播放控制器
controller/AvPlayerController.ets
import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';
const TAG = 'AvPlayerController';
export class AvPlayerController {
private avPlayer?: media.AVPlayer;
private surfaceId?: string;
private onProgressCb?: (pos: number, dur: number) => void;
private onPlayStateCb?: (isPlaying: boolean) => void;
private onStateChangeCb?: (state: string) => void; // 新增:状态变化回调
private isPrepared: boolean = false;
private pendingVolume?: number;
constructor() {}
async init(surfaceId: string): Promise<void> {
this.surfaceId = surfaceId;
try {
this.avPlayer = await media.createAVPlayer();
this.setupListeners();
} catch (error) {
Logger.error(TAG, `init failed: ${JSON.stringify(error)}`);
throw new Error(String(error));
}
}
private setupListeners(): void {
if (!this.avPlayer) return;
this.avPlayer.on('stateChange', (state: string) => {
Logger.info(TAG, `State: ${state}`);
if (state === 'initialized') {
if (this.surfaceId && this.avPlayer) {
this.avPlayer.surfaceId = this.surfaceId;
Logger.info(TAG, `SurfaceId set: ${this.surfaceId}`);
}
this.prepare()
}
if (state === 'prepared') {
this.isPrepared = true;
if (this.pendingVolume !== undefined) {
this.setVolume(this.pendingVolume);
this.pendingVolume = undefined;
}
}
if (state === 'playing') {
this.onPlayStateCb?.(true);
}
if (state === 'paused') {
this.onPlayStateCb?.(false);
}
if (state === 'error') {
this.isPrepared = false;
}
// 通知外部状态变化
this.onStateChangeCb?.(state);
});
this.avPlayer.on('timeUpdate', (time: number) => {
if (this.onProgressCb && this.avPlayer && this.isPrepared) {
this.onProgressCb(time, this.avPlayer.duration);
}
});
this.avPlayer.on('error', (err: BusinessError) => {
Logger.error(TAG, `Error: ${err.code}, ${err.message}`);
this.isPrepared = false;
});
}
/** 注册状态变化回调(外部可监听 initialized, prepared, playing, paused 等) */
onStateChange(cb: (state: string) => void): void {
this.onStateChangeCb = cb;
}
async setUrl(url: string): Promise<void> {
if (!this.avPlayer) throw new Error('Player not init');
try {
this.avPlayer.url = url;
Logger.info(TAG, `URL set: ${url}`);
} catch (error) {
Logger.error(TAG, `setUrl failed: ${JSON.stringify(error)}`);
throw new Error(String(error));
}
}
private async prepare(): Promise<void> {
if (this.avPlayer) {
try {
await this.avPlayer.prepare();
} catch (error) {
Logger.error(TAG, `prepare failed: ${JSON.stringify(error)}`);
}
}
}
async play(): Promise<void> {
if (!this.avPlayer || !this.isPrepared) {
Logger.warn(TAG, 'play() ignored: not prepared');
return;
}
try {
await this.avPlayer.play();
} catch(e) {
Logger.error(TAG, `play error: ${e}`);
}
}
async pause(): Promise<void> {
if (!this.avPlayer) return;
try { await this.avPlayer.pause(); } catch(e) { Logger.error(TAG, `pause error: ${e}`); }
}
seek(ms: number): void {
if (!this.avPlayer || !this.isPrepared) return;
this.avPlayer.seek(ms)
}
setVolume(vol: number): void {
let rawVol = Math.min(15, Math.max(0, vol));
if (!this.avPlayer) return;
const normalized = rawVol / 15;
if (!this.isPrepared) {
this.pendingVolume = rawVol;
Logger.info(TAG, `Volume deferred: ${rawVol}`);
return;
}
try {
this.avPlayer.setVolume(normalized);
Logger.info(TAG, `Volume set: ${rawVol} -> ${normalized}`);
} catch(e) {
Logger.error(TAG, `volume error: ${e}`);
}
}
onProgress(cb: (pos: number, dur: number) => void): void {
this.onProgressCb = cb;
}
onPlayStateChange(cb: (isPlaying: boolean) => void): void {
this.onPlayStateCb = cb;
}
release(): void {
this.avPlayer?.release().catch(() => {});
this.avPlayer = undefined;
this.isPrepared = false;
this.pendingVolume = undefined;
}
}
5.4 EntryAbility(接续核心,支持快速拉起)
entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { Logger } from '../utils/Logger';
import { CommonConstants } from '../constants/CommonConstants';
import { PlayState } from '../model/VideoData';
const TAG = 'EntryAbility';
export default class EntryAbility extends UIAbility {
private storage: LocalStorage = new LocalStorage();
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.handleContinuation(want, launchParam);
}
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.handleContinuation(want, launchParam);
}
// 统一处理冷启动和热启动的接续逻辑
private handleContinuation(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 快速拉起:应用提前启动,数据未到达,仅设置加载状态,不恢复窗口
if (launchParam.launchReason === AbilityConstant.LaunchReason.PREPARE_CONTINUATION) {
Logger.info(TAG, 'Quick start: app prepared');
this.storage.setOrCreate('quickStartLoading', true);
return;
}
// 正常接续:数据已到达,解析并保存到 AppStorage
if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) {
Logger.info(TAG, 'Continuation: restoring data');
try {
const raw = want.parameters?.[CommonConstants.CONTINUE_DATA_KEY] as string;
if (raw) {
const restored = JSON.parse(raw) as PlayState;
AppStorage.setOrCreate('restoredPlayState', restored);
AppStorage.setOrCreate('isContinuation', true);
Logger.info(TAG, `Restore state: ${raw}`);
}
this.storage.setOrCreate('quickStartLoading', false);
} catch (error) {
Logger.error(TAG, `Parse error: ${error}`);
}
}
// 恢复页面栈(普通启动或正常接续都需要调用,快速拉起已提前 return)
try {
this.context.restoreWindowStage(this.storage);
} catch (error) {
Logger.error(TAG, `restoreWindowStage failed: ${error}`);
}
}
// 源端:保存迁移数据
onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult {
const state = AppStorage.get<PlayState>('currentPlayState');
if (!state) {
return AbilityConstant.OnContinueResult.AGREE;
}
wantParam[CommonConstants.CONTINUE_DATA_KEY] = JSON.stringify(state);
Logger.info(TAG, `Save state: ${JSON.stringify(state)}`);
return AbilityConstant.OnContinueResult.AGREE;
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index').catch((err: Error) => {
Logger.error(TAG, `Load content failed: ${JSON.stringify(err)}`);
});
}
}
5.5 首页(播放页)
pages/Index.ets
import { AvPlayerController } from '../controller/AvPlayerController';
import { PlayState } from '../model/VideoData';
import { CommonConstants } from '../constants/CommonConstants';
import { Logger } from '../utils/Logger';
const TAG = 'IndexPage';
@Entry
@Component
struct Index {
@StorageLink('currentPlayState') currentPlayState: PlayState = {
videoUrl: CommonConstants.DEFAULT_VIDEO_URL,
positionMs: 0,
isPaused: false,
volume: CommonConstants.DEFAULT_VOLUME,
};
@StorageLink('restoredPlayState') restoredPlayState: PlayState | null = null;
@StorageLink('isContinuation') isContinuation: boolean = false;
@StorageLink('quickStartLoading') quickStartLoading: boolean = false;
@State duration: number = 0; // 总时长,单独维护
private player: AvPlayerController = new AvPlayerController();
private xcController: XComponentController = new XComponentController();
private pendingRestore: boolean = false;
private restorePosition: number = 0;
private restorePaused: boolean = false;
private isPrepared: boolean = false;
aboutToAppear(): void {
if (this.quickStartLoading) {
AppStorage.setOrCreate('quickStartLoading', false);
}
// 冷启动时必须主动同步 UI 状态
if (this.isContinuation && this.restoredPlayState) {
this.currentPlayState.videoUrl = this.restoredPlayState.videoUrl
this.currentPlayState.isPaused = this.restoredPlayState.isPaused
this.currentPlayState.positionMs = this.restoredPlayState.positionMs
this.currentPlayState.volume = this.restoredPlayState.volume
// 保存恢复参数
this.pendingRestore = true;
this.restorePosition = this.restoredPlayState.positionMs;
this.restorePaused = this.restoredPlayState.isPaused;
// 清除接续标记
this.isContinuation = false;
this.restoredPlayState = null;
return;
}
}
private onXComponentLoad(): void {
if (this.isPrepared) return;
this.isPrepared = true;
this.initPlayer();
}
private async initPlayer(): Promise<void> {
try {
const surfaceId = this.xcController.getXComponentSurfaceId();
if (!surfaceId) {
Logger.error(TAG, 'surfaceId is empty');
return;
}
await this.player.init(surfaceId);
// 先设置 URL,播放器开始进入 initialized -> prepared
await this.player.setUrl(this.currentPlayState.videoUrl);
this.player.setVolume(this.currentPlayState.volume);
this.player.onStateChange((state) => {
if (state === 'prepared') {
if (this.pendingRestore) {
// 确保 seek 完成后再决定播放
this.player.seek(this.restorePosition);
// 简单延迟等待 seek 生效(或监听 seekDone 事件)
setTimeout(() => {
if (!this.restorePaused) {
this.player.play();
}
}, 50);
this.pendingRestore = false;
} else {
this.player.play();
}
}
});
this.player.onProgress((pos, dur) => {
this.currentPlayState.positionMs = pos;
this.duration = dur;
});
this.player.onPlayStateChange((playing) => {
this.currentPlayState.isPaused = !playing;
});
} catch (err) {
Logger.error(TAG, `Init player failed: ${JSON.stringify(err)}`);
}
}
aboutToDisappear(): void {
this.player.release();
}
@Builder
ProgressBar() {
Row({ space: 10 }) {
Text(this.formatTime(this.currentPlayState.positionMs))
.fontSize(12)
.width(50)
.textAlign(TextAlign.Center)
Slider({
value: this.currentPlayState.positionMs,
min: 0,
max: this.duration,
step: 1000,
})
.layoutWeight(1)
.onChange((val, mode) => {
if (mode === SliderChangeMode.End) {
this.player.seek(val);
}
})
Text(this.formatTime(this.duration))
.fontSize(12)
.width(50)
.fontFamily('monospace')
.textAlign(TextAlign.Center)
}
.width('90%')
.padding(10)
}
@Builder
ControlButtons() {
Row({ space: 20 }) {
Button(this.currentPlayState.isPaused ? '▶️ 播放' : '⏸️ 暂停')
.onClick(() => {
if (this.currentPlayState.isPaused) {
this.player.play();
} else {
this.player.pause();
}
})
Button('⏪ 后退10s').onClick(() => {
this.player.seek(Math.max(0, this.currentPlayState.positionMs - 10000));
})
Button('⏩ 前进10s').onClick(() => {
this.player.seek(Math.min(this.duration, this.currentPlayState.positionMs + 10000));
})
}
}
@Builder
VolumeControl() {
Row() {
Text('音量').width(50)
Slider({
value: this.currentPlayState.volume,
min: 0,
max: 15,
step: 1,
})
.layoutWeight(1)
.onChange((val) => {
this.currentPlayState.volume = val
this.player.setVolume(val);
})
Text(`${this.currentPlayState.volume}`).width(30)
}.width('90%').padding(10)
}
@Builder
LoadingOverlay() {
if (this.quickStartLoading) {
Column() {
LoadingProgress().width(48).height(48)
Text('正在准备迁移...').margin({ top: 12 })
}
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.7)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
build() {
Stack() {
Column() {
XComponent({
type: XComponentType.SURFACE,
controller: this.xcController,
})
.onLoad(() => this.onXComponentLoad())
.width('100%')
.aspectRatio(16 / 9)
.backgroundColor(Color.Black)
this.ProgressBar()
this.ControlButtons()
this.VolumeControl()
}
.width('100%')
.height('100%')
this.LoadingOverlay()
}
}
private formatTime(ms: number): string {
if (ms <= 0) return '00:00';
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
}
六、效果展示

七、踩坑经验
- 有声音无画面:
surfaceId必须在initialized状态设置且只设置一次,不要在reset后重复设置。 - 错误码 5400102:状态机时序错误,例如在非
initialized状态调用prepare。严格按照createAVPlayer→url→initialized→prepare→prepared→play顺序。 - 音量设置无效:
setVolume参数范围是0.0~1.0,需要将 UI 层0~15归一化。
-冷启动接续进度异常 未同步currentPlayState,确保aboutToAppear中同步restoredPlayState到currentPlayState
八、总结
本文通过代码示例,演示了如何使用 want.param 为在线视频应用添加跨端续播功能,并介绍了快速拉起优化体验。核心要点:
- 数据保存:在源端
onContinue中将播放状态(URL、进度、暂停状态、音量)序列化为 JSON 存入wantParam。 - 数据恢复:在目标端
onCreate/onNewWant中判断launchReason为CONTINUATION时,从want.parameters中解析 JSON 并存入AppStorage。 - 页面栈恢复:目标端必须调用
this.context.restoreWindowStage(this.storage)来恢复系统自动迁移的页面栈和窗口状态(否则页面不会加载)。 - 状态同步:播放页使用
@StorageLink实时更新AppStorage中的currentPlayState,确保接续时能保存最新进度。 - 快速拉起:在
module.json5中配置continueType带_ContinueQuickStart后缀,并在EntryAbility中处理PREPARE_CONTINUATION生命周期,可显著减少大数据迁移时的等待时间。 - AVPlayer 状态机时序:
createAVPlayer→url→initialized(设置surfaceId)→prepare→prepared→play。任何一步顺序错误都会导致5400102错误或有声音无画面。
触发迁移:满足环境要求后,系统会自动在任务中心的应用卡片上显示“迁移”图标,用户点击即可完成迁移,无需应用编写触发代码。
如果你的应用需要迁移本地视频文件(如 MP4)或字幕文件,由于数据量大且涉及文件传输,需要使用分布式对象 + 文件资产迁移方案。我会另外开一篇帖子分享如何实现,这篇文章仅仅提前做了快速拉起。如果本文对你有帮助,欢迎点赞、收藏、转发!
更多推荐




所有评论(0)