今天用的是Android10真機(jī),需要的權(quán)限都申請(qǐng)了,還是遇到了這個(gè)問(wèn)題。
W/System.err: java.io.FileNotFoundException: /storage/emulated/0/xxx.cfg: open failed: EACCES (Permission denied)
Android Q最大的變化莫過(guò)于是對(duì)用戶(hù)隱私權(quán)的進(jìn)一步保護(hù),其中有一個(gè)feature更是讓Android用戶(hù)(尤其是國(guó)內(nèi)用戶(hù))拍手稱(chēng)快,這就是分區(qū)存儲(chǔ)(Scoped Storage, 也有翻譯為存儲(chǔ)沙盤(pán)化的)。截止目前,Google已經(jīng)發(fā)布了Android Q的第4個(gè)beta版本(QPP4),想必許多開(kāi)發(fā)者已經(jīng)開(kāi)始適配(踩坑)了。最近為了不在年底的時(shí)候手忙腳亂,本人也在開(kāi)始準(zhǔn)備Q的適配了。目前關(guān)于Scoped Storage適配的文章已經(jīng)不少了,但個(gè)人覺(jué)得大多都講得太泛,缺乏實(shí)際的操作指南,看完之后還是有些云里霧里。于是,筆者決定結(jié)合現(xiàn)有的文章,自己以實(shí)際行動(dòng)踩坑,總結(jié)一些實(shí)際的適配技巧。
??本文也不打算寫(xiě)成一篇大而全的適配指南,只是為了補(bǔ)充現(xiàn)有適配文章的一些不足,講一些個(gè)人經(jīng)實(shí)踐驗(yàn)證過(guò)的Scoped Storage適配技巧。
??關(guān)于Scoped Storage在Android Q上的所有行為都是在AndroidStudio上的模擬器上驗(yàn)證的,模擬器系統(tǒng)版本為QPP4。
關(guān)于Scoped Storage
關(guān)于Scoped Storage在開(kāi)始之前,先簡(jiǎn)單說(shuō)說(shuō)Scoped Storage的理解。要理解Google引入這個(gè)feature的原因,你只需要隨便找一臺(tái)Android手機(jī),打開(kāi)文件管理器:
現(xiàn)在大家明白了吧?在Q以前,任何一個(gè)APP, 一旦拿到了外部權(quán)限(
WRITE_EXTERNAL_STORAGE)后,就可以在你的內(nèi)部存儲(chǔ)的根目錄下肆意建立文件夾了,這導(dǎo)致幾乎每個(gè)Android用戶(hù)的內(nèi)部存儲(chǔ)活像一個(gè)垃圾桶,想必大多數(shù)人都體驗(yàn)過(guò)在這一堆文件夾中定位自己的某一個(gè)文檔的痛苦吧。
Google想必也是聽(tīng)到了用戶(hù)們的抱怨,下決心要好好管一管這個(gè)事了,引入了Scoped Storage來(lái)防止App們到處建文件夾的行為,而且態(tài)度還挺強(qiáng)硬,不管你targetSDK調(diào)不調(diào)到29,反正只要運(yùn)行在Q上,Scoped Storage就會(huì)強(qiáng)制適用。所以在第二個(gè)beta版本發(fā)布后,很多用戶(hù)發(fā)現(xiàn)不少APP包括微信的媒體選擇器都掛了。但這沒(méi)持續(xù)多久,Google就心軟了,在beta3時(shí)又放寬了適用策略,表示給大家一些適配的時(shí)間,但是明年Android R發(fā)布時(shí)就不給機(jī)會(huì)了,一律強(qiáng)制適用。
到目前為止,Scoped Storage的適用策略如下:
- targetSDK = 29, 默認(rèn)開(kāi)啟Scoped Storage, 但可通過(guò)在manifest里添加
requestLegacyExternalStorage = true關(guān)閉; - targetSDK < 29, 默認(rèn)不開(kāi)啟Scoped Storage, 但可通過(guò)在manifest里添加
requestLegacyExternalStorage = false打開(kāi);
有兩點(diǎn)要注意:
- 當(dāng)你的targetSDK < 29,并且想通過(guò)
requestLegacyExternalStorage來(lái)打開(kāi)Scoped Storage策略時(shí),你需要把compileSdkVersion上調(diào)到29, 否則會(huì)編譯失敗。另外,可在運(yùn)行時(shí)通過(guò)Environment.isExternalStorageLegacy()判斷Scoped Storage策略是否打開(kāi)。 - 當(dāng)修改了
requestLegacyExternalStorage屬性的值,必須要卸載掉舊APK,重新安裝才會(huì)生效。
接下來(lái)我們通過(guò)實(shí)際的例子來(lái)對(duì)比Scoped Storage策略適用前后的一些行為變化。
適配心得
1. getExternalStorageDirectory(), getExternalStoragePublicDirectory()讀寫(xiě)權(quán)限變化
在之前,只要你有外部存儲(chǔ)權(quán)限,你可以通過(guò)以下的操作,在內(nèi)部?jī)?chǔ)存肆意構(gòu)建自己的目錄結(jié)構(gòu):
File dir = new File(Environment.getExternalStorageDirectory(), "my_dir");
if(!dir.exists()){
dir.mkdir();
}
但是Scoped Storage引入后,你會(huì)發(fā)現(xiàn)以上代碼根本不起作用了,這樣APP就無(wú)法再亂建文件夾啦。
2. Java File API, BitmapFactory.decodeFile()無(wú)法讀寫(xiě)app-specific目錄之外的地方
- app-specific目錄:即通過(guò)context. getExternalFilesDir()返回的目錄,一般為
/storage/emulated/0/Android/data/<package name>/files/, 這是屬于APP的私有目錄,在該目錄下的讀寫(xiě)是不需要申請(qǐng)權(quán)限的,當(dāng)APP卸載時(shí),系統(tǒng)會(huì)清理該目錄。值得一提的是,在Q之前,其他擁有外部存儲(chǔ)權(quán)限的APP其實(shí)也是可以讀寫(xiě)該目錄的,但從Q開(kāi)始,這個(gè)行為被禁止了。
當(dāng)你獲取到一個(gè)app-specific目錄之外的文件路徑時(shí),你也許會(huì)這么這么做: 將文件路徑傳給FileOutputStream或者FileWriter,然后開(kāi)始讀寫(xiě)操作;又或者該文件是張圖片,你通過(guò)BitmapFactory.decodeFile()來(lái)獲取到Bitmap對(duì)象。
比如我在項(xiàng)目中曾見(jiàn)過(guò)這種做法:通過(guò)MediaStore API中的DATA字段獲取到圖片的路徑,接著就通過(guò)BitmapFactory.decodeFile()獲取Bitmap對(duì)象。
只要你獲得了外部存儲(chǔ)權(quán)限, 這么做沒(méi)問(wèn)題。但Scoped Storage適用之后, 這些行為也被禁止了。谷歌推薦采用FileDescriptor的方式,如下:
ContentResolver cr = context.getContentResolver();
ParcelFileDescriptor fd = cr.openFileDescriptor(captureUri, "r");
//接下來(lái)就可以讀寫(xiě)了
FileInputStream istream = new FileInputStream(fd.getFileDescriptor());//讀
FileOutputStream ostream = new FileOutputStream(fd.getFileDescriptor());//寫(xiě)
//對(duì)于圖片的情況,可以這么做
Bitmap bitmap = BitmapFactory.decodeFileDescriptor(fd.getFileDescriptor());
順便提一下,關(guān)于Media.DATA, 在Scoped Storage的官方介紹頁(yè)面里也有這么一句話(huà):
Don't load media files using the deprecated DATA columns.
想必大家也注意到了,以上操作都必須是在獲取了文件Uri的前提下才能進(jìn)行,文件Uri的獲取方式很多,這里不展開(kāi)討論。你只需要知道,你無(wú)法再通過(guò)文件路徑跟app-specific目錄外的文件打交道了。
3. APP產(chǎn)生的文件只能通過(guò)MediaStore API寫(xiě)入磁盤(pán)
前面也提到了,你無(wú)法直接通過(guò)文件路徑來(lái)讀寫(xiě)app-specific目錄外的位置了。你也許會(huì)說(shuō)那我往app-specific里存不就完事了嗎,更不用申請(qǐng)存儲(chǔ)權(quán)限, 還不怕被其他應(yīng)用窺探到文件內(nèi)容。是的,谷歌確實(shí)推薦這么做,但并不是所有的數(shù)據(jù)都適合放在這里。假如你的APP是圖像或視頻類(lèi)應(yīng)用,使用過(guò)程中產(chǎn)生的圖片視頻就不適合放在app-specific里,首先是這個(gè)目錄路徑太深,用戶(hù)不好查找,其次是這一類(lèi)數(shù)據(jù)用戶(hù)不希望隨應(yīng)用卸載而被刪掉。所以必須要尋求放在app-specific目錄之外的地方。但正如前面所說(shuō),你必須要有Uri才能讀寫(xiě),這個(gè)時(shí)候你就得用到MediaStore API了,下面以創(chuàng)建圖片為例:
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, fileName);
contentValues.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis());
contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg");
Uri uri = context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues);
那么這個(gè)時(shí)候你就有了一個(gè)Uri了,接著就可以按照上述所提到的使用FileDescriptor的方式去寫(xiě)文件了。不過(guò)這也有個(gè)問(wèn)題,你往MediaStore里插入一條記錄后,對(duì)應(yīng)Uri就可能被其他應(yīng)用檢索到,但又可能找不到這條記錄對(duì)應(yīng)的那個(gè)文件(因?yàn)榇藭r(shí)你的文件可能還沒(méi)真正寫(xiě)入),這個(gè)問(wèn)題Google也給了一個(gè)解決方案。
再看另外一個(gè)更為常見(jiàn)的例子—調(diào)用相機(jī)拍攝并存儲(chǔ)照片,這個(gè)操作在Android Developer上的training中提供了最佳實(shí)踐,這個(gè)例子中將照片存在了app-specific目錄,但在實(shí)際業(yè)務(wù)中我們更可能是放在app-specific目錄之外,只要你有外部存儲(chǔ)權(quán)限,這是可以做到的,但是在Scoped Storage策略下,你必須得通過(guò)MediaStore API來(lái)產(chǎn)生照片的Uri了,然后通過(guò)以下語(yǔ)句傳給Intent takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI);
那么接下來(lái)你可能會(huì)有兩個(gè)問(wèn)題:
-
問(wèn)題1
上面通過(guò)MediaStore創(chuàng)建Uri的時(shí)候,我們沒(méi)有指定文件路徑(MediaStore.Images.Media.DATA),那文件最終會(huì)存到哪?
系統(tǒng)會(huì)按分類(lèi)自動(dòng)幫你存入到相應(yīng)的文件夾下,默認(rèn)在Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_XXXX)返回的路徑下,比如圖片就是Environment.DIRECTORY_PICTURES, 音頻文件就是Environment. DIRECTORY_MUSIC……
-
問(wèn)題2
這樣的話(huà)那我的APP產(chǎn)生的圖片豈不是跟其他APP的圖片放在通過(guò)文件夾下,這樣不是也很混亂嗎? 不用擔(dān)心,你可以通過(guò)Media.RELATIVE_PATH建立自己的二級(jí)目錄,假如上面的圖片我想放到Pictures/MY_PIC/目錄下,只需要這么做:
contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/MY_PIC");
圖片也不一定只能存到Pictures中,也可以放到DCIM目錄中,也通過(guò)上述字段來(lái)實(shí)現(xiàn),但如果你這么做的話(huà):
contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment. DIRECTORY_MOVIES);
你會(huì)收到如下提示:
Primary directory Movies not allowed for content://media/external/images/media; allowed directories are [DCIM, Pictures]
后言
以上便是本人對(duì)Scoped Storage的一些適配心得,希望能夠?qū)Υ蠹矣兴鶐椭?。如有錯(cuò)誤,歡迎指正。另外,在Android Q的正式版發(fā)布時(shí)以上的行為可能還會(huì)發(fā)生變化。 關(guān)于Scoped Storage更全面的信息,建議大家閱讀參考鏈接。