Android 10获取设备标识方案探究

前言
我们在开发中可能会使用到一些第三方的应用统计SDK,用于统计应用的用户量等等,如何区分每个用户呢?当然就需要每个设备对应一个唯一的标识,Android中当然也提供了这样的API来获取到设备相关标识,但遗憾的是随着Android版本的迭代,官方对于用户隐私的权限越来越严格,在最新的Android 10版本中甚至已经无法通过原来的一些API来获取到设备相关标识了。本文就来探究一下Android中的各种设备相关标识符,介绍几种在Android 10限制下获取设备相关标识的方案。

1.Android中的几个设备相关标识

  • IMEI

IMEI(International Mobile Equipment Identity)是国际移动设备识别码的缩写,由15-17位数字组成,与手机是一一对应的关系,该码是全球唯一的,并且永远不会改变。
在Android 8.0(API Level 26)以下,可以通过TelephonyManagergetDeviceId()方法获取到设备的IMEI码(其实这里的说法不准确,该方法是会根据手机设备的制式(GSM或CDMA)返回相应的设备码(IMEI、MEID和ESN)),该方法在Android 8.0及之后的版本已经被废弃了,取而代之的是getImei()方法。获取设备IMEI码的示例代码如下:

private String getIMEI(Context context) {
    TelephonyManager tm = (TelephonyManager) context.getSystemService(Service.TELEPHONY_SERVICE);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        return tm.getImei();
    } else {
        return tm.getDeviceId();
    }
}

无论是getDeviceId()方法还是getImei()方法都可以传入一个参数slotIndex,用于设备中插入了双卡的情况,这里就不展示了。
IMEI码的获取方式很简单,也能保证唯一性和不变性,目前很多应用都使用IMEI码作为设备的唯一标识,但众所周知,在Android 6.0以上获取IMEI码是需要动态申请READ_PHONE_STATE权限的,一旦用户拒绝了该权限就获取不到了。这还不是最要命的,在Android 10中官方已经明确说明第三方应用无法获取到IMEI码,详细内容可以查看Android 10 中的隐私权变更,这里附上一张图。

Android 10 IMEI限制.png

下面我们分几种情况来验证一下IMEI码的获取情况:

  • Android 6.0以下:无需申请权限,可以通过getDeviceId()方法获取到IMEI码
  • Android 6.0-Android 8.0:需要申请READ_PHONE_STATE权限,可以通过getDeviceId()方法获取到IMEI码,如果用户拒绝了权限,会抛出java.lang.SecurityException异常
  • Android 8.0-Android 10:需要申请READ_PHONE_STATE权限,可以通过getImei()方法获取到IMEI码,如果用户拒绝了权限,会抛出java.lang.SecurityException异常
  • Android 10及以上:分为以下两种情况:
    • targetSdkVersion<29:没有申请权限的情况,通过getImei()方法获取IMEI码时抛出java.lang.SecurityException异常;申请了权限,通过getImei()方法获取到IMEI码为null
    • targetSdkVersion=29:无论是否申请了权限,通过getImei()方法获取IMEI码时都会直接抛出java.lang.SecurityException异常

不难看出,IMEI码在Android 10之后已经无法获取到了,而且甚至会直接抛出异常导致程序崩溃,在Android 10以下版本虽然可以获取到IMEI码,但是需要在应用获取到了READ_PHONE_STATE权限的前提下,我们依然无法保证这一点。

  • 设备序列号

设备序列号是手机生产厂商提供的,如果拼接上厂商名称(Build.MANUFACTURER)基本上可以保证唯一性。在Android 8.0以下版本,可以通过android.os.Build.SERIAL获取到设备序列号,同样的,这种方式在Android 8.0及以上版本被废弃了,通过Build.SERIAL在Android 8.0及以上设备获取到设备的序列号始终为“unknown”,取而代之的是使用android.os.Build.getSerial()方法。获取设备序列号的示例代码如下:

private String getSerial() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        return Build.getSerial();
    } else {
        return Build.SERIAL;
    }
}

getImei()方法的弊端相同,Build.getSerial()方法在Android 6.0及以上版本是需要动态申请READ_PHONE_STATE权限的,并且该方法在Android 10上同样无法获取到设备序列号。
我们同样来看一下几种情况下获取设备序列号的情况:

  • Android 8.0以下:无需申请权限,可以通过Build.SERIAL获取到设备序列号
  • Android 8.0-Android 10:需要申请READ_PHONE_STATE权限,可以通过Build.getSerial()获取到设备序列号,如果用户拒绝了权限,会抛出java.lang.SecurityException异常
  • Android 10及以上:分为以下两种情况:
    • targetSdkVersion<29:没有申请权限的情况,调用Build.getSerial()方法时抛出java.lang.SecurityException异常;申请了权限,通过Build.getSerial()方法获取到的设备序列号为“unknown”
    • targetSdkVersion=29:无论是否申请了权限,调用Build.getSerial()方法时都会直接抛出java.lang.SecurityException异常

可以看出,和IMEI码一样,官方同样限制了设备序列号的获取。此外,由于序列号是手机生产厂商提供的,无法保证各个厂商的规范性,甚至有些厂商的手机获取不到设备序列号。

  • MAC地址

MAC地址(Media Access Control Address),直译为媒体存取控制位址,也称为局域网地址、以太网地址或物理地址,由48位二进制数组成。与我们熟悉的IP地址不同,mac地址只由设备的网卡决定,每个网卡都会有一个唯一的mac地址,只要不更换设备的网卡,mac地址就不会变,因此mac地址符合我们对于设备标识的要求。
在Android 6.0以下版本可以通过下面的代码获取到设备的mac地址:

private String getMacAddress(Context context) {
    WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
    return wm.getConnectionInfo().getMacAddress();
}

通过该方法获取mac地址需要声明ACCESS_WIFI_STATE权限,并且设备需要开启wifi。但是从Android 6.0开始,使用该方法获取到的mac地址都为02:00:00:00:00:00。替代方案是通过读取系统文件/sys/class/net/wlan0/address来获取mac地址,示例代码如下:

private String getMacAddress() {
    return new BufferedReader(new FileReader(new File("/sys/class/net/wlan0/address"))).readLine();
}

不幸的是,该方法在Android 7.0开始也行不通了,执行上面的代码会抛出java.io.FileNotFoundException: /sys/class/net/wlan0/address (Permission denied)异常,也就是说我们没有权限读取该文件。但好在目前还是有获取mac地址的方法的,即通过扫描所有的网络接口,示例代码如下:

private String getMacAddress() {
    try {
        List<NetworkInterface> all = Collections.list(NetworkInterface.getNetworkInterfaces());
        for (NetworkInterface nif : all) {
            if (!nif.getName().equalsIgnoreCase("wlan0")) {
                continue;
            }
            byte[] macBytes = nif.getHardwareAddress();
            if (macBytes == null) {
                return "";
            }
            StringBuilder res1 = new StringBuilder();
            for (byte b : macBytes) {
                res1.append(String.format("%02X:", b));
            }
            if (res1.length() > 0) {
                res1.deleteCharAt(res1.length() - 1);
            }
            return res1.toString();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}

目前我在Android 10的真机和模拟器上测试了该方法,都能获取到mac地址,甚至都不需要联网,而且每次获取到mac地址都是一样的。可以看出,mac地址的获取相对来说是最麻烦的一个,但好在目前还是能获取到的,因此我们可以考虑使用mac地址来作为设备标识。
后来很多同学提出了Android 10 mac地址随机化的问题,每次连接wifi网络获取到的MAC地址都是随机的,因此不能使用mac地址作为设备的唯一标识。其实我此前在官网上也看到过随机分配 MAC 地址这个特性,但是我自己测试的情况确实每次获取到的mac地址都是固定的,起初还是很疑惑的,通过查找资料和咨询其他大佬才知道mac地址随机化这个特性并不是所有Android 10的手机都支持的,目前大部分手机还不支持这个特性,因此获取到的mac地址就是固定的。如何判断手机是否支持mac地址随机化呢,我们可以打开手机的开发者选项,如果有看到“连接时随机选择MAC网址”这个选项,就说明手机是支持这个特性的,当开启了这个选项后,每次切换wifi网络获取到的mac地址就是随机的了。
总结一下,目前支持mac地址随机化的手机还比较少,因此我们还是可以考虑使用mac地址作为设备标识的,但是随着各大厂商手机的更新换代,当市面上大部分手机都支持了这一特性后,这种方案就不太可行了。

  • ANDROID_ID

ANDROID_ID是设备的系统首次启动时随机生成的一串字符,由16个16进制数(64位)组成,基本上还是可以保证唯一性的,获取ANDROID_ID的示例代码如下:

String androidId = Settings.System.getString(getContentResolver(), Settings.Secure.ANDROID_ID);

相比于上面几种设备相关标识,ANDROID_ID的获取门槛是最低的,不需要任何权限,但哪里有十全十美的事,ANDROID_ID也存在一些缺点,就是无法保证稳定性,root、刷机或恢复出厂设置都会导致设备的ANDROID_ID发生改变。此外,我看到部分文章中有提到某些厂商定制系统的Bug会导致不同的设备可能会产生相同的ANDROID_ID,而且某些设备获取到的ANDROID_ID为null。总体来说,相比于其他几种设备标识或多或少都有被官方“照顾”过,ANDROID_ID还是比较稳定的,如果应用对于设备标识的要求不是特别高的话还是一个值得考虑的方案。

2.Android 10中获取设备相关标识的方案

根据上文对几个常见设备标识的分析我们可以看出列出的几种设备标识都有或多或少的缺陷,那么针对Android 10的限制,我们应该如何获取到稳定的设备标识呢?关于这个问题我查阅一些相关文章,大体总结出了以下几种方案:

  • 方案一、使用ANDROID_ID

ANDROID_ID的获取不需要任何权限,并且可以很好地保证唯一性,缺点就是无法保证稳定性,即一些操作可能导致ANDROID_ID的改变。

  • 方案二、使用mac地址

目前来说mac地址仍然是可以获取到的,也能很好地保证唯一性和稳定性,缺点是不能保证以后官方是否会限制mac地址的获取,并且随着各大厂商手机的更新,启用mac地址随机化的手机会越来越多,mac地址就无法再作为设备标识来使用了。

  • 方案三、自定义一个生成规则

我们同样可以自定义一个设备标识的生成规则,在应用首次安装后将生成的标识保存到本地。生成的规则其实有很多种,最简单的是直接使用UUID或GUID,复杂一些的可以在此基础上拼接上设备生产厂商的信息。我这里就不具体介绍各种生成方案了,感兴趣的话可以查找一下相关文章。

  • 方案四、使用移动安全联盟(MSA)提出的补充设备标识

这其实是我主要想介绍的一个方案,是由移动安全联盟提出的,包含以下三个标识:

名称 说明
OAID 匿名设备标识符,最长64为,所有应用都获取到同一个ID,但是用户可关闭、可重置
AAID 应用匿名设备标识符,最长64为,每个应用获取到各自的ID
VAID 开发者匿名设备标识符,最长64为,同一开发者不同应用获取到的一致

目前文档中给出的覆盖设备范围如下:

补充设备表示覆盖范围

可以看出目前主流的厂商都做出了相应的适配,这后三个厂商是啥情况。。。不过我看SDK更新的信息中有提到这三个厂商的支持。

具体集成步骤和获取方法我这里就不介绍了,官方提供了详细的文档,可以到官网下载,我也已经相关文件上传到了github,附上地址
当然这种方案的覆盖机型也不是100%的,SDK提供的API可以判断设备是否支持获取补充设备标识,对于不支持的设备我们依然可以选择使用此前介绍过的几种设备标识。
最后说一下我个人的方案吧,其实针对那些对设备标识要求不高的应用来说,使用ANDROID_ID是最好也是最简单的方案了,如果应用对设备标识的要求比较高,可以尝试使用MSA提出的补充设备标识(如OAID),该方案对于国产手机厂商的支持还是比较好的,后续的适配也还在进行,首先要判断一下设备是否支持获取补充设备标识,支持的话就直接使用,不支持的话仍然可以使用ANDROID_ID或者mac地址等设备标识,如果说觉得同时综合几种标识会导致格式(位数)不统一,可以在此基础上进行一个统一的处理,比如MD5加密等等,最后获取到的就是一个格式统一的设备标识码了。当然上面这种想法只是我个人的见解,还有很多方案可选择,但是最终目标都是一致的,就是尽量多地适配各种设备并且保证标识的唯一性和稳定性,如果大家觉得不妥或是有更好的方案欢迎提出,一起交流。

3.总结

Android的碎片化一直都很让开发者头痛,目前国内更是各大厂商“百花齐放”,在适配方面我们往往需要根据厂商的不同进行各自的处理,解决方案就是需要针对各大厂商的差异性提出一个统一的适配方案,就像文中介绍的补充设备标识以及统一推送联盟这样。随着Android版本的迭代,官方对于设置隐私的限制越来也高,我们很难找到一个稳定获取设备标识的方案,不过我相信在未来随着补充设备标识SDK版本的更新,适配性会越来越好。
相关代码我已经上传到了github,可以进行参考。

4.参考文章

漫谈唯一设备ID
谈谈 Android 中的各种设备标识符
Android 10 中的隐私权变更

推荐阅读更多精彩内容