嵌入式鸿蒙方向(HarmonyOS NEXT)应用开发入门(三):权限管理、本地缓存、跨 App 通信与跨设备通信

鸿蒙(HarmonyOS NEXT)应用开发入门(三):权限管理、本地缓存、跨 App 通信与跨设备通信

前两篇解决了"界面怎么写"和"数据怎么来"。本篇解决真实项目里绕不开的四个系统级问题: 第一章 应用权限(其他三章的地基,跨设备、蓝牙、定位都要先过权限关) 第二章 本地缓存(用 Preferences 存 token、存配置,杀掉进程数据还在) 第三章 跨 App 通信(拉起别的应用、传参数、被别的应用拉起) 第四章 跨设备通信(手机拉起平板上的应用、多台设备间同步数据 —— 鸿蒙的看家本领) 每章统一按三步走:先立知识点 → 总结分几步 → 详细代码(逐行注释),代码可直接复制到工程运行。

第一章 应用权限:从声明到拒绝后再引导的完整闭环

1.1 先立知识点

知识点 1:权限分两大类,申请方式完全不同
表格
 
 
类型 含义 申请方式 例子
system_grant(系统授权) 风险低,不涉及隐私 只在 module.json5 声明即可,安装时系统自动授予 INTERNET(网络)、GET_NETWORK_INFO
user_grant(用户授权) 涉及隐私数据 声明 + 运行时弹窗向用户申请 相机、麦克风、定位、通讯录、分布式数据同步
判断标准很简单:只要不碰用户隐私,基本都是 system_grant;碰隐私就必须弹窗。
知识点 2:权限声明的三要素
user_grant 权限在 module.json5 里声明时,必须写全三个字段:
JSON
 
{
  "name": "ohos.permission.APPROXIMATELY_LOCATION",  // 权限名(官方文档查)
  "reason": "$string:location_reason",               // 申请理由,会显示在弹窗/设置页,写 string 资源
  "usedScene": {                                     // 使用场景
    "abilities": ["EntryAbility"],                   // 哪个 Ability 用
    "when": "inuse"                                  // 什么时候用:inuse 使用期间
  }
}
上架审核时,reason 写"需要该权限"这种废话会被打回。要写清楚业务场景,比如"用于推荐附近门店"。
知识点 3:用户拒绝后,弹窗不会再来第二次(最大的坑)
鸿蒙的机制:requestPermissionsFromUser 被拒后,再次调用直接返回失败、不弹窗。此时有两条路:
  • requestPermissionOnSetting:在应用内二次拉起授权弹窗(官方推荐,不用跳出 App);
  • startAbility 跳系统设置页:让用户手动开(兜底方案)。
知识点 4:不要在 Ability 的 onCreate 里申请权限
权限弹窗依赖 UI 已经渲染出来,onCreate 阶段界面还没起来,弹窗可能显示不出来。正确时机:页面 aboutToAppear 之后,或用户点击按钮触发时。而且按需申请,别一启动就弹五六个权限框,用户大概率全拒。

1.2 分几步(流程总览)

plain
 
① 声明:module.json5 → requestPermissions 数组里写 name/reason/usedScene
② 检查:checkAccessToken 查询当前是否已授权
③ 申请:未授权则 requestPermissionsFromUser 弹窗
④ 处理结果:授权 → 执行功能;拒绝 → 进入⑤
⑤ 二次引导:requestPermissionOnSetting 再弹一次,或跳系统设置页

1.3 详细代码:封装一个全局权限工具类

实际项目里权限申请散落在各处没法维护,必须封装。新建 entry/src/main/ets/utils/PermissionUtil.ets:
TypeScript
 
import { abilityAccessCtrl, bundleManager, common, Permissions } from '@kit.AbilityKit'

/**
 * 权限工具类:检查、申请、二次引导,一个类全搞定
 * 使用前提:先在 module.json5 的 requestPermissions 里声明过该权限
 */
export class PermissionUtil {
  // 权限管理器:系统提供,全局创建一个复用即可
  private static atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager()

  /**
   * 第一步:检查权限是否已授权
   * @param permission 权限名,如 'ohos.permission.MICROPHONE'
   * @returns true=已授权
   */
  static async check(permission: Permissions): Promise<boolean> {
    try {
      // 每个应用安装后系统会分配一个 accessTokenId,相当于"身份证号"
      // 校验权限本质就是查这个身份证号有没有被授权
      const bundleInfo: bundleManager.BundleInfo =
        await bundleManager.getBundleInfoForSelf(
          bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION)
      const tokenId: number = bundleInfo.appInfo.accessTokenId

      // 用身份证号 + 权限名,查授权状态
      const status: abilityAccessCtrl.GrantStatus =
        await PermissionUtil.atManager.checkAccessToken(tokenId, permission)
      return status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED
    } catch (err) {
      console.error('权限检查失败:' + JSON.stringify(err))
      return false
    }
  }

  /**
   * 第二步:向用户申请权限(弹窗)
   * 注意:用户拒绝过一次后,再调此方法不会弹窗,直接返回失败
   * @param context 必须是 UIAbilityContext,页面里用 getContext(this) 获取
   * @param permissions 权限数组,可同时申请多个
   * @returns true=全部授权通过
   */
  static async request(context: common.UIAbilityContext, permissions: Permissions[]): Promise<boolean> {
    try {
      const result = await PermissionUtil.atManager.requestPermissionsFromUser(context, permissions)
      // authResults 是数组,和传入的权限一一对应;every 表示"全部通过才算过"
      return result.authResults.every(status => status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)
    } catch (err) {
      console.error('权限申请失败:' + JSON.stringify(err))
      return false
    }
  }

  /**
   * 第三步:二次引导授权(应用内弹窗,用户拒绝后的补救)
   * 用户体验好于跳设置页,官方推荐
   */
  static async requestOnSetting(context: common.UIAbilityContext, permissions: Permissions[]): Promise<boolean> {
    try {
      const result = await PermissionUtil.atManager.requestPermissionOnSetting(context, permissions)
      return result.every(status => status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)
    } catch (err) {
      console.error('二次授权失败:' + JSON.stringify(err))
      return false
    }
  }

  /**
   * 兜底方案:跳转系统设置 → 应用详情页,让用户手动打开权限开关
   * 适用于 requestPermissionOnSetting 也被拒的极端情况
   */
  static openSystemSettings(context: common.UIAbilityContext): void {
    context.startAbility({
      bundleName: 'com.huawei.hmos.settings',           // 系统设置应用的包名(固定)
      abilityName: 'com.huawei.hmos.settings.MainAbility', // 系统设置主 Ability(固定)
      uri: 'application_info_entry',                    // 直达"应用信息"页(固定)
      parameters: {
        // pushParams 传自己应用的包名,设置页就知道要显示哪个应用的详情
        pushParams: context.abilityInfo.bundleName
      }
    })
  }
}

1.4 页面中使用:以"录音前申请麦克风权限"为例

TypeScript
 
import { common } from '@kit.AbilityKit'
import { PermissionUtil } from '../utils/PermissionUtil'

@Entry
@Component
struct RecordPage {
  build() {
    Column() {
      Button('开始录音')
        .onClick(async () => {
          // 页面里的 Context 就是 UIAbilityContext,权限接口只认它
          const context = getContext(this) as common.UIAbilityContext

          // ① 先检查:已授权就直接干活,不打扰用户
          const hasPermission = await PermissionUtil.check('ohos.permission.MICROPHONE')
          if (hasPermission) {
            this.startRecord()
            return
          }

          // ② 未授权则弹窗申请
          const granted = await PermissionUtil.request(context, ['ohos.permission.MICROPHONE'])
          if (granted) {
            this.startRecord()
            return
          }

          // ③ 用户拒绝了 → 二次引导(应用内弹窗)
          const grantedAgain = await PermissionUtil.requestOnSetting(context, ['ohos.permission.MICROPHONE'])
          if (grantedAgain) {
            this.startRecord()
          } else {
            // ④ 还是被拒 → 提示并可跳系统设置页
            console.warn('用户彻底拒绝麦克风权限,功能不可用')
            // PermissionUtil.openSystemSettings(context)  // 视产品策略决定是否强引导
          }
        })
    }
  }

  startRecord() {
    // 走到这里,权限一定有了,放心调用录音 API
    console.info('开始录音...')
  }
}
记忆口诀:先查再申请,拒了弹二次,不行跳设置。

第二章 本地缓存:Preferences 用户首选项

2.1 先立知识点

知识点 1:Preferences 是什么
鸿蒙官方提供的轻量级键值对(KV)存储,地位相当于安卓的 SharedPreferences、前端的 localStorage。特点:
  • 数据持久化在应用沙箱里,杀掉进程、重启手机都在;
  • 卸载应用时随沙箱一起删除;
  • 线程不安全的多进程场景别用,也别存大数据(单条 value 上限 8192 字符,整个文件建议 10KB 以内)。
知识点 2:该存什么、不该存什么
表格
 
 
适合存 不适合存
登录 token、用户ID 大量业务数据(用数据库 RDB)
是否首次启动、用户设置项 图片、文件(用文件系统 fs)
搜索历史(少量) 敏感密码明文(需加密或用 Asset Store)
知识点 3:内存 + 磁盘两层结构,flush 才落盘(核心机制)
Preferences 实例启动时把文件加载进内存:
  • putSync 只是写内存,速度极快;
  • 必须调 flush() 才会把内存数据持久化到磁盘;
  • 光 put 不 flush,进程被杀后数据丢失。这是新手丢数据的头号原因。
知识点 4:和第二篇学的状态管理什么关系
  • @State / AppStorage:数据在内存,驱动界面刷新,进程死了就没了;
  • Preferences:数据落磁盘,但不会驱动界面刷新;
  • 二者配合的典型模式:启动时从 Preferences 读出来 → 塞进 @State/AppStorage → 界面用它渲染。

2.2 分几步

plain
 
① 获取实例:getPreferencesSync(context, { name })  —— 全局一次,建议封装
② 写入:putSync(key, value)  —— 只写内存
③ 落盘:flush()  —— 关键一步,漏了等于白写
④ 读取:getSync(key, 默认值)  —— key 不存在时返回默认值
⑤ 删除:deleteSync(key) + flush()

2.3 详细代码:封装 PreferencesUtil

新建 entry/src/main/ets/utils/PreferencesUtil.ets:
TypeScript
 
import { preferences } from '@kit.ArkData'
import { common } from '@kit.AbilityKit'

/**
 * 本地缓存工具类:全局单例,存取 token、配置都用它
 * 必须在 Ability 启动时先调 init() 初始化
 */
export class PreferencesUtil {
  // Preferences 实例,全局唯一。一个 name 对应沙箱里一个文件
  private static pref: preferences.Preferences
  // 存储文件名(不是文件路径,系统会自己拼路径)
  private static readonly STORE_NAME: string = 'app_store'

  /**
   * 初始化:必须在 EntryAbility 的 onCreate 或 onWindowStageCreate 里调用一次
   * @param context 应用上下文
   */
  static init(context: common.UIAbilityContext): void {
    // 同步方式获取实例:文件存在则加载,不存在则创建
    PreferencesUtil.pref = preferences.getPreferencesSync(context, { name: PreferencesUtil.STORE_NAME })
    console.info('Preferences 初始化完成')
  }

  /**
   * 写入并落盘
   * @param key 键,不超过 80 字符
   * @param value 值,支持 number/string/boolean/数组,不超过 8192 字符
   */
  static put(key: string, value: preferences.ValueType): void {
    // putSync 只写内存缓存,速度极快,不会卡顿 UI
    PreferencesUtil.pref.putSync(key, value)
    // flush 把内存数据持久化到磁盘文件 —— 不写这行,进程被杀数据就丢!
    PreferencesUtil.pref.flush()
  }

  /**
   * 读取
   * @param key 键
   * @param defaultValue key 不存在时返回的默认值(必填,类型要和存的值一致)
   */
  static get(key: string, defaultValue: preferences.ValueType): preferences.ValueType {
    return PreferencesUtil.pref.getSync(key, defaultValue)
  }

  /** 判断 key 是否存在 */
  static has(key: string): boolean {
    return PreferencesUtil.pref.hasSync(key)
  }

  /** 删除某个 key,同样要 flush 落盘 */
  static delete(key: string): void {
    PreferencesUtil.pref.deleteSync(key)
    PreferencesUtil.pref.flush()
  }
}

2.4 详细代码:实战"登录存 token,启动自动登录"

第 1 步:应用启动时初始化(EntryAbility.ets):
TypeScript
 
import { common, UIAbility, Want } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
import { PreferencesUtil } from '../utils/PreferencesUtil'

export default class EntryAbility extends UIAbility {
  onCreate(want: Want): void {
    // 应用一创建就初始化缓存工具,后面全局可用
    PreferencesUtil.init(this.context)
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 从缓存读 token,决定进首页还是登录页
    const token = PreferencesUtil.get('token', '') as string
    // loadContent 加载哪个页面,由缓存里的 token 说了算
    windowStage.loadContent(token === '' ? 'pages/LoginPage' : 'pages/MainPage')
  }
}
第 2 步:登录成功后存 token(LoginPage.ets 关键片段):
TypeScript
 
// 假设 login() 是第二篇封装的 axios 请求,返回服务器给的 token
async handleLogin() {
  const token = await login(this.username, this.password)
  // 写入缓存:下次启动 App 不用重新登录
  PreferencesUtil.put('token', token)
  PreferencesUtil.put('username', this.username)
  // 跳首页(路由跳转下一篇讲)
}
第 3 步:请求时自动带 token(改造第二篇的 axios 拦截器):
TypeScript
 
instance.interceptors.request.use((config) => {
  // 每个请求发出前,从缓存取 token 塞进请求头
  const token = PreferencesUtil.get('token', '') as string
  if (token !== '') {
    config.headers.Authorization = 'Bearer ' + token
  }
  return config
})
第 4 步:退出登录清缓存:
TypeScript
 
logout() {
  PreferencesUtil.delete('token')
  PreferencesUtil.delete('username')
  // 跳回登录页
}
到这里,"登录态保持"这条真实项目最基础的链路就闭环了:登录存 token → 启动读 token 决定入口 → 请求自动带 token → 退出清 token。

第三章 跨 App 通信:拉起其他应用与被拉起

3.1 先立知识点

知识点 1:Want —— 鸿蒙的"意图信封"
跨应用、跨 Ability、跨设备通信,靠的都是 Want 对象。把它理解成一个信封,里面能装:
表格
 
 
字段 作用 类比
bundleName + abilityName 明确指定拉起谁 收信人的详细地址(显式)
action + uri 描述"我要干什么" 只写"我要寄快递",谁来接都行(隐式)
parameters 携带的参数 信封里装的信
deviceId 目标设备 寄到哪个城市(第四章用)
显式 Want:指名道姓拉起某个应用的某个 Ability。隐式 Want:声明一个动作(如"打开网页"),系统匹配能处理的应用。
知识点 2:两种拉起方式对应两种场景
表格
 
 
方式 原理 适用场景
显式 startAbility want 里写死 bundleName + abilityName 拉起自家另一个应用、拉起系统设置
Deep Link(openLink) want 里只写 uri 链接(如 myapp://detail) 拉起第三方应用、H5/短信里点链接唤起 App
知识点 3:被拉起的应用在两个回调里接参数
  • 冷启动(应用原本没在运行):EntryAbility 的 onCreate(want) 里拿参数;
  • 热启动(应用在后台被唤起):onNewWant(want) 里拿参数。
  • 两个都要处理,只写一个会漏场景。
知识点 4:Deep Link 需要被拉起方"先登记"
想让 myapp://detail/goods 这种链接能拉起你的应用,必须在被拉起应用的 module.json5 的 skills 里登记 scheme/host,系统才知道这个链接归你管。

3.2 分几步

plain
 
显式拉起(三步):
① 构造 want:bundleName + abilityName + parameters(参数)
② 调用 context.startAbility(want)
③ 被拉起方在 onCreate / onNewWant 里通过 want.parameters 取参

Deep Link 拉起(四步):
① 被拉起方:module.json5 → skills 里配置 actions + uris(登记链接)
② 拉起方:context.openLink('myapp://detail/goods?id=1001')
③ 被拉起方:onCreate / onNewWant 里解析 want.uri,拆出参数
④ 根据参数跳转对应页面

3.3 详细代码(一):显式拉起并传参

TypeScript
 
import { common, Want } from '@kit.AbilityKit'

// ===== 拉起方(App A)=====
launchAppB() {
  const context = getContext(this) as common.UIAbilityContext

  const want: Want = {
    // 指名道姓:拉起包名为 com.example.appb 的应用的 EntryAbility
    bundleName: 'com.example.appb',
    abilityName: 'EntryAbility',
    // parameters 是字典,值支持字符串/数字/布尔,跨进程传递
    parameters: {
      from: 'com.example.appa',   // 告诉对方:我是谁
      goodsId: '1001'             // 业务参数:让它打开 id=1001 的商品
    }
  }

  context.startAbility(want)
    .then(() => console.info('拉起成功'))
    .catch((err: Error) => {
      // 最常见的失败原因:目标应用没安装
      console.error('拉起失败:' + err.message)
    })
}

// ===== 被拉起方(App B 的 EntryAbility.ets)=====
export default class EntryAbility extends UIAbility {
  // 冷启动:App 没在运行时被拉起,走这里
  onCreate(want: Want): void {
    this.handleWant(want)
  }

  // 热启动:App 已在后台被唤起,走这里(不写会漏掉这种场景!)
  onNewWant(want: Want): void {
    this.handleWant(want)
  }

  // 统一处理入口参数
  private handleWant(want: Want): void {
    const goodsId = want.parameters?.goodsId as string
    const from = want.parameters?.from as string
    console.info(`被 ${from} 拉起,要打开商品 ${goodsId}`)
    // 拿到参数后存 AppStorage 或全局变量,页面加载后据此跳转
  }
}

3.4 详细代码(二):Deep Link 拉起(第三方应用场景)

第 1 步:被拉起方(App B)在 module.json5 登记链接:
JSON
 
{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["ohos.want.action.home"]
          },
          {
            "actions": [
              "ohos.want.action.viewData"   // 隐式拉起的标准动作,固定写法
            ],
            "uris": [
              {
                "scheme": "myapp",          // 自定义协议头,全网唯一,建议用自家域名倒写
                "host": "detail",           // 链接的"站点名"
                "path": "goods"             // 可选:限定具体路径
              }
            ]
          }
        ]
      }
    ]
  }
}
登记后,myapp://detail/goods?id=1001 这个链接就归 App B 处理。
第 2 步:拉起方(App A)用 openLink 拉起:
TypeScript
 
openAppBByLink() {
  const context = getContext(this) as common.UIAbilityContext
  // openLink 专门处理链接拉起:系统拿着链接去找登记的 App
  context.openLink('myapp://detail/goods?id=1001&source=appa')
    .catch((err: Error) => {
      // 没有任何应用登记过这个链接时走这里
      console.error('链接无人认领:' + err.message)
    })
}
第 3 步:被拉起方解析 uri 参数:
TypeScript
 
private handleWant(want: Want): void {
  // Deep Link 场景参数在 want.uri 里,不在 parameters 里
  const uri = want.uri   // 形如 myapp://detail/goods?id=1001&source=appa
  if (uri) {
    // 用 ? 拆出查询字符串,再按 & 和 = 逐个解析
    const query = uri.split('?')[1] ?? ''
    const params = new Map<string, string>()
    query.split('&').forEach(pair => {
      const [key, value] = pair.split('=')
      params.set(key, value)
    })
    console.info('商品id:' + params.get('id'))      // 1001
    console.info('来源:' + params.get('source'))    // appa
  }
}

3.5 常用系统应用拉起速查

TypeScript
 
const context = getContext(this) as common.UIAbilityContext

// 拉起系统浏览器打开网页(无需对方登记,https 链接系统浏览器天生认领)
context.openLink('https://developer.huawei.com')

// 拉起应用市场查看某个应用详情(常用于"去评分")
context.openLink('store://appgallery.huawei.com/app/detail?id=com.example.appb')

第四章 跨设备通信:鸿蒙分布式能力

4.1 先立知识点

知识点 1:跨设备通信靠什么 —— 分布式软总线
鸿蒙底层有一条"软总线",把登录同一华为账号、在同一局域网的多台设备(手机、平板、智慧屏、手表)虚拟成一台"超级终端"。开发者不用管蓝牙还是 Wi-Fi 直连,调系统 API 即可。这是鸿蒙区别于安卓/iOS 的核心能力。
知识点 2:跨设备的两类典型操作
表格
 
 
操作 说明 核心 API
跨设备拉起应用 手机上点一下,让平板上打开同一个应用的指定页面 want 里加 deviceId,startAbility
跨设备数据同步 多台设备间自动同步键值对数据(如多设备同步收藏夹) distributedKVStore
知识点 3:前置条件(缺一不可,调试不通先查这里)
  1. 两台设备登录同一个华为账号;
  2. 两台设备在同一 Wi-Fi 局域网,且蓝牙开启;
  3. 声明并动态申请权限 ohos.permission.DISTRIBUTED_DATASYNC(它是 user_grant 权限,要走第一章的弹窗流程);
  4. 模拟器不支持,必须两台真机。
知识点 4:deviceId 是动态发现的
设备没有固定 ID 可查,要用 deviceManager 在运行时发现周边可信设备,拿到临时 deviceId 再通信。

4.2 分几步

plain
 
跨设备拉起(四步):
① 声明并申请 DISTRIBUTED_DATASYNC 权限
② createDeviceManager(包名) 创建设备管理器
③ getAvailableDeviceListSync() 获取在线可信设备列表(让用户选一台)
④ want 中填入对端 deviceId + 应用包名 + Ability名 → startAbility 拉起

跨设备数据同步(五步):
① 同样先过权限关
② createKVManager 创建数据管理器
③ getKVStore 获取/创建分布式数据库(autoSync: true 自动同步)
④ put 写数据 → 自动同步到对端设备
⑤ on('dataChange') 监听对端同步过来的数据 → 更新本地界面

4.3 详细代码(一):跨设备拉起对端应用

第 1 步:module.json5 声明权限:
JSON
 
"requestPermissions": [
  {
    "name": "ohos.permission.DISTRIBUTED_DATASYNC",
    "reason": "$string:distributed_reason",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]
第 2 步:页面代码 —— 发现设备并拉起:
TypeScript
 
import { common, Want } from '@kit.AbilityKit'
import { deviceManager } from '@kit.DistributedServiceKit'
import { PermissionUtil } from '../utils/PermissionUtil'

@Entry
@Component
struct DevicePage {
  // 发现到的在线设备列表,驱动界面
  @State deviceList: deviceManager.DeviceBasicInfo[] = []
  // 设备管理器实例
  private dm?: deviceManager.DeviceManager

  async aboutToAppear(): Promise<void> {
    const context = getContext(this) as common.UIAbilityContext

    // ① 先过权限关(复用第一章封装的工具类)
    let granted = await PermissionUtil.check('ohos.permission.DISTRIBUTED_DATASYNC')
    if (!granted) {
      granted = await PermissionUtil.request(context, ['ohos.permission.DISTRIBUTED_DATASYNC'])
    }
    if (!granted) {
      console.warn('分布式权限未授予,无法发现设备')
      return
    }

    // ② 创建设备管理器,参数传自己应用的包名
    this.dm = deviceManager.createDeviceManager(context.abilityInfo.bundleName)

    // ③ 获取当前在线的可信设备(同一华为账号、同一局域网)
    this.deviceList = this.dm.getAvailableDeviceListSync()
    console.info('发现设备数量:' + this.deviceList.length)
  }

  /** ④ 拉起指定设备上的应用 */
  launchOnRemoteDevice(device: deviceManager.DeviceBasicInfo): void {
    const context = getContext(this) as common.UIAbilityContext
    const want: Want = {
      deviceId: device.deviceId,          // 关键:指定目标设备
      bundleName: 'com.example.myapp',     // 对端设备上已安装的应用包名
      abilityName: 'EntryAbility',
      parameters: {
        from: '手机端发起',                 // 可以像跨 App 一样带参数
        goodsId: '1001'
      }
    }
    context.startAbility(want)
      .then(() => console.info('已在对端设备拉起'))
      .catch((err: Error) => console.error('拉起失败:' + err.message))
  }

  build() {
    Column({ space: 12 }) {
      Text('附近在线设备').fontSize(20).fontWeight(FontWeight.Bold)

      // 设备列表:点哪台就拉起哪台上的应用
      List({ space: 10 }) {
        ForEach(this.deviceList, (device: deviceManager.DeviceBasicInfo) => {
          ListItem() {
            Row() {
              Text(device.deviceName)      // 设备名,如"Mate 70 Pro"
              Blank()
              Text('点击拉起').fontColor('#007DFF')
            }
            .width('100%')
            .padding(16)
            .backgroundColor(Color.White)
            .borderRadius(10)
            .onClick(() => {
              this.launchOnRemoteDevice(device)
            })
          }
        }, (device: deviceManager.DeviceBasicInfo) => device.deviceId)
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }
}

4.4 详细代码(二):分布式 KV 数据同步

场景:手机上把商品加入收藏,平板上同一应用的收藏列表自动更新。
TypeScript
 
import { distributedKVStore } from '@kit.ArkData'
import { common } from '@kit.AbilityKit'

@Entry
@Component
struct FavoritePage {
  @State favoriteList: string[] = []   // 收藏列表,驱动界面
  private kvStore?: distributedKVStore.SingleKVStore

  async aboutToAppear(): Promise<void> {
    // (权限申请同上一个示例,此处省略,实战必须先申请)

    const context = getContext(this) as common.UIAbilityContext

    // ① 创建 KV 管理器:分布式数据操作的入口,一个应用一个
    const kvManager = distributedKVStore.createKVManager({
      bundleName: context.abilityInfo.bundleName,
      context: context
    })

    // ② 获取/创建分布式数据库
    this.kvStore = await kvManager.getKVStore<distributedKVStore.SingleKVStore>('favorite_store', {
      createIfMissing: true,                                    // 不存在则创建
      encrypt: false,                                           // 是否加密(学习阶段不加密)
      backup: false,                                            // 是否参与备份
      autoSync: true,                                           // 关键:数据变化自动同步到其他设备
      kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION, // 单版本 KV,最常用
      securityLevel: distributedKVStore.SecurityLevel.S2        // 安全等级 S2,普通数据够用
    })

    // ③ 启动时读本地已有数据
    const saved = await this.kvStore.get('favorites') as string
    if (saved) {
      this.favoriteList = JSON.parse(saved) as string[]
    }

    // ④ 监听【对端设备】同步过来的数据变化 —— 平板改了收藏,这里会收到
    this.kvStore.on('dataChange', distributedKVStore.SubscribeType.SUBSCRIBE_TYPE_REMOTE,
      (data: distributedKVStore.ChangeNotification) => {
        // insertEntries 是新增的条目,updateEntries 是修改的条目
        data.insertEntries.forEach(entry => this.mergeRemote(entry))
        data.updateEntries.forEach(entry => this.mergeRemote(entry))
      })
  }

  /** 把对端同步来的数据合并到本地界面 */
  private mergeRemote(entry: distributedKVStore.Entry): void {
    if (entry.key === 'favorites') {
      // 对端的收藏列表覆盖本地(学习阶段用最简单的"最后写入获胜"策略)
      this.favoriteList = JSON.parse(entry.value.value as string) as string[]
      console.info('收到对端同步,收藏列表已更新')
    }
  }

  /** 添加收藏:写本地 → autoSync 自动推送到其他设备 */
  async addFavorite(goodsId: string): Promise<void> {
    this.favoriteList.push(goodsId)                    // ① 更新界面
    // ② 写入分布式库;value 只能是基本类型,对象数组要 JSON.stringify
    await this.kvStore?.put('favorites', JSON.stringify(this.favoriteList))
    // autoSync: true 时框架自动同步,无需手动调 sync()
  }

  build() {
    Column({ space: 12 }) {
      Text(`我的收藏(${this.favoriteList.length})`).fontSize(20).fontWeight(FontWeight.Bold)

      Button('收藏商品 1001')
        .onClick(() => this.addFavorite('1001'))

      List({ space: 8 }) {
        ForEach(this.favoriteList, (id: string) => {
          ListItem() {
            Text('商品 ' + id)
              .width('100%')
              .padding(14)
              .backgroundColor(Color.White)
              .borderRadius(8)
          }
        }, (id: string) => id)
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }
}
工程实践提醒:真实项目别把分布式 KV 当主数据库,它适合同步"小数据、变更事件"(设置项、收藏 id 列表)。每条数据建议带 updateTime 和 deviceId,冲突时按时间戳"最后写入获胜"。大文件、大量业务数据走自己的服务器。

第五章 本篇总结

表格
 
 
主题 核心知识点 必记的一句话
权限 system_grant 声明即用;user_grant 要弹窗;拒绝后不再弹窗 先查再申请,拒了弹二次,不行跳设置
本地缓存 Preferences = 内存 + 磁盘两层;put 后必须 flush 登录态闭环:存 token → 启动读 → 请求带 → 退出清
跨 App 通信 Want 是信封;显式写包名,隐式用链接;onCreate + onNewWant 双通道收参 被指名的要 parameters 收参,被链接唤起的要解析 want.uri
跨设备通信 软总线虚拟超级终端;deviceId 动态发现;KV 自动同步 同账号、同局域网、真机,三个条件缺一不可
实战作业:
  1. 把前两篇的课程列表页加上"登录态":登录页输入任意账号点登录 → 存 token → 杀进程重开直接进列表页;加"退出登录"按钮清 token 回登录页。
  2. 给列表页加"定位推荐"按钮,完整走一遍权限五步流程(声明 → 检查 → 申请 → 拒绝 → 二次引导)。 3.(有两台鸿蒙设备再做)用分布式 KV 做一个"跨设备同步的便签":一端输入,另一端实时显示。
第四篇预告:页面路由与跳转传参(router / Navigation)、AppStorage 全局状态、PersistentStorage 持久化联动 UI、Emitter 跨页面事件总线 —— 把多页面 App 的最后一块拼图补齐。
posted @ 2026-07-26 09:39  鬼门元歌  阅读(59)  评论(0)    收藏  举报