相冊適配 Android 11 繞的那些彎路!
Android加入安卓技術(shù)群作者:Android_ZzT
https://juejin.cn/post/6924270961889902599

一、背景
最近公司中的相冊組件被業(yè)務(wù)方反饋了新問題,在 targetSdk=30 的 Android 10 手機上運行相冊,縮略圖會加載不出來,于是就開啟了這次的趟坑之路。
定位問題
首先,我在相冊Demo中把 targetSdk 設(shè)置到 30, 然后在 Android 10 測試機上運行,發(fā)現(xiàn)縮略圖完美的顯示了出來。
很懵逼,為啥相同的代碼 demo 上正常,業(yè)務(wù)方的 app 不正常?
一定是有什么配置不一樣,才導(dǎo)致了這樣的結(jié)果。
經(jīng)過了各種找不同 ...
我發(fā)現(xiàn),demo 的 AndroidManifest.xml 中多了一個屬性
<application
android:requestLegacyExternalStorage="true"
...>
于是,正式開啟了我的適配之路...
二、requestLegacyExternalStorage 是什么?
通過翻查官方文檔,大概知道了這個屬性的意思:在配置targetSdk >= 29,應(yīng)用搭載在Android 10及以上版本的手機運行時,可以暫時停用「分區(qū)存儲」
1.「分區(qū)存儲」又是什么?
分區(qū)存儲
為了讓用戶更好地管理自己的文件并減少混亂,以 Android 10(API 級別 29)及更高版本為目標(biāo)平臺的應(yīng)用在默認情況下被賦予了對外部存儲空間的分區(qū)訪問權(quán)限(即分區(qū)存儲)。此類應(yīng)用只能訪問外部存儲空間上的應(yīng)用專屬目錄,以及本應(yīng)用所創(chuàng)建的特定類型的媒體文件。
在搭載 Android 9(API 級別 28)或更低版本的設(shè)備上,只要其他應(yīng)用具有相應(yīng)的存儲權(quán)限,任何應(yīng)用都可以訪問外部存儲空間中的應(yīng)用專屬文件。為了讓用戶更好地管理自己的文件并減少混亂,以 Android 10(API 級別 29)及更高版本為目標(biāo)平臺的應(yīng)用在默認情況下被授予了對外部存儲空間的分區(qū)訪問權(quán)限(即分區(qū)存儲)。啟用分區(qū)存儲后,應(yīng)用將無法訪問屬于其他應(yīng)用的應(yīng)用專屬目錄。
這是摘自官方文檔的一段話,我們可以把「分區(qū)存儲」簡單解釋為,Android 10 開啟分區(qū)存儲后,你的應(yīng)用在有權(quán)限的情況下也無法隨便訪問其他外部存儲空間中的公有文件夾了
2.「分區(qū)存儲」會造成什么影響?
比如在App中展示相冊縮略圖的時候,我們會把 filepath 傳給圖片加載框架去幫助渲染縮略圖,像這樣
ImageLoader.load(imageView, Uri.fromFile(path);
這里的 path 一般為 sdcard/DCIM/...,這明顯為外部存儲空間中的文件夾,且不是應(yīng)用專屬文件,這時在圖片加載框架層就會拋出異常java.io.FileNotFoundException。
假如你用的是 Glide,會在圖中的代碼位置拋出異常

三、Android 11 中 requestLegacyExternalStorage 屬性失效
在繼續(xù)翻閱官方文檔后,又得知了一個信息:
注意:當(dāng)您將應(yīng)用更新為以 Android 11(API 級別 30)為目標(biāo)平臺后,如果應(yīng)用在搭載 Android 11 的設(shè)備上運行,系統(tǒng)會忽略 requestLegacyExternalStorage 屬性,因此您的應(yīng)用必須做好支持分區(qū)存儲并為這些設(shè)備上的用戶遷移應(yīng)用數(shù)據(jù)的準(zhǔn)備。
這段信息,簡單可以理解為 requestLegacyExternalStorage=true 只能解燃眉之急,到了 Android 11 上,還是要做適配工作。
這也成功為我走上彎路,埋下了伏筆 ...
四、開始走彎路
1. 只適配 Android 10 (不推薦)
在Manifest中添加
<application
android:requestLegacyExternalStorage="true"
...>
我們剛才知道了,如果應(yīng)用在 Android 11 的設(shè)備上運行,系統(tǒng)會忽略 requestLegacyExternalStorage 屬性,強制開啟分區(qū)存儲。可能還是會出現(xiàn)異常(此處我并沒有真正用 Android 11 的機器驗證)。所以我默認認為,requestLegacyExternalStorage=true 只能解近憂,但不解本質(zhì)問題。
2. 放棄 File path,使用 Uri
前文已經(jīng)提到,我們用訪問 File path 的方式加載縮略圖,會拋出 java.io.FileNotFoundException。
那么,官方推薦我們怎么做呢?大致如下三步
獲取媒體數(shù)據(jù) id 獲取縮略圖 uri 用 uri 加載縮略圖
val projection = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.SIZE
)
...
val query = ContentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)
query?.use { cursor ->
media.id = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
...
media.thumbnailUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, media.id)
}
// Load thumbnail of a specific media item.
val thumbnail: Bitmap =
applicationContext.contentResolver.loadThumbnail(
media.thumbnailUri, Size(640, 480), null)
完整代碼,可參考 developer.android.com/training/da…
由于這個變動涉及到數(shù)據(jù)源的變化,改動點非常多,并且還要用 if else 區(qū)分版本,所以寫了很多膠水代碼 ...
但是,最終還是成功在 targetSdk=29 Android 10 的手機上成功顯示出了縮略圖。
3. 新問題又出現(xiàn)
相冊的圖片預(yù)覽功能也不能用了,經(jīng)過排查,發(fā)現(xiàn)是一樣的問題,膠水代碼已經(jīng)寫好,都在射程范圍內(nèi)。于是,用了半小時又改掉了圖片預(yù)覽的問題。
正當(dāng)我興奮地覺得馬上要完工的時候,點了一下視頻預(yù)覽 ... 好吧,看到了熟悉卻又令人絕望的錯誤信息,依賴的播放器庫拋出了熟悉的異常 java.io.FileNotFoundException open failed: EACCES (Permission denied)。播放器中也是通過 file path 傳給 ffmpeg 進行播放的,但在初始化播放器的時候就因為沒有權(quán)限就直接掛了。
4. 繞彎想方案
首先,我找到了播放器的開發(fā)同學(xué)進行溝通,能否用傳遞 uri 或者 FileDescriptor 的方式進行初始化。得到了幾個不太友好的結(jié)論:
傳 uri 到 Native 層,content://media/external/images/media/{media_id},這種 Uri Native 層貌似無法打開(沒再細查有沒有辦法 傳 fd 到 Native 層,可能會涉及 java 層 fd 被 Native 引用,然后無法釋放的問題,如果要釋放還需要開放釋放 fd 的接口 除了相冊,還有很多地方在將 File path 傳到 Native 層
然后,開始想怎么能繞過這個問題,大概找到了 2個 不靠譜的方案:
因為不能訪問公有目錄,那么可以先 copy file 到私有目錄(產(chǎn)品可能要罵街了
請求 MANAGE_EXTERNAL_STORAGE 權(quán)限
這是一個有意思的權(quán)限,官方是這樣說的
絕大多數(shù)需要共享存儲空間訪問權(quán)限的應(yīng)用都可以遵循共享媒體文件和共享非媒體文件方面的最佳做法。但是,某些應(yīng)用的核心用例需要廣泛訪問設(shè)備上的文件,但無法采用注重隱私保護的存儲最佳做法高效地完成這些操作。對于這些情況,Android 提供了一種名為“所有文件訪問權(quán)限”的特殊應(yīng)用訪問權(quán)限這段話里說的某些應(yīng)用,比如「殺毒應(yīng)用」「文件瀏覽器」,需要掃描 sdcard 的所有文件,如果沒有權(quán)限就沒法正常工作(很明顯,我們的App不是
另外,對于這個權(quán)限的描述很有意思,長這樣

如果我是用戶,看到了一個不需要這些權(quán)限的App卻申請了這種權(quán)限,無疑是一種勸退(產(chǎn)品又要罵街了
5.冷靜下來,再看文檔
做到第4步的時候,我開始意識到,很有可能繞彎路了,往常的適配工作還沒有這么變態(tài)過。于是我又查了一些資料,找到了這個視頻,https://www.youtube.com/watch?v=RjyYCUW-9tY&feature=youtu.be

視頻中對我們有用的信息大概是這樣,在 Android 10 的時候,很多開發(fā)者都反應(yīng)了類似的問題,在使用一些 native 的庫時,無法使用 File Api,造成了很多困難。于是,在 Android 11 中,又做了兼容,又可以通過 Java File Api 的方式訪問媒體庫文件了(此時的我不知道是不是應(yīng)該高興,Android 確實比蘋果爸爸對開發(fā)者好)
后來,我又仔細的翻了翻官方文檔,確實找到了一小段不起眼的文字
使用直接文件路徑和原生庫訪問文件
為了幫助您的應(yīng)用更順暢地使用第三方媒體庫,Android 11 允許您使用除 MediaStore API 之外的 API 通過直接文件路徑訪問共享存儲空間中的媒體文件。其中包括:
File API。 原生庫,例如 fopen()。
五、結(jié)論
好吧...
繞了一個大圈后,得到了幾個結(jié)果:
膠水代碼可能是白寫了,在 targetSdk=29 運行在 Android 10 的應(yīng)用上, requestLegacyExternalStorage 屬性完全夠用了(枉我開始我還鄙視它 Android 11 的時候也不需要適配啥了,雖然 requestLegacyExternalStorage 屬性失效,但相冊里通過 File Api 訪問的只是媒體庫文件,不會有任何問題。 如果 App 中有通過 File Api 訪問外部存儲共有目錄的代碼,還是要需做適配的,至于怎么去做本文就不再討論了
教訓(xùn)
繞了一圈之后,得出兩個教訓(xùn):
適配新版本的時候,最好先用真機測試一下,萬一完美運行就不用適配了 認真讀文檔、認真讀文檔、認真讀文檔
Glide 加載縮略圖
最后,說個與適配不太相干的話題,只想看適配內(nèi)容的朋友可以先跳過了。
我在適配的過程中也跟了一下 glide 加載縮略圖的流程,也搞清了一些問題,順便分享給大家
1. 為什么向 Glide 傳 content-uri 不會出錯,傳 file path 會報錯?
上文剛才介紹過,官方提供的獲取相冊縮略圖的做法是
// Load thumbnail of a specific media item.
val thumbnail: Bitmap =
applicationContext.contentResolver.loadThumbnail(
media.thumbnailUri, Size(640, 480), null)
但是我們平時開發(fā),大多都直接用圖片加載框架,比如 Glide
Glide
.with(imageView)
.asBitmap()
.load(uri) //或者 file path
.into()
復(fù)制代碼
在我們沒適配 Android 10 的時候,傳 file path 會拋出異常,這我們之前已經(jīng)解釋了。適配之后我們傳入了 content://media/external/images/media/{media_id} 給 Glide,Glide 又是怎么識別的然后加載出 bitmap 的呢?我?guī)е鴨栴}跟蹤了一下 Glide 加載圖片的過程的源碼,這里我們直接先說結(jié)論。
StreamLocalUriFetcher
private InputStream loadResourceFromUri(Uri uri, ContentResolver contentResolver)
throws FileNotFoundException {
switch (URI_MATCHER.match(uri)) {
case ID_CONTACTS_CONTACT:
return openContactPhotoInputStream(contentResolver, uri);
case ID_CONTACTS_LOOKUP:
case ID_LOOKUP_BY_PHONE:
// If it was a Lookup uri then resolve it first, then continue loading the contact uri.
uri = ContactsContract.Contacts.lookupContact(contentResolver, uri);
if (uri == null) {
throw new FileNotFoundException("Contact cannot be found");
}
return openContactPhotoInputStream(contentResolver, uri);
case ID_CONTACTS_THUMBNAIL:
case ID_CONTACTS_PHOTO:
case UriMatcher.NO_MATCH:
default:
return contentResolver.openInputStream(uri);
}
}
uri 經(jīng)過匹配邏輯走到了 default 分支,使用 contentResolver.openInputStream(uri) 的方式來讀取 bitmap,既然是通過系統(tǒng)的 contentResolver 獲取,那一定是沒問題的。
2. 淺談 Glide 加載圖片流程

這是我簡單總結(jié)的 Glide 加載圖片的流程,不做詳細解釋了,簡單介紹一下圖中的關(guān)鍵元素:
綠圈是時序 黃色方塊代表輸入、輸出 粗實線框代表類 細實線框代表關(guān)鍵方法 虛線代表方法屬于哪個類
圖中的過程就是這段代碼運行的過程
Glide
.with(imageView)
.asBitmap()
.load(uri) //或者 file path
.into()
參考
Android 存儲用例和最佳做法 Android 11 中的存儲機制更新 拖不得了,Android11真的要來了,最全適配實踐指南奉上
推薦閱讀
? 耗時2年,Android進階三部曲第三部《Android進階指北》出版!
? 『BATcoder』做了多年安卓還沒編譯過源碼?一個視頻帶你玩轉(zhuǎn)!
BATcoder技術(shù)群,讓一部分人先進大廠
你好,我是劉望舒,騰訊云最具價值專家TVP,著有暢銷書《Android進階之光》《Android進階解密》《Android進階指北》,蟬聯(lián)四屆電子工業(yè)出版社年度優(yōu)秀作者,谷歌開發(fā)者社區(qū)特邀講師。
前華為面試官,現(xiàn)大廠技術(shù)負責(zé)人。
想要加入 BATcoder技術(shù)群,公號回復(fù)Android 即可。
為了防止失聯(lián),歡迎關(guān)注我的小號
更文不易,點個“在看”支持一下??
