android10新存储方案

android10新存储方案

早期的Android开发,对文件操作缺少限制,只要申请个读写内存权限就可以对整个文件目录随便操作,绝大多数应用都会在根目录建一个自己的文件夹用来存储数据,甚至把应用数据库(SQLite)移到外部文件夹中以防止应用卸载后数据被删除。那么这么做有什么好处吗?我想了一下,大概有两点吧。第一,存储在SD卡的文件不会计入到应用程序的占用空间当中,也就是说即使你在SD卡存放了1G的文件,你的应用程序在设置中显示的占用空间仍然可能只有几十K。第二,存储在SD卡的文件,即使应用程序被卸载了,这些文件仍然会被保留下来,这有助于实现一些需要数据被永久保留的功能。

然而,这些“好处”真的是好处吗?或许对于开发者而言这算是好处吧,但对于用户而言,上述好处无异于一些流氓行为。因为这会将用户的SD卡空间搞得乱糟糟的,而且即使我卸载了一个完全不再使用的程序,它所产生的垃圾文件却可能会一直保留在我的手机上。

另外,存储在SD卡上的文件属于公有文件,所有的应用程序都有权随意访问,这也对数据的安全性带来了很大的挑战。

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

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

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在不同版本的表现,总结如下:

图片

AndroidP/Q

图片

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 10系统对于作用域存储适配的要求还不是那么严格,毕竟之前传统外置存储空间的用法实在是太广泛了。如果你的项目指定的targetSdkVersion低于29,那么即使不做任何作用域存储方面的适配,你的项目也可以成功运行到Android 10手机上。

而如果你的targetSdkVersion已经指定成了29,也没有关系,假如你还不想进行作用域存储的适配,只需要在AndroidManifest.xml中加入如下配置即可:

1
2
3
4
5
<manifest ... >
<application android:requestLegacyExternalStorage="true" ...>
...
</application>
</manifest>

这段配置表示,即使在Android 10系统上,仍然允许使用之前遗留的外置存储空间的用法来运行程序,这样就不用对代码进行任何修改了。当然,这只是一种权宜之计,在未来的Android系统版本中,这段配置随时都可能会失效(目前Android 11预览版已经确认,这段配置至少在Android 11上不会失效)。因此,我们还是非常有必要现在就来学习一下,到底该如何对作用域存储进行适配。

另外,本篇文章中演示的所有示例,都可以到ScopedStorageDemo这个开源库中找到其对应的源码。

开源库地址是:

https://github.com/guolindev/ScopedStorageDemo

那么到底什么是作用域存储/分区存储呢?

简单来讲,就是Android系统对SD卡的使用做了很大的限制。从Android 10开始,每个应用程序只能有权在自己的外置存储空间关联目录下读取和创建文件,获取该关联目录的代码是:context.getExternalFilesDir()。关联目录对应的路径大致如下:

1
/storage/emulated/0/Android/data/<包名>/files

将数据存放到这个目录下,你将可以完全使用之前的写法来对文件进行读写,不需要做任何变更和适配。但同时,刚才提到的那两个“好处”也就不存在了。这个目录中的文件会被计入到应用程序的占用空间当中,同时也会随着应用程序的卸载而被删除。

那么有些朋友可能会问了,我就是需要访问其他目录该怎么办呢?比如读取手机相册中的图片,或者向手机相册中添加一张图片。为此,Android系统针对文件类型进行了分类,图片、音频、视频这三类文件将可以通过MediaStore API来进行访问,而其他类型的文件则需要使用系统的文件选择器来进行访问。

另外,我们的应用程序向媒体库贡献的图片、音频或视频,将会自动拥有其读写权限,不需要额外申请READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限。而如果你要读取其他应用程序向媒体库贡献的图片、音频或视频,则必须要申请READ_EXTERNAL_STORAGE权限才行。WRITE_EXTERNAL_STORAGE权限将会在未来的Android版本中废弃。

分区存储带来了哪些变化

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

  1. App-specific directory (沙盒目录)

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

  1. 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 (沙盒目录)

图片

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

1
2
3
4
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数据

图片

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

1
2
3
4
5
6
7
8
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来实现,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
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)

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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的修改权限,直到设备重启。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//这里的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 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权限,删除其他应用的媒体文件时还需要额外向用户申请读写操作权限。


常用操作

/ 获取相册中的图片 /

首先来学习一下如何在作用域存储当中获取手机相册里的图片。注意,虽然本篇文章中我是以图片来举例的,但是获取音频、视频的用法也是基本相同的。

不同于过去可以直接获取到相册中图片的绝对路径,在作用域存储当中,我们只能借助MediaStore API获取到图片的Uri,示例代码如下:

1
2
3
4
5
6
7
8
9
val cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, "${MediaStore.MediaColumns.DATE_ADDED} desc")
if (cursor != null) {
while (cursor.moveToNext()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
println("image uri is $uri")
}
cursor.close()
}

上述代码中,我们先是通过ContentResolver获取到了相册中所有图片的id,然后再借助ContentUris将id拼装成一个完整的Uri对象。一张图片的Uri格式大致如下所示:

1
content://media/external/images/media/321

那么有些朋友可能会问了,获取到了Uri之后,我又该怎样将这张图片显示出来呢?这就有很多种办法了,比如使用Glide来加载图片,它本身就支持传入Uri对象来作为图片路径:

1
Glide.with(context).load(uri).into(imageView)

而如果你没有使用Glide或其他图片加载框架,想在不借助第三方库的情况下直接将一个Uri对象解析成图片,可以使用如下代码:

1
2
3
4
5
6
val fd = contentResolver.openFileDescriptor(uri, "r")
if (fd != null) {
val bitmap = BitmapFactory.decodeFileDescriptor(fd.fileDescriptor)
fd.close()
imageView.setImageBitmap(bitmap)
}

上述代码中,我们调用了ContentResolver的openFileDescriptor()方法,并传入Uri对象来打开文件句柄,然后再调用BitmapFactory的decodeFileDescriptor()方法将文件句柄解析成Bitmap对象即可。

Demo效果:

图片

这样我们就将获取相册中图片的方式掌握了,并且这种方式在所有的Android系统版本中都适用。

那么接下来,我们开始学习如何将一张图片添加到相册。

/ 将图片添加到相册 /

将一张图片添加到手机相册要相对稍微复杂一点,因为不同系统版本之间的处理方式是不太一样的。

我们还是通过一段代码示例来直观地学习一下,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun addBitmapToAlbum(bitmap: Bitmap, displayName: String, mimeType: String, compressFormat: Bitmap.CompressFormat) {
val values = ContentValues()
values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
} else {
values.put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName")
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
val outputStream = contentResolver.openOutputStream(uri)
if (outputStream != null) {
bitmap.compress(compressFormat, 100, outputStream)
outputStream.close()
}
}
}

这段代码演示了如何将一个Bitmap对象添加到手机相册当中,我来简单解释一下。

想要将一张图片添加到手机相册,我们需要构建一个ContentValues对象,然后向这个对象中添加三个重要的数据。一个是DISPLAY_NAME,也就是图片显示的名称,一个是MIME_TYPE,也就是图片的mime类型。还有一个是图片存储的路径,不过这个值在Android 10和之前的系统版本中的处理方式不一样。Android 10中新增了一个RELATIVE_PATH常量,表示文件存储的相对路径,可选值有DIRECTORY_DCIM、DIRECTORY_PICTURES、DIRECTORY_MOVIES、DIRECTORY_MUSIC等,分别表示相册、图片、电影、音乐等目录。而在之前的系统版本中并没有RELATIVE_PATH,所以我们要使用DATA常量(已在Android 10中废弃),并拼装出一个文件存储的绝对路径才行。

有了ContentValues对象之后,接下来调用ContentResolver的insert()方法即可获得插入图片的Uri。但仅仅获得Uri仍然是不够的,我们还需要向该Uri所对应的图片写入数据才行。调用ContentResolver的openOutputStream()方法获得文件的输出流,然后将Bitmap对象写入到该输出流当中即可。

以上代码即可实现将Bitmap对象存储到手机相册当中,那么有些朋友可能会问了,如果我要存储的图片并不是Bitmap对象,而是一张网络上的图片,或者是当前应用关联目录下的图片该怎么办呢?

其实方法都是相似的,因为不管是网络上的图片还是关联目录下的图片,我们都能获取到它的输入流,只要不断读取输入流中的数据,然后写入到相册图片所对应的输出流当中就可以了,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
fun writeInputStreamToAlbum(inputStream: InputStream, displayName: String, mimeType: String) {
val values = ContentValues()
values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
} else {
values.put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName")
}
val bis = BufferedInputStream(inputStream)
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
val outputStream = contentResolver.openOutputStream(uri)
if (outputStream != null) {
val bos = BufferedOutputStream(outputStream)
val buffer = ByteArray(1024)
var bytes = bis.read(buffer)
while (bytes >= 0) {
bos.write(buffer, 0 , bytes)
bos.flush()
bytes = bis.read(buffer)
}
bos.close()
}
}
bis.close()
}

这段代码中只是将输入流和输出流的部分重新编写了一下,其他部分和之前存储Bitmap的代码是完全一致的,相信很好理解。

Demo效果:

图片

好了,这样我们就将相册图片的读取和存储问题都解决了,下面我们来探讨另外一个常见的需求,如何将文件下载到Download目录。

/ 下载文件到Download目录 /

执行文件下载操作是一个很常见的场景,比如说下载pdf、doc文件,或者下载APK安装包等等。在过去,这些文件我们通常都会下载到Download目录,这是一个专门用于存放下载文件的目录。而从Android 10开始,我们已经不能以绝对路径的方式访问外置存储空间了,所以文件下载功能也会受到影响。

那么该如何解决呢?主要有以下两种方式。

第一种同时也是最简单的一种方式,就是更改文件的下载目录。将文件下载到应用程序的关联目录下,这样不用修改任何代码就可以让程序在Android 10系统上正常工作。但使用这种方式,你需要知道,下载的文件会被计入到应用程序的占用空间当中,同时如果应用程序被卸载了,该文件也会一同被删除。另外,存放在关联目录下的文件只能被当前的应用程序所访问,其他程序是没有读取权限的。

以上几个限制条件如果不能满足你的需求,那么就只能使用第二种方式,对Android 10系统进行代码适配,仍然将文件下载到Download目录下。

其实将文件下载到Download目录,和向相册中添加一张图片的过程是差不多的,Android 10在MediaStore中新增了一种Downloads集合,专门用于执行文件下载操作。但由于每个项目下载功能的实现都各不相同,有些项目的下载实现还十分复杂,因此怎么将以下的示例代码融合到你的项目当中是你自己需要思考的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
fun downloadFile(fileUrl: String, fileName: String) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Toast.makeText(this, "You must use device running Android 10 or higher", Toast.LENGTH_SHORT).show()
return
}
thread {
try {
val url = URL(fileUrl)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 8000
connection.readTimeout = 8000
val inputStream = connection.inputStream
val bis = BufferedInputStream(inputStream)
val values = ContentValues()
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
val outputStream = contentResolver.openOutputStream(uri)
if (outputStream != null) {
val bos = BufferedOutputStream(outputStream)
val buffer = ByteArray(1024)
var bytes = bis.read(buffer)
while (bytes >= 0) {
bos.write(buffer, 0 , bytes)
bos.flush()
bytes = bis.read(buffer)
}
bos.close()
}
}
bis.close()
} catch(e: Exception) {
e.printStackTrace()
}
}
}

这段代码总体来讲还是比较好理解的,主要就是添加了一些Http请求的代码,并将MediaStore.Images.Media改成了MediaStore.Downloads,其他部分几乎是没有变化的,我就不再多加解释了。

注意,上述代码只能在Android 10或更高的系统版本上运行,因为MediaStore.Downloads是Android 10中新增的API。至于Android 9及以下的系统版本,请你仍然使用之前的代码来进行文件下载。

Demo效果:

图片

/ 使用文件选择器 /

如果我们要读取SD卡上非图片、音频、视频类的文件,比如说打开一个PDF文件,这个时候就不能再使用MediaStore API了,而是要使用文件选择器。

但是,我们不能再像之前的写法那样,自己写一个文件浏览器,然后从中选取文件,而是必须要使用手机系统中内置的文件选择器。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const val PICK_FILE = 1

private fun pickFile() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
startActivityForResult(intent, PICK_FILE)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
PICK_FILE -> {
if (resultCode == Activity.RESULT_OK && data != null) {
val uri = data.data
if (uri != null) {
val inputStream = contentResolver.openInputStream(uri)
// 执行文件读取操作
}
}
}
}
}

这里在pickFile()方法当中通过Intent去启动系统的文件选择器,注意Intent的action和category都是固定不变的。而type属性可以用于对文件类型进行过滤,比如指定成image/就可以只显示图片类型的文件,这里写成/*表示显示所有类型的文件。注意type属性必须要指定,否则会产生崩溃。

然后在onActivityResult()方法当中,我们就可以获取到用户选中文件的Uri,之后通过ContentResolver打开文件输入流来进行读取就可以了。

Demo效果:

图片

/ 第三方SDK不支持怎么办? /

阅读完了本篇文章之后,相信你对Android 10作用域存储的用法和适配基本上都已经掌握了。然而我们在实际的开发工作当中还可能会面临一个非常头疼的问题,就是我自己的代码当然可以进行适配,但是项目中使用的第三方SDK还不支持作用域存储该怎么办呢?

这个情况确实是存在的,比如我之前使用的七牛云SDK,它的文件上传功能要求你传入的就是一个文件的绝对路径,而不支持传入Uri对象,大家应该也会碰到类似的问题。

由于我们是没有权限修改第三方SDK的,因此最简单直接的办法就是等待第三方SDK的提供者对这部分功能进行更新,在那之前我们先不要将targetSdkVersion指定到29,或者先在AndroidManifest文件中配置一下requestLegacyExternalStorage属性。

然而如果你不想使用这种权宜之计,其实还有一个非常好的办法来解决此问题,就是我们自己编写一个文件复制功能,将Uri对象所对应的文件复制到应用程序的关联目录下,然后再将关联目录下这个文件的绝对路径传递给第三方SDK,这样就可以完美进行适配了。这个功能的示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun copyUriToExternalFilesDir(uri: Uri, fileName: String) {
val inputStream = contentResolver.openInputStream(uri)
val tempDir = getExternalFilesDir("temp")
if (inputStream != null && tempDir != null) {
val file = File("$tempDir/$fileName")
val fos = FileOutputStream(file)
val bis = BufferedInputStream(inputStream)
val bos = BufferedOutputStream(fos)
val byteArray = ByteArray(1024)
var bytes = bis.read(byteArray)
while (bytes > 0) {
bos.write(byteArray, 0, bytes)
bos.flush()
bytes = bis.read(byteArray)
}
bos.close()
fos.close()
}
}

好的,关于Android 10作用域存储的重要知识点就讲到这里,相信你已经可以完全掌握了。下篇文章中我们会继续学习Android 10适配,讲一讲深色主题的功能,敬请期待。

注:本篇文章中演示的所有示例,都可以到ScopedStorageDemo这个开源库中找到其对应的源码。

开源库地址是:

https://github.com/guolindev/ScopedStorageDemo


———分割线————-

一文带你了解适配Android 11分区存储

1 分区存储概念

为了让用户更好地控制自己的文件并减少混乱,Android 10针对应用推出的一个新的存储范例,新的存储模型会让以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被赋予了对外部存储设备的分区访问权限,即分区存储(scoped storage)。分区存储改变了应用在设备的外部存储设备中存储和访问文件的方式。

从另一个角度来说,分区存储的推出更好的保护用户的隐私。默认情况下,对于以 Android 10 及更高版本为目标平台的应用,其访问权限范围限定为外部存储,即分区存储。此类应用可以查看外部存储设备内以下类型的文件,无需请求任何与存储相关的用户权限:

  • 特定于应用的目录中的文件(使用 getExternalFilesDir() 访问)。

  • 应用创建的照片、视频和音频片段(通过媒体库访问)。

意思是说,我们的app在外部存储设备(即SD卡)上存文件的时候,需要先想明白需要存的数据是属于app私有的还是需要分享的,如果是app私有的,存在getExternalFilesDir()返回的文件夹下,也就是Android/data/包名/files/文件夹;如果是需要分享的,需要采用媒体库(MediaStore)的方式来存取,后面会讲怎么存取。需要指出的是在分区存储模型下存取共享媒体文件是不需要存储权限的,而旧的存储模型是需要存储权限的。

下表总结了分区存储如何影响文件访问:

文件位置 所需权限 访问方法 (*) 卸载应用时是否移除文件?
特定于应用的目录 getExternalFilesDir()
媒体集合(照片、视频、音频) READ_EXTERNAL_STORAGE(仅当访问其他应用的文件时) MediaStore
下载内容(文档和电子书籍) 存储访问框架(加载系统的文件选择器)

*2 *适配分区存储

为什么要适配

在分区存储模型下,外部存储设备的公共区域是不让访问的,如果强行访问,会在创建或读写文件的api上报错,具体看分区存储模型下,访问SD卡公共区域错误举例。那么有没有办法关闭分区存储模型呢?

有两种办法,第一种是app的targetSdkVersion永远低于29,这个是不现实的;第二种办法是targetSdkVersion 29时覆盖安装和新安装能关闭,targetSdkVersion 30时覆盖安装能关闭,新安装是没有办法关闭的,具体看requestLegacyExternalStorage和preserveLegacyExternalStorage的理解。而且说不定,Android 12出来后,以Android 12为目标平台的app都是强制执行分区存储模型的。所以分区存储是一定需要适配的,而且越早适配越好。

怎么适配

适配分为两部分,新数据的存储和老数据的迁移,我们先说新数据的存储。

新数据的存储

把app所有需要存的数据梳理一遍,对于私有数据我们存到SD卡app私有目录下,对于需要共享的媒体数据我们通过MediaStore的方式。数据放到私有目录很简单我们不讲,主要讲怎么共享媒体数据,以视频为例,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 保存共享媒体资源,必须使用先在MediaStore创建表示视频保存信息的Uri,然后通过Uri写入视频数据的方式。
* 在"分区存储"模型中,这是官方推荐的,因为在Android 10禁止通过File的方式访问媒体资源,Android 11又允许了
* 从Android 10开始默认是分区存储模型
*
*
* 说明:
* 此方法中MediaStore默认的保存目录是/storage/emulated/0/video
* 而Environment.DIRECTORY_MOVIES的目录是/storage/emulated/0/Movies
* @param context
* @return
*/
static Uri getSaveToGalleryVideoUri(Context context, String videoName, String mineType, String subDir) {
ContentValues values = new ContentValues();
values.put(MediaStore.Video.Media.DISPLAY_NAME, videoName);
values.put(MediaStore.Video.Media.MIME_TYPE, mineType);
values.put(MediaStore.Video.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + subDir);
}

Uri uri = context.getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
printMediaInfo(context, uri);
return uri;
}

需要保存视频的时候,其实就是先在MediaStore的Video表插入一条记录,获取一个Uri,然后把视频写入这Uri就行了。具体保存位置,我们不用操心,它其实是保存到了Sd卡的Movies文件夹下了,在Android 10以上系统提供RELATIVE_PATH字段用于创建子目录。

我们会问,高版本可以这样共享视频,那么低版本可以吗?如果可以的话,低版本的也用这种方式,一套方案解决。理论上是可以的,毕竟MediaStore从Android诞生就存在。可实际操作发现了问题,具体看下面代码注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
* 此接口用于获取保存共享视频的输出流,推荐!!!
*
* 在低于29的系统上采用getSaveToGalleryVideoUri的方式保存共享视频,会有文件名不能定制、视频保存类型是.3gp、视频保存在video文件夹等问题
* 所以在低版本上采用文件路径的方式写入数据。在低于29的系统上采用文件路径的方式是没有问题的,因为在这些系统上没有分区存储的概念
* 以及,getExternalStoragePublicDirectory函数可用
*
* @param context
* @param videoName
* @param mineType
* @return
* @throws FileNotFoundException
*/
public static FileOutputStream getSaveToGalleryVideoOutputStream(@NonNull Context context, @NonNull String videoName, @NonNull String mineType) throws FileNotFoundException {
//先在MediaStore中查询,有的话直接返回
Uri uri = SHScopedStorageManager.querySpecialVideoUri(context, videoName);
if (uri != null) {
ParcelFileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(uri, "w");
FileOutputStream outputStream = new FileOutputStream(fileDescriptor.getFileDescriptor());
return outputStream;
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
uri = getSaveToGalleryVideoUri(context, videoName, mineType);
if (uri == null)
return null;
ParcelFileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(uri, "w");
FileOutputStream outputStream = new FileOutputStream(fileDescriptor.getFileDescriptor());
return outputStream;
} else {
if (TextUtils.isEmpty(videoName)) {
videoName = String.valueOf(System.currentTimeMillis());
}
//通过显示路径方式共享媒体的时候,是需要指定文件后缀,要不然下载文件会没有后缀名
if (!TextUtils.isEmpty(mineType)) {
String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mineType);
if (videoName.contains(".")) {
videoName = videoName.substring(0, videoName.indexOf(".")) + "." + extension;
} else {
videoName += "." + extension;
}
}

/**
* 直接路径的方式,组合出的文件路径,路径中的文件夹一定要存在,否则转成FileOutputStream的时候会报FileNotFoundException
* 即便是通过DATA注册到MediaStore中,也是如此
*/
String rootPath = getSaveToGalleryVideoPath();
String videoPath = null;
if (rootPath.endsWith(File.separator)) {
videoPath = rootPath + videoName;
} else {
videoPath = rootPath + File.separator + videoName;
}

//通过DATA字段在MediaStore中注册一下
ContentValues values = new ContentValues();
values.put(MediaStore.Video.Media.DISPLAY_NAME, videoName);
values.put(MediaStore.Video.Media.MIME_TYPE, mineType);
values.put(MediaStore.Video.Media.DATA, videoPath);
values.put(MediaStore.Video.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000);
uri = context.getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);

if (uri == null)
return null;

SHScopedStorageManager.printMediaInfo(context, uri);
ParcelFileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(uri, "w");
FileOutputStream outputStream = new FileOutputStream(fileDescriptor.getFileDescriptor());

return outputStream;
}
}

public static String getSaveToGalleryVideoPath() {
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
if (!path.exists()) {
path.mkdirs();
}
String pathStr = path.getAbsolutePath() + VIDEO_DIR;
File file = new File(pathStr);
if (!file.exists()) {
file.mkdirs();
}
return pathStr;
}

解决办法,进行了版本区分,对外暴露OutputStream接口,低版本我们采用直接路径的方式,直接把视频保存到Movies目录下,而且还可以有子目录,为了让相册或者别的app能看到保存的视频,我们通过DATA把保存路径注册给了MediaStore,这个在低版本上是可行的,这种方式绝大多数开发者之前都是这么做的,但是,DATA从Android 10开始标记为弃用。

我们这里会问,我们可不可以在Android 10及以上也用直接路径保存视频到Movies目录下呢?可以,但是会有问题,首先Android 10的分区存储模型下不能使用直接路径,因为使用File api报错,不过我们可以通过requestLegacyExternalStorage禁用分区存储模型;最大的问题是获取Movies目录的接口getExternalStoragePublicDirectory从Android 10开始标记为弃用。而且google还提示了使用直接路径操作媒体文件的性能问题。

当您使用直接文件路径依序读取媒体文件时,其性能与 MediaStore API 相当。但是,当您使用直接文件路径随机读取和写入媒体文件时,进程的速度可能最多会慢一倍。在此类情况下,我们建议您改为使用 MediaStore API。

这套适配方案无论是在旧存储模型还是分区存储模型下都能完美运行,把共享视频保存到Medias的指定文件夹下,而且相册和别的app都能扫描的到。共享图片、音频和共享视频思路一样,大家自行编写。

老数据的迁移

迁移老数据是为了在分区存储模型下,老数据依旧可以访问,如果不迁移这些散落在SD卡公共区域的数据,一旦开始执行分区存储模型,这些数据app就访问不到了。也就是说,在app还是旧存储模型的时候,需要把数据迁移到能够兼容分区存储要求的文件夹下。这块具体看数据迁移。

3 数据迁移

理清头绪

在数据迁移的时候,有个很重要的前提是,app能够访问旧存储模型。我们看看什么情况能访问旧存储模型,得分几种情况讨论:

    1. targetSdkVersion 28的app安装在Android 9(28)的手机上,手机系统升级到Android10或11,app正常访问旧存储模型。这种情况和把targetSDKVersion 28的app安装到Android10或11系统手机上一样的情况。
    1. target 28在Android 9上,app target升级到30,覆盖安装,旧存储模型访问正常;target 28在Android10上,app target升级到30,覆盖安装,旧存储模型访问正常。

requestLegacyExternalStorage设置成true,在Android 10上新安装的target 30 app,也可以正常访问旧存储模型。

target28在Android11上,app target升级到30,覆盖安装,旧存储模型不能访问了,需要preserveLegacyExternalStorage设置成true。

怎么进行数据迁移最好呢?

targetSDKVersion 28的时候,先大规模的升级一次,此app就包含数据迁移功能,同时共享媒体的方式也按照分区存储模型的规范来,这样不论什么版本系统的用户,都能完成数据迁移,同时进行共享媒体的方式也正确。

但是,有部分用户就是不升级我们的app,可是我们app以后也得发版,而且target也得升级,假如有一部分用户没升级,等升级的时候,我们的app的target已经是30了,这些用户的系统如果是小于29的,可以正常迁移,如果这些用户的系统版本是29或者30,那也得给这些用户迁移数据呀,target30的app在29的系统上正常迁移,target30的app在30系统上,preserveLegacyExternalStorage设置成true,正常迁移。

所以我们的数据迁移方案就是,做好数据迁移功能和共享媒体功能,requestLegacyExternalStorage和preserveLegacyExternalStorage都设置成true,target升级不升级都没问题。不过前提是compileSdkVersion得是30。

实战

在8.0及以上的系统,采用Files.move进行数据迁移,8.0以下的系统采用File.rename进行数据迁移。Files的move方法既可以作用于文件也可以作用于文件夹。

我们项目中需要move的是文件夹,首先看看对move文件夹的定义:

Empty directories can be moved. If the directory is not empty, the move is allowed when the directory can be moved without moving the contents of that directory. On UNIX systems, moving a directory within the same partition generally consists of renaming the directory. In that situation, this method works even when the directory contains files.

从定义中,我们知道在UNIX系统(linux源自UNIX)上同一个partition上,即便被move的文件夹中有内容,也是可以move的,实际就是重命名了一下。

我们的需求:

在分区存储模型下,SD卡的公共区域是禁止app使用的,为了保证我们app之前下载到SD的视频在分区存储模型下还能被app识别,所以,在app还是采用旧存储模型的时候,我们需要把这些视频迁移到app在SD卡的私有目录下。这两个目录都在SD卡上,属于同一个partition。说明一下,targetSDKVersion 29或30的app在Android 10和Android 11上,也是有办法让app采用旧存储模型的;targetSDKVersion 29以下的app在任何系统上都是执行旧存储模型。

我们的实际情况:

  • 共享数据迁移 把之前保存的需要分享的视频从app自建的目录迁移到分区存储模型下app也能访问到Movies目录,这样做的目的是在分区存储模型下,自己和别的app还是访问到这些视频。
1
2

/storage/emulated/0/shvdownload/video/VideoGallery 迁移到 /storage/emulated/0/Movies/SHVideo

VideoGallery目录中有文件,SHVideo目录不存在,move可以成功。app在分区存储模型下,在任何版本系统上上述迁移都正常。

  • 私有数据迁移
1
2

/storage/emulated/0/xxx/data 迁移到 /storage/emulated/0/Android/data/包名/files/data

xxx/data目录中有文件,files/data目录不存在,==在Android 10及以下的系统上,可以move成功;在Android 11的系统上 ,move失败了,报DirectoryNotEmptyException。== 猜测可能是Android 11对Android/data目录有了限制吧!如果,在Android 11上还需要进行这种迁移的话,可以采用遍历文件夹输入输出流拷贝的方式。

1
2
3
4
5
6
java.nio.file.DirectoryNotEmptyException: /storage/emulated/0/xxx/data
at sun.nio.fs.UnixCopyFile.move(UnixCopyFile.java:498)
at sun.nio.fs.UnixFileSystemProvider.move(UnixFileSystemProvider.java:262)
at java.nio.file.Files.move(Files.java:1395)
at com.xxx.sdk.android.storage.SHDataMigrateUtil.moveData(SHDataMigrateUtil.java:257)
...

File.move 文件夹的时候,如果目标文件夹存在,那么会报java.nio.file.FileAlreadyExistsException异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
private boolean moveData(File source, File target) {
long start = System.currentTimeMillis();
// 只有目标文件夹不存在的时候,move文件夹才能成功
if (target.exists() && target.isDirectory() && (target.list() == null || target.list().length == 0)) {
target.delete();
}
boolean isSuccess;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Path sourceP = source.toPath();
Path targetP = target.toPath();

if (target.exists()) {
isSuccess = copyDir(source, target);
LogUtils.i(TAG, "moveData copyDir");
} else {
try {
Files.move(sourceP, targetP);
isSuccess = true;
LogUtils.i(TAG, "moveData Files.move");
} catch (IOException e) {
e.printStackTrace();
LogUtils.i(TAG, Log.getStackTraceString(e));
//在Android11上,move ATOMIC_MOVE会报AtomicMoveNotSupportedException异常
//在Android11上,move REPLACE_EXISTING会报DirectoryNotEmptyException异常
isSuccess = copyDir(source, target);
LogUtils.i(TAG, "moveData move fail, use copyDir");
}
}
} else {
if (target.exists()) {
isSuccess = copyDir(source, target);
LogUtils.i(TAG, "moveData copyDir");
} else {
isSuccess = source.renameTo(target);
LogUtils.i(TAG, "moveData renameTo result " + isSuccess);
}
}
long end = System.currentTimeMillis();
long val = end - start;
LogUtils.i(TAG, "moveData migrate data take time " + val +" from " + source.getAbsolutePath() + " to " + target.getAbsolutePath());

return isSuccess;
}

4 requestLegacyExternalStorage和preserveLegacyExternalStorage的理解

requestLegacyExternalStorage是Android10引入的,preserveLegacyExternalStorage 是 Android11 引入的。

如果你已经适配Android 10,如果应用通过升级安装,那么还会使用以前的储存模式(Legacy View),只有通过首次安装或是卸载重新安装才能启用新模式(Filtered View)。

经过测试,确实是这样,我们在Android10的手机上安装了一个targetSDKVersion是27的app,旧的存储模型是可以正常使用的,然后覆盖安装了target是29的新包,旧存储模型也是可以访问的,但是,卸载重新安装旧存储模型就不能访问了。

requestLegacyExternalStorage让targetSDKVersion是29(适配了Android 10)的app新安装在Android 10系统上也继续访问旧的存储模型。

如果某个应用在安装时启用了传统外部存储,则该应用会保持此模式,直到卸载为止。无论设备后续是否升级为搭载 Android 10 或更高版本,或者应用后续是否更新为以 Android 10 或更高版本为目标平台,此兼容性行为均适用。

这句话是有些问题的,估计当时说这话的时候,是Android10的时候。在Android11中引入了preserveLegacyExternalStorage,看下面的解释:

按照文档说targetSDKVersion<29时,requestLegacyExternalStorage默认是true的,也就是说这些app是采用旧的存储模型运行的,targetSDKVersion升级到29后,requestLegacyExternalStorage默认是false的,但是覆盖安装的,还是采用旧的存储模式运行。重新安装的,由于requestLegacyExternalStorage是false,就采用分区存储模式运行了,除非requestLegacyExternalStorage显示设置成true。

也就是说requestLegacyExternalStorage给了app,在Android 10的系统上,无论是覆盖安装还是重新安装都能使用旧存储模式的机会。

targetSDKVersion升级到30后,在Android 11设备上,requestLegacyExternalStorage会被忽略掉,在Android 10的系统上requestLegacyExternalStorage依旧有效。

preserveLegacyExternalStorage只是让覆盖安装的app能继续使用旧的存储模型,如果之前是旧的存储模型的话。如果您使用 preserveLegacyExternalStorage,旧版存储模型只在用户卸载您的应用之前保持有效。如果用户在搭载 Android 11 的设备上安装或重新安装您的应用,那么无论 preserveLegacyExternalStorage 的值是什么,您的应用都无法停用分区存储模型。

app targetSDKVersion适配到30,在Android 11的系统上首次安装,是没有任何机会,让app能继续使用旧存储模型的。

5 分区存储模型下,访问SD卡公共区域错误举例

File的api

  • createNewFile targetSdkVersion 28的app在Android 10的系统上,运行旧存储模型,targetSdkVersion升级到30后,覆盖安装在Android10系统上,也是运行旧存储模型的。targetSdkVersion 30的app首次安装到Android10系统上,是开启分区存储模型的(没有配置requestLegacyExternalStorage),在SD卡的公共目录上调用File的createNewFile()方法会报java.io.IOException: No such file or directory。在旧存储模型下,没有开启读写权限的时候,在SD卡的公共目录上调用File的createNewFile()方法也会报java.io.IOException: No such file or directory
1
2
3
4
5
6
7
8

java.io.IOException: No such file or directory
at java.io.UnixFileSystem.createFileExclusively0(Native Method)
at java.io.UnixFileSystem.createFileExclusively(UnixFileSystem.java:317)
at java.io.File.createNewFile(File.java:1008)
at com.xxx.sdk.android.storage.SohuStorageManager.tryGetGalleryPathState(SohuStorageManager.java:748)
...

  • listFiles 分区存储模式下,SD卡的公共目录调用File的listFiles会返回null,即便此文件夹下有文件。

FileOutputStream|FileInputStream

在分区存储模型下,SD卡的公共目录是不让访问的,除了共享媒体的那几个文件夹。所以,用一个公共目录的路径实例化FileOutputStream或者FileInputStream会报FileNotFoundException异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
java.io.FileNotFoundException: /storage/emulated/0/xxx/SharePic/1603277403193.jpg: open failed: ENOENT (No such fileor directory)
at libcore.io.IoBridge.open(IoBridge.java:496)
at java.io.FileOutputStream.<init>(FileOutputStream.java:235)
at com.xxx.ui.QrCodeActivity.askSDCardSaveImgPermission(QrCodeActivity.java:242)
...

java.io.FileNotFoundException: /storage/emulated/0/xxx/data/testusf: open failed: EACCES (Permission denied)
at libcore.io.IoBridge.open(IoBridge.java:496)
at java.io.FileInputStream.<init>(FileInputStream.java:159)
at java.io.FileReader.<init>(FileReader.java:72)
at com.android.xxx.sdk.common.toolbox.FileUtils.readSingleLineStringFromFile(FileUtils.java:747)

本文完。

———分割线————-

杂项

文件系统目录

/storage/emulated/0/ 对应的是手机->文件管理-> 内部存储空间

/storage/emulated/0/Android/ 有些手机这么目录进不去(踩坑),需要借助一些三方app, 比如下载一个’ES文件浏览器’,他去获取访问权限,用他来查看

函数 对应的目录地址
cacheDir /data/user/0/<包名>/cache
dataDir /data/user/0/<包名>
filesDir /data/user/0/<包名>/files
codeCacheDir /data/user/0/<包名>/code_cache
noBackupFilesDir /data/user/0/<包名>/no_backup
obbDir /storage/emulated/0/Android/obb/<包名>
externalCacheDir /storage/emulated/0/Android/data/<包名>/cache
externalFilesDir /storage/emulated/0/Android/data/<包名>/files/Download

如何读写

正确的读写

  • 只在外部存储的应用私有目录下,用直接路径读写文件
  • 访问或者共享媒体文件,使用MediaStore在公共目录下读写文件
  • 访问或者共享非媒体文件,使用系统的文件选择器SAF在公共目录Download下读写文件

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!