在上一篇谷歌博客 (识别应用安装) 中,谷歌介绍了Android 中一些常用的标识符,并提出了合理识别应用每次安装的办法。指出通过获取设备可靠,唯一,稳定标识符来追踪设备可能产生的错误,并简单介绍了 Android 中一些设备标识符可能存在的问题。今天,我会介绍一下 Android 中的一些标识符以及如何获取它们,以及获取这些标识符过程中可能存在的坑。

标识符(identifier)

设备ID(DeviceId)

  • 获取办法

    1
    2
    android.telephony.TelephonyManager tm = (android.telephony.TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
    String deviceId = tm.getDeviceId();
  • 当设备为手机时,返回设备的唯一ID。手机制式为 GSM 时,返回手机的 IMEI 。手机制式为 CDMA 时,返回手机的 MEID 或 ESN 。

  • 非电话设备或者 Device ID 不可用时,返回 null .
  • 属于比较稳定的设备标识符。
  • 需要 READ_PHONE_STATE 权限。 (Android 6.0 以上需要用户手动赋予该权限)。
  • 某些设备上该方法存在 Bug ,返回的结果可能是一串0或者一串*号。

Sim 序列号(Sim Serial Number)

  • 获取办法:

    1
    2
    android.telephony.TelephonyManager tm = (android.telephony.TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
    String simSerialNum = tm.getSimSerialNumber();
  • 不同 Sim 卡的序列号不同.

  • Sim 卡序列号,当手机上装有 Sim 卡并且可用时,返回该值。手机未装 Sim 卡或者不可用时,返回 null.
  • 需要 READ_PHONE_STATE 权限。 (Android 6.0 以上需要用户手动赋予该权限)

Mac 地址(Mac Address)

  • 获取办法:

    1
    2
    android.net.wifi.WifiManager wifi = (android.net.wifi.WifiManager) context.getSystemService(Context.WIFI_SERVICE);
    String macAddress = wifi.getConnectionInfo().getMacAddress();
  • 没有 WiFi 硬件或者 WiFi 不可用的设备可能返回 null 或空,注意判空.

  • 比较稳定的硬件标识符。
  • 需要 ACCESS_WIFI_STATE 权限。
  • Android 6.0开始,谷歌为保护用户数据,用此方法获取到的 Wi-Fi mac 地址都为02:00:00:00:00:00更多信息查看此处
  • 如果 app 在装有谷歌框架的设备中读取了mac地址,会被谷歌检测为有害应用提示用户卸载。这也是为什么像友盟、TalkingData 等数据统计 sdk 提供商专门针对 Google Play 提供特供版的 sdk.

读取 mac 地址导致 app 被谷歌框架判定为有害应用

设备序列号(Serial Number, SN)

  • 获取办法:

    1
    String serialNum = android.os.Build.SERIAL;
  • 比较稳定的设备硬件标识符,在上一篇文章中谷歌也未提到有啥缺点。

ANDROID_ID

  • 获取办法:

    1
    String androidId = android.provider.Settings.Secure.getString(context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID);
  • 在设备第一次启动的时候生成并保存,并且可能会在恢复出厂设置后重置该值。理论上是大部分是重置的。(API 中原话是:The value may change if a factory reset is performed on the device.)

  • 在 Android 2.2 中不可靠.
  • 部分设备由于制造商错误实现,导致会返回相同的 Android_ID.
  • 在 Android 4.2 及以上, 设备启用多用户功能后,每个用户的 Android_ID 不相同.

制造商 (Manufacturer)

  • 获取办法:
    1
    String manufacturer = android.os.Build.MANUFACTURER;

型号(Model)

  • 获取办法:
    1
    String model = android.os.Build.MODEL;

品牌(Brand)

  • 获取办法:
    1
    String brand = android.os.Build.BRAND;

设备名 (Device)

  • 获取办法:
    1
    String device = android.os.Build.DEVICE;

以下是我的一台 Nexus 4 所获取的全部值:
以下的值仅作为举例,并非真实

1
2
3
4
5
6
7
8
9
Identifier_Device_ID:    355136021808056
Identifier_Mac_Address: 10:68:3f:81:ed:ff
Identifier_Android_ID:    6ae48d23d1887323
Identifier_Serial_Num:    01b4549262d6a4a2
Identifier_Sim_SN:    898600e6111551111111
Identifier_Manufacturer: LGE
Identifier_Model:    Nexus 4
Identifier_Brand:    google
Identifier_Device:    mako

如何合理使用标识符跟踪设备

介绍完了一些常见的、可能的作为标识符的值,现在来谈谈如何合理地使用这些标识符跟踪设备。

首先,我们先要弄清自己跟踪设备的具体需求。目前看来,需求无非两种:

  1. 跟踪用户设备使用周期层次上的设备。
    意思是将每次用户的擦除设备、恢复出厂设置动作后将设备视为一台新的设备。
  2. 跟踪硬件层次上的设备。
    意思是无论设备擦除数据或者恢复出厂设置后都需要将该设备视为同一台设备。

跟踪用户使用层次上的设备

方案 1:
这个层次上的设备跟踪,我比较推荐使用谷歌官方推荐的办法来跟踪, App 首次启动时生成一个 Random UUID 并保存在本地存储,以后每次启动时检查该 UUID 文件。具体可以查看我的上一篇翻译文章)中,谷歌介绍了)),其中有具体的代码实现。

方案 2:
如果你不喜欢谷歌推荐的这种方式,或者觉得这种方式涉及到文件读写太过繁琐等。我们也可以通过以上介绍的这些标识符来跟踪设备。因为需要将设备擦除数据或恢复出厂设置后将其视为一台新的设备,所以需要使用一些与当前用户设备使用周期有关的值。

理论上,Android_ID 这一个值就已经足够我们实现这样的需求,不过正是因为 Android_ID 存在缺陷,所以我们无法直接拿来识别设备。这里我们使用多个值拼凑来规避这些缺点。
与用户设备使用周期有关的标识符我推荐使用Android_ID和Sim Serial Number。另外可以加上Device_ID,通过 UUID 或者 MD5 等来计算生成设备的标识符。

以下是一个简单的实现,参考了 Stack Overflow 上的这个问题下面的回答

  • UUID 实现:
    1
    2
    UUID deviceUuid = new UUID(androidId.hashCode(), ((long)deviceId.hashCode() << 32) | simSerialNum.hashCode());
    String deviceId = deviceUuid.toString();

结果类似:00000000-54b3-e7c7-0000-000046bffd97

  • 或者你也可以使用 MD5 实现(MD5 算法见下文):
    1
    String md5ID = md5(androidId + deviceId + simSerialNum);

结果类似:f87b20b3c359c4af608b3eb26b26a1b8

跟踪硬件层次上的设备

跟踪硬件层次上的设备建议使用硬件的标识符,比如设备ID(DeviceId)、Mac 地址、设备序列号(SN)或者设备的品牌,型号名等,这些值在用户擦除数据或者恢复出厂设置后也不会改变。同样的,为了提升稳定性及排除单一标识符所存在的缺陷,我们使用多个标识符拼接,然后通过 UUID 或者 MD5 算法计算得出我们需要的设备标识符。

以下是一个简单的实现,使用了设备序列号(SN)、设备ID(DeviceId)和 Mac 地址。

拼接后的字符串类似于:01b4549262d6a4a235513602180805610:68:3f:81:ed:ff

同时为了不暴露用户的设备具体信息,这里我们同样采用 MD5 对拼接后的字符串进行Hash操作:

1
String md5ID = md5("01b4549262d6a4a235513602180805610:68:3f:81:ed:ff");

拼凑的标识符选择,拼接的顺序,MD5或者UUID的选择并无绝对,重要的是思想。

其他

  • 以上这些值在使用前都建议判空。
  • 因为硬件缺失或者不可用,获取标识符过程中也可能返回 null 对象。为了避免 NullPointerException,建议获取标识符操作全部在 try…catch 中操作。
  • 安卓设备的用户不乏极客,修改 Android_ID 或者 Build 文件对他们来说并非难题。所以一定程度上说,没有绝对准确的跟踪设备的标识符。

*以下是一个Demo,项目建立后添加权限后即可使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
public class MainActivity extends AppCompatActivity {
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        String deviceId = "";
        String macAddress = "";
        String androidId = "";
        String serialNum = "";
        String simSerialNum = "";
 
        //需要READ_PHONE_STATE权限
        android.telephony.TelephonyManager tm = (android.telephony.TelephonyManager) this
                .getSystemService(Context.TELEPHONY_SERVICE);
        if(checkPermission(this, Manifest.permission.READ_PHONE_STATE)){
            deviceId = tm.getDeviceId();
            simSerialNum = tm.getSimSerialNumber();
        }
 
        //需要ACCESS_WIFI_STATE权限
        android.net.wifi.WifiManager wifi = (android.net.wifi.WifiManager) this
                .getSystemService(Context.WIFI_SERVICE);
        macAddress = wifi.getConnectionInfo().getMacAddress();
 
        androidId = android.provider.Settings.Secure.getString(this.getContentResolver(),
                android.provider.Settings.Secure.ANDROID_ID);
 
        serialNum = Build.SERIAL;
 
        String deviceManufacturer = Build.MANUFACTURER;
        String deviceModel = Build.MODEL;
        String deviceBrand = Build.BRAND;
        String device = Build.DEVICE;
 
        //==============
        Log.e("Identifier_Device_ID", validate(deviceId));
        Log.e("Identifier_Mac_Address", validate(macAddress));
        Log.e("Identifier_Android_ID", validate(androidId));
        Log.e("Identifier_Serial_Num", validate(serialNum));
        Log.e("Identifier_Sim_SN", validate(simSerialNum));
 
        Log.e("Identifier_Manufacturer", validate(deviceManufacturer));
        Log.e("Identifier_Model", validate(deviceModel));
        Log.e("Identifier_Brand", validate(deviceBrand));
        Log.e("Identifier_Device", validate(device));
 
        UUID deviceUserLifetimeUUID = new UUID(validate(androidId).hashCode(), ((long)validate(deviceId).hashCode() << 32) | validate(simSerialNum).hashCode());
        String deviceUserLifetimeId = deviceUserLifetimeUUID.toString();
 
        String deviceHardwareId = md5(validate(serialNum)  + validate(deviceId) + validate(macAddress));;
 
        Log.e("deviceUserLifetimeId", deviceUserLifetimeId);
        Log.e("deviceHardwareId", deviceHardwareId);
    }
 
    // MD5加密,32位小写
    public static String md5(String str) {
        MessageDigest md5 = null;
        try {
            md5 = MessageDigest.getInstance("MD5");
        } catch (Exception e) {
            e.printStackTrace();
            return "";
        }
        md5.update(str.getBytes());
        byte[] md5Bytes = md5.digest();
        StringBuilder hexValue = new StringBuilder();
        for (int i = 0; i < md5Bytes.length; i++) {
            int val = ((int) md5Bytes[i]) & 0xff;
            if (val < 16) {
                hexValue.append("0");
            }
            hexValue.append(Integer.toHexString(val));
        }
        return hexValue.toString();
    }
 
    //检查权限,READ_PHONE_STATE在API>=23需要用户手动赋予权限
    public static boolean checkPermission(Context context, String permission) {
        boolean result = false;
        if (Build.VERSION.SDK_INT >= 23) {
            if (context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) {
                result = true;
            }
        } else {
            PackageManager pm = context.getPackageManager();
            if (pm.checkPermission(permission, context.getPackageName()) == PackageManager.PERMISSION_GRANTED) {
                result = true;
            }
        }
        return result;
    }
 
    //判空
    private String validate(String value) {
        if(value == null) {
            return "";
        }
        return value;
    }
}