Flutter x_app_utils 应用信息工具鸿蒙适配,ArkTS 聚合设备、包信息、OAID 与密钥库本地 ID,键名单位对齐 Android,异常静默降级
开发工具: 华为云码道
原始仓库: jimlonger/app_info_helper
本文配套仓库: oh-flutter/app_info_helper
DevEco Studio:
26.0.0(26.0.0.851)
x_app_utils 把散落在 device_info_plus、package_info_plus、locale、广告标识符等几个库里的信息合并成一次调用:设备型号、系统版本、包名与版本、安装时间、签名摘要、语言与时区、各类设备标识符,外加一套基于系统密钥库的本地 ID。本文以 x_app_utils 0.4.0 为例,介绍源码准备、OHOS 工程配置、ArkTS 实现和 example 真机运行。
插件目前支持 ohos 平台,源码位于 AtomGit 配套仓库。文中的代码以提交 9e96b4aa26ec0a9283d67cf4476d256bf3548b69 为参考,对应 TAG 0.4.0-ohos-1.0.0-beta.1。

这里先说明一个容易踩的错位:仓库名是 app_info_helper,Dart 包名是 x_app_utils,通道名也是 x_app_utils。改 pubspec.yaml、写 import、搜通道,用的是三个不同场景下的同一个名字,看错一处就会得到“包不存在”或“方法未实现”。上游历史里这个包还叫过 app_info_utils、x_app_utils,改名提交都在 Git 记录里。

一、插件简介与适配目标
应用信息类插件在业务里通常出现在三个位置:启动时的埋点上报、设置页里的“关于本机”、以及需要稳定设备标识的风控与统计。这些场景的共同要求是一次调用拿到全部字段、拿不到时给出可判断的空值、并且不要让初始化失败把应用启动拖崩。
x_app_utils 的设计就是围绕这三点:
- 所有字符串取值不为 null,不可用时返回空字符串或文档标注的默认值(如
languageCode默认en、countryCode默认US); - 初始化失败被内部吞掉,
init()返回false,业务侧不需要为它写 try/catch; - Dart 层 API 与 Android/iOS 完全一致,OpenHarmony 只是补一份原生实现。
适配目标因此很明确:不改 Dart 层一行代码,在 ArkTS 里复刻 x_app_utils 这条通道的 11 个方法,并保证返回的键名、类型、单位与 Android 一致。本文后面出现的五处修复,全部是“键名对了、但值的语义没对齐”这类问题。
二、环境准备
环境搭建参考社区文档:Flutter OH 开发环境搭建,完成 Flutter OH SDK 安装、环境变量和 DevEco Studio 配置。
完成后,在宿主机终端执行以下命令,确认当前选中的是支持 OHOS 的 Flutter 工具链,并能发现目标设备:
flutter --version
flutter doctor -v
hdc list targets


工程使用的工具链和 SDK 配置如下:
| 项目 | 版本或配置 | 用途 |
|---|---|---|
| Flutter OHOS SDK | 3.47.7-ohos-1.0.0 | Flutter 编译与 OHOS 平台工具链 |
| Dart SDK | 3.13.3 | Dart 语言与包管理环境 |
| DevEco Studio | 26.0.0(26.0.0.851) | 打开 example/ohos、配置签名、提供 hvigor |
| HarmonyOS 开发套件 | 7.0.0(API 26) | 开发套件版本及对应的 API 级别 |
compileSdkVersion | 26.0.0 | 编译时使用的 SDK API |
targetSdkVersion | 26.0.0 | 应用面向的行为版本 |
compatibleSdkVersion | 6.1.1(24) | 本文工程声明的最低兼容版本 |
| 插件版本 | 0.4.0 | pubspec.yaml 中的包版本 |
| 原生语言 | ArkTS | HarmonyOS 插件实现 |
| 插件产物 | HAR | 被应用 entry 模块依赖 |
2.1 开发套件版本与工程中的 SDK 版本配置
7.0.0(API 26) 和 26.0.0 涉及 HarmonyOS 开发套件版本(API 版本)及其底座 OpenHarmony 的版本号体系。在本文工程中,相关版本的含义与配置方式如下:
7.0.0(API 26)表示 HarmonyOS 开发套件版本为7.0.0,对应 API 26;26.0.0是本文 HarmonyOS 应用工程中compileSdkVersion和targetSdkVersion的属性值;6.1.1(24)是本文工程中compatibleSdkVersion的属性值,声明最低兼容 API 24。
对应的 product 配置为:
{
"name": "default",
"signingConfig": "default",
"compatibleSdkVersion": "6.1.1(24)",
"targetSdkVersion": "26.0.0",
"runtimeOS": "HarmonyOS"
}

这组配置使用 API 26 SDK 编译,并以 API 26 为目标版本,最低兼容 API 24。写 26.0.0 作为 compatibleSdkVersion 会直接装机失败,报 9568297 older sdk version,第九节有对应排查。
三、从源码仓库开始准备适配工程
3.1 将上游源码同步到 AtomGit
适配已有第三方插件时,先从 Pub 包信息或项目 README 确认上游地址、包名、版本和许可证。同步仓库时保留源码、许可证和提交历史,方便后续跟踪上游更新。
在 AtomGit 网页的新建仓库流程中,找到从已有仓库导入的入口,填写上游 Git 地址,选择自己有写权限的组织或个人空间,设置目标仓库名称后执行导入。完成后检查目标分支、pubspec.yaml、LICENSE 和提交历史是否完整,并记录适配起点的提交号。如果上游本身托管在 AtomGit,也可以通过 Fork 获得自己的工作仓库。
3.2 将代码拉取到宿主机
在安装了 Flutter OH 和 DevEco Studio 的开发电脑上打开终端,进入准备存放项目的目录,app_info_helper 现可从 AtomGit 获取。下面使用 AtomGit 地址拉取代码;需要提交修改时,使用自己有写权限的仓库或 Fork。
git clone https://atomgit.com/oh-flutter/app_info_helper.git
cd app_info_helper
pwd
ls
git remote -v
git status --short --branch
git rev-parse HEAD
git clone 会创建 app_info_helper/ 目录;cd 后的位置就是下文所说的插件仓库根目录,这里应能看到 pubspec.yaml、lib/ 和 example/。Git 仓库名是 app_info_helper,Dart 包名是 x_app_utils。

图 1:拉取配套仓库并查看适配提交。
上游基线是 a0da5c9(Release x_app_utils 0.4.0 API safety updates),此后两个提交是本次适配:9f6d19d(ArkTS 实现与示例工程)、a3917eb(example 的 README 与 product 配置)。
3.3 在仓库根目录创建适配分支
接着在 app_info_helper/ 根目录创建分支。分支名统一使用 feat/ohos_库名称_版本号,库名称取 pubspec.yaml 的 name,版本号取此次适配的基线版本。本例为:
git switch -c feat/ohos_x_app_utils_0.4.0
git branch --show-current
如果该分支已存在,使用 git switch feat/ohos_x_app_utils_0.4.0 切换即可。分支合并进 main 之后,TAG 打在与 main 相同的提交上:

图 2:分支、TAG 与远端配置。
3.4 自动补全 OHOS 适配结构
分支创建后,仍在同一个插件根目录执行结构补全。以下命令适用于尚无 ohos/ 目录的既有 Flutter 平台插件:
flutter create --template=plugin --platforms=ohos --project-name x_app_utils .
git status --short
git diff -- pubspec.yaml lib example
--template=plugin指定插件模板;--platforms=ohos指定需要补全的平台;--project-name x_app_utils使用 Dart 包名,这里不能用含下划线仓库名之外的猜测值,必须与pubspec.yaml的name一致;- 最后的
.表示在当前插件目录补全工程,不是另建一层目录。
该命令生成 OHOS 平台脚手架,业务逻辑需要在 ArkTS 中实现。生成后通过 diff 检查 pubspec.yaml、lib/ 和 example/ 的变化,保留已有 API、其他平台注册项及依赖配置。
如果生成后 example/ohos/ 仍不存在,进入已有示例应用补全平台:
cd example
flutter create --platforms=ohos .
cd ..
配套仓库已经包含 ohos/ 和 example/ohos/,直接运行示例时可以跳过结构补全。
3.5 适配后的项目目录
适配后的关键目录如下:
app_info_helper/
├── lib/
│ ├── x_app_utils.dart
│ ├── event_bus_manager.dart
│ ├── event_bus_manager_flutter.dart
│ └── src/
│ ├── event_bus_manager_core.dart
│ └── event_bus_state_subscriber_mixin.dart
├── ohos/
│ ├── index.ets
│ ├── oh-package.json5
│ └── src/main/
│ ├── ets/components/plugin/XAppUtilsPlugin.ets
│ └── module.json5
├── example/
│ ├── lib/main.dart
│ └── ohos/entry/
├── test/
├── doc/
└── pubspec.yaml
项目根目录如下,其中包含 ohos/、example/,以及 OpenHarmony 中英文说明和变更记录文件:

图 3:适配后的目录结构与实际行数。
| 文件 | 主要职责 |
|---|---|
lib/x_app_utils.dart | 对外 API:单例、同步 getter、本地 ID 方法与通道封装 |
lib/src/event_bus_manager_core.dart | 纯 Dart 事件总线,与平台无关 |
lib/event_bus_manager_flutter.dart | Flutter 侧的绑定与自动销毁,纯 Dart |
XAppUtilsPlugin.ets | 注册通道、聚合系统信息、实现本地 ID 加密存储 |
插件 module.json5 | 声明 HAR 模块 |
示例 entry module.json5 | 声明宿主应用 Ability 与设备类型 |
example/lib/main.dart | 展示事件总线演示与 XAppUtils 全功能面板 |
四、Dart 接口与通道分析
OHOS 实现需要遵循 Dart 层已有的方法、参数和回调约定。先阅读 lib/x_app_utils.dart,再在 ohos/src/main/ets/components/plugin/XAppUtilsPlugin.ets 中实现对应的原生调用。
本例的对应关系如下:
| Dart 入口 | 通道协议 | OHOS 实现 | 应保持的行为 |
|---|---|---|---|
init() / refresh() | x_app_utils / getAll | bundleManager、deviceInfo、i18n、hidebug、statvfs 等系统 API | 一次返回全部字段,键名与 Android 一致 |
refreshAdvertisingId() / refreshDeviceId() | refreshAdvertisingId / refreshDeviceId | identifier.getOAID() | 只刷新标识符,不得重建本地 ID |
readLocalId / writeLocalId / containsLocalId | 同名方法,参数 slot、value | asset 密钥库读写 | 按槽位读写,缺失时可创建 |
resetLocalId / resetAllLocalIds | 同名方法 | 先 remove 再生成新 UUID | 重置后返回新的可用 ID |
readAllLocalIds | readAllLocalIds | 两次 asset.query | 返回 primary/secondary 两个值 |
requestAppTrackingTransparency() | 同名方法 | 无对应框架 | 返回 failure: unavailable |
原生端需要保持这些方法名、参数名和返回键一致。系统能力不可用时不抛错,而是返回空值或定义的失败结构——这一点与“订阅失败要报错”的传感器类插件正好相反。
4.1 跨端架构与调用时序
Flutter 侧和 HarmonyOS 侧之间只有一条通道:
MethodChannel('x_app_utils'):负责全部 11 个方法,既包括一次性查询,也包括本地 ID 的读写。
没有 EventChannel,因为插件不订阅任何系统事件。唯一的“自动刷新”发生在 Dart 层:XAppUtils 混入 WidgetsBindingObserver,应用回到前台时自己再走一次 getAll。
4.1.1 一次初始化的时序
getAll 和 initializeLocalIds 是两次独立调用:前者只拿“只读信息”,后者才碰密钥库。这样设计的好处是 refresh() 可以只刷新前者,永远不会覆盖已经持久化的本地 ID。
4.2 数据模型:返回值键契约
这个库没有状态枚举,跨端契约就是一张 Map<String, dynamic> 的键表。localIdStorageOptions 每次调用都会被附加到参数里,原生侧可据此决定存储别名:
static const MethodChannel _channel = MethodChannel('x_app_utils');
Future<Map<String, dynamic>> _call(
String method, [
Map<String, dynamic> arguments = const <String, dynamic>{},
]) async {
try {
return (await _channel.invokeMapMethod<String, dynamic>(
method,
<String, dynamic>{
...arguments,
'localIdStorageOptions': _localIdStorageOptions.toMap(),
},
)) ??
<String, dynamic>{};
} catch (_) {
return <String, dynamic>{};
}
}
主要键位如下:
| 键 | 类型 | Android 语义 | OHOS 实现 |
|---|---|---|---|
deviceModel | String | Build.MODEL | deviceInfo.productModel |
platform | String | Build.MANUFACTURER | deviceInfo.manufacture |
osVersion | String | Build.VERSION.SDK_INT | deviceInfo.sdkApiVersion |
systemName | String | "Android" | "OpenHarmony" |
deviceName | String | Settings.Global.DEVICE_NAME | deviceInfo.marketName |
freeDiskSize / totalDiskSize | int | 字节数 | statvfs 字节数 |
physicalRamSize / availableRamSize | int | MB | hidebug KB ÷ 1024 |
buildSignature | String | 签名证书 SHA-256(hex) | 证书 Base64 解码后 SHA-256(hex) |
locale | String | Locale.toLanguageTag() | language-script-region |
oaid / advertisingId | String | 无 | identifier.getOAID() |
primaryLocalId / secondaryLocalId | String | 加密存储 | Asset Store Kit |
localIdsPersisted | bool | 是否落地成功 | 同 |
localIdsCreated | bool | 本次是否新建 | 同 |
单位是最容易错的地方:磁盘按字节、内存按 MB、时间戳按毫秒。三者任一错位,业务侧不会崩,只会拿到一个看起来合理的错数字。
4.3 公开 API 与平台能力
业务侧主要通过单例使用:
class XAppUtils with WidgetsBindingObserver {
factory XAppUtils() => _instance;
static XAppUtils get instance => _instance;
Future<bool> init({LocalIdStorageOptions localIdStorageOptions = const LocalIdStorageOptions()});
Future<XAppUtils> get ready;
Future<bool> refresh();
Future<void> refreshAdvertisingId();
Future<void> refreshDeviceId();
Future<String> readLocalId({LocalIdSlot slot = LocalIdSlot.primary});
Future<void> writeLocalId(String value, {LocalIdSlot slot = LocalIdSlot.primary});
Future<bool> containsLocalId({LocalIdSlot slot = LocalIdSlot.primary});
Future<String> resetLocalId({LocalIdSlot slot = LocalIdSlot.primary});
Future<Map<LocalIdSlot, String>> readAllLocalIds();
Future<Map<LocalIdSlot, String>> resetAllLocalIds();
Future<void> configureLocalIds(LocalIdStorageOptions options);
Future<IdfaAuthorizationResult> requestAppTrackingTransparency({...});
}
两点值得单独说明:
- 同步 getter 会自动触发初始化。
deviceModel、appName这些是普通 getter,内部调用_ensureInitializedSoon()。首次读可能拿到空串或默认值,需要真实值时先await XAppUtils.instance.ready。 requestAppTrackingTransparency在非 iOS 平台直接短路。Dart 层判断!Platform.isIOS就返回failure: unavailable,根本不会发起通道调用。所以 OHOS 上这个方法不需要原生实现也能工作——原生里的同名分支只是兜底。
LocalIdStorageOptions 用来给存储别名加命名空间,只在“读不到包名/包标识”时才需要:
class LocalIdStorageOptions {
const LocalIdStorageOptions({
this.fallbackNamespace,
this.primaryKey,
this.secondaryKey,
});
final String? fallbackNamespace;
final String? primaryKey;
final String? secondaryKey;
}
4.4 Dart 通道协议分析
4.4.1 通道名称必须两端完全一致
static const MethodChannel _channel = MethodChannel('x_app_utils');
Dart 和 ArkTS 任何一端拼写不一致,_call 都会捕获 MissingPluginException 并返回空 Map。注意这里不会崩溃、也不会提示,表现是“所有字段都是空字符串”。这个静默行为是排查时最容易走错方向的地方:页面看起来完全正常,只是没有数据。
4.4.2 通道失败的静默约定
Future<bool> _loadSafely({bool loadLocalIds = false}) async {
try {
return _load(loadLocalIds: loadLocalIds);
} catch (_) {
if (loadLocalIds) {
_localIdsInitialized = true;
_ensureLocalIdsInMemory();
}
return false;
}
}
_guard、_guardVoid、_loadSafely 三层都把异常转成返回值。原生侧因此不需要考虑“抛错要不要包成 PlatformException”,只要保证成功时键齐全;失败时也可以返回 {isSuccess: false, failure: ...},Dart 会原样合并进缓存。
4.4.3 回前台自动刷新
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_loadSafely());
}
}
_loadSafely() 不带 loadLocalIds,所以回前台只会刷新只读信息,不会重新初始化密钥库。真机上验证这条逻辑的方式很简单:改系统时间或时区,切回应用,timeZone 与 utcOffsetSeconds 会跟着变,而 primaryLocalId 保持不变。
五、补全 OHOS 原生实现与工程配置
5.1 在 XAppUtilsPlugin.ets 中实现原生能力
以 getAll 为例:业务仍调用 XAppUtils.instance.init(),Dart 通道发出 getAll。需要补全的是 XAppUtilsPlugin.ets 中的对应分支:聚合系统 API 的返回值,拼成与 Android 同键的 Map,用 result.success 回传。
XAppUtilsPlugin 实现 FlutterPlugin 和 MethodCallHandler。下面列出类中的主要成员和方法,完整文件还包含 getUniqueClassName() 等插件接口。
原生插件位于:
ohos/src/main/ets/components/plugin/XAppUtilsPlugin.ets
5.1.1 引入 Flutter 和 HarmonyOS 能力
import {
FlutterPlugin,
FlutterPluginBinding,
MethodCall,
MethodCallHandler,
MethodChannel,
MethodResult,
} from '@ohos/flutter_ohos';
import bundleManager from '@ohos.bundle.bundleManager';
import deviceInfo from '@ohos.deviceInfo';
import statvfs from '@ohos.file.statvfs';
import hidebug from '@ohos.hidebug';
import i18n from '@ohos.i18n';
import intl from '@ohos.intl';
import identifier from '@ohos.identifier.oaid';
import asset from '@ohos.security.asset';
import { util } from '@kit.ArkTS';
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
其中:
FlutterPlugin负责接入 Flutter Engine 生命周期;MethodChannel接收 Dart 发来的命令;bundleManager提供包信息、签名证书与资源;deviceInfo提供型号、厂商、市场名与 API 版本;statvfs、hidebug分别提供存储与内存数据;i18n、intl提供语言、脚本、地区与时区;identifier提供 OAID;asset是系统密钥库,用来存本地 ID;util负责编解码,cryptoFramework负责 SHA-256。
5.1.2 连接 Flutter Engine
private channel: MethodChannel | null = null;
private context: Context | null = null;
onAttachedToEngine(binding: FlutterPluginBinding): void {
this.context = binding.getApplicationContext();
this.channel = new MethodChannel(binding.getBinaryMessenger(), 'x_app_utils');
this.channel.setMethodCallHandler(this);
}
这里是单个 MethodChannel,没有 EventChannel,但多保存了一个 context——bundleManager、resourceManager、filesDir 都挂在它上面,后面的读取全部依赖这个引用。
5.1.3 一次 getAll 的系统 API 映射
设备与系统信息集中在一个方法里:
private common(): Map<string, Object> {
const values = new Map<string, Object>();
values.set('deviceModel', deviceInfo.productModel);
values.set('platform', deviceInfo.manufacture);
values.set('osVersion', deviceInfo.sdkApiVersion.toString());
values.set('systemName', 'OpenHarmony');
values.set('deviceName', deviceInfo.marketName);
values.set('isPhysicalDevice', true);
values.set('freeDiskSize', this.disk(false));
values.set('totalDiskSize', this.disk(true));
const mem = hidebug.getSystemMemInfo();
values.set('physicalRamSize', Number(mem.totalMem / 1024n));
values.set('availableRamSize', Number(mem.availableMem / 1024n));
return values;
}
三处换算需要留意:
hidebug给的是 KB,Android 的ActivityManager给的是 MB,所以要除以 1024 才能落在同一个量纲上;statvfs给的是字节,与 Android 的StatFs一致,不用换算;isPhysicalDevice恒为true,因为 OpenHarmony 没有公开的模拟器标识接口。
Android 专有字段用一组固定空值补齐,避免 Dart 侧读到 null:
for (const key of [
'androidBoard', 'androidBootloader', 'androidBrand', 'androidDevice',
'androidDisplay', 'androidFingerprint', 'androidHardware', 'androidHost',
'androidId', 'androidProduct', 'androidTags', 'androidType',
'androidBaseOs', 'androidRelease', 'androidCodename', 'androidIncremental',
'androidSecurityPatch',
]) {
values.set(key, '');
}
values.set('androidSdkInt', deviceInfo.sdkApiVersion);
磁盘容量的取值路径值得单独说:不能直接 statvfs('/data')。沙箱内这么调,会得到一对完全相同的数值(本机实测 6086742016,约 5.7 GB),而设备 data 分区实际是 226 GB。改成应用自己的沙箱路径后数值才对得上:
private disk(total: boolean): number {
try {
// The sandbox cannot statvfs("/data") directly: that call answers with a
// pair of identical, unrelated numbers. Use the app's own sandbox path,
// which lives on the same userdata partition as Android's
// Environment.getDataDirectory().
const path = this.context != null ? this.context.filesDir : '/data';
return Math.trunc(total ? statvfs.getTotalSizeSync(path) : statvfs.getFreeSizeSync(path));
} catch (e) {
return 0;
}
}
这是本次适配里最隐蔽的一个缺陷:值不为 0、类型也对,只有和 df -k 比对才会发现两个字段一模一样。
5.1.4 应用信息、签名摘要与资源名称
应用信息的读取要显式带上两个标志:
const flags = bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT |
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION |
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_SIGNATURE_INFO;
const info = bundleManager.getBundleInfoForSelfSync(flags);
let appName = '';
// appInfo.label holds the resource reference declared in app.json5
// ("$string:app_name") instead of the resolved text, so resolve it
// through labelId and only keep a plain label as a fallback.
try {
if (info.appInfo.labelId > 0 && this.context != null) {
appName = this.context.resourceManager.getStringSync(info.appInfo.labelId);
}
} catch (e) {
appName = '';
}
if (appName.length === 0) {
const label = info.appInfo.label;
if (typeof label === 'string' && label.length > 0 && label.charAt(0) !== '$') {
appName = label;
}
}
values.set('appName', appName);
values.set('packageName', info.name);
values.set('version', info.versionName ?? '');
values.set('buildNumber', info.versionCode.toString());
values.set('buildSignature', await this.buildSignature(info));
两个坑都在这里:
appInfo.label是资源引用,不是文案。首版直接把它当应用名回传,演示页上显示的就是App: $string:app_name。正确做法是用labelId到resourceManager里解析,只有不以$开头的字面量才直接使用。signatureInfo需要显式请求。不传GET_BUNDLE_INFO_WITH_SIGNATURE_INFO时info.signatureInfo是空的,buildSignature永远是空串,而 Android 那边一直有值。
签名摘要本身要做一次格式转换,OpenHarmony 给的是 Base64 的 X.509 证书,Android 给的是证书的 SHA-256 十六进制:
private async buildSignature(info: bundleManager.BundleInfo): Promise<string> {
try {
const certificate = info.signatureInfo.certificate;
if (certificate == null || certificate.length === 0) {
return '';
}
const bytes = await new util.Base64Helper().decode(certificate);
const md = cryptoFramework.createMd('SHA256');
md.updateSync({ data: bytes });
const digest = md.digestSync();
let hex = '';
digest.data.forEach((b: number) => {
hex += (b < 16 ? '0' : '') + b.toString(16);
});
return hex;
} catch (e) {
return '';
}
}
注意 util.Base64Helper 没有静态 getInstance(),必须 new 出来再调 decode。
version 与 buildNumber 这两栏顺带记录一个现象:本机 AppScope/app.json5 声明的是 1.0.0 / 1000000,但打出来的 HAP 里是 1.0 / 1(bm dump 与插件返回一致)。这是打包环节的取值问题,不影响插件本身,接入方以自己的 HAP 为准即可。
5.1.5 本地 ID 的加密存储
本地 ID 落在系统密钥库里,别名由命名空间与键名拼成,重装应用即清除:
private alias(slot: string, options: LocalIdOptions): Uint8Array {
let namespace = options.fallbackNamespace ?? '';
if (namespace.length === 0) {
try {
namespace = bundleManager.getBundleInfoForSelfSync(
bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT).name;
} catch (e) {
namespace = 'x_app_utils';
}
}
const suffix = slot === 'secondary'
? (options.secondaryKey ?? 'secondaryLocalId')
: (options.primaryKey ?? 'primaryLocalId');
const alias = `${namespace}.${suffix}`;
return util.TextEncoder.create('utf-8').encodeInto(alias);
}
写入时用 OVERWRITE 解决同别名冲突,用 DEVICE_POWERED_ON 让重启后仍可解密:
private secretAttributes(alias: Uint8Array, value: string): asset.AssetMap {
const attributes: asset.AssetMap = new Map<asset.Tag, asset.Value>();
attributes.set(asset.Tag.SECRET, util.TextEncoder.create('utf-8').encodeInto(value));
attributes.set(asset.Tag.ALIAS, alias);
attributes.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_POWERED_ON);
attributes.set(asset.Tag.CONFLICT_RESOLUTION, asset.ConflictResolution.OVERWRITE);
return attributes;
}
重置不是“删掉了事”,而是删完立刻生成新的 UUID 并写回,这样 resetLocalId 的返回值始终可用:
private resetAllLocalIds(options: LocalIdOptions): Map<string, Object> {
let persisted = true;
try {
asset.removeSync(this.aliasAttributes(this.alias('primary', options)));
asset.removeSync(this.aliasAttributes(this.alias('secondary', options)));
} catch (e) {
persisted = false;
}
const primaryValue = util.generateRandomUUID();
const secondaryValue = util.generateRandomUUID();
const primarySaved = this.storeQuietly('primary', primaryValue, options);
const secondarySaved = this.storeQuietly('secondary', secondaryValue, options);
persisted = persisted && primarySaved && secondarySaved;
return this.toLocalIdMap(primaryValue, secondaryValue, persisted, true, options);
}
如果密钥库写入失败(例如设备未解锁),persisted 会是 false;Dart 层随后用 _ensureLocalIdsInMemory() 生成内存态 ID,本次会话可用、重启后变化。这个降级路径在演示页上表现为“能读到 ID,但 localIdsPersisted 为 false”。
5.1.6 处理 MethodChannel 命令与 Engine 解绑
onMethodCall(call: MethodCall, result: MethodResult): void {
this.handle(call).then((values) => {
if (values == null) {
result.notImplemented();
} else {
result.success(values);
}
}).catch((error: Error) => {
// Mirrors the Android behaviour: internal failures never reject, they
// resolve to the same empty failure map.
result.success(this.emptyResult());
});
}
handle 内部是一个 switch,覆盖 getAll、两个标识符刷新、六个本地 ID 方法与 requestAppTrackingTransparency,默认分支返回 null 触发 result.notImplemented()。
catch 里不回 result.error,而是回一张与 Android 相同的失败表:
private emptyResult(): Map<string, Object> {
const values = new Map<string, Object>();
values.set('isSuccess', false);
values.set('idfa', '');
values.set('failure', 'systemError');
return values;
}
这么做是为了对齐 Android:两端都用“成功 + 失败结构”代替平台异常,Dart 的 _call 也就不必区分 PlatformException 和普通返回值。
Engine 解绑时释放通道:
onDetachedFromEngine(binding: FlutterPluginBinding): void {
if (this.channel != null) {
this.channel.setMethodCallHandler(null);
this.channel = null;
}
}
Flutter Engine 销毁时执行清理,避免热重启后旧 Handler 仍持有通道。
5.2 声明插件和宿主权限
本插件不需要任何权限。读取设备信息、包信息、语言时区、OAID 与密钥库读写都在应用自身沙箱与公开 API 范围内,ohos/src/main/module.json5 因此不写 requestPermissions:
{
"module": {
"name": "x_app_utils",
"type": "har",
"deviceTypes": ["default", "tablet"]
}
}
example/ohos/entry/src/main/module.json5 同样不需要追加权限声明。与依赖多模态感知或传感器能力的插件相比,这里省掉了 requestPermissions 与 usedScene 两段配置,也省掉了运行时授权流程。
需要注意 deviceTypes 只声明了 default 和 tablet,示例应用 entry 声明的是 phone。安装到手机不受影响,但真要覆盖平板,两边都要声明。
5.3 注册并导出插件
pubspec.yaml 通过以下配置声明 OHOS 插件类:
flutter:
plugin:
platforms:
ohos:
pluginClass: XAppUtilsPlugin
插件的 ohos/index.ets 需要导出实现:
import XAppUtilsPlugin from './src/main/ets/components/plugin/XAppUtilsPlugin';
export default XAppUtilsPlugin;
执行 flutter pub get 和构建后,Flutter 工具会为应用生成插件注册代码:
import XAppUtilsPlugin from 'x_app_utils';
export class GeneratedPluginRegistrant {
static registerWith(flutterEngine: FlutterEngine) {
try {
flutterEngine.getPlugins()?.add(new XAppUtilsPlugin());
} catch (e) {
Log.e(TAG, "Tried to register plugins with FlutterEngine failed.");
}
}
}

图 4:pubspec 注册段与 ArkTS 中的关键取值行。
通常不应手工编辑 GeneratedPluginRegistrant.ets,因为下次构建可能覆盖它。注册异常的排查步骤见第九节 MissingPluginException。
5.4 检查 example 的 OHOS 应用结构
本例的 example/ohos/build-profile.json5 应在 products 中设置版本。下面是需核对的配置片段,请合并到现有工程;其中 signingConfig: "default" 需要与本机配置的签名名称一致,签名材料保留在本地。
{
"app": {
"products": [
{
"name": "default",
"signingConfig": "default",
"compatibleSdkVersion": "6.1.1(24)",
"targetSdkVersion": "26.0.0",
"runtimeOS": "HarmonyOS"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": ["default"]
}
]
}
]
}
compatibleSdkVersion 这里写的是 6.1.1(24)。写 26.0.0 时安装会报 9568297 older sdk version,因为设备的 API 版本低于声明值。
配置后,在 DevEco Studio 中执行一次 Sync Project。如果 entry 模块正常识别,Project 视图中会出现 entry,并能打开 entry 模块的签名配置。
六、补全交付文件并提交适配分支
6.1 除代码外还要补全哪些文件
代码适配完成后,还需要整理安装说明、接口文档、版本记录和开源信息。接收仓库有专用模板时,按其格式填写:
| 文件 | 应写清楚的内容 |
|---|---|
README.OpenSource.md | 上游名称、源码地址、配套仓库、适配基线、许可证信息 |
README.md | 原项目说明、OHOS 支持入口、配套 Demo 和文档链接;保留上游信息 |
README.OpenHarmony_CN.md | 简介、AtomGit 安装方式、版本对应关系、环境约束、权限、接口表、示例、已验证范围和遗留问题 |
README.OpenHarmony.md | 与中文说明对应的英文文档 |
CHANGELOG.OpenHarmony.md | OHOS 新增能力、适配版本、修复记录与测试范围 |
LICENSE / NOTICE | 保留上游许可证;NOTICE 按许可证和原项目要求保留或补充 |
example/README.md | 依赖方式、运行目录、签名、操作步骤与效果图 |
pubspec.yaml、ohos/oh-package.json5 | 核对包名、版本、插件注册、许可证和依赖 |
.gitignore | 忽略构建缓存及本机签名材料,不漏提交必要源码和配置 |
本库的包名是 x_app_utils,版本 0.4.0,采用 MIT 许可证。ohos/oh-package.json5 里 flutter create 留下的模板值是 Apache-2.0,与上游 LICENSE 不符,需要改成 MIT——许可证写错在分发环节是实质问题,不只是文档问题。
6.2 提交前检查
提交前先完成第八节的插件、example 和真机测试,再从根目录检查改动:
git branch --show-current
git diff --check
git status --short
git diff --stat
git diff
检查 diff 中的接口、平台注册和依赖变化,移除本机路径及签名信息,并使文档中的版本和分支与提交内容一致。
6.3 提交并推送到 AtomGit
文档和代码整理完成后,在根目录暂存并提交。文件名按项目实际情况调整:
git add ohos pubspec.yaml example test .gitignore
git add README.md README.OpenSource.md README.OpenHarmony_CN.md
git add README.OpenHarmony.md CHANGELOG.OpenHarmony.md
git diff --cached --check
git diff --cached --stat
git commit -m "feat: add OHOS support for x_app_utils 0.4.0"
git remote -v
git branch --show-current
git push -u origin feat/ohos_x_app_utils_0.4.0
DevEco 可能向 example/ohos/build-profile.json5 写入本机签名配置,需要在提交前从暂存内容中移除。推送时,origin 应指向自己的 AtomGit 仓库,当前分支为 feat/ohos_x_app_utils_0.4.0。
推送后在 AtomGit 发起合并请求,说明上游来源和版本、OHOS 实现范围、依赖及权限、测试环境、操作结果、已知限制,并附 Demo 运行图。目标分支和评审流程以接收仓库要求为准。
七、使用根目录 example 演示接入
仓库自带 example/,可以直接用来调试插件和查看各项取值。
7.1 本地适配时使用路径依赖
当前 example/pubspec.yaml 的依赖是:
dependencies:
flutter:
sdk: flutter
x_app_utils:
path: ../
../ 相对于 example/pubspec.yaml 指向插件根目录,修改根目录插件后可直接联调。
7.2 通过 AtomGit 引入插件
业务应用通过 AtomGit 引入时,将 x_app_utils 的 path 配置替换为下面的 Git 依赖。这里固定到本文使用的提交:
dependencies:
flutter:
sdk: flutter
x_app_utils:
git:
url: https://atomgit.com/oh-flutter/app_info_helper.git
ref: 9e96b4aa26ec0a9283d67cf4476d256bf3548b69
使用自己的适配版本时,先推送分支,再将 url 改为对应仓库,ref 改为 feat/ohos_x_app_utils_0.4.0。正式发布后可固定到 tag 或 commit。
从插件根目录执行:
cd example
flutter pub get
flutter pub deps
检查 example/pubspec.lock 中 x_app_utils 的来源为 git,并核对 url、ref 和 resolved-ref。同时检查没有 dependency_overrides 或 pubspec_overrides.yaml 将其覆盖回本地依赖。
7.3 调用接口实现信息面板
下面的页面展示常用字段和本地 ID,可用于 example/lib/main.dart。仓库中的完整 Demo 还提供事件总线演示和逐按钮的调试面板。
import 'package:flutter/material.dart';
import 'package:x_app_utils/x_app_utils.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 失败不影响启动,getter 会返回空串或文档默认值。
await XAppUtils.instance.init();
runApp(const MaterialApp(home: AppInfoPage()));
}
class AppInfoPage extends StatelessWidget {
const AppInfoPage({super.key});
Widget build(BuildContext context) {
final info = XAppUtils.instance;
return Scaffold(
appBar: AppBar(title: const Text('应用信息')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Text('设备:${info.deviceModel} / ${info.platform}'),
Text('系统:${info.systemName} API ${info.osVersion}'),
Text('应用:${info.appName} ${info.version}(${info.buildNumber})'),
Text('地区:${info.locale} ${info.timeZone}'),
Text('标识:${info.deviceId}'),
Text('本地 ID:${info.primaryLocalId}'),
],
),
);
}
}
同步 getter 在首帧可能还是空串,需要立刻拿到真实值时把页面改成 FutureBuilder,或先 await XAppUtils.instance.ready。演示页在 main() 里 await 了 init(),因此首屏就是最终值。
7.4 页面退出与本地 ID 的重置
普通字段读的是内存缓存,页面销毁不需要清理。真正需要注意生命周期的是本地 ID:
- 读操作用
readLocalId,不会创建新值; - 写操作用
writeLocalId,值为空字符串时直接返回、不覆盖旧值; - 只在确实要换标识时才调
resetLocalId/resetAllLocalIds,它们会立刻生成新 UUID 并写回密钥库。
多个页面都要用这些值时,从 XAppUtils.instance 取同一个单例即可,不需要自己再缓存一份——回前台时它已经自动刷新过了。
八、验证、构建与鸿蒙设备运行效果
8.1 分别验证插件与 example
从插件仓库根目录执行:
flutter pub get
flutter analyze
cd example
flutter pub get
flutter analyze
本机实测结果:仓库根目录只剩一条上游 warning(unawaited_return_in_try_block,位于 lib/x_app_utils.dart:218),example/ 为 No issues found!。这条 warning 属于上游 _loadSafely 的写法,改它会破坏“Dart 层零改动”的前提,因此保留。
flutter test
flutter test 在本机未跑通:加载测试套件阶段就报 Connection closed before test suite loaded.,用例一条都没执行。仓库里的 test/x_app_utils_test.dart 用 mock 通道覆盖了单例、初始化与本地 ID 分支,test/event_bus_manager_test.dart 覆盖事件总线,但本次没有拿到通过结果,所以本文不声明“测试通过”。
Dart 测试覆盖接口和页面逻辑,系统 API 的取值与权限行为还需要在鸿蒙设备上验证。
8.2 确认设备连接
hdc list targets
flutter devices
设备首次连接电脑时,需要在手机端确认调试授权。列表为空时,检查 USB 连接、调试模式和电脑授权。
本文使用的设备信息:
TLR-AL00
7.0.0.109(SP8C00E105R3P2)
API 26
8.3 配置签名
真机安装的 HAP 通常需要有效签名。推荐使用 DevEco Studio 为 entry 模块配置自动签名:
- 用 DevEco Studio 打开
example/ohos,不是仓库根目录; - 等待工程 Sync 成功,确认 Project 视图中存在
entry模块; - 打开 File > Project Structure > Signing Configs;
- 为
defaultproduct 选择或生成签名; - 确认设备、应用包名、证书和 Profile 匹配;
- 再回到终端执行构建。
签名材料保存在本机,公开仓库中只保留构建所需的通用配置(提交版里 signingConfigs 是空数组)。
8.4 构建 HAP
在 example/ohos 目录执行:
ohpm install
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
两次实测均为 BUILD SUCCESSFUL(首次 50 s 左右,增量 28~35 s)。在 Windows 上执行时若终端返回退出码 1,先看输出里有没有 BUILD SUCCESSFUL:命令行尾部会触发 wmic.exe 的黑名单拦截,退出码不能直接当失败标志。
典型产物位于:
example/ohos/entry/build/default/outputs/default/
目录中通常包含已签名和未签名 HAP。真机安装应选择与当前设备匹配的已签名产物:
hdc install -r entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.example.color_system_example
8.5 在设备上测试各项取值
演示应用首页是事件总线示例,平台信息卡片会先做一次冒烟:
- 打开应用,确认首页显示
Platform: HUAWEI、App: x_app_utils_example、Device: TLR-AL00; - 点“XAppUtils 全功能面板”,进入逐按钮调试页;
- 点“打印常用参数”,核对
appName、locale、advertisingId、deviceId与primaryLocalId; - 点“打印完整参数 JSON”,核对
buildSignature是 64 位十六进制、freeDiskSize与totalDiskSize不相等; - 点“请求用户跟踪授权”,确认返回
failure: unavailable; - 在“本地安全 ID”区点“读取 主本地 ID”“检查 主本地 ID 是否存在”“读取全部本地 ID”,确认三处值互相一致;
- 点“重置全部本地 ID”,再点“读取全部本地 ID”,确认两个 ID 都变成新值。
第 3 步的 advertisingId 为空串是预期结果:本机没有广告服务,identifier.getOAID() 返回的是全零 UUID,插件按“不可用值为空”的契约把它转成空串,deviceId 随之回落到 primaryLocalId。
8.6 鸿蒙设备运行效果
下面按操作顺序展示真机结果。首页先确认插件已经注册、字段有值:
| 首页平台信息 | 常用参数 | 完整参数(设备段) |
|---|---|---|
App: x_app_utils_example | locale: zh-Hans-CN、advertisingId: "" | freeDiskSize 与 totalDiskSize 分别为 201 GB / 237 GB |
完整参数继续往下是应用段与标识符段,签名摘要与语言字段都在这里:
| 签名与语言字段 | 跟踪授权 | 读取主本地 ID |
|---|---|---|
buildSignature 为 64 位 hex | failure: unavailable | 83462dd8-…,与 deviceId 相同 |
密钥库的读写与重置:
| 检查是否存在 | 读取全部本地 ID |
|---|---|
true | 两个槽位分别返回 UUID,与单槽读取一致 |
设备上的这些结果显示:只读信息(设备、应用、语言、时区、存储、内存)来自系统 API 且量纲正确;标识符在无广告服务的设备上按约定退化为空串;本地 ID 走密钥库,能在多次读取之间保持一致。
九、FAQ:适配过程与使用问题
9.1 Missing SDK components
典型错误如下:
Missing SDK components. SDK path: ...,
missing components: toolchains,ets,js,native,previewer.
这个错误发生在 Hvigor 同步阶段。通常需要检查构建工具使用的 SDK 路径、组件是否完整,以及 Hvigor 与 SDK 的版本是否匹配。
处理顺序:
- 在 DevEco Studio SDK Manager 中确认 API 26 组件已经下载完整;
- 检查 Flutter 和 DevEco Studio 使用的 SDK 路径是否一致;
- 避免误用
/Applications/DevEco-Studio.app/Contents/sdk之类的不完整目录; - 确认 SDK 根目录下存在
toolchains、ets、js、native、previewer; - 执行
flutter config --ohos-sdk <正确路径>; - 重新执行
flutter doctor -v和 DevEco Studio Sync。
因为 Sync 和 Compile 首先读取本机 SDK。手机 API 版本只在部署、安装和运行时参与兼容判断。即使完全不连接手机,本地 SDK 不完整时也会得到相同错误。
当前工程的 compatibleSdkVersion 是 API 24,因此 API 24 及以上设备在安装版本门槛上是满足的。
9.2 DevEco Studio 中看不到 entry 模块
插件的 ohos/ 目录是 HAR 模块,可安装应用的 entry 模块位于 example/ohos/entry。
请直接使用 DevEco Studio 打开:
app_info_helper/example/ohos
如果仍看不到 entry,先解决 SDK Sync 错误,再检查 example/ohos/build-profile.json5 的 modules 是否包含 ./entry。同步失败时,Project Structure 无法正确解析模块,签名界面也可能不显示 entry。
9.3 无法手动签名
签名配置依附于可构建的应用模块和 product。只有 HAR 插件模块、工程 Sync 失败,或者打开了错误目录时,DevEco Studio 都可能无法提供 entry 签名入口。
建议先确认:
- 打开的是
example/ohos; - SDK 组件完整并且 Sync 成功;
entry的模块类型为entry;defaultproduct 和 target 已正确关联;- 当前账号、证书和调试设备状态有效。
9.4 能安装,但所有字段都是空字符串
按以下顺序检查:
hilog中是否出现Tried to register plugins with FlutterEngine的失败日志;GeneratedPluginRegistrant.ets里是否有new XAppUtilsPlugin();pubspec.yaml的flutter.plugin.platforms.ohos.pluginClass是否与ohos/index.ets的导出名一致;- 通道名是否两端都是
x_app_utils(下划线,不是连字符); - 是否只做了热重载——新增原生插件必须重新构建安装。
这个库的失败形态很安静:_call 把 MissingPluginException 吞掉后返回空 Map,页面照常渲染,只是每个字段都是空串或默认值。所以先确认“原生是否被调用”,再看“字段是否正确”,不要一上来就怀疑系统 API。
9.5 MissingPluginException
这通常表示 Dart 通道找不到已注册的原生插件。新增原生插件后需要重新构建应用。从仓库根目录执行:
cd example
flutter clean
flutter pub get
如果仍然出现,检查自动生成的插件注册文件中是否包含 XAppUtilsPlugin,同时核对 pubspec.yaml、ohos/index.ets 和 oh-package.json5。
9.6 首次读取拿到的是默认值
同步 getter 会自己触发一次异步初始化,但首帧读到的仍是缓存里的旧值。典型表现是 languageCode 显示 en、countryCode 显示 US,第二次读就正常。
处理方式两种:在 main() 里 await XAppUtils.instance.init(),或用 await XAppUtils.instance.ready 后再读。演示页用的是前者。
9.7 编译成功但安装失败
常见原因包括:
- HAP 未签名或使用了错误的 Profile;
- 设备未加入调试设备列表;
- 包名与签名 Profile 不匹配;
- 安装包的
compatibleSdkVersion高于设备 API,报9568297 older sdk version; - 手机上已经安装了使用不同证书签名的同包名应用。
最后一条在本例中很实际:示例应用沿用了 com.example.color_system_example 这个包名,和同目录其他几个库的示例冲突。确认设备上跑的是当前产物,用 hdc install -r 重装,不要只凭界面猜。
9.8 flutter create 不认识 ohos,或包名不合法
先执行 flutter --version,确认使用的是 OHOS 版工具链,环境配置回到第二节的社区链接核对。包名报错时,确认当前目录包含目标插件的 pubspec.yaml,并显式传入 --project-name x_app_utils;仓库名 app_info_helper 不能直接作为 Dart 包名。生成后检查 diff,再补充 ArkTS 业务实现。
9.9 AtomGit 依赖提示找不到分支或无权限
先检查 URL 是否指向已同步的目标仓库,再确认 feat/ohos_x_app_utils_0.4.0 已推送。分支尚未推送时,可以先使用第七节的提交号。私有仓库还需在本机配置 Git 认证。
9.10 改了本地 ArkTS,Demo 为什么没变化
先检查 example/pubspec.yaml:Git 依赖读取远程提交,不会自动读取本地插件改动。本地联调切回 path: ../;测试远程版本则先提交推送,再更新依赖并核对 pubspec.lock 的 resolved-ref。原生代码变动后停止应用并重新构建运行,不能只做 Dart 热重载。
另一个更隐蔽的情况:ArkTS 改了、构建也过了,界面却没变。这时检查 entry/build 里是否有旧产物,必要时先 hvigorw clean 再构建——本次适配就遇到过一次增量构建沿用了旧的打包 profile,导致应用信息与配置对不上。
9.11 appName 显示成 $string:app_name
这说明原生侧直接把 BundleInfo.appInfo.label 当文案回传了。OpenHarmony 上该字段是资源引用,需要按 labelId 到 resourceManager 里解析。5.1.4 的写法同时保留了“字面量 label”这条兜底路径,两者都不成立时才返回空串。
同样的思路适用于任何 $string:、$media: 开头的资源引用:看到值以 $ 开头且带冒号,就说明拿到的是引用而不是内容。
相关链接
更多推荐
所有评论(0)