1. 从崩溃现场说起:Android存储权限为什么这么难搞
先还原一个我上个月遇到的真实场景:应用把targetSdkVersion从28升到30,发版第二天用户反馈崩溃率暴涨,后台堆栈里全是SecurityException: Permission Denial: reading com.android.providers.media.MediaProvider。当时的第一反应是"权限申请没改?",仔细排查后发现不是没申请,而是申请了但没生效——Android 11之后READ_EXTERNAL_STORAGE的授权范围变了,读取公共目录里的文件再也不是"声明一下、弹个框"就能解决的。
这其实不是个例。存储权限是Android所有适配内容里最容易踩坑的点,原因很直接:它不是一个版本改一次,而是把权限模型、文件访问方式、URI规范、分区策略这四样东西像挤牙膏一样,从Android 6.0一直改到Android 14,每一代都留了兼容后门,但后门本身也有有效期。
先说清楚这篇文章的适用对象:Android开发、应用升级适配的执行者、以及维护老项目需要接触FileProvider和MediaStore的工程师。如果你正遇到"代码在Andorid 9跑得好好的,到Android 11/13上突然文件读不到、图片存不进相册、分享APK闪退",这篇内容能帮你把整条知识线串起来,而不是继续对着报错瞎试。
2. 存储权限的世代更替:从"一把钥匙"到"每个应用一个保险箱"
2.1 老玩家都知道的"万能钥匙"时代
在Android 6.0之前,存储权限是真的简单粗暴。READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE只需要在AndroidManifest.xml里声名一次,安装时系统弹一个"允许访问所有文件"的授权页,用户点同意,应用就能像逛自家后院一样遍历整个外部存储的公共目录。那时候的代码长这样:
File path = Environment.getExternalStorageDirectory(); File file = new File(path, "Download/report.pdf");这段代码放到今天,API 29以上的设备上直接报FileNotFoundException,API 33以上的设备连Environment.getExternalStorageDirectory()拿到的路径都不保证可读。原因不在代码本身,而在于系统对外部存储的定位变了:它不再认为一个第三方应用有权随便梭巡全盘。
2.2 两个真正的分水岭:Android 6.0和Android 10
如果要把存储权限的演进压缩成两句话,那就是:
- Android 6.0(API 23)引入了运行时权限,权限不再"安装即授予",而是应用在使用时动态申请,用户可以在系统设置里随时收回。
- Android 10(API 29)引入了Scoped Storage(分区存储),应用默认只能直接访问自己的专属目录和公共媒体目录中自己创建的文件,想碰其他应用的私有文件基本没门。
Android 6.0到Android 9,改动只是一层权限交互模型;Android 10开始,文件系统的访问边界被重画了。这也是为什么很多老项目在Android 9上只做一个运行时权限适配还能凑合,一升级到Android 10就全线崩盘。
2.3 现阶段存储模型的全貌
到现在(Android 14/15),外部存储的访问规则是四层体系,缺一层都会出问题:
第一层:应用专属目录(无需权限)通过context.getExternalFilesDir()、context.getCacheDir()等获取,路径类似/storage/emulated/0/Android/data/你的包名/,读写自己专属目录里的文件不需要任何存储权限,应用卸载时系统自动清理。
第二层:公共媒体目录(需要细粒度权限)图片、视频、音频等媒体文件通过MediaStore API访问。Android 13开始权限拆分成READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO,应用只需申请自己实际用到的类型。
第三层:公共非媒体目录(需要特殊管理权限)比如Download目录里的PDF,或者自建根目录下的文件夹,普通文件读写需要MANAGE_EXTERNAL_STORAGE这个"所有文件管理权限"。
第四层:其他应用专属目录(默认不可访问)没有常规授权路径,除非系统明确开放(比如Android 11可以读取按压入口的缓存文件),或者走SAF文件选择器让用户主动选取。
这个四层模型就是所有适配方案的底层依据。我见过不少开发者把MANAGE_EXTERNAL_STORAGE当成"万能权限"乱申请,结果被应用商店审核拒了——这个权限在Google Play上只允许文件管理类应用使用,普通应用强行申请大概率被打回。所以先认清自己的应用到底需要哪一层,比盲目加权限重要得多。
3. 核心适配实战:每个API级别到底要怎么改代码
存储权限适配最大的迷惑点在于:你必须同时考虑"运行设备的系统版本"和"应用声明的targetSdkVersion",二者不一致时,以较高规则为准。换句话说,在Android 13手机上用一个targetSdkVersion=28的老应用,很多新版限制反而不生效;但一旦你升级targetSdkVersion到33,老设备上的行为也会被新规则约束。
3.1 先搞定版本探测
无论适配到什么级别,第一步永远是明确当前运行环境,推荐用官方推荐的写法:
fun isAtLeastSdk(version: Int): Boolean { return Build.VERSION.SDK_INT >= version }适配时用Build.VERSION.SDK_INT判断系统版本,不要用Build.VERSION.RELEASE解析字符串,后者在非标准ROM上可能是"10"也可能是"10.0.0",“如果祖传代码在QA的手机上意外地拿到了奇怪的ROM版本号,这锅你背不动”。
3.2 Android 6.0(API 23)~ Android 9:运行时权限的黄金时代
这个阶段的核心改法是动态申请权限。清单里声明,代码里判断是否已授权,没授权就requestPermissions。
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />注意WRITE_EXTERNAL_STORAGE我这里加了maxSdkVersion="32"——这是适配Android 13的关键细节,后面细说。
动态申请的核心流程:
class MainActivity : AppCompatActivity() { private val permissionRequestCode = 1001 private val requiredPermissions = arrayOf( Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) checkStoragePermission() } private fun checkStoragePermission() { val deniedPermissions = requiredPermissions.filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED } if (deniedPermissions.isNotEmpty()) { ActivityCompat.requestPermissions( this, deniedPermissions.toTypedArray(), permissionRequestCode ) } else { onPermissionGranted() } } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<out String>, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == permissionRequestCode) { val isAllGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED } if (isAllGranted) { onPermissionGranted() } else { // 提示用户去设置页打开权限 } } } private fun onPermissionGranted() { // 真正的存储读写逻辑 } }这个阶段的坑主要在适配行为上:用户选了"拒绝"后再次申请,应用会被标记为不友好,第三次弹窗系统会直接跳过,需要引导用户去系统设置手动开启。
3.3 Android 10~12:Scoped Storage的强制执行期
API 29开始分区存储成为默认行为,但Google留了一个逃生舱门:requestLegacyExternalStorage="true"。把这个属性写在application节点下,就能暂时用旧的文件访问方式。注意这只是缓冲,到Android 11(API 30)这个逃生舱门直接封死,requestLegacyExternalStorage会被无视。
所以正确的适配方向,是从一开始就拥抱新模型:
写入公共目录:用MediaStore而不是裸路径
fun saveImageToGallery(context: Context, imageBitmap: Bitmap, displayName: String) { val contentResolver = context.contentResolver val contentValues = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, displayName) put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/MyApp") put(MediaStore.Images.Media.IS_PENDING, 1) } } val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) } else { MediaStore.Images.Media.EXTERNAL_CONTENT_URI } val uri = contentResolver.insert(collection, contentValues) uri?.let { contentResolver.openOutputStream(it)?.use { outputStream -> imageBitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { contentValues.clear() contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) contentResolver.update(it, contentValues, null, null) } } }IS_PENDING这个字段是API 29新增的,它的作用是告诉其他应用"这个文件还在写,别碰"。写入完成后务必置0,否则相册里会出现一个"正在处理"的幽灵文件,等系统触发媒体扫描才会消失,实测在部分手机上要等好几分钟。
读取公共目录:查询MediaStore
fun getImages(context: Context): List<Uri> { val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI val projection = arrayOf(MediaStore.Images.Media._ID) val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC" val imageUris = mutableListOf<Uri>() context.contentResolver.query( uri, projection, null, null, sortOrder )?.use { cursor -> val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) while (cursor.moveToNext()) { val id = cursor.getLong(idColumn) imageUris.add( ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) ) } } return imageUris }拿到了uri,用ContentResolver.openInputStream(uri)读取,定位到图片直接photoImageView.setImageURI(uri)。注意不要把uri转成文件路径再去new File(path),MediaStore的uri内部可能对应的是FUSE文件系统上的虚拟路径,File拿不到合法句柄。
3.4 Android 13(API 33)以后:细粒度权限的真正分水岭
Android 13对存储权限的改动是颠覆性的:READ_EXTERNAL_STORAGE被废弃,拆成三个新权限:
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />如果只做图片功能,就只申请READ_MEDIA_IMAGES,用户也会觉得更合理。同时,WRITE_EXTERNAL_STORAGE在API 33上完全失效——不再只是"被忽略",而是无论写多少行代码、怎么动态申请都不会触发弹窗,直接静默拒绝。
这就是为什么前面清单声明里写了android:maxSdkVersion="32":避免在Android 13上做无用功。如果targetSdkVersion>=33,WRITE_EXTERNAL_STORAGE根本不需要声明,写了反而可能被应用商店审核质疑。
申请逻辑必须按版本分流:
private fun requestStoragePermission() { val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13+:只申请需要的类型 arrayOf(Manifest.permission.READ_MEDIA_IMAGES) } else { // Android 12及以下:申请原来的读取权限 arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) } val needRequest = permissions.any { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED } if (needRequest) { ActivityCompat.requestPermissions(this, permissions, STORAGE_REQUEST_CODE) } }还有一个新出现的东西:部分访问权限(Selected Photos Access)。Android 14在用户弹窗上加了"选择照片"选项,用户可以在不给整个图库权限时指定几张图给应用。此时授权结果对应的是READ_MEDIA_VISUAL_USER_SELECTED,代码里如果用PERMISSION_GRANTED判断,拿到的是拒绝,必须额外判断这个新权限。
private fun hasVisualAccess(): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { return ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED } return false }3.5 读写其他公共非媒体文件的姿态
如果应用确实要管理Download目录或其他自建目录,Android 11以上需要一个特殊权限:MANAGE_EXTERNAL_STORAGE。
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />申请这个权限时不能走常规的requestPermissions,要跳转系统设置页:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (!Environment.isExternalStorageManager()) { val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.data = Uri.parse("package:$packageName") startActivity(intent) } }这里必须说实话:这个权限是"核武器",Google Play审核很严,普通应用申请大概率被拒,两年前我做的一个工具类应用就被驳回两次。如果不是应用核心功能就是文件管理/杀毒清理,建议绕过:用系统文件选择器(Storage Access Framework)让用户主动选文件,或者干脆把自己的业务文件放进应用专属目录。
4. FileProvider与content:// URI:文件分享绕不开的核心机制
4.1 为什么intent.putExtra(uri)会闪退
Android 7.0之后,一个常年让人血压升高的异常出现了:FileUriExposedException。原因很简单:系统不再允许应用通过file://URI向其他应用暴露文件。为什么禁止?因为file://URI用的是绝对路径,目标应用在拿到这个URI之后理论上可以拼接路径去访问你应用专属目录里的其他文件,这属于裸奔式数据泄露。
解决办法是FileProvider,它把应用内文件包装成一个带权限控制的content://URI。很多人在热搜里看到的content://com.tencent.wework.fileprovider/external_path/...,就是腾讯系应用用了自己的FileProvider配置把这些内容包装过的结果。
4.2 FileProvider的配置步骤
FileProvider本身其实是AndroidX ContentProvider的子类,配置三步走。
第一步:Manifest注册
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>authorities要求全局唯一,一般用${applicationId}.fileprovider占位符保证不同应用不冲突。如果项目还没接入AndroidX,用android.support.v4.content.FileProvider,逻辑一样。
第二步:file_paths.xml映射
<?xml version="1.0" encoding="utf-8"?> <paths> <files-path name="internal_files" path="." /> <cache-path name="cache_files" path="." /> <external-path name="external_files" path="." /> <external-files-path name="external_app_files" path="." /> <external-cache-path name="external_cache_files" path="." /> </paths>每个标签对应一种根目录:
files-path:context.getFilesDir()cache-path:context.getCacheDir()external-path:Environment.getExternalStorageDirectory()external-files-path:context.getExternalFilesDir()external-cache-path:context.getExternalCacheDir()
第三步:代码生成content URI
fun shareApk(context: Context, apkFile: File) { val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", apkFile ) val intent = Intent(Intent.ACTION_SEND).apply { type = "application/vnd.android.package-archive" putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(intent, "分享APK")) }重点坑位来了:addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)最容易漏。不加这个flag,接收方虽然拿到了content://URI却没有读权限,打开时直接Permission Denial。共享URI权限在FileProvider的设计里是显式授权的,不能省略。
4.3 path映射踩坑记录
file_paths.xml里的path="."表示根目录下的所有子目录和文件。如果你只想暴露某个特定子目录,经典写法是:
<external-files-path name="download_export" path="exports/" />但path是相对路径,不能以/开头,很多人写path="/exports",结果运行时报错Failed to find configured root。还有人在配置时用了../试图往根目录外跳,同样会被拒绝——FileProvider设计的初衷就是限制边界、不能穿越。
另外注意:外部存储根目录用external-path标签容易暴露整个SD卡的内容,不推荐把整个/storage/emulated/0/暴露出去。实战中我见过团队为了省事,直接把external-path的path设成.,这意味着应用能把用户手机里所有公共目录的文件的content URI发出去,这在应用安全审计上是有风险的。
4.4 拍照场景的FileProvider适配
拍照是另一个FileProvider高频场景。旧代码:
Uri fileUri = Uri.fromFile(photoFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri);Android 7.0以上直接FileUriExposedException。正确姿势:
fun createImageUri(context: Context): Uri { val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "camera") if (!dir.exists()) dir.mkdirs() val file = File(dir, "IMG_${System.currentTimeMillis()}.jpg") return FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file ) }拍照完成后用file.absolutePath读回来,还是用uri读回来?我的建议是保存File对象引用,拍完用BitmapFactory.decodeFile(file.absolutePath)解析,别用uri作二次查询。
5. 升级迁移路线图:老项目改造的推荐顺序和排查清单
5.1 建议的迁移步骤
如果手头是一个老项目,targetSdkVersion还是28或29,我的建议改造顺序如下:
第一步:替换file:// URI为FileProvider全局搜索Uri.fromFile和file://拼接,全部换掉。这一步不牵涉权限申请,只是修复Crash,发布出去就能止血。
第二步:把公共目录读写切到MediaStore搜索Environment.getExternalStoragePublicDirectory,改成MediaStore的insert/query。如果读取的是图片、视频、音频,这个迁移非常划算。
第三步:应用内私有文件目录规范化把自己的缓存、下载、导出文件全部收拢到context.getExternalFilesDir()或context.getCacheDir(),避免散落公共目录。这样不但不受分区存储影响,应用卸载时还能自动清理。
第四步:升级targetSdkVersion前自查用官方兼容性测试工具的测试用例过一遍,重点看:安装/卸载、分享、拍照、相册选取、下载到公共目录、打开PDF等场景。
5.2 快速排查清单
| 场景 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 分享APK/图片 | FileUriExposedException | 暴露了file:// URI | 改用FileProvider |
| 分享后对方打不开 | Permission Denial | 没加FLAG_GRANT_READ_URI_PERMISSION | 添加URI授权flags |
| 应用内拍的照片保存失败 | FileNotFoundException | 试图写公共目录裸路径 | 写入应用专属目录或MediaStore |
| 相册看不到新保存的图 | 图库没刷新 | 未正确处理IS_PENDING | 写完置0或触发媒体扫描 |
| 升级targetSdk后原有功能崩溃 | SecurityException | 分区存储强制生效 | 全面切到MediaStore/SAF |
| 申请不到WRITE权限 | 静默忽略 | Android 13废弃该权限 | 只申请对应READ_MEDIA类型 |
| 拿不到相册权限 | 授权结果异常 | 忽略了用户"选择照片"选项 | 判断READ_MEDIA_VISUAL_USER_SELECTED |
5.3 一个改善体验的小技巧
分区存储模型下,应用第一次访问公共目录时的权限弹窗时机很有讲究。我的经验是:不要在onCreate里弹,等用户真正触发"保存图片""打开图库"动作时再弹,这样授权率和用户理解度都高很多。可以做一个权限状态的预检工具类,把权限检查和业务触发点分离:
object StoragePermissionHelper { fun ensureReadMediaPermission( activity: Activity, permissionType: MediaPermissionType, onGranted: () -> Unit, onDenied: (() -> Unit)? = null ) { val permission = when (permissionType) { MediaPermissionType.IMAGES -> Manifest.permission.READ_MEDIA_IMAGES MediaPermissionType.VIDEO -> Manifest.permission.READ_MEDIA_VIDEO MediaPermissionType.AUDIO -> Manifest.permission.READ_MEDIA_AUDIO MediaPermissionType.LEGACY -> Manifest.permission.READ_EXTERNAL_STORAGE } val sdk = Build.VERSION.SDK_INT val actualPermission = when { sdk >= Build.VERSION_CODES.TIRAMISU && permissionType != MediaPermissionType.LEGACY -> permission sdk >= Build.VERSION_CODES.TIRAMISU -> Manifest.permission.READ_MEDIA_IMAGES else -> Manifest.permission.READ_EXTERNAL_STORAGE } when { ContextCompat.checkSelfPermission(activity, actualPermission) == PackageManager.PERMISSION_GRANTED -> onGranted() ActivityCompat.shouldShowRequestPermissionRationale( activity, actualPermission ) -> { // 解释为什么需要这个权限 showRationaleDialog(activity, actualPermission, onGranted, onDenied) } else -> { ActivityCompat.requestPermissions( activity, arrayOf(actualPermission), PERMISSION_REQUEST_CODE ) } } } }这类的细节其实还有很多,比如Android 14的READ_MEDIA_VISUAL_USER_SELECTED、Android 11上MediaStore.createWriteRequest申请批量写权限等,每个都是"看到报错才想起来"的隐性坑。
6. 写在最后:一个老开发者的适配心态
做存储权限适配这几年,我最大的感受是:它不只是一个技术问题,更是一个做产品权衡的问题。新版本的每一项权限收紧背后都有一个真实的安全动机——用户不希望一个拍照软件翻遍他整部手机的PDF合同和下载目录,这个诉求相当合理。你的适配方案越是理解这个动机,越容易走在正确的方向上。
我在实际项目里总结下来的一个效益最高的做法是:在每次targetSdkVersion升级时,把存储相关代码当作一个整体模块重写,而不是零敲碎打地修补丁。零敲碎打的代价是你永远在追着报错跑,整体重写虽然一开始多花时间,但换来的是接下来两三个大版本的安稳。比如现在写一个下载功能,默认模板就是"4.4+以上用DownloadManager或MediaStore,老设备降级到裸路径",一次写好,后面无需再动。
最后给一个调适期的实用工具:拿一台Android 13真机,在开发者选项里开启"权限检查报告",配合adb shell dumpsys package 你的包名看权限授受情况,很多"为什么功能没反应"的问题,其实早就在权限日志里写了答案。多看看这些原始输出,比照着搜索引擎猜要快得多。