简介:面向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 再到厂商 OAID
2.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.0(API 26)之前,ANDROID_ID 由设备首次启动时生成,对所有应用一致。
- Android 8.0 之后,ANDROID_ID 变更为“签名密钥 + 用户 + 设备”的组合派生值,不同签名、不同用户、不同设备之间均不同。
这就是插件存在的价值:它把你从“这个值是不是变了”的焦虑里解放出来。用一句大白话说,应用自己生成的随机 UUID 一定是唯一的,但它会随卸载丢失;ANDROID_ID 能跨卸载保留,但不是绝对唯一。GetAndroidphoneId 常用的策略是把两者结合。
2.3 Unity C# 如何走到 Android 层的 Settings.Secure
Unity 的 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 各方案横向对比
| 方案 | 获取方式 | 系统要求 | 是否会变 | 备注 |
|---|---|---|---|---|
| IMEI | TelephonyManager | Android 10 后禁止 | 基本不变 | 需权限,已不适用 |
| ANDROID_ID | Settings.Secure | 所有版本可用 | 升级系统可能变,恢复出厂必变 | 无权限,最适合 |
| OAID | 厂商 SDK | 仅国产厂商 | 用户重置可变 | 需要接入个推等 SDK |
| MAC 地址 | WifiManager | Android 6 后返回随机 MAC | 每次重启可能变 | 已不可靠 |
| 自有 UUID | PlayerPrefs 存储 | 无 | 卸载变,清数据变 | 最简单最不稳定 |
从表格看出,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:android="http://schemas.android.com/apk/res/android"> <application> <activity android:name="com.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.GetStatic<AndroidJavaObject>("currentActivity"); AndroidJavaObject context = activity.Call<AndroidJavaObject>("getApplicationContext"); AndroidJavaClass provider = new AndroidJavaClass("com.example.deviceid.DeviceIdProvider"); result = provider.CallStatic<string>("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 ...)释放原生引用,防止内存泄漏。GetStatic<AndroidJavaObject>("currentActivity")读取的是 Unity 内部的静态对象,必须在主线程调用。CallStatic<string>返回的 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 10(API 29)开始强制分区存储,Android 11(API 30)开始包可见性限制,这些都会间接影响插件读取文件或获取标识的方式。
设备 ID 这块真正的分水岭也是 API 29。Android 10 之前,Settings.Secure.ANDROID_ID可以跨系统应用一致读取;Android 10 之后(含 10),系统对刚恢复出厂设置的设备会生成一个随机值,并在首次“可信”使用后固定。这意味着你在新手机上第一次启动 App 时拿到的 ID,可能在你手动重置设备后第二次启动时变成另一个值。
实践中我会这样设:Target API保持 34(Android 14),Min 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_ID;OPPO 和 vivo 在 Android 12 之后,部分机型对第三方应用返回全 0 字符串。
处理这些边界的最稳妥办法,是上面的“后端建一张表”方案:用 ANDROID_ID 作为主键,不行就换回退值。但标题里这个插件本质是单机方案,做不到服务端比对。那唯一的防线就是回退链。
在GetAndroidphoneId这类项目的常见实现里,回退链一般是:
Settings.Secure.ANDROID_ID(无权限、跨卸载)SystemInfo.deviceUniqueIdentifier(Unity 封装,Android 上本质是 ANDROID_ID + 应用签名)- 首次启动时生成的 GUID,存到
Application.persistentDataPath下(不会因清缓存丢失,但会随卸载清除)
如果你在 Pico4 这类 VR 一体机上做 Unity 开发,也要把它当成一台“有特殊权限的 Android 设备”,它通常运行的是经过厂商定制的 Android 系统,读取方法与手机一致。但注意,部分一体机开放了android.permission.READ_PRIVILEGED_PHONE_STATE给系统应用,而普通 Unity 应用并没有这个权限,所以还是老老实实走 Settings.Secure。
4.3 失败时的观察点与排查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 返回 null | AAR 未打包 | 检查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.Call<AndroidJavaObject>("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 仅是匿名标识,不要拿它跟用户手机号或真实姓名关联,这既尊重用户隐私,也避免上架审核被拒。
本文还有配套的精品资源,点击获取