咱就是说:不要在用户的“外部存储空间”肆意妄为了好吗

1,651 阅读5分钟

事出起因

我经常用到文件管理器,需要导入导出文件,每次面对外部储存中杂乱的文件列表,作为一个处女座强迫症患者,非常不爽~

为什么呢

众所周知,Android储存权限一直被大家饱受诟病,开发者拿到读写权限之后可以肆意妄为,可是有些开发者太傻X了,在用户存储空间中一通乱搞!

我们随便看一下某些app在外部储存创建的目录:

第一类傻X:
直接创建文件夹用来保存图片视频的(你放到PicturesDownload目录下不行吗)

第二类傻X:
直接将缓存文件放在外面的,用户根本不知道你这是谁建的也不知道干啥的

终极傻X:
直接创建包名文件夹,你谁啊,企鹅了不起啊

有些人可能觉得没什么,反正有多少用户天天看文件管理器啊,但是遇到强迫症患者我必须好好纠正这些坏毛病!

说一说读写权限

读写权限是获取手机外部存储权限,也就是:外部储存空间整个目录下你可以随便读写。除了保存图片视频和一些较大文件的时候,大部分文件都可以放在App私有目录下,而App私有目录读写是不需要权限的,我们的大部分临时文件、缓存、kv、数据库都是放在这里。

所以我们考虑一下:在不读写大文件或者和其他app数据的时候,我们真的需要读写权限吗?

Android文件系统

Android文件权限一直在变化,但是依旧没什么卵用,该怎么样还是怎么样,我之前在聊一聊Android存储行为的变化 - 掘金 (juejin.cn)中说了分区存储和权限变化,建议大家可以看下。

由于早期SdCard存在,Android一直沿用了这个传统,虽然如今大部分手机没有SdCard了,在手机管理器中依然可见,其实这是系统映射的虚拟地址,我们一般称为外部存储空间。

APP私有空间

内有储存分为内部私有和外部私有,就是这部分空间是app私有的,理论上其他app无法访问。在手机内部储存空间和外部储存空间上都为你的app安排了这样一个空间,我们一般用来缓存文件、存储敏感文件。

内存空间私有目录:

一般以包名为名称,在哪里呢?看图::

通过系统api访问到的路径:/data/user/0/app_packageName/...
对应的真是目录:/data/date/app_packageName/...

外部储存空间下的app私有目录:

在Android10之前此目录依旧是不安全的,其他app获取外部读写权限之后依旧可以读写。在Android11上彻底引入分区储存才算是安全了,属于真正的app私有目录了。

image.png

怎么解决呢

这个要看开发者的自觉程度了,有些项目老,很多人不愿意为此付出修改的代价。

说一下我的做法:

  • 图片、视频、文档等资源放入系统DCIM、Pictures、Video、Download、Document对应目录下,可以使用MediaStoreFileProvider插入。
  • 内存私有空间存储敏感文件
  • 外部私有空间存储临时缓存

工具类

在此分享我常用的文件管理工具类,祝你文件不再混乱

FilePath:文件路径管理工具类


object FilePath {

    /*----------------------外部:分区存储目录------------------------*/
    /**
     *  分区存储-Cache目录
     */
    fun getAppExternalCachePath(subDir: String?=null):String{
        val path = StringBuilder(getContext().externalCacheDir?.absolutePath)
        subDir?.let {
            path.append(File.separator).append(it).append(File.separator)
        }
        val dir = File(path.toString())
        if (!dir.exists()) dir.mkdir()
        return path.toString()
    }

    /**
     *  分区存储-File目录
     */
    fun getAppExternalFilePath(subDir: String?=null):String{
        val path = getContext().getExternalFilesDir(subDir)?.absolutePath
        val dir = File(path.toString())
        if (!dir.exists()) dir.mkdir()
        return path.toString()
    }
    /*--------------------------------------------------*/


    /*-----------------------内部:私有目录--------------------------*/

    /**
     *  私有目录-files
     */
    fun getAppFilePath(subDir:String?=null): String {
        val path = StringBuilder(getContext().filesDir.absolutePath)
        subDir?.let {
            path.append(File.separator).append(it).append(File.separator)
        }
        val dir = File(path.toString())
        if (!dir.exists()) dir.mkdir()
        return path.toString()
    }

    /**
     * 私有目录-cache
     */
    fun getAppCachePath(subDir:String?=null):String{
        val path = StringBuilder(getContext().cacheDir.absolutePath)
        subDir?.let {
            path.append(File.separator).append(it).append(File.separator)
        }
        val dir = File(path.toString())
        if (!dir.exists()) dir.mkdir()
        return path.toString()
    }

    /*------------------------cache子目录------------------------*/
    fun getAudioPathEndWithSeparator(): String {
        return getAppCachePath("audio")
    }

    fun getTxtPathEndWithSeparator(): String {
        return getAppCachePath("txt")
    }

    fun getMp3PathEndWithSeparator(): String {
        return getAppCachePath("mp3")
    }

    fun getTempPathEndWithSeparator(): String {
        return getAppCachePath("temp")
    }

    /*--------------------------------------------------*/



    /*-----------------外部:公共目录(需要权限)----------------*/
    /**
     *  Pictures
     */
    fun getExternalPicturesPath(subDir:String?=null): String{
        val path = StringBuilder(Environment.getExternalStorageDirectory().absolutePath)
            .append(File.separator)
            .append(Environment.DIRECTORY_PICTURES)
        subDir?.let {
            path.append(File.separator).append(it).append(File.separator)
        }
        val dir = File(path.toString())
        if (!dir.exists()) dir.mkdir()
        return path.toString()
    }

    /**
     *  Download
     */
    fun getExternalDownloadPath(subDir:String?=null): String{
        val path = StringBuilder(Environment.getExternalStorageDirectory().absolutePath)
            .append(File.separator)
            .append(Environment.DIRECTORY_DOWNLOADS)
        subDir?.let {
            path.append(File.separator).append(it).append(File.separator)
        }
        val dir = File(path.toString())
        if (!dir.exists()) dir.mkdir()
        return path.toString()
    }

    /**
     *  DCIM
     */
    fun getExternalCameraPath( subDir:String?=null): String{
        val path = StringBuilder(Environment.getExternalStorageDirectory().absolutePath)
            .append(File.separator)
            .append(Environment.DIRECTORY_DCIM)
        subDir?.let {
            path.append(File.separator).append(it).append(File.separator)
        }
        val dir = File(path.toString())
        if (!dir.exists()) dir.mkdir()
        return path.toString()
    }

    /**
     *  Music
     */
    fun getExternalMusicPath(subDir:String?=null): String{
        val path = StringBuilder(Environment.getExternalStorageDirectory().absolutePath)
            .append(File.separator)
            .append(Environment.DIRECTORY_MUSIC)
        subDir?.let {
            path.append(File.separator).append(it).append(File.separator)
        }
        val dir = File(path.toString())
        if (!dir.exists()) dir.mkdir()
        return path.toString()
    }
    /*---------------------------------------------------------*/


}

FileUtil文件操作工具类:


object FileUtil{

    /**
     *  File转Uri
     */
    fun file2Uri( file: File?): Uri?{
        if (file==null) return null

        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            //适配Android 7.0文件权限,通过FileProvider创建一个content类型的Uri
            FileProvider.getUriForFile(getContext(), "${getContext().packageName}.fileProvider", file)
        } else {
            Uri.fromFile(file)
        }
    }


    /**
     *  将文件转换成byte数组
     */
    fun file2Byte(file: File?): ByteArray? {
        if (file==null) return null

        var buffer: ByteArray? = null
        try {
            val fis = FileInputStream(file)
            val bos = ByteArrayOutputStream()
            val b = ByteArray(1024)
            var n: Int
            while (fis.read(b).also { n = it } != -1) {
                bos.write(b, 0, n)
            }
            fis.close()
            bos.close()
            buffer = bos.toByteArray()
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        }
        return buffer
    }


    /**
     *  Uri转File
     */
    fun uri2File(uri: Uri?): File? {
        if (uri==null) return null
        var file:File ?= File(uri.toString())
        if (file!=null && file.exists()) return file


        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            when (uri.scheme) {
                ContentResolver.SCHEME_FILE -> {
                    file = File(requireNotNull(uri.path))
                }
                ContentResolver.SCHEME_CONTENT -> {
                    //把文件保存到沙盒
                    val contentResolver = getContext().contentResolver
                    val displayName = "${TimeUtil.getCurrentTime()}.${
                        MimeTypeMap.getSingleton().getExtensionFromMimeType(
                            contentResolver.getType(uri)
                        )
                    }".replace(".bin","")
                    val ios = contentResolver.openInputStream(uri)
                    if (ios != null) {
                        file = File(FilePath.getTxtPathEndWithSeparator(), displayName).apply {
                            val fos = FileOutputStream(this)
                            FileUtils.copy(ios, fos)
                            fos.close()
                            ios.close()
                        }
                    }
                }
                else -> {

                }
            }
            return file
        }else{
            var path: String? = null
            when(uri.scheme){
                "file" -> {
                    path = uri.encodedPath
                    if (path != null) {
                        path = Uri.decode(path)
                        val cr = getContext().contentResolver
                        val buff = StringBuffer()
                        buff.append("(").append(MediaStore.Images.ImageColumns.DATA).append("=")
                            .append("'$path'").append(")")
                        val cur: Cursor? = cr.query(
                            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                            arrayOf(
                                MediaStore.Images.ImageColumns._ID,
                                MediaStore.Images.ImageColumns.DATA
                            ),
                            buff.toString(),
                            null,
                            null
                        )
                        var index = 0
                        var dataIdx = 0
                        cur?.let {
                            cur.moveToFirst()
                            while (!cur.isAfterLast()) {
                                index = cur.getColumnIndex(MediaStore.Images.ImageColumns._ID)
                                index = cur.getInt(index)
                                dataIdx = cur.getColumnIndex(MediaStore.Images.ImageColumns.DATA)
                                path = cur.getString(dataIdx)
                                cur.moveToNext()
                            }
                            cur.close()
                        }
                        if (index == 0) {
                        } else {
                            val u = Uri.parse("content://media/external/images/media/$index")
                            println("temp uri is :$u")
                        }
                    }
                }
                "content" -> {
                    // 4.2.2以后
                    val proj = arrayOf(MediaStore.Images.Media.DATA)
                    val cursor: Cursor? = getContext().contentResolver.query(uri, proj, null, null, null)
                    cursor?.let {
                        if (cursor.moveToFirst()) {
                            val columnIndex: Int = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
                            path = cursor.getString(columnIndex)
                        }
                        cursor.close()
                    }
                }
                else -> {
                    //Log.i(TAG, "Uri Scheme:" + uri.getScheme());
                }
            }
            return File(path)
        }
    }

    /**
     * 删除文件夹
     */
    fun deleteRecursive(fileOrDirectory: File) {
        if (fileOrDirectory.isDirectory) for (child in fileOrDirectory.listFiles()) deleteRecursive(
            child
        )
        fileOrDirectory.delete()
    }

    fun deleteCacheDir() = thread{
        File(FilePath.getTempPathEndWithSeparator()).deleteRecursively()
        File(FilePath.getMp3PathEndWithSeparator()).deleteRecursively()
        File(FilePath.getTxtPathEndWithSeparator()).deleteRecursively()
        File(FilePath.getAudioPathEndWithSeparator()).deleteRecursively()
    }

}

还有一些媒体操作工具类,比如保存图片、视频,创建PDF文件,txt文件。这些都放在GitHub上啦,有兴趣的可以去看下,参考:

AndroidStorageDemo

聊一聊Android存储行为的变化 - 掘金 (juejin.cn)