阅读 5836

Android 10存储适配一一我们项目是这么干的!

前言

我司APP在华为应用市场一直显示未 兼容EMUI10.0.0系统。咨询客服人员,直接甩给我们google官方适配文档。

适配10完之后上线市场,联系华为市场客服,果然未兼容提示没了!

说到10系统适配的重点,当然是里面的存储啦!虽然 谷歌微信公众号 推了如下文章:

作为一个严谨的码农,还决定选择MediaStore API来给自己找点乐子(实际是因为我都适配完了,才给我推送了这个兼容方案...😂)

10系统已经出来好久了,其中的存储好多大佬的博客都给过详细解释。今天我也就不多说概念了,接下来将 结合APP的功能 分享一下适配经验。

因为我司APP迭代3年了,目前旧代码是Java,新代码是Kotlin,接下来的代码会出现Java(旧)与Kotlin(新)穿插,我懒得统一了,希望大家理解。

如果有适配不到位的地方,还希望各位指出

相册功能

第一步是撸取手机里的图片跟视频,直接上代码:

  ContentResolver contentResolver = context.getContentResolver();
     //按照修改时间降序,也就是最新拍摄的在前面
     String sort = MediaStore.Images.Media.DATE_MODIFIED + " desc ";
     String selection = MediaStore.Images.Media.MIME_TYPE + "=? or " + MediaStore.Images.Media.MIME_TYPE + "=?";
     String[] selectionArgs = new String[]{"image/jpeg", "image/png"};//只找jpg跟png图片
     String[] projection = {MediaStore.Images.Media._ID,
         MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME,//文件夹的名字?反正我用来按照文件夹分类的
         MediaStore.Images.ImageColumns.DATE_MODIFIED};

     Cursor cursor = contentResolver.query(
         MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
         projection,
         selection,
         selectionArgs,
         sort);

     if (cursor != null) {
         while (cursor.moveToNext()) {
             long id = cursor.getLong(cursor.getColumnIndex(MediaStore.Images.Media._ID));
             String bucketName = cursor.getString(cursor.getColumnIndex(MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME));
             if (TextUtils.isEmpty(bucketName)) continue;//做个过滤
             //重点来了,android.content.ContentUris 提供API转 android.net.Uri
             Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
             
             //省略业务逻辑
             
             cursor.close();
        }
复制代码

视频的获取跟图片差不多,这里我给出查询逻辑,注意视频使用的是 MediaStore.Video.Media

String[] project = new String[]{MediaStore.Video.Media._ID,
     MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
     MediaStore.Video.Media.DURATION,//视频的时长
     MediaStore.Video.Media.DATE_MODIFIED};

Cursor cursor = context.getContentResolver().query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
     project,
     MediaStore.Video.Media.MIME_TYPE + "=?",
     new String[]{"video/mp4"},//这里只查了mp4格式
     MediaStore.Video.Media.DATE_MODIFIED + " desc");
复制代码

上面代码获取了关键的 android.net.Uri,那么如何展示图片跟视频呢?这个时候我要吹爆 Glide (顺便提一下,Glide可以加载视频的Uri)

Glide的4.9.0版本是支持Uri传参的,而且最新的 4.11.0版本 是支持 Uri.toString() 参数的(强烈推荐使用最新版)。Glide内部会自动区分你的String参数是 “网络地址”、“File的路径”、“Uri的String”等等。这样我们业务层基本无需关心这些String的区别了。

项目里以前查出来的是绝对路径,这次改成 Uri.toString() 替换起来 so easy。而且这一套展示的逻辑写下来,基本上是兼容低版本的! 为啥这么说?是因为:

我测试了 夜神模拟器(5.1系统)、锤子与OPPO与华为(7.1系统)、小米(9系统)、华为(10系统) ,Glide的Uri.toString()加载毫无问题!所以Glide的内部加载机制是值得一看的!当然这不是今天的主题(主要是因为我自己都没搞明白)。

图片裁剪

我们的APP使用的 图片裁剪库 不支持Uri传参,所以我选择的方案是把图片先拷贝到应用的私用目录,再进行File的传参。关于拷贝图片,直接上代码(我们这一步还做了图片压缩):

InputStream originInputStream = null;
InputStream composeInputStream = null;
ByteArrayOutputStream outputStream = null;
FileOutputStream fileOutputStream = null;

try {
     //开始操作 Uri 到 File
     ContentResolver contentResolver = context.getContentResolver();
     if (contentResolver != null) {
         //contentResolver.openInputStream 具体实现可以稍微看出来Uri的格式有几种
         originInputStream = contentResolver.openInputStream(uri);
         BitmapFactory.Options options = new BitmapFactory.Options();
         options.inJustDecodeBounds = true;
         options.inSampleSize = 1;

         BitmapFactory.decodeStream(originInputStream, null, options);
         int srcWidth = options.outWidth;
         int srcHeight = options.outHeight;
         //computeSize 是我们的压缩逻辑,可以不用在意
         options.inSampleSize = BitmapUtil.computeSize(srcWidth, srcHeight);
         //获取真实的图片
         options.inJustDecodeBounds = false;
         composeInputStream = contentResolver.openInputStream(uri);
         Bitmap tagBitmap = BitmapFactory.decodeStream(composeInputStream, null, options);
         outputStream = new ByteArrayOutputStream();
         tagBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream);
         //业务有些逻辑需要size
         int[] size = new int[]{tagBitmap.getWidth(), tagBitmap.getHeight()};
         tagBitmap.recycle();
         //把文件写到私有目录的File
         fileOutputStream = new FileOutputStream(destFile);
         fileOutputStream.write(outputStream.toByteArray());
         fileOutputStream.flush();
         fileOutputStream.close();
         return size;
     }
} catch (IOException e) {
     e.printStackTrace();
} finally {
     //这里关闭IO流
}
复制代码

视频编辑

我们的视频,我们使用的是某企鹅的视频处理SDK(收费的), 大厂真香定律 保证了SDK是兼容Android 10的,所以视频这一块没怎么适配。所以我建议大家找个 真靠谱 的。

唯一的缺点是SDK编辑后的视频输出文件貌似只支持File Api,所以在 Android 10 上 我索性直接输出到私有目录,再拷贝到相册目录。因为我们编辑完的视频是可以让其他APP搜到的。

至于怎么拷贝私有目录的视频到公有目录,下面介绍下载的时候会说到。

文件下载

一开始我天真地认为文件下载也能直接兼容 Android 10 及以下,写一套代码就行了。但是现实给了我残酷的 暴击!所以我采用了版本判断。

10系统及以上

先说说Android 10及以上的做法吧,先来个判断条件:

if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) //大于9.0
复制代码

项目里使用的是 Retrofit2+RxJava2+OkHttp4,顺便提一下 ok4可以简单理解为ok3的kotlin版本。一套操作下来我们拿到了okhttp3(ok4里还是使用的ok3的包名)包中的 abstract class ResponseBody : Closeable (这里是kotlin语法)

ResponseBody 是什么不重要(实际是因为我讲不来...),重要的是怎么操作它:

val inputSystem = responseBody.byteStream()//转成java.io.InputStream
val contentLength = responseBody.contentLength()//取个长度,用来算进度
//开始干!
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
   //自己封装的操作类,下面再说,BufferedInputStream这个参数会在内部close()
    UriOperator.write(BufferedInputStream(inputSystem), saveFileName, saveType, object :WriteListener<Uri> {
        private var localLength: Long = 0L//做个写入进度记录

        //计算进度
        override fun onWriting(length: Long) {
            localLength += length
            RxSchedulers.scheduleMainThread {//自己封装切换到主线程通知调用方
                this@AsyncDownloadCallback.downloadProgress(localLength, contentLength)
            }
        }
       
        // 下载失败
        override fun onFailed(code: Int, msg: String) {
            //closeQuietly() 来自 package okhttp3.internal 中 Util.kt
            inputSystem.closeQuietly()
            RxSchedulers.scheduleMainThread {
                this@AsyncDownloadCallback.onFail(null, DownloadException(code, msg))
            }
        }

        override fun onSuccess(data: Uri) {
            inputSystem.closeQuietly()
            RxSchedulers.scheduleMainThread {
                this@AsyncDownloadCallback.onSuccess(data)
            }
        }
    })
} else {
    //10以下待会说
}
复制代码

让我们来看看 UriOperator.write 方法:

//这个方法的外层调用时是异步的
fun write(bufferedInputStream: BufferedInputStream, saveFileName: String,
               @FileSaveType fileType: String, listener: WriteListener<Uri>?) {
    if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) {//判断存储卡是否可用
        getInsertUri(saveFileName, fileType)?.let { uri ->//根据fileType找到Uri
            FileSystem.context.contentResolver.openOutputStream(uri)?.let {
                if (writeIO(bufferedInputStream, it, object : IOListener {
                    override fun operator(length: Long) {
                        listener?.onWriting(length)
                    }
                   })) {
                       listener?.onSuccess(uri)
                } else {
                    listener?.onFailed(ERROR_IO_EXCEPTION, "写入出现异常!")
                }
            } ?: listener?.onFailed(ERROR_OUTPUT_NULL, "无法打开输出流!")
        } ?: listener?.onFailed(ERROR_UNKNOW_TYPE, "未知的文件类型!")
    } else {
        //如果有需要这里可以转私有目录
        listener?.onFailed(ERROR_NO_SDCARD, "没有外部存储!")
    }
}
复制代码

咱们先看看 getInsertUri(saveFileName, fileType) 这个方法, @FileSaveType 是我自定义的类型注解:

fun getInsertUri(saveFileName: String, @FileSaveType fileType: String) = when (fileType) {
    SAVE_JPG, SAVE_PNG -> {//图片格式
        FileSystem.context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, ContentValues().apply {
            this.put(MediaStore.Images.Media.DISPLAY_NAME, saveFileName.plus(".").plus(if (fileType == SAVE_JPG) "jpg" else "png"))
            this.put(MediaStore.Images.Media.MIME_TYPE, if (fileType == SAVE_JPG) "image/jpg" else "image/png")
            //可以指定图片目下一个子文件夹
            //this.put(MediaStore.Images.Media.RELATIVE_PATH, "MyPic")
            //在搭载 Android 10(API 级别 29)及更高版本的设备上,您的应用可以通过使用 IS_PENDING 标记在媒体文件写入磁盘时获得对文件的独占访问权限。
            //this.put(MediaStore.Images.Media.IS_PENDING, 1)
            //这个 IS_PENDING 之后是需要调用API关闭的!所以这里我就没用,暂时没这个需求
        })
    }
    
    SAVE_MP4 -> {
        FileSystem.context.contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, ContentValues().apply {
            this.put(MediaStore.Video.Media.DISPLAY_NAME, saveFileName.plus(".mp4"))
            this.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
        })
    }
    
    SAVE_APK -> {
        FileSystem.context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, ContentValues().apply {
            this.put(MediaStore.Downloads.DISPLAY_NAME, saveFileName.plus(".apk"))
            this.put(MediaStore.Downloads.MIME_TYPE, "application/vnd.android.package-archive")
        })
    }
    
    else -> null
}
复制代码

最后看看 writeIO 这个方法:

fun writeIO(bufferedInputStream: BufferedInputStream, outputStream: OutputStream, listener: IOListener? = null): Boolean {
     try {
         var len: Int
         bufferedInputStream.use { input ->
             outputStream.use { output ->
                 val buffer = ByteArray(1024)
                 while (input.read(buffer).also { len = it } != -1) {
                     output.write(buffer, 0, len)
                     output.flush()
                     listener?.operator(len.toLong())//返回写入进度
                 }
             }
         }
     } catch (t: Throwable) {
         return false
     }
     return true
}
复制代码

上面流程跑下来,我们就可以下载文件啦!但是遗憾的是低版本的兼容性不太好。

在我测试的 7.0 系统上,通过系统相册打开我下载的视频,发现时长,分辨率等属性缺失了。毕竟在下载的时候我是不知道这些属性的。

当然,等文件下载下来你是可以读取属性,并通过 ContentResolver 的 API 写进去的。

读取视频的属性,我建议使用 android.media.MediaMetadataRetriever 这里就不多说了。

对于如何把私有目录的文件拷贝到共有目录,这里也有答案了:

val stream = FileInputStream(file)
//上面的 write 方法
write(BufferedInputStream(stream) ...)
复制代码

10系统以下

因为项目里之前有针对File下载的代码,索性我直接偷懒了😜,在10以下还是走的以前的方式。

套路跟上面差不多,这里说一下File目录的获取:

这里截图是想给大家看看在targetSdkVersion=29的时候 Environment.getExternalStoragePublicDirectory 这个已经被废弃了。以后不知道会不会直接给删了。。。😓

然后是将下载下来的数据扫描一下,这样 相册等APP 才能看到图片,这里使用的是 android.media.MediaScannerConnection , 接受File传参就行了:

public static void mediaScan(Context context, File file, String mediaType) {
     MediaScannerConnectionClientImpl impl = new MediaScannerConnectionClientImpl(file, mediaType);
     MediaScannerConnection mediaScannerConnection = new MediaScannerConnection(context, impl);
     impl.setMediaScannerConnection(mediaScannerConnection);
     mediaScannerConnection.connect();
}

public static class MediaScannerConnectionClientImpl implements MediaScannerConnection.MediaScannerConnectionClient {

     private MediaScannerConnection mediaScannerConnection;
     private File file;
     private String mediaType;

     MediaScannerConnectionClientImpl(File file, String mediaType) {
         this.file = file;
         this.mediaType = mediaType;
     }

     void setMediaScannerConnection(MediaScannerConnection mediaScannerConnection) {
         this.mediaScannerConnection = mediaScannerConnection;
     }

     @Override
     public void onMediaScannerConnected() {
         if (mediaScannerConnection != null)
             mediaScannerConnection.scanFile(file.getAbsolutePath(), mediaType);
     }

     @Override
     public void onScanCompleted(String path, Uri uri) {
         mediaScannerConnection.disconnect();
     }
}
复制代码

至于 MediaScanner 能不能在10系统把Uri给刷进去,大家可以自己试一试。。。😂

因为 MediaScanner 其实是绑定了一个 service,所以我建议使用 Application 来启动,处理不好可能造成 Activity 泄漏(其实我已经遇到了)。

其他一些

文件上传

我司APP都是在私有目录上传的, 也就是说 File API 还是可以继续使用的,嘿嘿!我的思路就是把 Uri转化成IO流 ,说起来一句话,实际编码还是有点麻烦的,这部分暂时没实践。

分享

分享我们用的是友盟分享SDK,由于我们业务简单,目前的图片的分享都是走的网络图片。友盟应该是把图片下载到私有目录(测试得出的结论),目前没遇到问题。

应用内更新

APK是下载到私有目录,这个还是旧的方式,测试没有问题。

照片拍摄

目前拍到私有目录,是旧的方式,测试没有问题。

参考文献

谷歌开发者:Android 11 中的存储机制更新

官方:处理外部存储中的媒体文件

Android 10 适配攻略

文章分类
Android
文章标签