
简介面向Unity开发者的安卓设备唯一标识获取示例包解决在安卓手机上获取稳定设备号的问题。资源包含两个文件一个Java原生插件与一个C#调用脚本整体压缩包不到1KB轻量易用。Java插件在安卓端取得设备唯一标识C#脚本封装了与Java层的交互入口调用后可直接返回设备号。这套代码展示了Unity与安卓原生代码通过JNI协作的典型方式开发者无需深入理解JNI细节即可复用。设备唯一标识常用于账号绑定、推送注册、统计分析、广告归因等场景属于移动开发中的基础设施模块。资源包体积虽小但文件结构完整Java与C#分工明确开发者可以直接拷贝到项目中二次开发也可根据自身需求调整标识生成逻辑。已有2226人学习下载对Unity初学者或需要快速集成设备标识功能的项目是一个实用的参考范例。1. 设备唯一 ID 这件事为什么值得单独写一个插件很多 Unity 开发者第一次处理设备识别时第一反应是取 IMEI。但 Android 10 之后应用层拿不到 IMEI读不到就退回 ANDROID_ID而 ANDROID_ID 在某些 ROM 上会因恢复出厂设置或系统升级变化。标题里的 GetAndroidphoneId 这类方案做的就是把可用的设备标识从系统层捞出来再包一层兼容逻辑避免每条渠道都重新踩一遍 Android 版本坑。实际上你需要的往往不是“唯一”而是“相对稳定”同一台设备、同一次安装期内保持不变卸载重装后尽力保持跨应用时能区分出“这是同一台真机”。这篇讲清楚它在 Unity 侧怎么落地核心原理是什么参数怎么调以及真机运行时会遇到哪些边界。涉及的技术点集中在 Android SDK 的 Settings.Secure、AndroidJavaClass 桥接、AAR 打包与 ProGuard 混淆。写插件的人往往把它封装成一个 C# 类暴露一个无参方法返回字符串。但真正决定返回值的是 Android 端对设备标识策略的取舍而不是 C# 侧几行代码。下面按“原理 → 实现 → 实战 → 收尾技巧”推进。2. 先理解 Android 端的设备 ID 体系从 IMEI 到 ANDROID_ID 再到厂商 OAID2.1 为什么 IMEI 不能直接用Android 早期版本确实允许普通应用通过TelephonyManager.getImei()读 IMEI配合READ_PHONE_STATE权限就能拿到硬件级标识。但 Google 从 Android 6.0 开始把权限归入危险权限需要动态申请Android 10 进一步收紧为“仅系统应用可读”应用层直接返回空字符串或抛SecurityException。你现在去应用商店收录的 App 里搜权限声明几乎看不到 IMEI 的声明原因就在这。反直觉的一点是即便你的 app 目标是低版本系统在 Android 10 设备上依旧拿不到 IMEI。所以面向新设备的 Unity 项目不要在 IMEI 上花时间。它只适合那种“几十台测试机全部 root 过、通过 adb 预先写入白名单”的内部工具。2.2 ANDROID_ID 的工作机制与坑Settings.Secure.ANDROID_ID是 Android 官方推荐给应用层使用的设备标识不需要任何权限通过ContentResolver读取格式是 16 位十六进制字符串。它的规则有两条必须记牢在 Android 8.0API 26之前ANDROID_ID 由设备首次启动时生成对所有应用一致。Android 8.0 之后ANDROID_ID 变更为“签名密钥 用户 设备”的组合派生值不同签名、不同用户、不同设备之间均不同。这就是插件存在的价值它把你从“这个值是不是变了”的焦虑里解放出来。用一句大白话说应用自己生成的随机 UUID 一定是唯一的但它会随卸载丢失ANDROID_ID 能跨卸载保留但不是绝对唯一。GetAndroidphoneId 常用的策略是把两者结合。2.3 Unity C# 如何走到 Android 层的 Settings.SecureUnity 的 C# 运行在 IL2CPP 或 Mono 之上不能直接调用 Android SDK 的 Java 层 API它依赖的是 AndroidJavaObject / AndroidJavaClass 两个类。每个 Unity 安装包都内置了对UnityPlayer.currentActivity的引用你可以拿到 Activity 实例再通过getApplicationContext()或者getContentResolver()去读系统设置。public class DeviceIdHelper { public static String getAndroidId(android.content.Context context) { String id android.provider.Settings.Secure.getString( context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID ); return id; } }这段 Java 代码做了最基础的事把Settings.Secure.ANDROID_ID取出来。注意context来自外部调用方不能在这里new一个 Context因为 Context 是抽象类必须依赖系统运行时的环境。注释里没有权限声明这是它的优点也是它的局限——读到的是系统层面的值但不同厂商 ROM 对这个值的处理方式不同下文第 4 章详细展开。参数层面的关键点是Settings.Secure.getString的第二个参数是一个字符串常量ANDROID_ID实际就是android_id。你要是在 Unity 侧拼错了键名返回值就是 null而且系统不会报错。这种“静默失败”在设计接口时要格外注意最好在 C# 层做兜底返回Guid.NewGuid().ToString()。2.4 各方案横向对比方案获取方式系统要求是否会变备注IMEITelephonyManagerAndroid 10 后禁止基本不变需权限已不适用ANDROID_IDSettings.Secure所有版本可用升级系统可能变恢复出厂必变无权限最适合OAID厂商 SDK仅国产厂商用户重置可变需要接入个推等 SDKMAC 地址WifiManagerAndroid 6 后返回随机 MAC每次重启可能变已不可靠自有 UUIDPlayerPrefs 存储无卸载变清数据变最简单最不稳定从表格看出ANDROID_ID 是综合成本最低的选择。GetAndroidphoneId 这类插件在实现上并不会只依赖它通常在 ANDROID_ID 为 null 或全零时回退到随机 UUID并且把 UUID 持久化到本地。这就是“设备 ID”一个真正稳定输出的逻辑。3. Unity 集成步骤从 AAR 到 C# 调用的一次完整闭环3.1 前期准备与目录位置在动手之前先确认你本机的 Android SDK 路径和 Unity 版本。Unity 不同大版本对Plugins/Android目录的扫描规则有区别自 Unity 2019.4 之后所有 .aar 与 .jar 都放在Assets/Plugins/Android下即可。从 Android Studio 导出的 AAR 文件直接拖进该目录Unity 打包时会自动合并其 AndroidManifest.xml 与资源文件。常见项目里我一般会保留一份AndroidManifest.xml放在这个目录下里面只声明两个东西manifest xmlns:androidhttp://schemas.android.com/apk/res/android application activity android:namecom.unity3d.player.UnityPlayerActivity / /application /manifest这段清单的作用是给 Unity 指定启动 Activity避免默认配置在某些渠道包上产生兼容问题。如果只是获取设备 ID其实不需要添加任何权限。不要手滑加一条READ_PHONE_STATE这会导致应用被 Play Protect 判定为低质量。3.2 用 Android Studio 生成最小 AAR打开 Android Studio新建一个空工程Module 类型选 Android Library。在MainActivity里写一个静态方法package com.example.deviceid; import android.content.Context; import android.provider.Settings; public class DeviceIdProvider { public static String getAndroidId(Context context) { try { String id Settings.Secure.getString( context.getContentResolver(), Settings.Secure.ANDROID_ID ); return (id null || id.equals(9774d56d682e549c)) ? : id; } catch (Exception e) { return ; } } }注意这里多判断了一个9774d56d682e549c。这是 Android 4.0 到 4.1 时期一个著名的 Bug部分设备会全部返回同一个 ANDROID_ID导致设备之间无法区分。虽然现在很少见但作为兼容性代码保留这个判断能防止老设备数据污染。然后执行 Gradle 的assembleRelease任务在build/outputs/aar目录拿到library-release.aar。改名为deviceid.aar后导入 Unity。你不需要导出源码 jar因为 Unity 直接引用 AAR 内的 class 即可。3.3 C# 侧封装用 AndroidJavaClass 做桥接Unity 侧写一个静态类对外暴露GetDeviceId()using UnityEngine; public static class DeviceIdUtil { private static string _cachedId; public static string GetDeviceId() { if (!string.IsNullOrEmpty(_cachedId)) return _cachedId; string result ; try { if (Application.platform RuntimePlatform.Android) { using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { AndroidJavaObject activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); AndroidJavaObject context activity.CallAndroidJavaObject(getApplicationContext); AndroidJavaClass provider new AndroidJavaClass(com.example.deviceid.DeviceIdProvider); result provider.CallStaticstring(getAndroidId, context); } } } catch (System.Exception e) { Debug.LogWarning([DeviceId] Failed: e.Message); } if (string.IsNullOrEmpty(result)) result SystemInfo.deviceUniqueIdentifier; _cachedId result; return result; } }代码里做了三层协作第一通过UnityPlayer.currentActivity拿到当前 Activity第二调用getApplicationContext()得到应用级上下文第三将 context 传给 Java 侧的静态方法获取 ANDROID_ID。如果抛异常或得到空字符串回退到SystemInfo.deviceUniqueIdentifier这是 Unity 引擎自带的设备标识接口底层实际由 Android 的 ANDROID_ID 或部分厂商适配值支撑。参数说明using (AndroidJavaClass ...)释放原生引用防止内存泄漏。GetStaticAndroidJavaObject(currentActivity)读取的是 Unity 内部的静态对象必须在主线程调用。CallStaticstring返回的 Java 字符串会自动转换到 C# 的 string。_cachedId做进程内缓存避免频繁 JNI 调用拖慢帧率。3.4 真机验证与日志输出构建 APK 时选 “Build App Bundle (Google Play)” 之外的普通 APK 即可。装到测试机之后通过 adb 查看日志adb shell am start -n com.example.unitydemo/com.unity3d.player.UnityPlayerActivity adb logcat -s Unity关键日志行是[DeviceId] Failed:。如果你在 logcat 里看到这个说明 Java 层抛了异常需要先在 C# 代码里打印e.StackTrace再看是不是 AAR 没有正确打进包里。另一个常见现象是返回全零字符串这通常意味着 AAR 内部读取出了空值。这里有个容易踩的坑Unity 2019 以后默认启用 IL2CPP如果在 Android Studio 那边用了BuildConfig相关的常量IL2CPP 裁剪时可能会把未引用的类剪掉。所以 Java 类的静态方法名不要混淆C# 调用前先做一次反射测试。4. 参数与边界Android 10、厂商 ROM 与 Pico4 这类 Android 设备4.1 三个必调的兼容参数4.1.1 目标 API 等级Unity 的 Player Settings 中Target API Level决定系统授予该应用的行为模型。Android 10API 29开始强制分区存储Android 11API 30开始包可见性限制这些都会间接影响插件读取文件或获取标识的方式。设备 ID 这块真正的分水岭也是 API 29。Android 10 之前Settings.Secure.ANDROID_ID可以跨系统应用一致读取Android 10 之后含 10系统对刚恢复出厂设置的设备会生成一个随机值并在首次“可信”使用后固定。这意味着你在新手机上第一次启动 App 时拿到的 ID可能在你手动重置设备后第二次启动时变成另一个值。实践中我会这样设Target API保持 34Android 14Min API一般 26。不追求过高的目标版本因为 Android 14 并没有进一步收紧 ANDROID_ID只收紧了对精确闹钟和后台定位的权限。4.1.2 命名空间与包名混淆AAR 内的类名如果被压缩混淆Unity 侧调用com.example.deviceid.DeviceIdProvider就会抛ClassNotFoundException。在 Android Studio 侧关闭混淆或为入口类加 keep 规则-keep class com.example.deviceid.DeviceIdProvider { *; }把这行放在 Library 模块的proguard-rules.pro里。否则 Unity Build 时报错类型要么是找不到类要么是NoSuchMethodError。这种问题在 Release 包上冒出来Debug 包正常排查就是要靠adb logcat看ClassNotFoundException关键字。4.1.3 主线程超时控制某些国产 ROM 第一次访问系统设置时会触发磁盘 IO 和权限检查极端情况下耗时可能超过 100ms。如果恰好在Awake或Start里调用会让首帧卡一下。要缓解有两个办法移到主线程空闲时调用。用协程延迟一帧再读。4.2 厂商 ROM 对 Android ID 的改造Android 生态里国内厂商对 Settings.Secure 的实现不完全一致。华为 EMUI/HarmonyOS 在部分版本会将 ANDROID_ID 限定为“同一设备同一应用相同”但不同应用读到的值可能不同小米 MIUI 在开启“隐私保护”后每次恢复出厂设置都会重新生成 ANDROID_IDOPPO 和 vivo 在 Android 12 之后部分机型对第三方应用返回全 0 字符串。处理这些边界的最稳妥办法是上面的“后端建一张表”方案用 ANDROID_ID 作为主键不行就换回退值。但标题里这个插件本质是单机方案做不到服务端比对。那唯一的防线就是回退链。在GetAndroidphoneId这类项目的常见实现里回退链一般是Settings.Secure.ANDROID_ID无权限、跨卸载SystemInfo.deviceUniqueIdentifierUnity 封装Android 上本质是 ANDROID_ID 应用签名首次启动时生成的 GUID存到Application.persistentDataPath下不会因清缓存丢失但会随卸载清除如果你在 Pico4 这类 VR 一体机上做 Unity 开发也要把它当成一台“有特殊权限的 Android 设备”它通常运行的是经过厂商定制的 Android 系统读取方法与手机一致。但注意部分一体机开放了android.permission.READ_PRIVILEGED_PHONE_STATE给系统应用而普通 Unity 应用并没有这个权限所以还是老老实实走 Settings.Secure。4.3 失败时的观察点与排查表现象可能原因处理方式返回 nullAAR 未打包检查Plugins/Android下 .aar 是否存在且非空返回全 0厂商 ROM 改写回退到 GUID 并持久化每次重启变ANDROID_ID 因设备状态变化用持久化 GUID 覆盖调用时报NoSuchMethodError混淆规则误伤添加 ProGuard keep 规则首帧明显卡顿主线程直接 IO延迟到协程或空闲调用卸载重装后 ID 变了ANDROID_ID 对同签名生效但部分系统不遵守接受现实靠 Account 体系或自有账号绑定表格里有一条很关键卸载重装导致 ID 变化在部分 ROM 上是预期行为。Android 8 之后的文档说 ANDROID_ID 在卸载重装后只有备份恢复时能保持但国内不少厂商会在“恢复出厂设置”或“应用卸载”时清除密钥导致同一应用重装后 ID 变化。这不是你代码能修复的。如果你的应用有网络建议在首次启动时把设备 ID 上报到自己的用户系统后续再变就视为新设备。这关系到后续历史上的用户行为归因不只是写个 getter 那么简单。4.4 与 Unity 序列化 / IL2CPP 相关的注意事项一段容易忽略的知识点C# 侧的AndroidJavaObject在 IL2CPP 下会走内部封装如果 Java 方法签名里有Context参数而你在 C# 侧传了 Activity 而不是 Application Context会导致 Activity 泄漏。AndroidJavaObject context activity.CallAndroidJavaObject(getApplicationContext);这行已经规避了泄漏。但如果你图省事直接把activity传过去方法签名虽然匹配Activity 对象被静态持有旋转屏幕时旧 Activity 无法销毁会触发LeakedActivity警告。这就是第 4.1.2 节里说的“别用 Activity 当 Context”的原因。5. 稳定性兜底的一招把“会话 ID”与“设备 ID”分开持久化如果你觉得前面逻辑太啰嗦想直接拿一个能PlayerPrefs.GetString(device_id)就完事的方案那考虑一下这个现象用户清缓存时会清掉 PlayerPrefs但不会清掉文件系统里的自有文件。所以更可靠的持久化位置是文件而不是 PlayerPrefs。实现思路是private static string LoadOrCreateId() { string path Path.Combine(Application.persistentDataPath, did.dat); if (File.Exists(path)) { string cached File.ReadAllText(path); if (!string.IsNullOrEmpty(cached)) return cached; } string newId Guid.NewGuid().ToString(N); File.WriteAllText(path, newId); return newId; }这段代码将首次生成的 GUID 写入持久化目录。persistentDataPath对应 Android 的/data/data/包名/files不会备份到外部公共存储卸载应用时被删除但清缓存不会动它。它比PlayerPrefs稳一点因为 PlayerPrefs 在 Android 上通常落在shared_prefs目录系统设置里的“清除数据”会连它一起删掉而persistentDataPath下的文件在某些 ROM 上清数据也会被删但概率低一些。如果你要把“设备 ID”和“安装实例 ID”分开就有两个概念设备级 ID 用 ANDROID_ID 取不落地存储安装级 ID 用上面的 GUID落盘。外部分析系统需要识别的其实是安装级 ID。很多第三方统计 SDK 就是这么设计的。验证方法很直接adb shell run-as com.example.unitydemo cat files/did.datrun-as需要 debuggable 包才能读。如果看到一串 32 位字母数字说明落盘成功。再次启动应用返回同一字符串整个链路就闭环了。最后再强调一句设备 ID 仅是匿名标识不要拿它跟用户手机号或真实姓名关联这既尊重用户隐私也避免上架审核被拒。本文还有配套的精品资源点击获取