temp

轻松适配Android 10 Scoped Storage 分区

2020-04-17  本文已影响0人  HurryYu_YZH

Android 10(API 级别 29)引入了多项功能和行为变更,目的是更好地保护用户的隐私权。其中最重要的变化之一就是存储访问权限

Android 10中,Google针对外部存储引入了一个新特性,它的名字叫:Scoped Storage,Google官方对它的翻译为分区存储,我们也可以把它叫做作用域存储,至于为什么?

image-20200414211649994.png

本文中,我们还是将它翻译为分区存储。好了,我要开始提问了!

image-20200414212753228.png

问题一、外部存储?内部存储?内存?

1.1、内存

有些朋友经常将内存和内部存储搞混,因为内部存储也可以被简称为内存:

A:听说你买了新手机?内存多大的啊?后台能开多少啊?

B:哈哈,256G,装几百个都不是问题!

A:呵呵

但实际上它们是两个不一样的东西。内存(RAM)简单理解就是程序运行时临时的数据存储器,某个程序进程结束后,关于此程序的所有内存数据都会消失,而断电后整个内存里面的数据都会丢失。由于内存经常与CPU打交道,因此它的读写速度是相当快的,内存也是我们通常所说的随机存取存储器(Random Access Memory)

1.2、内部存储

内部存储顾名思义就是手机自带的存储空间,一般情况下,系统和应用都是安装在内部存储中的。在没有root的情况下,普通用户是无法查看内部存储中的文件的。对于Android开发者,最熟悉的应该就是:/data/user/0/<package>这个路径,其中0表示用户ID(似乎Android 6.0以后开始支持多用户)。而我们更熟悉的/data/data/<package>实际上是/data/user/<current_user_id>/<package>的一个链接,注意是current_user_id

这个路径是应用的内部存储私有目录,保存到这个路径下的文件是应用的私有文件,其他应用不能访问这些文件(除非拥有 Root 访问权限),非常适合保存用户无需直接访问的内部应用数据。

当我们卸载应用后,保存在私有路径中的文件也会被删除。因此,我们不应该将那些希望应用卸载以后还保留的数据文件放在私有路径中。

主要有以下几个常用的目录:

1.3、外部存储

在很久很久以前,几乎所有的Android手机都可以插入一张micro SD卡,因为内部存储实在太小了,我第一款Android手机是SONY LT18i,内部存储只有1GB,最大支持32GB的SD卡。我们所说的外部存储,指的就是我们插入的那张SD卡。SD卡一般会被挂载到/storage/sdcard1,根据设备的不同,不一定叫sdcard1,比如在我的模拟器中,路径为:/storage/1106-3A09

而现在,几乎没有Android手机再提供SD卡的插口。

那...为什么不提供了?不能插SD卡意味着我的手机没有外部存储了?那为什么我还能用文件管理器看到很多文件?不是说内部存储不root看不到吗?

image-20200415213357961.png
1.3.1、为什么不再提供SD卡插口?

我个人觉得,有以下原因:

1.3.2、不能插SD卡意味着我的手机没有外部存储了吗?

现在几乎所有手机都会自带比较可观的内部存储容量,动不动上来就是128GB、256GB、512GB,起步都是128GB了,64GB都几乎看不到了,不过64GB是真的不够用,亲身经历!

系统会将内部存储空间通过fuse技术挂载到/storage/emulated/0上,这个挂载点就是外部存储,没有SD卡一样可以拥有外部存储了,这个外部存储严格意义上叫做内置外部存储,和内部存储共享空间,它有如下好处:

1.3.3、外部存储分私有目录和公有目录吗?
image-20200415232030508.png

卸载应用后,外部存储私有路径中的文件同样会被清除。那...内部存储中的私有目录和外部存储中的私有目录有什么区别呢?官方文档的解释如下:

尽管这些文件在技术上可被用户和其他应用访问(因为它们存储在外部存储上),但它们不能为应用之外的用户提供价值。可以使用此目录来存储您不想与其他应用共享的文件。

问题二、Android 10的分区存储究竟影响了什么?

相信每一台Android手机的外部存储根目录都是乱得一塌糊涂,这是因为在Android 10以前,只要程序获得了READ_EXTERNAL_STORAGE权限,就可以随意读取外部存储的公有目录;只要程序获得了WRITE_EXTERNAL_STORAGE权限,就可以随意在外部存储的公有目录上新建文件夹或文件。

于是Google终于开始动手了,在Android 10中提出了分区存储,意在限制程序对外部存储中公有目录的为所欲为。分区存储对 内部存储私有目录 和 外部存储私有目录 都没有影响

简而言之,在Android 10中,对于私有目录的读写没有变化,仍然可以使用File那一套,且不需要任何权限。而对于公有目录的读写,则必须使用MediaStore提供的API或是SAF(存储访问框架),意味着我们不能再使用File那一套来随意操作公有目录了。

使用分区存储的应用对自己创建的文件始终拥有读/写权限,无论文件是否位于应用的私有目录内。因此,如果您的应用仅保存和访问自己创建的文件,则无需请求获得 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE权限。

若要访问其他应用创建的文件,则需要READ_EXTERNAL_STORAGE权限。并且仍然只能使用MediaStore提供的API或是SAF(存储访问框架)访问。

需要注意的是,MediaStore提供的API只能访问:图片、视频、音频,如果需要访问其它任意格式的文件,需要使用SAF(存储访问框架),它会调用系统内置的文件浏览器供用户自主选择文件。

似乎没WRITE_EXTERNAL_STORAGE什么事儿了?确实,听说这个权限在后续会被废除。

问题三、必须要适配吗?

本来从Android 10开始,Google就决定强制采用分区存储,但从预览版的反馈来看,很多应用都GG了,因此Google决定给开发者一段过渡时间,暂时不强制要求,但早晚会强制要求的。

如果我们将targetSdkVersion设置为低于29的值,那么即使不做任何关于Android 10的适配,我们的项目也可以成功运行到Android 10手机上。

如果我们将targetSdkVersion设置为29了,但就是不想适配分区存储,可以在清单文件中做如下设置:

<manifest ... >
    <application android:requestLegacyExternalStorage="true" ... >
        ...
    </application>
</manifest>

问题四、真的不能在外部存储撒野了?我不信

首先我们将应用的compileSdkVersiontargetSdkVersion都设置为29,并且授予程序读写的权限,然后编写如下代码:

val file = File(Environment.getExternalStorageDirectory(), "MyAppDir")
if (!file.exists()) {
    Log.d("createDir", file.mkdir().toString())
}

代码很简单,作用是在外部存储的根目录下创建一个名叫MyDir的文件夹。我们将这个程序运行在Android 10的设备上,发现并没有创建成功(file.mkdir().toString()false)。

同样的程序,我们将它运行在Android 10以下的设备,就可以成功创建这个文件夹。

问题五、那我应该怎么操作外部存储?

现在我们已经知道,在应用确认支持分区存储之后,就不能再使用以前那一套来操作外部存储了。那现在应该怎么办呢?Google官方推荐我们使用MediaStore提供的API访问图片、视频、音频资源,使用SAF(存储访问框架)访问其它任意类型的资源。

5.1、使用MediaStore将图片保存到Pictures目录

Environment中我们能找到很多公有目录文件夹的名字,其中Pictures这个文件夹就适合用来保存图片数据:

image-20200417020830849.png

在以前,我们经常会根据目录或文件的绝对路径得到File对象,再将File对象传给FileOutputStream得到输出流,然后就可以愉快地写入数据了。Android 10以后,我们要向这些公有目录写入数据,必须要用MediaStore了。

下面,我们通过代码学习如何将Bitmap保存到Pictures文件夹下:

const val APP_FOLDER_NAME = "ExternalScopeTestApp"

val bitmap = BitmapFactory.decodeResource(resources, R.drawable.die)
val displayName = "${System.currentTimeMillis()}.jpg"
val mimeType = "image/jpeg"
val compressFormat = Bitmap.CompressFormat.JPEG

private fun saveBitmapToPicturePublicFolder(
    bitmap: Bitmap,
    displayName: String,
    mimeType: String,
    compressFormat: Bitmap.CompressFormat
) {
    val contentValues = ContentValues()
    contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
    contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
    val path = getAppPicturePath()
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, path)
    } else {
        val fileDir = File(path)
        if (!fileDir.exists()){
            fileDir.mkdir()
        }
        contentValues.put(MediaStore.MediaColumns.DATA, path + displayName)
    }
    val uri =
    contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
    uri?.also {
        val outputStream = contentResolver.openOutputStream(it)
        outputStream?.also { os ->
            bitmap.compress(compressFormat, 100, os)
            os.close()
            Toast.makeText(this, "添加图片成功", Toast.LENGTH_SHORT).show()
        }
    }
}

fun getAppPicturePath(): String {
    return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        // full path
        "${Environment.getExternalStorageDirectory().absolutePath}/" +
                "${Environment.DIRECTORY_PICTURES}/$APP_FOLDER_NAME/"
    } else {
        // relative path
        "${Environment.DIRECTORY_PICTURES}/$APP_FOLDER_NAME/"
    }
}

代码为了方便展示做了位置调整,大家可以直接查看完整代码。项目地址在文末给出。

首先我们创建了ContentValues对象,并往里面添加了三种信息:

  1. DISPLAY_NAME:图片的名字,需要包含后缀名。在这里,我们使用的是当前的时间戳命名

  2. MIME_TYPE:文件的mime类型。在这里,我们使用的是image/jpeg

  3. RELATIVE_PATH、DATA:文件的存储路径。在Android 10中,新增了RELATIVE_PATH,它表示文件存储的相对路径,可选值其实就是Environment里面那堆,比如PicturesMusic等。但是注意看我们的getAppPicturePath()中的代码:"${Environment.DIRECTORY_PICTURES}/$APP_FOLDER_NAME/",后面还跟了一个$APP_FOLDER_NAME,表示在Pictures这个目录下面 ,还要创建一个名叫:ExternalScopeTestApp的文件夹,这是因为如果所有应用都将图片保存到Pictures的根目录,势必会非常混乱,因此我们针对自己的应用建立了二级文件夹,将图片都保存到自己的二级文件夹中。

    DATA这个字段是Android 10以前使用的字段,在Android 10中已经废弃,但为了兼容老版本系统, 我们还是要用。这个字段需要文件的绝对路径。

ContentValues里面的值都设置完成后,我们就可以使用ContentResolverinsert()方法插入数据了,插入完成后会得到插入图片的Uri,接下来我们要根据这个Uri得到OutputStream对象,通过:contentResolver.openOutputStream(uri)就可得到,剩下的就是将Bitmap写入了。

我们发现,以前我们可以通过真实路径得到输出流,而现在只能通过Uri得到了。

如果我们不是将Bitmap保存到公有目录,而是网络上的图片呢?其实原理都是一样的,网络上的图片我们肯定是可以得到输入流的,输出流还是通过Uri获取,然后读取输入流写入输出流不就行了吗?

到此,保存图片就学习完了,保存音频、视频都类似。注意,这些操作都是不需要权限的。

5.2、使用MediaStore获取媒体库中的图片

我们向Pictures中添加了图片文件,怎么才能获取到呢?也必须通过MediaStore。如果我们没有获得存储空间权限,那么我们只能通过MediaStore获取到自己应用创建的图片;如果我们获取了存储空间权限,那么我们就可以获取到其它应用创建的图片了。

我们通过代码来学习:

val cursor = contentResolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    null,
    null,
    null,
    "${MediaStore.MediaColumns.DATE_ADDED} desc"
)

cursor?.also {
    while (it.moveToNext()) {
        val id = it.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
        val displayName =
        it.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME))
        val uri =
        ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
    }
}
cursor?.close()

代码很简单,最终通过Uri与图片文件id的组合,得到了图片文件的Uri。得到了这个图片的Uri后,怎么显示出来呢?可以使用Glide,因为Glide原生支持Uri:

Glide.with(this).load(uri).into(ivPicture)

如果没有使用Glide呢?可以这样来做,得到一个Bitmap:

val openFileDescriptor = contentResolver.openFileDescriptor(uri, "r")
openFileDescriptor?.apply {
    val bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
    ivPicture.setImageBitmap(bitmap)
}
openFileDescriptor?.close()

但是需要注意,如果图片分辨率高,bitmap会很占用内存,而实际要显示的区域可能比原图小得多,需要自己控制下bitmap的像素。

在没有获取存储空间权限对情况下,我们只能获取到应用自己创建的图片:

mediaStoreReadSelf.gif

下面我们预先在手机中放置几张图片,模拟它们是其它应用创建的,这几张图片如下:

image-20200417034542458.png

它们有两张在DCIM/Camera,有一张在Download/OtherApp_01目录下。现在我们授予应用存储权限,看看是否能获取到这三张图片:

mediaStoreReadAll.gif

果然显示出来了。大家可能有一个疑问:应用是怎么在拥有权限的情况下只显示自己保存的图片的?不是一旦有权限后,就是查询的所有吗?答案就是增加WHERE语句,过滤DATA和RELATIVE_PATH:

private fun queryImages(queryAll: Boolean = false): List<ImageBean> {
    var pathKey = ""
    var pathValue = ""
    if (!queryAll) {
        pathKey = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            MediaStore.MediaColumns.DATA
        } else {
            MediaStore.MediaColumns.RELATIVE_PATH
        }
        // RELATIVE_PATH会在路径的最后自动添加/
        pathValue = getAppPicturePath()
    }
    val dataList = mutableListOf<ImageBean>()
    val cursor = contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        null,
        if (pathKey.isEmpty()) {
            null
        } else {
            "$pathKey LIKE ?"
        },
        if (pathValue.isEmpty()) {
            null
        } else {
            arrayOf("%$pathValue%")
        },
        "${MediaStore.MediaColumns.DATE_ADDED} desc"
    )

    cursor?.also {
        while (it.moveToNext()) {
            val id = it.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
            val displayName = it.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME))
            val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
            dataList.add(ImageBean(id, uri, displayName))
        }
    }
    cursor?.close()
    return dataList
}

用LIKE的原因是DATA中存储的是图片的绝对路径,我们需要匹配应用自己图片路径下的所有图片。到此,获取媒体库中的图片就学习完毕。

5.3、使用MediaStore删除媒体库中的图片

同样的,删除自己创建的图片不需要任何权限,但是删除或者修改其它应用创建的图片就需要权限了,而且即使我们拥有了存储权限,也不能修改或删除其它APP的资源,需要由MediaProvider弹出弹框给用户选择是否允许APP修改或删除图片、视频、音频文件。用户操作的结果,将通过onActivityResult回调返回到APP。如果用户允许,APP将获得该uri的修改权限,直到设备下一次重启。我们先来学习删除自己应用的图片:

val row = contentResolver.delete(imageUri, null, null)
if (row > 0) {
    Toast.makeText(this, "删除成功", Toast.LENGTH_SHORT).show()
}

很简单,图片从ContentResolver中查询出来的时候,我们可以获取到id,图片的uri就是通过MediaStore.Images.Media.EXTERNAL_CONTENT_URI与图片的id组合而来。现在只需要通过uri进行删除即可。我们来看下在没有存储权限时,删除应用创建图片的效果,当然如果有存储权限,也是一样的:

mediaStoreDeleteSelf.gif

但是这段代码是不够严谨的,因为当我们删除的是其它应用的资源,程序会闪退,并抛出:RecoverableSecurityException异常。因此我们需要捕获这个异常,提示用户给予此uri修改或删除的权限:

companion object {
    const val REQUEST_DELETE_PERMISSION = 1
}

private var pendingDeleteImageUri: Uri? = null
private var pendingDeletePosition: Int = -1

private fun deleteImage(imageUri: Uri, adapterPosition: Int) {
    var row = 0
    try {
        // Android 10+中,如果删除的是其它应用的Uri,则需要用户授权
        // 会抛出RecoverableSecurityException异常
        row = contentResolver.delete(imageUri, null, null)
    } catch (securityException: SecurityException) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val recoverableSecurityException =
                securityException as? RecoverableSecurityException
                    ?: throw securityException
            pendingDeleteImageUri = imageUri
            pendingDeletePosition = adapterPosition
            // 我们可以使用IntentSender向用户发起授权
            requestRemovePermission(recoverableSecurityException.userAction.actionIntent.intentSender)
        } else {
            throw securityException
        }
    }

    if (row > 0) {
        Toast.makeText(this, "删除成功", Toast.LENGTH_SHORT).show()
        pictureAdapter.deletePosition(adapterPosition)
    }
}

private fun requestRemovePermission(intentSender: IntentSender) {
    startIntentSenderForResult(intentSender, REQUEST_DELETE_PERMISSION, 
        null, 0, 0, 0, null)
}

private fun deletePendingImageUri(){
    pendingDeleteImageUri?.let {
        pendingDeleteImageUri = null
        deleteImage(it,pendingDeletePosition)
        pendingDeletePosition = -1
    }
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (resultCode == Activity.RESULT_OK &&
        requestCode == REQUEST_DELETE_PERMISSION
    ) {
        // 执行之前的删除逻辑
        deletePendingImageUri()
    }
}

简单解释下,当修改或删除非本应用创建的文件uri时,在Android 10+的系统中,会抛出RecoverableSecurityException,我们捕获到这个异常后,从异常中获得了IntentSender,并使用它来向用户索取该uri的修改、删除权限。代码都很简单,不再赘述,效果如下:

mediaStoreDeleteAll.gif

至此,删除媒体库中的图片就学习完成了。

问题六、我想读取Download文件夹下的某个非媒体文件怎么办?

拿PDF举例,显然,PDF不属于音频、视频、图片,因此我们不能使用MediaStore来获取。对于这种其它类型的文件,我们一般使用SAF(存储访问框架)让用户选择:

private fun selectPdfUseSAF() {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        type = "application/pdf"
        // 我们需要使用ContentResolver.openFileDescriptor读取数据
        addCategory(Intent.CATEGORY_OPENABLE)
    }
    startActivityForResult(intent, REQUEST_OPEN_PDF)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
        REQUEST_OPEN_PDF -> {
            if (resultCode == Activity.RESULT_OK) {
                data?.data?.also { documentUri ->
                    val fileDescriptor =
                        contentResolver.openFileDescriptor(documentUri, "r") ?: return
                    // 现在,我们可以使用PdfRenderer等类通过fileDescriptor读取pdf内容
                    Toast.makeText(this, "pdf读取成功", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}

注意,ACTION_OPEN_DOCUMENT用于打开文件。

问题七、我想创建任意类型文件怎么办?

也是使用SAF(存储访问框架)让用户去创建。其Intent Action为:ACTION_CREATE_DOCUMENT,我们可以使用Intent的putExtra()来指定文件的名字:intent.putExtra(Intent.EXTRA_TITLE, "Android.pdf"),有点类似于"另存为"功能。其它的用法差不多,就不多说了。

问题八、我想将文件下载到Download目录怎么办?

拿下载app为例,在Android 10以前,只要获取到了File对象,就能得到输入流,而由于我们适配了Android 10中的分区存储,因此不能这样做了。MediaStore中提供了一种Downloads集合,专门用于执行文件下载操作。它的使用和添加图片是几乎一样的:

private fun downloadApkAndInstall(fileUrl: String, apkName: String) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        Toast.makeText(this, "请使用原始方式", Toast.LENGTH_SHORT).show()
        return
    }
    thread {
        try {
            val url = URL(fileUrl)
            val connection = url.openConnection() as HttpURLConnection
            connection.requestMethod = "GET"
            val inputStream = connection.inputStream
            val bis = BufferedInputStream(inputStream)
            val values = ContentValues()
            values.put(MediaStore.MediaColumns.DISPLAY_NAME, apkName)
            values.put(MediaStore.MediaColumns.RELATIVE_PATH, getAppDownloadPath())
            val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
            uri?.also {
                val outputStream = contentResolver.openOutputStream(uri) ?: return@thread
                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()
                runOnUiThread {
                    installAPK(uri)
                }
            }
            bis.close()

        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

private fun installAPK(uri: Uri) {
    val intent = Intent(Intent.ACTION_VIEW)
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    intent.setDataAndType(uri, "application/vnd.android.package-archive")
    startActivity(intent)
}

由于我们获取到的这个uri本来就是content://开头,所以不需要使用FileProvider。对于应用私有目录的文件,我们可以使用FileProvider进行分享。

项目地址

本文的所有代码都可以在项目中查看。传送门

上一篇下一篇

猜你喜欢

热点阅读