HarmonyOS 新手入门:ArkData 配合应用接续,手机写一半平板继续写
HarmonyOS 新手入门:ArkData 配合应用接续,手机写一半平板继续写
前面已经写了 Preferences、KVStore、DeviceKVStore。如果再往跨端体验走一步,就会碰到“应用接续”。
我的理解很简单:Preferences/KVStore 负责本机长期保存,onContinue() 负责把“此刻页面状态”交给另一台设备。两者不是替代关系,而是配合关系。
官方资料可以先看:
- 跨端迁移:应用接续投播
- 跨端迁移:应用接续数据
- 跨端迁移:应用接续流程
- 设备管理:设备发现开发指导
- Distributed Service Kit 简介
- AbilityConnectManager 开发指导
- LinkEnhance 开发指导
先说结论
应用接续解决的是“把当前任务接过去”,ArkData 解决的是“状态别丢”。
这篇不做复杂设备发现,也不模拟远端按钮,只讲清楚两条线:
- 本机落盘:
Preferences -> flush()。 - 跨端迁移:
AppStorage -> onContinue(wantParam) -> want.parameters。
这个 Demo 做什么
这次还是做草稿场景:
- 页面里编辑标题、内容、阅读进度。
- 本机用
Preferences保存草稿。 - 页面编辑时把当前状态同步到
AppStorage。 - 发生应用接续时,
EntryAbility.onContinue()把AppStorage快照写进wantParam。 - 目标端启动后从
want.parameters读回数据,并打开对应页面。
关键配置
EntryAbility 要声明可接续:
"continuable": true,
"continueType": [
"ContinueQuickStart"
]
然后在 Ability 里打开任务接续状态:
this.context.setMissionContinueState(AbilityConstant.ContinueState.ACTIVE);
真正迁移数据的位置是:
onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult {
wantParam['continueTitle'] = AppStorage.get<string>('continueTitle') ?? '';
return AbilityConstant.OnContinueResult.AGREE;
}
不要把几个能力混在一起
看到“跨端”“协同”“设备发现”,新手很容易把它们理解成一个东西。其实它们解决的问题不一样。
| 能力 | 解决什么 | 和本文 Demo 的关系 | | --- | --- | --- | | 应用接续 | 把当前任务从一台设备接到另一台设备继续 | 本文主角,靠 onContinue() 传页面快照 | | 设备发现 | 找到附近或可信设备 | 扩展时可以先发现目标设备,再做协同 | | AbilityConnectManager | 两端 Ability 建立协同连接 | 适合后续做实时协同,不是普通页面跳转 | | LinkEnhance | 建立或增强端到端传输链路 | 适合后续传输数据流、二进制数据 | | ArkData | 保存本机数据或轻量协同状态 | 负责草稿、进度、任务包,不负责发现设备 |
所以这篇 Demo 不写设备发现,也不写远端连接。它只做一件事:把“当前页面应该接着什么状态继续”讲清楚。
如果继续扩展
后续可以按这个顺序加能力,不建议一口气全塞进去:
1. 先用 DeviceManager 做设备发现,拿到可协同设备列表。 2. 如果只是“手机写一半,平板继续写”,继续用应用接续。 3. 如果两端要实时互相发消息,再看 AbilityConnectManager。 4. 如果要更稳定地传输数据流,再看 LinkEnhance。 5. 需要保存状态时,再用 Preferences、KVStore 或 DeviceKVStore 兜底。
一句话:应用接续负责“把任务接过去”,设备发现负责“找到谁”,连接管理负责“两边通信”,ArkData 负责“状态别丢”。
权限和弹窗怎么理解
应用接续不是进入页面就弹一个授权框。页面打开后没有弹窗是正常的。
它通常依赖这些前提:
module.json5声明 Ability 可接续。- Ability 里打开任务接续状态。
- 系统侧存在可信设备、同应用和跨设备任务入口。
- 用户在系统跨设备入口里触发接续。
也就是说,App 页面里不用自己弹一个“是否允许应用接续”的权限框。真正的接续确认发生在系统流程里。
容易踩坑的地方
应用接续不是普通按钮点击,也不是 router.pushUrl()。它依赖系统跨设备任务流、同应用、可信设备、版本匹配和系统能力。
所以 Demo 页面只展示“可接续数据快照”,不伪造跨设备按钮。真正接续触发时,系统会回调 onContinue()。
欢迎评论区一起分享~
你们会更想把应用接续用在什么场景?文章草稿、视频进度、购物车,还是表单填写?如果评论区场景够集中,后面可以继续把这个 Demo 扩展成完整的跨端表单。
完整示例代码
页面:ArkDataContinueDemo.ets
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { promptAction, router } from '@kit.ArkUI';
const STORE_NAME: string = 'continue_draft_demo';
const KEY_TITLE: string = 'continue_title';
const KEY_CONTENT: string = 'continue_content';
const KEY_PROGRESS: string = 'continue_progress';
const APP_KEY_PAGE: string = 'continuePage';
const APP_KEY_TITLE: string = 'continueTitle';
const APP_KEY_CONTENT: string = 'continueContent';
const APP_KEY_PROGRESS: string = 'continueProgress';
const APP_KEY_SOURCE: string = 'continueSource';
class ContinueInfoItem {
label: string = '';
value: string = '';
constructor(label: string, value: string) {
this.label = label;
this.value = value;
}
}
@Entry
@Component
struct ArkDataContinueDemo {
@State title: string = '';
@State content: string = '';
@State progress: number = 30;
@State source: string = '本机编辑';
@State tips: string = '编辑内容会同步到 AppStorage,接续时由 onContinue 携带到新设备';
@State showResultSheet: boolean = false;
@State infoItems: ContinueInfoItem[] = [];
private store: preferences.Preferences | undefined = undefined;
aboutToAppear(): void {
this.loadDraft();
}
private async getStore(): Promise<preferences.Preferences> {
if (this.store !== undefined) {
return this.store;
}
const context = getContext(this) as common.UIAbilityContext;
this.store = await preferences.getPreferences(context, { name: STORE_NAME });
return this.store;
}
private getStringFromAppStorage(key: string, defaultValue: string): string {
const value = AppStorage.get<string>(key);
return value !== undefined ? value : defaultValue;
}
private getNumberFromAppStorage(key: string, defaultValue: number): number {
const value = AppStorage.get<number>(key);
return value !== undefined ? value : defaultValue;
}
private updateContinueSnapshot(): void {
AppStorage.setOrCreate<string>(APP_KEY_PAGE, 'pages/arkData/ArkDataContinueDemo');
AppStorage.setOrCreate<string>(APP_KEY_TITLE, this.title);
AppStorage.setOrCreate<string>(APP_KEY_CONTENT, this.content);
AppStorage.setOrCreate<number>(APP_KEY_PROGRESS, this.progress);
AppStorage.setOrCreate<string>(APP_KEY_SOURCE, this.source);
this.updateInfoItems();
}
private updateInfoItems(): void {
this.infoItems = [
new ContinueInfoItem('本地 Preferences', STORE_NAME),
new ContinueInfoItem('标题', this.title.length > 0 ? this.title : '未填写'),
new ContinueInfoItem('内容', this.content.length > 0 ? this.content : '未填写'),
new ContinueInfoItem('阅读进度', `${this.progress}%`),
new ContinueInfoItem('状态来源', this.source),
new ContinueInfoItem('接续页面', this.getStringFromAppStorage(APP_KEY_PAGE, '未写入')),
new ContinueInfoItem('迁移说明', 'onContinue 会把 AppStorage 快照写入 wantParam')
];
}
private async loadDraft(): Promise<void> {
try {
const continueTitle = this.getStringFromAppStorage(APP_KEY_TITLE, '');
const continueContent = this.getStringFromAppStorage(APP_KEY_CONTENT, '');
const continueProgress = this.getNumberFromAppStorage(APP_KEY_PROGRESS, -1);
const continueSource = this.getStringFromAppStorage(APP_KEY_SOURCE, '');
if (continueTitle.length > 0 || continueContent.length > 0 || continueProgress >= 0) {
this.title = continueTitle;
this.content = continueContent;
this.progress = continueProgress >= 0 ? continueProgress : 30;
this.source = continueSource.length > 0 ? continueSource : '接续参数恢复';
this.tips = '已从应用接续参数恢复页面状态';
this.updateContinueSnapshot();
return;
}
const store = await this.getStore();
this.title = store.getSync(KEY_TITLE, '跨端阅读草稿') as string;
this.content = store.getSync(KEY_CONTENT, '手机上写一半,平板继续写。') as string;
this.progress = store.getSync(KEY_PROGRESS, 30) as number;
this.source = '本机 Preferences';
this.tips = '已读取本机草稿,编辑时会同步更新可接续快照';
this.updateContinueSnapshot();
} catch (err) {
this.tips = '读取失败,请查看日志';
console.error('Continue draft load failed');
}
}
private async saveDraft(): Promise<void> {
try {
const store = await this.getStore();
const title = this.title.length > 0 ? this.title : '跨端阅读草稿';
await store.put(KEY_TITLE, title);
await store.put(KEY_CONTENT, this.content);
await store.put(KEY_PROGRESS, this.progress);
await store.flush();
this.title = title;
this.source = '本机保存';
this.tips = '保存成功:本地 Preferences 和接续快照已更新';
this.updateContinueSnapshot();
promptAction.showToast({ message: '接续草稿已保存' });
} catch (err) {
this.tips = '保存失败,请查看日志';
promptAction.showToast({ message: '保存失败' });
console.error('Continue draft save failed');
}
}
private async showContinueInfo(): Promise<void> {
this.updateContinueSnapshot();
this.showResultSheet = true;
}
private resetDraft(): void {
this.title = '';
this.content = '';
this.progress = 30;
this.source = '本机重置';
this.tips = '已重置页面状态,保存后会覆盖本地草稿';
this.updateContinueSnapshot();
}
@Builder
private continueInfoSheet() {
Column({ space: 14 }) {
Text('应用接续数据快照')
.width('100%')
.fontSize(22)
.fontWeight(700)
.fontColor('#0F172A')
Text('下面展示当前 Demo 准备交给 onContinue 的页面状态。列表只展示少量接续信息,不做懒加载渲染处理。')
.width('100%')
.fontSize(13)
.fontColor('#64748B')
Column({ space: 8 }) {
ForEach(this.infoItems, (item: ContinueInfoItem) => {
Column({ space: 4 }) {
Text(item.label)
.fontSize(12)
.fontColor('#64748B')
Text(item.value)
.fontSize(16)
.fontColor('#0F172A')
}
.width('100%')
.padding(12)
.backgroundColor('#F8FAFC')
.borderRadius(8)
}, (item: ContinueInfoItem) => item.label)
}
.width('100%')
Button('关闭')
.width('100%')
.height(44)
.fontSize(16)
.backgroundColor('#7C3AED')
.onClick(() => {
this.showResultSheet = false;
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 20 })
}
build() {
Scroll() {
Column({ space: 18 }) {
Row() {
Text('返回')
.fontSize(14)
.fontColor('#7C3AED')
.onClick(() => {
router.back();
})
Blank()
}
.width('100%')
Text('应用接续草稿')
.width('100%')
.fontSize(28)
.fontWeight(700)
.fontColor('#182431')
Text('Preferences 负责本机落盘,onContinue 负责跨端迁移当前页面状态。')
.width('100%')
.fontSize(14)
.fontColor('#56616F')
Column({ space: 10 }) {
Text('草稿标题')
.fontSize(14)
.fontColor('#334155')
TextInput({ placeholder: '输入标题', text: this.title })
.height(44)
.fontSize(16)
.backgroundColor('#F5F3FF')
.fontColor('#0F172A')
.onChange((value: string) => {
this.title = value;
this.source = '本机编辑';
this.updateContinueSnapshot();
})
}
.width('100%')
Column({ space: 10 }) {
Text('草稿内容')
.fontSize(14)
.fontColor('#334155')
TextArea({ placeholder: '输入草稿内容', text: this.content })
.height(120)
.fontSize(16)
.backgroundColor('#F5F3FF')
.fontColor('#0F172A')
.onChange((value: string) => {
this.content = value;
this.source = '本机编辑';
this.updateContinueSnapshot();
})
}
.width('100%')
Column({ space: 10 }) {
Row() {
Text('阅读进度')
.fontSize(16)
.fontColor('#0F172A')
.layoutWeight(1)
Text(`${this.progress}%`)
.fontSize(16)
.fontWeight(700)
.fontColor('#7C3AED')
}
.width('100%')
Slider({ value: this.progress, min: 0, max: 100, step: 5 })
.onChange((value: number) => {
this.progress = value;
this.source = '本机编辑';
this.updateContinueSnapshot();
})
}
.width('100%')
Column({ space: 8 }) {
Text(this.title.length > 0 ? this.title : '未命名接续草稿')
.width('100%')
.fontSize(18)
.fontWeight(700)
.fontColor('#0F172A')
Text(this.content.length > 0 ? this.content : '这里会预览接续草稿内容')
.width('100%')
.fontSize(15)
.fontColor('#334155')
Text(`来源:${this.source} · 进度:${this.progress}%`)
.width('100%')
.fontSize(12)
.fontColor('#64748B')
}
.width('100%')
.padding(16)
.backgroundColor('#F8FAFC')
.borderRadius(8)
Row({ space: 10 }) {
Button('保存')
.layoutWeight(1)
.height(44)
.fontSize(15)
.backgroundColor('#7C3AED')
.onClick(() => {
this.saveDraft();
})
Button('查看快照')
.layoutWeight(1)
.height(44)
.fontSize(15)
.fontColor('#0F172A')
.backgroundColor('#DDD6FE')
.onClick(() => {
this.showContinueInfo();
})
Button('重置')
.layoutWeight(1)
.height(44)
.fontSize(15)
.fontColor('#0F172A')
.backgroundColor('#CBD5E1')
.onClick(() => {
this.resetDraft();
})
}
.width('100%')
Text(this.tips)
.width('100%')
.fontSize(13)
.fontColor('#64748B')
}
.width('100%')
.padding(24)
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.bindSheet(this.showResultSheet, this.continueInfoSheet(), {
height: SheetSize.MEDIUM,
dragBar: true,
showClose: true,
onDisappear: () => {
this.showResultSheet = false;
}
})
}
}
Ability:EntryAbility.ets
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
const DOMAIN = 0x0000;
const PAGE_INDEX: string = 'pages/Index';
const PAGE_CONTINUE: string = 'pages/arkData/ArkDataContinueDemo';
const APP_KEY_PAGE: string = 'continuePage';
const APP_KEY_TITLE: string = 'continueTitle';
const APP_KEY_CONTENT: string = 'continueContent';
const APP_KEY_PROGRESS: string = 'continueProgress';
const APP_KEY_SOURCE: string = 'continueSource';
export default class EntryAbility extends UIAbility {
private startPage: string = PAGE_INDEX;
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
try {
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
}
this.restoreContinueParams(want);
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
this.context.setMissionContinueState(AbilityConstant.ContinueState.ACTIVE).catch((err: Error) => {
hilog.error(DOMAIN, 'testTag', 'Failed to active continue state. Cause: %{public}s', JSON.stringify(err));
});
windowStage.loadContent(this.startPage, (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
});
}
onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult {
wantParam[APP_KEY_PAGE] = PAGE_CONTINUE;
wantParam[APP_KEY_TITLE] = this.getStringFromAppStorage(APP_KEY_TITLE);
wantParam[APP_KEY_CONTENT] = this.getStringFromAppStorage(APP_KEY_CONTENT);
wantParam[APP_KEY_PROGRESS] = this.getNumberFromAppStorage(APP_KEY_PROGRESS);
wantParam[APP_KEY_SOURCE] = '应用接续参数';
return AbilityConstant.OnContinueResult.AGREE;
}
private restoreContinueParams(want: Want): void {
const params = want.parameters;
if (params === undefined) {
return;
}
this.startPage = this.getStringFromParams(params, APP_KEY_PAGE, PAGE_INDEX);
AppStorage.setOrCreate<string>(APP_KEY_PAGE, this.startPage);
AppStorage.setOrCreate<string>(APP_KEY_TITLE, this.getStringFromParams(params, APP_KEY_TITLE, ''));
AppStorage.setOrCreate<string>(APP_KEY_CONTENT, this.getStringFromParams(params, APP_KEY_CONTENT, ''));
AppStorage.setOrCreate<number>(APP_KEY_PROGRESS, this.getNumberFromParams(params, APP_KEY_PROGRESS, -1));
AppStorage.setOrCreate<string>(APP_KEY_SOURCE, this.getStringFromParams(params, APP_KEY_SOURCE, '应用启动'));
}
private getStringFromParams(params: Record<string, Object>, key: string, defaultValue: string): string {
const value = params[key];
return typeof value === 'string' ? value : defaultValue;
}
private getNumberFromParams(params: Record<string, Object>, key: string, defaultValue: number): number {
const value = params[key];
return typeof value === 'number' ? value : defaultValue;
}
private getStringFromAppStorage(key: string): string {
const value = AppStorage.get<string>(key);
return value !== undefined ? value : '';
}
private getNumberFromAppStorage(key: string): number {
const value = AppStorage.get<number>(key);
return value !== undefined ? value : 0;
}
}
配置:module.json5
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"exported": true,
"continuable": true,
"continueType": [
"ContinueQuickStart"
]
}
- 点赞
- 收藏
- 关注作者
评论(0)