跨端迁移:实现应用状态在手机与平板间无缝流转(63)

在鸿蒙(HarmonyOS)生态中,跨端迁移(应用接续)是实现“人随场景走、服务随人走”的核心能力。它允许用户在手机上进行的操作(如编辑文档、观看视频、浏览网页),无缝流转至平板或智慧屏上继续,且保持上下文状态完全一致。

这一过程并非将运行内存直接搬运,而是由分布式任务调度分布式软总线应用状态保存/恢复机制三者协同完成的。以下是跨端迁移的底层原理及代码。

一、 核心运作机制

  1. 状态快照与序列化:源端设备(如手机)在发起迁移前,系统会回调onContinue()接口。开发者需在此接口中将当前页面的业务数据(如文档内容、光标位置、视频播放进度)序列化为轻量级状态数据(通常限制在 100KB 以内)。
  2. 软总线传输与任务拉起:分布式软总线负责设备发现、可信认证并建立加密通道,将状态数据传输至目标设备(如平板)。同时,分布式任务调度负责在目标设备上拉起对应的 UIAbility。
  3. 状态恢复与重建:目标设备的 UIAbility 被拉起时,系统通过onCreate()onNewWant()接口将迁移数据传递给应用。应用反序列化这些数据,重新构建 UI 界面并恢复业务状态。

二、 跨端迁移代码实战

1. 前置配置:开启迁移能力

在应用的module.json5中,必须将continuable标签配置为true,否则系统会识别该应用无法迁移。同时需申请分布式相关权限:

{ "module": { "requestPermissions": [ { "name": "ohos.permission.DISTRIBUTED_DATASYNC" }, { "name": "ohos.permission.GET_DISTRIBUTED_DEVICE_INFO" } ], "abilities": [ { "name": "EntryAbility", "continuable": true } ] } }
2. 源端实现:保存业务状态

在源端 UIAbility 中重写onContinue接口,将当前需要恢复的状态打包到wantParam中:

import { UIAbility, AbilityConstant } from '@kit.AbilityKit'; export default class EntryAbility extends UIAbility { // 当用户触发跨设备迁移时,系统会调用此方法 onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult { try { // 1. 获取当前业务状态(如文档编辑内容、光标位置) const currentState = { docId: 'DOC_20260105', content: 'Hello from Phone!', cursorPos: 18 }; // 2. 将状态数据写入 wantParam(注意数据大小需控制在 100KB 以内) wantParam['migrationData'] = JSON.stringify(currentState); console.info('状态保存成功,准备迁移'); return AbilityConstant.OnContinueResult.AGREE; // 同意迁移 } catch (error) { console.error('状态保存失败:', error); return AbilityConstant.OnContinueResult.REJECT; // 拒绝迁移 } } }
3. 目标端实现:恢复业务状态

在目标端 UIAbility 中,通过判断启动原因(LaunchReason)来提取数据并恢复 UI:

import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit'; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { // 判断是否为跨设备迁移触发的冷启动 if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) { const dataStr = want.parameters?.['migrationData'] as string; if (dataStr) { const restoredState = JSON.parse(dataStr); console.info('成功恢复迁移状态:', restoredState); // 将恢复的数据存入全局状态(如 AppStorage),供 UI 页面读取 AppStorage.setOrCreate('resumeData', restoredState); } } } }
4. UI 层:读取状态并渲染

在 ArkUI 页面中,通过aboutToAppear生命周期读取恢复的数据,实现无缝衔接:

import { AppStorage } from '@kit.ArkUI'; @Entry @Component struct EditorPage { @State textContent: string = ''; @State cursorPosition: number = 0; aboutToAppear() { // 读取目标端 Ability 传递过来的恢复数据 const resumeData = AppStorage.get<Record<string, Object>>('resumeData'); if (resumeData) { this.textContent = resumeData['content'] as string; this.cursorPosition = resumeData['cursorPos'] as number; } } build() { Column() { TextInput({ text: this.textContent, placeholder: '继续编辑...' }) .onChange((value) => { this.textContent = value; }) } .width('100%') .height('100%') .padding(20) } }

三、 双向回迁(Reversible Continuation)

在某些场景下(如用户在平板上编辑了一半,发现还是手机方便),用户希望任务能从目标设备“回退”到源设备。鸿蒙提供了continueAbilityReversibly机制,允许源设备在迁移后保持后台存活,并支持随时回迁。

  • 发起可逆迁移:源端调用continueAbilityReversibly代替普通的continueAbility
  • 处理回迁通知:源端需重写onRemoteTerminated回调。当目标设备完成任务并结束,或者用户主动回迁时,源端会收到此通知,从而重新接管 UI 焦点。
1. 源端:发起可逆迁移

在源设备(如手机)上,开发者需要调用continueAbilityReversibly接口来发起迁移。与普通的continueAbility不同,该接口会保留源端应用的生命周期,使其在后台挂起等待回迁。

import { UIAbility } from '@kit.AbilityKit'; export default class SourceAbility extends UIAbility { // 发起双向回迁 startReversibleContinuation(targetDeviceId: string) { try { // 调用可逆迁移接口,传入目标设备的 deviceId this.continueAbilityReversibly(targetDeviceId); console.info('已发起可逆迁移,源端应用进入后台挂起状态'); } catch (err) { console.error('发起可逆迁移失败:', err); } } }
2. 目标端:执行回迁操作

当用户在目标设备(如平板)上完成当前任务,希望将应用流转回源设备时,目标端应用只需调用reverseContinueAbility接口即可。

import { UIAbility } from '@kit.AbilityKit'; export default class TargetAbility extends UIAbility { // 用户在平板上点击“返回手机继续”按钮时触发 onReturnToSourceClick() { try { // 触发回迁流程,系统会将控制权交还给源设备 this.reverseContinueAbility(); console.info('已发起回迁,目标端应用即将销毁'); } catch (err) { console.error('回迁失败:', err); } } }
3. 源端:监听回迁通知并恢复 UI

当目标设备触发回迁后,源设备会收到系统的回调通知。在早期的 HarmonyOS 架构(如基于 Java/JS 的 FA 模型)中,开发者需要重写onRemoteTerminated方法来感知这一事件,并重新激活 UI。

import { UIAbility } from '@kit.AbilityKit'; export default class SourceAbility extends UIAbility { // 重写 onRemoteTerminated 回调 onRemoteTerminated() { console.info('收到目标端回迁通知,源端应用重新接管 UI'); // 在此处执行恢复前台焦点的逻辑 // 例如:刷新当前页面状态、恢复视频播放、重新获取焦点等 this.restoreUIState(); } private restoreUIState() { // 恢复业务状态和 UI 焦点 console.info('UI 焦点已恢复,用户可继续操作'); } }

四、 完整页面栈(Navigation Stack)迁移

对于复杂的多级页面应用,仅迁移当前页面的状态是不够的,用户期望在目标设备上能继续点击“返回”回到上一级页面。

  • 栈序列化:在onContinue中,开发者需要将当前的navPathStack(或 Router 栈)连同每个页面的状态一并序列化打包。
  • 栈重建:目标设备在onCreate中解析数据后,不仅要恢复当前页面的 UI,还要通过代码自动执行pushPath将历史页面栈重新压入,实现真正的“无缝接续”。
1、 基于 Navigation 路由的页面栈迁移

对于使用Navigation组件构建的应用,系统目前暂不支持自动恢复页面栈,开发者需要手动获取栈快照、通过Want传递,并在目标端手动重建。

1. 源端:获取并序列化 Navigation 页面栈

在源端的onContinue生命周期中,获取当前的NavPathStack快照,并将其写入wantParam

import { AbilityConstant, UIAbility } from '@kit.AbilityKit'; export default class EntryAbility extends UIAbility { onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult { // 1. 从全局状态中获取当前的 NavPathStack let pathStack = AppStorage.get('navPathStack') as NavPathStack; let navPathInfo = pathStack.getPathStack(); // 获取页面栈快照数组 // 2. 将页面栈信息写入 wantParam 传递给目标端 wantParam['navPathStack'] = navPathInfo; console.info('Navigation 页面栈已打包,准备迁移'); return AbilityConstant.OnContinueResult.AGREE; } }
2. 目标端:解析数据并恢复栈

在目标端的onCreateonNewWant中读取栈数据,存入AppStorage

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) { // 1. 读取目标端传递过来的 Navigation 页面栈快照 if (Array.isArray(want.parameters?.['navPathStack'])) { AppStorage.setOrCreate('NavPathInfo', want.parameters['navPathStack'] as Array<NavPathInfo>); } else { AppStorage.setOrCreate('NavPathInfo', []); } // 2. 恢复窗口状态 this.context.restoreWindowStage(new LocalStorage()); } } }
3. UI 层:自动重建页面栈

Navigation根页面的onPageShow生命周期中,判断是否存在迁移数据,若存在则自动压入历史页面:

import { AppStorage } from '@kit.ArkUI'; @Entry @Component struct IndexPage { @StorageProp('NavPathInfo') navPathInfo: Array<NavPathInfo> = []; @StorageLink('navPathStack') pageStack: NavPathStack = new NavPathStack(); onPageShow(): void { // 如果存在迁移过来的页面路径信息,自动重建页面栈 if (this.navPathInfo && this.navPathInfo.length > 0) { this.navPathInfo.forEach((pathInfo) => { this.pageStack.pushPathByName(pathInfo.name, pathInfo.param); }); console.info('Navigation 历史页面栈重建完成'); } } }

2、 基于 Router 路由的页面栈迁移

对于使用传统Router组件的应用,系统提供了更便捷的自动恢复机制,开发者只需通过开关进行控制。

1. 源端:开启 Router 栈迁移开关

onContinue中,将 Router 栈迁移开关设置为true

import { AbilityConstant, UIAbility } from '@kit.AbilityKit'; import wantConstant from '@ohos.app.ability.wantConstant'; export default class EntryAbility extends UIAbility { onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult { // 开启 Router 页面栈自动迁移 wantParam[wantConstant.Params.SUPPORT_CONTINUE_PAGE_STACK_KEY] = true; console.info('Router 页面栈迁移开关已开启'); return AbilityConstant.OnContinueResult.AGREE; } }
2. 目标端:处理窗口恢复与降级

在目标端的onCreate中读取开关状态,并在onWindowStageRestore中决定加载逻辑。如果由于特殊原因关闭了栈迁移,系统会强制回落到首页:

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; import window from '@ohos.window'; export default class EntryAbility extends UIAbility { private SUPPORT_CONTINUE_PAGE_STACK_KEY: boolean = true; onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) { // 读取 Router 栈迁移开关 if (typeof want.parameters?.['ohos.extra.param.key.supportContinuePageStack'] === 'boolean') { this.SUPPORT_CONTINUE_PAGE_STACK_KEY = want.parameters['ohos.extra.param.key.supportContinuePageStack'] as boolean; } this.context.restoreWindowStage(new LocalStorage()); } } onWindowStageRestore(windowStage: window.WindowStage): void { // 如果 Router 栈迁移被关闭,强制加载首页以防出现不兼容的子页面 if (!this.SUPPORT_CONTINUE_PAGE_STACK_KEY) { windowStage.loadContent('pages/Index', (err) => { if (err.code) { console.error('加载首页失败:', err); return; } console.info('已回退并加载首页'); }); } } }

五、 突破 100KB 限制:分布式对象与文件协同

wantParam的传输大小被严格限制在 100KB 以内,这对于包含高清图片、长富文本或视频进度的应用是远远不够的。

  • 混合架构:将轻量级的状态(如光标位置、当前页码、文档 ID)通过wantParam传递;将大体积内容(如图片 Base64、视频流)存入分布式键值数据库(KV-Store)分布式文件系统(DFS)
  • 按需拉取:目标设备在接收到轻量级状态并渲染出基础 UI 后,通过相同的SessionId或分布式文件 URI,在后台静默拉取大体积数据,实现“骨架屏秒开,内容随后加载”的极致体验。
1、 源端:构建混合数据并发起迁移

在源端的onContinue中,将业务数据拆分为“轻量级状态”和“重量级内容”,分别存入不同的分布式存储中,并将对应的索引放入wantParam

import { AbilityConstant, UIAbility } from '@kit.AbilityKit'; import { distributedKVStore } from '@kit.ArkData'; export default class EntryAbility extends UIAbility { async onContinue(wantParam: Record<string, Object>): Promise<AbilityConstant.OnContinueResult> { // 1. 轻量级状态:直接放入 wantParam(远小于 100KB) const lightState = { docId: 'DOC_20260623', cursorPos: 1024, currentPage: 5 }; wantParam['lightState'] = JSON.stringify(lightState); // 2. 重量级内容:存入分布式 KV-Store(如长富文本、Base64 图片) const kvManager = distributedKVStore.createKVManager({ context: this.context, bundleName: 'com.example.app' }); const kvStore = await kvManager.getKVStore('rich_content_store'); // 假设 heavyContent 是一段 500KB 的富文本 await kvStore.put('DOC_20260623_content', this.heavyContent); // 3. 仅将文档 ID 作为索引传递,目标端通过此 ID 拉取数据 wantParam['contentRefId'] = 'DOC_20260623_content'; return AbilityConstant.OnContinueResult.AGREE; } }
2、 目标端:接收轻量数据,渲染骨架屏

目标端在onCreate中优先读取轻量级状态,立即构建基础 UI(如展示骨架屏或占位符),避免用户等待。

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) { // 1. 优先解析轻量级状态,立即驱动 UI 渲染 const lightStateStr = want.parameters?.['lightState'] as string; if (lightStateStr) { const lightState = JSON.parse(lightStateStr); AppStorage.setOrCreate('lightState', lightState); console.info('轻量状态已恢复,UI 骨架屏准备就绪'); } // 2. 提取重量级内容的引用 ID const contentRefId = want.parameters?.['contentRefId'] as string; if (contentRefId) { AppStorage.setOrCreate('contentRefId', contentRefId); } } } }
3、 UI 层:后台静默拉取,无缝填充内容

在 ArkUI 页面中,当基础 UI 渲染完成后,利用aboutToAppear生命周期在后台静默拉取大体积数据,实现平滑过渡。

import { AppStorage } from '@kit.ArkUI'; import { distributedKVStore } from '@kit.ArkData'; @Entry @Component struct EditorPage { @State isLoading: boolean = true; @State textContent: string = ''; @StorageProp('lightState') lightState: Record<string, Object> = {}; @StorageProp('contentRefId') contentRefId: string = ''; async aboutToAppear() { // 1. 后台静默拉取重量级内容 try { const kvManager = distributedKVStore.createKVManager({ context: getContext(this), bundleName: 'com.example.app' }); const kvStore = await kvManager.getKVStore('rich_content_store'); // 2. 根据索引获取完整数据 const result = await kvStore.get(this.contentRefId); this.textContent = result.value as string; console.info('重量级内容拉取完成,骨架屏替换为真实内容'); } catch (err) { console.error('后台拉取内容失败:', err); } finally { // 3. 关闭加载状态,完成无缝填充 this.isLoading = false; } } build() { Column() { if (this.isLoading) { // 骨架屏 / 占位符 UI Text('正在同步内容...').fontSize(16).fontColor(Color.Gray) } else { // 真实内容 UI TextArea({ text: this.textContent }) } } .width('100%').height('100%').padding(20) } }

六、 跨设备 UI 适配与交互接管

手机和平板的屏幕尺寸、交互方式差异巨大。迁移不仅仅是数据的转移,更是 UI 的重构。

  • 响应式布局:利用 ArkUI 的GridRow/GridCol栅格系统和MediaQuery媒体查询,在目标设备恢复数据时,自动将手机的“单列列表”切换为平板的“双列/三列分栏布局”。
  • 硬件能力接管:迁移后,应用可以感知目标设备的硬件特性。例如,从手机迁移到平板后,自动接管平板的键盘输入;从平板迁移到智慧屏时,自动切换为大屏遥控器焦点交互模式。

七、 企业级跨端迁移

  1. 状态快照的原子性:在onContinue中保存状态时,务必保证数据的原子性。如果涉及多个文件的并发写入,应加锁或采用事务机制,避免传递出“写了一半”的脏数据。
  2. 优雅降级与异常处理:分布式环境具有不确定性。必须完善onFailedContinuation的异常处理逻辑。当目标设备离线、版本不兼容或网络超时时,应在源端给出明确的 Toast 提示,并保留本地任务,避免用户操作丢失。
  3. 版本兼容性校验:在onContinuewantParam中,系统会自动携带目标设备的version。开发者应在此处进行版本号比对,若目标端应用版本过低不支持某些新字段,应进行数据裁剪或拒绝迁移,防止目标端解析崩溃。
  4. 隐私与权限前置校验:在发起迁移前,主动检查目标设备的安全等级。若当前页面包含敏感支付信息或健康数据,且目标设备为低安全等级的公共设备,应主动拦截迁移请求,保障用户隐私安全。