关于 Android Q 的 Scoped Storage
如果你还不了解 Android Q 的 Scoped Storage,那么请先自行详细阅读官方 文档
。
Scoped Storage 的好处自不用说,以下会从两个角度来考虑这个新变化所带来的影响:
- 媒体生产者
- 媒体消费者
NOTE:
Storage Access Framework(SAF)不在本文讨论范围内,主要是考虑到知乎可能不会用上。
媒体生产者
举个简单的例子,得到一个 Bitmap,需要把它写入文件并能被检索:
val file = File(Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_PICTURES), "${System.currentTimeMillis()}.jpg") val fos = FileOutputStream(file) fos.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) intent.data = Uri.fromFile(file) context.sendBroadcast(intent)
这段代码在非 Android Q 上没问题,主要做了以下事情:
ACTION_MEDIA_SCANNER_SCAN_FILE
在 Android Q 上,代码还是照常跑,看起来一切没有变化,但实际上,因为这个 file 的目录是在你自己 app 的沙箱里,别的 app 是无法访问的,即便拿到 DATA 里的 filePath,尝试去读取的时候还是会出问题。
官方的一个推荐的做法是直接使用 MediaStore
插入媒体到数据库。
事实上 MediaStore.Images.Media
就有一个 insertImage
的方法,看起来很简单,但是点你点进源码一看:
public static final String insertImage(ContentResolver cr, String imagePath, String name, String description) throws FileNotFoundException { // Check if file exists with a FileInputStream FileInputStream stream = new FileInputStream(imagePath); try { Bitmap bm = BitmapFactory.decodeFile(imagePath); String ret = insertImage(cr, bm, name, description); bm.recycle(); return ret; } finally { try { stream.close(); } catch (IOException e) { } } }
整个 Bitmap 都 decode 出来了,这是为啥呢?
public static final String insertImage(ContentResolver cr, Bitmap source, String title, String description) { ContentValues values = new ContentValues(); values.put(Images.Media.TITLE, title); values.put(Images.Media.DESCRIPTION, description); values.put(Images.Media.MIME_TYPE, "image/jpeg"); Uri url = null; String stringUrl = null; /* value to be returned */ try { url = cr.insert(EXTERNAL_CONTENT_URI, values); if (source != null) { OutputStream imageOut = cr.openOutputStream(url); try { source.compress(Bitmap.CompressFormat.JPEG, 50, imageOut); } finally { imageOut.close(); } long id = ContentUris.parseId(url); // Wait until MINI_KIND thumbnail is generated. Bitmap miniThumb = Images.Thumbnails.getThumbnail(cr, id, Images.Thumbnails.MINI_KIND, null); // This is for backward compatibility. Bitmap microThumb = StoreThumbnail(cr, miniThumb, id, 50F, 50F, Images.Thumbnails.MICRO_KIND); } else { Log.e(TAG, "Failed to create thumbnail, removing original"); cr.delete(url, null, null); url = null; } } catch (Exception e) { // ... } // ... return stringUrl; }
可以看到主要还是帮你生成缩略图,而且注意一下这行代码:
source.compress(Bitmap.CompressFormat.JPEG, 50, imageOut);
以 JPEG 的格式保存图片,而且质量只有 50%…
所以不是很明白官方的这个做法用意如何,既容易 OOM,保存的质量又低。不过正如文档 这里
说的,我们自己手动插一行媒体也是可以的,一些要求:
-
至少提供
DISPLAY_NAME
和MIME_TYPE
-
针对 Android Q,可以设置
PRIMARY_DIRECTORY
或者SECONDARY_DIRECTORY
-
不要自己写
DATA
假设你有一个已经在沙箱里的文件,想把它保存到 SharedCollection 里,你可以这么做:
fun insertImage(cr: ContentResolver, filePath: String, title: String?, desc: String?): Uri? { val file = File(filePath) if (!file.exists()) { return null } val values = ContentValues() values.put(MediaStore.Images.Media.DISPLAY_NAME, file.name) values.put(MediaStore.Images.Media.TITLE, title) values.put(MediaStore.Images.Media.DESCRIPTION, desc) values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") //todo extract mime types values.put(MediaStore.MediaColumns.SIZE, file.length()) val uri = cr.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null // Since we want to persist the file to shared collection, // we do not manually insert the DATA column. // NOTE: the bucket id & name is auto generated by system, thus // by doing this, you will see the images in Picture/ dir. val transferred = transfer(cr, File(filePath), uri) if (!transferred) { cr.delete(uri, null, null) return null } return uri } private fun transfer(cr: ContentResolver, srcFile: File, outUri: Uri): Boolean { val outputFileDescriptor = cr.openFileDescriptor( outUri, "w")?.fileDescriptor ?: return false val inputFileDescriptor = cr.openFileDescriptor( Uri.fromFile(srcFile), "r")?.fileDescriptor ?: return false try { val fos = FileOutputStream(outputFileDescriptor) val fis = FileInputStream(inputFileDescriptor) val outputChannel = fos.channel val inputChannel = fis.channel outputChannel.use { oc -> inputChannel.use { ic -> ic.transferTo(0, ic.size(), oc) } } } catch (e: Exception) { return false } return true }
当然如果你不希望先保存成文件,然后再保存到 SharedCollection,那么在插入数据库后,直接使用 ContentResolver
的 openFileDescriptor
方法打开 FileDescriptor
然后创建流写入就好了。
不过这样有一个缺点,插入的媒体文件的 bucket 无法指定。根据文档说:
/** * The bucket id of the image. This is a read-only property that * is automatically computed from the DATA column. *Type: TEXT
*/ public static final String BUCKET_ID = "bucket_id";
bucket 是从 DATA 里读取的,因此目前无法指定保存的 bucket,也就是说,用户只能在 Pictures 下看见刚刚保存的图片。
媒体消费者
如果你已经在使用 MediaStore
来读取媒体文件,那么而一切都好。但是相信有一小部分 app (比如音频和视频播放相关的)会遍历某个目录下的文件来获取文件,但是这样显然只能获取自己沙箱里的文件。
值得注意的是,去读取外部媒体文件的时候,不要再使用获得文件路径然后再去读了。应该使用 ContentResolver
里的方法:
context.contentResolver.openFileDescriptor(uri, "r")
获取到的文件路径,从原来的:
/storage/emulated/0/DCIM/Camera/test1.jpg
变为
/mnt/content/media/external/images/media/26500
尽管经试验后 BitmapFactory.decodeFile()
依然能成功解码,但还是依然推荐使用 contentResolver
的方式打开一个流,然后再用 BitmapFactory
或者 P 上的 ImageDecoder
。
此外可以看到,已经不能从文件路径来判断文件类型了,如果需要提前获取文件的 MimeType,建议:
- 读取数据库里的 MIME_TYPE,但此方法可能不太可靠,可以 fallback 到下一个方法
- 从文件头读取格式