背景介绍
应用开发中不管是埋点统计还是推送通知,都会用到唯一识别标识,在Android中设备唯一码有很多,如:MAC地址、IMEI号(DeviceId)、IMSI号、ANDROID_ID、序列号(SerialNumber)等,但并不是所有设备上都能稳定获取到这些值。
在10.0以前这些值还能获取到,Mac地址6.0之后通过api是获取不到的,通过扫描硬件端口还能获取,但是10.0之后,这些唯一识别标识都被Android官方禁用了,Mac地址会返回一个虚假的值。
谷歌在禁用上文所述的唯一识别码后,还给了一个唯一识别码的最佳做法,原文链接:https://developer.android.com/training/articles/user-data-ids。
使用 Android 标识符的最佳做法
在使用 Android 标识符时,请遵循以下最佳做法:
-
避免使用硬件标识符。在大多数用例中,您可以避免使用硬件标识符,例如 SSAID (Android ID),而不会限制所需的功能。
Android 10(API 级别 29)对不可重置的标识符(包括 IMEI 和序列号)添加了限制。您的应用必须是设备或个人资料所有者应用,具有特殊运营商权限或具有
READ_PRIVILEGED_PHONE_STATE
特许权限,才能访问这些标识符。 -
只针对用户剖析或广告用例使用广告 ID。在使用广告 ID 时,请始终遵循用户关于广告跟踪的选择。此外,请确保标识符无法关联到个人身份信息 (PII),并避免桥接广告 ID 重置。
-
尽一切可能针对防欺诈支付和电话以外的所有其他用例使用实例 ID 或私密存储的 GUID。对于绝大多数非广告用例,使用实例 ID 或 GUID 应已足够。
-
使用适合您的用例的 API 以尽量降低隐私权风险。使用 DRM API 保护重要内容,并使用 SafetyNet API 防止滥用行为。SafetyNet API 是能够确定设备真伪而不会招致隐私权风险的最简单方法。
原文中推荐使用
-
使用广告 ID
-
使用实例 ID 和 GUID
广告ID暂不考虑,这里其实主要是使用GUID,什么是GUID呢?
其实GUID就是JDK中的UUID,使用代码如下:
String uniqueID = UUID.randomUUID().toString();
唯一识别标识存储设计
既然官方推荐使用JDK的UUID来做唯一识别码,剩下的工作就是如何存储这个UUID了。我的设计是使用SharedPreferences
结合本地外部存储文件存储UUID
。
SharedPreferences
的作用是当应用没有被卸载的时候,如果外部存储中的文件被删除了,启动应用的时候会创建新文件,并将SharedPreferences
中的UUID写入文件;
外部存储的UUID文件首先设置成隐藏文件,存储在外部存储中,应用卸载后不会被删除,作用是当应用卸载后再次安装的时候,会去找这个隐藏文件,如果存在这个文件,就把UUID读出来,然后赋值给SharedPreferences
;
最后还要判断文件中的UUID是否被篡改了,若被篡改了就以SharedPreferences
为主,并将SharedPreferences
的UUID同步更新到文件中。
本来还想使用联系人的系统数据库进行存储,能够兼容7.0一下的设备,这一块如果需要可以自己实现。从SharedPreferences
、本地文件、系统数据库三方面考虑,保证UUID不被破坏。
源码如下:
public class GuidUtil {
private static final String GUID_KEY = "guid";
private static final String uuidFileName = ".app-guid";
private static final String filePath = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + uuidFileName;
public static String createGUID(Context context) throws Exception {
// 读取顺序,sp file
String guid = getFromSP(context);
if (guid != null) {
return guid;
}
guid = getFromFile();
if (guid != null) {
// 如果能够从文件中获取,表示应用卸载了,需要重新给SP赋值
setToSP(context, guid);
return guid;
}
// 前面三个都没有数据,表示第一次安装,需要生成一个UUID
guid = UUID.randomUUID().toString().replace("-", "");
// 将UUID保存到SP、外部存储目录
setToSP(context, guid);
setToFile(guid);
return guid;
}
/**
* 从SharedPreferences中读取UUID
*/
private static String getFromSP(Context context) throws Exception {
String guid = SPUtil.getString(context, GUID_KEY, "");
if (!TextUtils.isEmpty(guid)) {
// sp中有值,更新一下系统数据库和外置存储文件
updateFile(guid);
return guid;
}
return null;
}
/**
* 从文件读取UUID
*/
private static String getFromFile() throws Exception {
File file = new File(filePath);
if (!file.exists()) {
return null;
}
BufferedReader bufferedReader = new BufferedReader(new FileReader(file));
// 读取一行即可
return bufferedReader.readLine();
}
/**
* 检测外部存储目录是否有该UUID,没有则更新,没有表示文件被用户清除了
*/
private static void updateFile(String guid) throws Exception {
File file = new File(filePath);
// 如果文件不存在,则表示被清理了,
if (!file.exists()) {
file.createNewFile();
writeData(guid);
} else {
// 如果文件存在,则需要对比一下sp中和文件中的是否一致
String fileUUID = getFromFile();
// 如果不相等,则表示被篡改了,需要更新
if (!guid.equals(fileUUID)) {
setToFile(guid);
}
}
}
/**
* 保存到sp
*/
private static void setToSP(Context context, String guid) {
SPUtil.putString(context, GUID_KEY, guid);
}
/**
* 保存到文件
*/
private static void setToFile(String guid) throws Exception {
File file = new File(filePath);
if (file.exists()) {
file.delete();
}
file.createNewFile();
writeData(guid);
}
/**
* 将UUID写入文件
*/
private static void writeData(String guid) throws Exception {
FileOutputStream fos = new FileOutputStream(filePath);
fos.write(guid.getBytes());
fos.close();
}
}
有个SPUtil
就不贴代码了,可以到源码里去找。
源码地址:https://github.com/RenZhongrui/android-learn/tree/master/026-android-uuid
设计优缺点
-
谷歌推荐使用,10.0以后只能这么设计了,10.0之前的可以使用Mac地址、AndroidId等;
-
如果设备恢复出厂设置了,那么应用和文件都会被清理,不过一般也没人会恢复出厂设置;
-
UUID也可以做一下加解密,可以更安全一些;
-
酷狗音乐也是使用的这种方式,有图有真相;
兼容Android11.0(重要补充)
上面的方案支持10.0和10.0以下是没有问题的,10.0的设备需要添加过渡方案(targetSdkVersion= 29),在AndroidManifest.xml
添加android:requestLegacyExternalStorage="true"
,如下所示:
<application
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true">
</application>
但是过渡方案毕竟是过渡,到了Android11.0,如果targetSdkVersion变更为30,谷歌就会强制执行分区存储
,什么是分区存储
?
官网介绍:https://developer.android.com/about/versions/11/privacy/storage?hl=zh-cn
分区存储
主要体现在以下几点:
- Android Q文件存储机制修改成了沙盒模式;
- APP只能访问自己目录下的文件(可以使用File Api操作)和公共媒体文件(使用MediaStore操作);
- 对于AndroidQ以下,还是使用老的文件存储方式(可以使用File Api操作);
所以到了Android11,过渡方案将不可行,GUID
存储文件必须存储到应用自己的沙盒中或者公共媒体文件中,其他目录将无权限访问。
为了一劳永逸,干脆撇去过渡方案,直接兼容Android11,修改了一下方案:
- GUID存储到
SharedPreferences
中; - 10.0以下,GUID存储到外部存储隐藏文件中;
- 10.0和11.0,GUID存储到媒体图片中,也就是
Pictures
中,当然文件会以图片的形式存储到该目录下。
代码这里就别贴了,源码地址:https://github.com/RenZhongrui/android-learn/tree/master/026-android-uuid
了解MediaStore
媒体操作示例:https://developer.android.com/training/data-storage/shared/media?hl=zh-cn
公有目录下的文件不会跟随APP卸载而删除,媒体API操作:
这里主要说一下10.0和11.0使用MediaStore
遇到的一些坑:
1、Downloads
和Pictures
权限问题
一开始想使用Downloads
来创建文本存储,但是Downloads
有个权限问题,Downloads
无法读取和修改别的应用创建的非媒体文件,
那自己创建的文件应该可以修改吧,然后把应用卸载之后,再安装,会发现使用query
查询出来的Uri为null,最后导致的是每次卸载应用再启动,都会创建一个新文件,如下图所示:
后来试了一下Pictures
媒体,是可以读取别的应用创建的文件。
2、Pictures
删除文件
首先,Pictures
媒体,是可以读取别的应用创建的文件,但是不能删除别的应用创建的文件,这里别的应用包括同一个应用卸载后安装后会变成新的应用的情况。
然后,存到Pictures
媒体中,文件格式只能是图片格式,如果存储文本格式会报错,另外如果文件名称没有添加后缀的话,系统会默认添加.jpg
的后缀,所以最好自己添加一个后缀。
最后,如果手动去Pictures
目录中删除文件,再次去创建文件的时候会创建不成功;存储到Pictures
媒体中,会以图片的形式显示到相册中,如果从相册中删除图片,则再次创建的时候不会出现问题。
3、应用只能删除自己创建的文件,没有权限删除别的应用创建的文件,包括卸载又安装,卸载前和重新安装后的应用不是一个应用。
4、非自己创建的文件,查询时添加参数无效,返回Cursor为null
ContentResolver resolver = context.getContentResolver();
// 设置入参
String[] projection = new String[]{
MediaStore.Images.Media._ID
};
// where 占位符
String selection = MediaStore.Images.Media.DISPLAY_NAME + "=?";
// 匹配占位符参数
String[] args = new String[]{
GUID_FILE_NAME
};
// 查询所有数据
Cursor query = resolver.query(external, projection, selection, args, null);
5、媒体中不允许创建隐藏文件,也就是只有后缀没有名称的文件,如.guid
,如果要创建的文件没有名称,系统会自动以下划线为名称,如:_.guid
。
查询文献:
AndroidQ(10)分区存储完美适配方法:https://www.yht7.com/news/13427
这篇文章所说的方法不可靠:https://www.jianshu.com/p/477c7b5d58e3?utm_campaign=hugo
郭霖的文章:https://blog.csdn.net/guolin_blog/article/details/105419420
Android 媒体操作Demo:https://developer.android.com/training/data-storage/shared/media?hl=zh-cn