Android | 作用域存储适配

语言: CN / TW / HK

前言

Android 10 已经发布了很长一段时间了,并且 Android 11 已经有很大一部分人在使用了,那么你的程序对他做了适配吗?

在 10.0 中,作用域存储变得非常重要,这个新的功能颠覆了我们一直惯用外置存储的方式,因此大量的 app 都面临着代码的适配

本篇文章对作用域存储,以及如何进行适配,做了比较详细的介绍

在 7.0 以前我们访问内存卡中的文件时可以通过 Uri.fromFile ,将 File 转换成 Uri 对象,这个 uri 对象表示这本地真实路径。
复制代码

​ 在 7.0 后,这种通过真实路径来获取的 Uri 被认为是不安全的,所以提供了一种新的解决方案,就是通过 FileProvide 来实现文件的访问,FileProvider 是一种比较特殊的内容提供器,他使用了类似于内容提供器的机制来对数据进行保护。

​ 在7.0以前,访问一个图片如下所示:

String fileName = "defaultImage.jpg";

File file = new File("文件路径", fileName);
Uri uri = Uri.fromFile(file);
复制代码

​ 7.0后,访问如下所示:

File file = new File(CACHE_IMG, fileName);
Uri imageUri=FileProvider.getUriForFile(activity,"com.sandan.fileprovider", file);//这里进行替换uri的获得方式
复制代码
 <provider
        android:name="android.support.v4.content.FileProvider"
        android:authorities="com.sandan.fileprovider"//这里需要和上面部分字符串相同
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>
复制代码
<resource xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path
        name="images"
        path="" />
        //path 表示共享的具体路径,这里为空表示整个SD卡进行共享
</resource>
复制代码

​ 然而上面这种真的好吗,对用开发者而且这算是好处吧,但是对用用户而言,上述的无疑一些流氓作用,因为开发者完全可以访问的内存中的所有位置,并作出一些改变,导致 SD 卡中的空间变得非常乱,即使卸载了 app,但是一些垃圾文件却还在内存中。

作用域存储

10.0 中,为了解决上述问题, google 在 Android 10 中加入了作用域功能

​ 什么是作用域呢?就是 Android 系统对 SD 卡做了很大的限制,从 10.0 开始,每个程序只能有权在自己的外置存储空间关联的目录下读取和创建相应的文件,也称作沙箱。获取改目录的代码是:getExternalFilesDir() ,关联的目录路径大致如下:

/storage/emulated/0/Android/data/<包名>/files
复制代码

​ 将数据放在这个目录下,你可以使用之前的方法对文件进行读写,不需要作出任何变更和适配。但是这个文件夹中的文件会随着应用卸载而被随之删除。

​ 那如果需要访问其他目录怎么办呢,比如获取相册中的图片,向相册中添加一张图片。为此,Android 系统针对系统文件类型进行了分类**:图片,音频,视频 这三类文件可以通过 MediaStore API 来进行访问,这种称为共享空间,其他的系统文件需要使用 系统的文件选择器来进行访问,**

​ 另外,如果程序向媒体库写入图片,视频,音频,将会自动用于读写权限,不需要额外申请权限,如果你要读取其他程序向媒体贡献的图片,视频,音频,则必须要申请 READ_EXTERNAL_STORAGE 权限,WRITE_EXTERNAL_STORAGE 权限会在未来的版本中被废弃。

获取系统图片:

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()
}
复制代码

适配要点

示例代码,以及Demo

  • 打开相机

    1. 如果是10.0,需要根据共享文件创建一条图片地址的 Uri,用于保存拍照后的照片。

    2. 拍照完成后,拿到对应的 uri

    3. 如果要直接显示图片,则通过 uri 可直接加载

    4. 如果图片要上传,则需要将 uri 处理为一个 file 对象

      ​ 在 10.0 中,只能访问沙箱文件和共享文件夹,需要注意的是:共享文件夹可以通过 uri 进行访问,如拿到输入/输出流等。但是不能将其转为 file。因为就算是共享文件夹,也不能直接通过 file 进行访问。

      ​ 所以在图片上传的时候,需要通过 contentProider 将 uri 转为一个 inputStream,然后将数据读取出来,并且保存在沙箱文件中,然后在获取沙箱文件中的 file 即可。

      ​ 注意,在拿到 uri 后可以对图片进行一些压缩处理。

  • 打开相册

    1,直接通过 intent 打开相册

    2,拿到 返回的 uri 地址

    3,如果是10.0,则需要进行和 “打开相机” 中 3,4,同样的操作。

  • 下载文件

    1,如果是 10.0,需要根据共享文件夹创建一条文件地址的 uri,用于保存文件

    2,通过网络操作,拿到对应的 inputSteam

    3,通过 contentProider 将 uri 转为一个 outputStream

    4,input 读取数据,output 写入数即可。

  • 需要注意的

    • 只能在沙箱中操作 file 对象,切记。

    • 在对 图片进行复制和压缩上传的时候,需要注意耗时,如果太耗时,需要放在子线程中。

  • 上传文件

    • 需要将文件复制到沙箱中,然后在进行上传操作

      1,使用文件选择器,选择文件

      val mimeTypes = arrayOf(
          FileIntentUtils.getMap("doc"),
          FileIntentUtils.getMap("pdf"), FileIntentUtils.getMap("ppt"),
          FileIntentUtils.getMap("xls"), FileIntentUtils.getMap("xlsx")
      )
      FileIntentUtils.openBle(this, REQUEST_CHOICE_FILE, mimeTypes)
      复制代码
      /**
       * 选择文件
       */
      fun openBle(activity: Activity, code: Int, types: Array<String>) {
          val intent = Intent(Intent.ACTION_GET_CONTENT)
          intent.addCategory(Intent.CATEGORY_OPENABLE)
          intent.type = "application/*";
          intent.putExtra(Intent.EXTRA_MIME_TYPES, types)
          activity.startActivityForResult(intent, code)
      }
      复制代码
      /**
       * 获取常见文件类型
       * @param key
       * @return
       */
      fun getMap(key: String): String {
          val map: MutableMap<String, String> = HashMap()
          map["rar"] = "application/x-rar-compressed"
          map["jpg"] = "image/jpeg"
          map["png"] = "image/jpeg"
          map["jpeg"] = "image/jpeg"
          map["zip"] = "application/zip"
          map["pdf"] = "application/pdf"
          map["doc"] = "application/msword"
          map["docx"] = "application/msword"
          map["wps"] = "application/msword"
          map["xls"] = "application/vnd.ms-excel"
          map["et"] = "application/vnd.ms-excel"
          map["xlsx"] = "application/vnd.ms-excel"
          map["ppt"] = "application/vnd.ms-powerpoint"
          map["html"] = "text/html"
          map["htm"] = "text/html"
          map["txt"] = "text/html"
          map["mp3"] = "audio/mpeg"
          map["mp4"] = "video/mp4"
          map["3gp"] = "video/3gpp"
          map["wav"] = "audio/x-wav"
          map["avi"] = "video/x-msvideo"
          map["flv"] = "flv-application/octet-stream"
          map[""] = "*/*"
          return map[key] ?: "application/msword"
      }
      复制代码

      2,选择文件后,intent 会返回一个 uri,然后将 uri 转为 file

      /**
       * uri 转 file
       */
      fun uriToFile(context: Context, uri: Uri): File? = when (uri.scheme) {
          ContentResolver.SCHEME_FILE -> uri.toFile()
          ContentResolver.SCHEME_CONTENT -> {
              val cursor = context.contentResolver.query(uri, null, null, null, null)
              cursor?.let { it ->
                  if (it.moveToFirst()) {
                      //如果是 10.0 以上
                      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                          //保存到本地
                          val ois = context.contentResolver.openInputStream(uri)
                          val displayName =
                              it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
                          ois?.let { input ->
                              val file = File(
                                  context.externalCacheDir?.absolutePath + File.separator,
                                  displayName
                              )
                              if (file.exists()) file.delete()
                              file.createNewFile()
                              file.outputStream().use { input.copyTo(it) }
                              file
                          }
                      } else {
                          //com.blankj:utilcodex:1.30.5
                          UriUtils.uri2File(uri)
                      }
                  } else {
                      it.close()
                      null
                  }
              }
          }
          else -> null
      }
      复制代码

      通过以上步骤,就可以将 uri 转成一个 file 对象,并且支持上传。

    • 如果添加了可以打开文件的需求,如何处理?

      到此时,文件以及被复制到了沙箱中,你可以对他进行任意处理,但是如果要打开这个文件,则需要使用其他应用来打开,这个时候文件存储在沙箱下面就不行了,因为其他 app 无法获取当前 app 沙箱下的文件。

      所以,在这里需要将文件复制到共享目录下面,然后生成对应的 uri,在通过别的 app 打开即可

      //打开文件
      data.fileData?.file?.also { file ->
          val index = file.name.lastIndexOf(".")
          val suffix = file.name.substring(index + 1, file.name.length)
          //android 10 之后,需要将文件复制到公有目录下,其他应用才可以打开
          showLoading()
          lifecycleScope.launch(Dispatchers.IO) {
              //10.0 以上则将文件复制到共享目录
              val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                  FileIntentUtils.copyToDownloadAndroidQ(this@WorkReleaseActivity, suffix, file.inputStream(), file.name,"tidycar")
              } else {
                  //否则直接转为 uri
                  file.toUri()
              }
              launch(Dispatchers.Main) {
                  dismissLoading()
                  //打开文件
                  FileIntentUtils.openFileEx( uri, suffix, this@WorkReleaseActivity )
              }
          }
      }
      复制代码
      /**
       * 复制或下载文件到公有目录
       *
       * @param context
       * @param mimeType 文件类型
       * @param input 输入流
       * @param fileName 文件名称
       * @param saveDirName 文件夹名称
       * @return
       */
      @RequiresApi(api = Build.VERSION_CODES.Q)
      fun copyToDownloadAndroidQ(  context: Context, mimeType: String?, input: InputStream, fileName: String,saveDirName: String): Uri? {
          val file = File(
              Environment.getExternalStorageDirectory().path + "/Download/$saveDirName",
              fileName
          )
          //如果公有目录中已经存在相同文件,则直接返回
          if (file.exists()) {
              return file.toUri()
          }
          if (!FileQUtils.isExternalStorageReadable()) {
              throw RuntimeException("External storage cannot be written!")
          }
          val values = ContentValues()
          //显示名称
          values.put(MediaStore.Downloads.DISPLAY_NAME, fileName)
          //存储文件的类型
          values.put(MediaStore.Downloads.MIME_TYPE, mimeType)
          //公有文件路径
          values.put(
              MediaStore.Downloads.RELATIVE_PATH,
              "Download/" + saveDirName.replace("/".toRegex(), "") + "/"
          )
          //生成一个Uri
          val external = MediaStore.Downloads.EXTERNAL_CONTENT_URI
          val resolver = context.contentResolver
          //写入
          val insertUri = resolver.insert(external, values) ?: return null
          val fos: OutputStream?
          try {
              //输出流
              fos = resolver.openOutputStream(insertUri)
              if (fos == null)  return null
              var read: Int
              val buffer = ByteArray(1444)
              while (input.read(buffer).also { read = it } != -1) {
                  //写入uri中
                  fos.write(buffer, 0, read)
              }
          } catch (e: java.lang.Exception) {
              e.printStackTrace()
          }
          return insertUri
      }
      复制代码

      在共享目录中,创建一个文件夹,然后将文件复制进去,最后返回 uri 即可

      /**
       * 打开文件
       */
      fun openFileEx(uri: Uri?, fileType: String, context: Context) {
          try {
              val intent = Intent()
              intent.action = Intent.ACTION_VIEW
              intent.addCategory("android.intent.category.DEFAULT")
              intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
              // 判断版本大于等于7.0
              if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                  val builder = VmPolicy.Builder()
                  StrictMode.setVmPolicy(builder.build())
              }
              //getMap 在最上面有代码
              intent.setDataAndType(uri, getMap(fileType))
              context.startActivity(intent)
          } catch (e: Exception) {
          }
      }
      复制代码

如果你的项目还没有适配,就赶紧提上日程吧!!

Happy Codeing!