鸿蒙分布式软总线与跨设备迁移

本文讲清 HarmonyOS NEXT 的运行时跨设备协同:分布式软总线的发现与组网、可信设备认证、跨设备迁移的保存与恢复回调、分布式数据对象与 KV 同步、跨端调用与权限申请,以及多设备协同场景的落地方式、环境依赖判断与优雅降级策略,并附常见坑清单。

引言

鸿蒙的分布式能力是它与单机操作系统最本质的差别:设备之间不再是「连蓝牙或投屏」的松散关系,而是被抽象成一个超级终端,应用可以跨设备调用能力、迁移任务、同步数据。这套能力的技术底座是分布式软总线,它把发现、组网、传输、认证四件事统一封装,对上层只暴露「设备」与「能力」两个概念。

开发者最容易混淆的一点是「多端适配」与「跨设备协同」。前者是 UI 层面的响应式布局,解决同一份代码在不同屏幕上怎么排版;后者是运行时层面的设备互联,解决同一份任务在不同设备之间怎么流转。两者完全独立,一个应用可以只做前者,也可以只做后者。本文只讲后者,布局适配部分请看 一次开发多端部署与响应式适配 。

跨设备协同真正的难点在状态的一致性:迁移发生时,旧设备要冻结并保存状态,新设备要恢复并接管,中间任何一步失败都会让用户看到「接续后白屏」或「数据错乱」。本文按「发现、认证、迁移、同步、调用」五条线展开,并给出可迁移阅读器的完整落地方式。

目录

  1. 分布式能力的三层架构
  2. 分布式软总线:发现与组网
  3. 设备发现与 deviceManager
  4. 可信设备与认证
  5. 跨设备迁移的保存与恢复
  6. 迁移的触发条件与限制
  7. 分布式数据对象 distributedDataObject
  8. 分布式数据同步:KV 与文件
  9. 跨端调用与权限申请
  10. 多设备协同的典型场景
  11. 实战:可迁移的阅读器
  12. 权衡取舍
  13. 常见坑清单
  14. 小结

1. 分布式能力的三层架构

鸿蒙把分布式能力分成三层,理解每层的职责是选型的前提。

层次组件职责对开发者可见的 API
通信层分布式软总线发现、组网、传输、认证间接使用,不直接调用
数据层分布式数据管理KV 同步、数据对象、文件同步distributedKVStore、distributedDataObject
任务层分布式任务调度迁移、跨端启动、能力调用distributedMissionManager、want 的 deviceId

三层是依赖关系:数据层与任务层都建立在软总线之上,因此设备未组网时上层全部失效。开发中最常见的排查思路就是先确认「设备列表里能不能看到对方」,再往上排查数据与任务。

一个关键约束:所有分布式能力都要求设备登录同一个华为账号并处于同一局域网(或已通过蓝牙组网)。跨账号的设备即使在同一 WiFi 下也发现不了,这是排查「设备搜不到」时的第一检查项。

2. 分布式软总线:发现与组网

分布式软总线不是一个可以 import 的模块,而是系统在后台维护的一条虚拟总线。它的工作流程是:发现设备、建立可信关系、按需建立传输通道、在通道上跑上层协议。

阶段触发时机使用的传输开发者可干预的点
发现应用请求设备列表蓝牙、WiFi 广播申请设备发现权限
认证首次建立可信关系软总线内部弹窗确认、PIN 码
组网认证通过后自动完成WiFi P2P 或局域网无需干预
传输首次调用上层 API按数据类型自动选择选择同步方式与数据规模

软总线会自动选择传输通道:小数据走局域网,大文件走 WiFi P2P,无网络时降级到蓝牙。开发者不需要指定通道,但需要知道通道切换会带来延迟抖动,因此跨设备操作不能假设「和本地一样快」。

一个工程上的经验值:设备发现的首轮结果通常在 1 到 3 秒内返回,但结果会持续更新。正确做法是订阅设备状态变化事件,而不是只调一次 getAvailableDeviceListSync 就完事。

3. 设备发现与 deviceManager

设备发现通过 distributedDeviceManager 完成,它提供同步列表查询与状态变化订阅两种方式。

import { distributedDeviceManager } from '@kit.DistributedServiceKit';
import { BusinessError } from '@kit.BasicServicesKit';

export function listDevices(): distributedDeviceManager.DeviceBasicInfo[] {
  const dm = distributedDeviceManager.createDeviceManager('com.example.reader');
  const devices = dm.getAvailableDeviceListSync();
  devices.forEach((d: distributedDeviceManager.DeviceBasicInfo) => {
    console.info(`device: ${d.deviceName} ${d.networkId} type=${d.deviceType}`);
  });
  return devices;
}

export function watchDevices(onChange: () => void): void {
  const dm = distributedDeviceManager.createDeviceManager('com.example.reader');
  dm.on('deviceStateChange', (data: distributedDeviceManager.DeviceStateChange) => {
    // action 为 available 或 unavailable
    console.info(`state change: ${data.action} ${data.device.deviceName}`);
    onChange();
  });
  // 主动触发一次发现,结果是异步回填到 getAvailableDeviceListSync
  dm.getAvailableDeviceList((err: BusinessError, list) => {
    if (err) { console.error(`discover failed: ${err.code}`); }
  });
}

createDeviceManager 的第一个参数是调用方的 bundleName,写错会导致权限校验失败。networkId 是设备在软总线内的唯一标识,是后续所有跨设备 API 的关键参数,但它不是稳定不变的——设备重新组网后可能变化,因此不能把它当业务主键持久化。

deviceType 用于区分设备形态(phone、tablet、tv、car、wearable),做跨设备能力分发时用它决定「这个任务该迁到哪台设备」。业务上通常按优先级排序:手机优先、平板次之、智慧屏只做展示。

4. 可信设备与认证

发现到的设备未必是可信设备。首次与某台设备交互时,系统会弹出一个认证确认框,用户确认后才建立可信关系。

状态含义能否调用上层能力
available已发现且在同一网络否,仅可见
trusted已完成认证,建立可信关系是
unavailable已离线或超出范围否

DeviceBasicInfo 里的 deviceType 之外还有一个隐含字段判断可信性,实践中更可靠的做法是直接尝试调用上层 API,用返回的错误码判断。认证失败的错误码通常是 201(权限)或 16000001(设备不可信),两者排查方向完全不同:前者是权限没申请,后者是用户拒绝了认证。

认证的粒度是「应用 + 设备」而不是「账号 + 设备」,所以同一个应用在换设备安装后需要重新认证一次。这解释了为什么「明明同一个账号却调用失败」——认证关系绑定在应用身份上。

权限申请是进入分布式世界的第一道门,写法与普通运行时权限一致,但必须显式检查用户是否拒绝:

import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

export async function ensureDistributedPermission(
  context: common.UIAbilityContext): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();
  const permissions: Permissions[] = ['ohos.permission.DISTRIBUTED_DATASYNC'];
  try {
    const result = await atManager.requestPermissionsFromUser(context, permissions);
    // authResults 中 0 表示已授权,-1 表示被拒绝
    return result.authResults.every((code: number) => code === 0);
  } catch (err) {
    const e = err as BusinessError;
    console.error(`request permission failed: ${e.code}`);
    return false;
  }
}

被拒绝后不要反复弹窗,应该把迁移入口置灰并提示「需要分布式权限」,同时在设置页提供跳转授权的引导。反复弹窗会被系统限制,用户拒绝第二次之后短时间内不会再弹出,表现为「点了按钮没反应」。

5. 跨设备迁移的保存与恢复

跨设备迁移的核心是两个回调:源设备上的 onContinue 与目标设备上的恢复逻辑。

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';

export default class ReaderAbility extends UIAbility {
  // 源设备:系统决定迁移时回调,在这里保存需要带走的状态
  onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult {
    const progress = AppStorage.get<number>('readProgress') ?? 0;
    const bookId = AppStorage.get<string>('bookId') ?? '';
    if (bookId.length === 0) {
      // 没有可迁移的内容,直接拒绝,系统会给出提示
      return AbilityConstant.OnContinueResult.REJECT;
    }
    wantParam['readProgress'] = progress;
    wantParam['bookId'] = bookId;
    wantParam['savedAt'] = Date.now();
    return AbilityConstant.OnContinueResult.AGREE;
  }
}
// 目标设备:迁移过来的数据通过 onCreate 的 want.parameters 送达
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) {
    const bookId = want.parameters?.['bookId'] as string;
    const progress = want.parameters?.['readProgress'] as number;
    AppStorage.setOrCreate('bookId', bookId);
    AppStorage.setOrCreate('readProgress', progress);
  }
}

这里有三个必须记住的语义:其一,迁移没有独立的恢复回调,恢复数据通过重新拉起时的 onCreate 送达,靠 launchReason 区分;其二,onContinue 的返回值决定迁移是否继续,返回 REJECT 会中断迁移;其三,wantParam 只接受可序列化的基础类型,PixelMap、UIContext 一律不能传。

想迁移的数据如果太大(例如整本书的离线内容),正确做法是提前同步到目标设备的分布式文件,迁移时只传文件标识符与进度。这和 鸿蒙网络请求与数据持久化 里讲的「大块数据落盘再传标识符」是同一条原则。

6. 迁移的触发条件与限制

迁移不是随时都能发生的,系统对触发条件有明确约束。

触发方式条件备注
用户主动迁移在最近任务列表点击迁移按钮最常用,无需应用干预
应用发起迁移调用迁移接口并指定目标设备需要迁移权限
系统自动迁移智慧场景联动(如检测到用户切换设备)依赖系统能力,应用只能被动响应

迁移有明确的限制,踩中任意一条都会导致迁移失败:目标设备必须已完成认证;应用必须在两台设备上都已安装且版本兼容;迁移的目标 Ability 必须是 launchType 为 singleton 的 UIAbility;onContinue 的执行时间有上限(通常几百毫秒),超时会被判定为失败。

onContinue 里不能做耗时操作是最重要的一条。读取数据库、发起网络请求、解码图片都会导致超时。正确做法是把这些数据提前准备好放在内存或 Preferences 里,onContinue 只做一次同步读取与赋值。

7. 分布式数据对象 distributedDataObject

分布式数据对象是跨设备共享内存对象的机制,多个设备上的同名对象会自动保持同步。

import { distributedDataObject } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';

interface ReadingState {
  bookId: string;
  progress: number;
  lastReadAt: number;
}

export class SharedReading {
  private obj: distributedDataObject.DataObject | null = null;

  create(sessionId: string, init: ReadingState): void {
    this.obj = distributedDataObject.create(getContext(this), init);
    this.obj.setSessionId(sessionId);

    // 本地修改会自动同步到同 sessionId 的其他设备
    this.obj.on('change', (sessionId: string, fields: Array<string>) => {
      console.info(`remote change: ${fields.join(',')}`);
    });

    this.obj.on('status', (sessionId: string, networkId: string, status: string) => {
      console.info(`sync status: ${status} on ${networkId}`);
    });
  }

  updateProgress(progress: number): void {
    if (this.obj === null) { return; }
    // 属性名必须与初始化时一致,否则不会触发同步
    this.obj['progress'] = progress;
  }
}

两个容易踩的坑:sessionId 是同步的命名空间,只有 sessionId 相同的对象才会互相同步,不同设备上创建的 sessionId 必须一致;属性修改必须走「已有属性赋值」的方式,新增属性不会同步,这也是 ArkTS 禁止动态增删属性带来的连锁约束。

分布式数据对象适合小规模、高频变化的状态(进度、开关、光标位置),不适合大对象。它的同步是最终一致的,不保证时序,因此不能用它做需要严格顺序的业务状态。

8. 分布式数据同步:KV 与文件

数据层的另外两条通道是分布式 KV 与分布式文件。

方案同步粒度冲突策略适用数据
distributedKVStore键值对按时间戳覆盖配置、偏好、小状态
distributedDataObject内存对象字段级覆盖实时协同状态
分布式文件文件最后写入者胜出离线内容、导出文件、图片
RDB(本地)表不跨设备结构化业务数据

distributedKVStore 需要 ohos.permission.DISTRIBUTED_DATASYNC 权限,且必须先通过 KVManager 创建 store,再调用 sync 指定目标设备列表。它的 API 比 Preferences 重得多,单设备应用不要引入,只有在确实需要跨设备同步时才用。

import { distributedKVStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export async function syncSetting(context: common.UIAbilityContext,
  deviceIds: string[], key: string, value: string): Promise<void> {
  const config: distributedKVStore.KVManagerConfig = {
    context: context,
    bundleName: 'com.example.reader'
  };
  const manager = distributedKVStore.createKVManager(config);
  const store = await manager.getKVStore('reader_settings', {
    createIfMissing: true,
    encrypt: false,
    backup: false,
    kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION,
    securityLevel: distributedKVStore.SecurityLevel.S1
  });

  await store.put(key, value);
  // 指定设备列表同步,传空数组表示同步到所有可信设备
  await store.sync(deviceIds, distributedKVStore.SyncMode.PUSH_PULL);
}

SyncMode 有三个取值:PUSH 只把本地改动推出去,PULL 只拉取远端改动,PUSH_PULL 双向同步。多数场景用 PUSH_PULL,但要注意它的冲突策略是「按写入时间戳覆盖」,两端几乎同时修改同一个键时,后写入的一方会赢,业务上要能容忍这种丢更新。

分布式文件的路径通过 context.distributedFilesDir 获取,写入后调用 fileIo 的跨设备拷贝接口同步。要注意分布式文件目录的空间通常有限,不要把它当成大容量缓存用。文件同步没有冲突合并能力,两端同时写入同一路径的结果是「最后写入者胜出」,因此共享文件应该按设备加命名空间,避免互相覆盖。

9. 跨端调用与权限申请

跨端调用有两种形态:拉起其他设备上的 Ability,或调用其他设备上的 ExtensionAbility。

import { common, Want, StartOptions } from '@kit.AbilityKit';

export function startOnDevice(context: common.UIAbilityContext, deviceId: string): void {
  const want: Want = {
    bundleName: 'com.example.reader',
    abilityName: 'ReaderAbility',
    parameters: { bookId: '1001' }
  };
  const options: StartOptions = { deviceId: deviceId };
  context.startAbility(want, options).catch((err) => {
    console.error(`cross-device start failed: ${err.code}`);
  });
}

权限是跨端调用最常见的失败原因,需要申请的权限按能力区分:

能力必需权限授权模式
设备发现与组网ohos.permission.DISTRIBUTED_DATASYNCuser_grant
跨设备拉起 Abilityohos.permission.DISTRIBUTED_DATASYNCuser_grant
分布式数据同步ohos.permission.DISTRIBUTED_DATASYNCuser_grant
跨设备相机调用ohos.permission.CAMERAuser_grant

注意 DISTRIBUTED_DATASYNC 是 user_grant 权限,必须在运行时通过 requestPermissionsFromUser 申请,只在 module.json5 里声明是不够的。声明了但没申请的表现是「调用返回 201」,很多人会误以为是签名问题。

10. 多设备协同的典型场景

跨设备协同的价值体现在几个具体场景里,理解这些场景有助于判断哪些能力值得接。

场景使用的能力关键约束
阅读进度接续跨设备迁移 + 分布式数据对象迁移数据必须小
跨端拍照跨端调用 + 相机权限目标设备需在可信列表
会议投屏跨端拉起 + 分布式文件大文件需提前同步
多设备剪贴板分布式数据对象仅小文本
手表遥控手机播放跨端调用 ExtensionAbility需要长连接保活

场景选型的一条经验:优先做「接续」而不是「遥控」。接续是状态迁移,一次完成,失败面小;遥控需要保持长连接与状态同步,任何一端掉线都要处理重连,复杂度高一个量级。绝大多数应用做接续就能覆盖 80% 的用户价值。

还有一条容易被忽略的交互约束:跨设备操作必须给用户明确的设备选择入口。系统虽然能给出推荐设备,但用户对「任务被送到哪台设备」有强预期,自动选择而不提示会让人困惑。稳妥的交互是「默认推荐 + 可切换」:优先展示最近使用的可信设备,同时提供完整设备列表,用户切换后记住偏好。

11. 实战:可迁移的阅读器

把迁移与同步组合起来,做一个阅读进度接续的完整链路。

// 阅读页:进度变化时同步本地与分布式状态
@Entry
@Component
struct ReaderPage {
  @State progress: number = 0;
  private shared: SharedReading = new SharedReading();

  aboutToAppear(): void {
    const bookId = AppStorage.get<string>('bookId') ?? '';
    this.shared.create(`reading_${bookId}`, {
      bookId: bookId,
      progress: this.progress,
      lastReadAt: Date.now()
    });
  }

  private onScrollEnd(offset: number): void {
    this.progress = offset;
    // 本地状态:供 onContinue 读取
    AppStorage.setOrCreate('readProgress', offset);
    // 分布式状态:供其他设备实时同步
    this.shared.updateProgress(offset);
  }
}

这套设计的要点是两条通道各司其职:AppStorage 承载「迁移时一次性带走」的状态,在 onContinue 里同步读取;分布式数据对象承载「其他设备实时可见」的状态,用于多设备同时打开同一本书的场景。如果只做迁移,分布式数据对象可以省掉;如果只做多端同步,onContinue 可以省掉。两者都上,复杂度翻倍但覆盖了完整场景。

权衡取舍

跨设备协同的取舍集中在「覆盖场景」与「失败面」之间。

决策点方案 A方案 B建议
状态传递迁移时一次性带走分布式对象实时同步只做接续选 A,多端同时在线选 B
大数据处理迁移时全量传提前同步文件传标识符一律选 B,迁移数据必须小
设备选择用户手动选按 deviceType 自动排序提供默认推荐 + 手动兜底
冲突处理时间戳覆盖用户手动选择简单状态用 A,复杂内容用 B

必须接受的现实是:分布式能力的可用性依赖环境。设备不在同一局域网、用户未登录同一账号、目标设备未安装应用,都会让功能不可用。因此工程上必须做「能力探测 + 优雅降级」:发现不了设备时把迁移入口隐藏或置灰,而不是让用户点进去报错。这与 物联网架构总览 里讲的「设备不可达是常态而非异常」是同一个设计前提。

降级策略要分三档来设计,而不是简单的「有或没有」:

环境状态应展示的交互兜底方案
有可信设备且已安装直接展示迁移按钮与设备列表无
有可信设备但未安装展示按钮,点击后引导安装提示跳转应用市场
无可信设备隐藏迁移入口提供本机多端账号同步

第三档的兜底最有价值:如果用户确实无法使用跨设备迁移,至少要保证「换设备登录后进度还在」。这要求关键状态同时写一份到账号维度的云端,而不是只依赖设备间的直连同步。把分布式协同当增强、把账号同步当基线,是这类功能最稳妥的分层方式。

常见坑清单

  1. 设备列表为空但设备就在旁边。 检查是否登录同一华为账号、是否在同一局域网,跨账号设备永远发现不了。
  2. createDeviceManager 的 bundleName 写错。 权限校验失败,返回空列表且不报错。
  3. 把 networkId 当稳定主键持久化。 重新组网后该值会变化,业务主键必须用自己的 ID。
  4. 只在 module.json5 声明 DISTRIBUTED_DATASYNC。 它是 user_grant 权限,必须运行时申请,否则调用返回 201。
  5. onContinue 里做耗时操作。 读取数据库或发网络请求会超时,迁移被判定失败。
  6. 在 onContinue 里传不可序列化对象。 PixelMap、UIContext 会导致迁移中断。
  7. 等待独立的恢复回调。 迁移没有恢复回调,数据通过 onCreate 的 want.parameters 送达。
  8. 分布式数据对象的 sessionId 不一致。 两端 sessionId 不同则永不同步,且没有任何报错。
  9. 给分布式数据对象新增属性期待同步。 ArkTS 禁止动态增删属性,只有初始化时声明的属性会同步。
  10. 迁移目标 Ability 的 launchType 不是 singleton。 迁移失败,且错误信息不指向这个原因。
  11. 把迁移入口常驻显示。 环境不支持时用户点击必失败,应做能力探测后动态隐藏。

小结

跨设备协同的技术底座是软总线,对上层暴露的只有「设备」与「能力」两个概念,因此排查问题的顺序永远是自下而上:先确认设备能发现、再确认已完成认证、然后才排查数据与任务。迁移这条链路的要点是「保存要快、恢复要幂等」——onContinue 里只做同步读取与赋值,恢复逻辑写在 onCreate 并按 launchReason 分支。数据同步这条链路的要点是「小状态用对象、大内容走文件」,并且接受最终一致而非严格时序。

最后一条工程建议:把「环境不支持」当成一等公民来设计。分布式能力依赖账号、网络、设备三方条件,任何一环不满足都不可用,因此能力探测与降级提示必须和功能本身一起实现,而不是等用户报障后再补。布局层面的多端适配与运行时协同是两条独立的线,前者见 一次开发多端部署与响应式适配 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. ArkUI 动画体系与手势交互
  3. 鸿蒙应用安全:权限模型与 HUKS 密钥管理