开发工具: 华为云码道

原始仓库: 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 记录里。

在这里插入图片描述


一、插件简介与适配目标

应用信息类插件在业务里通常出现在三个位置:启动时的埋点上报、设置页里的“关于本机”、以及需要稳定设备标识的风控与统计。这些场景的共同要求是一次调用拿到全部字段、拿不到时给出可判断的空值、并且不要让初始化失败把应用启动拖崩。

完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON

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 SDK3.47.7-ohos-1.0.0Flutter 编译与 OHOS 平台工具链
Dart SDK3.13.3Dart 语言与包管理环境
DevEco Studio26.0.0(26.0.0.851)打开 example/ohos、配置签名、提供 hvigor
HarmonyOS 开发套件7.0.0(API 26)开发套件版本及对应的 API 级别
compileSdkVersion26.0.0编译时使用的 SDK API
targetSdkVersion26.0.0应用面向的行为版本
compatibleSdkVersion6.1.1(24)本文工程声明的最低兼容版本
插件版本0.4.0pubspec.yaml 中的包版本
原生语言ArkTSHarmonyOS 插件实现
插件产物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.dartFlutter 侧的绑定与自动销毁,纯 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 / getAllbundleManager、deviceInfo、i18n、hidebug、statvfs 等系统 API一次返回全部字段,键名与 Android 一致
refreshAdvertisingId() / refreshDeviceId()refreshAdvertisingId / refreshDeviceIdidentifier.getOAID()只刷新标识符,不得重建本地 ID
readLocalId / writeLocalId / containsLocalId同名方法,参数 slot、valueasset 密钥库读写按槽位读写,缺失时可创建
resetLocalId / resetAllLocalIds同名方法先 remove 再生成新 UUID重置后返回新的可用 ID
readAllLocalIdsreadAllLocalIds两次 asset.query返回 primary/secondary 两个值
requestAppTrackingTransparency()同名方法无对应框架返回 failure: unavailable

原生端需要保持这些方法名、参数名和返回键一致。系统能力不可用时不抛错,而是返回空值或定义的失败结构——这一点与“订阅失败要报错”的传感器类插件正好相反。

4.1 跨端架构与调用时序

Flutter 侧和 HarmonyOS 侧之间只有一条通道:

  1. MethodChannel('x_app_utils'):负责全部 11 个方法,既包括一次性查询,也包括本地 ID 的读写。

Flutter 页面

XAppUtils 单例

MethodChannel x_app_utils

ArkTS XAppUtilsPlugin

bundleManager 包信息

deviceInfo 设备信息

i18n / intl 语言与时区

hidebug 内存

statvfs 存储

identifier OAID

asset 密钥库

Map 结果按 key 回传

没有 EventChannel,因为插件不订阅任何系统事件。唯一的“自动刷新”发生在 Dart 层:XAppUtils 混入 WidgetsBindingObserver,应用回到前台时自己再走一次 getAll。

4.1.1 一次初始化的时序
OpenHarmony 系统 API XAppUtilsPlugin.ets XAppUtils Flutter App OpenHarmony 系统 API XAppUtilsPlugin.ets XAppUtils Flutter App init() invokeMethod(getAll) bundleManager / deviceInfo / i18n / hidebug / statvfs Map(设备、应用、地区、标识符) _mergeWithoutLocalIds(values) invokeMethod(initializeLocalIds) asset.query(primary) / asset.query(secondary) Map(primaryLocalId, secondaryLocalId, localIdsPersisted) _loadedNativeValues = true true 回前台时 didChangeAppLifecycleState(resumed) invokeMethod(getAll) 再次刷新(不重建本地 ID)

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 实现
deviceModelStringBuild.MODELdeviceInfo.productModel
platformStringBuild.MANUFACTURERdeviceInfo.manufacture
osVersionStringBuild.VERSION.SDK_INTdeviceInfo.sdkApiVersion
systemNameString"Android""OpenHarmony"
deviceNameStringSettings.Global.DEVICE_NAMEdeviceInfo.marketName
freeDiskSize / totalDiskSizeint字节数statvfs 字节数
physicalRamSize / availableRamSizeintMBhidebug KB ÷ 1024
buildSignatureString签名证书 SHA-256(hex)证书 Base64 解码后 SHA-256(hex)
localeStringLocale.toLanguageTag()language-script-region
oaid / advertisingIdString无identifier.getOAID()
primaryLocalId / secondaryLocalIdString加密存储Asset Store Kit
localIdsPersistedbool是否落地成功同
localIdsCreatedbool本次是否新建同

单位是最容易错的地方:磁盘按字节、内存按 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));

两个坑都在这里:

  1. appInfo.label 是资源引用,不是文案。首版直接把它当应用名回传,演示页上显示的就是 App: $string:app_name。正确做法是用 labelId 到 resourceManager 里解析,只有不以 $ 开头的字面量才直接使用。
  2. 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.mdOHOS 新增能力、适配版本、修复记录与测试范围
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 模块配置自动签名:

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

签名材料保存在本机,公开仓库中只保留构建所需的通用配置(提交版里 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 在设备上测试各项取值

演示应用首页是事件总线示例,平台信息卡片会先做一次冒烟:

  1. 打开应用,确认首页显示 Platform: HUAWEI、App: x_app_utils_example、Device: TLR-AL00;
  2. 点“XAppUtils 全功能面板”,进入逐按钮调试页;
  3. 点“打印常用参数”,核对 appName、locale、advertisingId、deviceId 与 primaryLocalId;
  4. 点“打印完整参数 JSON”,核对 buildSignature 是 64 位十六进制、freeDiskSize 与 totalDiskSize 不相等;
  5. 点“请求用户跟踪授权”,确认返回 failure: unavailable;
  6. 在“本地安全 ID”区点“读取 主本地 ID”“检查 主本地 ID 是否存在”“读取全部本地 ID”,确认三处值互相一致;
  7. 点“重置全部本地 ID”,再点“读取全部本地 ID”,确认两个 ID 都变成新值。

第 3 步的 advertisingId 为空串是预期结果:本机没有广告服务,identifier.getOAID() 返回的是全零 UUID,插件按“不可用值为空”的契约把它转成空串,deviceId 随之回落到 primaryLocalId。

8.6 鸿蒙设备运行效果

下面按操作顺序展示真机结果。首页先确认插件已经注册、字段有值:

完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON 完整参数 JSON

首页平台信息常用参数完整参数(设备段)
App: x_app_utils_examplelocale: zh-Hans-CN、advertisingId: ""freeDiskSize 与 totalDiskSize 分别为 201 GB / 237 GB

完整参数继续往下是应用段与标识符段,签名摘要与语言字段都在这里:

签名与语言字段跟踪授权读取主本地 ID
buildSignature 为 64 位 hexfailure: unavailable83462dd8-…,与 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 的版本是否匹配。

处理顺序:

  1. 在 DevEco Studio SDK Manager 中确认 API 26 组件已经下载完整;
  2. 检查 Flutter 和 DevEco Studio 使用的 SDK 路径是否一致;
  3. 避免误用 /Applications/DevEco-Studio.app/Contents/sdk 之类的不完整目录;
  4. 确认 SDK 根目录下存在 toolchains、ets、js、native、previewer;
  5. 执行 flutter config --ohos-sdk <正确路径>;
  6. 重新执行 flutter doctor -v 和 DevEco Studio Sync。
为什么连接 API 24 手机仍然会报这个错误?

因为 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;
  • default product 和 target 已正确关联;
  • 当前账号、证书和调试设备状态有效。

9.4 能安装,但所有字段都是空字符串

按以下顺序检查:

  1. hilog 中是否出现 Tried to register plugins with FlutterEngine 的失败日志;
  2. GeneratedPluginRegistrant.ets 里是否有 new XAppUtilsPlugin();
  3. pubspec.yaml 的 flutter.plugin.platforms.ohos.pluginClass 是否与 ohos/index.ets 的导出名一致;
  4. 通道名是否两端都是 x_app_utils(下划线,不是连字符);
  5. 是否只做了热重载——新增原生插件必须重新构建安装。

这个库的失败形态很安静:_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: 开头的资源引用:看到值以 $ 开头且带冒号,就说明拿到的是引用而不是内容。


相关链接

Logo

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

更多推荐