Android操作文件也太难了趴,File vs DocumentFile 以及 DocumentsProvider vs FileProvider 的异同

6,335 阅读8分钟

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 适配)

代码如下:

        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卡哪个目录,还是要外置卡权限的!

 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 卡目录都能写入呢?

我们创建一个自定义的文件夹试试呢?

代码如下:

    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

低版本确实是可以写入的!

我不用自定义目录行了吧!那我写到系统的其他目录行了吧!

    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 的 方式使用:


    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 就可以操作它的创建文件夹,创建文件,或写入文件等操作了。

    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 的输出流而已!

    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的方式,手动选择文件夹,并且授权同意权限!

大致的代码如下:

    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

在回调授权结果中处理:

 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卡自定义文件路径下面:


        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 :

        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 选取文件了:

比如如下代码:

    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 操作了。

于是大家都这么玩了!

   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 文件:

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 链接,甚至还能自定义返回字段高分辨的图片与低画质的图片等。

当然我们这里使用的没那么复杂,只是用于查询本地的文件夹而已,当我们定义完成之后需要在清单文件注册:

        <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 文件夹:

        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,这一期就此完结。

本文正在参加「金石计划」