嵌入式鸿蒙方向(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:前置条件(缺一不可,调试不通先查这里)
-
两台设备登录同一个华为账号;
-
两台设备在同一 Wi-Fi 局域网,且蓝牙开启;
-
声明并动态申请权限
ohos.permission.DISTRIBUTED_DATASYNC(它是 user_grant 权限,要走第一章的弹窗流程); -
模拟器不支持,必须两台真机。
知识点 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 自动同步 | 同账号、同局域网、真机,三个条件缺一不可 |
实战作业:
把前两篇的课程列表页加上"登录态":登录页输入任意账号点登录 → 存 token → 杀进程重开直接进列表页;加"退出登录"按钮清 token 回登录页。 给列表页加"定位推荐"按钮,完整走一遍权限五步流程(声明 → 检查 → 申请 → 拒绝 → 二次引导)。 3.(有两台鸿蒙设备再做)用分布式 KV 做一个"跨设备同步的便签":一端输入,另一端实时显示。
第四篇预告:页面路由与跳转传参(router / Navigation)、AppStorage 全局状态、PersistentStorage 持久化联动 UI、Emitter 跨页面事件总线 —— 把多页面 App 的最后一块拼图补齐。
浙公网安备 33010602011771号