从零写一个跨 Android/iOS/鸿蒙 的设备标识符 UTS 插件:降级链路、反射 OAID、Keychain 持久化
移动端"设备唯一标识"是统计、风控、广告归因的刚需,但也是合规重灾区:Android 禁 IMEI、iOS 废 UDID、鸿蒙要授权。这篇文章用源码级的方式拆解一个实际可用的 uni-app x UTS 插件
kyokasanagi-devid,讲清楚三端各自的实现原理、降级状态机、反射调用 MSA OAID SDK、Keychain/Swift 混编,以及几个容易踩的坑。
一、先明确问题域
在动手前,把三端各自"能稳定拿到什么"梳理成一张约束表,这是整个设计的起点:
| 平台 | 可用标识符 | 是否需权限/授权 | 变化条件 |
|---|---|---|---|
| Android | ANDROID_ID(Settings.Secure) |
8.0+ 免权限 | 恢复出厂、部分系统重置广告标识 |
| Android | OAID(MSA 广告标识) |
需集成 MSA SDK + 用户开"广告跟踪" | 用户重置广告标识 |
| iOS | IDFV(identifierForVendor) |
免权限 | 卸载同供应商其他 App |
| iOS | IDFA |
需 ATT 授权 | 用户重置 |
| HarmonyOS | ODID |
需 APP_TRACKING_CONSENT |
用户重置 |
| HarmonyOS | OAID |
同上 | 用户重置 |
| 三端 | 本地持久化 UUID | 无 | 卸载重装(iOS Keychain 除外) |
核心结论:没有任何一个标识符是"永久稳定 + 免授权 + 全平台通用"的。 所以插件必须做成多级降级,且永远不要承诺 IMEI / 序列号 / MAC 这类不可重置的硬件 ID------这是合规底线,也是避免返工的设计红线。
二、UTS 插件如何做到"三端一套 API"
uni-app x 的 UTS 编译机制是:interface.uts 里用 export type 声明对外 API 签名,各平台目录(app-android/、app-ios/、app-harmony/)各自用 export const 实现同名函数,编译期按目标平台分发,调用方统一从插件根路径导入:
bash
uni_modules/kyokasanagi-devid/
└─ utssdk/
├─ interface.uts # 只做类型/签名声明
├─ unierror.uts # 错误码常量
├─ app-android/index.uts # Kotlin 混编 + UTS
├─ app-android/OaidHelper.kt
├─ app-ios/index.uts # Swift 混编 + UTS
├─ app-ios/KeychainHelper.swift
└─ app-harmony/index.uts # ArkTS 实现
调用侧一行 import 搞定,平台差异全部被编译期抹平:
ts
import { getDeviceIdentifierInfo } from '@/uni_modules/kyokasanagi-devid'
const info = await getDeviceIdentifierInfo()
// { mainId, mainIdType, success, errorCode, oaid, androidId, idfv, odid, localUuid, errorMsg }
三、Android 实现:反射 OAID 是核心难点
3.1 降级状态机
getDeviceIdentifierInfo() 在 Android 的判定逻辑,本质上是一个三段状态机:
ini
┌─ 有效 ──► mainId = OAID (type=OAID)
OAID ──► 非空? ──┤
└─ 全0/空 ─► ┌─ 非空 ──► mainId = ANDROID_ID (type=ANDROID_ID)
ANDROID_ID? │
└─ 空 ──► mainId = localUuid (type=LOCAL_UUID)
对应的 UTS 代码(已精简):
ts
if (oaid != '' && !isInvalidId(oaid)) {
result.mainId = oaid
result.mainIdType = 'OAID'
} else if (androidId != '') {
result.mainId = androidId
result.mainIdType = 'ANDROID_ID'
} else {
result.mainId = result.localUuid
result.mainIdType = 'LOCAL_UUID'
}
result.success = result.mainId != ''
3.2 OAID 用"反射 + 动态代理"实现可选集成
OAID 来自移动安全联盟(MSA)的 SDK,是一个 aar。如果把它直接 compile 进插件,那么没放 aar 的工程连编译都过不了 ------这对"可选集成"是致命的。解法是把 MSA 调用做成纯反射 ,混编一个 Kotlin 文件 OaidHelper.kt:
kotlin
object OaidHelper {
// 反射目标类名,编译期不依赖 aar
private const val HELPER_CLASS = "com.bun.miitmdid.core.MdidSdkHelper"
private const val LISTENER_CLASS = "com.bun.miitmdid.interfaces.MdidSdkListener"
fun getOaid(context: Context, callback: (String) -> Unit) {
try {
val helper = Class.forName(HELPER_CLASS)
val listenerClz = Class.forName(LISTENER_CLASS)
// 关键点:MSA 用接口回调(MdidSdkListener),必须动态代理
val listener = Proxy.newProxyInstance(
listenerClz.classLoader, arrayOf(listenerClz)
) { _, method, args ->
if (method.name == "onMdidGet" && args != null && args.isNotEmpty()) {
val ids = args[0]
val getOaid = ids.javaClass.getMethod("getOAID")
val oaid = getOaid.invoke(ids) as? String ?: ""
callback(oaid)
}
null
}
// InitSdk(Context, boolean(debug), MdidSdkListener)
val init = helper.getMethod("InitSdk",
Context::class.java, Boolean::class.javaPrimitiveType, listenerClz)
init.invoke(null, context, true, listener)
} catch (e: Throwable) {
callback("") // 未放 aar / 类不存在 → 自动降级
}
}
}
三个值得注意的点:
- 编译期零依赖 :
Class.forName是运行时才解析的,只要没写import com.bun.*,不放 aar 也能正常编译。这就是"可选集成"的技术基础。 - 必须用
Proxy.newProxyInstance:MSA 的回调是接口MdidSdkListener,不是方法引用。反射拿到接口Class后,动态代理实例必须由该接口的classLoader创建(listenerClz.classLoader),否则会ClassCastException。 - UTS 侧做超时兜底 :
InitSdk是异步初始化,回调可能永远不来(比如厂商服务异常)。所以 UTS 侧包了一层带 5s 超时的 Promise,且只resolve一次:
ts
function getOaidImpl(): Promise<string> {
return new Promise<string>((resolve) => {
try {
OaidHelper.getOaid(UTSAndroid.getAppContext(), (oaid: string) => resolve(oaid))
setTimeout(() => resolve(''), 5000) // 兜底,Promise 幂等
} catch (e) { resolve('') }
})
}
启用 OAID 只需要两步,不动任何源码:把 msa_mdid_x.x.x.aar 放进 app-android/libs/,在 AndroidManifest.xml 填 AppId。加密版(云打包)买家也能用,这直接决定了插件的商业化形态------不用逼用户买源码版才能开广告标识。
3.3 全 0 判定,一个防呆细节
OAID / ODID 在未授权或模拟器上经常返回 00000000-0000-0000-0000-000000000000,有些实现还返回不带连字符的 32 位全 0。直接 == '' 判断是拦不住的,必须做归一化:
ts
function isInvalidId(value: string): boolean {
if (value == '') return true
const compact = value.split('-').join('') // 去掉连字符
if (compact == '') return true
return compact.split('0').join('') == '' // 去掉所有 0 后是否为空
}
这里用 UTS 字符串操作而不是正则,是因为 UTS 在各平台的正则支持不一致,字符串 API 是三端都稳定的最小公分母。
3.4 本地 UUID:apply() + 进程内缓存
持久化用 SharedPreferences。有两个容易翻车的点:
- 必须用
apply()而不是commit():commit()是同步写盘,在主线程调用会卡 UI;apply()异步写,先更新内存再落盘。 - 并发竞态 :多次并发调用
getDeviceIdentifierInfo()时,如果每次都现读现写,可能各自UUID.randomUUID()生成不同值,导致同一进程内标识不稳定。所以加一层模块级缓存:
ts
let cachedLocalUuid = ''
function getLocalUuid(): string {
if (cachedLocalUuid != '') return cachedLocalUuid
const saved = sp.getString(KEY_LOCAL_UUID, '')
if (saved != '') { cachedLocalUuid = saved; return saved }
const uuid = UUID.randomUUID().toString()
sp.edit().putString(KEY_LOCAL_UUID, uuid).apply()
cachedLocalUuid = uuid
return uuid
}
clearLocalUuid() 会同步把 cachedLocalUuid 清空,保证"清除后再次获取生成新 UUID"的语义一致。
3.5 屏幕尺寸:Android 11 的 API 分裂
getDefaultDisplay().getRealMetrics() 在 API 30(Android 11)起废弃,必须换 WindowManager#getCurrentWindowMetrics(),按版本分流:
ts
if (Build.VERSION.SDK_INT >= 30) {
const wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
const bounds = wm.currentWindowMetrics.bounds
res.screenWidth = bounds.width(); res.screenHeight = bounds.height()
} else {
const metrics = context.getResources()?.getDisplayMetrics()
res.screenWidth = metrics.widthPixels; res.screenHeight = metrics.heightPixels
}
这个坑很隐蔽:低版本 API 在 11+ 上不是报错,而是返回过时/错误的尺寸,不按版本分流拿到的宽高就是错的。
四、iOS 实现:Keychain 混编与 ATT 状态机
4.1 IDFV 的语义边界
identifierForVendor 的规则要记牢:同一设备、同一供应商(CFBundleIdentifier 前缀相同的 App 组)下所有 App 一致;只要卸载同供应商的任意一个其他 App,IDFV 就可能变化。所以它适合"安装量/去重",不适合"跨 App 长期追踪"。正因如此,插件把它放第一级,第二级用 Keychain UUID 兜底。
4.2 用 Swift 混编把 UUID 写进 Keychain
UTS 混编的机制是:app-ios/ 目录下的 .swift 文件自动参与 iOS 编译,UTS 端无需 import 直接调用 。KeychainHelper.swift 的关键点:
swift
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, // 泛型密码类型
kSecAttrService as String: "uts-device-id",
kSecAttrAccount as String: "local-uuid",
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary) // 先删后插,保证幂等
SecItemAdd(query as CFDictionary, nil)
这里有一个容易误导的点:kSecAttrAccessibleAfterFirstUnlock 不需要配置 entitlements(Keychain Sharing)也能用 。很多人以为读写 Keychain 必须开 Keychain Sharing 能力,其实只有跨 App 共享 Keychain 才需要 entitlements;单 App 用自己的 service/account 读写 kSecClassGenericPassword 是免授权的。这也让插件开箱即用,不用买家去配签名能力。
效果:卸载重装后 Keychain 里的 UUID 仍在,这是 iOS 端唯一能跨卸载保持的本地持久化方案。
4.3 IDFA 与 ATT:版本分裂 + 状态机
ATT 是 iOS 14+ 才有的框架,iOS 13 及以下根本没有 ATTrackingManager,直接调用会崩。所以 getIdfa() 必须按系统大版本分支:
ts
const major = Number.parseInt(UIDevice.current.systemVersion.split('.')[0])
if (major < 14) {
const manager = ASIdentifierManager.sharedManager()
if (manager.isAdvertisingTrackingEnabled) { // iOS13 的旧开关(14 后废弃)
res.status = 3; res.idfa = manager.advertisingIdentifier.uuidString
} else { res.status = 2 }
} else {
switch (ATTrackingManager.trackingAuthorizationStatus) {
case .notDetermined: res.status = 0
case .restricted: res.status = 1
case .denied: res.status = 2
case .authorized: res.status = 3; res.idfa = ...
}
}
注意 isAdvertisingTrackingEnabled 在 iOS 14 后废弃但可编译 ,所以混编/编译不会报错,低版本分支完全可行。插件不触发 ATT 弹窗 (弹窗是隐私交互,必须由宿主在用户同意隐私政策后自己调 ATTrackingManager.requestTrackingAuthorization()),getIdfa() 只做只读状态查询------把"授权时机"决策权交给业务层,这是合规设计的关键。
4.4 具体机型
UIDevice.current.model 只返回设备族("iPhone"),要具体型号得自己维护机型映射表。uni-app x 4.11+ 内置了 UTSiOS.getModel()(返回如 iPhone 15 Pro),直接调,空值再回退设备族:
ts
try { model = UTSiOS.getModel() } catch (e) { model = '' }
if (model == '') model = UIDevice.current.model
五、HarmonyOS 实现:异步偏好存储与权限
鸿蒙侧逻辑与 Android 同构,但有两个平台特有的点:
1. preferences 的同步/异步混合 API。 @ohos.data.preferences 的 getPreferences() 是异步的,但取/写值有同步方法 getSync()/putSync(),且写完后必须显式 flush() 才落盘:
ts
let cachedPref: preferences.Preferences | null = null // 实例缓存,避免重复 open
async function getPref() {
if (cachedPref != null) return cachedPref
cachedPref = await preferences.getPreferences(getContext(), 'uts_device_id')
return cachedPref
}
async function getLocalUuid(): Promise<string> {
if (cachedLocalUuid != '') return cachedLocalUuid
const pref = await getPref()
const saved = pref.getSync(KEY_LOCAL_UUID, '') as string
if (saved != '') { cachedLocalUuid = saved; return saved }
const uuid = util.generateRandomUUID(true) // true = 大写
pref.putSync(KEY_LOCAL_UUID, uuid); await pref.flush()
cachedLocalUuid = uuid
return uuid
}
2. 标识符来自 kit 且都要授权。 ODID 从 @kit.DeviceInfoKit 的 odid.getOdid() 取,OAID 从 @kit.AdsKit 的 ads.getOaid() 取,两者都依赖 ohos.permission.APP_TRACKING_CONSENT(已在 module.json5 声明)。未授权时接口返回空,插件自动降级,不弹框、不报错。若目标 API level 不支持 kit 导入,readme 里给了 @ohos.identifier.odid / @ohos.ads.oaid 的替代方案------kit 与旧模块的 API 差异是鸿蒙开发的常见坑。
型号取 deviceInfo.marketName(市场名,如 "Mate 60"),为空时回退 deviceInfo.name。
六、错误码与降级:让业务层可编程
所有标识符都失败时,不能只丢一个空串。DeviceIdResult 里 errorCode 与 errorMsg 双通道:
| errorCode | 含义 | 触发场景 |
|---|---|---|
0 |
成功 | 拿到了有效主标识 |
1001 |
权限缺失 | ATT / APP_TRACKING_CONSENT 未授权 |
1002 |
标识符不可用 | 全部标识符为空或全 0 |
1003 |
本地存储失败 | SharedPreferences / Keychain / preferences 读写异常 |
1999 |
未知错误 | 兜底 |
三端统一判据:localUuid 都取不到才报 1003,否则报 1002。errorMsg 里用 notes.join('; ') 拼接降级过程(如 "OAID不可用(全0); ANDROID_ID不可用"),方便真机排查链路走到哪一级了。业务层拿到 errorCode 就知道该弹"重新授权引导"还是"降级 UI"。
七、合规与健壮性总结
- 不碰硬件永久标识:IMEI / 序列号 / MAC 一律不提供,这是上架各大应用商店的前提;
- 标识都可重置:ANDROID_ID 恢复出厂变、OAID/IDFA/ODID 用户可重置、本地 UUID 卸载重装变(iOS Keychain 除外)------在隐私政策里如实声明;
- 授权时机交给宿主:插件不主动弹窗、不网络上报;涉及 IDFA/ODID/OAID 时务必"同意 → 授权 → 读取";
- 健壮性兜底 :OAID 5s 超时、全 0 判定、进程内缓存防并发串号、异常全部 try/catch 降级------Promise 永不悬挂是硬要求。
八、结语
设备标识这件事没有银弹,但把三端约束抽象成"降级状态机 + 可选集成 + 错误码",就能用一套 API 覆盖 90% 的场景,而且把合规主动权还给业务层。插件的完整实现(含示例工程 uts-device-id-demo)在 DCloud 插件市场,搜索 kyokasanagi-devid(跨端设备标识符,uni-app x,v1.1.0)即可获取。
鸿蒙的厂商差异和 OAID 的适配数据还在持续跟进,欢迎交流踩坑经验。