Android操作檔案也太難了趴,File vs DocumentFile 以及 DocumentsProvider vs FileProvider 的異同

語言: CN / TW / HK

highlight: agate theme: smartblue


DocumentFile 與 DocumentsProvider 到底怎麼用?

前言

Android 的檔案操作真的是太難了,隨著版本的迭代,許可權的收緊,給了開發者過渡時期,然後再次收緊許可權。搞得開發者都不知道怎麼操作檔案了。

本文的內容並不涉及到多媒體檔案選擇,都是以檔案的概念來講的,多媒體檔案的操作通過 MediaStore 操作即可,並不複雜,就不在本文的討論範圍了。

關於檔案操作許可權變化的大節點是 Android10,加入了分割槽儲存的特性

什麼是分割槽儲存?

為了讓使用者更好地管理自己的檔案並減少混亂,以 Android 10(API 級別 29)及更高版本為目標平臺的應用在預設情況下被賦予了對外部儲存空間的分割槽訪問許可權(即分割槽儲存)。 此類應用只能訪問外部儲存空間上的應用專屬目錄,以及本應用所建立的特定型別的媒體檔案,不能訪問其他應用的外部儲存空間。

為了開發者過渡,可以選擇讓應用不適配分割槽儲存:

以 Android 9(API 級別 28)或更低版本為目標平臺。 如果您以 Android 10(API 級別 29)或更高版本為目標平臺,請在應用的清單檔案中將 requestLegacyExternalStorage 的值設定為 true 當您將應用更新為以 Android 11(API 級別 30)為目標平臺後,如果應用在搭載 Android 11 的裝置上執行,系統會忽略 requestLegacyExternalStorage 屬性。

除了分割槽儲存之外關於操作檔案概念還有SAF,額,不是startActivityForResult,是(Storage Access Framework)儲存訪問框架:

什麼是儲存訪問框架 (SAF)?

Android 4.4(API 級別 19)引入了儲存訪問框架 (SAF)。

藉助 SAF,使用者可輕鬆瀏覽和開啟各種文件、圖片及其他檔案,而不用管這些檔案來自其首選文件儲存提供程式中的哪一個。

使用者可通過易用的標準介面,跨所有應用和提供程式以統一的方式瀏覽檔案並訪問最近用過的檔案。

雲端儲存服務或本地儲存服務可實現用於封裝其服務的 DocumentsProvider,從而加入此生態系統。客戶端應用如需訪問提供程式中的文件,只需幾行程式碼即可與 SAF 整合。

關於以上幾點知識點,我總結了幾個疑問,大家可以帶著疑問往下看:

  1. 通過 File 能不能把檔案存入到SD卡?能存到哪些資料夾?每一種檔案都能存嗎?有沒有版本限制?
  2. DocumentFiler 如何使用?有沒有版本限制?
  3. 如何通過 DocumentFile 存入檔案呢?和 File 存檔案有什麼區別?
  4. 明確的知道一個檔案的路徑,能不能直接通過 File 或 DocumentFile 讀取到?
  5. 相對少見的 DocumentsProvider 是如何使用的?
  6. 有了 DocumentsProvider 為什麼還要 FileProfider?他們之間有什麼異同?

話不多說,Let's go

300.png

一、File 與 DocumentFile

File 與 DocumentFile 的搜尋解釋如下:

File 是 Java 中的一個類,用於表示作業系統中的檔案或目錄。DocumentFile 是 Android 中的一個類,用於表示儲存訪問框架(SAF)中的檔案或目錄。 DocumentFile 可以是基於 File 的,也可以是基於另一種抽象稱為 DocumentProvider 的。DocumentFile 的優點是可以訪問更多的儲存位置,例如雲端、SD 卡等。

File 可以獲取檔案的絕對路徑和名稱,而 DocumentFile 只能獲取 Uri 和顯示名稱。File 的效能比 DocumentFile 高,因為 DocumentFile 需要通過ContentResolver 查詢資料庫來獲取檔案資訊。

1.1 一、File操作檔案

到底是什麼意思我們使用一個例子來解釋,讀取 assets 目錄的檔案,直接通過 IO 流的方式寫入到 SD 卡。然後測試是否需要許可權?能寫入到哪些資料夾?有沒有版本限制?(本專案基於Tartget31,並且不使用 requestLegacyExternalStorage 適配)

程式碼如下: ```kotlin val downLoadPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath

    val isDirectory = parent.isDirectory
    val canRead = parent.canRead()
    val canWrite = parent.canWrite()

    YYLogUtils.w("${android.os.Build.VERSION.RELEASE} isDirectory:$isDirectory canRead:$canRead canWrite:$canWrite path:$downLoadPath")

    //獲取檔案流寫入檔案到File
    val newFile = File(parent.absolutePath + "/材料清單PDF.pdf")
    if (!newFile.exists()) {
         newFile.createNewFile()
    }

    val inputStream = assets.open("材料清單PDF.pdf")
    val inBuffer = inputStream.source().buffer()
    newFile.sink(true).buffer().use {
        it.writeAll(inBuffer)
        inBuffer.close()
    }

```

很簡單的程式碼,至於如何使用 IO 流寫入檔案到 SD 卡?額...這不是本文的重點,方式很多,這裡不展開介紹。

接下來這裡我以三臺裝置為例子,分別為Androd7、Android9、Android 12。我們分別檢視 Log 情況試試,

image.png

額,好像三個手機都不行額,什麼問題?SD卡許可權未申請的啦,不管是寫入到SD卡哪個目錄,還是要外接卡許可權的!

```kotlin extRequestPermission(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) {

 val downLoadPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath

 ...

newFile.sink(true).buffer().use {
    it.writeAll(inBuffer)
    inBuffer.close()
}

...

} ```

好了,加上許可權申請程式碼之後我們再看看三臺裝置的Log:

image.png

image.png

image.png

確實都已經執行完畢了!也都確實寫入成功了:

image.png

這沒什麼問題啊,說明只要有SD卡許可權,系統的 DownLoad 目錄都是可以寫入的。那是不是所以的 SD 卡目錄都能寫入呢?

我們建立一個自定義的資料夾試試呢?

程式碼如下:

```kotlin extRequestPermission(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) {

    val downLoadPath = Environment.getExternalStoragePublicDirectory("DownloadMyFiles").absolutePath
    val parent = File(downLoadPath)
    if (!parent.exists()){
        parent.mkdir()
    }
    val isDirectory = parent.isDirectory
    val canRead = parent.canRead()
    val canWrite = parent.canWrite()

    YYLogUtils.w("${android.os.Build.VERSION.RELEASE} isDirectory:$isDirectory canRead:$canRead canWrite:$canWrite path:$downLoadPath")

    //獲取檔案流寫入檔案到File
    val newFile = File(parent.absolutePath + "/材料清單PDF.pdf")
    if (!newFile.exists()) {
         newFile.createNewFile()
    }

    val inputStream = assets.open("材料清單PDF.pdf")
    val inBuffer = inputStream.source().buffer()
    newFile.sink(true).buffer().use {
        it.writeAll(inBuffer)
        inBuffer.close()
    }
}

```

接下來我們建立一個自定義的目錄 DownloadMyFiles 。申請許可權之後再度嘗試寫入:

image.png

image.png

image.png

這... Android10 一下的是可以下入的,高版本就無法寫入了,沒許可權操作!

image.png

低版本確實是可以寫入的!

我不用自定義目錄行了吧!那我寫到系統的其他目錄行了吧!

```kotlin extRequestPermission(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) {

    val downLoadPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).absolutePath

   ...

    newFile.sink(true).buffer().use {
        it.writeAll(inBuffer)
        inBuffer.close()
    }
}

```

比如我們寫入多媒體資料夾 DCIM 。會怎麼樣?其實是和自定義資料夾一樣的效果:

image.png

而 Android10 一下的版本都是可以正常寫入的:

image.png

所以我們才說,Android10 以上的系統是無法使用 File 來寫入的,只是谷歌給我們放開了一個口子,DownLoad資料夾是特殊處理的都能寫入。

那 Android10 以上的裝置想寫入自定義資料夾中的檔案,該如何操作呢?此時就輪到 DocumentFile 登場!

1.2 DocumentFile 操作檔案

File 可以直接使用 java.io.File 介面操作外接 SD 卡檔案,但是在 Android 10 以上版本需要申請特殊許可權或者使用 Storage Access Framework(SAF)。DocumentFile 則可以通過 SAF 在任何版本上訪問外接SD卡檔案。

SAF的外接 SD 卡的訪問由 DocumentsUI (com.android.documentsui) 提供支援。使用 ACTION_OPEN_DOCUMENT_TREE 跳轉到 DocumentsUI 的儲存選擇介面,之後使用者手動開啟外接儲存並選擇。

例如,我們可以通過標準的 SAF 開啟方式 Intent 的 方式使用:

```kotlin

fun wirteFile(){
   val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
   startActivityForResult(intent, 1)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { super.onActivityResult(requestCode, resultCode, resultData)

    if (resultCode == Activity.RESULT_OK && requestCode == 1) {

        resultData?.data?.let { uri ->
            YYLogUtils.w("開啟資料夾:$uri")
            DocumentFile.fromTreeUri(this, uri)
                // 在資料夾內建立新資料夾
                ?.createDirectory("DownloadMyFiles")
                ?.apply {
                    // 在新資料夾內建立檔案
                    YYLogUtils.w("在新資料夾內建立檔案")
                    createFile("text/plain", "test.txt")

                    // 通過檔名找到檔案
                    findFile("test.txt")?.also {
                        try {
                            // 在檔案中寫入內容
                            contentResolver.openOutputStream(uri)?.write("hello world".toByteArray())
                            YYLogUtils.w("在檔案中寫入內容完成")
                        }catch (e:Exception){
                            e.printStackTrace()
                        }
                    }
                    // 刪除檔案
                    // ?.delete()
                }
            // 刪除資料夾
            // ?.delete()

        }

    }

}

```

通過使用者指定資料夾之後我們就能拿到 DocumentFile 物件,就可以建立資料夾,建立檔案,刪除檔案等等操作:

image.png

使用 Intent 的方法還要使用者手動的選擇,這也太那啥了,難道我下載一個外掛或更新包還需要使用者去選存放在哪?笑話!

我們當然也能直接通過 DocumentFile 去操作,我們只需要從 File 轉換為 DocumentFile 就可以操作它的建立資料夾,建立檔案,或寫入檔案等操作了。

```kotlin extRequestPermission(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) {

     val downLoadPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
    val parent = File(downLoadPath)

    val documentFile: DocumentFile? = DocumentFile.fromFile(parent)

    YYLogUtils.w("documentFile:"+documentFile +" uri:"+documentFile?.uri)

    documentFile?.createDirectory("DownloadMyFiles")?.apply {
        createFile("text/plain", "test123")
        val findFile = findFile("test123.txt")
        YYLogUtils.w("findFile:"+findFile +" uri:"+findFile?.uri)
        findFile?.uri?.let {
            contentResolver.openOutputStream(it)?.write("hello world".toByteArray())
        }

    }
}

```

這樣不就能建立一個文字檔案了嗎?

image.png

依次類推,我們現在和 File 的邏輯一樣,我們先建立資料夾,再建立檔案,再開啟 outputstream 流,還是一樣的能把 asset 中的檔案通過 IO 流寫入到檔案中。只不過之前是通過 File 拿到 IO 流,現在是通過 contentResolver.openOutputStream 開啟一個 uri 的輸出流而已!

```kotlin extRequestPermission(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) {

     val downLoadPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
    val parent = File(downLoadPath)

    val documentFile: DocumentFile? = DocumentFile.fromFile(parent)

    YYLogUtils.w("documentFile:"+documentFile +" uri:"+documentFile?.uri)

     documentFile?.createDirectory("DownloadMyFiles")?.apply {
        val findFile = createFile("application/pdf", "材料清單PDF")
        YYLogUtils.w("findFile:"+findFile +" uri:"+findFile?.uri)

        findFile?.uri?.let {
            val outs = contentResolver.openOutputStream(it)
            val inBuffer = assets.open("材料清單PDF.pdf").source().buffer()
            outs?.sink()?.buffer()?.use {
                it.writeAll(inBuffer)
                inBuffer.close()
            }
        }

    }

}

```

寫入效果:

image.png

我看程式碼你這還是寫在 Download 資料夾下面的啊? 那這樣的話,我要 DocumentFile 寫檔案有什麼優勢? 我還不如使用 File 呢?

別慌,使用 DocumentFile 也是可以在 Android10 上寫入自定義的資料夾內的,但是呢,比較麻煩,比如先使用Intent的方式,手動選擇資料夾,並且授權同意權限!

大致的程式碼如下:

```kotlin val uri = Uri.parse("content://com.android.externalstorage.documents/tree/primary%3ADownloadMyFiles%2Fabcd")

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
intent.addFlags(
    Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
startActivityForResult(intent, 2)

```

選定指定資料夾之後,會出現授權的彈窗,每個系統實現的UI不同,大致如下:

image.png

只有申請了許可權之後把授權永久儲存起來,下次再使用這個資料夾就可以無需授權直接操作了

並且此時的操作方式換成了 DocumentFile.fromTreeUri 的方式,注意此時如果是用 DocumentFile.fromFile 是不行的,DocumentFile.fromFile 只能靜默寫入 DownLoad 資料夾。

但是還有一點,如果使用 DocumentFile.fromTreeUri 的話有一個大問題,我不知道 Uri 啊,按照常規是需要用 ACTION_OPEN_DOCUMENT 的方式獲取到 treeUri 才能獲取到 DocumentFile物件從而寫入檔案。

此時這就需要我們按照規則拼接 uri ,例如:

SD 卡目錄下 DownloadMyFile 的自定義資料夾下面的資料夾abcd:

image.png

故意搞一個長一點的路徑方便大家檢視 URI 的拼接規則,那麼這麼長的路徑下拼接規則則是 %3A %2F :

content://com.android.externalstorage.documents/tree/primary%3ADownloadMyFiles%2Fabcd

在回撥授權結果中處理:

```kotlin override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == 2) {
        //單獨申請指定資料夾許可權
        resultData?.data?.let {
            contentResolver.takePersistableUriPermission(
                it,
                Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
            )

            //進行檔案寫入的操作
            val documentFile: DocumentFile? = DocumentFile.fromTreeUri(mActivity, it)

            YYLogUtils.w("documentFile:" + documentFile + " uri:" + documentFile?.uri)

            documentFile?.run {
                val findFile = createFile("application/pdf", "材料清單PDF")
                YYLogUtils.w("findFile:" + findFile + " uri:" + findFile?.uri)

                findFile?.uri?.let {
                    val outs = contentResolver.openOutputStream(it)
                    val inBuffer = assets.open("材料清單PDF.pdf").source().buffer()
                    outs?.sink()?.buffer()?.use {
                        it.writeAll(inBuffer)
                        inBuffer.close()
                    }
                }
            }
        }

    }

}

```

如果我們判斷使用者已經授權過許可權,那下面我們還能使用無感知的方式使用 DocumentFile 的方式,偷偷摸摸的下載一個檔案到這個外接SD卡自定義檔案路徑下面:

```kotlin

    val uri = Uri.parse("content://com.android.externalstorage.documents/tree/primary%3ADownloadMyFiles%2Fabcd")

    contentResolver.takePersistableUriPermission(uri,
        Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
    )

    //如果是Download資料夾之外的資料夾需要使用 fromTreeUri 的方式
    val documentFile: DocumentFile? =  DocumentFile.fromTreeUri(mActivity, uri)

    YYLogUtils.w("documentFile:"+documentFile +" uri:"+documentFile?.uri)

    documentFile?.run {
        val findFile = createFile("application/pdf", "材料清單PDF")
        YYLogUtils.w("findFile:"+findFile +" uri:"+findFile?.uri)

        findFile?.uri?.let {
            val outs = contentResolver.openOutputStream(it)
            val inBuffer = assets.open("材料清單PDF.pdf").source().buffer()
            outs?.sink()?.buffer()?.use {
                it.writeAll(inBuffer)
                inBuffer.close()
            }
        }
    }

```

效果如圖:

image.png

其實只要有使用者手動授權的 android.permission.MANAGE_DOCUMENTS 許可權之後,我們就可以在 SD 卡任意的目錄存放檔案了,不管是管理比較鬆的 Download 資料夾還是管理較為嚴格的 DCIM 目錄都可以隨意存入檔案:

image.png

image.png

雖然和 Android10 之前的 File 的方式還是不能比,但也能說是勉強夠用。是可以存了,也是較為麻煩,需要使用者授權!如果有其他的方式當然是不推薦這種方式了。

so ,當我們有檔案儲存的需求的時候,首先還是存沙盒中,其次存 SD 卡的Download 目錄,最終考慮的才是存放在 SD 卡的自定義目錄。

二、DocumentsProvider 與 FileProvider

其實對我們普通的應用來說存取還好說,大不了存沙盒,存 SD 卡的 Download 目錄嘛,但是取檔案怎麼辦?

作為一個普通應用也是有選擇檔案的功能的,不比選擇相簿,使用 MediaStore 可以輕鬆獲取到多媒體內容,檔案選擇相對而言就是非常的坑爹了。

2.1 檔案的獲取

市面上大多數的開源的檔案選擇器都是使用的 File 的 Api 獲取檔案,也只能使用這種方式,在 Android10 以上的裝置一些作者就會推薦使用 SAF去獲取指定的檔案。

如果 target api >= 30,那麼 requestLegacyExternalStorage 會被忽略,READ_EXTERNAL_STORAGE 許可權僅允許讀取媒體檔案(比如圖片),而無法讀取其他型別的檔案(比如PDF等)。 如果您處於此種情況,建議自己使用 SAF 框架自行實現文件訪問,而不要使用這個庫。 另一個解決方法是申請 MANAGE_EXTERNAL_STORAGE,不過請您慎重考慮使用此許可權。 如果 target api == 29,必須使用 requestLegacyExternalStorage 標記

requestLegacyExternalStorage 是不可能用的,那我改如何獲取 SD卡的檔案,管他真的假的,我先試試!

這裡我們還是請出我們的三臺裝置 Android7、Android9、Android12。我們再三臺裝置的 Download 目錄下面分別匯入相同的檔案,分別為png格式,txt格式,doc格式與pdf格式。

image.png

我們使用同樣的程式碼,申請許可權之後我們能獲取到這些檔案嗎?

程式碼很簡單,就是普通的 File 的 API :

```kotlin fun readFile() {

        val downLoadPath = Environment.getExternalStoragePublicDirectory("Download").absolutePath

        val parentFile = File(downLoadPath)

        if (parentFile.exists() && parentFile.isDirectory) {

            val listFiles = parentFile.listFiles()

            if (listFiles != null && listFiles.isNotEmpty()) {

                val nameList = arrayListOf<String>()

                listFiles.forEach { file ->

                    if (file.exists()) {

                        if (file.isDirectory) {
                            val fileName = file.name
                            nameList.add(fileName)
                        } else {
                            val fileName = file.name
                            nameList.add(fileName)
                        }

                    }
                }

                YYLogUtils.w("${android.os.Build.VERSION.RELEASE} 找到的檔案和資料夾為:$nameList")

            }
        }

    }

```

那麼不同的版本執行的 Log 如下圖所示:

image.png

image.png

image.png

可以看到 Android12 版本確實是無法讀取到文件檔案!而 Android10 以下的裝置則可以正常的讀取到。

既然 File 讀取不到那我們通過 DocumentFile 行不行?行是行,但是如果用 SAF 的方式去授權也太不優雅了吧!

那怎麼辦?最主流的方法當然是大佬們都推薦的直接啟動 SAF 選取檔案了:

比如如下程式碼:

```java Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE);

//可選:指定選擇文字型別的檔案, 指定多型別查詢
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{PDF, DOC, DOCX, PPT, PPTX, XLS, XLSX, TXT});

activity.startActivityForResult(intent, 10402);

```

我們可以選擇任何檔案,也可以選擇指定檔案格式的檔案。

類似的效果如下:

image.png

每一個系統的具體 UI 是不同的,並且能篩選出指定的檔案,比如下圖的圖片是不可選的:

image.png

結果在 onActivityResult 的回到中,通過 data 欄位拿到 uri 資料,之後就可以拿到流進行一些 IO 操作了。

於是大家都這麼玩了!

```kotlin if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
    intent.addCategory(Intent.CATEGORY_OPENABLE)

    //指定選擇文字型別的檔案
    intent.type = "*/*"
    //指定多型別查詢
    intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf(PDF , DOC, DOCX, PPT, PPTX, XLS, XLSX, TXT))

    startActivityForResult(intent, 10402)

}else{

    ...

    val listFiles = parentFile.listFiles()

    ...

}

```

這樣總算是能完成需求了,搞完收工!

df08c51a13d244a0a977d5121d5e1c5f.jpeg

沒多大會問題就來了,老闆不樂意了!為什麼 Android10 以下的裝置和 Android 10 以上的裝置展示的 UI 效果不統一,你趕緊改成設計圖一樣的效果啊...你看看iOS就...巴拉巴拉。

“老闆啊你聽我講,不是我不改,是谷歌就讓這麼幹的!連官方都推薦這麼用,不同的版本是會有不同的 UI 展示的,都是系統的 UI 頁面的,這東西不能完全按設計圖來的。"

“別跟我巴巴的,就問是不是你不行?為什麼 iOS 能做 Android 不能做?”

我當時的心情是這樣的:

t01a39b874d6abd8fec.jpg

我尼瑪,額,冷靜一下,收!

話說回來,那 Android 到底能不能實現統一的 UI 這個需求呢?

或者我們換一個問法, Android10 以上的裝置能否在 SD 卡的資料夾裡自由讀取檔案呢?

大許上也是可以做的。其實本質上還是 File 的操作,不過我們可以使用 DocumentsProvider 和 FileProvider 進行一些包裝而已。

2.2 DocumentsProvider vs FileProvider

很多同學可能都只用過 FileProvider ,並沒有用過 DocumentsProvider ,對哦它也不是很瞭解。

去年的文章中 【Android中FileProvider的各種場景應用】 我大致講解了 FileProvider 的用法,在文章的後面我嘗試了一種方法,把自己的檔案分享給別的 App 使用。

是不是和我們現在的場景很相像呢?其實 FileProvider 並不推薦這麼用,看 FileProvider 的原始碼註釋就知道:

FileProvider 的 exported 和 grantUriPermissions 都是指定的寫法,不能改變,且不允許暴露,不允許給別的 App 主動訪問!

此時再回看 DocumentsProvider 的使用,相對而言就沒那麼的嚴格,不就是應用在此時此景嗎?

他們兩者的功能大致上是差不多的,都是通過 ContentProvider 的方式提供檔案給對方使用的,但是他們又有不同:

FileProvider 它可以將檔案的 Uri 轉換為 content:// 的形式,從而在不同的應用間共享檔案 DocumentsProvider 它可以提供一個文件樹的結構,從而讓使用者在不同的應用間訪問和管理文件。

FileProvider 主要用於臨時共享檔案,而 DocumentsProvider 主要用於長期儲存和管理檔案。

DocumentsProvider 常見用於一些雲盤儲存的應用,可以自定義返回一些 URL 或其他的自定義欄位,而 FileProvider 的作用則比較受限,只能用於本機上的一些檔案。

在我們的這個場景中,我們只需要用到普通的本地文件管理即可。我們甚至還能通過 DocumentsProvider 和 FileProvider 的相互配合才能完成功能。

加下來就是看看 DocumentsProvider 如何實現一個自定義檔案選擇器:

和 FileProvider 的定義方式類似,我們需要先建立一個 Provider 檔案:

```kotlin public class SelectFileProvider extends DocumentsProvider {

private final static String[] DEFAULT_ROOT_PROJECTION = new String[]{Root.COLUMN_ROOT_ID, Root.COLUMN_SUMMARY,
        Root.COLUMN_FLAGS, Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_ICON,
        Root.COLUMN_AVAILABLE_BYTES};

private final static String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{Document.COLUMN_DOCUMENT_ID,
        Document.COLUMN_DISPLAY_NAME, Document.COLUMN_FLAGS, Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE,
        Document.COLUMN_LAST_MODIFIED};

public static final String AUTOHORITY = "com.guadou.kt_demo.selectfileprovider.authorities";

//是否有許可權
private static boolean hasPermission(@Nullable Context context) {
    if (context == null) {
        return false;
    }
    if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE)
            == PackageManager.PERMISSION_GRANTED) {
        return true;
    }
    return false;
}

@Override
public Cursor queryRoots(String[] projection) throws FileNotFoundException {

return null;


}

@Override
public boolean isChildDocument(String parentDocumentId, String documentId) {
    return documentId.startsWith(parentDocumentId);
}

@Override
public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {
    // 判斷是否缺少許可權
    if (!hasPermission(getContext())) {
        return null;
    }

    // 建立一個查詢cursor, 來設定需要查詢的項, 如果"projection"為空, 那麼使用預設項
    final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
    includeFile(result, new File(documentId));
    return result;
}

@Override
public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException {
    // 判斷是否缺少許可權
    if (!hasPermission(getContext())) {
        return null;
    }

    // 建立一個查詢cursor, 來設定需要查詢的項, 如果"projection"為空, 那麼使用預設項
    final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
    final File parent = new File(parentDocumentId);
    String absolutePath = parent.getAbsolutePath();
    boolean isDirectory = parent.isDirectory();
    boolean canRead = parent.canRead();
    boolean canWrite = parent.canWrite();
    File[] files = parent.listFiles();

    YYLogUtils.w("parent:" + parent + " absolutePath:" + absolutePath + " isDirectory:" +
            isDirectory + " canRead:" + canRead + " canWrite:" + canWrite + " files:" + files);

    if (isDirectory && canRead && files != null && files.length > 0) {
        for (File file : parent.listFiles()) {
            // 不顯示隱藏的檔案或資料夾
            if (!file.getName().startsWith(".")) {
                // 新增檔案的名字, 型別, 大小等屬性
                includeFile(result, file);
            }
        }
    }

    return result;
}

private void includeFile(final MatrixCursor result, final File file) throws FileNotFoundException {
    final MatrixCursor.RowBuilder row = result.newRow();
    row.add(Document.COLUMN_DOCUMENT_ID, file.getAbsolutePath());
    row.add(Document.COLUMN_DISPLAY_NAME, file.getName());
    String mimeType = getDocumentType(file.getAbsolutePath());
    row.add(Document.COLUMN_MIME_TYPE, mimeType);
    int flags = file.canWrite()
            ? Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE | Document.FLAG_SUPPORTS_RENAME
            | (mimeType.equals(Document.MIME_TYPE_DIR) ? Document.FLAG_DIR_SUPPORTS_CREATE : 0) : 0;
    if (mimeType.startsWith("image/"))
        flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
    row.add(Document.COLUMN_FLAGS, flags);
    row.add(Document.COLUMN_SIZE, file.length());
    row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
}


@Override
public String getDocumentType(String documentId) throws FileNotFoundException {
    if (!hasPermission(getContext())) {
        return null;
    }

    File file = new File(documentId);
    if (file.isDirectory()) {
        //如果是資料夾-先返回再說
        return Document.MIME_TYPE_DIR;
    }

    final int lastDot = file.getName().lastIndexOf('.');
    if (lastDot >= 0) {
        //如果檔案有後綴-直接返回字尾名的型別
        final String extension = file.getName().substring(lastDot + 1);
        final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
        if (mime != null) {
            return mime;
        }
    }
    return "application/octet-stream";
}


@Override
public ParcelFileDescriptor openDocument(String documentId, String mode, @Nullable CancellationSignal signal) throws FileNotFoundException {
    return null;
}

@Override
public boolean onCreate() {
    return true;
}

} ```

主要是需要重寫 onCreate 方法和幾個 query 方法,看各自的需求實現,我這裡只查資料夾下面的內容,所以重點關注的是 queryChildDocuments 方法。

並且可以看到為什麼說它比 FileProvider 更加的靈活呢?一是因為可以自定義Provider類,二是可以自定義查詢各種目錄,三是可以自定義返回欄位。

比如如果我們要做的是雲盤應用,那麼就可以返回圖片或檔案的 URL 連結,甚至還能自定義返回欄位高分辨的圖片與低畫質的圖片等。

當然我們這裡使用的沒那麼複雜,只是用於查詢本地的資料夾而已,當我們定義完成之後需要在清單檔案註冊:

xml <provider android:name=".demo.demo6_imageselect_premision_rvgird.files.SelectFileProvider" android:authorities="com.guadou.kt_demo.selectfileprovider.authorities" android:exported="true" android:grantUriPermissions="true" android:permission="android.permission.MANAGE_DOCUMENTS"> <intent-filter> <action android:name="android.content.action.DOCUMENTS_PROVIDER"/> </intent-filter> </provider>

需要注意的是 Android4.4 以上才可用,但是我們也只用於 Android10 以上的裝置,所以直接宣告即可。

接下來我們就能根據本地路徑的 path 路徑來訪問此資料夾,例如我直接訪問我們之前建立好的 DownloadMyFiles 資料夾:

```kotlin val downLoadPath1 = Environment.getExternalStoragePublicDirectory("DownloadMyFiles").absolutePath

    val uri = DocumentsContract.buildChildDocumentsUri(
        "com.guadou.kt_demo.selectfileprovider.authorities",
        downLoadPath1
    )

    val cursor = contentResolver.query(uri, null, null, null, null)
    YYLogUtils.w("cursor $cursor")

    cursor?.run {

        while (moveToNext()) {

            val documentId = getString(getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID))
            val displayName = getString(getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME))
            val type = getString(getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE))
            val flag = getInt(getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS))
            val size = getLong(getColumnIndex(DocumentsContract.Document.COLUMN_SIZE))
            val updateAt = getLong(getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED))

            YYLogUtils.w("${android.os.Build.VERSION.RELEASE} documentId:$documentId displayName:$displayName type:$type flag:$flag size:$size updateAt:$updateAt")
        }

        close()
    }

}

```

列印日誌如下:

image.png

到此整個流程就結束了,我們可以把資料夾下面的全部檔案打印出來,當然了可能我們獲取到的是原始 path 路徑,可能無法直接訪問的,我們最好是配合 FileProvider 把本地路徑轉換為 URI 去訪問。這也是需要完善的點。

總歸到底,最底層的實現還是沒脫離 File 的範疇,只是最終可能用 DocumentsProvider 和 FileProvider 兩者結合再包裝一層而已。

後記

File vs DocumentFile的區別 以及 DocumentsProvider vs FileProvider的異同,大家看完應該有一些瞭解。

總的來說,當我們有檔案儲存的需求的時候,首先考慮的還是存沙盒,這是最保險的!

其次我們可以存 SD 卡的 Download 目錄,但是就算把檔案放在 SD 卡內,也需要注意高版本的相容問題,也最好使用 FileProvider 去獲取 URI的方式獲取檔案資源,避免直接 File 或 DocumentFile 無法直接讀取的情況

最終考慮實在沒辦法的才是存放在 SD 卡的自定義目錄,需要使用者手動選擇資料夾授權了之後才能直接存取檔案。

而獲取檔案我們首先是使用 File 的 API 去獲取,對 Android10 以上的版本使用 SAF 框架訪問,這是最好的隨著Android 版本的迭代也是最為推薦的方式。

當然如果硬是有 UI 方面的限制,一定要使用設計師的效果,我們也能用 DocumentsProvider 配合 FileProvider 實現自定義的檔案資料獲取,只是相對麻煩一點。

說到這裡挖一個坑,後期可能出一個相容 Android10 以上檔案選擇框架。。。不過由於時間關係和一些其他的原因,需要等等,先待我仔細思量思量。

到尾聲了,先說一聲抱歉,文章篇幅太長太亂,時間太趕了,還請各位見諒,特別是公司最近的專案也蠻多,需求改動的東西都是蠻趕的。實在抱歉!

慣例了,我如有講解不到位或錯漏的地方,希望同學們可以指出。如果有更好的方式或其他方式,或者你有遇到的坑也都可以在評論區交流一下,大家互相學習進步。

同時我也很好奇,大家都是怎麼儲存檔案,用什麼方式選擇檔案的呢?歡迎大家到評論區交流!

如果感覺本文對你有一點點的幫助,還望你能點贊支援一下,純用愛發電,你的支援是我最大的動力。

本文的部分程式碼可以在我的 Kotlin 測試專案中看到,【傳送門】。你也可以關注我的這個Kotlin專案,我有時間都會持續更新。

Ok,這一期就此完結。

本文正在參加「金石計劃」

「其他文章」