在这里插入图片描述

📖 引言

你有没有过这样的体验:在手机上刷到一篇好文章,看到一半,觉得屏幕太小,想换到平板继续看。于是你放下手机,解锁平板,打开同一个App,搜索同一篇文章,找到刚才看到的位置……这一套操作下来,阅读的兴致早就没了。

或者,你正在手机上浏览「民族图鉴」里的56个民族列表,想看看傣族的详细信息,但手机屏幕太小,图片和文字都挤在一起,体验很差。这时候你心想:“要是能直接推到平板上看就好了。”

这就是**跨设备流转(Cross-Device Continuity)**要解决的问题——让用户在不同设备之间无缝切换,就像在同一个设备上操作一样自然。

在鸿蒙操作系统中,跨设备流转是分布式能力的核心体现。它基于分布式软总线(Distributed Soft Bus),让设备之间可以像本地调用一样进行通信和数据传输。从API 9开始,鸿蒙就提供了跨端迁移(Cross-Device Migration)的正式API,而在API 26(鸿蒙7)中,软总线升级到了2.0版本,延迟降至8毫秒,最多支持16台设备同时协同——这意味着一台手机可以同时向多台平板、智慧屏、车机等设备流转内容。

对于「民族图鉴」来说,跨设备流转能带来什么?

  • 手机浏览列表,平板看详情:手机上浏览民族列表,轻轻一点,平板自动打开详情页,大屏看民族服饰、建筑、文化介绍,体验大幅提升
  • 阅读进度无缝接续:在手机上看到傣族泼水节的介绍,读到一半走到客厅,平板自动接续,从刚才的位置继续阅读
  • 收藏列表跨设备同步:在手机上收藏的民族,平板上打开就能看到,配合分布式数据同步能力(后续文章会讲到),实现真正的跨设备一致性体验

本文将从跨设备流转的原理讲起,深入到分布式软总线、流转配置、状态序列化、设备发现、接续恢复等核心概念,结合「民族图鉴」的手机-平板流转场景,带你掌握鸿蒙7跨设备流转的完整实现。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解跨设备流转的核心概念与分布式软总线原理
  • ✅ 掌握鸿蒙分布式架构:源设备、目标设备、流转生命周期的完整流程
  • ✅ 学会配置应用的跨设备流转能力(module.json5 配置项详解)
  • ✅ 掌握 onContinue() 回调:源设备端保存流转数据的完整实现
  • ✅ 掌握 onRestoreData() 回调:目标设备端恢复流转数据的完整实现
  • ✅ 理解设备发现机制与 continueAbility() 发起流转
  • ✅ 了解API 26软总线2.0的性能提升:8ms延迟、16设备协同
  • ✅ 为「民族图鉴」实现手机浏览列表 → 平板自动打开详情页的流转功能
  • ✅ 解决流转失败、状态丢失、设备发现异常等常见问题

💡 需求分析

什么是跨设备流转?

在讲技术之前,我们先搞清楚:什么是跨设备流转?它和普通的"数据同步"有什么区别?

普通数据同步(Data Sync)

  • 手机和平板各自有一份数据,通过云端或局域网同步
  • 数据同步是"异步"的——可能有延迟,可能冲突
  • 适合场景:笔记、日程、通讯录等不需要实时切换的场景

跨设备流转(Cross-Device Continuity)

  • 把手机上的"任务状态"实时迁移到平板上
  • 流转是"同步"的——手机上的进度、UI状态、数据都完整迁移到平板
  • 平板接着手机的状态继续运行,就像同一个应用换了个屏幕
  • 适合场景:阅读、视频、导航、购物等需要无缝切换的场景

💡 核心洞察:跨设备流转的本质是把应用的运行状态从一个设备完整地搬到另一个设备。不只是数据,还包括UI状态(滚动位置、选中项、输入框内容)、上下文信息(当前页面、用户操作历史)等。用户在平板上看到的是手机上的"延续",而不是"重新开始"。

流转的底层原理:分布式软总线

跨设备流转的背后,是鸿蒙的分布式软总线(Distributed Soft Bus)

什么是分布式软总线?

简单说,分布式软总线是鸿蒙操作系统提供的一套跨设备通信机制。它让不同设备之间的通信变得像同一台设备内部的进程间通信一样简单。

传统方式:
  手机App → WiFi/蓝牙 → 平板App(需要自己处理网络连接、数据序列化、传输协议)

分布式软总线:
  手机App → 分布式软总线(系统自动处理) → 平板App

分布式软总线的核心能力

能力 说明 重要性
设备发现 自动发现附近的鸿蒙设备 ⭐⭐⭐⭐⭐
连接管理 自动建立安全连接通道 ⭐⭐⭐⭐⭐
数据传输 高效、可靠的数据传输 ⭐⭐⭐⭐⭐
服务发现 发现其他设备上可用的服务 ⭐⭐⭐⭐
安全认证 设备间身份认证与加密通信 ⭐⭐⭐⭐⭐

API 26 软总线2.0的关键升级

指标 软总线1.0(API 9-25) 软总线2.0(API 26)
端到端延迟 约20-30ms 低至8ms
最大协同设备数 8台 16台
带宽利用率 约60% 约85%
连接建立时间 约500ms 约200ms
抗干扰能力 一般 增强(自适应跳频)
功耗 中等 降低约30%

软总线2.0的8ms延迟意味着什么?意味着从你点击"流转"按钮,到平板收到数据并开始显示,这个时间短到几乎感觉不到——比人眨眼的平均时间(约100ms)还要短一个数量级。

流转的生命周期

一次完整的流转包含以下几个阶段:

┌─────────────────────────────────────────────────────────────────┐
│                        流转生命周期                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────┐     ┌──────────┐     ┌──────────┐     ┌─────────┐│
│  │ 设备发现  │ ──> │ 发起流转  │ ──> │ 状态保存  │ ──> │ 状态迁移 ││
│  │Discovery │     │ Continue │     │ onContinue│     │ Transfer ││
│  └──────────┘     └──────────┘     └──────────┘     └─────────┘│
│                                                                 │
│       │                                                         │
│       ▼                                                         │
│  ┌──────────┐     ┌──────────┐     ┌──────────┐     ┌─────────┐│
│  │ 数据恢复  │ <── │ 应用启动  │ <── │ 接收数据  │ <── │ 传输完成 ││
│  │onRestore │     │ Launch   │     │ Receive  │     │ Complete ││
│  └──────────┘     └──────────┘     └──────────┘     └─────────┘│
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  源设备(手机)                目标设备(平板)              │   │
│  │  onContinue() → 序列化数据    onRestoreData() → 反序列化   │   │
│  └──────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

阶段详解

  1. 设备发现:用户点击流转按钮,系统自动搜索附近可信设备,展示可用设备列表
  2. 发起流转:用户选择目标设备,调用 continueAbility() 发起流转
  3. 状态保存:源设备触发 onContinue() 回调,应用保存当前状态(页面、数据、滚动位置等)
  4. 状态迁移:系统通过分布式软总线将状态数据传输到目标设备
  5. 接收数据:目标设备接收到流转数据,准备启动应用
  6. 应用启动:目标设备自动启动对应的应用(如果未安装则提示下载)
  7. 数据恢复:目标设备触发 onRestoreData() 回调,应用恢复流转过来的状态,呈现给用户

流转的数据类型

onContinue() 中,你可以保存任意类型的数据,只要它能被序列化:

数据类型 示例 说明
字符串 民族ID、名称 最简单的数据,直接传递
数字 滚动位置、页码 状态快照
布尔值 是否收藏、是否登录 状态标记
JSON对象 用户信息、筛选条件 结构化数据,推荐格式
二进制数据 缓存图片 适合小文件,大文件建议用分布式文件系统

💡 最佳实践:建议将流转数据封装为JSON对象,结构清晰,便于扩展。同时,流转数据量不宜过大(建议控制在100KB以内),过大的数据会影响流转速度,用户会感觉卡顿。


「民族图鉴」的跨设备流转场景

场景1:手机浏览列表 → 平板自动打开详情页

这是最核心的场景。用户场景如下:

  1. 用户在手机上打开「民族图鉴」,浏览56个民族列表
  2. 用户看到傣族,想在大屏上看详细信息
  3. 用户点击流转按钮,选择客厅的平板
  4. 平板自动打开「民族图鉴」,直接进入傣族详情页
  5. 用户在平板上继续浏览,体验流畅

流转的数据:当前选中的民族ID、列表滚动位置、筛选条件

场景2:阅读进度无缝接续

用户在手机上阅读傣族的文化介绍,读到一半,走到客厅,流转到平板从同一位置继续阅读。

流转的数据:当前民族ID、详情页滚动位置、已展开的段落标识

场景3:收藏列表跨设备同步

配合后续文章(第84篇:分布式数据同步),实现收藏列表的跨设备同步:

  • 手机上的收藏,平板上自动可见
  • 平板上的收藏,手机上自动可见
  • 流转时携带收藏状态,目标设备直接展示

流转的数据:收藏列表数据(JSON数组)、最近收藏时间戳


🛠️ 核心实现

步骤1:配置跨设备流转能力

要让你的应用支持跨设备流转,首先需要在 module.json5 中配置相关能力。

1.1 配置 continuable 属性

module.json5 中,为需要支持流转的 Ability 添加 continuable: true 属性:

// module.json5
{
    "module": {
        "name": "entry",
        "type": "entry",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:module_desc",
        "mainElement": "EntryAbility",
        "deviceTypes": [
            "phone",
            "tablet",
            "2in1"
        ],
        "deliveryWithInstall": true,
        "installationFree": false,
        "pages": "$profile:main_pages",
        "abilities": [
            {
                "name": "EntryAbility",
                "srcEntry": "./ets/entryability/EntryAbility.ets",
                "description": "$string:EntryAbility_desc",
                "icon": "$media:layered_image",
                "label": "$string:EntryAbility_label",
                "startWindowIcon": "$media:startIcon",
                "startWindowBackground": "$color:start_window_background",
                "exported": true,
                "continuable": true,
                "skills": [
                    {
                        "entities": [
                            "entity.system.home"
                        ],
                        "actions": [
                            "action.system.home"
                        ]
                    }
                ]
            }
        ],
        "requestPermissions": [
            {
                "name": "ohos.permission.DISTRIBUTED_DATASYNC",
                "reason": "$string:distributed_data_sync_reason",
                "usedScene": {
                    "abilities": [
                        "EntryAbility"
                    ],
                    "when": "inuse"
                }
            }
        ]
    }
}

关键配置项说明

配置项 说明
continuable true 开启该Ability的跨设备流转能力(必须)
deviceTypes ["phone", "tablet", "2in1"] 声明支持的设备类型,确保平板能够安装和运行
ohos.permission.DISTRIBUTED_DATASYNC 权限声明 分布式数据同步权限,跨设备流转的必要权限

⚠️ 注意continuable: true 是跨设备流转的必要条件。如果忘记配置,系统将不会为该Ability提供流转入口,onContinue() 回调也不会被触发。

1.2 配置应用签名

跨设备流转要求两台设备使用相同的应用签名。这意味着:

  • 同一开发者账号下签名的应用
  • 同一应用包名(bundleName)
  • 同一版本签名证书

在开发阶段,使用自动签名即可满足要求。但在发布阶段,需要确保所有设备上的应用使用相同的发布签名。

1.3 配置分布式组网

确保两台设备满足以下条件:

  • 登录同一华为账号
  • 开启蓝牙和WiFi
  • 连接同一局域网(或在蓝牙通信范围内)
  • 在设置中开启了"多设备协同"功能

步骤2:源设备端——保存流转数据(onContinue)

当用户点击流转按钮并选择目标设备后,系统会调用源设备上当前Ability的 onContinue() 回调。在这个回调中,你需要保存当前应用的状态,以便在目标设备上恢复。

2.1 onContinue 回调的基本实现
/*
 * 文件用途:民族图鉴入口Ability —— 跨设备流转核心实现
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:流转数据序列化需确保所有字段可序列化,禁止携带函数、循环引用对象
 */

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';

/**
 * 流转数据的结构定义
 */
interface ContinuationData {
    /** 当前页面标识 */
    currentPage: 'ethnicList' | 'ethnicDetail' | 'collection' | 'settings';
    /** 当前选中的民族ID(详情页时有效) */
    selectedEthnicId: string | null;
    /** 列表页的滚动位置 */
    listScrollPosition: number;
    /** 详情页的滚动位置 */
    detailScrollPosition: number;
    /** 筛选条件 */
    filterCondition: string;
    /** 流转时间戳 */
    timestamp: number;
    /** 用户是否已登录 */
    isLoggedIn: boolean;
    /** 收藏的民族ID列表 */
    collectedEthnicIds: string[];
}

export default class EntryAbility extends UIAbility {
    /** 当前应用状态,用于流转时保存 */
    private currentState: ContinuationData = {
        currentPage: 'ethnicList',
        selectedEthnicId: null,
        listScrollPosition: 0,
        detailScrollPosition: 0,
        filterCondition: 'all',
        timestamp: 0,
        isLoggedIn: false,
        collectedEthnicIds: []
    };

    onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
        hilog.info(0x0000, 'EntryAbility', '应用创建');

        // 检查是否是从流转恢复启动的
        if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) {
            hilog.info(0x0000, 'EntryAbility', '应用从流转恢复启动');
        }
    }

    onDestroy(): void {
        hilog.info(0x0000, 'EntryAbility', '应用销毁');
    }

    onWindowStageCreate(windowStage: window.WindowStage): void {
        hilog.info(0x0000, 'EntryAbility', '窗口创建');

        windowStage.loadContent('pages/Index', (err) => {
            if (err.code) {
                hilog.error(0x0000, 'EntryAbility', '加载页面失败: %{public}s', JSON.stringify(err));
                return;
            }
            hilog.info(0x0000, 'EntryAbility', '页面加载成功');
        });
    }

    onWindowStageDestroy(): void {
        hilog.info(0x0000, 'EntryAbility', '窗口销毁');
    }

    onForeground(): void {
        hilog.info(0x0000, 'EntryAbility', '应用进入前台');
    }

    onBackground(): void {
        hilog.info(0x0000, 'EntryAbility', '应用进入后台');
    }

    /**
     * 跨设备流转回调 —— 源设备保存流转数据
     * 当用户发起流转时,系统调用此方法,要求保存当前状态
     *
     * @param wantParams - 用于存储流转数据的参数对象,将传输到目标设备
     * @returns 是否同意流转,true表示同意,false表示拒绝
     */
    onContinue(wantParams: Record<string, Object>): AbilityConstant.OnContinueResult {
        hilog.info(0x0000, 'EntryAbility', 'onContinue 触发,准备保存流转数据');

        try {
            // 1. 更新时间戳
            this.currentState.timestamp = Date.now();

            // 2. 将状态数据序列化为JSON字符串
            const stateJson = JSON.stringify(this.currentState);

            // 3. 将数据写入流转参数
            wantParams.continuationData = stateJson;

            // 4. 可选:写入版本号,用于目标设备判断数据兼容性
            wantParams.continuationVersion = '1.0.0';

            // 5. 可选:写入流转场景标识,用于目标设备做不同的恢复逻辑
            wantParams.continuationScene = this.currentState.currentPage;

            hilog.info(0x0000, 'EntryAbility',
                '流转数据已保存,当前页面: %{public}s, 数据大小: %{public}d 字节',
                this.currentState.currentPage,
                stateJson.length
            );

            // 6. 返回同意流转(AGREE表示同意,MISMATCH表示拒绝)
            return AbilityConstant.OnContinueResult.AGREE;
        } catch (error) {
            hilog.error(0x0000, 'EntryAbility',
                '保存流转数据失败: %{public}s',
                JSON.stringify(error)
            );

            // 数据保存失败,拒绝流转
            return AbilityConstant.OnContinueResult.MISMATCH;
        }
    }

    /**
     * 更新当前应用状态 —— 供页面调用
     * 各个页面在状态变化时调用此方法,确保流转时能获取最新状态
     *
     * @param partialState - 部分状态更新
     */
    updateCurrentState(partialState: Partial<ContinuationData>): void {
        this.currentState = {
            ...this.currentState,
            ...partialState
        };
        hilog.info(0x0000, 'EntryAbility',
            '应用状态已更新: %{public}s',
            JSON.stringify(partialState)
        );
    }
}

关键点解析

  1. onContinue() 返回值

    • AGREE:同意流转,系统将继续执行流转流程
    • MISMATCH:拒绝流转,流转流程终止
    • REJECT:拒绝流转(与MISMATCH类似,但语义不同)
  2. 数据序列化wantParams 中的值必须是可序列化的类型(字符串、数字、布尔值、数组、对象)。不能包含函数、Symbol、循环引用对象。

  3. 数据大小限制:建议流转数据控制在100KB以内。如果数据过大,考虑使用分布式文件系统或分布式数据库来传递数据,在 wantParams 中只传递文件路径或数据库键。

  4. 版本兼容:建议在流转数据中包含版本号,以便目标设备判断数据格式是否兼容。

2.2 页面层如何配合 onContinue

在页面层,需要在状态变化时通知Ability更新状态。通常通过全局事件或Ability引用实现:

/*
 * 文件用途:民族列表页 —— 流转状态同步
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:页面状态变化频繁,注意防抖处理,避免频繁更新Ability状态
 */

import { UIAbility } from '@kit.AbilityKit';
import { EthnicGroup } from '../models/EthnicModels';
import { EthnicDataService } from '../services/EthnicDataService';

/**
 * 民族列表项接口
 */
interface EthnicListItem {
    id: string;
    name: string;
    thumbnail: string;
    category: string;
    isCollected: boolean;
}

@Entry
@Component
struct EthnicListPage {
    @State ethnicList: EthnicListItem[] = [];
    @State selectedFilter: string = 'all';
    @State scrollOffset: number = 0;
    @State isLoading: boolean = false;
    @State isEmpty: boolean = false;

    private scroller: Scroller = new Scroller();
    private ethnicDataService: EthnicDataService = new EthnicDataService();

    aboutToAppear(): void {
        this.loadEthnicList();
    }

    async loadEthnicList(): Promise<void> {
        this.isLoading = true;
        try {
            const data = await this.ethnicDataService.getEthnicList(this.selectedFilter);
            this.ethnicList = data;
            this.isEmpty = data.length === 0;
        } catch (error) {
            console.error('加载民族列表失败:', JSON.stringify(error));
            this.isEmpty = true;
        } finally {
            this.isLoading = false;
        }
    }

    /**
     * 滚动事件处理 —— 记录滚动位置,用于流转时恢复
     */
    onScroll(scrollOffset: number): void {
        this.scrollOffset = scrollOffset;

        // 通知Ability更新滚动位置状态(防抖处理:每300ms更新一次)
        this.updateContinuationState();
    }

    /**
     * 选择民族 —— 跳转到详情页
     */
    onSelectEthnic(ethnicId: string): void {
        // 更新Ability状态:当前选中的民族
        this.updateContinuationState(ethnicId);

        // 跳转详情页
        router.pushUrl({
            url: 'pages/EthnicDetailPage',
            params: { ethnicId: ethnicId }
        });
    }

    /**
     * 更新流转状态 —— 通知Ability当前页面状态
     */
    private updateContinuationState(selectedEthnicId?: string): void {
        const context = getContext(this) as common.UIAbilityContext;
        const ability = context.ability as EntryAbility;

        if (ability && ability.updateCurrentState) {
            ability.updateCurrentState({
                currentPage: 'ethnicList',
                selectedEthnicId: selectedEthnicId || null,
                listScrollPosition: this.scrollOffset,
                filterCondition: this.selectedFilter
            });
        }
    }

    /**
     * 筛选条件变更
     */
    onChangeFilter(filter: string): void {
        this.selectedFilter = filter;
        this.loadEthnicList();

        // 通知Ability状态变更
        this.updateContinuationState();
    }

    build() {
        Column() {
            // 筛选栏
            this.buildFilterBar()

            // 列表
            if (this.isLoading) {
                this.buildLoadingView()
            } else if (this.isEmpty) {
                this.buildEmptyView()
            } else {
                this.buildEthnicList()
            }
        }
        .width('100%')
        .height('100%')
        .backgroundColor($r('app.color.page_background'))
    }

    @Builder
    buildFilterBar(): void {
        Row({ space: 8 }) {
            ForEach(['all', 'south', 'north', 'northwest', 'southwest', 'northeast'],
                (filter: string) => {
                    Text(this.getFilterLabel(filter))
                        .fontSize(14)
                        .fontColor(this.selectedFilter === filter
                            ? $r('app.color.primary_color')
                            : $r('app.color.text_secondary'))
                        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                        .borderRadius(16)
                        .backgroundColor(this.selectedFilter === filter
                            ? 'rgba(0, 122, 255, 0.1)'
                            : 'transparent')
                        .onClick(() => this.onChangeFilter(filter))
                }, (filter: string) => filter)
        }
        .width('100%')
        .padding(12)
        .scrollable(ScrollDirection.Horizontal)
    }

    @Builder
    buildEthnicList(): void {
        List({ scroller: this.scroller }) {
            ForEach(this.ethnicList, (item: EthnicListItem) => {
                ListItem() {
                    this.buildEthnicCard(item)
                }
                .onClick(() => this.onSelectEthnic(item.id))
            }, (item: EthnicListItem) => item.id)
        }
        .width('100%')
        .layoutWeight(1)
        .onScroll((scrollOffset: number) => {
            this.onScroll(scrollOffset);
        })
    }

    @Builder
    buildEthnicCard(item: EthnicListItem): void {
        Row({ space: 12 }) {
            Image(item.thumbnail)
                .width(60)
                .height(60)
                .borderRadius(8)
                .objectFit(ImageFit.Cover)

            Column({ space: 4 }) {
                Text(item.name)
                    .fontSize(16)
                    .fontWeight(FontWeight.Medium)
                    .fontColor($r('app.color.text_primary'))

                Text(item.category)
                    .fontSize(12)
                    .fontColor($r('app.color.text_secondary'))
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            if (item.isCollected) {
                Text('★')
                    .fontSize(18)
                    .fontColor('#FFD700')
            }

            Image($r('app.media.arrow_right'))
                .width(16)
                .height(16)
                .fillColor($r('app.color.text_hint'))
        }
        .width('100%')
        .padding(12)
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .margin({ left: 12, right: 12, bottom: 8 })
    }

    @Builder
    buildLoadingView(): void {
        Column({ space: 12 }) {
            LoadingProgress()
                .width(40)
                .height(40)
            Text('正在加载民族列表...')
                .fontSize(14)
                .fontColor($r('app.color.text_hint'))
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
    }

    @Builder
    buildEmptyView(): void {
        Column({ space: 12 }) {
            Image($r('app.media.empty_state'))
                .width(120)
                .height(120)
            Text('暂无数据')
                .fontSize(14)
                .fontColor($r('app.color.text_hint'))
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
    }

    private getFilterLabel(filter: string): string {
        const labelMap: Record<string, string> = {
            'all': '全部',
            'south': '南方',
            'north': '北方',
            'northwest': '西北',
            'southwest': '西南',
            'northeast': '东北'
        };
        return labelMap[filter] || filter;
    }
}

步骤3:目标设备端——恢复流转数据(onRestoreData)

当流转数据到达目标设备后,系统会启动对应Ability(如果尚未运行),并调用 onCreate()onRestoreData() 方法。在 onRestoreData() 中,你需要从流转数据中恢复应用状态。

3.1 onRestoreData 回调的基本实现

EntryAbility 中补充 onRestoreData() 方法:

/*
 * 文件用途:民族图鉴入口Ability —— 目标设备流转数据恢复
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:恢复数据前必须校验数据完整性,不完整数据应降级到默认页面
 */

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';

/**
 * 流转数据恢复结果
 */
interface ContinuationRestoreResult {
    /** 是否成功恢复 */
    success: boolean;
    /** 恢复后的目标页面 */
    targetPage: string;
    /** 恢复后的页面参数 */
    pageParams: Record<string, Object>;
    /** 恢复失败原因 */
    errorReason?: string;
}

export default class EntryAbility extends UIAbility {
    /** 从流转恢复的目标页面路由 */
    private continuationTargetPage: string = '';
    /** 从流转恢复的页面参数 */
    private continuationPageParams: Record<string, Object> = {};

    onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
        hilog.info(0x0000, 'EntryAbility', '应用创建');

        // 检查启动原因
        if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) {
            hilog.info(0x0000, 'EntryAbility', '应用从流转恢复启动,将在 onRestoreData 中恢复状态');
        }
    }

    /**
     * 跨设备流转回调 —— 目标设备恢复流转数据
     * 当应用通过流转启动时,系统调用此方法,传入源设备保存的流转数据
     *
     * @param wantParams - 包含源设备保存的流转数据的参数对象
     * @returns 是否成功恢复数据,true表示成功,false表示失败
     */
    onRestoreData(wantParams: Record<string, Object>): boolean {
        hilog.info(0x0000, 'EntryAbility', 'onRestoreData 触发,开始恢复流转数据');

        try {
            // 1. 提取流转数据
            const continuationData = wantParams.continuationData as string;
            const continuationVersion = wantParams.continuationVersion as string;

            if (!continuationData) {
                hilog.error(0x0000, 'EntryAbility', '流转数据为空,无法恢复');
                return false;
            }

            hilog.info(0x0000, 'EntryAbility',
                '接收到流转数据,版本: %{public}s, 数据大小: %{public}d 字节',
                continuationVersion || '未知',
                continuationData.length
            );

            // 2. 校验版本兼容性
            if (!this.isVersionCompatible(continuationVersion)) {
                hilog.error(0x0000, 'EntryAbility',
                    '流转数据版本不兼容: %{public}s', continuationVersion);
                return false;
            }

            // 3. 解析JSON数据
            const state: ContinuationData = JSON.parse(continuationData);

            // 4. 校验数据完整性
            const validationResult = this.validateContinuationData(state);
            if (!validationResult.success) {
                hilog.error(0x0000, 'EntryAbility',
                    '流转数据校验失败: %{public}s', validationResult.errorReason);
                return false;
            }

            // 5. 根据当前页面决定恢复目标
            const restoreResult = this.buildRestoreTarget(state);
            this.continuationTargetPage = restoreResult.targetPage;
            this.continuationPageParams = restoreResult.pageParams;

            hilog.info(0x0000, 'EntryAbility',
                '流转数据恢复成功,目标页面: %{public}s, 参数: %{public}s',
                this.continuationTargetPage,
                JSON.stringify(this.continuationPageParams)
            );

            return true;
        } catch (error) {
            hilog.error(0x0000, 'EntryAbility',
                '恢复流转数据失败: %{public}s', JSON.stringify(error));
            return false;
        }
    }

    /**
     * 校验版本兼容性
     */
    private isVersionCompatible(version: string | undefined): boolean {
        if (!version) {
            return true; // 没有版本号,假设兼容
        }

        // 简单的主版本号比较
        const [major] = version.split('.').map(Number);
        const [currentMajor] = '1.0.0'.split('.').map(Number);

        return major === currentMajor;
    }

    /**
     * 校验流转数据的完整性
     */
    private validateContinuationData(state: ContinuationData): ContinuationRestoreResult {
        // 校验必填字段
        if (!state.currentPage) {
            return {
                success: false,
                targetPage: '',
                pageParams: {},
                errorReason: '缺少 currentPage 字段'
            };
        }

        // 校验页面类型合法性
        const validPages = ['ethnicList', 'ethnicDetail', 'collection', 'settings'];
        if (!validPages.includes(state.currentPage)) {
            return {
                success: false,
                targetPage: '',
                pageParams: {},
                errorReason: `无效的页面类型: ${state.currentPage}`
            };
        }

        // 详情页必须包含 selectedEthnicId
        if (state.currentPage === 'ethnicDetail' && !state.selectedEthnicId) {
            return {
                success: false,
                targetPage: '',
                pageParams: {},
                errorReason: '详情页缺少 selectedEthnicId'
            };
        }

        return {
            success: true,
            targetPage: state.currentPage,
            pageParams: {}
        };
    }

    /**
     * 构建恢复目标——根据流转数据决定打开哪个页面
     */
    private buildRestoreTarget(state: ContinuationData): ContinuationRestoreResult {
        const pageParams: Record<string, Object> = {
            fromContinuation: true,
            timestamp: state.timestamp
        };

        switch (state.currentPage) {
            case 'ethnicList':
                // 恢复到民族列表页,携带筛选条件和滚动位置
                pageParams.filterCondition = state.filterCondition;
                pageParams.scrollPosition = state.listScrollPosition;
                return {
                    success: true,
                    targetPage: 'pages/EthnicListPage',
                    pageParams: pageParams
                };

            case 'ethnicDetail':
                // 恢复到民族详情页,携带民族ID和滚动位置
                if (!state.selectedEthnicId) {
                    return {
                        success: false,
                        targetPage: 'pages/EthnicListPage',
                        pageParams: {},
                        errorReason: '详情页缺少民族ID,降级到列表页'
                    };
                }
                pageParams.ethnicId = state.selectedEthnicId;
                pageParams.scrollPosition = state.detailScrollPosition;
                return {
                    success: true,
                    targetPage: 'pages/EthnicDetailPage',
                    pageParams: pageParams
                };

            case 'collection':
                // 恢复到收藏页
                pageParams.collectedEthnicIds = state.collectedEthnicIds;
                return {
                    success: true,
                    targetPage: 'pages/CollectionPage',
                    pageParams: pageParams
                };

            default:
                // 未知页面,降级到列表页
                return {
                    success: true,
                    targetPage: 'pages/EthnicListPage',
                    pageParams: {}
                };
        }
    }

    /**
     * 获取流转恢复的目标页面(供页面加载时读取)
     */
    getContinuationTarget(): { page: string; params: Record<string, Object> } {
        return {
            page: this.continuationTargetPage,
            params: this.continuationPageParams
        };
    }

    onWindowStageCreate(windowStage: window.WindowStage): void {
        hilog.info(0x0000, 'EntryAbility', '窗口创建');

        // 如果是流转恢复,加载恢复目标页面;否则加载默认页面
        const targetPage = this.continuationTargetPage || 'pages/Index';

        windowStage.loadContent(targetPage, this.continuationPageParams, (err) => {
            if (err.code) {
                hilog.error(0x0000, 'EntryAbility',
                    '加载页面失败: %{public}s, 降级加载默认页面', JSON.stringify(err));
                // 加载失败,降级到默认页面
                windowStage.loadContent('pages/Index', (fallbackErr) => {
                    if (fallbackErr.code) {
                        hilog.error(0x0000, 'EntryAbility',
                            '加载默认页面也失败: %{public}s', JSON.stringify(fallbackErr));
                    }
                });
                return;
            }
            hilog.info(0x0000, 'EntryAbility', '流转恢复页面加载成功: %{public}s', targetPage);
        });
    }
}

关键点解析

  1. onRestoreData() 返回值:返回 true 表示数据恢复成功,false 表示失败。返回 false 时,系统会让应用正常启动(不恢复流转状态)。

  2. 数据校验:务必校验流转数据的完整性和合法性。源设备传来的数据可能因为版本差异、数据损坏等原因不完整,不校验就直接使用可能导致应用崩溃。

  3. 降级策略:如果数据校验失败,不要直接崩溃,而是降级到默认页面(如列表页),保证应用至少能正常打开。

  4. 页面加载时机:在 onCreate() 阶段,continuationTargetPage 可能还未设置(因为 onRestoreData()onCreate() 之后调用)。正确的做法是在 onWindowStageCreate() 中加载目标页面,此时 onRestoreData() 已经执行完毕。

3.2 详情页接收流转参数

在民族详情页中,需要接收流转参数并恢复阅读位置:

/*
 * 文件用途:民族详情页 —— 接收流转参数并恢复阅读位置
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:流转恢复的滚动位置可能在页面数据加载完成前设置,需等待数据加载完毕后再滚动
 */

import { router } from '@kit.ArkUI';
import { EthnicDetail } from '../models/EthnicModels';
import { EthnicDataService } from '../services/EthnicDataService';

interface DetailPageParams {
    ethnicId: string;
    fromContinuation?: boolean;
    scrollPosition?: number;
    timestamp?: number;
}

@Entry
@Component
struct EthnicDetailPage {
    @State ethnicDetail: EthnicDetail | null = null;
    @State isLoading: boolean = true;
    @State isError: boolean = false;
    @State isFromContinuation: boolean = false;

    private ethnicId: string = '';
    private targetScrollPosition: number = 0;
    private scroller: Scroller = new Scroller();
    private ethnicDataService: EthnicDataService = new EthnicDataService();

    aboutToAppear(): void {
        // 获取路由参数
        const params = router.getParams() as DetailPageParams;

        if (params) {
            this.ethnicId = params.ethnicId;
            this.isFromContinuation = params.fromContinuation === true;
            this.targetScrollPosition = params.scrollPosition || 0;
        }

        this.loadEthnicDetail();
    }

    async loadEthnicDetail(): Promise<void> {
        this.isLoading = true;
        this.isError = false;

        try {
            const detail = await this.ethnicDataService.getEthnicDetail(this.ethnicId);
            this.ethnicDetail = detail;

            // 如果是流转恢复,延迟滚动到目标位置
            if (this.isFromContinuation && this.targetScrollPosition > 0) {
                this.restoreScrollPosition();
            }
        } catch (error) {
            console.error('加载民族详情失败:', JSON.stringify(error));
            this.isError = true;
        } finally {
            this.isLoading = false;
        }
    }

    /**
     * 恢复滚动位置 —— 流转恢复时使用
     * 延迟执行确保内容已渲染完成
     */
    private restoreScrollPosition(): void {
        setTimeout(() => {
            if (this.scroller) {
                this.scroller.scrollTo({
                    xOffset: 0,
                    yOffset: this.targetScrollPosition,
                    animation: { duration: 300 }
                });
                console.info('流转恢复滚动位置:', this.targetScrollPosition);
            }
        }, 200); // 延迟200ms,等待内容渲染完成
    }

    build() {
        Column() {
            // 顶部导航栏
            this.buildNavBar()

            if (this.isLoading) {
                this.buildLoadingView()
            } else if (this.isError) {
                this.buildErrorView()
            } else if (this.ethnicDetail) {
                this.buildDetailContent()
            }
        }
        .width('100%')
        .height('100%')
        .backgroundColor($r('app.color.page_background'))
    }

    @Builder
    buildNavBar(): void {
        Row() {
            Image($r('app.media.back_arrow'))
                .width(24)
                .height(24)
                .fillColor($r('app.color.text_primary'))
                .onClick(() => router.back())

            // 流转来源标识
            if (this.isFromContinuation) {
                Row({ space: 4 }) {
                    Text('📱→📋')
                        .fontSize(12)
                    Text('已接续')
                        .fontSize(12)
                        .fontColor($r('app.color.primary_color'))
                }
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .backgroundColor('rgba(0, 122, 255, 0.1)')
                .borderRadius(12)
                .margin({ left: 8 })
            }

            Blank()

            Text(this.ethnicDetail?.name || '')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor($r('app.color.text_primary'))
                .layoutWeight(1)
                .textAlign(TextAlign.Center)

            Blank()
            Image($r('app.media.more'))
                .width(24)
                .height(24)
        }
        .width('100%')
        .height(48)
        .padding({ left: 16, right: 16 })
        .alignItems(VerticalAlign.Center)
    }

    @Builder
    buildDetailContent(): void {
        Scroll(this.scroller) {
            Column({ space: 16 }) {
                // 民族封面图
                Image(this.ethnicDetail!.coverImage)
                    .width('100%')
                    .height(240)
                    .objectFit(ImageFit.Cover)

                // 基本信息
                Column({ space: 8 }) {
                    Text(this.ethnicDetail!.name)
                        .fontSize(24)
                        .fontWeight(FontWeight.Bold)
                        .fontColor($r('app.color.text_primary'))

                    Text(`人口: ${this.ethnicDetail!.population}`)
                        .fontSize(14)
                        .fontColor($r('app.color.text_secondary'))

                    Text(`主要分布: ${this.ethnicDetail!.distribution}`)
                        .fontSize(14)
                        .fontColor($r('app.color.text_secondary'))
                }
                .width('100%')
                .padding(16)
                .alignItems(HorizontalAlign.Start)

                // 文化介绍
                Text(this.ethnicDetail!.culturalDescription)
                    .fontSize(15)
                    .fontColor($r('app.color.text_primary'))
                    .lineHeight(24)
                    .width('100%')
                    .padding({ left: 16, right: 16 })

                // 民俗风情
                Text(this.ethnicDetail!.customDescription)
                    .fontSize(15)
                    .fontColor($r('app.color.text_primary'))
                    .lineHeight(24)
                    .width('100%')
                    .padding({ left: 16, right: 16 })
            }
        }
        .width('100%')
        .layoutWeight(1)
        .scrollBar(BarState.Auto)
        .edgeEffect(EdgeEffect.Spring)
    }

    @Builder
    buildLoadingView(): void {
        Column({ space: 12 }) {
            LoadingProgress()
                .width(40)
                .height(40)
            Text('正在加载详情...')
                .fontSize(14)
                .fontColor($r('app.color.text_hint'))
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
    }

    @Builder
    buildErrorView(): void {
        Column({ space: 12 }) {
            Image($r('app.media.error_state'))
                .width(120)
                .height(120)
            Text('加载失败,请稍后重试')
                .fontSize(14)
                .fontColor($r('app.color.text_hint'))
            Button('重新加载')
                .onClick(() => this.loadEthnicDetail())
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
    }
}

步骤4:设备发现与发起流转

4.1 设备发现机制

在鸿蒙中,设备发现由系统自动完成。当用户触发流转操作时,系统会弹出设备选择面板,展示附近可用的设备列表。

设备发现的前提条件

  1. 两台设备登录同一华为账号
  2. 两台设备都开启了蓝牙和WiFi
  3. 两台设备在同一个局域网内,或者在蓝牙通信范围内
  4. 目标设备上安装了相同签名的应用(或支持免安装)

设备发现流程

用户点击"流转"按钮
    ↓
系统调用分布式软总线发现附近设备
    ↓
系统筛选可信设备(同账号、同应用签名)
    ↓
系统弹出设备选择面板
    ↓
用户选择目标设备
    ↓
系统发起流转
4.2 手动调用流转 API

除了系统自动的流转入口(从最近任务列表操作),你还可以在应用内手动发起流转:

/*
 * 文件用途:流转管理器 —— 手动发起跨设备流转
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:continueAbility 调用需要用户授权,首次调用会弹出权限确认对话框
 */

import { abilityAccessCtrl, PermissionRequestResult } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

/**
 * 流转管理器
 * 负责设备发现、权限检查、发起流转等功能
 */
export class ContinuationManager {
    private static instance: ContinuationManager | null = null;

    private constructor() {}

    static getInstance(): ContinuationManager {
        if (!ContinuationManager.instance) {
            ContinuationManager.instance = new ContinuationManager();
        }
        return ContinuationManager.instance;
    }

    /**
     * 检查是否支持流转
     * @returns 是否支持流转
     */
    async isContinuationSupported(): Promise<boolean> {
        try {
            // 检查分布式能力是否可用
            const context = getContext(this) as common.UIAbilityContext;
            const isDistributedEnabled = await this.checkDistributedCapability(context);
            return isDistributedEnabled;
        } catch (error) {
            hilog.error(0x0000, 'ContinuationManager',
                '检查流转支持失败: %{public}s', JSON.stringify(error));
            return false;
        }
    }

    /**
     * 检查分布式能力
     */
    private async checkDistributedCapability(context: common.UIAbilityContext): Promise<boolean> {
        // 检查分布式数据同步权限
        const atManager = abilityAccessCtrl.createAtManager();
        const grantStatus = await atManager.checkAccessToken(
            context.applicationInfo.accessTokenId,
            'ohos.permission.DISTRIBUTED_DATASYNC'
        );

        return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
    }

    /**
     * 请求分布式数据同步权限
     * @returns 是否授权成功
     */
    async requestDistributedPermission(): Promise<boolean> {
        try {
            const context = getContext(this) as common.UIAbilityContext;
            const atManager = abilityAccessCtrl.createAtManager();

            const result: PermissionRequestResult = await atManager.requestPermissionsFromUser(
                context,
                ['ohos.permission.DISTRIBUTED_DATASYNC']
            );

            return result.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
        } catch (error) {
            hilog.error(0x0000, 'ContinuationManager',
                '请求分布式权限失败: %{public}s', JSON.stringify(error));
            return false;
        }
    }

    /**
     * 发起流转 —— 将当前Ability迁移到指定设备
     * @param deviceId - 目标设备ID
     * @returns 是否发起成功
     */
    async continueToDevice(deviceId: string): Promise<boolean> {
        try {
            const context = getContext(this) as common.UIAbilityContext;

            // 调用 continueAbility 发起流转
            await context.continueAbility(deviceId);

            hilog.info(0x0000, 'ContinuationManager',
                '流转发起成功,目标设备: %{public}s', deviceId);
            return true;
        } catch (error) {
            const err = error as BusinessError;
            hilog.error(0x0000, 'ContinuationManager',
                '流转发起失败, 错误码: %{public}d, 错误信息: %{public}s',
                err.code, err.message);

            // 根据错误码给出不同的提示
            this.handleContinuationError(err.code);
            return false;
        }
    }

    /**
     * 处理流转错误
     */
    private handleContinuationError(errorCode: number): void {
        let message = '发起流转失败';

        switch (errorCode) {
            case 16000001:
                message = '分布式能力不可用,请检查网络和蓝牙';
                break;
            case 16000002:
                message = '未找到目标设备';
                break;
            case 16000003:
                message = '目标设备不支持流转';
                break;
            case 16000004:
                message = '流转被拒绝';
                break;
            case 16000005:
                message = '流转数据异常';
                break;
            default:
                message = `流转失败(错误码: ${errorCode}`;
        }

        AlertDialog.show({
            title: '流转提示',
            message: message,
            confirm: { value: '知道了' }
        });
    }

    /**
     * 获取设备选择器 —— 让用户选择目标设备
     * 系统会自动弹出设备选择界面
     */
    async showDeviceSelector(): Promise<void> {
        try {
            const context = getContext(this) as common.UIAbilityContext;

            // 直接调用 continueAbility 不传设备ID,系统会弹出设备选择器
            // 用户选择设备后,系统自动完成流转
            // 注意:此方法在某些API版本中可能行为不同,请参考最新文档
            await context.continueAbility();

            hilog.info(0x0000, 'ContinuationManager', '设备选择器已弹出');
        } catch (error) {
            hilog.error(0x0000, 'ContinuationManager',
                '弹出设备选择器失败: %{public}s', JSON.stringify(error));
        }
    }
}
4.3 在页面中添加流转按钮

在民族列表页添加一个流转按钮,让用户可以手动发起流转:

// 在 EthnicListPage 的 build 方法中添加流转按钮

@Builder
buildContinuationButton(): void {
    Row({ space: 4 }) {
        Image($r('app.media.continuation_icon'))
            .width(18)
            .height(18)
            .fillColor($r('app.color.primary_color'))

        Text('流转到平板')
            .fontSize(13)
            .fontColor($r('app.color.primary_color'))
    }
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .backgroundColor('rgba(0, 122, 255, 0.08)')
    .borderRadius(16)
    .border({ width: 1, color: 'rgba(0, 122, 255, 0.2)' })
    .onClick(() => {
        this.onContinuationButtonClick();
    })
}

/**
 * 流转按钮点击处理
 */
async onContinuationButtonClick(): Promise<void> {
    const continuationManager = ContinuationManager.getInstance();

    // 1. 检查是否支持流转
    const isSupported = await continuationManager.isContinuationSupported();
    if (!isSupported) {
        AlertDialog.show({
            title: '不支持流转',
            message: '当前设备或环境不支持跨设备流转,请确保已登录华为账号并开启蓝牙和WiFi。',
            confirm: { value: '知道了' }
        });
        return;
    }

    // 2. 请求权限(如果未授权)
    const hasPermission = await continuationManager.requestDistributedPermission();
    if (!hasPermission) {
        AlertDialog.show({
            title: '权限提示',
            message: '跨设备流转需要分布式数据同步权限,请在设置中授权。',
            confirm: { value: '去设置' },
            cancel: { value: '取消' }
        });
        return;
    }

    // 3. 弹出设备选择器并发起流转
    await continuationManager.showDeviceSelector();
}

步骤5:民族图鉴实战 —— 完整流转流程

现在,我们把所有步骤整合起来,实现「民族图鉴」中"手机浏览民族列表 → 平板自动接续打开详情页"的完整流转流程。

5.1 流转流程总结
┌──────────────────────────────────────────────────────────────────┐
│              民族图鉴跨设备流转完整流程                             │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  手机端(源设备)                    平板端(目标设备)              │
│  ──────────────                     ──────────────                │
│                                                                  │
│  1. 用户在民族列表页浏览                                        │
│     ↓                                                            │
│  2. 用户点击"傣族" → 进入详情页                                  │
│     ↓                                                            │
│  3. 详情页更新 state:                                            │
│     currentPage: 'ethnicDetail'                                  │
│     selectedEthnicId: 'dai'                                      │
│     detailScrollPosition: 450                                    │
│     ↓                                                            │
│  4. 用户点击"流转"按钮                                           │
│     ↓                                                            │
│  5. onContinue() 触发                                            │
│     → 序列化 state 为JSON                                        │
│     → 写入 wantParams                                             │
│     → 返回 AGREE                                                 │
│     ↓                                                            │
│  6. 分布式软总线传输数据                          7. 接收流转数据  │
│     (8ms延迟,API 26)                           ↓                │
│                                               8. onCreate()      │
│                                                  launchReason =   │
│                                                  CONTINUATION    │
│                                                  ↓               │
│                                               9. onRestoreData() │
│                                                  → 解析JSON       │
│                                                  → 校验数据       │
│                                                  → 设置目标页面   │
│                                                  ↓               │
│                                               10. 加载详情页      │
│                                                   ethnicId='dai'  │
│                                                   scrollPos=450   │
│                                                  ↓               │
│                                               11. 用户在大屏上    │
│                                                   继续阅读        │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘
5.2 跨设备流转状态管理

在真实项目中,建议使用一个专门的流转状态管理器来统一管理流转状态:

/*
 * 文件用途:流转状态管理器 —— 统一管理跨设备流转状态
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:状态更新频率需控制,避免频繁触发序列化导致性能问题
 */

import { hilog } from '@kit.PerformanceAnalysisKit';

/**
 * 流转状态接口
 */
export interface ContinuationState {
    /** 当前页面标识 */
    currentPage: 'ethnicList' | 'ethnicDetail' | 'collection' | 'settings' | 'unknown';
    /** 选中的民族ID */
    selectedEthnicId: string;
    /** 列表页滚动位置(像素) */
    listScrollPosition: number;
    /** 详情页滚动位置(像素) */
    detailScrollPosition: number;
    /** 当前筛选条件 */
    filterCondition: string;
    /** 搜索关键词 */
    searchKeyword: string;
    /** 收藏的民族ID列表 */
    collectedEthnicIds: string[];
    /** 最近浏览的民族ID列表(用于历史记录) */
    recentEthnicIds: string[];
    /** 流转时间戳 */
    timestamp: number;
    /** 流转版本号 */
    version: string;
}

/**
 * 流转状态管理器
 * 单例模式,全局唯一
 */
export class ContinuationStateManager {
    private static instance: ContinuationStateManager | null = null;

    private state: ContinuationState = {
        currentPage: 'unknown',
        selectedEthnicId: '',
        listScrollPosition: 0,
        detailScrollPosition: 0,
        filterCondition: 'all',
        searchKeyword: '',
        collectedEthnicIds: [],
        recentEthnicIds: [],
        timestamp: 0,
        version: '1.0.0'
    };

    /** 防抖定时器 */
    private updateTimer: number | null = null;
    /** 防抖间隔(毫秒) */
    private readonly DEBOUNCE_DELAY: number = 300;

    private constructor() {
        hilog.info(0x0000, 'ContinuationStateManager', '流转状态管理器初始化');
    }

    static getInstance(): ContinuationStateManager {
        if (!ContinuationStateManager.instance) {
            ContinuationStateManager.instance = new ContinuationStateManager();
        }
        return ContinuationStateManager.instance;
    }

    /**
     * 更新流转状态(防抖处理)
     * @param partialState - 部分状态更新
     */
    updateState(partialState: Partial<ContinuationState>): void {
        this.state = {
            ...this.state,
            ...partialState,
            timestamp: Date.now()
        };

        // 防抖:延迟更新,避免频繁触发
        if (this.updateTimer !== null) {
            clearTimeout(this.updateTimer);
        }

        this.updateTimer = setTimeout(() => {
            hilog.info(0x0000, 'ContinuationStateManager',
                '流转状态已更新: %{public}s', JSON.stringify(partialState));
            this.updateTimer = null;
        }, this.DEBOUNCE_DELAY);
    }

    /**
     * 获取当前流转状态(深拷贝)
     */
    getState(): ContinuationState {
        return JSON.parse(JSON.stringify(this.state));
    }

    /**
     * 序列化流转状态为JSON字符串
     */
    serializeState(): string {
        return JSON.stringify(this.state);
    }

    /**
     * 从JSON字符串反序列化流转状态
     * @param json - JSON字符串
     * @returns 恢复的状态,失败返回null
     */
    static deserializeState(json: string): ContinuationState | null {
        try {
            const state = JSON.parse(json) as ContinuationState;

            // 校验必填字段
            if (!state.currentPage || !state.version) {
                hilog.error(0x0000, 'ContinuationStateManager',
                    '状态数据缺少必填字段');
                return null;
            }

            return state;
        } catch (error) {
            hilog.error(0x0000, 'ContinuationStateManager',
                '状态反序列化失败: %{public}s', JSON.stringify(error));
            return null;
        }
    }

    /**
     * 重置状态
     */
    resetState(): void {
        this.state = {
            currentPage: 'unknown',
            selectedEthnicId: '',
            listScrollPosition: 0,
            detailScrollPosition: 0,
            filterCondition: 'all',
            searchKeyword: '',
            collectedEthnicIds: [],
            recentEthnicIds: [],
            timestamp: 0,
            version: '1.0.0'
        };
    }

    /**
     * 清理资源
     */
    destroy(): void {
        if (this.updateTimer !== null) {
            clearTimeout(this.updateTimer);
            this.updateTimer = null;
        }
        ContinuationStateManager.instance = null;
    }
}

⚠️ 常见问题与解决方案

问题1:流转按钮不显示或流转失败

现象
用户点击流转后,没有弹出设备选择器,或者弹出了但找不到目标设备。

常见原因及解决方案

原因1:continuable 未配置
module.json5 中没有设置 continuable: true

解决
检查 module.json5,确保需要流转的Ability配置了 continuable: true

"abilities": [
    {
        "name": "EntryAbility",
        "continuable": true  // 必须配置
    }
]

原因2:设备未登录同一华为账号
两台设备必须登录同一个华为账号。

解决

  • 检查两台设备的华为账号是否一致
  • 在设置 → 华为账号中确认

原因3:蓝牙或WiFi未开启
流转依赖蓝牙或WiFi进行设备发现。

解决

  • 确保两台设备都开启了蓝牙
  • 确保两台设备都连接了WiFi(或至少开启了WiFi)
  • 确保两台设备在同一局域网内

原因4:分布式数据同步权限未授权
解决

  • 在设置 → 应用 → 民族图鉴 → 权限中,开启"分布式数据同步"权限
  • 或在代码中请求权限(参考步骤4中的 requestDistributedPermission 方法)

原因5:目标设备未安装应用
解决

  • 在目标设备上安装相同签名的应用
  • 或配置 installationFree: true 支持免安装流转(需要上架应用市场)

问题2:流转后状态丢失——页面回到了首页

现象
手机上的民族详情页流转到平板,但平板打开的是应用首页(民族列表),而不是详情页。

常见原因及解决方案

原因1:onRestoreData() 返回了 false
如果 onRestoreData() 中数据校验失败返回了 false,系统会正常启动应用,不恢复流转状态。

解决

  • 检查 onRestoreData() 中的校验逻辑,确保数据格式正确
  • onRestoreData() 中添加详细日志,排查校验失败的具体原因
  • 确保 onContinue() 中写入的数据格式与 onRestoreData() 中期望的格式一致

原因2:流转数据解析失败
JSON 解析异常,导致无法恢复状态。

解决

onRestoreData(wantParams: Record<string, Object>): boolean {
    try {
        const continuationData = wantParams.continuationData as string;
        if (!continuationData) {
            hilog.error(0x0000, 'EntryAbility', '流转数据为空');
            return false;
        }

        // 确保JSON格式正确
        const state = JSON.parse(continuationData);
        // 校验 state 字段...
        return true;
    } catch (error) {
        hilog.error(0x0000, 'EntryAbility',
            'JSON解析失败: %{public}s', JSON.stringify(error));
        return false;
    }
}

原因3:页面加载时机错误
onCreate() 中加载了默认页面,而 onRestoreData() 的恢复目标页面被覆盖了。

解决
onWindowStageCreate() 中加载页面,而不是 onCreate()。因为 onRestoreData()onCreate() 之后调用,在 onCreate() 时恢复目标尚未确定。

原因4:wantParams 的 key 不一致
源设备写入的 key 和目标设备读取的 key 不一致。

解决

  • 定义统一的常量,确保两端使用相同的 key
// constants/ContinuationConstants.ets
export const CONTINUATION_KEY_DATA = 'continuationData';
export const CONTINUATION_KEY_VERSION = 'continuationVersion';
export const CONTINUATION_KEY_SCENE = 'continuationScene';

问题3:流转数据过大导致传输慢

现象
流转时出现明显的等待时间,或者传输失败。

原因
流转数据量过大(超过100KB),超出分布式软总线的推荐传输大小。

解决策略

策略1:只传输必要数据
不要传输完整的业务数据,只传输"定位信息"和"状态快照":

// 不好的做法:传输完整数据
wantParams.allEthnicList = JSON.stringify(ethnicList); // 可能几十KB

// 好的做法:只传输定位信息
wantParams.continuationData = JSON.stringify({
    currentPage: 'ethnicDetail',
    selectedEthnicId: 'dai',          // 只传ID
    detailScrollPosition: 450,        // 只传滚动位置
    filterCondition: 'southwest'      // 只传筛选条件
}); // 通常只有几百字节

策略2:使用分布式文件系统传递大文件
对于图片缓存等大文件,使用分布式文件系统传递,在 wantParams 中只传递文件路径。

策略3:使用分布式数据库传递结构化数据
对于收藏列表等结构化数据,使用分布式数据库同步,在 wantParams 中只传递同步标识。


问题4:平板端应用未安装或版本不一致

现象
流转到平板时,平板提示"应用未安装"或"版本不兼容"。

解决方案

方案1:配置免安装流转
module.json5 中添加 installationFree: true

{
    "module": {
        "installationFree": true,
        "deliveryWithInstall": true
    }
}

注意:免安装流转需要应用已在应用市场上架。

方案2:版本兼容性检查
onRestoreData() 中检查版本号,对不兼容的版本做降级处理:

private isVersionCompatible(version: string | undefined): boolean {
    if (!version) return true;
    const [major] = version.split('.').map(Number);
    return major === CURRENT_MAJOR_VERSION;
}

方案3:提示用户安装
如果目标设备未安装应用,给用户友好的提示:

AlertDialog.show({
    title: '需要安装应用',
    message: '目标设备未安装「民族图鉴」,请在应用市场搜索下载。',
    confirm: { value: '去下载' },
    cancel: { value: '取消' }
});

问题5:流转后收藏列表不同步

现象
手机上收藏的民族,流转到平板后看不到,或者平板上的收藏和手机不一致。

原因
跨设备流转只传递了当前状态快照,不会自动同步数据。收藏列表的跨设备同步需要配合分布式数据管理能力(将在第84篇详细讲解)。

过渡方案(在完整分布式同步实现之前):
在流转数据中携带收藏列表的快照,作为临时方案:

onContinue(wantParams: Record<string, Object>): AbilityConstant.OnContinueResult {
    const state = {
        currentPage: 'collection',
        collectedEthnicIds: this.currentState.collectedEthnicIds, // 携带收藏列表
        timestamp: Date.now()
    };
    wantParams.continuationData = JSON.stringify(state);
    return AbilityConstant.OnContinueResult.AGREE;
}

💡 注意:这只是过渡方案。真正的跨设备同步需要依赖分布式数据管理能力(@kit.DistributedDataKit),使用分布式数据库实现数据的实时同步。这部分内容将在第84篇详细讲解。


📝 本章小结

核心知识点

本文从跨设备流转的原理讲到实战应用,系统介绍了鸿蒙7的跨端迁移能力:

1. 跨设备流转原理

  • 跨设备流转的本质是把应用的运行状态从一个设备完整迁移到另一个设备
  • 分布式软总线是底层通信基础,提供设备发现、安全连接、高效数据传输
  • API 26 软总线2.0将延迟降至8ms,支持最多16台设备协同,连接建立时间缩短至200ms

2. 流转生命周期

  • 设备发现 → 发起流转 → 状态保存(onContinue)→ 状态迁移 → 接收数据 → 应用启动 → 数据恢复(onRestoreData)
  • 源设备负责保存状态,目标设备负责恢复状态
  • 两个关键回调:onContinue()onRestoreData()

3. 核心API使用

  • module.json5 中配置 continuable: true 开启流转能力
  • onContinue():源设备保存流转数据,返回 AGREE 同意流转
  • onRestoreData():目标设备恢复流转数据,校验后设置目标页面
  • continueAbility():手动发起流转,系统弹出设备选择器
  • ohos.permission.DISTRIBUTED_DATASYNC:流转所需的分布式权限

4. 「民族图鉴」流转场景

  • 手机浏览民族列表 → 平板自动打开详情页:传递 selectedEthnicId 和滚动位置
  • 阅读进度无缝接续:传递 detailScrollPosition,目标设备自动滚动到对应位置
  • 收藏列表临时同步:在流转数据中携带收藏列表快照

5. 常见问题排查

  • 流转按钮不显示:检查 continuable 配置、权限、设备登录状态
  • 状态丢失:检查 onRestoreData() 校验逻辑、页面加载时机
  • 数据传输慢:只传输必要数据,控制在100KB以内
  • 版本不兼容:做好版本校验和降级处理

最佳实践总结

做好配置

module.json5 中必须配置 continuable: true
deviceTypes 包含 phone 和 tablet
申请 DISTRIBUTED_DATASYNC 权限

只传必要数据

onContinue 中只保存定位信息(ID、滚动位置、筛选条件)
不要传输完整业务数据(列表、图片等)
数据量控制在100KB以内

做好数据校验

onRestoreData 中校验数据完整性
校验失败时降级到默认页面
记录详细日志,方便排查问题

注意页面加载时机

在 onWindowStageCreate 中加载目标页面
不要在 onCreate 中加载(此时恢复目标尚未确定)
页面数据加载完成后,再恢复滚动位置

做好用户体验

提供流转按钮,降低用户操作门槛
流转成功后给出视觉提示("已接续"标识)
失败时给出友好提示,不暴露技术错误码

下一步预告

在下一篇文章中,我们将:

  • 学习什么是闪控悬浮窗,以及悬浮窗的类型
  • 理解全局悬浮窗、应用内悬浮窗、迷你控制栏的区别
  • 了解悬浮窗的权限与限制
  • 掌握悬浮窗的交互:拖动、吸附、展开/收起
  • 理解悬浮窗与应用的通信:状态同步、操作回调
  • 学习悬浮窗的性能优化:渲染开销、内存占用
  • 实战实现音乐悬浮播放球,支持拖动和快捷控制
  • 解决权限被拒、悬浮窗消失、拖动不跟手等常见问题

🔗 相关链接


💡 提示:跨设备流转是鸿蒙分布式能力的核心特性之一。它不只是"把数据从A传到B",而是"把整个应用体验从A搬到B"。实现流转的关键在于两件事:一是 onContinue() 中保存正确的状态(不多不少,恰好够恢复),二是 onRestoreData() 中做好校验和降级(宁可少恢复,不能崩溃)。掌握了这两点,你的应用就能在手机、平板、智慧屏、车机之间自由流转,给用户带来真正的"无缝体验"。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐