开发工具: 华为云码道

本文配套仓库: 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 才可解析)。

在这里插入图片描述


KeyHash 示例页 KeyHash 示例页 KeyHash 示例页 KeyHash 示例页 KeyHash 示例页 KeyHash 示例页

我先逐张查看这 6 张截图,再按表 1 / 表 2 格式整理输出。

这 6 张截图实际上是 Device UUID Example 示例页(OpenHarmony,TLR-AL00 6.1.0.135)在不同操作阶段的快照——与上文 SliderGradient 截图不同,因此我按同样的"模块说明 + 状态快照"双表结构整理如下(附件共 6 张,非 7 张)。

表 1 · 页面模块与配置说明

模块关键配置预期表现
设备 UUIDDeviceUuid().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 resumeWidgetsBindingObserver开关开启时应用回到前台自动刷新 UUID
通道契约MethodChannel('device_uuid')getUUID()String?(never throws)展示 Android / iOS·macOS / OpenHarmony 三端标识来源与 OAID 全零说明

表 2 · 各截图实测状态快照

截图时刻设备 UUID 卡(格式 / 获取时间 / 延迟 / 次数 / 平台)一致性测试(档位 / 结果 / unique·errors·avg)调用历史演示设置通道契约
00:2092332691-f9a5-4e5b-8b65-5d4640e498ba · Standard UUID (36 chars) / 00:19:50 / 2420 ms / 1 / ohos10 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 ms3 条: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 / ohos50 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 鸿蒙适配的预期。

以下是操作的视屏,可以参考一下:

Example 启动授权 Example 启动授权 Example 启动授权


一、插件简介与适配目标

device_uuid 把"取一个设备唯一标识"这件事压缩成一行 API。业务只需要实例化 DeviceUuidawait getUUID(),就能拿到一个平台相关的设备标识字符串;插件不抛平台异常——任何失败都以 null 静默返回,调用方用 String? 接住即可:

import 'package:device_uuid/device_uuid.dart';

final deviceUuid = DeviceUuid();
final String? uuid = await deviceUuid.getUUID();

上游仅提供 Android / iOS / macOS 平台实现,OpenHarmony 平台无法直接使用。OHOS 适配目标有三个:

  1. 平台通道对齐:补全 ohos/ 根目录模块,新增 DeviceUuidPlugin.ets(160 行)注册与 Android / iOS 完全一致的通道 device_uuid 和方法 getUUID(无参数、返回 String?);失败路径逐字对齐 Android 的静默契约——异常兜底 result.success(null),未知方法 result.notImplemented()
  2. 原生标识选型与授权:OpenHarmony 没有 ANDROID_ID 的等价物,需要为"设备标识"选一个 OHOS 原生方案;本次适配选用 OAID(@ohos.identifier.oaid,Open Anonymous Device Identifier),getOAID() 原样返回 36 位标准 UUID,不做二次哈希,并在每次调用前处理 ohos.permission.APP_TRACKING_CONSENT(user_grant)的运行时授权;
  3. 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 SDK3.44.9+ohos-0.0.1-canary1Flutter 编译与 OHOS 平台工具链
DevEco Studio26.0.0(DS-261.23567.138.36.2600821)OHOS 工程构建、Sync 与签名
compatibleSdkVersion5.1.0(18)当前工程声明的最低兼容版本
设备 ROMOpenHarmony 6.1.1.120(API 24)真机验证环境
插件版本0.0.4(未随 OHOS 适配 bump)pubspec.yaml 中的包版本
OHOS TAG0.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.getBundleInfoForSelfSyncatManager.checkAccessTokenSyncrequestPermissionsFromUser 等授权 API 也都在最低兼容范围内;插件本体不依赖更高版本 API。


三、从源码仓库开始准备适配工程

3.1 将上游源码同步到 AtomGit

适配已有第三方插件时,先从 Pub 包信息或项目 README 确认上游地址、包名、版本和许可证。同步仓库时保留源码、许可证和提交历史,方便后续跟踪上游更新。

在 AtomGit 网页的新建仓库流程中,找到从已有仓库导入的入口,填写上游 Git 地址,选择自己有写权限的组织或个人空间,设置目标仓库名称后执行导入。完成后检查目标分支、pubspec.yamlLICENSE 和提交历史是否完整,并记录适配起点的提交号。如果上游本身托管在 AtomGit,也可以通过 Fork 获得自己的工作仓库。

device_uuid 的上游是 GitHub 仓库 thoson-it/device_uuid(默认分支 main,MIT 许可证,Copyright © 2009 ThoSon)。本系列的发布仓库是 AtomGit CPF-Flutter/fluttertpc_device_uuidCPF-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.yamllib/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_uuidgit status --short --branch 显示本地 main 领先 origin/main 1 个提交(OHOS 适配提交尚未推送),pubspec.yamlname: device_uuidversion: 0.0.4

3.3 在仓库根目录创建适配分支

接着在 device_uuid/ 根目录创建分支。分支名统一使用 feat/ohos_库名称_版本号,库名称取 pubspec.yamlname,版本号取此次适配的基线版本。本例为:

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.yamllib/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.dart5 卡片演示 + “How the UUID is produced” 说明区
example/ohos/AppScope/app.json5bundleName com.example.ohos_example_scaffold(与调试 profile 对齐,见 8.3)
example/ohos/build-profile.json5products.default + modules.entry,含本机签名材料,需在提交前剥离(见 9.5)
README.OpenHarmony_CN.md / README.OpenHarmony.md中英文安装方式、环境约束、权限、接口表、示例
CHANGELOG.OpenHarmony.mdOHOS 适配变更记录(当前仅 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 实现 FlutterPluginMethodCallHandlerAbilityAware,对未实现方法统一返回 notImplemented();与 Android 端的 Kotlin 实现一样,任何异常都不通过 result.error 上抛,而是静默 result.success(null)

4.1 跨端架构与调用时序

demo 的每一次 UUID 读取都经过通道;OHOS 原生侧在拿到 OAID 之前要先完成 APP_TRACKING_CONSENT 的授权检查,未授权时弹系统权限弹窗。整体架构如下:

getUUID

已授权

未授权且有 UIAbilityContext

authResults[0] === 0

36 位标准 UUID

result.success

String?

Future

Flutter 页面 MyHomePage

DeviceUuid getUUID

MethodChannel device_uuid

ArkTS DeviceUuidPlugin

ensureTrackingConsent 权限检查

oaid getOAID

requestPermissionsFromUser 弹窗

与系列前几篇"纯 Dart 组件 + 一次性版本查询"不同,本插件的全部价值都在这条通道上;且通道返回值受权限状态影响——拒绝授权时系统返回全 0 UUID,插件透传,不报错。

4.1.1 一次 getUUID() 调用的时序
@ohos.identifier.oaid abilityAccessCtrl DeviceUuidPlugin.ets MethodChannel device_uuid DeviceUuid (Dart) Flutter App @ohos.identifier.oaid abilityAccessCtrl DeviceUuidPlugin.ets MethodChannel device_uuid DeviceUuid (Dart) Flutter App alt [未授权且有 UIAbilityContext] getUUID() invokeMethod("getUUID") onMethodCall(call, result) checkAccessTokenSync(APP_TRACKING_CONSENT) requestPermissionsFromUser(context, [...]) authResults[0] === 0 getOAID() 36 位标准 UUID result.success(oaid) String? Future<String?>

注意两个分支:用户拒绝授权时,系统侧 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 {
  // 使用设备标识
}

三端的返回值语义本就不一致,这是本系列少见的"上游三端语义各自为政"的库:

平台标识来源返回值形态
AndroidSettings.Secure.ANDROID_ID 经 SHA-140 位十六进制字符串
iOS / macOSXYUUID 库,Keychain 持久化库定义的设备 UUID
OpenHarmonyOAID(@ohos.identifier.oaid36 位标准 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.oaidgetOAID()✅ 采用Open Anonymous Device Identifier,API 10+,系统级匿名化标识,返回 36 位标准 UUID
@ohos.deviceInfoserial❌ 放弃需要 MANAGE_DEVICE_INFO 系统权限,三方应用不可申请
自生成 UUID 存 preferences❌ 放弃卸载即失效,不满足"设备标识"的跨安装周期语义

两点设计决定值得说明:

  • 不做二次哈希:Android 端对 ANDROID_ID 做 SHA-1 是为了抹平原始标识;OAID 本身已经过系统匿名化(用户可在设置中重置),原样返回 36 位 UUID 即可,再做哈希反而破坏标准形态;
  • 接受用户可控性:OAID 是广告跟踪类标识,用户可以关闭授权(此时返回全 0)或重置,这是合规特性而非缺陷,demo 的说明区对此有明确提示。
5.1.3 权限检查与 AbilityAware

ensureTrackingConsent 的流程是:getBundleInfoForSelfSyncaccessTokenIdcheckAccessTokenSync 查授权 → 已授权直接放行;未授权且 UIAbilityContextrequestPermissionsFromUser 弹窗,以 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),插件透传、不报错。所有通路——onAttachedToEngineonMethodCallgetUUID、权限检查——都用 try / catch 包住,最终兜底 result.success(null),与 Android 端 Kotlin catch 里的 result.success(null) 逐字对齐。

getUniqueClassName() 必须返回 'DeviceUuidPlugin',与 pubspec.yamlplugin.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_reasonusedScenewhen: 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.json5bundleNamecom.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.mdOHOS 新增能力、适配版本、兼容限制与测试范围
LICENSE / NOTICE保留上游许可证;NOTICE 按许可证和原项目要求保留或补充
example/README.md依赖方式、运行目录、签名、操作步骤与效果图;覆盖 5 张卡片
pubspec.yamlohos/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.yamlflutter.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_uuidpath 配置替换为下面的 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.lockdevice_uuid 的来源为 git,并核对 urlrefresolved-ref。同时检查没有 dependency_overridespubspec_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 onRefresh 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,避免页面销毁后继续调用 setStategetUUID() 是一次性的 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_testerWebSocketException 无法加载测试运行器,这是本地测试环境问题,不是被测代码缺陷。

该 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 模块配置自动签名:

  1. 用 DevEco Studio 打开 example/ohos,不是仓库根目录;
  2. 等待工程 Sync 成功,确认 Project 视图中存在 entry 模块;
  3. 打开 File > Project Structure > Signing Configs
  4. default product 选择或生成签名;
  5. 确认设备、应用包名 com.example.ohos_example_scaffold、证书和 Profile 匹配;
  6. 再回到终端执行 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 张卡片

  1. 打开应用,首次调用 getUUID() 触发 APP_TRACKING_CONSENT 权限弹窗,点"允许";
  2. 确认 UUID 详情卡显示 36 位标准 UUID 与 Format: Standard UUID (36 chars)Running on: ohos;首次调用 Latency 为 915 ms(含权限弹窗等待);
  3. 点 Refresh UUID 再次调用,授权后 Latency 降至 4 ms 量级,Call count 递增;
  4. 点 Copy,底部出现 “UUID copied to clipboard” snackbar,剪贴板内容与卡片一致;
  5. 在一致性压测卡选择 10 calls 并 Run test,确认结果 “10 / 10”、PASS、unique values: 1、errors: 0;
  6. 在系统设置中关闭本应用的广告跟踪授权后再调用,确认返回全 0 UUID(00000000-0000-0000-0000-000000000000),插件不报错——对应 5.2.3 修复前的 OAIDService 日志;
  7. 切到后台再切回前台,确认卡片状态不丢失;
  8. 卸载后重新安装,确认 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)、一致性压测:


KeyHash 示例页 KeyHash 示例页 KeyHash 示例页 KeyHash 示例页 KeyHash 示例页 KeyHash 示例页

我先逐张查看这 6 张截图,再按表 1 / 表 2 格式整理输出。

这 6 张截图实际上是 Device UUID Example 示例页(OpenHarmony,TLR-AL00 6.1.0.135)在不同操作阶段的快照——与上文 SliderGradient 截图不同,因此我按同样的"模块说明 + 状态快照"双表结构整理如下(附件共 6 张,非 7 张)。

表 1 · 页面模块与配置说明

模块关键配置预期表现
设备 UUIDDeviceUuid().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 resumeWidgetsBindingObserver开关开启时应用回到前台自动刷新 UUID
通道契约MethodChannel('device_uuid')getUUID()String?(never throws)展示 Android / iOS·macOS / OpenHarmony 三端标识来源与 OAID 全零说明

表 2 · 各截图实测状态快照

截图时刻设备 UUID 卡(格式 / 获取时间 / 延迟 / 次数 / 平台)一致性测试(档位 / 结果 / unique·errors·avg)调用历史演示设置通道契约
00:2092332691-f9a5-4e5b-8b65-5d4640e498ba · Standard UUID (36 chars) / 00:19:50 / 2420 ms / 1 / ohos10 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 ms3 条: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 / ohos50 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 鸿蒙适配的预期。

以下是操作的视屏,可以参考一下:

Example 启动授权 Example 启动授权 Example 启动授权


九、FAQ:适配过程与使用问题

9.1 OHOS 上需要写原生 ArkTS 代码吗?

要写,而且这次不是模板桩,是 160 行的真原生逻辑。

device_uuid 的全部价值都在系统侧:设备标识必须由操作系统提供,Flutter 框架内拿不到 OAID。因此 OHOS 适配补全了:

  • pubspec.yamlflutter.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 的语义差异

三端返回值形态本就不一致,这是上游设计使然:

平台标识形态可变性
AndroidANDROID_ID 经 SHA-140 位十六进制恢复出厂后变化
iOS / macOSXYUUID + Keychain库定义 UUID卸载后由 Keychain 维持
OpenHarmonyOAID36 位标准 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 有两条产生路径:

  1. 用户拒绝授权APP_TRACKING_CONSENT 是 user_grant 权限,用户点"拒绝"后系统侧 getOAID() 返回全 0,插件透传、不报错——这是合规行为,不是 bug;
  2. 宿主未声明权限: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_reasonusedScene when: inuse),但这份声明只是文档性质的提示——安装到设备上的是宿主应用的 HAP,权限以宿主声明为准。

因此接入方必须做两件事:

  1. 宿主 module.json5requestPermissions 里自己声明 ohos.permission.APP_TRACKING_CONSENT(本例 example/ohos/entry/src/main/module.json5 声明了 INTERNET + APP_TRACKING_CONSENTabilities: ["EntryAbility"]when: inuse);
  2. 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 是调试签名口令,一旦泄露需要轮换证书。这与系列前两篇是同一类安全卫生问题。

处理顺序:

  1. 本地立即轮换调试证书:在 DevEco Studio 删除现有自动签名,重新生成一份;

  2. 剥离仓库中的敏感配置:把 example/ohos/build-profile.json5signingConfigs 替换为通用占位:

    {
      "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"] }] }
      ]
    }
    
  3. 提交一个清理 PR:在 PR 描述中说明 git diff -- example/ohos/build-profile.json5 已不再包含绝对路径或口令;

  4. 避免再次写入:使用 DevEco Studio 时关闭"保存签名到项目",或把 example/ohos/build-profile.json5 加入本地 .gitignore(注意:这会影响其他贡献者,需要在 README 中说明)。

9.6 LICENSE 与 oh-package.json5license 不一致

LICENSE 文件声明的是 MIT(Copyright © 2009 ThoSon),但 ohos/oh-package.json5license 字段写的是 "Apache-2.0"example/ohos/oh-package.json5license 则是空字符串,三处两种口径。

本文仅记录该不一致,不作修改。建议:

  • 保留 LICENSE 不动:这是上游的现状;
  • ohos/oh-package.json5license 改为 "MIT":与上游保持一致;如果上游未来调整为 Apache-2.0,再同步修改;
  • example/ohos/oh-package.json5license 补为 "MIT" 或删除空字符串字段,避免下游合规扫描误报。

9.7 getUUID() 返回 null 的情况

null 与全 0 是两种不同的失败形态,不要混淆:

  • 全 0 UUID(36 位):权限被拒或宿主未声明权限,系统返回全 0,插件透传(见 9.3);
  • null:异常兜底路径。插件所有通路(onAttachedToEngine / onMethodCall / getUUID / 权限检查)都包了 try / catch,最终兜底 result.success(null)——与 Android 端 Kotlin catch 里的 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.yamlflutter.plugin.platforms.ohos.pluginClass 是不是 DeviceUuidPlugin
  • ohos/index.ets 是否正确 export default DeviceUuidPlugin
  • 插件的 ohos/oh-package.json5name 字段是不是 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 的取法都是从声明文件里逐条确认的,不要凭记忆或博客片段写原生代码。


相关链接

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐