HarmonyOS 新手入门:ArkData 配合应用接续,手机写一半平板继续写

举报
蓝瘦的蜕变 发表于 2026/06/26 10:58:59 2026/06/26
【摘要】 前面已经写了 Preferences、KVStore、DeviceKVStore。如果再往跨端体验走一步,就会碰到“应用接续”。 我的理解很简单:Preferences/KVStore 负责本机长期保存,onContinue() 负责把“此刻页面状态”交给另一台设备。两者不是替代关系,而是配合关系。 官方资料可以先看:

HarmonyOS 新手入门:ArkData 配合应用接续,手机写一半平板继续写

前面已经写了 PreferencesKVStoreDeviceKVStore。如果再往跨端体验走一步,就会碰到“应用接续”。

我的理解很简单:Preferences/KVStore 负责本机长期保存,onContinue() 负责把“此刻页面状态”交给另一台设备。两者不是替代关系,而是配合关系。

官方资料可以先看:

先说结论

应用接续解决的是“把当前任务接过去”,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. 需要保存状态时,再用 PreferencesKVStoreDeviceKVStore 兜底。

一句话:应用接续负责“把任务接过去”,设备发现负责“找到谁”,连接管理负责“两边通信”,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"
  ]
}
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。