设备唯一标识对于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 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冲突。
为了解决唯一性问题,自然地想到组合这些唯一标识。
设两个独立的唯一标识A和B和另一台设备相同的概率分别为Pa, Pb, 则两者都相同的概率为Pa x Pb;
设一段时间后A和B发生变化的概率为Pm,Pn, 则两者至少有一个变化的概率为Pm + Pn + Pm x Pn。
假若Pa,Pb, Pm, Pn都很小,那么组合后冲突概率会大幅降低(唯一性提高),漂移概率会小幅提高(稳定性降低);
因为Pa x Pb是指数级变化,Pm + Pn + Pm x Pn几乎是线性级变化(Pm x Pn远小于Pm和Pn)。
很多情况下,设备标识的唯一性要比稳定性更重要,所以稍微牺牲稳定来提高唯一性是合理的;
当然,也不能不加限制地组合,不然唯一性是上去了,但稳定性下来了,超过了容忍的范围,也是不可接受的。
三、具体实现
前面是介绍和分析,下面给出方案:
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
* 先从应用目录的文件加载,若为空,尝试从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
* 优先根据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 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