Android设备唯一标识的获取和构造

简介: 设备唯一标识对于app开发是很重要的一个点,主要应用于统计,有时也应用于业务。Android平台提供了很多获取唯一标识的API,但都不是很稳定。一、获取唯一标识Android开发者网站上的一篇文章Identifying App Installations给出了几种获取方式;中文博文也有很多,这是其中一篇 Android获取设备唯一ID的几种方式。

设备唯一标识对于app开发是很重要的一个点,主要应用于统计,有时也应用于业务。
Android平台提供了很多获取唯一标识的API,但都不是很稳定。

一、获取唯一标识

Android开发者网站上的一篇文章Identifying App Installations给出了几种获取方式;
中文博文也有很多,这是其中一篇 Android获取设备唯一ID的几种方式

各类文章都介绍了各种API,这里简单地复述一下:
DeviceId
通过调用TelephonyManager.getDeviceId()获取。
优点:
1、硬件标识,刷机和恢复出厂设置不擦除。
缺点:
1、具有通话功能Android设备才有,平板等设备没有;
2、需要READ_PHONE_STATE权限才能访问,可能涉及隐私问题;
3、有的厂商有BUG,返回错误的数据

MAC地址
一般是指wifi模块或者蓝牙模块的mac地址。
此处分析wifi模块:
优点:
1、硬件标识,刷机和恢复出厂设置不擦除;
2、大多android设备都有wifi模块。
缺点:
1、不稳定,有时候获取不到,有时候获取到了,却是“假的”MAC地址(02:00:00:00:00:00);
2、基于隐私考虑,官方不建议获取;6.0之后通过WifiManager 获取不到真正的mac地址,7.0之后访问不了/sys/class/net/wlan0/address;
3、不同的厂商有不同的限制,比如同样是7.0,一加3可以访问,小米6不可以访问(至少当前是这样的,以后怎么发展就不知道了)。
文章Android MacAddress 适配心得中有描述mac地址获取方法和限制。

Serial Number
设备序列号,通过android.os.Build.SERIAL获得。
也是不稳定的唯一标识,依赖厂商是否提供。

ANDROID_ID
通过Settings.Secure.ANDROID_ID获取,也是不稳定的设备标识。
甚至恢复出厂设置和刷机会重置ANDROID_ID。

二、稳定性和唯一性分析

文章关于设备唯一标识中提到两个概念:ID冲突ID漂移
ID冲突:两台不同的设备获取到相同的设备ID(这个“冲突”类似于hash的碰撞);
ID漂移:指不同的时间获取同一台设备的ID,两次获取不相同(例如刷机后ANDROID_ID会变化)。

也许是开放性和多样性的原因,至今,Android平台没有稳定可靠唯一标识API。
稳定是指尽量避免ID漂移,可靠是指尽量避免ID冲突。

为了解决唯一性问题,自然地想到组合这些唯一标识。
设两个独立的唯一标识AB和另一台设备相同的概率分别为Pa, Pb, 则两者都相同的概率为Pa x Pb;
设一段时间后AB发生变化的概率为Pm,Pn, 则两者至少有一个变化的概率为Pm + Pn + Pm x Pn
假若PaPb, Pm, Pn都很小,那么组合后冲突概率会大幅降低(唯一性提高),漂移概率会小幅提高(稳定性降低);
因为Pa x Pb是指数级变化,Pm + Pn + Pm x Pn几乎是线性级变化(Pm x Pn远小于PmPn)。

很多情况下,设备标识的唯一性要比稳定性更重要,所以稍微牺牲稳定来提高唯一性是合理的;
当然,也不能不加限制地组合,不然唯一性是上去了,但稳定性下来了,超过了容忍的范围,也是不可接受的。

三、具体实现

前面是介绍和分析,下面给出方案:

public class DeviceIdManager {
    private static final String TAG = "DeviceIdManager";

    private static final String INVALID_DEVICE_ID = "000000000000000";

    private static final String INVALID_BLUETOOTH_ADDRESS = "02:00:00:00:00:00";

    private static final String INVALID_ANDROID_ID = "9774d56d682e549c";

    private static volatile String sDeviceDigest;

    public static String getDeviceID() {
        // 双重校验锁
        if (sDeviceDigest == null) {
            synchronized (DeviceIdManager.class){
                if(sDeviceDigest == null){
                    sDeviceDigest = loadDeviceID();
                }
            }
        }

        return sDeviceDigest;
    }

    /**
     * 加载设备ID <br/>
     * 先从应用目录的文件加载,若为空,尝试从SD卡加载;
     * 如果还是为空,则构造一个设备ID,然后写入SD卡;
     * 无论设备ID是从SD卡加载出来还是构造生成,最终都写入应用目录的文件。
     * @return 设备ID
     */
    private static String loadDeviceID(){
        String deviceID = GlobalData.getString(GlobalData.Keys.DEVICE_ID);
        if(TextUtils.isEmpty(deviceID)){
            deviceID = SDCardStorage.readDataFromSDCard(SDCardStorage.DEVICE_ID_FILE_PATH);
            if(TextUtils.isEmpty(deviceID)){
                deviceID = generateDeviceID();
                SDCardStorage.writeDataToSDCard(SDCardStorage.DEVICE_ID_FILE_PATH, deviceID);
            }
            GlobalData.putString(GlobalData.Keys.DEVICE_ID, deviceID);
        }
        return deviceID;
    }

    /**
     * 生成设备ID <br/>
     * 优先根据deviceID,蓝牙地址,SERIAL,AndroidID拼接设备ID;
     * 以上唯一标识,凑够两个即可,如果凑不足,则加上UUID;
     * 拼接之后,计算其MD5, 并用base64编码。
     * @return 设备ID
     */
    private static String generateDeviceID(){
        Context context = BaseApplication.getContext();
        StringBuilder sb = new StringBuilder(32);
        for (int c = 0, i = 0; c < 2 && i < 5; i++) {
            String id = getID(context, i);
            if (!TextUtils.isEmpty(id)) {
                if(c > 0){
                    sb.append('|');
                }
                sb.append(id);
                c++;
            }
        }

        if(sb.length() == 0){
            throw new RuntimeException("can not get device id");
        }

        return DigestUtil.getMD5(sb.toString());
    }

    private static String getID(Context context, int i) {
        switch (i) {
            case 0:
                return getDeviceId(context);
            case 1:
                return getBlueToothAddress(context);
            case 2:
                return getDeviceSerial();
            case 3:
                return getAndroidID(context);
            case 4:
                return getUUID();
            default:
                return "";
        }
    }

    private static String getDeviceId(Context context) {
        if (context != null) {
            try {
                TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
                String deviceId = telephonyManager.getDeviceId();
                if (!TextUtils.isEmpty(deviceId) && !INVALID_DEVICE_ID.equals(deviceId)) {
                    return deviceId;
                }
            } catch (Exception ignore) {
            }
        }
        return "";
    }

    private static String getBlueToothAddress(Context context){
        if (context != null) {
            try {
                String bluetoothAddress = Settings.Secure.getString(context.getContentResolver(), "bluetooth_address");
                if (!TextUtils.isEmpty(bluetoothAddress) && !INVALID_BLUETOOTH_ADDRESS.equals(bluetoothAddress)) {
                    return bluetoothAddress;
                }
            }catch (Exception ignore){
            }
        }
        return "";
    }

    private static String getDeviceSerial() {
        if (!TextUtils.isEmpty(Build.SERIAL) && !Build.UNKNOWN.equals(Build.SERIAL)) {
            return Build.SERIAL;
        }
        return "";
    }

    private static String getAndroidID(Context context) {
        if (context != null) {
            String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
            if (!TextUtils.isEmpty(androidId) && !INVALID_ANDROID_ID.equals(androidId)) {
                return androidId;
            }
        }
        return "";
    }

    private static String getUUID() {
        return UUID.randomUUID().toString();
    }
}

设备ID的存储
为了效率和稳定性起见,需将构造好的设备ID持久化。
Android的持久化存储分为内部存储和外部存储

内部存储的特征:
1、始终可用;
2、只有应用本身可以访问内部存储保存的文件;
3、当用户卸载您的应用时,系统会从内部存储中移除您的应用的所有文件。

外部存储的特征:
1、它并非始终可用,因为用户可采用 USB 存储设备的形式装载外部存储,并在某些情况下会从设备中将其移除;
2、它是全局可读的,保存的文件可能被其他应用读取;
3、当用户卸载应用时,只有将文件保存在 getExternalFilesDir()目录时,系统才会移除该文件。
4、如果不想在卸载应用时被删除,通过Environment.getExternalStorageDirectory()获取即可。

如果存在内部存储中,卸载后就丢失了;
如果存在外部存储中,可能会遇到SD卡移除,文件被删除,被篡改等。

故此,一种方案是:同时保存在内部存储和外部存储(见上述代码loadDeviceID()函数)。

示例代码中,
GlobalData 是我自己写的一个内部存储的类,和SharePreferences类似;相关代码量不少,这里就不贴出来了。
SDCardStorage 用于保存文件到外部存储。重要性比较高的内容保存到外部存储时,最好加密存储;篇幅原因,例子中没有加密存储。

public class SDCardStorage {
    private static final String TAG = "SDCardStorage";

    public final static String SD_DIR = Environment.getExternalStorageDirectory().getAbsolutePath();

    public static final String DEVICE_ID_FILE_PATH = SD_DIR + "/.bx/did.dt";

    public static void writeDataToSDCard(String path, String value) {
        try {
            if (isSdCardAvailable()) {
                File file = new File(path);
                if (FileUtil.existFile(file)) {
                    FileUtil.stringToFile(file, value);
                }
            }
        } catch (Exception e) {
            LogUtil.error(TAG, e);
        }
    }

    public static String readDataFromSDCard(String path) {
        try {
            if (isSdCardAvailable()) {
                File file = new File(path);
                if (FileUtil.existFile(file)) {
                    return FileUtil.fileToString(file);
                }
            }
        } catch (Exception e) {
            LogUtil.error(TAG, e);
        }
        return "";
    }

    public static boolean isSdCardAvailable() {
        String state = Environment.getExternalStorageState();
        return (!TextUtils.isEmpty(state) && state.equals("mounted") && Environment.getExternalStorageDirectory() != null);
    }
}

构造设备唯一标识
1、从DeviceID,蓝牙地址,Serial Number,AndroidID四个唯一标识中获取选取两个,如果凑不够两个就补UUID;
2、拼接成一个字符串;
3、计算MD5;
4、base64编码。

第一节分析各个唯一标识的局限性,第二节分析了提高设备ID唯一性的策略,据此,本方案采用拼接唯一标识的来构造设备唯一标识。
候选项中,UUID的唯一性最高,为什么不首选UUID呢?UUID稳定性最低(每次调用返回都不一样)。
万一前四个候选项凑不够两个,就得拼接UUID了,这时候只能靠持久化来维持稳定性了;
最好的情况是,这个用户一直不刷机不恢复出厂设置,外部存储也不出什么问题直到这台设备报废~

故此,优先选取DeviceID和蓝牙地址, 因为这两个是硬件标识,不会随着刷机和恢复出厂设置而变化;
wifi的mac地址不稳定,官方也不推荐获取,所以没列入候选项。

之所以计算MD5,是基于两个考虑: 隐私;形式统一。
MD5计算出来是16字节(128bit)的数组,为了方便传输,存储和阅读,需转成字符串;
字节数组转字符串,一般用base64或者转十六进制,用base64编码相对节约长度。

下面给出计算摘要的相关代码:

public class DigestUtil {

    @StringDef({MD5, SHA1, SHA256})
    @Retention(RetentionPolicy.SOURCE)
    public @interface Algorithm {
    }

    public static final String MD5 = "MD5";
    public static final String SHA1 = "SHA-1";
    public static final String SHA256 = "SHA-256";

    public static byte[] getDigest(String text, @Algorithm String algorithm) {
        try {
            MessageDigest md = MessageDigest.getInstance(algorithm);
            md.update(text.getBytes("UTF-8"));
            byte[] bytes = md.digest();
            return bytes;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static String getMD5(String str) {
        // 为了方便存储和http传输,encode特性用 Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE
        return new String(Base64.encode(getDigest(str, MD5), Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE));
    }
}

最后要提醒的一点是,前面讨论的是设备唯一标识的唯一性和稳定性,没有提到通用性:
这套方案用于APP开发者自身的统计和业务是没有问题的,但有时候需要和合作方对统计数据(例如广告点击),
就需要双方约定设备ID(通常是DeviceID的MD5)。
当然,如果有这方面的需求,用这套方案的同时,也采集一份DeviceID的MD5就是了。


以上是一年前想到的方案,一年之间,Android平台发生了不少变化。
比如权限,收得越来越紧了,除了Android_ID, 其他的唯一标识都可能取不到了,外部存储也可能访问不到了。
采集多个字段到服务端,然后通过一定的策略去匹配ID(比如相似度比较),是当前比较可靠的设备识别方案。

相关文章
|
4月前
|
Android开发 iOS开发 UED
探索未来:Android与iOS在智能穿戴设备上的较量
随着科技的飞速进步,智能穿戴设备已经成为我们日常生活中不可或缺的一部分。本文将深入探讨两大操作系统——Android和iOS——在智能穿戴领域的竞争与发展,分析它们各自的优势与挑战,并预测未来的发展趋势。通过比较两者在设计哲学、生态系统、用户体验及创新技术的应用等方面的差异,揭示这场较量对消费者选择和市场格局的影响。 【7月更文挑战第31天】
51 0
|
3月前
|
Shell Linux 开发工具
"开发者的救星:揭秘如何用adb神器征服Android设备,开启高效调试之旅!"
【8月更文挑战第20天】Android Debug Bridge (adb) 是 Android 开发者必备工具,用于实现计算机与 Android 设备间通讯,执行调试及命令操作。adb 提供了丰富的命令行接口,覆盖从基础设备管理到复杂系统操作的需求。本文详细介绍 adb 的安装配置流程,并列举实用命令示例,包括设备连接管理、应用安装调试、文件系统访问等基础功能,以及端口转发、日志查看等高级技巧。此外,还提供了常见问题的故障排除指南,帮助开发者快速解决问题。掌握 adb 将极大提升 Android 开发效率,助力项目顺利推进。
85 0
|
3月前
|
Android开发
基于Amlogic 安卓9.0, 驱动简说(四):Platform平台驱动,驱动与设备的分离
本文介绍了如何在基于Amlogic T972的Android 9.0系统上使用Platform平台驱动框架和设备树(DTS),实现设备与驱动的分离,并通过静态枚举在设备树中描述设备,自动触发驱动程序的加载和设备创建。
49 0
基于Amlogic 安卓9.0, 驱动简说(四):Platform平台驱动,驱动与设备的分离
|
3月前
|
Android开发 C语言
基于Amlogic 安卓9.0, 驱动简说(二):字符设备驱动,自动创建设备
这篇文章是关于如何在基于Amlogic T972的Android 9.0系统上,通过自动分配设备号和自动创建设备节点文件的方式,开发字符设备驱动程序的教程。
53 0
基于Amlogic 安卓9.0, 驱动简说(二):字符设备驱动,自动创建设备
|
3月前
|
自然语言处理 Shell Linux
基于Amlogic 安卓9.0, 驱动简说(一):字符设备驱动,手动创建设备
本文是关于在Amlogic安卓9.0平台上创建字符设备驱动的教程,详细介绍了驱动程序的编写、编译、部署和测试过程,并提供了完整的源码和应用层调用示例。
75 0
基于Amlogic 安卓9.0, 驱动简说(一):字符设备驱动,手动创建设备
|
3月前
|
传感器 Android开发 芯片
不写一行代码(三):实现安卓基于i2c bus的Slaver设备驱动
本文是系列文章的第三篇,展示了如何在Android系统中利用现有的i2c bus驱动,通过编写设备树节点和应用层的控制代码,实现对基于i2c bus的Slaver设备(如六轴陀螺仪模块QMI8658C)的控制,而无需编写设备驱动代码。
47 0
不写一行代码(三):实现安卓基于i2c bus的Slaver设备驱动
|
3月前
|
Android开发
不写一行代码(二):实现安卓基于PWM的LED设备驱动
本文介绍了在Android系统中不编写任何代码,通过设备树配置和内核支持的通用PWM LED驱动来实现基于PWM的LED设备驱动,并通过测试命令调整LED亮度级别。
43 0
不写一行代码(二):实现安卓基于PWM的LED设备驱动
|
3月前
|
Linux Android开发 C语言
不写一行代码(一):实现安卓基于GPIO的LED设备驱动
本文通过实践操作,展示了在Android系统中不编写任何代码,利用设备树(DTS)配置和内核支持的通用GPIO LED驱动来控制LED设备,并进一步通过C语言编写NDK测试APP来实现LED的闪烁效果。
136 0
不写一行代码(一):实现安卓基于GPIO的LED设备驱动
|
3月前
|
Java 测试技术 Android开发
Android项目架构设计问题之构造一个Android中的线程池如何解决
Android项目架构设计问题之构造一个Android中的线程池如何解决
28 0
|
3月前
|
存储 Ubuntu API
如何使用Python创建服务器向Android设备发送GCM推送通知
如何使用Python创建服务器向Android设备发送GCM推送通知
27 0