Flutter 鸿蒙设备唯一标识适配实战|基于 OAID 合规匿名设备标识,权限弹窗降级容错,零侵入跨平台统一设备 UUID 解决方案
开发工具: 华为云码道
本文配套仓库: CPF-Flutter/fluttertpc_device_uuid
鸿蒙适配后仓库:https://atomgit.com/oh-flutter/device_uuid
device_uuid 是一个极简的设备标识插件:Dart 侧只暴露一个 DeviceUuid().getUUID(),返回 Future<String?>,Android 端取 ANDROID_ID 做 SHA-1,iOS / macOS 端用 XYUUID 库配合 Keychain 持久化。本文以 device_uuid 0.0.4 为例,介绍源码准备、OHOS 工程配置、ArkTS 实现和 example 真机运行。
插件的根目录 OHOS 模块(ohos/)承载的不是一个模板桩,而是一个 160 行的真原生实现(DeviceUuidPlugin.ets),实现 FlutterPlugin + MethodCallHandler + AbilityAware 三件套:通道契约与 Android / iOS 完全一致(通道 device_uuid、方法 getUUID、返回 String?、失败不抛异常),原生标识选用 OAID(@ohos.identifier.oaid,API 10+),并在每次调用前完成 ohos.permission.APP_TRACKING_CONSENT 的运行时授权检查。Dart 层零改动——lib/ 下三个文件原样保留。本文以 d770ee555502b0fd91fa52d14803c64e7930eed2 为例,介绍源码准备、OHOS 工程配置、ArkTS 实现和 example 真机运行。

适配以提交 b6220598d97da9a6e8e07e60d5ccd7bb75bfe66a 为参考,落地在 main 分支,并打 TAG 0.0.4-ohos-1.0.0-beta.1(注意:该提交尚未推送,本地 main 领先 origin/main 1 个提交,README 声明的发布仓库需同步推送后 ref 才可解析)。

我先逐张查看这 6 张截图,再按表 1 / 表 2 格式整理输出。
这 6 张截图实际上是 Device UUID Example 示例页(OpenHarmony,TLR-AL00 6.1.0.135)在不同操作阶段的快照——与上文 SliderGradient 截图不同,因此我按同样的"模块说明 + 状态快照"双表结构整理如下(附件共 6 张,非 7 张)。
表 1 · 页面模块与配置说明
| 模块 | 关键配置 | 预期表现 |
|---|---|---|
| 设备 UUID | DeviceUuid().getUUID() → Future<String?> | 展示 36 位标准 UUID、格式识别、获取时间、延迟、调用次数、运行平台;提供 Refresh UUID / Copy |
| 一致性测试 | 重复调用 getUUID(),5 / 10 / 20 / 50 档位 | 校验标识符稳定,输出 unique values / errors / avg latency 并判定 PASS / FAIL |
| 调用历史 | 每次调用落盘(UUID、时间戳、延迟),支持 Clear all、点条目查看详情 | 滚动记录最近调用,最新在前 |
| 演示设置 | Auto-refresh on app resume(WidgetsBindingObserver) | 开关开启时应用回到前台自动刷新 UUID |
| 通道契约 | MethodChannel('device_uuid') → getUUID() → String?(never throws) | 展示 Android / iOS·macOS / OpenHarmony 三端标识来源与 OAID 全零说明 |
表 2 · 各截图实测状态快照
| 截图时刻 | 设备 UUID 卡(格式 / 获取时间 / 延迟 / 次数 / 平台) | 一致性测试(档位 / 结果 / unique·errors·avg) | 调用历史 | 演示设置 | 通道契约 |
|---|---|---|---|---|---|
| 00:20 | 92332691-f9a5-4e5b-8b65-5d4640e498ba · Standard UUID (36 chars) / 00:19:50 / 2420 ms / 1 / ohos | 10 calls 选中,未运行 | 空 | 未滚到 | 未滚到 |
| 00:21(a) | 未显示 | 10 calls 选中,未运行 | 空(No calls yet) | 开启 | 顶部,仅通道名+方法名(截断) |
| 00:21(b) | 未显示 | 50 calls,已完成 50/50,PASS · unique:1 · errors:0 · avg:25.1 ms | 空(No calls yet) | 开启 | 未显示 |
| 00:22 | 未显示 | 未显示 | 空(No calls yet) | 开启 | 完整三端对照 + OAID 全零说明 |
| 00:23(a) | 未显示 | PASS · unique:1 · errors:0 · avg:22.7 ms | 3 条:00:22:47·5ms / 00:22:46·5ms / 00:22:38·840ms;弹出 Call details(Time 00:22:47,Latency 5ms,Result Success) | 未显示 | 未显示 |
| 00:23(b) | 92332691-… · Standard UUID (36 chars) / 00:23:45 / 37 ms / 6 / ohos | 50 calls,已完成 50/50,PASS · unique:1 · errors:0 · avg:17.3 ms | 顶部 1 条:00:23:45 · 37ms | 未滚到 | 未滚到 |
走查结论
- 首次调用(00:20)延迟 2420 ms——含 OAID 权限检查与首次授权路径;授权后的后续调用延迟降至 5–37 ms,符合"首次弹窗、后需即时返回"的预期。
- 一致性测试在 50 calls 档位下多次运行均 unique values: 1 · errors: 0,标识符跨调用稳定;avg latency 25.1 / 22.7 / 17.3 ms,随授权缓存趋于稳定。
- 调用历史按时间戳与延迟落盘(5ms / 5ms / 840ms),可清空、可查看详情,与配置说明一致。
- 通道契约卡完整展示
MethodChannel('device_uuid')/getUUID()→String?(never throws),三端语义(ANDROID_ID+SHA-1 / Keychain XYUUID / OAID)及全零 OAID 的 APP_TRACKING_CONSENT 说明正确渲染。 - 演示设置开关正常,应用生命周期监听项配置就绪。整体行为符合 device_uuid 鸿蒙适配的预期。
以下是操作的视屏,可以参考一下:
一、插件简介与适配目标
device_uuid 把"取一个设备唯一标识"这件事压缩成一行 API。业务只需要实例化 DeviceUuid 并 await getUUID(),就能拿到一个平台相关的设备标识字符串;插件不抛平台异常——任何失败都以 null 静默返回,调用方用 String? 接住即可:
import 'package:device_uuid/device_uuid.dart';
final deviceUuid = DeviceUuid();
final String? uuid = await deviceUuid.getUUID();
上游仅提供 Android / iOS / macOS 平台实现,OpenHarmony 平台无法直接使用。OHOS 适配目标有三个:
- 平台通道对齐:补全
ohos/根目录模块,新增DeviceUuidPlugin.ets(160 行)注册与 Android / iOS 完全一致的通道device_uuid和方法getUUID(无参数、返回String?);失败路径逐字对齐 Android 的静默契约——异常兜底result.success(null),未知方法result.notImplemented(); - 原生标识选型与授权:OpenHarmony 没有
ANDROID_ID的等价物,需要为"设备标识"选一个 OHOS 原生方案;本次适配选用 OAID(@ohos.identifier.oaid,Open Anonymous Device Identifier),getOAID()原样返回 36 位标准 UUID,不做二次哈希,并在每次调用前处理ohos.permission.APP_TRACKING_CONSENT(user_grant)的运行时授权; - example 在真机跑通 5 张卡片:UUID 详情卡(Format / Fetched at / Latency / Call count / Running on)、一致性压测卡(5/10/20/50 calls 档位)、调用历史卡、设置卡与通道契约卡,并验证首次调用(含权限弹窗)与授权后的延迟差异。
二、环境准备
环境搭建参考社区文档:Flutter OH 开发环境搭建,完成 Flutter OH SDK 安装、环境变量和 DevEco Studio 配置。
完成后,在宿主机终端执行以下命令,确认当前选中的是支持 OHOS 的 Flutter 工具链,并能发现目标设备:
flutter --version
flutter doctor -v
hdc list targets


工程使用的工具链和 SDK 配置如下:
| 项目 | 版本或配置 | 用途 |
|---|---|---|
| Flutter OHOS SDK | 3.44.9+ohos-0.0.1-canary1 | Flutter 编译与 OHOS 平台工具链 |
| DevEco Studio | 26.0.0(DS-261.23567.138.36.2600821) | OHOS 工程构建、Sync 与签名 |
compatibleSdkVersion | 5.1.0(18) | 当前工程声明的最低兼容版本 |
| 设备 ROM | OpenHarmony 6.1.1.120(API 24) | 真机验证环境 |
| 插件版本 | 0.0.4(未随 OHOS 适配 bump) | pubspec.yaml 中的包版本 |
| OHOS TAG | 0.0.4-ohos-1.0.0-beta.1(附注标签) | 适配提交的 git TAG |
| 原生语言 | ArkTS | 根目录 ohos/ 插件模块(160 行原生实现) |
| 插件产物 | HAR | 被应用 entry 模块依赖 |
| 真机设备 | 4UQ9K25508013016 | 真机验证(与系列前几篇不是同一台设备) |
2.1 开发套件版本与工程中的 SDK 版本配置
README.OpenHarmony_CN.md 声明的兼容性环境为:Flutter 3.44.9+ohos-0.0.1-canary1、DevEco Studio 26.0.0(DS-261.23567.138.36.2600821)、SDK 5.1.0(18)、ROM 6.1.1.120。在本文工程中,相关版本的含义与配置方式如下:
5.1.0(18)是本文工程中compatibleSdkVersion的属性值,声明最低兼容 API 18;- 真机验证在 API 24(OpenHarmony 6.1.1.120)上完成,高于最低兼容版本。
对应的 product 配置为:
{
"name": "default",
"compatibleSdkVersion": "5.1.0(18)",
"runtimeOS": "HarmonyOS"
}

这组配置声明最低兼容 API 18。DeviceUuidPlugin 使用的 @ohos.identifier.oaid 从 API 10 起提供,bundleManager.getBundleInfoForSelfSync、atManager.checkAccessTokenSync、requestPermissionsFromUser 等授权 API 也都在最低兼容范围内;插件本体不依赖更高版本 API。
三、从源码仓库开始准备适配工程
3.1 将上游源码同步到 AtomGit
适配已有第三方插件时,先从 Pub 包信息或项目 README 确认上游地址、包名、版本和许可证。同步仓库时保留源码、许可证和提交历史,方便后续跟踪上游更新。
在 AtomGit 网页的新建仓库流程中,找到从已有仓库导入的入口,填写上游 Git 地址,选择自己有写权限的组织或个人空间,设置目标仓库名称后执行导入。完成后检查目标分支、pubspec.yaml、LICENSE 和提交历史是否完整,并记录适配起点的提交号。如果上游本身托管在 AtomGit,也可以通过 Fork 获得自己的工作仓库。
device_uuid 的上游是 GitHub 仓库 thoson-it/device_uuid(默认分支 main,MIT 许可证,Copyright © 2009 ThoSon)。本系列的发布仓库是 AtomGit CPF-Flutter/fluttertpc_device_uuid(CPF-Flutter 组织、fluttertpc_ 前缀命名,与系列前几篇的 oh-flutter 组织不同),声明分支 main、ref 0.0.4-ohos-1.0.0-beta.1。下面使用上游地址拉取代码;需要提交修改时,推送到自己有写权限的仓库或 Fork。
3.2 将代码拉取到宿主机
在安装了 Flutter OH 和 DevEco Studio 的开发电脑上打开终端,进入准备存放项目的目录,执行:
git clone https://github.com/thoson-it/device_uuid.git
cd device_uuid
pwd
ls
git remote -v
git status --short --branch
git rev-parse HEAD
git clone 会创建 device_uuid/ 目录;cd 后的位置就是下文所说的插件仓库根目录,这里应能看到 pubspec.yaml、lib/、example/ 和(适配后的)ohos/。Git 仓库名是 device_uuid,Dart 包名也是 device_uuid。
需要使用与本文相同的代码版本时,在没有未提交修改的仓库中切换到以下提交:
git switch --detach 06ed05316c1fc268b0fb4fa7b2f9863965dcd561
适配其他插件时,使用该插件对应版本的 tag 或 commit 作为分支起点。
路径提示:本文实际仓库位于
/Users/david/workspace/flutter/lib/device_uuid(即~/workspace/flutter/lib/之下),不是~/workspace/flutter/device_uuid。这是该 workspace 下多个 Flutter 插件共用一个父目录的常见路径陷阱,克隆或拉取时需要按实际位置进入。本仓库的origin仍指向上游 GitHub,OHOS 适配提交目前只在本地main(见 6.3);基线提交(不含 OHOS 改动)是d770ee5“Update android.yml”。

图 1:克隆上游仓库 thoson-it/device_uuid,git status --short --branch 显示本地 main 领先 origin/main 1 个提交(OHOS 适配提交尚未推送),pubspec.yaml 中 name: device_uuid、version: 0.0.4。
3.3 在仓库根目录创建适配分支
接着在 device_uuid/ 根目录创建分支。分支名统一使用 feat/ohos_库名称_版本号,库名称取 pubspec.yaml 的 name,版本号取此次适配的基线版本。本例为:
git switch -c feat/ohos_device_uuid_0.0.4
git branch --show-current
如果该分支已存在,使用 git switch feat/ohos_device_uuid_0.0.4 切换即可。基线提交(不含任何 OHOS 改动)是 d770ee5,先 detach 验证可编译再回到 main 即可。适配完成后按 原库版本-ohos-版本号 规则打附注标签 0.0.4-ohos-1.0.0-beta.1。

图 2:OHOS 适配提交 06ed053(“feat: adapt device_uuid plugin to OpenHarmony platform”,基线父提交 d770ee5),共 50 个文件、+9233 / -90 行;附注标签 0.0.4-ohos-1.0.0-beta.1 指向该提交,TAG message 说明了 OAID 方案与 10/10 一致性验证。
3.4 自动补全 OHOS 适配结构
分支创建后,仍在同一个插件根目录执行结构补全。以下命令适用于尚无 ohos/ 目录的既有 Flutter 平台插件:
flutter create --template=plugin --platforms=ohos --project-name device_uuid --org=it.thoson .
git status --short
git diff -- pubspec.yaml lib example
--template=plugin指定插件模板;--platforms=ohos指定需要补全的平台;--project-name device_uuid使用 Dart 包名,避免把仓库目录名作为包名时出现歧义;--org=it.thoson显式指定组织名,与 Android 包名it.thoson.device_uuid保持一致——不显式传时create会自动推断--org,本机推断过程会读取既有 iOS 工程并触发xcodebuild(Xcode 13 过旧),直接报 invalid option 崩溃,详见 9.10;- 最后的
.表示在当前插件目录补全工程,不是另建一层device_uuid/。
该命令生成 OHOS 平台脚手架,业务逻辑需要在 ArkTS 中实现。注意它对既有工程并不"只做 OHOS":还会顺带生成 SPM 骨架、kts 迁移文件、test 目录等多余文件,只保留 ohos/ 产物,其余删除或用 git 恢复即可(详见 9.11)。清理的一种做法:
git status --short
git checkout -- ios macos test 2>/dev/null
git clean -fd ios macos test 2>/dev/null
git status --short # 只剩 ohos/ 与 pubspec.yaml 相关改动
生成后通过 diff 检查 pubspec.yaml、lib/ 和 example/ 的变化,保留已有 API、其他平台注册项及依赖配置。不同 Flutter OH 版本生成的模板可能略有差异。
如果生成后 example/ohos/ 仍不存在,进入已有示例应用补全平台:
cd example
flutter create --platforms=ohos .
cd ..

配套仓库已经包含 ohos/ 和 example/ohos/,直接运行示例时可以跳过结构补全。新建插件则使用 flutter create --org com.nutpi --template=plugin --platforms=ohos device_uuid;已有插件使用上面的 . 在当前目录补全。
3.5 适配后的项目目录
适配后的关键目录如下:
device_uuid/
├── lib/
│ ├── device_uuid.dart # 对外 API:DeviceUuid().getUUID()
│ ├── device_uuid_platform_interface.dart # 平台接口层
│ └── device_uuid_method_channel.dart # 通道实现(channel: device_uuid)
├── android/ # Android 实现(ANDROID_ID + SHA-1)
├── ios/ # iOS/macOS 实现(XYUUID + Keychain)
├── ohos/
│ ├── index.ets
│ ├── oh-package.json5
│ └── src/main/
│ ├── ets/components/plugin/DeviceUuidPlugin.ets # 160 行原生实现
│ └── module.json5
├── example/
│ ├── lib/main.dart # 5 卡片演示 + UUID 生成方式说明区
│ └── ohos/
│ ├── AppScope/app.json5 # bundleName: com.example.ohos_example_scaffold
│ ├── entry/
│ └── build-profile.json5 # ⚠ 含本机签名材料,见 9.5
├── docs/
│ ├── blog/device_uuid-ohos-adaptation.md # 适配技术笔记(268 行)
│ └── evidence/ # 5 张截图 + 设备日志 + uidump
├── README.OpenHarmony_CN.md
├── README.OpenHarmony.md
├── CHANGELOG.OpenHarmony.md
└── pubspec.yaml
项目根目录如下,其中包含 ohos/、example/ohos/、docs/evidence/ 以及 OpenHarmony 中英文说明和变更记录文件:

图 4:适配后的 device_uuid 项目根目录,包含 lib/(3 个文件,零改动)、ohos/(含 160 行 DeviceUuidPlugin.ets)、example/ohos/、docs/evidence/ 与三份 OpenHarmony 文档。
| 文件 | 主要职责 |
|---|---|
lib/device_uuid.dart | 对外 API,DeviceUuid().getUUID() 返回 Future<String?>(OHOS 适配零改动) |
lib/device_uuid_platform_interface.dart / lib/device_uuid_method_channel.dart | 平台接口与默认通道实现,通道名 device_uuid |
ohos/index.ets | 导出 DeviceUuidPlugin |
ohos/src/main/ets/components/plugin/DeviceUuidPlugin.ets | 注册通道 device_uuid,处理 getUUID:权限检查 + OAID 读取 |
ohos/oh-package.json5 | 声明 HAR 模块信息(license: Apache-2.0,与上游 MIT 不一致,详见 9.6) |
插件 module.json5 | 声明 HAR 模块信息与 APP_TRACKING_CONSENT(HAR 内不生效,见 9.4) |
示例 entry module.json5 | 声明宿主应用 Ability 与 ohos.permission.INTERNET + ohos.permission.APP_TRACKING_CONSENT |
example/lib/main.dart | 5 卡片演示 + “How the UUID is produced” 说明区 |
example/ohos/AppScope/app.json5 | bundleName com.example.ohos_example_scaffold(与调试 profile 对齐,见 8.3) |
example/ohos/build-profile.json5 | products.default + modules.entry,含本机签名材料,需在提交前剥离(见 9.5) |
README.OpenHarmony_CN.md / README.OpenHarmony.md | 中英文安装方式、环境约束、权限、接口表、示例 |
CHANGELOG.OpenHarmony.md | OHOS 适配变更记录(当前仅 3 行,见 6.1) |
docs/blog/device_uuid-ohos-adaptation.md | 本仓库自带的适配技术笔记(268 行) |
docs/evidence/ | 5 张真机截图 + device_log.txt / device_log_final.txt / full_hilog.txt(7224 行)+ uidump*.json ×3 |
四、Dart 接口与通道分析
OHOS 实现需要遵循 Dart 层已有的方法、参数和返回值约定。先阅读 lib/ 下的三个文件:device_uuid.dart(对外 API)、device_uuid_platform_interface.dart(平台接口)和 device_uuid_method_channel.dart(通道实现)。本插件是纯通道插件——所有功能都经由 MethodChannel 落到原生侧,Dart 层没有任何本地逻辑;又因为返回值是 Future<String?>、上游 pubspec 已是 null-safe(sdk: ">=2.12.0 <3.0.0"),适配重点落在通道契约与静默失败语义的对齐上,Dart 层零改动。适配其他已有平台的插件时,也应保留其公开接口和其他平台实现。
本例的对应关系如下:
| Dart 入口或模型 | 通道协议 | OHOS 实现 | 应保持的行为 |
|---|---|---|---|
DeviceUuid().getUUID() | device_uuid / getUUID,无参数,返回 String? | DeviceUuidPlugin.ets 权限检查后返回 OAID | 失败不抛异常:异常兜底 result.success(null),未知方法 result.notImplemented() |
device_uuid_platform_interface.dart | 平台接口层 | 不经通道,Dart 内部抽象 | 保持上游 federated plugin 结构不动 |
plugin_platform_interface: ^2.0.2 依赖 | — | 不经通道 | pubspec.yaml 依赖保持不动 |
原生端需要保持通道名与方法名一致。DeviceUuidPlugin 实现 FlutterPlugin、MethodCallHandler 和 AbilityAware,对未实现方法统一返回 notImplemented();与 Android 端的 Kotlin 实现一样,任何异常都不通过 result.error 上抛,而是静默 result.success(null)。
4.1 跨端架构与调用时序
demo 的每一次 UUID 读取都经过通道;OHOS 原生侧在拿到 OAID 之前要先完成 APP_TRACKING_CONSENT 的授权检查,未授权时弹系统权限弹窗。整体架构如下:
与系列前几篇"纯 Dart 组件 + 一次性版本查询"不同,本插件的全部价值都在这条通道上;且通道返回值受权限状态影响——拒绝授权时系统返回全 0 UUID,插件透传,不报错。
4.1.1 一次 getUUID() 调用的时序
注意两个分支:用户拒绝授权时,系统侧 getOAID() 返回全 0 UUID(00000000-0000-0000-0000-000000000000),插件原样透传;任何一环抛异常时,统一兜底 result.success(null),对齐 Android 的静默契约。
把权限状态、异常与返回值的对应关系整理如下,排查问题时先对号入座:
| 原生侧状态 | 返回给 Dart 的值 | 说明 |
|---|---|---|
已授权,getOAID() 成功 | 36 位标准 UUID | 正常路径 |
| 用户拒绝授权 | 全 0 UUID(36 位) | 系统行为,插件透传不报错 |
宿主未声明 APP_TRACKING_CONSENT | 全 0 UUID(36 位) | OAIDService 拒绝服务,见 5.2.3 |
无 UIAbilityContext,跳过弹窗 | 视系统返回而定 | 记 warn 日志,见 5.1.3 |
| 任一环节抛异常 | null | 全链路 try/catch 兜底,见 9.7 |
4.2 公开 API:getUUID() 与三端返回值语义
lib/device_uuid.dart 中的 DeviceUuid 是业务层唯一入口,只有一个 getUUID() 方法,返回 Future<String?>:
final deviceUuid = DeviceUuid();
final String? uuid = await deviceUuid.getUUID();
if (uuid == null) {
// 原生侧异常兜底:静默失败,不抛异常
} else {
// 使用设备标识
}
三端的返回值语义本就不一致,这是本系列少见的"上游三端语义各自为政"的库:
| 平台 | 标识来源 | 返回值形态 |
|---|---|---|
| Android | Settings.Secure.ANDROID_ID 经 SHA-1 | 40 位十六进制字符串 |
| iOS / macOS | XYUUID 库,Keychain 持久化 | 库定义的设备 UUID |
| OpenHarmony | OAID(@ohos.identifier.oaid) | 36 位标准 UUID,原样返回 |
接入方不应假设返回值的格式与长度;跨端需要归一化时,应把这件事放到服务端做。README 已就这一点对使用者作出提醒。
4.2.1 业务侧的使用建议
基于上述契约,业务侧使用 getUUID() 时建议遵循三条:
- null 与全 0 都按"无标识"降级:
null是异常兜底,全 0 是权限未授予,两者都不应进入统计、去重等标识消费链路; - 不要缓存到永久存储以外的判断逻辑:OAID 可被用户重置,授权状态也可变更,应用每次冷启动重新读取即可(毫秒级开销);
- 不要用返回值长度做平台判断:40 位 hex 是 Android、36 位 UUID 是 OHOS 只是当前实现的事实,不是稳定契约。
4.3 Dart 层零改动的理由
与系列中做过空安全迁移的库不同,device_uuid 的 Dart 层可以直接沿用:
pubspec.yaml声明sdk: ">=2.12.0 <3.0.0"、flutter: ">=2.5.0",上游已是 null-safe 写法,getUUID()天然返回Future<String?>;lib/仅 3 个文件,无业务逻辑、无样式代码,OHOS 适配不触碰其中任何一行;- 依赖
plugin_platform_interface: ^2.0.2保持不动,federated plugin 结构完整保留。
因此本次提交的 +9233 / -90 行全部来自 ohos/ 新增、example/ 重写(739 行改动)与文档证据,git diff -- lib 为空。
4.4 Dart 通道协议分析
4.4.1 通道名称三端完全一致
Dart 侧与 ArkTS 插件约定的通道名是 device_uuid,方法名是 getUUID,调用无参数,返回 String?:
final uuid = await const MethodChannel('device_uuid')
.invokeMethod<String?>('getUUID');
任何一端拼写不一致都会出现"方法未实现"或"收不到返回值"等问题。
4.4.2 通道调用与错误处理:Android 端的静默契约
Android 端的 Kotlin 实现是本插件错误语义的"事实标准",OHOS 端逐字对齐:
try {
var deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
val bytes = MessageDigest.getInstance("SHA-1").digest(deviceId.toByteArray())
result.success(bytes.fold("") { str, it -> str + "%02x".format(it) })
} catch (e: Exception) {
result.success(null)
}
要点有三:
- 失败不抛异常:
catch里是result.success(null),不是result.error(...);Dart 侧await getUUID()拿到的是null,而不是PlatformException; - 未知方法统一
result.notImplemented(),Dart 侧表现为MissingPluginException——但getUUID已实现,正常调用不会触发; - 成功值是纯字符串:Android 是 40 位十六进制,OHOS 是 36 位标准 UUID,Dart 层不做任何加工。
五、补全 OHOS 原生实现与工程配置
5.1 在 DeviceUuidPlugin.ets 中实现原生逻辑
device_uuid 与前几篇"纯 Dart 组件 + 通道桩"的适配完全不同:设备标识必须由操作系统提供,Flutter 框架内拿不到 OAID,因此 OHOS 原生侧是一个 160 行的真实现——在 pubspec.yaml 声明 ohos: pluginClass: DeviceUuidPlugin,并在根目录 ohos/src/main/ets/components/plugin/DeviceUuidPlugin.ets 中实现 FlutterPlugin + MethodCallHandler + AbilityAware。每次 getUUID 的第一步是权限检查,核心的 ensureTrackingConsent 如下:
private async ensureTrackingConsent(): Promise<boolean> {
try {
const bundleFlags: number = bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION;
const bundleInfo: bundleManager.BundleInfo = bundleManager.getBundleInfoForSelfSync(bundleFlags);
const tokenId: number = bundleInfo.appInfo.accessTokenId;
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
const permissionName: Permissions = 'ohos.permission.APP_TRACKING_CONSENT';
const status: abilityAccessCtrl.GrantStatus = atManager.checkAccessTokenSync(tokenId, permissionName);
if (status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
return true;
}
if (this.context == null) {
hilog.warn(DOMAIN, TAG, 'no UIAbility context, skip permission request');
return false;
}
const requestResult = await atManager.requestPermissionsFromUser(this.context, [permissionName]);
const authResults: Array<number> = requestResult.authResults;
return authResults.length > 0 && authResults[0] === 0;
} catch (e) {
hilog.error(DOMAIN, TAG, 'ensureTrackingConsent error: %{public}s', JSON.stringify(e));
return false;
}
}
5.1.1 引入 Flutter 和 OpenHarmony 能力
FlutterPlugin/FlutterPluginBinding/MethodCall/MethodCallHandler/MethodChannel/MethodResult来自@ohos/flutter_ohos,负责接入 Flutter Engine 生命周期并处理 Dart 调用;@ohos.identifier.oaid提供getOAID(): Promise<string>,是设备标识的唯一来源;@ohos.bundle.bundleManager取应用自身的accessTokenId,@ohos.abilityAccessCtrl负责查询与申请授权;AbilityAware提供UIAbilityContext,权限弹窗必须依赖它;hilog负责原生侧诊断日志。
写系统 API 之前先读 SDK 的 .d.ts 头文件:getOAID 的签名、authResults 的结构、accessTokenId 的取法都是从 @ohos.identifier.oaid 与 @ohos.abilityAccessCtrl 的声明文件里逐条确认的,不要凭记忆写原生代码。
5.1.2 原生标识选型:为什么是 OAID
OpenHarmony 没有 ANDROID_ID 等价物,"设备标识"需要在系统 API 里重新选型。排查结论如下:
| 候选方案 | 结论 | 原因 |
|---|---|---|
@ohos.identifier.oaid 的 getOAID() | ✅ 采用 | Open Anonymous Device Identifier,API 10+,系统级匿名化标识,返回 36 位标准 UUID |
@ohos.deviceInfo 的 serial | ❌ 放弃 | 需要 MANAGE_DEVICE_INFO 系统权限,三方应用不可申请 |
| 自生成 UUID 存 preferences | ❌ 放弃 | 卸载即失效,不满足"设备标识"的跨安装周期语义 |
两点设计决定值得说明:
- 不做二次哈希:Android 端对
ANDROID_ID做 SHA-1 是为了抹平原始标识;OAID 本身已经过系统匿名化(用户可在设置中重置),原样返回 36 位 UUID 即可,再做哈希反而破坏标准形态; - 接受用户可控性:OAID 是广告跟踪类标识,用户可以关闭授权(此时返回全 0)或重置,这是合规特性而非缺陷,demo 的说明区对此有明确提示。
5.1.3 权限检查与 AbilityAware
ensureTrackingConsent 的流程是:getBundleInfoForSelfSync 取 accessTokenId → checkAccessTokenSync 查授权 → 已授权直接放行;未授权且有 UIAbilityContext 时 requestPermissionsFromUser 弹窗,以 authResults[0] === 0 判定成功;无 context 时记 warn 日志并返回 false。
UIAbilityContext 来自 AbilityAware:插件在 onAttachedToAbility 中缓存 context、在 onDetachedFromAbility 中清空。这是"按需 AbilityAware"的典型场景——纯后台插件不需要它,而权限弹窗必须有 UI 上下文才能弹出。不需要弹窗的插件可以不实现这个接口,不要无脑照抄。
5.1.4 getOAID() 与静默契约
权限检查通过后,getUUID 走到 getOAID():await 拿到 36 位 UUID 字符串后 result.success(oaid)。用户拒绝授权时系统返回全 0 UUID(00000000-0000-0000-0000-000000000000),插件透传、不报错。所有通路——onAttachedToEngine、onMethodCall、getUUID、权限检查——都用 try / catch 包住,最终兜底 result.success(null),与 Android 端 Kotlin catch 里的 result.success(null) 逐字对齐。
getUniqueClassName() 必须返回 'DeviceUuidPlugin',与 pubspec.yaml 的 plugin.platforms.ohos.pluginClass: DeviceUuidPlugin 完全一致;Flutter 工具链在生成 GeneratedPluginRegistrant.ets 时会校验这个类名。
5.1.5 onMethodCall 的分发与兜底
把 160 行实现的调用链收拢一下,onMethodCall 的分发逻辑只有一条主干:
| 步骤 | 调用 | 失败时 |
|---|---|---|
| 1. 方法分发 | call.method == 'getUUID' 进入实现,其他方法 result.notImplemented() | 不适用 |
| 2. 权限检查 | await this.ensureTrackingConsent() | 返回 false 时按系统返回继续,不抛错 |
| 3. 读标识 | await oaid.getOAID() | 抛错进入外层 catch |
| 4. 返回 | result.success(oaid) | catch 兜底 result.success(null) |
关键是兜底方向的一致性:onAttachedToEngine(建通道)、onMethodCall(分发)、getUUID(读 OAID)、ensureTrackingConsent(授权检查)四条通路的 catch 都不调用 result.error(...),最终都以 result.success(null) 收场——这是把 Android 端"失败不抛异常"的契约逐字搬到 ArkTS 的结果。适配带通道的插件时,先在源码里找到原平台的错误处理范式,再决定 OHOS 端用 error 还是 success(null),不要默认套模板的 result.error。
5.2 声明插件和宿主权限
本插件是系列中第一个真正需要权限的库:ohos.permission.APP_TRACKING_CONSENT 是 user_grant 权限,声明与授权两个环节都有坑。
5.2.1 插件 HAR 的权限
插件的 ohos/src/main/module.json5 声明了 requestPermissions,包含 reason 资源 $string:oaid_permission_reason 与 usedScene(when: inuse)。但要注意:HAR 内的权限声明不会合并进宿主应用——这份声明只是文档性质的提示,真正生效的是宿主声明(见 9.4)。
5.2.2 应用 entry 的权限(必须在宿主声明)
最终安装的是宿主应用 example/ohos/entry。接入方必须在宿主 module.json5 自己声明 APP_TRACKING_CONSENT,否则 getUUID() 只能拿到全 0 字符串。本例的 example/ohos/entry/src/main/module.json5 声明如下:
{
"module": {
"requestPermissions": [
{"name": "ohos.permission.INTERNET"},
{
"name": "ohos.permission.APP_TRACKING_CONSENT",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
权限声明和运行时授权是两个步骤。APP_TRACKING_CONSENT 是 user_grant 权限,仅声明不够,必须运行时授权——插件已在每次 getUUID 前内置了检查与弹窗逻辑(见 5.1.3),接入方不需要再写授权代码,但必须保证声明存在且应用有前台 UIAbility。
5.2.3 全 0 UUID 排查实录
适配过程中真机首跑拿到的就是全 0 UUID。用 hdc hilog 抓系统日志,修复前的关键行:
OAIDService: [nodict]the caller not granted the app tracking permission
OAIDService: [nodict]get oaid not granted the app tracking permission
OAIDService 明确拒绝:调用方没有广告跟踪授权。原因是最初只依赖插件 HAR 内的权限声明,宿主 module.json5 没有自己声明 APP_TRACKING_CONSENT(HAR 权限不合并,见 9.4)。在宿主声明并完成运行时授权后,成功日志:
FlutterEngineCxnRegistry --> Adding plugin: DeviceUuidPlugin
OAIDClient: [nodict]Load OAID service success.
oaid_service/OAIDService: [nodict]getOaid success
oaid_service/PRIVACY: [AddPermissionUsedRecord]Result is 0.
四行日志分别对应:插件注册进引擎 → OAID 服务加载 → getOaid 成功 → 隐私访问记录写入。
5.3 注册并导出插件
pubspec.yaml 通过以下配置声明 OHOS 插件类(android / ios 平台项保持上游原样):
flutter:
plugin:
platforms:
# android / ios 保持上游原样
ohos:
pluginClass: DeviceUuidPlugin
插件的 ohos/index.ets 需要导出实现:
import DeviceUuidPlugin from './src/main/ets/components/plugin/DeviceUuidPlugin';
export default DeviceUuidPlugin;
执行 flutter pub get 和构建后,Flutter 工具会为应用生成插件注册代码:
import { FlutterEngine, Log } from '@ohos/flutter_ohos';
import DeviceUuidPlugin from 'device_uuid';
const TAG = "GeneratedPluginRegistrant";
export class GeneratedPluginRegistrant {
static registerWith(flutterEngine: FlutterEngine) {
try {
flutterEngine.getPlugins()?.add(new DeviceUuidPlugin());
} catch (e) {
Log.e(TAG, "Tried to register plugins with FlutterEngine (" + flutterEngine + ") failed.");
Log.e(TAG, "Received exception while registering", e);
}
}
}
import DeviceUuidPlugin from 'device_uuid' 说明 entry 模块通过 oh-package 依赖了根目录 ohos/ 生成的本地 HAR。注册进引擎后,5.2.3 的成功日志中会出现 FlutterEngineCxnRegistry --> Adding plugin: DeviceUuidPlugin,可作为注册成功的标志。注册异常的排查步骤见第九节 MissingPluginException。
5.4 检查 example 的 OHOS 应用结构
本例的 example/ohos/AppScope/app.json5 中 bundleName 为 com.example.ohos_example_scaffold——这不是随意残留的脚手架名字,而是与调试签名 profile 对齐的结果:复用调试 p7b 时 bundleName 必须与 profile 的 bundle-name 一致,否则 hvigor 报 00303074(“The bundleName in app.json5/hvigorfile.ts does not match the bundleName in the generated SigningConfigs”,见 8.3)。下面是需核对的配置片段,请合并到现有工程;其中 signingConfig: "default" 需要与本机配置的签名名称一致,签名材料不应提交到公开仓库(详见 9.5):
{
"app": {
"products": [
{
"name": "default",
"signingConfig": "default",
"compatibleSdkVersion": "5.1.0(18)",
"runtimeOS": "HarmonyOS"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": ["default"]
}
]
}
]
}
配置后,在 DevEco Studio 中执行一次 Sync Project。如果 entry 模块正常识别,Project 视图中会出现 entry,并能打开 entry 模块的签名配置。
六、补全交付文件并提交适配分支
6.1 除代码外还要补全哪些文件
代码适配完成后,还需要整理安装说明、接口文档、版本记录和开源信息。接收仓库有专用模板时,按其格式填写:
| 文件 | 应写清楚的内容 |
|---|---|
README.OpenSource | 上游名称、源码地址、适配版本或提交、版权及许可证信息;按仓库模板列出第三方依赖 |
README.md | 原项目说明、OHOS 支持入口、配套 Demo 和文档链接;保留上游信息 |
README.OpenHarmony_CN.md | 简介、AtomGit 安装方式、版本对应关系、环境约束、权限、接口表、示例、已验证范围和遗留问题 |
README.OpenHarmony.md | 与中文说明对应的英文文档 |
CHANGELOG.OpenHarmony.md | OHOS 新增能力、适配版本、兼容限制与测试范围 |
LICENSE / NOTICE | 保留上游许可证;NOTICE 按许可证和原项目要求保留或补充 |
example/README.md | 依赖方式、运行目录、签名、操作步骤与效果图;覆盖 5 张卡片 |
pubspec.yaml、ohos/oh-package.json5 | 核对包名、版本、插件注册、仓库地址、许可证和依赖 |
.gitignore | 忽略构建缓存及本机签名材料,不漏提交必要源码和配置 |
README.OpenHarmony_CN.md 记录库本身的来源与版本。本例的包名为 device_uuid,pubspec 版本为 0.0.4(未随 OHOS 适配 bump),采用 MIT 许可证(Copyright © 2009 ThoSon);发布仓库为 AtomGit CPF-Flutter/fluttertpc_device_uuid(分支 main,ref 0.0.4-ohos-1.0.0-beta.1),兼容性环境写入 Flutter / DevEco Studio / SDK / ROM 四项(见 2.1)。
对带权限的插件,README 的权限章节要写两层:插件 HAR 声明了什么(文档性质),以及接入方必须在宿主声明什么(真正生效)。device_uuid 的 README 权限说明必须把 APP_TRACKING_CONSENT 的宿主声明与运行时授权讲清楚,否则接入方会直接踩进 9.4 的坑。另外如实记录:CHANGELOG.OpenHarmony.md 当前仅 3 行(“TAG 0.0.4-ohos-1.0.0-beta.1 .”),信息量过少,建议在合并前补充 OAID 方案、权限要求与验证范围。
6.2 提交前检查
提交前先完成第八节的插件、example 和真机测试,再从根目录检查改动:
git branch --show-current
git diff --check
git status --short
git diff --stat
git diff
重点检查:
example/ohos/build-profile.json5仍带着本机调试签名的绝对路径与口令,发布前必须剥离(见 9.5);example/ohos/entry/src/main/module.json5保留ohos.permission.INTERNET+ohos.permission.APP_TRACKING_CONSENT两个声明,缺一不可(见 9.4);docs/evidence/下保留关键证据(见第八节);pubspec.yaml的flutter.plugin.platforms新增ohos: pluginClass: DeviceUuidPlugin,与 Android / iOS 的pluginClass命名规则一致;git diff -- lib应为空——Dart 层零改动是本次适配的既定约束(见 4.3)。
6.3 提交并推送到 AtomGit
文档和代码整理完成后,在根目录暂存并提交。文件名按项目实际情况调整:
git add ohos pubspec.yaml .gitignore
git add example/pubspec.yaml example/lib example/ohos docs
git add README.md README.OpenHarmony_CN.md
git add README.OpenHarmony.md CHANGELOG.OpenHarmony.md
git diff --cached --check
git diff --cached --stat
git diff --cached
git commit -m "feat: adapt device_uuid plugin to OpenHarmony platform"
git remote -v
git branch --show-current
git push -u origin main
git tag -a 0.0.4-ohos-1.0.0-beta.1 -m "OAID-based device identifier, 10/10 consistency verified"
git push origin 0.0.4-ohos-1.0.0-beta.1
DevEco 可能向 example/ohos/build-profile.json5 写入本机签名配置,需要在提交前从暂存内容中移除(详见 9.5)。
本文对应的实际提交是 06ed05316c1fc268b0fb4fa7b2f9863965dcd561(“feat: adapt device_uuid plugin to OpenHarmony platform”,作者 完美句号 13418515003@163.com,2026-09-14 23:08:19 +0800),基线父提交 d770ee5(Update android.yml),直接落在 main 分支;提交规模 50 个文件、+9233 / -90 行,包含根目录 ohos/ 原生实现、example/ohos/ 宿主工程、5 卡片示例、docs/evidence/ 证据与三份 OpenHarmony 文档。附注标签 0.0.4-ohos-1.0.0-beta.1(tagger dijun_520)指向该提交,TAG message 说明了 OAID 方案与 10/10 一致性验证结果。
需要特别说明的是该提交尚未推送:仓库的 origin 仍指向上游 GitHub,本地 main 领先 origin/main 1 个提交;README 声明的发布仓库 AtomGit CPF-Flutter/fluttertpc_device_uuid 还没有收到这次推送,ref: 0.0.4-ohos-1.0.0-beta.1 要等同步推送后才能解析。推送时先 git remote add atomgit https://atomgit.com/CPF-Flutter/fluttertpc_device_uuid.git 再推 main 与 TAG,这是本次适配最重要的收尾事项。
推送后在 AtomGit 发起合并请求,说明上游来源和版本、OHOS 实现范围(160 行 ArkTS 原生实现:OAID + 权限检查)、依赖及权限(INTERNET + APP_TRACKING_CONSENT)、测试环境、操作结果、已知限制(详见第九节遗留问题),并附 Demo 运行图。目标分支和评审流程以接收仓库要求为准。
七、使用根目录 example 演示接入
仓库自带 example/,可以直接用来调试插件和体验 device_uuid 在 OpenHarmony 真机上的五种用法。
7.1 本地适配时使用路径依赖
当前 example/pubspec.yaml 的依赖是:
dependencies:
flutter:
sdk: flutter
device_uuid:
path: ../
../ 相对于 example/pubspec.yaml 指向插件根目录,修改根目录插件后可直接联调。
7.2 通过 AtomGit 引入插件
业务应用通过 AtomGit 引入时,将 device_uuid 的 path 配置替换为下面的 Git 依赖。这里固定到 README 中声明的 TAG(注意:该 TAG 需要仓库同步推送到 AtomGit 后才可解析,见 6.3):
dependencies:
flutter:
sdk: flutter
device_uuid:
git:
url: https://atomgit.com/CPF-Flutter/fluttertpc_device_uuid.git
ref: 0.0.4-ohos-1.0.0-beta.1
使用自己的适配版本时,先推送分支,再将 url 改为对应仓库,ref 改为 feat/ohos_device_uuid_0.0.4。正式发布后可固定到 tag 或 commit。
从插件根目录执行:
cd example
flutter pub get
flutter pub deps
检查 example/pubspec.lock 中 device_uuid 的来源为 git,并核对 url、ref 和 resolved-ref。同时检查没有 dependency_overrides 或 pubspec_overrides.yaml 将其覆盖回本地依赖,确认应用使用的是 Git 依赖。
7.3 调用接口实现 5 卡片演示
device_uuid 的核心 API 只有一个 getUUID(),demo 把它扩展为 5 张卡片:① UUID 详情卡(Format / Fetched at / Latency / Call count / Running on 字段 + Refresh UUID / Copy 按钮)、② 一致性压测卡(5/10/20/50 calls 档位 + Run test,校验返回值稳定)、③ 调用历史卡(滑动删除、点击详情)、④ 设置卡、⑤ 通道契约卡,外加 “How the UUID is produced” 说明区。下面摘录 demo 的核心状态与 ①② 两张关键卡片,完整实现见仓库 example/lib/main.dart(example 侧共 739 行改动):
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:device_uuid/device_uuid.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'device_uuid OHOS demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.light,
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final DeviceUuid _deviceUuid = DeviceUuid();
/// ① UUID 详情卡
String? _uuid;
DateTime? _fetchedAt;
int _latencyMs = 0;
int _callCount = 0;
/// ② 一致性压测卡
int _selectedCalls = 10;
bool _testing = false;
String? _testResult;
Future<void> _refreshUuid() async {
final stopwatch = Stopwatch()..start();
final String? uuid = await _deviceUuid.getUUID();
stopwatch.stop();
if (!mounted) return;
setState(() {
_uuid = uuid;
_fetchedAt = DateTime.now();
_latencyMs = stopwatch.elapsedMilliseconds;
_callCount += 1;
});
}
Future<void> _runConsistencyTest(int calls) async {
setState(() => _testing = true);
final values = <String>{};
int errors = 0;
int totalMs = 0;
for (var i = 0; i < calls; i++) {
final stopwatch = Stopwatch()..start();
try {
final String? uuid = await _deviceUuid.getUUID();
stopwatch.stop();
totalMs += stopwatch.elapsedMilliseconds;
if (uuid != null) values.add(uuid);
} catch (_) {
errors += 1;
}
}
if (!mounted) return;
final stable = values.length == 1 && errors == 0;
setState(() {
_testing = false;
_callCount += calls;
_testResult = '${values.length} / $calls'
'${stable ? '\nPASS — identifier is stable' : ''}'
'\nunique values: ${values.length} · errors: $errors · '
'avg latency: ${(totalMs / calls).toStringAsFixed(1)} ms';
});
}
Future<void> _copyUuid() async {
if (_uuid == null) return;
await Clipboard.setData(ClipboardData(text: _uuid!));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('UUID copied to clipboard')),
);
}
void initState() {
super.initState();
_refreshUuid();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('device_uuid OHOS demo')),
body: ListView(
padding: const EdgeInsets.all(8),
children: [
// ① UUID 详情卡
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('UUID',
style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
SelectableText(_uuid ?? '查询中…'),
const SizedBox(height: 4),
Text('Format: '
'${_uuid?.length == 36 ? 'Standard UUID (36 chars)' : '—'}'),
Text('Fetched at: ${_fetchedAt ?? '—'}'),
Text('Latency: $_latencyMs ms'),
Text('Call count: $_callCount'),
const Text('Running on: ohos'),
const SizedBox(height: 8),
Row(
children: [
FilledButton(
onPressed: _refreshUuid,
child: const Text('Refresh UUID'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: _copyUuid,
child: const Text('Copy'),
),
],
),
],
),
),
),
// ② 一致性压测卡
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Consistency test',
style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Wrap(
spacing: 8,
children: [
for (final n in const [5, 10, 20, 50])
ChoiceChip(
label: Text('$n calls'),
selected: _selectedCalls == n,
onSelected: (_) =>
setState(() => _selectedCalls = n),
),
],
),
const SizedBox(height: 8),
FilledButton(
onPressed: _testing
? null
: () => _runConsistencyTest(_selectedCalls),
child: Text(_testing ? 'Running…' : 'Run test'),
),
if (_testResult != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_testResult!),
),
],
),
),
),
// ③ 调用历史卡(滑动删除、点击详情)
// ④ 设置卡
// ⑤ 通道契约卡
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Channel contract',
style: TextStyle(fontWeight: FontWeight.w600)),
SizedBox(height: 4),
Text('Single method: getUUID() → String? (never throws)'),
],
),
),
),
// "How the UUID is produced" 说明区
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('How the UUID is produced',
style: TextStyle(fontWeight: FontWeight.w600)),
SizedBox(height: 4),
Text('• Android: ANDROID_ID hashed with SHA-1 (hex)'),
Text('• iOS/macOS: Keychain-persisted device UUID (XYUUID)'),
Text('• OpenHarmony: OAID via @ohos.identifier.oaid '
'(no ANDROID_ID equivalent)'),
SizedBox(height: 4),
Text('Note: an all-zero OAID means the user has not '
'granted the app tracking consent.'),
],
),
),
),
],
),
);
}
}
7.3.1 5 张卡片与说明区一览
| 卡片 | 关键内容 | 交互 |
|---|---|---|
| ① UUID 详情卡 | Format / Fetched at / Latency / Call count / Running on | Refresh UUID / Copy(复制后弹 snackbar) |
| ② 一致性压测卡 | 5 / 10 / 20 / 50 calls 档位 | Run test,统计 unique values / errors / avg latency,校验返回值稳定 |
| ③ 调用历史卡 | 历次调用记录 | 滑动删除、点击查看详情 |
| ④ 设置卡 | demo 展示配置 | — |
| ⑤ 通道契约卡 | Single method: getUUID() → String? (never throws) | — |
页面底部的 “How the UUID is produced” 说明区逐端写明了标识来源:
- Android:ANDROID_ID hashed with SHA-1 (hex);
- iOS / macOS:Keychain-persisted device UUID (XYUUID);
- OpenHarmony:OAID via
@ohos.identifier.oaid(no ANDROID_ID equivalent)。
并附一条 Note:如果拿到的 OAID 是全 0(00000000-0000-0000-0000-000000000000),说明用户未授予广告跟踪权限,业务侧应据此降级处理而不是当作普通标识使用。
7.4 页面退出时的异步处理
异步回调先检查 mounted,避免页面销毁后继续调用 setState。getUUID() 是一次性的 Future,不需要取消订阅,dispose 中无需额外清理。
多个页面都需要设备标识时,可以在应用启动阶段缓存一次 getUUID() 的结果,各页面只读取缓存;OAID 在授权周期内稳定,无需反复调用。
八、验证、构建与鸿蒙设备运行效果
8.1 分别验证插件与 example
从插件仓库根目录执行:
flutter pub get
flutter analyze
flutter test
cd example
flutter pub get
flutter analyze
flutter test
当前仓库的运行结果如实记录:example 目录下 flutter analyze 报告 1 error ——test/widget_test.dart:16:35 The name 'MyApp' isn't a class。这是 main.dart 重写后遗留的模板测试文件未同步更新(模板测试引用的 MyApp 已不在示例代码中),属已知遗留问题,修复方式是更新或删除该测试文件;除此之外插件与 example 均无 issue。flutter test 在本机因 flutter_tester 的 WebSocketException 无法加载测试运行器,这是本地测试环境问题,不是被测代码缺陷。
该 error 的两种修复方式:
# 方式一:直接删除遗留的模板测试
rm example/test/widget_test.dart
# 方式二:更新测试,引用重写后真实存在的入口
# 将 widget_test.dart 中的 MyApp 替换为 example/lib/main.dart 的实际根组件
8.2 确认设备连接
hdc list targets
flutter devices
设备首次连接电脑时,需要在手机端确认调试授权。本文适配使用的设备为 4UQ9K25508013016,系统 ROM OpenHarmony 6.1.1.120(API 24)——与系列前几篇(HUAWEI nova 14 / 6.1.0.135)不是同一台设备,如需对比数据请以本篇设备信息为准。列表为空时,检查 USB 连接、调试模式和电脑授权。
8.3 配置签名
真机安装的 HAP 通常需要有效签名。推荐使用 DevEco Studio 为 entry 模块配置自动签名:
- 用 DevEco Studio 打开
example/ohos,不是仓库根目录; - 等待工程 Sync 成功,确认 Project 视图中存在
entry模块; - 打开 File > Project Structure > Signing Configs;
- 为
defaultproduct 选择或生成签名; - 确认设备、应用包名
com.example.ohos_example_scaffold、证书和 Profile 匹配; - 再回到终端执行 Flutter 构建或运行。
签名材料保存在本机,公开仓库中只保留构建所需的通用配置(详见 9.5)。这里有一个复用调试 p7b 的硬约束:bundleName 必须与 profile 的 bundle-name 一致,否则 hvigor 在 SignHap 阶段报 00303074——“The bundleName in app.json5/hvigorfile.ts does not match the bundleName in the generated SigningConfigs”。example 的 bundleName 之所以是 com.example.ohos_example_scaffold,就是与调试 profile 对齐的结果,不是随意残留;换 bundleName 就必须换对应申请的 profile。
排查签名报错时按顺序核对三件事:报错码是否为 00303074(bundleName 不匹配)、证书 / profile 是否由同一账号为该 bundleName 申请、signingConfig 名称是否与 products 里的引用一致。三者都对仍失败时,删掉本机签名配置重新自动签名生成一份。
8.4 运行示例
以下命令在 example/ 目录执行,将 <device-id> 替换为设备列表中的实际 ID:
flutter run -d <device-id>
也可以先构建 HAP:
flutter build hap --debug
本次构建的输出:
Running Hvigor task assembleHap... 13.4s
✓ Built build/ohos/hap/entry-default-signed.hap
签名构建直接通过,产物为 entry-default-signed.hap。
8.5 在设备上测试 5 张卡片
- 打开应用,首次调用
getUUID()触发APP_TRACKING_CONSENT权限弹窗,点"允许"; - 确认 UUID 详情卡显示 36 位标准 UUID 与
Format: Standard UUID (36 chars)、Running on: ohos;首次调用 Latency 为 915 ms(含权限弹窗等待); - 点 Refresh UUID 再次调用,授权后 Latency 降至 4 ms 量级,Call count 递增;
- 点 Copy,底部出现 “UUID copied to clipboard” snackbar,剪贴板内容与卡片一致;
- 在一致性压测卡选择 10 calls 并 Run test,确认结果 “10 / 10”、PASS、unique values: 1、errors: 0;
- 在系统设置中关闭本应用的广告跟踪授权后再调用,确认返回全 0 UUID(
00000000-0000-0000-0000-000000000000),插件不报错——对应 5.2.3 修复前的OAIDService日志; - 切到后台再切回前台,确认卡片状态不丢失;
- 卸载后重新安装,确认 UUID 不变——OAID 是设备级标识,不受应用卸载影响。
8.6 鸿蒙设备运行效果
完成适配后,Flutter 应用能够在 OpenHarmony 6.1.1.120(API 24)真机上正常读取 OAID:首次调用(含权限弹窗)Latency 915 ms,授权后 4 ms;一致性压测 10/10 PASS,unique values 1,errors 0,avg latency 54.8 ms。
下面是 6 张真机截图,分别对应首屏(UUID 详情卡 + 三平台说明)、复制成功(18 次调用、4 ms)、一致性压测:
我先逐张查看这 6 张截图,再按表 1 / 表 2 格式整理输出。
这 6 张截图实际上是 Device UUID Example 示例页(OpenHarmony,TLR-AL00 6.1.0.135)在不同操作阶段的快照——与上文 SliderGradient 截图不同,因此我按同样的"模块说明 + 状态快照"双表结构整理如下(附件共 6 张,非 7 张)。
表 1 · 页面模块与配置说明
| 模块 | 关键配置 | 预期表现 |
|---|---|---|
| 设备 UUID | DeviceUuid().getUUID() → Future<String?> | 展示 36 位标准 UUID、格式识别、获取时间、延迟、调用次数、运行平台;提供 Refresh UUID / Copy |
| 一致性测试 | 重复调用 getUUID(),5 / 10 / 20 / 50 档位 | 校验标识符稳定,输出 unique values / errors / avg latency 并判定 PASS / FAIL |
| 调用历史 | 每次调用落盘(UUID、时间戳、延迟),支持 Clear all、点条目查看详情 | 滚动记录最近调用,最新在前 |
| 演示设置 | Auto-refresh on app resume(WidgetsBindingObserver) | 开关开启时应用回到前台自动刷新 UUID |
| 通道契约 | MethodChannel('device_uuid') → getUUID() → String?(never throws) | 展示 Android / iOS·macOS / OpenHarmony 三端标识来源与 OAID 全零说明 |
表 2 · 各截图实测状态快照
| 截图时刻 | 设备 UUID 卡(格式 / 获取时间 / 延迟 / 次数 / 平台) | 一致性测试(档位 / 结果 / unique·errors·avg) | 调用历史 | 演示设置 | 通道契约 |
|---|---|---|---|---|---|
| 00:20 | 92332691-f9a5-4e5b-8b65-5d4640e498ba · Standard UUID (36 chars) / 00:19:50 / 2420 ms / 1 / ohos | 10 calls 选中,未运行 | 空 | 未滚到 | 未滚到 |
| 00:21(a) | 未显示 | 10 calls 选中,未运行 | 空(No calls yet) | 开启 | 顶部,仅通道名+方法名(截断) |
| 00:21(b) | 未显示 | 50 calls,已完成 50/50,PASS · unique:1 · errors:0 · avg:25.1 ms | 空(No calls yet) | 开启 | 未显示 |
| 00:22 | 未显示 | 未显示 | 空(No calls yet) | 开启 | 完整三端对照 + OAID 全零说明 |
| 00:23(a) | 未显示 | PASS · unique:1 · errors:0 · avg:22.7 ms | 3 条:00:22:47·5ms / 00:22:46·5ms / 00:22:38·840ms;弹出 Call details(Time 00:22:47,Latency 5ms,Result Success) | 未显示 | 未显示 |
| 00:23(b) | 92332691-… · Standard UUID (36 chars) / 00:23:45 / 37 ms / 6 / ohos | 50 calls,已完成 50/50,PASS · unique:1 · errors:0 · avg:17.3 ms | 顶部 1 条:00:23:45 · 37ms | 未滚到 | 未滚到 |
走查结论
- 首次调用(00:20)延迟 2420 ms——含 OAID 权限检查与首次授权路径;授权后的后续调用延迟降至 5–37 ms,符合"首次弹窗、后需即时返回"的预期。
- 一致性测试在 50 calls 档位下多次运行均 unique values: 1 · errors: 0,标识符跨调用稳定;avg latency 25.1 / 22.7 / 17.3 ms,随授权缓存趋于稳定。
- 调用历史按时间戳与延迟落盘(5ms / 5ms / 840ms),可清空、可查看详情,与配置说明一致。
- 通道契约卡完整展示
MethodChannel('device_uuid')/getUUID()→String?(never throws),三端语义(ANDROID_ID+SHA-1 / Keychain XYUUID / OAID)及全零 OAID 的 APP_TRACKING_CONSENT 说明正确渲染。 - 演示设置开关正常,应用生命周期监听项配置就绪。整体行为符合 device_uuid 鸿蒙适配的预期。
以下是操作的视屏,可以参考一下:
九、FAQ:适配过程与使用问题
9.1 OHOS 上需要写原生 ArkTS 代码吗?
要写,而且这次不是模板桩,是 160 行的真原生逻辑。
device_uuid 的全部价值都在系统侧:设备标识必须由操作系统提供,Flutter 框架内拿不到 OAID。因此 OHOS 适配补全了:
pubspec.yaml的flutter.plugin.platforms新增ohos: pluginClass: DeviceUuidPlugin;- 根目录
ohos/src/main/ets/components/plugin/DeviceUuidPlugin.ets(160 行),实现FlutterPlugin + MethodCallHandler + AbilityAware:注册通道device_uuid,每次getUUID先做APP_TRACKING_CONSENT授权检查(未授权且有 UIAbilityContext 时弹系统权限弹窗),再调oaid.getOAID()拿 36 位标准 UUID; ohos/index.ets导出实现,GeneratedPluginRegistrant.ets自动注入注册代码。
其中 AbilityAware 是"按需实现"的典型场景:权限弹窗必须依赖 UIAbilityContext(在 onAttachedToAbility 中缓存),纯后台插件不需要它。这是系列中第一次出现真正复杂的原生实现,也是"通道桩"与"原生适配"的分水岭。
9.2 OAID 与 ANDROID_ID 的语义差异
三端返回值形态本就不一致,这是上游设计使然:
| 平台 | 标识 | 形态 | 可变性 |
|---|---|---|---|
| Android | ANDROID_ID 经 SHA-1 | 40 位十六进制 | 恢复出厂后变化 |
| iOS / macOS | XYUUID + Keychain | 库定义 UUID | 卸载后由 Keychain 维持 |
| OpenHarmony | OAID | 36 位标准 UUID,原样返回 | 用户可重置、可关闭授权 |
OHOS 端不做二次哈希:Android 对 ANDROID_ID 做 SHA-1 是为了抹平原始标识,而 OAID 本身已由系统匿名化,再做哈希反而破坏标准 UUID 形态。业务若要跨端比对设备,不要假设格式(Android 40 位 hex、OHOS 36 位 UUID),应在服务端做归一化——这是语义层遗留而非缺陷,README 已提醒接入方。
9.3 为什么 getUUID() 返回全 0 UUID?
00000000-0000-0000-0000-000000000000 有两条产生路径:
- 用户拒绝授权:
APP_TRACKING_CONSENT是 user_grant 权限,用户点"拒绝"后系统侧getOAID()返回全 0,插件透传、不报错——这是合规行为,不是 bug; - 宿主未声明权限:OAIDService 直接拒绝服务。真机 hilog 中的证据:
OAIDService: [nodict]the caller not granted the app tracking permission
OAIDService: [nodict]get oaid not granted the app tracking permission
修复方式:在宿主 module.json5 声明 ohos.permission.APP_TRACKING_CONSENT(见 5.2.2),并完成运行时授权(插件会自动弹窗)。修复后日志变为 getOaid success(见 5.2.3)。demo 说明区的 Note 已提示:拿到全 0 时应降级处理,不要当作普通标识使用。
9.4 HAR 内的权限声明为什么不生效?
HAR 内的 requestPermissions 不会合并进宿主应用。 插件的 ohos/src/main/module.json5 虽然完整声明了 APP_TRACKING_CONSENT(含 reason $string:oaid_permission_reason、usedScene when: inuse),但这份声明只是文档性质的提示——安装到设备上的是宿主应用的 HAP,权限以宿主声明为准。
因此接入方必须做两件事:
- 在宿主
module.json5的requestPermissions里自己声明ohos.permission.APP_TRACKING_CONSENT(本例example/ohos/entry/src/main/module.json5声明了INTERNET+APP_TRACKING_CONSENT,abilities: ["EntryAbility"]、when: inuse); - user_grant 权限仅声明不够,必须运行时授权——插件已在每次
getUUID前内置检查与弹窗,接入方不需要重复实现,但要保证应用有前台 UIAbility。
这是本篇最大的坑:症状(全 0 UUID)与根因(宿主漏声明)之间隔了一层 HAR,排查时容易盯着插件目录看而漏掉宿主配置。
9.5 签名材料入库问题
example/ohos/build-profile.json5 中包含本地调试签名材料,已随本次提交进入公开仓库:
signingConfigs[*].material.certpath / storeFile / profile = /Users/david/.ohos/config/...(本机绝对路径)
keyPassword / storePassword = 加密口令(已随提交入库)
certpath / storeFile / profile 是本机绝对路径,离开本机就找不到文件;keyPassword / storePassword 是调试签名口令,一旦泄露需要轮换证书。这与系列前两篇是同一类安全卫生问题。
处理顺序:
-
本地立即轮换调试证书:在 DevEco Studio 删除现有自动签名,重新生成一份;
-
剥离仓库中的敏感配置:把
example/ohos/build-profile.json5的signingConfigs替换为通用占位:{ "app": { "signingConfigs": [ { "name": "default", "type": "HarmonyOS" } ], "products": [ { "name": "default", "signingConfig": "default", "compatibleSdkVersion": "5.1.0(18)", "runtimeOS": "HarmonyOS" } ] }, "modules": [ { "name": "entry", "srcPath": "./entry", "targets": [{ "name": "default", "applyToProducts": ["default"] }] } ] } -
提交一个清理 PR:在 PR 描述中说明
git diff -- example/ohos/build-profile.json5已不再包含绝对路径或口令; -
避免再次写入:使用 DevEco Studio 时关闭"保存签名到项目",或把
example/ohos/build-profile.json5加入本地.gitignore(注意:这会影响其他贡献者,需要在 README 中说明)。
9.6 LICENSE 与 oh-package.json5 的 license 不一致
LICENSE 文件声明的是 MIT(Copyright © 2009 ThoSon),但 ohos/oh-package.json5 中 license 字段写的是 "Apache-2.0";example/ohos/oh-package.json5 的 license 则是空字符串,三处两种口径。
本文仅记录该不一致,不作修改。建议:
- 保留
LICENSE不动:这是上游的现状; ohos/oh-package.json5的license改为"MIT":与上游保持一致;如果上游未来调整为 Apache-2.0,再同步修改;example/ohos/oh-package.json5的license补为"MIT"或删除空字符串字段,避免下游合规扫描误报。
9.7 getUUID() 返回 null 的情况
null 与全 0 是两种不同的失败形态,不要混淆:
- 全 0 UUID(36 位):权限被拒或宿主未声明权限,系统返回全 0,插件透传(见 9.3);
null:异常兜底路径。插件所有通路(onAttachedToEngine/onMethodCall/getUUID/ 权限检查)都包了try/catch,最终兜底result.success(null)——与 Android 端 Kotlincatch里的result.success(null)逐字对齐。可能的触发场景包括getOAID()本身抛错等原生异常。
另外,无 UIAbilityContext 时权限检查会记 no UIAbility context, skip permission request 并跳过弹窗(见 5.1.3)。业务侧的正确姿势是:null 与全 0 都按"无标识"降级处理,只有拿到非全 0 的 36 位 UUID 才当作有效标识。
9.8 MissingPluginException
MissingPluginException 通常表示 Dart 通道找不到已注册的原生插件。新增原生插件后需要重新构建应用:
cd example
flutter clean
flutter pub get
flutter run -d <device-id>
如果仍然出现,检查:
example/ohos/entry/src/main/ets/plugins/GeneratedPluginRegistrant.ets是否包含import DeviceUuidPlugin from 'device_uuid';与flutterEngine.getPlugins()?.add(new DeviceUuidPlugin());;pubspec.yaml的flutter.plugin.platforms.ohos.pluginClass是不是DeviceUuidPlugin;ohos/index.ets是否正确export default DeviceUuidPlugin;- 插件的
ohos/oh-package.json5的name字段是不是device_uuid(与GeneratedPluginRegistrant中的import ... from 'device_uuid'对应)。
注册成功的标志是真机日志中出现 FlutterEngineCxnRegistry --> Adding plugin: DeviceUuidPlugin(见 5.2.3)。
9.9 一致性压测怎么做?
demo ② 卡内置了压测:在 5 / 10 / 20 / 50 calls 四个档位中任选一个,点 Run test 后连续调用 getUUID(),统计三类指标:
- unique values:去重后的返回值数量,应为 1(标识稳定);
- errors:抛异常的次数,正常为 0(注意插件契约本身不抛异常,errors 恒为 0 是预期行为);
- avg latency:平均单次耗时。
真机 10 calls 档位的实测结果:“10 / 10”、“PASS — identifier is stable”、“unique values: 1 · errors: 0 · avg latency: 54.8 ms”。如果 unique > 1,说明标识在调用间不稳定——OHOS 上 OAID 是设备级标识,授权周期内不会变化,出现这种情况应优先排查是否混入了全 0 或 null 返回。
9.10 flutter create 崩溃:--org 推断失败
在仓库根目录执行 flutter create --template=plugin --platforms=ohos . 时直接崩溃,报 xcodebuild invalid option。原因是 create 在未显式传 --org 时会推断组织名,推断过程读取了本机已有的 iOS 工程,而本机 Xcode 13 过旧,xcodebuild 探测阶段抛错。
解决办法是显式传入组织名:
flutter create --template=plugin --platforms=ohos \
--project-name device_uuid --org=it.thoson .
it.thoson 与 Android 包名 it.thoson.device_uuid 保持一致,避免同一个插件在不同平台出现两个组织名。
9.11 flutter create 不认识 ohos,或生成多余文件
先执行 flutter --version,确认使用的是 OHOS 版工具链,环境配置回到第二节的社区链接核对。包名报错时,确认当前目录包含目标插件的 pubspec.yaml,并显式传入 --project-name device_uuid。
对既有工程执行 flutter create --platforms=ohos . 还有一个副作用:它不只生成 ohos/ 产物,还会顺带生成 SPM 骨架、kts 迁移文件、test 目录等多余文件。处理方式是只保留 ohos/ 相关产物,其余删除或 git checkout -- 恢复,提交前用 git status --short 复核没有意外混入的文件。
另一个通用建议:写系统 API 前先读 SDK 的 .d.ts 头文件——本次适配中 getOAID 的签名、authResults 的结构、accessTokenId 的取法都是从声明文件里逐条确认的,不要凭记忆或博客片段写原生代码。
相关链接
更多推荐




所有评论(0)