重学Android-文件的路径管理

2,293 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第14天,点击查看活动详情

Android文件管理路径的几种方式

前言

Android的文件管理分为外置卡和内置卡,内置卡就是在应用沙盒中了,外置卡分为沙盒和公共目录。

不管是内置卡沙盒目录还是外置卡沙盒目录,都是随着应用卸载之后删除的,而外置卡的公共目录是不会随着应用的卸载而删除的。

在操作沙盒目录的情况下,不管是内置卡还是外置卡都是不需要申请权限的,但是如果是外置的公共目录文件的操作,就需要申请权限了。

申请权限使用Android的文件夹管理又是分版本的,如果你的target是30以下,使用兼容模式是可以使用的,由于我们是海外版本都是30以上了,所以必须做适配。

先不管那么多,先看看如何创建文件夹,操作文件吧!

一、内置卡沙盒目录

内置沙盒中分为cache目录和file目录,顾名思义一个推荐放缓存文件,一个推荐大家放普通文件的。

我们这里测试沙盒中文件路径 ,无需申请SD卡权限,直接通过 getFilesDir() 拿到路径,得到的目录为 data/data/< package name >/files/

    fun save2file() {
            val dirPath = commContext().filesDir.absolutePath + "/haha"

            val fileName = dirPath + File.separator + "test.txt"

            GlobalScope.launch(Dispatchers.IO) {
                FileWriter(fileName, true).use {
                    it.append("测试写入的文本")
                    it.append("\n")
                    it.flush()
                }
            }
    }

日志:

报错了,但是我们可以看到 file 文件的路径是对的。为什么找不到文件,因为我们在 files 目录下面又新建了一个目录 。我们需要手动的创建目录

修改代码为:

    fun save2file() {
            val dirPath = commContext().filesDir.absolutePath + "/haha"

           //如果不存在就创建一个文件夹
            val dir = File(dirPath)
            if (!dir.exists()) {
                dir.mkdirs()
            }

            val fileName = dirPath + File.separator + "test.txt"

            GlobalScope.launch(Dispatchers.IO) {
                FileWriter(fileName, true).use {
                    it.append("测试写入的文本")
                    it.append("\n")
                    it.flush()
                }
            }
    }

再次运行,可以看到结果:

同样的我们再内置卡中获取到 cache 目录,和 files 目录的操作是类似的,只是调用的方法不同。通过 getCacheDir() 方法拿到 cache 目录。目录为 /data/data/<application package>/cache

    fun save2file() {
            val dirPath = commContext().cacheDir.absolutePath + "/haha2"

           //如果不存在就创建一个文件夹
            val dir = File(dirPath)
            if (!dir.exists()) {
                dir.mkdirs()
            }

            val fileName = dirPath + File.separator + "test.txt"

            GlobalScope.launch(Dispatchers.IO) {
                FileWriter(fileName, true).use {
                    it.append("测试写入的文本")
                    it.append("\n")
                    it.flush()
                }
            }
    }

结果为:

二、外置卡沙盒目录

当我们设备有外置SD卡的时候,我们可以通过 getExternalCacheDir() getExternalFilesDir 方法来获取 cache 目录和 files 目录 。它们的操作和内置卡的操作是一样的。

    fun save2file() {

            val dirPath = commContext().getExternalFilesDir(null)?.absolutePath + "hehe2"
            val fileName = dirPath + File.separator + "test.txt"

            GlobalScope.launch(Dispatchers.IO) {
                FileWriter(fileName, true).use {
                    it.append("测试写入的文本")
                    it.append("\n")
                    it.flush()
                }
            }
    }

同样的会报错,因为我没有创建文件夹。

但是这里我就不创建文件夹,我们使用带参数的构造,传入文件夹目录名称。

可以看到系统已经定义了很多文件夹名称:

我们可以自定义,也可以使用系统定好的一些名称。使用这种方式就不需要我们自己手动的创建文件夹了!看看使用的效果:


   val dirPath = commContext().getExternalFilesDir("hehe")?.absolutePath

    val dirPath = commContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)?.absolutePath

可以看到效果:

三、外置公共目录

查看设备的文件夹,可以看到SD卡的根目录有很多自定义的文件夹

比如我们想往 Pictures 文件夹中写入文件。

    fun save2file() {

        extRequestPermission(
                Manifest.permission.WRITE_EXTERNAL_STORAGE,
                Manifest.permission.READ_EXTERNAL_STORAGE,
                block = {
                    //申请权限成功
                    val sdCard = Environment.getExternalStorageDirectory()
                    val directoryPictures = File(sdCard, "Pictures")

//                    val directoryPictures = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)

                    val fileName = directoryPictures.absolutePath + File.separator + "test.txt"
                    YYLogUtils.w("外置SD卡路径:" + directoryPictures.absolutePath)

                    GlobalScope.launch(Dispatchers.IO) {
                        FileWriter(fileName, true).use {
                            it.append("测试写入的文本")
                            it.append("\n")
                            it.flush()
                        }
                    }

                })
    }

看代码,可以看到我们使用外置卡的公共目录,必须要申请SD卡权限了,我们拿到跟目录中的 Pictures 文件夹 。有两种方式可以获取到这个目录,代码中已经注释。点击试试文件的写入!

在权限弹框中同意权限,然后就写入了,写入了,写入了。

这不科学啊!我测试设备是安卓12,不应该啊。

哦,我的target是29,可以兼容模式启动。ok, 我改为target30。(设置android:requestLegacyExternalStorage="true"无效了,谷歌强制不能使用外置卡直接管理了)

再次运行:

可算把你搞崩溃了,其实我们再调用外置卡公共目录的几个方法的时候就已经标注过时了,不推荐使用这种方式了。

那我们现在需要使用外置卡的公共目录怎么办?比如读取相机相册?文件?

  • 照片、视频、音频这类媒体文件。使用 MediaStore 来存储与访问
  • 其他目录,使用存储访问框架SAF(Storage Access Framwork)

四、外置公共目录-媒体文件

照片、视频、音频,等我们通过 MediaStore 来存储

简单的理解 MediaStore 为一个中间的数据库,我们就是通过数据库存入指定的信息,然后通过 ContentResolver 来插入和查询数据,是不是和调用联系人数据类似。

一般操作如下:

        ContentValues values = new ContentValues();
       
        values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image");
        values.put(MediaStore.Images.Media.DISPLAY_NAME, "Image.png");
        values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
        values.put(MediaStore.Images.Media.TITLE, "Image.png");
        values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/test");

        return context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);

对于媒体资源的访问:比如图片选择器这类的场景。无法直接使用File,而应使用Uri。否则报错如下

java.io.FileNotFoundException: open failed: EACCES (Permission denied)

简单的说就是file你拿不到了,只能给你uri,所以项目中使用的图片选择器时,首先修改了Glide 通过加载File的方式显示图片。改为加载Uri的方式,否则图片无法显示出来。

Uri的获取方式还是使用MediaStore:

String id = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID));
Uri uri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);

我们上传图片的时候都是使用File,你给我一个图片uri,我该怎么上传给我们的服务器呢?我们只需要吧uri转为inputStream 然后写入到我们的外置沙盒中,然后通过沙盒中的file上传到服务器:

    File imgFile = this.getExternalFilesDir("image");
    if (!imgFile.exists()){
        imgFile.mkdir();
    }
    try {
        File file = new File(imgFile.getAbsolutePath() + File.separator + System.currentTimeMillis() + ".jpg");
        InputStream fileInputStream = getContentResolver().openInputStream(uri);
        FileOutputStream fileOutputStream = new FileOutputStream(file);
        byte[] buffer = new byte[1024];
        int byteRead;
        while (-1 != (byteRead = fileInputStream.read(buffer))) {
            fileOutputStream.write(buffer, 0, byteRead);
        }
        fileInputStream.close();
        fileOutputStream.flush();
        fileOutputStream.close();
    } catch (Exception e) {
        e.printStackTrace();        
    }

这里我用kotlin的代码演示一下如何保存图片,kotlin的io操作更方便一点!

private class OutputFileTaker(var file: File? = null)

fun InputStream.saveToAlbum(context: Context, fileName: String, relativePath: String?): Uri? {
    val resolver = context.contentResolver
    val outputFile = OutputFileTaker()
    val imageUri = resolver.insertMediaImage(fileName, relativePath, outputFile)
    if (imageUri == null) {
        YYLogUtils.w("insert: error: uri == null")
        return null
    }

    (imageUri.outputStream(resolver) ?: return null).use { output ->
        this.use { input ->
            input.copyTo(output)
            imageUri.finishPending(context, resolver, outputFile.file)
        }
    }
    return imageUri
}


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 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)
        }
        // 保存的位置
       val collection: Uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
     
    // 插入图片信息
    return this.insert(collection, imageValues)
}


private fun Uri.finishPending(
    context: Context,
    resolver: ContentResolver,
    outputFile: File?,
) {
    val imageValues = ContentValues()

    if (outputFile != null) {
        imageValues.put(MediaStore.Images.Media.SIZE, outputFile.length())
    }
    resolver.update(this, imageValues, null, null)
    // 通知媒体库更新
    val intent = Intent(@Suppress("DEPRECATION") Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, this)
        context.sendBroadcast(intent)
    
}

这里我们调用图片的保存方法试试:

这里虽然存到了Pictures目录中,但是我们不能通过File直接拿到了哦,还是只能拿到Uri来操作的!

我们保存之后把图片更新到了系统应用图库中了,我们看看能不能正常显示。

完美!

最后说明一下图片分享怎么做? 我们不需要一锤子买卖就直接用FileProvider的,如果图片的真实路径是在我们的沙盒目录中,那么是要用FileProvider的,但是这个图片的真实路径在外置卡公共目录Pictures 中的,我们直接使用Uri分享就行了

    val intent = Intent(Intent.ACTION_SEND)
        .putExtra(Intent.EXTRA_STREAM, uri)
        .setType("image/*")
    startActivity(Intent.createChooser(intent, null))

五、外置公共目录-普通文件

我们通过Intent的类型设置 ACTION_OPEN_DOCUMENTACTION_OPEN_DOCUMENT_TREE 来选择文件或文件夹,获取对应的Uri,通过IO流操作文件,或者 DocumentFile 的Api来操作文件。

  val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
    // 只显示可以打开的文件
    intent.addCategory(Intent.CATEGORY_OPENABLE)
    // 可选择所有文件类型
    intent.type = "*/*"
    startActivityForResult(intent, 1)

在回调中,我们就可以拿到uri,可以进行IO操作了,上面多媒体文件已经讲到过,这里就不细述了。

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    if (resultCode == Activity.RESULT_OK && requestCode == 1) {
        resultData?.data?.let {
            val inputStream = contentResolver.openInputStream(it)
        }
    }
    super.onActivityResult(requestCode, resultCode, data)
}

创建文件比较麻烦:

     extRequestPermission(
                Manifest.permission.WRITE_EXTERNAL_STORAGE,
                Manifest.permission.READ_EXTERNAL_STORAGE,
                block = {

                    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
                    startActivityForResult(intent, 1)

                }
            )


     override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
        if (resultCode == Activity.RESULT_OK && requestCode == 1) {
            resultData?.data?.let { uri ->
                YYLogUtils.w("打开文件夹:$uri")
                DocumentFile.fromTreeUri(this, uri)
                    // 在文件夹内创建新文件夹
                    ?.createDirectory("newFolder")
                    ?.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()

            }

        }
        super.onActivityResult(requestCode, resultCode, resultData)
    }           

这种方案是通过 DocumentFile Api的方式创建文件夹与文件。看看效果

不过呢,我们还是可以通过直接File操作Dwonload文件夹的。这里测试一下

 extRequestPermission(
                Manifest.permission.WRITE_EXTERNAL_STORAGE,
                Manifest.permission.READ_EXTERNAL_STORAGE,
                block = {
                    //申请权限成功
                    val directoryPictures = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)

                    val fileName = directoryPictures.absolutePath + File.separator + "test.txt"
                    YYLogUtils.w("外置SD卡路径:" + directoryPictures.absolutePath)

                    GlobalScope.launch(Dispatchers.IO) {
                        FileWriter(fileName, true).use {
                            it.append("测试写入的文本")
                            it.append("\n")
                            it.flush()
                        }
                    }

                })

效果:

但是我们直接用File操作tmp文件夹不行,其他的文件夹不行,只有download才可以,难道是为了方便我们下载Apk或插件,安装更新吗?

总结

自从Android10之后,对外置卡的公共目录的操作就开始逐年的收紧了。多媒体的操作和文件的操作都挺麻烦的了。

大家如果没有特殊的需求,还是推荐存储在沙盒文件中。更加的简单和方便哦!

当然了,闭门造车要不得,如果你有更好的方法还望告知,如果有疑问也可以留言。如有需求,源码在此

到此完结!