引言
鸿蒙的分布式能力是它与单机操作系统最本质的差别:设备之间不再是「连蓝牙或投屏」的松散关系,而是被抽象成一个超级终端,应用可以跨设备调用能力、迁移任务、同步数据。这套能力的技术底座是分布式软总线,它把发现、组网、传输、认证四件事统一封装,对上层只暴露「设备」与「能力」两个概念。
开发者最容易混淆的一点是「多端适配」与「跨设备协同」。前者是 UI 层面的响应式布局,解决同一份代码在不同屏幕上怎么排版;后者是运行时层面的设备互联,解决同一份任务在不同设备之间怎么流转。两者完全独立,一个应用可以只做前者,也可以只做后者。本文只讲后者,布局适配部分请看 一次开发多端部署与响应式适配 。
跨设备协同真正的难点在状态的一致性:迁移发生时,旧设备要冻结并保存状态,新设备要恢复并接管,中间任何一步失败都会让用户看到「接续后白屏」或「数据错乱」。本文按「发现、认证、迁移、同步、调用」五条线展开,并给出可迁移阅读器的完整落地方式。
目录
- 分布式能力的三层架构
- 分布式软总线:发现与组网
- 设备发现与 deviceManager
- 可信设备与认证
- 跨设备迁移的保存与恢复
- 迁移的触发条件与限制
- 分布式数据对象 distributedDataObject
- 分布式数据同步:KV 与文件
- 跨端调用与权限申请
- 多设备协同的典型场景
- 实战:可迁移的阅读器
- 权衡取舍
- 常见坑清单
- 小结
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_DATASYNC | user_grant |
| 跨设备拉起 Ability | ohos.permission.DISTRIBUTED_DATASYNC | user_grant |
| 分布式数据同步 | ohos.permission.DISTRIBUTED_DATASYNC | user_grant |
| 跨设备相机调用 | ohos.permission.CAMERA | user_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 |
必须接受的现实是:分布式能力的可用性依赖环境。设备不在同一局域网、用户未登录同一账号、目标设备未安装应用,都会让功能不可用。因此工程上必须做「能力探测 + 优雅降级」:发现不了设备时把迁移入口隐藏或置灰,而不是让用户点进去报错。这与 物联网架构总览 里讲的「设备不可达是常态而非异常」是同一个设计前提。
降级策略要分三档来设计,而不是简单的「有或没有」:
| 环境状态 | 应展示的交互 | 兜底方案 |
|---|---|---|
| 有可信设备且已安装 | 直接展示迁移按钮与设备列表 | 无 |
| 有可信设备但未安装 | 展示按钮,点击后引导安装 | 提示跳转应用市场 |
| 无可信设备 | 隐藏迁移入口 | 提供本机多端账号同步 |
第三档的兜底最有价值:如果用户确实无法使用跨设备迁移,至少要保证「换设备登录后进度还在」。这要求关键状态同时写一份到账号维度的云端,而不是只依赖设备间的直连同步。把分布式协同当增强、把账号同步当基线,是这类功能最稳妥的分层方式。
常见坑清单
- 设备列表为空但设备就在旁边。 检查是否登录同一华为账号、是否在同一局域网,跨账号设备永远发现不了。
createDeviceManager的 bundleName 写错。 权限校验失败,返回空列表且不报错。- 把
networkId当稳定主键持久化。 重新组网后该值会变化,业务主键必须用自己的 ID。 - 只在
module.json5声明 DISTRIBUTED_DATASYNC。 它是 user_grant 权限,必须运行时申请,否则调用返回 201。 onContinue里做耗时操作。 读取数据库或发网络请求会超时,迁移被判定失败。- 在
onContinue里传不可序列化对象。PixelMap、UIContext会导致迁移中断。 - 等待独立的恢复回调。 迁移没有恢复回调,数据通过
onCreate的want.parameters送达。 - 分布式数据对象的 sessionId 不一致。 两端 sessionId 不同则永不同步,且没有任何报错。
- 给分布式数据对象新增属性期待同步。 ArkTS 禁止动态增删属性,只有初始化时声明的属性会同步。
- 迁移目标 Ability 的 launchType 不是 singleton。 迁移失败,且错误信息不指向这个原因。
- 把迁移入口常驻显示。 环境不支持时用户点击必失败,应做能力探测后动态隐藏。
小结
跨设备协同的技术底座是软总线,对上层暴露的只有「设备」与「能力」两个概念,因此排查问题的顺序永远是自下而上:先确认设备能发现、再确认已完成认证、然后才排查数据与任务。迁移这条链路的要点是「保存要快、恢复要幂等」——onContinue 里只做同步读取与赋值,恢复逻辑写在 onCreate 并按 launchReason 分支。数据同步这条链路的要点是「小状态用对象、大内容走文件」,并且接受最终一致而非严格时序。
最后一条工程建议:把「环境不支持」当成一等公民来设计。分布式能力依赖账号、网络、设备三方条件,任何一环不满足都不可用,因此能力探测与降级提示必须和功能本身一起实现,而不是等用户报障后再补。布局层面的多端适配与运行时协同是两条独立的线,前者见 一次开发多端部署与响应式适配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。