应谷歌应用商店要求#NAME?,自11月1日起#NAME?,所有上传到谷歌应用商店的应用将被强制要求升级目标 API 版本到 30。
这里记录我升级目标版本到 30 的过程中遇到的问题。
1
Toast API 内部变更
1.1 问题详情
一般来说,这种 API 级别的变更不会被记录到官方的文档中,但是,遇到了就是坑。
sToast = Toast.makeText(UtilsApp.getApp, text, duration);
final TextView tvMessage = sToast.getView.findViewById(android.R.id.message);
if(sMsgColor != COLOR_DEFAULT) {
tvMessage.setTextColor(sMsgColor);
}
if(sMsgTextSize != -1) {
tvMessage.setTextSize(sMsgTextSize);
}
if(sGravity != -1|| sXOffset != -1|| sYOffset != -1) {
sToast.setGravity(sGravity, sXOffset, sYOffset);
}
// View fromgetter isprior thenglobaltoastViewCallback.
if(getter != null) {
sToast.setView(getter.getView(text));
} else{
View view;
if(toastViewCallback != null (view = toastViewCallback.getView(text, style)) != null) {
sToast.setView(view);
}
}
showToast;
如果你像上面这样Toast.makeText 之后使用getView 方法获取 android.R.id.message 对应的控件,那么将会抛出空指针异常。
根据这个 API 的注释。
Return the view.
Toasts constructed withToast(Context) that haven 't called setView(View) with a non-null view will return null here.
Starting from Android Build.VERSION_CODES.R, inapps targeting API level Build.VERSION_CODES.R orhigher, toasts constructed withmakeText(Context, CharSequence, int) orits variants will also return nullhere unless they had called setView(View) witha non- nullview. Ifyou want tobe notified when the toast isshown orhidden, use addCallback(Toast.Callback).
Deprecated
Custom toast views are deprecated. Apps can create a standard text toast withthe makeText(Context, CharSequence, int) method, oruse a Snackbar when inthe foreground. Starting from Android Build.VERSION_CODES.R, apps targeting API level Build.VERSION_CODES.R orhigher that are inthe background will nothave custom toast views displayed.
See Also:
setView
显然是从 target API 30开始这个方法只返回 null. 不过,如果我们使用自定义的 View 调用 setView 方法还是可以继续使用的。只是 Toast 的 ui 要自己定义。
1.2 适配方案
方法一:如果不需要自定义 Toast 展示的文本的样式,直接使用原生的书写方式即可,即 Toast.makeText(...)。
方法二:调用 Toast 的 setView 方法自己传入用来自定义的 View 来进行 UI 样式自定义。
2
获取设备信息方法变更
2.1 问题详情
当 Target API 提升到了 30 之后,许多获取设备信息的方法将无法使用,这包括(目前遇到的 API 如下所示):
TelephonyManager #getImei
TelephonyManager #getMeid
TelephonyManager #getSubscriberId
TelephonyManager #getDeviceId
TelephonyManager #getSimSerialNumber
Build #getSerial
读取这些信息的时候将会抛出如下异常:
2021-11-04 22:54:30.340 15085-15085/me.shouheng.samples E/AndroidRuntime: FATAL EXCEPTION: main
Process: me.shouheng.samples, PID: 15085
java.lang.RuntimeException: Unable to startactivity ComponentInfo{me.shouheng.samples/me.shouheng.samples.device.TestDeviceUtilsActivity}: java.lang.SecurityException: getImeiForSlot: The user10165does notmeet the requirements toaccessdevice identifiers.
atandroid.app.ActivityThread.performLaunchActivity(ActivityThread.java: 3449)
atandroid.app.ActivityThread.handleLaunchActivity(ActivityThread.java: 3601)
atandroid.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java: 85)
atandroid.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java: 135)
atandroid.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java: 95)
atandroid.app.ActivityThread$H.handleMessage(ActivityThread.java: 2066)
atandroid.os.Handler.dispatchMessage(Handler.java: 106)
atandroid.os.Looper.loop(Looper.java: 223)
atandroid.app.ActivityThread.main(ActivityThread.java: 7656)
atjava.lang.reflect.Method.invoke( NativeMethod)
atcom.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java: 592)
atcom.android.internal.os.ZygoteInit.main(ZygoteInit.java: 947)
Caused by: java.lang.SecurityException: getImeiForSlot: The user10165does notmeet the requirements toaccessdevice identifiers.
atandroid.os.Parcel.createExceptionOrNull(Parcel.java: 2373)
atandroid.os.Parcel.createException(Parcel.java: 2357)
atandroid.os.Parcel.readException(Parcel.java: 2340)
atandroid.os.Parcel.readException(Parcel.java: 2282)
atcom.android.internal.telephony.ITelephony$Stub$Proxy.getImeiForSlot(ITelephony.java: 11511)
atandroid.telephony.TelephonyManager.getImei(TelephonyManager.java: 2049)
atandroid.telephony.TelephonyManager.getImei(TelephonyManager.java: 2004)
atme.shouheng.utils.device.DeviceUtils.getDeviceId(DeviceUtils.java: 232)
atme.shouheng.samples.device.TestDeviceUtilsActivity$ 1.onGetPermission(TestDeviceUtilsActivity.java: 30)
atme.shouheng.utils.permission.PermissionUtils.checkPermissions(PermissionUtils.java: 227)
atme.shouheng.utils.permission.PermissionUtils.checkPhonePermission(PermissionUtils.java: 109)
atme.shouheng.samples.device.TestDeviceUtilsActivity.onCreate(TestDeviceUtilsActivity.java: 24)
atandroid.app.Activity.performCreate(Activity.java: 8000)
atandroid.app.Activity.performCreate(Activity.java: 7984)
atandroid.app.Instrumentation.callActivityOnCreate(Instrumentation.java: 1309)
atandroid.app.ActivityThread.performLaunchActivity(ActivityThread.java: 3422)
atandroid.app.ActivityThread.handleLaunchActivity(ActivityThread.java: 3601)
atandroid.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java: 85)
atandroid.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java: 135)
atandroid.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java: 95)
atandroid.app.ActivityThread$H.handleMessage(ActivityThread.java: 2066)
atandroid.os.Handler.dispatchMessage(Handler.java: 106)
atandroid.os.Looper.loop(Looper.java: 223)
atandroid.app.ActivityThread.main(ActivityThread.java: 7656)
atjava.lang.reflect.Method.invoke( NativeMethod)
atcom.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java: 592)
atcom.android.internal.os.ZygoteInit.main(ZygoteInit.java: 947)
在 Target API 提升到 30 之后,需要增加如下权限才可以使用上述方法:
uses-permissionandroid:name= "android.permission.READ_PRIVILEGED_PHONE_STATE"/
不过这个权限只有系统应用才可以获取,我们的应用即便在 manifest 中注册了这个权限也一样会在获取上述信息的时候发生崩溃。
2.2 适配方案
不要使用上述信息作为用户标识。
3
存储权限变更
3.1 问题详情
关于requestLegacyExternalStorage 属性的问题:虽然 Android10 上面提出了外部存储分区的概念,不过之前的版本中,我们只要为应用添加了 android:requestLegacyExternalStorage="true"就可以像之前的方式一样访问手机的外部存储空间。但是当升级 Target API 到 30 之后将强制要求使用分区存储。但是如果覆盖安装的话,android:requestLegacyExternalStorage="true" 还是继续生效的。不过,既然我们要适配 Android11,就应该卸载重装,然后重构读写外部存储的逻辑。
下面是升级目标版本到 30 之后关于读取手机内文件的一些问题或者现象。
1. 写入到应用专属外部存储权限规则不变:应用专属外部权限 Android/data/package_name下面,跟之前一致,不需要申请任何权限。
2. 可以通过请求MANAGE_EXTERNAL_STORAGE 来获取外部存储空间的管理权限。但是,不建议使用这种方式进行适配,因为请求的权限过多。
3. 写入到外部存储更加复杂,下面是适配的方案。
3.2 适配方案
这里,我使用 Androidx 提供的 documentfile 进行适配,大致的逻辑是:
1. 写入外部存储之前先请求用户获取专属存储路径;
2. 获取到之后保存到 SP(SharedPreference) 中,下次使用的时候从 SP 读取,通过 SP 中是否存在这个值来判断是否需要重新获取外部存储空间;
3. 校验读写权限,然后通过 DocumentFile/或者 File 读写文件。步骤如下:
首先,为应用添加依赖:
implementation 'androidx.documentfile:documentfile:1.0.1'
1. 请求权限外部存储权限
下面是兼容的请求方案,对于 Android 11 及以上的版本使用Intent+startActivityForResult 打开应用选择外部存储目录;对于 Android11 以下的版本,走请求外部存储权限的逻辑。
overridefunTcheckExternalPermission(
activity: T,
onGetPermission: - Unit
) where T : PermissionResultResolver, T : AppCompatActivity {
if(AppManager.isAboveAndroidR) {
// 适用于 Android11
valuriString = SPUtils. get.getString( "__external_storage_path")
if(TextUtils.isEmpty(uriString)) {
requestExternalPermission(activity)
return
}
valuri = Uri.parse(uriString)
valfile = DocumentFile.fromTreeUri(UtilsApp.getApp, uri)
if(file == null|| !file.canWrite || !file.canRead) {
requestExternalPermission(activity)
} else{
root = file
onGetPermission.invoke
}
} else{
// 适用于 Android11 以下,通过之前的方式获读写权限
PermissionUtils.checkStoragePermission(activity) {
onGetPermission.invoke
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
privatefunrequestExternalPermission(activity: AppCompatActivity) {
varuri = Uri.parse( "content://com.android.externalstorage.documents/tree/primary")
uri = DocumentFile.fromTreeUri(activity, uri)?.uri
valintent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.flags = (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
activity.startActivityForResult(intent, 0x01111111)
}
之前我对请求外部存储权限的逻辑做了封装,这里其实可以考虑通过封装,内部隐藏实现细节,然后根据 API 版本,统一处理请求和请求到结果的逻辑。
2. 保存请求的外部存储路径的逻辑
这里获取到用户选择的外部存储路径之后使用SharedPreferences保存起来,并调用 ContentResolver的takePersistableUriPermission 方法存储请求结果。
overridefunsavePermissionState(
activity: AppCompatActivity,
requestCode: Int,
resultCode: Int,
data: Intent?
) {
if(resultCode != Activity.RESULT_OK || requestCode != 0x01111111) return
try{
valuri: Uri = data?. data?: return
SPUtils. get.put( "__external_storage_path", uri.toString)
activity.contentResolver.takePersistableUriPermission(uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
root = DocumentFile.fromTreeUri(UtilsApp.getApp, uri)
} catch(e: Exception) {
e.printStackTrace
}
}
那么,下次我们可以使用SharedPreferences 中是否有__external_storage_path 的信息来判断当前应用是否已经选择了外部存储目录,并决定是否需要再次请求。
3. 写入文件到外部存储空间
这里仅以写文件作为示例。首先,让我们把脑洞打开,尝试使用之前的对 File *作的方式来访问磁盘文件。
下面是使用存储分区之后的按照老的方式读写文件方式示例。
valuriString = SPUtils. get.getString( "__external_storage_path")
valleft = uriString.removePrefix( "content://com.android.externalstorage.documents/tree/primary%3A")
valpath = EncodeUtils.urlDecode(left)
valroot = PathUtils.getExternalStoragePath
valfile = File( " $root${File.separator}$path" , "write_old.text")
IOUtils.writeFileFromString(file, "test test")
即,因为上面请求权限的时候,我们保存了外部存储的目录,所以,可以根据保存的 uri,移除前缀之后获取用户选择的相对目录,然后使用相对路径,按照之前的方式读写。因为 uri 是编码之后的,所以这里需要先做解码*作。
这里是我的一种写法,亲测写入时有效。但是按照这种方式读取文件的时候,当我们调用 File.listFiles 方法时只会返回目录和按照这种方式写入的文件,不会返回通过 Documentfile 写入的文件,所以这个方法是行不通的。
如果使用 Documentfile 进行读写,该逻辑如下:
valuriString = SPUtils. get.getString( "__external_storage_path")
try{
valuri = Uri.parse(uriString)
valroot = DocumentFile.fromTreeUri( this, uri)
vardoc = createOrExistsFile(root, "test_a", "application/txt", " ${System.currentTimeMillis}.txt" )
varous = this.contentResolver.openOutputStream(doc!!.uri)
varret = writeToOutputStream(ous, "sample a")
} catch(e: Exception) {
e.printStackTrace
toast( "failed!")
}
privatefuncreateOrExistsFile(
root: DocumentFile?,
directoryPath: String,
mimeType: String,
fileName: String
) : DocumentFile? {
if(root == null) returnnull
valdir = createOrExistsDirectory(root, directoryPath)
valfile = dir?.findFile(fileName)
returnif(file != null file.isFile) file elsedir?.createFile(mimeType, fileName)
}
privatefuncreateOrExistsDirectory(root: DocumentFile?, directoryPath: String) : DocumentFile? {
if(root == null) returnnull
valparts = directoryPath.split(File.separator).toTypedArray
vardir = root
parts.filter { it.isNotEmpty }.forEach { part -
dir = dir?.listFiles?.find {
part == it.name it.isDirectory
} ?: dir?.createDirectory(part)
}
returndir
}
privatefunwriteToOutputStream(ous: OutputStream?, text: String) : Boolean{
returntry{
ous?.write(text.toByteArray)
true
} catch(e: IOException) {
e.printStackTrace
false
} finally{
IOUtils.safeCloseAll(ous)
}
}
这里的逻辑稍微复杂点,主要是处理了可能写入到子目录中的情况。从上面的代码也可以看出,这种读写方式是需要通过listFiles 获取所有文件并遍历,通过匹配文件名的方式来判断指定的文件是否存在的。而写入*作这是通过打开 OutputStream,然后使用 OutputStream 写入到流来实现的。
综合对比:显然使用 documentfile 进行读写逻辑更加复杂,而且可能需要在代码中同时存在 File 和 documentfile 两套逻辑,而使用老的方式进行读写的话,我们可以复用之前的读写逻辑。不过,按照上面对字符串处理获取相对路径的方式在生产的实际表现如何,仍然有待验证。
小结:通常,我们在开发应用的时候会在外部存储空间创建一个专属的目录并进行读写,但是之前的外部存储管理方式过于宽泛,特别是相册和外部存储混合的情况,导致用户不得不给予外部存储权限,而这很可能把用户暴露在危险中。按照新的分区规范,我们一样可以请求用户给予一个专门的文件夹供我们读写,不过用户拥有了更多的自**,可以指定我们使用的目录。这对 Android 的安全和发展当然是一件好事,不过对开发而言就比较头疼了。
总结
这里记录的是升级目标版本到 30 遇到的一些问题以及实际解决办法,当然 AndroidR 上所做的变更比这更多,只是这里没有遇到。后续遇到升级问题会继续更新~
文件读写代码请参考:
https://github.com/Shouheng88/Android-utils
最后推荐一下我做的网站,玩Android: wanandroid.com,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
:
Kotlin开发中的一些Tips
写给Android开发者的芯片知识
9月Android面试经验分享
点击关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!
评论列表
国人以佩戴翡翠玉石来增加自己的品格修养
帝王绿的尊贵,红翡的妖艳如火紫罗兰的神秘等,都让人赞叹不绝。
一颗翡翠的精彩绽放,是在地下经历亿万年不断锤炼的结果