應(yīng)谷歌應(yīng)用商店要求诀浪,自11月1日起筑辨,所有上傳到谷歌應(yīng)用商店的應(yīng)用將被強制要求升級目標(biāo) API 版本到 30,
這里記錄我升級目標(biāo)版本到 30 的過程中遇到的問題介汹。
1、Toast API 內(nèi)部變更
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 from getter is prior then global toastViewCallback.
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
對應(yīng)的控件叹卷,那么將會拋出空指針異常。
根據(jù)這個 API 的注釋坪它,
Return the view.
Toasts constructed with Toast(Context) that haven't called setView(View) with a non-null view will return null here.
Starting from Android Build.VERSION_CODES.R, in apps targeting API level Build.VERSION_CODES.R or higher, toasts constructed with makeText(Context, CharSequence, int) or its variants will also return null here unless they had called setView(View) with a non-null view. If you want to be notified when the toast is shown or hidden, use addCallback(Toast.Callback).
Deprecated
Custom toast views are deprecated. Apps can create a standard text toast with the makeText(Context, CharSequence, int) method, or use a Snackbar when in the foreground. Starting from Android Build.VERSION_CODES.R, apps targeting API level Build.VERSION_CODES.R or higher that are in the background will not have custom toast views displayed.
See Also:
setView
顯然是從 target API 30開始這個方法只返回 null. 不過骤竹,如果我們使用自定義的 View 調(diào)用 setView
方法還是可以繼續(xù)使用的。只是 Toast 的 ui 要自己定義往毡。
1.2 適配方案
方法一:如果不需要自定義 Toast 展示的文本的樣式瘤载,直接使用原生的書寫方式即可,即 Toast.makeText(...)
方法二:調(diào)用 Toast 的 setView
方法自己傳入用來自定義的 View 來進行 UI 樣式自定義卖擅。
2鸣奔、獲取設(shè)備信息方法變更
2.1 問題詳情
當(dāng) Target API 提升到了 30 之后,許多獲取設(shè)備信息的方法將無法使用惩阶,這包括(目前遇到的 API 如下所示)
TelephonyManager#getImei
TelephonyManager#getMeid
TelephonyManager#getSubscriberId
TelephonyManager#getDeviceId
TelephonyManager#getSimSerialNumber
Build#getSerial
讀取這些信息的時候?qū)伋鋈缦庐惓#?/p>
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 start activity ComponentInfo{me.shouheng.samples/me.shouheng.samples.device.TestDeviceUtilsActivity}: java.lang.SecurityException: getImeiForSlot: The user 10165 does not meet the requirements to access device identifiers.
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3449)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:223)
at android.app.ActivityThread.main(ActivityThread.java:7656)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
Caused by: java.lang.SecurityException: getImeiForSlot: The user 10165 does not meet the requirements to access device identifiers.
at android.os.Parcel.createExceptionOrNull(Parcel.java:2373)
at android.os.Parcel.createException(Parcel.java:2357)
at android.os.Parcel.readException(Parcel.java:2340)
at android.os.Parcel.readException(Parcel.java:2282)
at com.android.internal.telephony.ITelephony$Stub$Proxy.getImeiForSlot(ITelephony.java:11511)
at android.telephony.TelephonyManager.getImei(TelephonyManager.java:2049)
at android.telephony.TelephonyManager.getImei(TelephonyManager.java:2004)
at me.shouheng.utils.device.DeviceUtils.getDeviceId(DeviceUtils.java:232)
at me.shouheng.samples.device.TestDeviceUtilsActivity$1.onGetPermission(TestDeviceUtilsActivity.java:30)
at me.shouheng.utils.permission.PermissionUtils.checkPermissions(PermissionUtils.java:227)
at me.shouheng.utils.permission.PermissionUtils.checkPhonePermission(PermissionUtils.java:109)
at me.shouheng.samples.device.TestDeviceUtilsActivity.onCreate(TestDeviceUtilsActivity.java:24)
at android.app.Activity.performCreate(Activity.java:8000)
at android.app.Activity.performCreate(Activity.java:7984)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:223)
at android.app.ActivityThread.main(ActivityThread.java:7656)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
在 Target API 提升到 30 之后挎狸,需要增加如下權(quán)限才可以使用上述方法,
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" />
不過這個權(quán)限只有系統(tǒng)應(yīng)用才可以獲取断楷,我們的應(yīng)用即便在 manifest 中注冊了這個權(quán)限也一樣會在獲取上述信息的時候發(fā)生崩潰锨匆。
2.2 適配方案
不要使用上述信息作為用戶標(biāo)識。
3、存儲權(quán)限變更
3.1 問題詳情
關(guān)于 requestLegacyExternalStorage 屬性的問題:雖然 Android10 上面提出了外部存儲分區(qū)的概念恐锣,不過之前的版本中茅主,我們只要為應(yīng)用添加了 android:requestLegacyExternalStorage="true"
就可以像之前的方式一樣訪問手機的外部存儲空間。但是當(dāng)升級 Target API 到 30 之后將強制要求使用分區(qū)存儲土榴。但是如果覆蓋安裝的話诀姚, android:requestLegacyExternalStorage="true"
還是繼續(xù)生效的。不過玷禽,既然我們要適配 Android11赫段,就應(yīng)該卸載重裝,然后重構(gòu)讀寫外部存儲的邏輯矢赁。
下面是升級目標(biāo)版本到 30 之后關(guān)于讀取手機內(nèi)文件的一些問題或者現(xiàn)象糯笙,
讀取相冊權(quán)限不需要存儲權(quán)限了:升級目標(biāo)版本到 30 之后不需要獲取外部存儲權(quán)限就能訪問相冊了,但是前提是通過 ContentProvider 的形式訪問相冊撩银。參考知乎開源的相冊框架 Matisse 的訪問方式给涕,讀取手機相冊的時候,無需獲取外部存儲權(quán)限额获。
寫入到應(yīng)用專屬外部存儲權(quán)限規(guī)則不變:應(yīng)用專屬外部權(quán)限
Android/data/package_name
下面稠炬,跟之前一致,不需要申請任何權(quán)限咪啡。可以通過請求
MANAGE_EXTERNAL_STORAGE
來獲取外部存儲空間的管理權(quán)限首启。但是,不建議使用這種方式進行適配撤摸,因為請求的權(quán)限過多毅桃。寫入到外部存儲更加復(fù)雜,下面是適配的方案准夷。
3.2 適配方案
這里钥飞,我使用 Androidx 提供的 documentfile 進行適配,大致的邏輯是衫嵌,
- 寫入外部存儲之前先請求用戶獲取專屬存儲路徑读宙;
- 獲取到之后保存到 SP(SharedPreference) 中,下次使用的時候從 SP 讀取楔绞,通過 SP 中是否存在這個值來判斷是否需要重新獲取外部存儲空間结闸;
- 校驗讀寫權(quán)限,然后通過 DocumentFile/或者 File 讀寫文件酒朵。步驟如下桦锄,
首先,為應(yīng)用添加依賴蔫耽,
implementation 'androidx.documentfile:documentfile:1.0.1'
1. 請求權(quán)限外部存儲權(quán)限
下面是兼容的請求方案结耀,對于 Android 11 及以上的版本使用 Intent+startActivityForResult 打開應(yīng)用選擇外部存儲目錄;對于 Android11 以下的版本,走請求外部存儲權(quán)限的邏輯,
override fun <T> checkExternalPermission(
activity: T,
onGetPermission: () -> Unit
) where T : PermissionResultResolver, T : AppCompatActivity {
if (AppManager.isAboveAndroidR()) {
// 適用于 Android11
val uriString = SPUtils.get().getString("__external_storage_path")
if (TextUtils.isEmpty(uriString)) {
requestExternalPermission(activity)
return
}
val uri = Uri.parse(uriString)
val file = DocumentFile.fromTreeUri(UtilsApp.getApp(), uri)
if (file == null || !file.canWrite() || !file.canRead()) {
requestExternalPermission(activity)
} else {
root = file
onGetPermission.invoke()
}
} else {
// 適用于 Android11 以下,通過之前的方式獲讀寫權(quán)限
PermissionUtils.checkStoragePermission(activity) {
onGetPermission.invoke()
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun requestExternalPermission(activity: AppCompatActivity) {
var uri = Uri.parse("content://com.android.externalstorage.documents/tree/primary")
uri = DocumentFile.fromTreeUri(activity, uri)?.uri
val intent = 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)
}
之前我對請求外部存儲權(quán)限的邏輯做了封裝呢蛤,這里其實可以考慮通過封裝,內(nèi)部隱藏實現(xiàn)細(xì)節(jié)嚼摩,然后根據(jù) API 版本,統(tǒng)一處理請求和請求到結(jié)果的邏輯博肋。
2. 保存請求的外部存儲路徑的邏輯
這里獲取到用戶選擇的外部存儲路徑之后使用 SharedPreferences 保存起來低斋,并調(diào)用 ContentResolver 的 takePersistableUriPermission
方法存儲請求結(jié)果蜂厅。
override fun savePermissionState(
activity: AppCompatActivity,
requestCode: Int,
resultCode: Int,
data: Intent?
) {
if (resultCode != Activity.RESULT_OK || requestCode != 0x01111111) return
try {
val uri: 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
的信息來判斷當(dāng)前應(yīng)用是否已經(jīng)選擇了外部存儲目錄,并決定是否需要再次請求掘猿。
3. 寫入文件到外部存儲空間
這里僅以寫文件作為示例病游。首先,讓我們把腦洞打開稠通,嘗試使用之前的對 File 操作的方式來訪問磁盤文件衬衬。
下面是使用存儲分區(qū)之后的按照老的方式讀寫文件方式示例,
val uriString = SPUtils.get().getString("__external_storage_path")
val left = uriString.removePrefix("content://com.android.externalstorage.documents/tree/primary%3A")
val path = EncodeUtils.urlDecode(left)
val root = PathUtils.getExternalStoragePath()
val file = File("$root${File.separator}$path", "write_old.text")
IOUtils.writeFileFromString(file, "test test")
即改橘,因為上面請求權(quán)限的時候滋尉,我們保存了外部存儲的目錄,所以飞主,可以根據(jù)保存的 uri狮惜,移除前綴之后獲取用戶選擇的相對目錄,然后使用相對路徑碌识,按照之前的方式讀寫碾篡。因為 uri 是編碼之后的,所以這里需要先做解碼操作筏餐。
這里是我的一種寫法开泽,親測寫入時有效。但是按照這種方式讀取文件的時候魁瞪,當(dāng)我們調(diào)用 File.listFiles() 方法時只會返回目錄和按照這種方式寫入的文件穆律,不會返回通過 Documentfile 寫入的文件,所以這個方法是行不通的导俘。
如果使用 Documentfile 進行讀寫众旗,該邏輯如下,
val uriString = SPUtils.get().getString("__external_storage_path")
try {
val uri = Uri.parse(uriString)
val root = DocumentFile.fromTreeUri(this, uri)
var doc = createOrExistsFile(root, "test_a", "application/txt", "${System.currentTimeMillis()}.txt")
var ous = this.contentResolver.openOutputStream(doc!!.uri)
var ret = writeToOutputStream(ous, "sample a")
} catch (e: Exception) {
e.printStackTrace()
toast("failed!")
}
private fun createOrExistsFile(
root: DocumentFile?,
directoryPath: String,
mimeType: String,
fileName: String
): DocumentFile? {
if (root == null) return null
val dir = createOrExistsDirectory(root, directoryPath)
val file = dir?.findFile(fileName)
return if (file != null && file.isFile) file else dir?.createFile(mimeType, fileName)
}
private fun createOrExistsDirectory(root: DocumentFile?, directoryPath: String): DocumentFile? {
if (root == null) return null
val parts = directoryPath.split(File.separator).toTypedArray()
var dir = root
parts.filter { it.isNotEmpty() }.forEach { part ->
dir = dir?.listFiles()?.find {
part == it.name && it.isDirectory
} ?: dir?.createDirectory(part)
}
return dir
}
private fun writeToOutputStream(ous: OutputStream?, text: String): Boolean {
return try {
ous?.write(text.toByteArray())
true
} catch (e: IOException) {
e.printStackTrace()
false
} finally {
IOUtils.safeCloseAll(ous)
}
}
這里的邏輯稍微復(fù)雜點趟畏,主要是處理了可能寫入到子目錄中的情況贡歧。從上面的代碼也可以看出,這種讀寫方式是需要通過 listFiles()
獲取所有文件并遍歷,通過匹配文件名的方式來判斷指定的文件是否存在的利朵。而寫入操作這是通過打開 OutputStream律想,然后使用 OutputStream 寫入到流來實現(xiàn)的。
綜合對比:顯然使用 documentfile 進行讀寫邏輯更加復(fù)雜绍弟,而且可能需要在代碼中同時存在 File 和 documentfile 兩套邏輯技即,而使用老的方式進行讀寫的話,我們可以復(fù)用之前的讀寫邏輯樟遣。不過而叼,按照上面對字符串處理獲取相對路徑的方式在生產(chǎn)的實際表現(xiàn)如何,仍然有待驗證豹悬。
小結(jié):通常葵陵,我們在開發(fā)應(yīng)用的時候會在外部存儲空間創(chuàng)建一個專屬的目錄并進行讀寫,但是之前的外部存儲管理方式過于寬泛瞻佛,特別是相冊和外部存儲混合的情況脱篙,導(dǎo)致用戶不得不給予外部存儲權(quán)限,而這很可能把用戶暴露在危險中伤柄。按照新的分區(qū)規(guī)范绊困,我們一樣可以請求用戶給予一個專門的文件夾供我們讀寫,不過用戶擁有了更多的自主權(quán)适刀,可以指定我們使用的目錄秤朗。這對 Android 的安全和發(fā)展當(dāng)然是一件好事,不過對開發(fā)而言就比較頭疼了笔喉。
總結(jié)
這里記錄的是升級目標(biāo)版本到 30 遇到的一些問題以及實際解決辦法取视,當(dāng)然 AndroidR 上所做的變更比這更多,只是這里沒有遇到然遏。后續(xù)遇到升級問題會繼續(xù)更新~