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、基于隐私考虑,官方不建议获取;6.0之后通过WifiManager 获取不到真正的mac地址,7.0之后访问不了/sys/class/net/wlan0/address;
2、不同的厂商有不同的限制,比如同样是7.0,一加3可以访问,小米6不可以访问。

如今,还是可以从NetworkInterface中获取到MAC的,但说不好后面也不可用了。

public static String getWifiMac() {
    try {
        Enumeration<NetworkInterface> enumeration = NetworkInterface.getNetworkInterfaces();
        if (enumeration == null) {
            return "";
        }
        while (enumeration.hasMoreElements()) {
            NetworkInterface netInterface = enumeration.nextElement();
            if (netInterface.getName().equals("wlan0")) {
                return formatMac(netInterface.getHardwareAddress());
            }
        }
    } catch (Exception e) {
        Log.e("tag", e.getMessage(), e);
    }
    return "";
}

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_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 getWifiMac(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 "";
    }

public static String getWifiMac() {
    try {
        Enumeration<NetworkInterface> enumeration = NetworkInterface.getNetworkInterfaces();
        if (enumeration == null) {
            return "";
        }
        while (enumeration.hasMoreElements()) {
            NetworkInterface netInterface = enumeration.nextElement();
            if (netInterface.getName().equals("wlan0")) {
                return formatMac(netInterface.getHardwareAddress());
            }
        }
    } catch (Exception e) {
        Log.e("tag", e.getMessage(), e);
    }
    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,MAC,Serial Number,AndroidID四个唯一标识中获取选取两个,如果凑不够两个就补UUID;
2、拼接成一个字符串;
3、计算MD5;
4、base64编码。

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

故此,优先选取DeviceID和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平台发生了不少变化。
比如权限,收得越来越紧了,很多原本可以获取到唯一标识都可能取不到了,外部存储也可能访问不到了。
采集多个字段到服务端,然后通过一定的策略去匹配ID,是当前比较可靠的设备识别方案。


如今再回头看,这篇文章已是两年前的了。
世事变幻,沧海桑田,之前的一些认知已不符合当下的情景了。
关于唯一设备ID的方案,可参考笔者最近的文章:https://www.jianshu.com/p/df3f549ddd35

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容