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,這一期就此完結。

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

「其他文章」