版本相关-Android10

Android10

Android 10版本特性

Android 10适配要点,作用域存储

作用域存储,直接颠覆了我们长久以来的外置存储空间的使用方式,因此大量App都将面临着较多代码模块的升级。

理解作用存储

Android 10以前支持外置存储空间这个空间,即SD卡存储。几乎所有App都喜欢在SD卡的根目录下建立一个自己专属的目录,用来存放各类文件和数据。

这样做的好处是:

  1. 存储在SD卡的文件不会计入到应用程序的占用空间当中;
  2. 存储在SD卡的文件,即使应用程序被卸载了,这些文件仍被保留下来有助于实现一些数据永久保留的功能。

缺点:

  1. 导致SD卡空间乱糟糟,卸载应用程序后所产生的垃圾文件扔存在。
  2. SD卡上的文件属于公有文件,其他应用程序都有权随意访问,导致数据安全性没有保证。

针对上面的问题,Android 10中加入了作用域存储功能。Android 10之后,每个应用程序只能有权在自己的外置存储空间关联目录下读取和创建文件,获取该关联目录的代码context.getExternalFilesDir(),关联目录对应路径大致如下:

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

这个目录中的文件会被计入到应用程序的占用空间中,会随应用程序卸载而被删除。

访问其他目录(如 读取/存储 相册中的图片)。Android系统针对文件类型进行了分类,图片、音频、视频这三类可通过MediaStore API进行访问,其他类型的文件需要使用系统的文件选择器来进行访问。

应用程序向媒体库贡献的图片、音频、视频,自动拥有读写权限,不需要额外申请READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE权限。

应用程序访问其他应用程序向媒体库共享的图片、音频、视频,则必须申请READ_EXTERNAL_STORAGE权限才行。WRITE_EXTERNAL_STORAGE在未来将被废弃。

我们一定要升级吗?

暂时可以不用升级,因为之前传统外置存储空间的用法太广泛了。即项目指定targetSdkVersion低于 29,即使不做任何作用域存储方面的适配也可以成功运行在Android 10手机上。不过需要在 AndroidManifest.xml 中加入如下配置:

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

这段配置在Android 11预览版上还不会失效。

针对作用域存储进行适配

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

获取相册中的图片

PS 获取音频、适配的用法也基本相同

不同于过去的直接获取到相册中图片的绝对路径,在作用域存储当中,需要借助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

图片显示有多种方法,如借助第三方库Glide

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

不借助第三方库的做法:将Uri对象解析成图片

1
2
3
4
5
6
val fd = contentResolver.openFileDescriptor(uri, "r")//打开文件句柄
if(fd != null){
val bitmap = BitmapFactory.decodeFileDescriptor(fd.fileDescriptor)//将文件句柄解析成Bitmap对象
fd.close()
imageView.setImageBitmap(bitmap)
}
将图片添加到相册
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 将一个Bitmap对象添加到手机相册中
*/
fun addBitmapToAlbum(bitmap: Bitmap, displayName: String, mimeType: String, compressFormat: Bitmap.CompressFormat) {
val values = ContentValues()//构建一个ContentValues对象,并向这个对象添加三个重要数据
values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)//DISPLAY_NAME图片显示的名称
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)//MIME_TYPE图片的mime类型
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)//图片的存储路径(拼装成绝对路径)。RELATIVE_PATH表示文件存储的相对路径,可选值有DIRECTORY_DCIM、DIRECTORY_PICTURES、DIRECTORY_MOVIES、DIRECTORY_MUSIC等,分别表示相册、图片、电影、音乐等目录
} else {
values.put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName")//图片的存储路径
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)//插入图片的Uri
if (uri != null) {
//向该Uri所对应的图片写入数据
val outputStream = contentResolver.openOutputStream(uri)//获取文件的输出流
if (outputStream != null) {
bitmap.compress(compressFormat, 100, outputStream)//将Bitmap对象写入到该输出流中
outputStream.close()
}
}
}
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
/**
* 将网络上的图片存储到手机相册
*/
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()
}
下载文件到Download目录

Android 10上两种解决方式:

第一种较简单,更改文件的下载目录,将文件下载到应用程序关联目录下。这样不改任何代码即可让程序在Android 10上正常工作。缺点:文件会被计入到应用程序的占用空间中,程序被卸载文件也会被删除。

第二种,对Android 10系统进行代码视频,仍将文件下载Download目录下

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
/**
* 将文件下载到Download目录
*/
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()
}
}
}

与向相册中添加一张图片的过程差不多,Android 10在MediaStore中新增一个 Downloads 稽核,专门用于执行文件下载操作。

主要添加了一些Http请求,并将MeadiaStore.Images.Media改成MediaStore.Downloads

使用文件选择器

读取SD卡上非图片、音频、视频类的文件,此时不能用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
24
25
26
27
28
29
30
31
32
/**
* 调用系统内置的文件选择器
*/
const val PICK_FILE = 1

/**
* 通过Intent启动系统的文件选择。
* 注意:
* Intent的action和category是固定不变的。
* type用于对文件类型进行过滤,如指定成 image/* 即可只显示图片类型的文件
*/
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)//打开文件输入流
// 执行文件读取操作
}
}
}
}
}
第三方SDK不支持怎么办

如七牛云SDK,它的文件上传功能要求你传入一个文件的绝对路径,不支持传入Uri对象。

  1. 由于我们是没有权限修改第三方SDK的,因此最简单直接的办法就是等待第三方SDK的提供者对这部分功能进行更新,在那之前我们先不要将targetSdkVersion指定到29,或者先在AndroidManifest文件中配置一下requestLegacyExternalStorage属性。
  2. 另一个方法,自己编写一个文件复制功能,将Uri对象所对应的文件复制到应用程序的关联目录下,然后将关联目录下这个文件的绝对路径传递给第三方SDK,即可完美适配
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 将文件Uri对象复制到应用程序关联的目录下
*/
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()
}
}

示例代码