如何在不申请 WRITE_EXTERNAL_STORAGE 权限的情况下保存图片到相册?
解读
面试官真正想考察的是:
- 对 Android 10(API 29)开始强制分区存储(Scoped Storage)的理解深度;
- 能否区分「私有目录」「共享媒体集合」「SAF(Storage Access Framework)」三种写入路径;
- 是否知道国内厂商(华为、小米、OPPO、vivo)在 MediaStore 上的“强制刷新”差异;
- 是否具备“零权限”用户体验意识,以及对应兼容到 Android 13(API 33)的代码细节。
答“用 MediaStore 就行”只能拿 60 分;把“RELATIVE_PATH、IS_PENDING、ContentResolver.applyBatch、MediaScannerConnection、FileProvider、SAF”全串成闭环,才能拿到 90+。
知识点
- Scoped Storage 分区存储模型:App 无权限时只能写外部私有目录、MediaStore 公共媒体集合、SAF 用户授权目录。
- MediaStore.Images.Media 入口:API 29+ 需填充 RELATIVE_PATH(如 Pictures/MyApp/),API 30+ 优先用 EXTERNAL_CONTENT_URI,不再依赖 _DATA。
- IS_PENDING 标志:API 29+ 写入未完成时设为 1,插入完成后改 0,防止相册瞬间扫到半成品;国内部分机型(小米 13、华为鸿蒙 3)若跳过此位会出现 0 B 文件。
- ContentResolver.openOutputStream() 真正落盘;插入后调用 setNotificationUri 触发系统 MediaProvider 刷新。
- 兼容层:API 28 及以下仍可能弹“文件访问”对话框,需用 FileProvider+Intent.ACTION_MEDIA_SCANNER_SCAN_FILE 兜底;Android 13 新增 READ_MEDIA_IMAGES 权限,但“写入”仍无需申请。
- 国内 ROM 强制刷新:部分厂商 MediaProvider 缓存激进,需额外发送广播或调用 MediaScannerConnection.scanFile;否则用户打开系统相册延迟 5~30 s。
- SAF 备选方案:若图片大于 1 GB 或用户指定自定义目录,启动 Intent.ACTION_CREATE_DOCUMENT 让用户选路径,完全零权限;但体验重,适合“导出”场景而非“静默保存”。
答案
以 Kotlin 为例,覆盖 API 21-33,零权限保存 Bitmap 到公共相册并立即出现在系统图库:
object GallerySaver {
private val filename get() = "IMG_${System.currentTimeMillis()}.jpg"
fun saveBitmap(context: Context, bitmap: Bitmap, onComplete: (uri: Uri?) -> Unit) {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/MyApp")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
uri?.let {
try {
resolver.openOutputStream(it)?.use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, out)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(it, contentValues, null, null)
}
// 强制刷新:解决国内 ROM 延迟
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
MediaScannerConnection.scanFile(
context, arrayOf(getRealPath(context, it)), null, null
)
} else {
resolver.notifyChange(it, null)
}
onComplete(it)
} catch (e: Exception) {
resolver.delete(it, null, null)
onComplete(null)
}
} ?: onComplete(null)
}
@Suppress("DEPRECATION")
private fun getRealPath(context: Context, uri: Uri): String {
val projection = arrayOf(MediaStore.Images.Media.DATA)
context.contentResolver.query(uri, projection, null, null, null)?.use {
if (it.moveToFirst()) {
return it.getString(it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
}
}
return ""
}
}
使用方式:
GallerySaver.saveBitmap(this, myBitmap) { uri ->
if (uri != null) toast("已保存至相册") else toast("保存失败")
}
全程不声明 WRITE_EXTERNAL_STORAGE,也不申请新权限;Android 13 设备同样适用。
拓展思考
- 如果产品要求“保存到 DCIM/Custom”且兼容 Android 9 以下,只能走 SAF,让用户手动选一次目录,之后用 takePersistableUriPermission 持久化,可做到“一次授权,终身写入”。
- 高并发拍照场景(连拍 30 张)建议复用同一个 ContentValues,批量 applyBatch 插入,减少 IPC;否则 MediaProvider 会成为瓶颈。
- 保存 PNG、WEBP、AVIF 时,记得同步改 MIME_TYPE 与 CompressFormat;部分国产相册 App 依据 MIME 做缩略图解析,写错会导致无法预览。
- 隐私合规:虽然无权限,但仍需在《隐私政策》中声明“保存图片到公共相册”行为,否则国内应用市场(华为、应用宝)会被驳回。
- 未来 Android 14 可能进一步限制 IS_PENDING 时长(≤ 5 s),写入大文件时需分段压缩或提前落临时文件,再一次性拷贝到 MediaStore,避免扫描异常。