公眾號原標題:測試:“系統相冊里怎么看不到我剛保存的圖片,是我操作不對嗎?”
一、序
Hi,大家好,我是承香墨影!
App 內,創建一個文件並保存文件到本地的需求,是很常見的 I/O 操作。而如果這個文件變成了一張圖片,那你涉及到的就不僅僅是一個 I/O 操作了,還需要考慮如何更新 MediaStore,這樣才可以在系統相冊中,看到它。
這里說的 MediaStore,本質上是 Android 維護的一個文件系統的數據庫,它記錄了當前磁盤上所有的文件索引,我們可以通過它,快速的查找當前系統的文件。
MediaStore 刷新的時機是不一定的,也就是說,保存的一張圖片文件,MediaStore 並不會立即刷新文件系統,將此文件索引記錄下來。而系統本身是存在一些自動刷新 MediaStore 的時機,例如:重啟手機。表現就是,當你保存了一張圖片到本地文件夾中之后,通過文件管理器類的 App,可以在目錄下找到這漲照片,但是在系統相冊中,是無法立即看到它的,同時你想用諸如 微信、QQ 去分享這張圖片的時候,也是找不到的。所以在我們保存圖片文件之后,去觸發系統刷新 MediaStore 就尤為重要了。
本文就來講講,如何在保存圖片之后,刷新系統 MediaStore 那些事。
刷新系統 Media 通常有如下幾種方式:
- 通過操作 MediaStore 類。
- 發送廣播更新 MediaStore。
- 通過操作 MediaScannerConnection 類。
這三種方式,各有優缺點,我們慢慢分析。
二、操作 MediaStore
這里說的操作 MediaStore,實際是操作它的一個內部類 MediaStore.Image.Media
,它提供了幾個 inserImage ()
方法,供我們向 MediaStore 中插入圖片數據,並產生一個縮略圖。
這個方法傳遞進去的是一個 Bitmap 對象,其余的 title
和 description
分別是圖片文件的名稱和一段描述。
舉個 Kotlin 的例子:
MediaStore.Images.Media.insertImage(
contentResolver,
mShareBitmap!!,
"image_file",
"file")
使用 inserImage()
方法,不需要我們指定路徑,會自動將圖片保存至 Picture
目錄下。它也不支持我們指定路徑。如果我們對圖片保存的路徑沒有要求,並且保存的是一個 Bitmap 對象,此方法是非常的方便的。
細心的朋友可能已經發現了 inserImage()
還有一個其他的重載方法,支持我們傳遞進去一個圖片文件路徑,不過我並不推薦使用這個方法,因為它會將原本的圖片,再 Copy 一份,到 Picture
目錄下,也就是說你最終在磁盤上會得到兩張相同的圖片。
這一點,看源碼是最清晰的。它首先使用 BitmapFactory.decodeFile()
方法,得到一個 Bitmap,然后再去調用保存 Bitmap 對象的 inserImage()
方法,所以我們最終在磁盤上會有兩張一模一樣的圖片。
三、發送廣播
3.1 那些廣播可以更新 MediaStore
說到廣播,在 Android 4.4 之前,是可以通過 ACTION_MEDIA_MOUNTED
廣播,來通知系統刷新 MediaStore 的,不過假如你現在還在依賴這條廣播,你會得到一個錯誤信息。
E/AndroidRuntime(23718): java.lang.SecurityException: Permission Denial: not allowed to send broadcast android.intent.action.MEDIA_MOUNTED from pid=23718, uid=10097
在 Android 4.4 之后,這個廣播只能由系統進行廣播,App 只能對該廣播進行監聽,在當前的系統分布環境下,這條路已經走不通了。
這樣設計也很好理解,畢竟掃描全盤是非常的耗資源,所以系統肯定要把全盤掃描的權限拿在自己手里不開放出來,避免被第三方 App 濫用。
不過 Android 依然給我們提供了替代方案,那就是用 MediaScannerConnection
或者發送 ACTION_MEDIA_SCANNER_SCAN_FILE
廣播。
接下來就來說說 ACTION_MEDIA_SCANNER_SCAN_FILE
這個廣播。
3.2 使用廣播刷新
通過廣播刷新 MediaStore 的方式非常的簡單,只需要指定文件路徑和 Action 就好了。
val saveAs = "Your_Created_Image_File_Path"
val contentUri = Uri.fromFile(File(saveAs))
val mediaScanIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,contentUri)
sendBroadcast(mediaScanIntent)
正常情況下,它是沒有問題的,不過假如你發現它不生效,就需要檢查一下你文件的路徑是否傳遞正確。
通過查看 MediaScannerReceiver 的源碼,可以發現 onReceive()
方法中,針對 ACTION_MEDIA_SCANNER_SCAN_FILE
還有一個限制條件,那就是傳遞進去的文件絕對路徑,必須是以 Environment.getExternalStorageDirectory()
方法的返回值開頭。
有興趣可以仔細閱讀源碼,這里是 Android 6.0 的源碼:
本質上,還是 /mnt/sdcard/
路徑就認,而 /sdcard/
就無法使用,所以只要我們不硬編碼文件路徑,這個問題基本上也就不存在。
這里也提醒我們,一定不要在代碼里,硬編碼文件路徑,算是一個編碼規范了。
3.3 刪除文件后刷新MediaStore
本文一直都在說添加新文件的時候,如何刷新 MediaStore 的問題。但是其實還涉及到另外一個問題,我們刪除了一個已經被收錄在 MediaStore 中的文件,怎么辦?在本文里也順便講一下。
既然放在這一小節講,首先想到的是,直接再發一個廣播出去,刷新這個路徑,但是查閱最終執行掃描前的 MediaScanner 的 scanSingleFile()
方法,你就會知道這樣的方式是行不通的。
在這里可以看到,當你傳遞進去的文件路徑,指向的文件不存在的時候,會直接 return
出去了,就執行不到刷新的邏輯里。
所幸的是,我在 DownloadManager 類中,找到了刷新刪除文件的解決辦法,依然是通過 ContentResolver 來解決。
這里通過 ContentResolver 來向 MediaStore 中發起一個刪除文件的操作,只需要傳遞進去一個文件的絕對路徑即可。
四、操作 MediaScannerConnection 類
4.1 使用 MediaScannerConnection
刷新 MediaStore 還有一個最通用也是我推薦的一個方法,那就是使用 MediaScannerConnection 進行操作。
不同於 MediaStore.Image.Media
和廣播的方式,使用 MediaScannerConnection 不僅可以保存文件,還可以指定文件路徑,最好的就是,它還支持刷新完成的回調。
如果我們對時序有要求,並且需要制定文件保存路徑的話,最好的方式就是直接使用 MediaScannerConnection 類進行操作,並且這也應該是兼容最好的方式。
這里我們主要是利用 MediaScannerConnection 類的 scanFile()
方法進行觸發掃描。
通過 scanFile()
方法,我們只需要制定一個待刷新的文件路徑和對應的 MimeType 即可,它支持傳遞多個路徑,也可就是支持批量掃描。
注意這里的 MimeType 是一定要填寫的,並且不能寫通配符 */*
或 null
,否則會導致刷新失敗,通常我們保存的是一個圖片的話,只需要傳遞 image/jpeg
即可。
最后一個參數, onScanCompletedListener 中可以監聽我們掃描的結果,需要注意的是,假如這里掃描的是多個文件路徑,它也會被回調多次。所以如果有什么在刷新之后的后續操作,就需要特殊處理一下(原因后面是說)。
MediaScannerConnection.scanFile(this
, arrayOf(picFile.absolutePath)
, arrayOf("image/jpeg"), { path, uri ->
Log.i("cxmyDev", "onScanCompleted : " + path)
})
scanFile()
方法的使用還是很簡單的,沒什么需要額外交代的了。
4.2 MediaScannerConnection 原理
依然是從源碼中找答案,我們先來看看 scanFile()
方法的實現。
在 scanFile()
里,創建了一個 MediaScannerConnection 並調用了 connect()
方法。接下來我們繼續看 connect()
方法。
在 connect()
方法中,可以看到,它實際上是 bindServer()
了 MediaScannerService
這個系統服務,所有的操作都在 MediaScannerService 中。
MediaScannerService 的源碼,有興趣可以去這里查看:
這是一個系統服務,我到這里就不繼續跟下去了,回過頭來繼續看源碼。
不過看到 connect()
方法的時候,那對應的,一定有 disconnect()
方法存在了,前面 bindService()
了一個系統服務,我們一定要有一個時機去調用 unbindService()
,否則就會造成泄露。
MediaScannerConnection 確實提供了 disconnect()
方法,但是我們通過 scanFile()
方法拿不到這個對象。這里處理的非常的巧妙,不需要我們手動去觸發 disconnect()
,它是自維護的。
繼續看 scanFile()
里被我們忽略的 ClientProxy
類,邏輯都在這里面。
在 scanNextPath()
中,會去判斷傳遞進去的文件路徑是否都掃描過,如果已經沒有更多需要掃描的路徑了,就自己去調用 disconnect()
方法,回收資源。
到這里,也就解答了我們剛才的疑問,MediaScannerConnection 已經幫我們考慮了很多事情,我們只需要調用它的標准 API 就好了。
五、查缺補漏
5.1 掃描其他類型的媒體文件
在 Android 下,不僅僅只有圖片,對於其他媒體文件,使用本文介紹的方法,也是適用的。
5.2 避免某個目錄被 MediaStore 掃描
看完到這里應該會知道,哪怕我們什么都不做,在手機下次重啟的時候,系統依然會去全盤掃描文件系統,更新 MediaStore。
但是有時候,我們有一些目錄下的媒體文件,並不想讓 MediaStore 掃描到,例如在 SDCard 上緩存的圖片、圖標等,這些我們都不想出現在系統相冊內。
解決辦法其實在官方文檔中已經寫了。
https://developer.android.com/guide/topics/data/data-storage.html
這里簡單說一下,當不需要被 MediaStore 掃描的目錄下,創建一個名為 .nomedia
的空文件,它將阻止媒體掃描程序讀取這個目錄下的媒體文件。也就無法通過 MediaStore 分享給其他程序。
當然,一些重要的文件,依然建議放在自己的私有目錄下。
六、小結
關於在 MediaStore 刷新圖片,本文基本上就算是講清楚了。我推薦的方法,是使用 MediaScannerConnection 來實現。
你看了本文,還有什么更多的問題可以在留言區討論,如果覺得好,可以這篇文章,分享給你需要的朋友們。
今天在公眾號后台回復成長『成長』,將會得到我整理的一些學習資料,也能回復『加群』,一起學習進步。
推薦閱讀:
- 漫畫:程序員,你能“管理”好你的產品經理嗎?
- App 多語言翻譯,機器翻譯也能快如閃電!
- 2017 最權威區塊鏈報告(內含下載)
- Google 的 Flutter 學習資料!
- 遠程控制智能電視,方案已開源!