相冊(cè)適配 Android 11 繞的那些彎路
轉(zhuǎn)自:掘金?- Android_ZzT

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

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

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

視頻中對(duì)我們有用的信息大概是這樣,在 Android 10 的時(shí)候,很多開發(fā)者都反應(yīng)了類似的問題,在使用一些 native 的庫時(shí),無法使用 File Api,造成了很多困難。于是,在 Android 11 中,又做了兼容,又可以通過 Java File Api 的方式訪問媒體庫文件了(此時(shí)的我不知道是不是應(yīng)該高興,Android 確實(shí)比蘋果爸爸對(duì)開發(fā)者好)
后來,我又仔細(xì)的翻了翻官方文檔,確實(shí)找到了一小段不起眼的文字
使用直接文件路徑和原生庫訪問文件
為了幫助您的應(yīng)用更順暢地使用第三方媒體庫,Android 11 允許您使用除 MediaStore API 之外的 API 通過直接文件路徑訪問共享存儲(chǔ)空間中的媒體文件。其中包括:
File API。 原生庫,例如 fopen()。
五、結(jié)論
好吧...
繞了一個(gè)大圈后,得到了幾個(gè)結(jié)果:
膠水代碼可能是白寫了,在 targetSdk=29 運(yùn)行在 Android 10 的應(yīng)用上, requestLegacyExternalStorage 屬性完全夠用了(枉我開始我還鄙視它 Android 11 的時(shí)候也不需要適配啥了,雖然 requestLegacyExternalStorage 屬性失效,但相冊(cè)里通過 File Api 訪問的只是媒體庫文件,不會(huì)有任何問題。 如果 App 中有通過 File Api 訪問外部存儲(chǔ)共有目錄的代碼,還是要需做適配的,至于怎么去做本文就不再討論了
教訓(xùn)
繞了一圈之后,得出兩個(gè)教訓(xùn):
適配新版本的時(shí)候,最好先用真機(jī)測(cè)試一下,萬一完美運(yùn)行就不用適配了 認(rèn)真讀文檔、認(rèn)真讀文檔、認(rèn)真讀文檔
* Glide 加載縮略圖
最后,說個(gè)與適配不太相干的話題,只想看適配內(nèi)容的朋友可以先跳過了。
我在適配的過程中也跟了一下 glide 加載縮略圖的流程,也搞清了一些問題,順便分享給大家
1. 為什么向 Glide 傳 content-uri 不會(huì)出錯(cuò),傳 file path 會(huì)報(bào)錯(cuò)?
上文剛才介紹過,官方提供的獲取相冊(cè)縮略圖的做法是
//?Load?thumbnail?of?a?specific?media?item.
val?thumbnail:?Bitmap?=
????????applicationContext.contentResolver.loadThumbnail(
????????media.thumbnailUri,?Size(640,?480),?null)
但是我們平時(shí)開發(fā),大多都直接用圖片加載框架,比如 Glide
Glide
??.with(imageView)
??.asBitmap()
??.load(uri)?//或者?file?path
??.into()
復(fù)制代碼
在我們沒適配 Android 10 的時(shí)候,傳 file path 會(huì)拋出異常,這我們之前已經(jīng)解釋了。適配之后我們傳入了 content://media/external/images/media/{media_id} 給 Glide,Glide 又是怎么識(shí)別的然后加載出 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 加載圖片的流程,不做詳細(xì)解釋了,簡單介紹一下圖中的關(guān)鍵元素:
綠圈是時(shí)序 黃色方塊代表輸入、輸出 粗實(shí)線框代表類 細(xì)實(shí)線框代表關(guān)鍵方法 虛線代表方法屬于哪個(gè)類
圖中的過程就是這段代碼運(yùn)行的過程
Glide
??.with(imageView)
??.asBitmap()
??.load(uri)?//或者?file?path
??.into()
參考
Android 存儲(chǔ)用例和最佳做法 Android 11 中的存儲(chǔ)機(jī)制更新 拖不得了,Android11真的要來了,最全適配實(shí)踐指南奉上
