一、背景
最近公司中的相冊組件被業務方反饋了新問題,在 targetSdk=30 的 Android 10 手機上運行相冊,縮略圖會加載不出來,于是就開啟了這次的趟坑之路。
定位問題
首先,我在相冊Demo中把 targetSdk 設置到 30, 然后在 Android 10 測試機上運行,發現縮略圖完美的顯示了出來。
很懵逼,為啥相同的代碼 demo 上正常,業務方的 app 不正常?
一定是有什么配置不一樣,才導致了這樣的結果。
經過了各種找不同 ...
我發現,demo 的 AndroidManifest.xml 中多了一個屬性
- <application
- android:requestLegacyExternalStorage="true"
- ...>
于是,正式開啟了我的適配之路...
二、requestLegacyExternalStorage 是什么?
通過翻查官方文檔,大概知道了這個屬性的意思:在配置targetSdk >= 29,應用搭載在Android 10及以上版本的手機運行時,可以暫時停用「分區存儲」
1.「分區存儲」又是什么?
分區存儲
為了讓用戶更好地管理自己的文件并減少混亂,以 Android 10(API 級別 29)及更高版本為目標平臺的應用在默認情況下被賦予了對外部存儲空間的分區訪問權限(即分區存儲)。此類應用只能訪問外部存儲空間上的應用專屬目錄,以及本應用所創建的特定類型的媒體文件。
在搭載 Android 9(API 級別 28)或更低版本的設備上,只要其他應用具有相應的存儲權限,任何應用都可以訪問外部存儲空間中的應用專屬文件。為了讓用戶更好地管理自己的文件并減少混亂,以 Android 10(API 級別 29)及更高版本為目標平臺的應用在默認情況下被授予了對外部存儲空間的分區訪問權限(即分區存儲)。啟用分區存儲后,應用將無法訪問屬于其他應用的應用專屬目錄。
這是摘自官方文檔的一段話,我們可以把「分區存儲」簡單解釋為,Android 10 開啟分區存儲后,你的應用在有權限的情況下也無法隨便訪問其他外部存儲空間中的公有文件夾了
2.「分區存儲」會造成什么影響?
比如在App中展示相冊縮略圖的時候,我們會把 filepath 傳給圖片加載框架去幫助渲染縮略圖,像這樣
- ImageLoader.load(imageView, Uri.fromFile(path);
這里的 path 一般為 sdcard/DCIM/...,這明顯為外部存儲空間中的文件夾,且不是應用專屬文件,這時在圖片加載框架層就會拋出異常java.io.FileNotFoundException。
假如你用的是 Glide,會在圖中的代碼位置拋出異常
三、Android 11 中 requestLegacyExternalStorage 屬性失效
在繼續翻閱官方文檔后,又得知了一個信息:
- 注意:當您將應用更新為以 Android 11(API 級別 30)為目標平臺后,如果應用在搭載 Android 11 的設備上運行,系統會忽略 requestLegacyExternalStorage 屬性,因此您的應用必須做好支持分區存儲并為這些設備上的用戶遷移應用數據的準備。
這段信息,簡單可以理解為 requestLegacyExternalStorage=true 只能解燃眉之急,到了 Android 11 上,還是要做適配工作。
這也成功為我走上彎路,埋下了伏筆 ...
四、開始走彎路
1. 只適配 Android 10 (不推薦)
在Manifest中添加
- <application
- android:requestLegacyExternalStorage="true"
- ...>
我們剛才知道了,如果應用在 Android 11 的設備上運行,系統會忽略 requestLegacyExternalStorage 屬性,強制開啟分區存儲。可能還是會出現異常(此處我并沒有真正用 Android 11 的機器驗證)。所以我默認認為,requestLegacyExternalStorage=true 只能解近憂,但不解本質問題。
2. 放棄 File path,使用 Uri
前文已經提到,我們用訪問 File path 的方式加載縮略圖,會拋出 java.io.FileNotFoundException。
那么,官方推薦我們怎么做呢?大致如下三步
- 獲取媒體數據 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…
由于這個變動涉及到數據源的變化,改動點非常多,并且還要用 if else 區分版本,所以寫了很多膠水代碼 ...
但是,最終還是成功在 targetSdk=29 Android 10 的手機上成功顯示出了縮略圖。
3. 新問題又出現
相冊的圖片預覽功能也不能用了,經過排查,發現是一樣的問題,膠水代碼已經寫好,都在射程范圍內。于是,用了半小時又改掉了圖片預覽的問題。
正當我興奮地覺得馬上要完工的時候,點了一下視頻預覽 ... 好吧,看到了熟悉卻又令人絕望的錯誤信息,依賴的播放器庫拋出了熟悉的異常 java.io.FileNotFoundException open failed: EACCES (Permission denied)。播放器中也是通過 file path 傳給 ffmpeg 進行播放的,但在初始化播放器的時候就因為沒有權限就直接掛了。
4. 繞彎想方案
首先,我找到了播放器的開發同學進行溝通,能否用傳遞 uri 或者 FileDescriptor 的方式進行初始化。得到了幾個不太友好的結論:
- 傳 uri 到 Native 層,content://media/external/images/media/{media_id},這種 Uri Native 層貌似無法打開(沒再細查有沒有辦法
- 傳 fd 到 Native 層,可能會涉及 java 層 fd 被 Native 引用,然后無法釋放的問題,如果要釋放還需要開放釋放 fd 的接口
- 除了相冊,還有很多地方在將 File path 傳到 Native 層
然后,開始想怎么能繞過這個問題,大概找到了 2個 不靠譜的方案:
- 因為不能訪問公有目錄,那么可以先 copy file 到私有目錄(產品可能要罵街了
- 請求 MANAGE_EXTERNAL_STORAGE 權限
- 這是一個有意思的權限,官方是這樣說的
絕大多數需要共享存儲空間訪問權限的應用都可以遵循共享媒體文件和共享非媒體文件方面的最佳做法。但是,某些應用的核心用例需要廣泛訪問設備上的文件,但無法采用注重隱私保護的存儲最佳做法高效地完成這些操作。對于這些情況,Android 提供了一種名為“所有文件訪問權限”的特殊應用訪問權限
這段話里說的某些應用,比如「殺毒應用」「文件瀏覽器」,需要掃描 sdcard 的所有文件,如果沒有權限就沒法正常工作(很明顯,我們的App不是
另外,對于這個權限的描述很有意思,長這樣
如果我是用戶,看到了一個不需要這些權限的App卻申請了這種權限,無疑是一種勸退(產品又要罵街了
5.冷靜下來,再看文檔
做到第4步的時候,我開始意識到,很有可能繞彎路了,往常的適配工作還沒有這么變態過。于是我又查了一些資料,找到了這個視頻,https://www.youtube.com/watch?v=RjyYCUW-9tY&feature=youtu.be
視頻中對我們有用的信息大概是這樣,在 Android 10 的時候,很多開發者都反應了類似的問題,在使用一些 native 的庫時,無法使用 File Api,造成了很多困難。于是,在 Android 11 中,又做了兼容,又可以通過 Java File Api 的方式訪問媒體庫文件了(此時的我不知道是不是應該高興,Android 確實比蘋果爸爸對開發者好)
后來,我又仔細的翻了翻官方文檔,確實找到了一小段不起眼的文字
- 使用直接文件路徑和原生庫訪問文件
- 為了幫助您的應用更順暢地使用第三方媒體庫,Android 11 允許您使用除 MediaStore API 之外的 API 通過直接文件路徑訪問共享存儲空間中的媒體文件。其中包括:
- File API。
- 原生庫,例如 fopen()。
五、結論
好吧...
繞了一個大圈后,得到了幾個結果:
- 膠水代碼可能是白寫了,在 targetSdk=29 運行在 Android 10 的應用上, requestLegacyExternalStorage 屬性完全夠用了(枉我開始我還鄙視它
- Android 11 的時候也不需要適配啥了,雖然 requestLegacyExternalStorage 屬性失效,但相冊里通過 File Api 訪問的只是媒體庫文件,不會有任何問題。
- 如果 App 中有通過 File Api 訪問外部存儲共有目錄的代碼,還是要需做適配的,至于怎么去做本文就不再討論了
教訓
繞了一圈之后,得出兩個教訓:
- 適配新版本的時候,最好先用真機測試一下,萬一完美運行就不用適配了
- 認真讀文檔、認真讀文檔、認真讀文檔
* Glide 加載縮略圖
最后,說個與適配不太相干的話題,只想看適配內容的朋友可以先跳過了。
我在適配的過程中也跟了一下 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)
但是我們平時開發,大多都直接用圖片加載框架,比如 Glide
- Glide
- .with(imageView)
- .asBitmap()
- .load(uri) //或者 file path
- .into()
在我們沒適配 Android 10 的時候,傳 file path 會拋出異常,這我們之前已經解釋了。適配之后我們傳入了 content://media/external/images/media/{media_id} 給 Glide,Glide 又是怎么識別的然后加載出 bitmap 的呢?我帶著問題跟蹤了一下 Glide 加載圖片的過程的源碼,這里我們直接先說結論。
- 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 經過匹配邏輯走到了 default 分支,使用 contentResolver.openInputStream(uri) 的方式來讀取 bitmap,既然是通過系統的 contentResolver 獲取,那一定是沒問題的。
2. 淺談 Glide 加載圖片流程
這是我簡單總結的 Glide 加載圖片的流程,不做詳細解釋了,簡單介紹一下圖中的關鍵元素:
- 綠圈是時序
- 黃色方塊代表輸入、輸出
- 粗實線框代表類
- 細實線框代表關鍵方法
- 虛線代表方法屬于哪個類
圖中的過程就是這段代碼運行的過程
- Glide
- .with(imageView)
- .asBitmap()
- .load(uri) //或者 file path
- .into()
原文地址:https://juejin.cn/post/6924270961889902599