附完整源码:DistributedObjectDemo

前一篇《用 want.param 实现网络视频播放器跨端迁移续播》解决了在线视频的进度迁移,但本地视频文件(相册中的 MP4、拍摄的视频)动辄几十 MB,无法通过 want.param 传输。HarmonyOS 提供了分布式数据对象 + 资产同步能力,让大文件也能像普通变量一样自动跨设备迁移。
本文通过完整的代码示例,手把手教你实现视频跨端续播本地资产同步的核心要点,在文章最后附上踩坑经验以及分布式数据对象完整使用流程总结。

一、方案决策:什么时候用资产同步?

场景 推荐方案 原因
在线视频(URL + 进度 + 音量) want.param 数据量小,即时传输
本地视频文件(MP4)+ 进度 分布式数据对象 + 资产同步 文件本身被系统自动迁移,无需手动处理传输
本地视频 + 字幕文件 资产数组(Assets) 支持多个文件同时迁移

资产同步的优势

  • 系统自动完成文件传输、校验、暂存
  • 迁移后目标端自动获得视频资源uri。
  • 与播放状态(进度、音量)在同一个对象中同步,无需额外逻辑

二、环境要求与权限

条件 说明
SDK要求 HarmonyOS API 12 及以上(资产数组需要 API 20)
设备要求 双端 HarmonyOS 5.0+
账号要求 双端登录同一华为账号
网络要求 双端打开 WLAN 和蓝牙
系统设置 开启“设置 > 多设备协同 > 接续”
应用安装 双端安装同一签名应用

自 API12 起,无需申请 ohos.permission.DISTRIBUTED_DATASYNC 权限,但是分布式数据对象API会提示你申请权限,无需关心。本应用最低支持 API12,因此忽略权限申请步骤,但需在 module.json5 中声明网络权限咱也支持网络视频。

三、项目结构

VideoContinuationDemo/
├── entry/
│   ├── src/
│   │   ├── main/
│   │   │   ├── ets/
│   │   │   │   ├── entryability/
│   │   │   │   │   └── EntryAbility.ets   # 接续核心(资产同步)
│   │   │   │   ├── pages/
│   │   │   │   │   └── Index.ets          # 播放页面
│   │   │   │   ├── model/
│   │   │   │   │   └── VideoData.ets      # 数据模型
│   │   │   │   ├── controller/
│   │   │   │   │   └── AvPlayerController.ets  # 播放控制器
│   │   │   │   ├── utils/
│   │   │   │   │   └── FileHelper.ets     # 文件工具
│   │   │   │   └── constants/
│   │   │   │       └── CommonConstants.ets
│   │   │   └── resources/
│   │   └── module.json5
│   └── build-profile.json5
├── AppScope/
└── ...

四、核心代码实现

4.1 数据模型

model/VideoData.ets

import { commonType } from '@kit.ArkData';

export interface PlayState {
  videoUrl?: string;                // 在线视频 URL 或 fd:// 地址
  assets?: commonType.Assets;       // 本地视频资产数组(支持多文件)
  positionMs?: number;
  isPaused?: boolean;
  volume?: number;
}

4.2 文件辅助工具

重点:从本地选择视频我们拿到的uri是file:// 播放器media.AVPlayer是不支持的。需要转换成fd://${fd}才可以读取资源。

utils/FileHelper.ets

import { common } from '@kit.AbilityKit';
import { fileIo as fs, fileUri } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { commonType } from '@kit.ArkData';
import { Logger } from './Logger';

const TAG = 'FileHelper';

export class FileHelper {
  // 从相册选择视频,返回原始 URI
  static async pickVideoRawUri(context: common.UIAbilityContext): Promise<string | null> {
    try {
      const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
      photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE;
      photoSelectOptions.maxSelectNumber = 1;
      const photoPicker = new photoAccessHelper.PhotoViewPicker();
      const result = await photoPicker.select(photoSelectOptions);
      const uri = result.photoUris[0];
      if (!uri) return null;
      Logger.info(TAG, `Selected video raw URI: ${uri}`);
      return uri;
    } catch (err) {
      const error = err as BusinessError;
      Logger.error(TAG, `pickVideoRawUri failed: ${error.code}, ${error.message}`);
      return null;
    }
  }

  /**
   * 根据原始 URI 生成 fd:// 格式的 URL,用于直接播放
   */
  static async getFdUrlFromRawUri(rawUri: string): Promise<string | null> {
    try {
      const file = fs.openSync(rawUri, fs.OpenMode.READ_ONLY);
      const fd = file.fd;
      const fdUrl = `fd://${fd}`;
      Logger.info(TAG, `Generated fdUrl: ${fdUrl}`);
      return fdUrl;
    } catch (err) {
      const error = err as BusinessError;
      Logger.error(TAG, `getFdUrlFromRawUri failed: ${error.code}, ${error.message}`);
      return null;
    }
  }

  /**
   * 将媒体库 URI 复制到分布式目录并返回完整的 Asset 对象(仅在迁移时调用)
   */

  static async copyToDistributedDir(context: common.UIAbilityContext, srcUri: string): Promise<commonType.Asset | null> {
    try {
      const distDir = context.distributedFilesDir;
      const fileName = srcUri.substring(srcUri.lastIndexOf('/') + 1);
      const destPath = `${distDir}/${fileName}`;

      // 如果目标文件已存在,直接返回 Asset(避免重复复制)
      if (fs.accessSync(destPath)) {
        Logger.info(TAG, `File already exists: ${destPath}`);
        const stat = fs.statSync(destPath);
        const destUri = fileUri.getUriFromPath(destPath);
        return {
          name: fileName,
          uri: destUri,
          path: destPath,
          createTime: stat.ctime.toString(),
          modifyTime: stat.mtime.toString(),
          size: stat.size.toString(),
        };
      }

      // 1. 打开源文件(只读)
      const srcFile = fs.openSync(srcUri, fs.OpenMode.READ_ONLY);
      // 2. 创建目标文件(读写)
      const destFile = fs.openSync(destPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
      // 3. 获取源文件大小
      const srcStat = fs.statSync(srcUri);
      const totalSize = srcStat.size;
      const bufferSize = 64 * 1024; // 64KB 缓冲区
      let copied = 0;

      while (copied < totalSize) {
        const buffer = new ArrayBuffer(bufferSize);
        const readLen = fs.readSync(srcFile.fd, buffer);
        if (readLen <= 0) break;
        // 写入目标文件
        fs.writeSync(destFile.fd, buffer.slice(0, readLen));
        copied += readLen;
      }

      // 4. 关闭文件
      fs.closeSync(srcFile);
      fs.closeSync(destFile);

      // 5. 获取目标文件状态并构造 Asset
      const stat = fs.statSync(destPath);
      const destUri = fileUri.getUriFromPath(destPath);
      const asset: commonType.Asset = {
        name: fileName,
        uri: destUri,
        path: destPath,
        createTime: stat.ctime.toString(),
        modifyTime: stat.mtime.toString(),
        size: stat.size.toString(),
      };
      Logger.info(TAG, `Copied to distributed dir: ${destPath}, size=${stat.size}`);
      return asset;
    } catch (err) {
      const error = err as BusinessError;
      Logger.error(TAG, `copyToDistributedDir failed: ${error.code}, ${error.message}`);
      return null;
    }
  }
}

4.3 播放控制器

重点:在上一篇文章中只做了网络视频播放没有测试出啥问题,当播放本地视频的时候出现了两个问题

  1. 调整进度后直接又回退到原始进度,解决方法通过 seek 方法设置 SeekMode.SEEK_CLOSEST
  2. 滑动进度条增加拖动状态、如果拖动的时候不屏蔽onProgress回调中赋值,进度会来回跳动。
  3. 更换url前执行await this.avPlayer.reset();重置视频播放器,而不是直接销毁在重建提高性能。
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 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 === '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;
    });
  }

  onStateChange(cb: (state: string) => void): void {
    this.onStateChangeCb = cb;
  }

  onProgress(cb: (pos: number, dur: number) => void): void {
    this.onProgressCb = cb;
  }

  offStateChange(): void {
    this.onStateChangeCb = undefined;
  }

  async setUrl(url: string): Promise<void> {
    if (!this.avPlayer) throw new Error('Player not init');
    try {
      await this.avPlayer.reset();
      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,mode: media.SeekMode = media.SeekMode.SEEK_CLOSEST): void {
    if (!this.avPlayer || !this.isPrepared) return;
    this.avPlayer.seek(ms,mode);
    Logger.info(TAG, `seek called: ${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}`);
    }
  }

  release(): void {
    if (this.avPlayer) {
      this.avPlayer.off('error');
      this.avPlayer.off('timeUpdate');
      this.avPlayer.off('stateChange');
      this.avPlayer.release()
    }
    this.avPlayer = undefined;
    this.pendingVolume = undefined;
    this.onStateChangeCb = undefined;
    this.onProgressCb = undefined;
  }
}

4.4 接续核心,资产迁移 EntryAbility

entryability/EntryAbility.ets

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { distributedDataObject, commonType } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';
import { PlayState } from '../model/VideoData';
import { FileHelper } from '../utils/FileHelper';

const TAG = 'EntryAbility';
let globalDataObject: distributedDataObject.DataObject;

export default class EntryAbility extends UIAbility {
  private storage: LocalStorage = new LocalStorage();

  async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
    if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) {
      Logger.info(TAG, 'onCreate: continuation, will restore');
      await this.restoreFromContinuation(want);
    } else {
      Logger.info(TAG, `onCreate: launchReason=${launchParam.launchReason}`);
    }
    this.handleNormalLaunch(want, launchParam);
  }

  onDestroy(): void {
    globalDataObject?.off('status');
  }
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) {
      Logger.info(TAG, 'onNewWant: continuation, will restore');
      this.restoreFromContinuation(want);
    }
    this.handleNormalLaunch(want, launchParam);
  }

  private handleNormalLaunch(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;
    }
    try {
      this.context.restoreWindowStage(this.storage);
      Logger.info(TAG, 'restoreWindowStage success');
    } catch (error) {
      Logger.error(TAG, `restoreWindowStage failed: ${error}`);
    }
  }

  // 源端:保存迁移数据 
  async onContinue(wantParam: Record<string, Object>): Promise<AbilityConstant.OnContinueResult> {
    Logger.info(TAG, 'onContinue called');
    try {
      const state = AppStorage.get<PlayState>('currentPlayState');
      if (!state) {
        Logger.info(TAG, 'onContinue: no currentPlayState, skip');
        return AbilityConstant.OnContinueResult.AGREE;
      }
      Logger.info(TAG, `onContinue: currentPlayState = ${JSON.stringify(state)}`);

      let isAsset = false;
      let assetUri: string | undefined;

      // 判断是否有本地资产:assets 数组存在且第一个元素有 path
      if (state.assets && state.assets.length > 0 && state.assets[0].path) {
        isAsset = true;
        const srcPath = state.assets[0].path; // 原始媒体库路径
        Logger.info(TAG, `onContinue: asset path = ${srcPath}`);
        const newAsset = await FileHelper.copyToDistributedDir(this.context, srcPath);
        if (newAsset) {
          assetUri = newAsset.uri;
          Logger.info(TAG, `onContinue: asset copied, new uri = ${assetUri}`);
        } else {
          Logger.error(TAG, 'onContinue: copyToDistributedDir failed');
          return AbilityConstant.OnContinueResult.AGREE;
        }
      }

      // 构建要传输的基础数据
      const source: PlayState = {
        positionMs: state.positionMs,
        isPaused: state.isPaused,
        volume: state.volume,
        videoUrl: isAsset ? undefined : state.videoUrl, // 本地资产时 videoUrl 留空
      };
      Logger.info(TAG, `onContinue: source = ${JSON.stringify(source)}`);
      globalDataObject = distributedDataObject.create(this.context, source);

      if (isAsset && assetUri) {
        await globalDataObject.setAsset('assets', assetUri);
        Logger.info(TAG, `onContinue: setAsset assets = ${assetUri}`);
      }

      const sessionId = distributedDataObject.genSessionId();
      wantParam.distributedSessionId = sessionId;
      Logger.info(TAG, `onContinue: generated sessionId = ${sessionId}`);
      await globalDataObject.setSessionId(sessionId);
      await globalDataObject.save(wantParam.targetDevice as string);

      Logger.info(TAG, `onContinue success, sessionId=${sessionId}, isAsset=${isAsset}`);
      return AbilityConstant.OnContinueResult.AGREE;
    } catch (err) {
      const error = err as BusinessError;
      Logger.error(TAG, `onContinue error: ${error.code}, ${error.message}`);
      return AbilityConstant.OnContinueResult.AGREE;
    }
  }

  // 目标端:恢复数据
  private async restoreFromContinuation(want: Want): Promise<void> {
    const sessionId = want.parameters?.distributedSessionId as string;
    if (!sessionId) {
      Logger.warn(TAG, 'No sessionId in continuation');
      return;
    }
    Logger.info(TAG, `restoreFromContinuation: sessionId=${sessionId}`);

    // 定义一个空的数据结构
    const emptyState: PlayState = {
      videoUrl: undefined,
      positionMs: undefined,
      isPaused: undefined,
      volume: undefined,
      assets: undefined,
    };
    globalDataObject = distributedDataObject.create(this.context, emptyState);
    Logger.info(TAG, 'Created empty distributed object');

    try {
      globalDataObject.on('status', (sessionId: string, networkId: string, status: string) => {
        Logger.info(TAG, `Status event: status=${status}, networkId=${networkId}`);
        if (status === 'restored') {
          Logger.info(TAG, '=== Data restored, extracting ===');
          const videoUrlValue = globalDataObject['videoUrl'] as string | undefined;
          const positionMsValue = globalDataObject['positionMs'] as number | undefined;
          const isPausedValue = globalDataObject['isPaused'] as boolean | undefined;
          const volumeValue = globalDataObject['volume'] as number | undefined;
          const assetsValue = globalDataObject['assets'] as commonType.Assets | undefined;

          Logger.info(TAG, `Restored: videoUrl=${videoUrlValue}, positionMs=${positionMsValue}, isPaused=${isPausedValue}, volume=${volumeValue}`);
          if (assetsValue && assetsValue.length > 0) {
            Logger.info(TAG, `Asset restored: uri=${assetsValue[0].uri}, name=${assetsValue[0].name}, size=${assetsValue[0].size}`);
          } else {
            Logger.warn(TAG, 'No assets found in restored data');
          }

          const restored: PlayState = {
            videoUrl: videoUrlValue,
            positionMs: positionMsValue,
            isPaused: isPausedValue,
            volume: volumeValue,
          };
          if (assetsValue) {
            restored.assets = assetsValue;
          }
          Logger.info(TAG, `Final restored PlayState: ${JSON.stringify(restored)}`);
          AppStorage.setOrCreate('restoredPlayState', restored);
          AppStorage.setOrCreate('isContinuation', true);
          this.storage.setOrCreate('quickStartLoading', false);
          // 系统会自己清理,或者每次恢复完数据自己清理也行但是不要在onDestroy中清理,访问到undefined直接报错。
          globalDataObject?.off('status');
        }
      });
    } catch (error) {
      const err = error as BusinessError;
      Logger.error(TAG, `Failed to register status listener: ${err.code}, ${err.message}`);
      return;
    }

    try {
      await globalDataObject.setSessionId(sessionId);
      Logger.info(TAG, `Restore: joined session ${sessionId}`);
    } catch (error) {
      const err = error as BusinessError;
      Logger.error(TAG, `Failed to setSessionId: ${err.code}, ${err.message}`);
    }
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index').catch((err: Error) => {
      Logger.error(TAG, `Load content failed: ${JSON.stringify(err)}`);
    });
  }
}

4.5 播放页面(Index.ets)

完整代码从工程中看吧,代码有点多只做核心展示
pages/Index.ets

@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;
  @State isFileMigrating: boolean = false;
  private isDragging: boolean = false;
  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);
    }
    if (this.isContinuation && this.restoredPlayState) {
      // 接续恢复,显示“正在迁移文件”提示
      this.isFileMigrating = true;
      this.currentPlayState = this.restoredPlayState;
      this.pendingRestore = true;
      this.restorePosition = this.restoredPlayState.positionMs ?? 0;
      this.restorePaused = this.restoredPlayState.isPaused ?? false;
      this.isContinuation = false;
      this.restoredPlayState = null;
      Logger.info(TAG, `Restore pending: pos=${this.restorePosition}, paused=${this.restorePaused}`);
      if (this.isPrepared) {
        Logger.info(TAG, 'Player already prepared, seeking immediately');
        this.player.seek(this.restorePosition);
        if (!this.restorePaused) {
          this.player.play();
        }
        this.pendingRestore = false;
        this.isFileMigrating = false; // 立即完成,隐藏提示
      }
    }
  }

  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);

      let playUrl = '';
      if (this.currentPlayState.assets && this.currentPlayState.assets.length > 0) {
        const uri = this.currentPlayState.assets[0].uri;
        const fdUrl = await FileHelper.getFdUrlFromRawUri(uri);
        if (fdUrl) {
          playUrl = fdUrl;
        }
      } else if (this.currentPlayState.videoUrl) {
        playUrl = this.currentPlayState.videoUrl;
      } else {
        Logger.error(TAG, 'No playable video source');
        return;
      }

      await this.player.setUrl(playUrl);

      this.player.onStateChange((state) => {
        if (state === 'prepared') {
          if (this.pendingRestore) {
            Logger.info(TAG, `Seeking to ${this.restorePosition}`);
            this.player.seek(this.restorePosition);
            this.player.setVolume(this.currentPlayState.volume ?? 0);
            setTimeout(() => {
              if (!this.restorePaused) {
                this.player.play();
              }
              this.pendingRestore = false;
              this.isFileMigrating = false; // 迁移完成,隐藏提示
            }, 300);
          } else {
            this.player.play();
          }
        }
        if (state === 'paused') {
          this.currentPlayState.isPaused = true;
        }
        if (state === 'playing') {
          this.currentPlayState.isPaused = false;
        }
        if (state === 'completed') {
          this.currentPlayState.isPaused = true;
          this.currentPlayState.positionMs = 0;
        }
      });

      this.player.onProgress((pos, dur) => {
        if (!this.isDragging) {
          this.currentPlayState.positionMs = pos;
          this.duration = dur;
          AppStorage.setOrCreate('currentPlayState', this.currentPlayState);
        }
      });

    } catch (err) {
      Logger.error(TAG, `Init player failed: ${JSON.stringify(err)}`);
    }
  }

  // 选择本地视频
  private async onSelectLocalVideo() {
    const context = getContext(this) as common.UIAbilityContext;
    const rawUri = await FileHelper.pickVideoRawUri(context);
    if (!rawUri) return;
    const fdUrl = await FileHelper.getFdUrlFromRawUri(rawUri);
    if (!fdUrl) return;

    const fileName = rawUri.substring(rawUri.lastIndexOf('/') + 1);
    const asset: commonType.Asset = {
      name: fileName,
      uri: fdUrl,
      path: rawUri,
      createTime: '',
      modifyTime: '',
      size: '',
    };

    this.currentPlayState = {
      videoUrl: fdUrl,
      assets: [asset],
      positionMs: 0,
      isPaused: false,
      volume: CommonConstants.DEFAULT_VOLUME,
    };
    AppStorage.setOrCreate('currentPlayState', this.currentPlayState);
    this.pendingRestore = false;
    await this.player.setUrl(fdUrl);
  }

  // 播放网络视频
  private async onPlayNetworkVideo() {
    const networkUrl = CommonConstants.DEFAULT_VIDEO_URL;
    this.currentPlayState = {
      videoUrl: networkUrl,
      assets: undefined,
      positionMs: 0,
      isPaused: false,
      volume: CommonConstants.DEFAULT_VOLUME,
    };
    AppStorage.setOrCreate('currentPlayState', this.currentPlayState);
    this.pendingRestore = false;
    await this.player.setUrl(networkUrl);
  }

  aboutToDisappear(): void {
    this.player.release();
  }

  build(){
    
  }

4.6 配置文件 module.json5

{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "continuable": true,
        "continueType": ["EntryAbility_ContinueQuickStart"],
        // ...
      }
    ],
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET" 
      }
    ],

  }
}

五、效果展示

  1. 手机端在播放网络视频
  2. 源端(平板):从相册选择一个本地视频,播放到某个进度后暂停。
  3. 触发迁移:在任务中心(多任务界面)点击应用卡片右下角的迁移图标,选择目标设备(手机)。
  4. 目标端(手机):应用自动打开,视频从相同的进度、相同的音量、相同的暂停状态继续播放。

分布式数据对象资产跨端迁移

如果是大文件迁移一定要加个正在迁移提示,不然还以为视频播放出bug了呢。

六、踩坑经验

1. 资产文件必须在分布式目录下才能被迁移

  • 现象:源端 onContinue 成功,但目标端收不到文件,restored 事件中 assets 为空。
  • 解决:在 onContinue 中调用 FileHelper.copyToDistributedDir 将文件复制或写入到 context.distributedFilesDir,并使用返回的 Asset 对象设置资产字段。

2. 接收端必须创建一个空数据结构或者对象

  • 现象:目标端数据被本地默认值覆盖,没有从源端同步。
  • 解决:创建分布式对象时,将所有需要同步的属性显式设为 undefined(如 emptyState: PlayState = { videoUrl: undefined, ... })。

3. 本地视频 seek 后进度回退

  • 现象:迁移后进度条显示正确,但播放时从 0 开始;或者拖动进度条回弹。
  • 解决:在 seek 方法中指定 SeekMode.SEEK_CLOSEST(或 SEEK_PREV_SYNC),默认不传可能导致关键帧定位不准。
    seek(ms: number, mode: media.SeekMode = media.SeekMode.SEEK_CLOSEST)
    

4. 进度条拖动时被 timeUpdate 覆盖

  • 现象:拖动进度条时,滑块被播放器的实时进度拉回原位置。
  • 解决:使用 isDragging 标志,在 Slider.onChangeBegin 阶段设为 trueEnd 阶段设为 false,并在 onProgress 回调中判断 !isDragging 时才更新 positionMs

5. setAsset 传递字符串 URI,而非 Asset 对象

  • 现象:目标端收到的 assets 字段变成了数组,而非单个 Asset。
  • 解决globalDataObject.setAsset('assets', assetUri) 的第二个参数应该是字符串 URI,而不是 Asset 对象。目标端会自动将其还原为 Asset 数组。

6. onContinue 未调用

  • 原因module.json5 中未配置 continuable: truecontinueType 缺失。
  • 解决:添加配置,并确保双端登录同一华为账号、WLAN 和蓝牙开启、系统设置中开启“接续”。

7. status 监听器不可在 onDestroy 中清理

  • 现象:迁移完成后关闭应用(或应用被系统回收),在 onDestroy 中调用 globalDataObject.off('status') 导致应用崩溃。
  • 原因globalDataObject 可能在 Ability 销毁前已被系统释放,调用 off 会访问非法。
  • 解决:在 restored 状态下,处理完数据后立即调用 globalDataObject.off('status') 移除监听,不要依赖 Ability 生命周期,或者不写由系统自行管理。

七、分布式数据对象使用步骤总结

7.1 完整使用流程

角色 步骤 API 说明
源端 1 distributedDataObject.create(context, source) 创建分布式对象,source 为包含基础数据的普通对象
2 dataObject.setAsset(key, uri) 如果有本地文件资产,单独设置资产字段(uri 为字符串)
3 distributedDataObject.genSessionId() 生成唯一的会话 ID
4 wantParam['customSessionKey'] = sessionId 将 sessionId 存入 wantParam必须使用自定义 key(避免与系统保留的 sessionId 冲突)
5 dataObject.setSessionId(sessionId) 激活分布式对象,加入会话
6 dataObject.save(targetDeviceId) 将数据持久化到目标设备
目标端 1 want.parameters 取出 sessionId 例如 want.parameters['customSessionKey']
2 distributedDataObject.create(context, emptyObject) 创建空对象,所有需要同步的属性必须显式设为 undefined
3 dataObject.on('status', callback) 注册状态监听,等待 status === 'restored'
4 dataObject.setSessionId(sessionId) 加入同一个会话,触发数据同步
5 restored 回调中从 dataObject 读取属性 例如 dataObject['videoUrl']dataObject['assets']

7.2 资产同步的关键细节

  • 资产文件必须位于分布式目录:源端在调用 setAsset 之前,需将本地文件复制到 context.distributedFilesDir(或确保文件已在此目录下),并获取其标准 URI(file:// 格式)。目标端收到资产后,系统会自动将文件下载到本地分布式目录,并通过 assets[0].uri 提供可访问的 URI。
  • setAsset 的第二个参数是字符串 URI,不是 Asset 对象:常见错误是传入完整的 Asset 对象,导致目标端收到的资产变成数组而非单个对象。正确做法:
    const assetObj = await copyToDistributedDir(...);
    await dataObject.setAsset('assets', assetObj.uri);  // 传递字符串 URI
    
  • 接收端资产字段会自动转为数组:即使源端只设置了一个资产,目标端读取 dataObject['assets'] 得到的也是一个数组(commonType.Assets)。因此页面中应使用 assets[0]?.uri 获取播放地址。

7.4 sessionId 传递注意事项

  • 不能使用 wantParam['sessionId']:系统可能占用 sessionId 字段,必须使用自定义 key,如 'distributedSessionId''dataSessionId'
  • 目标端必须从 want.parameters 中读取相同的 key:两端 key 必须完全一致。

7.5 性能与限制

  • 每个分布式数据对象大小限制:跨端迁移场景下不超过 150KB(资产文件不计入此限制)。
  • 单个应用最多可创建 16 个分布式数据对象实例。
  • 最多支持 3 台设备同时数据协同。

7.6 调试建议

  • 在源端 onContinue 和目标端 restored 回调中打印详细日志,确认数据是否成功传递。
  • 检查 module.json5 中是否配置 continuable: true
  • 确认设备满足接续环境要求:同一华为账号、WLAN+蓝牙开启、系统设置中开启“接续”。

如果你的应用只需要迁移在线视频的播放进度,请参考上一篇《用 want.param 实现视频播放器跨端迁移续播》,实现更简单、迁移更快。

如果觉得本文对你有帮助,请点赞、收藏、转发,如果有错误、工程有问题请联系我!

Logo

一站式 AI 云服务平台

更多推荐