如何在不申请 WRITE_EXTERNAL_STORAGE 权限的情况下保存图片到相册?

解读

面试官真正想考察的是:

  1. 对 Android 10(API 29)开始强制分区存储(Scoped Storage)的理解深度;
  2. 能否区分「私有目录」「共享媒体集合」「SAF(Storage Access Framework)」三种写入路径;
  3. 是否知道国内厂商(华为、小米、OPPO、vivo)在 MediaStore 上的“强制刷新”差异;
  4. 是否具备“零权限”用户体验意识,以及对应兼容到 Android 13(API 33)的代码细节。

答“用 MediaStore 就行”只能拿 60 分;把“RELATIVE_PATH、IS_PENDING、ContentResolver.applyBatch、MediaScannerConnection、FileProvider、SAF”全串成闭环,才能拿到 90+。

知识点

  1. Scoped Storage 分区存储模型:App 无权限时只能写外部私有目录、MediaStore 公共媒体集合、SAF 用户授权目录。
  2. MediaStore.Images.Media 入口:API 29+ 需填充 RELATIVE_PATH(如 Pictures/MyApp/),API 30+ 优先用 EXTERNAL_CONTENT_URI,不再依赖 _DATA。
  3. IS_PENDING 标志:API 29+ 写入未完成时设为 1,插入完成后改 0,防止相册瞬间扫到半成品;国内部分机型(小米 13、华为鸿蒙 3)若跳过此位会出现 0 B 文件。
  4. ContentResolver.openOutputStream() 真正落盘;插入后调用 setNotificationUri 触发系统 MediaProvider 刷新。
  5. 兼容层:API 28 及以下仍可能弹“文件访问”对话框,需用 FileProvider+Intent.ACTION_MEDIA_SCANNER_SCAN_FILE 兜底;Android 13 新增 READ_MEDIA_IMAGES 权限,但“写入”仍无需申请。
  6. 国内 ROM 强制刷新:部分厂商 MediaProvider 缓存激进,需额外发送广播或调用 MediaScannerConnection.scanFile;否则用户打开系统相册延迟 5~30 s。
  7. 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 设备同样适用。

拓展思考

  1. 如果产品要求“保存到 DCIM/Custom”且兼容 Android 9 以下,只能走 SAF,让用户手动选一次目录,之后用 takePersistableUriPermission 持久化,可做到“一次授权,终身写入”。
  2. 高并发拍照场景(连拍 30 张)建议复用同一个 ContentValues,批量 applyBatch 插入,减少 IPC;否则 MediaProvider 会成为瓶颈。
  3. 保存 PNG、WEBP、AVIF 时,记得同步改 MIME_TYPE 与 CompressFormat;部分国产相册 App 依据 MIME 做缩略图解析,写错会导致无法预览。
  4. 隐私合规:虽然无权限,但仍需在《隐私政策》中声明“保存图片到公共相册”行为,否则国内应用市场(华为、应用宝)会被驳回。
  5. 未来 Android 14 可能进一步限制 IS_PENDING 时长(≤ 5 s),写入大文件时需分段压缩或提前落临时文件,再一次性拷贝到 MediaStore,避免扫描异常。