Android 10、11分区存储适配踩坑总结

转载自:Android 10、11分区存储适配踩坑总结

作者:

乎如冯虚御风的博客地址:

https://blog.csdn.net/sakura____

/   分区存储的背景和目的   /

早期的Android开发,对文件操作缺少限制,只要申请个读写内存权限就可以对整个文件目录随便操作,绝大多数应用都会在根目录建一个自己的文件夹用来存储数据,甚至把应用数据库(SQLite)移到外部文件夹中以防止应用卸载后数据被删除。

从文件角度来说,这样会造成用户内存文件特别多特别混乱,而且卸载的时候这些文件并不会被移除,长期占用存储空间,从用户隐私角度来说,应用可以监听文件的变化或者篡改文件等,存在很大的安全隐患,导致Android这方面的生态特别混乱。

其实Android并不是没有做这方面的API,Android早就提供了getCacheDir()、getFilesDir()、getExternalFilesDir()、getExternalCacheDir()等API供开发者使用,奈何开发者不听话,不论是为了应用方便统一管理文件,亦或者想让文件不会因为应用的卸载而被移除,多数开发者都会选择在外部建立自己的专有文件夹来保存文件。

为了解决文件混乱的问题,以及让用户能够更好地控制自己的文件和更好的保护用户隐私,Google从Android Q版本开始修改了外部存储权限。这种外部存储的新特性被称为分区存储(Scoped Storage)。官方翻译称为分区存储,我们一般称为沙盒模式。

至于为什么这么叫,大概是因为iOS一直都是这么叫的吧。  Android Q仍然使用READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE作为面向用户的存储相关运行时权限,但现在即使获取了这些权限,访问外部存储也受到了限制。APP需要这些运行时权限的情景发生了变化,且各种情况下外部存储对APP的可见性也发生了变化。

从Android Q正式发布,官方就开始推行新特性的适配工作,当时官方的说法是从Android Q开始(targetSdkVersion >= 29)将不再允许应用无限制的操作或访问公共目录,强制使用分区存储,但是Android Q版本可以通过在Manifest中声android:requestLegacyExternalStorage="true"   来继续使用以前的存储方式(当然你也可以选择不升级应用的targetSdkVersion),但是从AndroidR(11)开始,requestLegacyExternalStorage也会失效。

但是官方又新增了preserveLegacyExternalStorage属性,开启该属性可以使原本未开启分区存储的应用在覆盖安装后仍然可以继续使用旧的存储方式,但是新安装的应用将没有任何办法使用旧的存储方式。关于requestLegacyExternalStorage和preserveLegacyExternalStorage在不同版本的表现,总结如下:

Android 10、11分区存储适配踩坑总结_第1张图片

AndroidP/Q

Android 10、11分区存储适配踩坑总结_第2张图片

AndroidR

关于targetSdkVersion,Google Play的规定是从今年8月开始,所有新上线的应用的目标API,即targetSdkVersion必须升级到30以上,对现有应用更新新的版本,这个政策的要求将自 11 月开始生效。抛开Google Play的规定不谈,关于Gradle中的minSdkVersion、compileSdkVersion以及targetSdkVersion的具体作用,参考此篇博客:

https://blog.csdn.net/qq_23062979/article/details/81294550

/   这么做的好处   /

对用户而言,当开发者适配完成后,用户的文件目录将不再像以前那么混乱,能够让用户更好的管理自己的文件。

应用的数据全部存储在沙盒内,卸载应用时所有的文件也随之移除,解决了卸载应用仍然占用存储空间的问题。

保护用户隐私,应用将不能随便访问公共文件,存储媒体文件等需要通过操作媒体数据库的方式存储在特定的媒体文件夹中,对用户来说,所有的媒体文件都存储在特定的文件夹下,也方便管理。

对于开发者而言,这样做也能更好的保护自己的应用,应用不能访问其他应用的沙盒目录,应用的沙盒目录存储在  /Android/data/包名  文件夹下,而且在AndroidR版本的手机上,Android/data文件夹也向用户隐藏,通过系统文件管理器或者是连接电脑都无法访问data文件夹(可以通过SAF向用户申请权限来访问,仅用于文件管理类应用,或者获取Root权限)。

/   分区存储带来了哪些变化   /

分区存储将外部存储分成两部分:

App-specific directory (沙盒目录)

APP只能在Context.getExternalFilesDir()目录下通过File的方式创建文件,APP卸载的时候,这个目录下的文件会被删除;无法通过File的方式在其他路径创建文件。

Public Directory 公共目录 

公共目录包括:多媒体公共目录(Photos, Images, Videos, Audio)和下载文件目录(Downloads)。

APP可以通过MediaStore或者SAF(System Access Framework)的方式访问其中的文件。APP卸载后,文件不会被删除。

Android Q以上移除了WRITE_EXTERNAL_STORAGE权限,应用不需要这个权限就可以向沙盒内存储文件,也可以通过媒体数据库的方式保存媒体数据至特定位置。

公共目录的媒体文件(Photos, Images, Videos, Audio)通过MediaStore来访问,另外,MediaStore的DATA字段从Android Q开始被标记为deprecated,通过该字段获取的文件路径不再可靠,Android Q以上新增字段RELATIVE_PATH,代表文件的相对路径,在使用MediaStore保存媒体文件时,可以通过设置该字段来设置媒体文件保存的文件夹。

如:我们要保存一个图片文件,设置RELATIVE_PATH字段为Environment.DIRECTORY_DCIM时,图片会保存到DCIM文件夹下,如果我们想保存图片到DCIM/CustomDir 文件夹下时,可以设置RELATIVE_PATH的值为:Environment.DIRECTORY_DCIM+“/CustomDir”,当然,你也可以改成Environment.DIRECTORY_PICTURES来将图片保存在Pictures文件夹下。

应用可以通过MediaStore访问其他App创建的多媒体文件,但需要申请READ_EXTERNAL_STORAGE权限。同时,如果用户要修改或者删除其他App创建的多媒体文件,需要用户单独授权。

App卸载后,对应的沙盒目录也会被删除,如果APP想要在卸载时保留沙盒目录下的数据,要在AndroidManifest.xml中声明android:hasFragileUserData="true",这样在 APP卸载时就会有弹出框提示用户是否保留应用数据。

/   适配Android Q   /

保存文件至App-specific directory (沙盒目录)

Android 10、11分区存储适配踩坑总结_第3张图片

沙盒内的文件可以直接使用File的Api进行操作,且不需要申请读写内存权限,代码示例:


    val appFilePath = getExternalFilesDir(null)?.path?:""
    val appImagePath = getExternalFilesDir(Environment.DIRECTORY_DCIM)?.path?:""
    val appCustomPath = getExternalFilesDir("Demo")?.path?:""
    val appCachePath = getExternalCacheDir()?.path?:""

访问公共目录(MediaStore)

MediaStore提供下列Uri,可以用MediaProvider查询对应的Uri数据

Android 10、11分区存储适配踩坑总结_第4张图片

代码示例如下,使用MediaStore查询手机上的图片。


    val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    val projection = arrayOf(MediaStore.Images.Media._ID)
    val cursor = contentResolver.query(external, projection, null, null, null)
    if (cursor != null && cursor.moveToFirst()) {
        queryUri = ContentUris.withAppendedId(external, cursor.getLong(0))
        // queryUri即上图中对应的uri
        cursor.close()
    }

在Android Q以下版本,使用该方法可以拿到媒体文件的绝对路径(比如external/DCIM/xxx.png),即DATA字段,但是在Android Q及以上版本,DATA字段被弃用且不再可靠,新增了RELATIVE_PATH字段表示相对地址,通过该字段可以设置媒体文件保存的位置(具体见下文)。

Android Q以下版本可以通过DATA字段拿到绝对路径并转换成File类型,对文件进行操作,Android Q之后不再可行。要访问这个uri,通用的方法是通过文件描述符FileDescriptor来实现,示例代码如下:


    var pfd: ParcelFileDescriptor? = null
    try {
        pfd = contentResolver.openFileDescriptor(queryUri!!, "r")
        if (pfd != null) {
            val bitmap = BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor)
            imageIv.setImageBitmap(bitmap)
        }
    } catch (e: IOException) {
        e.printStackTrace()
    } finally {
        pfd?.close()
    }

读取MedisStore文件时,如果未申请READ_EXTERNAL_STORAGE权限,那么读取到的图片只有自己应用保存的图片,换句话说,应用读取和操作自己保存的媒体文件不需要申请READ_EXTERNAL_STORAGE权限,但是要访问其他应用创建的媒体文件,需要申请权限。

在Android Q以下只使用DATA字段,Android Q及以上不使用DATA字段,改为使用RELATEIVE_PATH字段。

保存文件至公共目录 (MediaStore)

最常见的一个操作:保存图片/视频到媒体目录。


    val imageMediaPath = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), "Demo").path
    } else {
        Environment.DIRECTORY_PICTURES + "/Demo"
    }

    if(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        try {
            val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
            //创建了一个红色的图片
            val canvas = Canvas(bitmap)
            canvas.drawColor(Color.RED)
            val outputFile = File(path)
            val fos = FileOutputStream(outputFile)
            bitmap.compress(Bitmap.CompressFormat.PNG, 90, fos)
            fos.close()
        } catch (e : FileNotFoundException) {
            Log.d(TAG, "创建失败:${e.message}")
        } catch (e : IOException) {
            Log.d(TAG, "创建失败:${e.message}")
        }
    }else{
        val values = ContentValues()
        values.put(MediaStore.Images.Media.DISPLAY_NAME, "red_image.png")
        values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image")
        values.put(MediaStore.Images.Media.MIME_TYPE, "image/png")
        values.put(MediaStore.Images.Media.RELATIVE_PATH, imageMediaPath)
        val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
        val insertUri = contentResolver.insert(external, values)
        var os: OutputStream? = null
        try {
            if (insertUri != null) {
                os = contentResolver.openOutputStream(insertUri)
            }
            if (os != null) {
                val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
                //创建了一个红色的图片
                val canvas = Canvas(bitmap)
                canvas.drawColor(Color.RED)
                // 向os流写入数据
                bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
            }
        } catch (e: IOException) {
            Log.d(TAG, "创建失败:${e.message}")
        } finally {
            os?.close()
        }
    }

注意:使用MediaStore保存媒体文件,不保证在Android Q以下的手机上也能成功,所以最稳妥的办法就是Android Q以下申请WRITE_EXTERNAL权限,直接使用File的Api保存文件并通知系统扫描媒体数据库,Android Q及以上版本才使用MediaStore方式存储。其他媒体文件(如视频,音频,文件等)同上。另外,使用MediaStore存储的方式不需要通知系统扫描媒体数据库。

删除公共目录文件(MediaStore)

Android Q以下版本,删除文件需要申请WRITE_EXTERNAL_STORAGE权限,通过MediaStore的DATA字段获得媒体文件的绝对路径,然后使用File相关API删除,在Android Q及以上版本,DATA字段被弃用,应用也无法通过路径访问公共目录,此时需要用getContentProvider.delete()方法来删除,应用删除自己创建的媒体文件不需要READ_EXTERNAL_STORAGE权限,也不需要用户授权就可以直接删除。

但是如果应用卸载后又重新安装,删除卸载之前保存的文件就无法直接删除,或者删除其他应用创建的媒体文件也不能直接删除,此时需要申请READ_EXTERNAL_STORAGE权限。Android Q以后,删除时还会抛出RecoverableSecurityException异常,在操作或删除公共目录的文件时,需要Catch该异常,由MediaProvider弹出弹框给用户选择是否允许应用修改或删除图片/视频/音频文件。用户操作的结果,将通过onActivityResult回调返回到APP。如果用户允许,APP将获得该Uri的修改权限,直到设备重启。

示例代码如下:


    //这里的imgUri是使用上述代码获取的
    val queryUri = imgUri
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        try {
            val projection = arrayOf(MediaStore.Images.Media.DATA)
            val cursor = contentResolver.query(queryUri, projection,
                        null, null, null)
            cursor?.let{
                val columnIndex = it.getColumnIndex(MediaStore.Images.Media.DATA)
                if (columnIndex > -1) {
                    val file = File(it.getString(columnIndex))
                    file.delete()
                }
            }
            cursor?.close()
        } catch (e: IOException) {
            Log.e(TAG, "delete failed :${e.message}")
        }
    } else {
        try {
            contentResolver.delete(queryUri, null, null)
        } catch (e: IOException) {
            Log.e(TAG, "delete failed :${e.message}")
        } catch (e1: RecoverableSecurityException) {
            //捕获 RecoverableSecurityException异常,发起请求
            try {
                startIntentSenderForResult(e1.userAction.actionIntent.intentSender,
                            REQUEST_CODE, null, 0, 0, 0)
            } catch (e2: IntentSender.SendIntentException) {
                e2.printStackTrace()
            }
        }
    }

申请权限时,我的手机弹窗内容如下:

Android 10、11分区存储适配踩坑总结_第5张图片

无法访问图片的地理位置数据

Android Q及以上版本,因为隐私问题,默认不再提供图片的地理位置信息,要获取该信息需要向用户申请ACCESS_MEDIA_LOCATION权限,并使用MediaStore.setRequireOriginal()接口更新文件Uri。

分享文件的处理(AndroidN)

在Android N以前,分享文件没有任何限制,拿到文件后通过Uri.fromFile()转换成uri,即可分享文件到其他应用,这样转换出来的uri是file://开头的,在Android N(Android7.0)以后,继续以这种方式分享会抛出FileUriExposedException的异常并崩溃,此时需要用FileProvider来实现文件的分享,具体就不展开讲了,自行百度即可。

使用SAF访问指定文件目录

SAF,即Storage Access Framework。根据当前系统中存在的DocumentsProvider,让用户选择特定的文件或文件夹,使调用SAF的APP获取它们的读写权限。APP通过SAF获得文件或目录的读写权限,无需申请任何存储相关的运行时权限。

/   总结   /

现在的手机系统,Android Q系统已逐渐成为主流,Android12也马上要发布,所以适配Android Q是十分必要的工作,个人建议是,在开发过程中,不管什么版本,如果文件只有自己应用需要,都保存在沙盒目录(App-special)内,根据文件类型做好文件夹区分,如File,Cache,Pictures等。

如果需要保存图片或者视频到相册时,Android Q以下系统依旧使用旧的方式,直接使用文件方式保存,Android Q以上使用MediaStore方式存储,读取媒体文件时(最常见的比如读取用户的图片并显示),Android Q以下使用DATA字段,Android Q以上使用RELATIVE_PATH字段,验证媒体文件是否存在时,均使用openFileDescriptor的方式。保存文档等文件同上,这样的意义在于文件不会随着应用的卸载而被删除。

Android Q以下版本保存图片等媒体文件时不要使用MediaStore的方式,因为Android Q以下并不能保证MediaStore方式的可靠性,有可能手机厂商更改了某些行为,所以这种方式只在Android Q及以上版本使用,Android Q以下版本,申请WRITE_EXTERNAL_STORAGE权限并使用文件操作即可。

如果有特殊需求需要访问公共目录的文件,使用SAF向用户申请权限,一般是文件管理类应用才有这类需求。

Android Q之后,无论是向沙盒内保存文件,还是使用MediaStore保存媒体文件,又或者是使用SAF访问特定文件目录,均不需要WRITE_EXTERNAL_STORAGE权限,适配工作做好以后,动态申请的时候根据版本去掉即可。在需要访问用户图片或者其他媒体文件时,再申请READ_EXTERNAL_STORAGE权限,删除其他应用的媒体文件时还需要额外向用户申请读写操作权限。

你可能感兴趣的:(android,开发,android)