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

设备唯一标识对于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(比如相似度比较),是当前比较可靠的设备识别方案。

上一篇:Kotlin委托属性-简化数据访问


下一篇:漫谈散列函数