从零写一个跨 Android/iOS/鸿蒙 的设备标识符 UTS 插件

从零写一个跨 Android/iOS/鸿蒙 的设备标识符 UTS 插件:降级链路、反射 OAID、Keychain 持久化

移动端"设备唯一标识"是统计、风控、广告归因的刚需,但也是合规重灾区:Android 禁 IMEI、iOS 废 UDID、鸿蒙要授权。这篇文章用源码级的方式拆解一个实际可用的 uni-app x UTS 插件 kyokasanagi-devid,讲清楚三端各自的实现原理、降级状态机、反射调用 MSA OAID SDK、Keychain/Swift 混编,以及几个容易踩的坑。

一、先明确问题域

在动手前,把三端各自"能稳定拿到什么"梳理成一张约束表,这是整个设计的起点:

平台 可用标识符 是否需权限/授权 变化条件
Android ANDROID_IDSettings.Secure 8.0+ 免权限 恢复出厂、部分系统重置广告标识
Android OAID(MSA 广告标识) 需集成 MSA SDK + 用户开"广告跟踪" 用户重置广告标识
iOS IDFVidentifierForVendor 免权限 卸载同供应商其他 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 / 类不存在 → 自动降级
        }
    }
}

三个值得注意的点:

  1. 编译期零依赖Class.forName 是运行时才解析的,只要没写 import com.bun.*,不放 aar 也能正常编译。这就是"可选集成"的技术基础。
  2. 必须用 Proxy.newProxyInstance :MSA 的回调是接口 MdidSdkListener,不是方法引用。反射拿到接口 Class 后,动态代理实例必须由该接口的 classLoader 创建(listenerClz.classLoader),否则会 ClassCastException
  3. 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.preferencesgetPreferences() 是异步的,但取/写值有同步方法 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.DeviceInfoKitodid.getOdid() 取,OAID@kit.AdsKitads.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

六、错误码与降级:让业务层可编程

所有标识符都失败时,不能只丢一个空串。DeviceIdResulterrorCodeerrorMsg 双通道:

errorCode 含义 触发场景
0 成功 拿到了有效主标识
1001 权限缺失 ATT / APP_TRACKING_CONSENT 未授权
1002 标识符不可用 全部标识符为空或全 0
1003 本地存储失败 SharedPreferences / Keychain / preferences 读写异常
1999 未知错误 兜底

三端统一判据:localUuid 都取不到才报 1003,否则报 1002errorMsg 里用 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 的适配数据还在持续跟进,欢迎交流踩坑经验。

相关推荐
tedcloud1231 小时前
diagram-design 怎么安装?用 AI 自动生成更专业的架构图、流程图
linux·运维·前端·人工智能·开源·流程图
心仪久了会沦陷2 小时前
数据结构入门系列——顺序表详解:概念、分类与动态实现
c语言·数据结构·经验分享·笔记·开源
狂师2 小时前
AI 测试丨一句指令生成测试报告,带失败截图、操作录屏、Trace、日志,这套 Skill 思路可以直接抄...
人工智能·开源·测试
sibylyue2 小时前
# Web端流媒体JS播放器开源库
前端·javascript·开源
chunmiao30322 小时前
达摩院肝癌AI模型登上《自然·医学》:可识别1厘米微小肿瘤
开源
zzzzzz3104 小时前
别急着把页面做成舞台:从 react-bits 看动画组件该怎么选
javascript·react.js·开源
运维全栈笔记6 小时前
Vue + Spring Boot 前后端分离项目部署笔记(若依 RuoYi-Vue 3.9.2)
运维·服务器·vue.js·spring boot·笔记·开源·开源软件
Hrain-AI14 小时前
Anthropic oncall-kit 开源拆解:运维 Agent 落地范式的四基石与权限边界
运维·人工智能·开源
淼澄研学14 小时前
Proliferate开源框架技术解析与Docker部署实操
docker·容器·开源