適配到Android 12,全版本支援儲存圖片到相簿方案
由於Google對使用者隱私和系統安全做的越來越完善,應用對一些敏感資訊的操作越來越難。比如最常見的共享儲存空間的訪問,像儲存圖片到相簿這種常見的需求。
Android 6。0
以前,應用要想儲存圖片到相簿,只需要透過
File
物件開啟IO流就可以儲存;
Android 6。0
添加了執行時許可權,需要先申請儲存許可權才可以儲存圖片;
Android 10
引入了分割槽儲存,但不是強制的,可以透過清單配置
android:requestLegacyExternalStorage=“true”
關閉分割槽儲存;
Android 11
強制開啟分割槽儲存,應用以 Android 11 為目標版本,系統會忽略
requestLegacyExternalStorage
標記,訪問共享儲存空間都需要使用
MediaStore
進行訪問。
我們透過上面的時間線可以看出,Google對系統公共儲存的訪問的門檻逐漸升高,摒棄傳統的Java File物件直接訪問檔案的方式,想將Android的共享空間訪問方式統一成一套API。這是我們的主角
MediaStore
MediaStore
是Android誕生之初就存在的一套媒體庫框架,透過文件可以看到
Added in API level 1
。但是由於最初系統比較開放,我們對它的使用並不多,但是隨著分割槽儲存的開啟,它的舞臺會越來越多。
所以怎麼才是正確的儲存圖片的方案呢?話不多說,步入正題
大致流程
我們訪問
MediaStore
有點像訪問資料庫,實際上就是資料庫,只是多了一些IO流的操作。將圖片想象成資料庫中的一條資料,我們怎麼插入資料庫呢,回想sqlite怎麼操作的。
實際上
Mediastore
也是這樣的:
先將圖片記錄插入媒體庫,獲得插入的Uri;
然後透過插入Uri開啟輸出流將檔案寫入;
大致流程就是這樣子,只是不同的系統版本有一些細微的差距;
Android 10 之前的版本需要申請儲存許可權,
Android 10及以後版本是不需要讀寫許可權的
Android 10 之前是透過File路徑開啟流的,所以需要判斷檔案是否已經存在,否者的話會將以存在的圖片給覆蓋
Android 10 及以後版本添加了
IS_PENDING
狀態標識,為0時其他應用才可見,所以在圖片儲存過後需要更新這個標識。
相信說了這麼多,大家已經不耐煩了,不慌程式碼馬上就來。
編碼時間
這裡用儲存Bitmap到相簿為例,儲存檔案 和 許可權申請的邏輯,這裡就不貼程式碼了,詳見 Demo
檢查清單檔案,如果應用裡沒有其他需要儲存許可權的需求可以加上
android:maxSdkVersion=“28”
,這樣Android 10的裝置的應用詳情就看不到這個許可權了。
<!——Android Q之後不需要儲存許可權,完全使用MediaStore API來實現——>
android:name= “android。permission。READ_EXTERNAL_STORAGE” android:maxSdkVersion= “28” /> android:name= “android。permission。WRITE_EXTERNAL_STORAGE” android:maxSdkVersion= “28” /> 儲存圖片到相簿。這裡為了演示方便,生產環境記得在IO執行緒處理,ANR了可不怪我。 private fun saveImageInternal () { val uri = assets 。 open ( “wallhaven_rdyyjm。jpg” )。 use { it 。 saveToAlbum ( this , fileName = “save_wallhaven_rdyyjm。jpg” , null ) } ?: return Toast 。 makeText ( this , uri 。 toString (), Toast 。 LENGTH_SHORT )。 show () } 是不是很簡單,詳細實現是怎麼弄的,接著往下看。這是一個儲存Bitmap的擴充套件方法 /** * 儲存Bitmap到相簿的Pictures資料夾 * * @param context 上下文 * @param fileName 檔名。 需要攜帶字尾 * @param relativePath 相對於Pictures的路徑 * @param quality 質量 */ fun Bitmap 。 saveToAlbum ( context : Context , fileName : String , relativePath : String ? = null , quality : Int = 75 ): Uri ? { val resolver = context 。 contentResolver val outputFile = OutputFileTaker () // 插入圖片資訊 val imageUri = resolver 。 insertMediaImage ( fileName , relativePath , outputFile ) if ( imageUri == null ) { Log 。 w ( TAG , “insert: error: uri == null” ) return null } // 透過Uri開啟輸出流 ( imageUri 。 outputStream ( resolver ) ?: return null )。 use { val format = fileName 。 getBitmapFormat () // 儲存圖片 this @saveToAlbum 。 compress ( format , quality , it ) // 更新 IS_PENDING 狀態 imageUri 。 finishPending ( context , resolver , outputFile 。 file ) } return imageUri } 插入圖片到媒體庫 需要注意Android 10以下需要圖片查重,防止檔案被覆蓋的問題。 // 儲存位置,這裡使用Picures,也可以改為 DCIM private val ALBUM_DIR = Environment 。 DIRECTORY_PICTURES /** * 用於Q以下系統獲取圖片檔案大小來更新[MediaStore。Images。Media。SIZE] */ private class OutputFileTaker ( var file : File ? = null ) /** * 插入圖片到媒體庫 */ private fun ContentResolver 。 insertMediaImage ( fileName : String , relativePath : String ?, outputFileTaker : OutputFileTaker ? = null ): Uri ? { // 圖片資訊 val imageValues = ContentValues ()。 apply { val mimeType = fileName 。 getMimeType () if ( mimeType != null ) { put ( MediaStore 。 Images 。 Media 。 MIME_TYPE , mimeType ) } // 插入時間 val date = System 。 currentTimeMillis () / 1000 put ( MediaStore 。 Images 。 Media 。 DATE_ADDED , date ) put ( MediaStore 。 Images 。 Media 。 DATE_MODIFIED , date ) } // 儲存的位置 val collection : Uri if ( Build 。 VERSION 。 SDK_INT >= Build 。 VERSION_CODES 。 Q ) { val path = if ( relativePath != null ) “${ALBUM_DIR}/${relativePath}” else ALBUM_DIR imageValues 。 apply { put ( MediaStore 。 Images 。 Media 。 DISPLAY_NAME , fileName ) put ( MediaStore 。 Images 。 Media 。 RELATIVE_PATH , path ) put ( MediaStore 。 Images 。 Media 。 IS_PENDING , 1 ) } collection = MediaStore 。 Images 。 Media 。 getContentUri ( MediaStore 。 VOLUME_EXTERNAL_PRIMARY ) // 高版本不用查重直接插入,會自動重新命名 } else { // 老版本 val pictures = Environment 。 getExternalStoragePublicDirectory ( ALBUM_DIR ) val saveDir = if ( relativePath != null ) File ( pictures , relativePath ) else pictures if (! saveDir 。 exists () && ! saveDir 。 mkdirs ()) { Log 。 e ( TAG , “save: error: can‘t create Pictures directory” ) return null } // 檔案路徑查重,重複的話在檔名後拼接數字 var imageFile = File ( saveDir , fileName ) val fileNameWithoutExtension = imageFile 。 nameWithoutExtension val fileExtension = imageFile 。 extension // 查詢檔案是否已經存在 var queryUri = this 。 queryMediaImage28 ( imageFile 。 absolutePath ) var suffix = 1 while ( queryUri != null ) { // 存在的話重新命名,路徑後面拼接 fileNameWithoutExtension(數字)。png val newName = fileNameWithoutExtension + “(${suffix++})。” + fileExtension imageFile = File ( saveDir , newName ) queryUri = this 。 queryMediaImage28 ( imageFile 。 absolutePath ) } imageValues 。 apply { put ( MediaStore 。 Images 。 Media 。 DISPLAY_NAME , imageFile 。 name ) // 儲存路徑 val imagePath = imageFile 。 absolutePath Log 。 v ( TAG , “save file: $imagePath” ) put ( MediaStore 。 Images 。 Media 。 DATA , imagePath ) } outputFileTaker ?。 file = imageFile // 回傳檔案路徑,用於設定檔案大小 collection = MediaStore 。 Images 。 Media 。 EXTERNAL_CONTENT_URI } // 插入圖片資訊 return this 。 insert ( collection , imageValues ) } /** * Android Q以下版本,查詢媒體庫中當前路徑是否存在 * @return Uri 返回null時說明不存在,可以進行圖片插入邏輯 */ private fun ContentResolver 。 queryMediaImage28 ( imagePath : String ): Uri ? { if ( Build 。 VERSION 。 SDK_INT >= Build 。 VERSION_CODES 。 Q ) return null val imageFile = File ( imagePath ) if ( imageFile 。 canRead () && imageFile 。 exists ()) { Log 。 v ( TAG , “query: path: $imagePath exists” ) // 檔案已存在,返回一個file://xxx的uri // 這個邏輯也可以不要,但是為了減少媒體庫查詢次數,可以直接判斷檔案是否存在 return Uri 。 fromFile ( imageFile ) } // 儲存的位置 val collection = MediaStore 。 Images 。 Media 。 EXTERNAL_CONTENT_URI // 查詢是否已經存在相同圖片 val query = this 。 query ( collection , arrayOf ( MediaStore 。 Images 。 Media 。 _ID , MediaStore 。 Images 。 Media 。 DATA ), “${MediaStore。Images。Media。DATA} == ?” , arrayOf ( imagePath ), null ) query ?。 use { while ( it 。 moveToNext ()) { val idColumn = it 。 getColumnIndexOrThrow ( MediaStore 。 Images 。 Media 。 _ID ) val id = it 。 getLong ( idColumn ) val existsUri = ContentUris 。 withAppendedId ( collection , id ) Log 。 v ( TAG , “query: path: $imagePath exists uri: $existsUri” ) return existsUri } } return null } 改變標誌位,通知媒體庫 到這裡整個圖片儲存就結束了。怎麼樣是不是很簡單,趕緊去系統圖庫裡看看圖片是不是已經在了。 private fun Uri 。 finishPending ( context : Context , resolver : ContentResolver , outputFile : File ? ) { val imageValues = ContentValues () if ( Build 。 VERSION 。 SDK_INT < Build 。 VERSION_CODES 。 Q ) { if ( outputFile != null ) { // Android 10 以下需要更新檔案大小欄位,否則部分裝置的相簿裡照片大小顯示為0 imageValues 。 put ( MediaStore 。 Images 。 Media 。 SIZE , outputFile 。 length ()) } resolver 。 update ( this , imageValues , null , null ) // 通知媒體庫更新,部分裝置不更新 相簿看不到 ??? val intent = Intent ( Intent 。 ACTION_MEDIA_SCANNER_SCAN_FILE , this ) context 。 sendBroadcast ( intent ) } else { // Android Q添加了IS_PENDING狀態,為0時其他應用才可見 imageValues 。 put ( MediaStore 。 Images 。 Media 。 IS_PENDING , 0 ) resolver 。 update ( this , imageValues , null , null ) } } 雖然程式碼有點多,但是相信 大家期盼已久了 ImageExt。kt 圖片分享 有很多場景是儲存圖片之後,呼叫第三方分享進行圖片分享,但是一些文章不管三七二十一說需要用 FileProvider 。實際上這是不準確的,部分情況是需要,還有一些場景是不需要的。 我們只需要記得 FileProvider是給其他應用分享應用私有檔案的 就夠了,只有在我們需要將應用沙盒內的檔案共享出去的時候才需要配置FileProvider。例如: 應用內更新,系統包安裝器需要讀取系統沙盒內的apk檔案(如果你下載了公共路徑那另說) 應用內沙盒圖片分享,微信已經要求一定要透過FileProvider才可以分享圖片了(沒有適配的趕緊看看分享還能用嗎) 但是儲存到系統圖庫並分享的場景明顯就不符合這個場景,因為相簿不是應用私有的空間。 private fun shareImageInternal () { val uri = assets 。 open ( “wallhaven_rdyyjm。jpg” )。 use { it 。 saveToAlbum ( this , fileName = “save_wallhaven_rdyyjm。jpg” , null ) } ?: return val intent = Intent ( Intent 。 ACTION_SEND ) 。 putExtra ( Intent 。 EXTRA_STREAM , uri ) 。 setType ( “image/*” ) startActivity ( Intent 。 createChooser ( intent , null )) } 所以在使用FileProvider要區分一下場景,是不是可以不需要,因為FileProvider是一種特殊的ContentProvider,每一個內容提供者在應用啟動的時候都要初始化,所以也會拖慢應用的啟動速度。 作者:de得得de 轉載來源於: https://juejin。cn/post/7042218651482587172 如有侵權,請聯絡刪除!