资讯详情 Android存储权限适配指南:从运行时权限到分区存储
📅 2026/10/3 6:02:42
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以上的设备上直接报FileNotFoundExceptionAPI 33以上的设备连Environment.getExternalStorageDirectory()拿到的路径都不保证可读。原因不在代码本身而在于系统对外部存储的定位变了它不再认为一个第三方应用有权随便梭巡全盘。2.2 两个真正的分水岭Android 6.0和Android 10如果要把存储权限的演进压缩成两句话那就是Android 6.0API 23引入了运行时权限权限不再安装即授予而是应用在使用时动态申请用户可以在系统设置里随时收回。Android 10API 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手机上用一个targetSdkVersion28的老应用很多新版限制反而不生效但一旦你升级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.0API 23~ Android 9运行时权限的黄金时代这个阶段的核心改法是动态申请权限。清单里声明代码里判断是否已授权没授权就requestPermissions。uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion32 /注意WRITE_EXTERNAL_STORAGE我这里加了maxSdkVersion32——这是适配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: Arrayout 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~12Scoped Storage的强制执行期API 29开始分区存储成为默认行为但Google留了一个逃生舱门requestLegacyExternalStoragetrue。把这个属性写在application节点下就能暂时用旧的文件访问方式。注意这只是缓冲到Android 11API 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否则相册里会出现一个正在处理的幽灵文件等系统触发媒体扫描才会消失实测在部分手机上要等好几分钟。读取公共目录查询MediaStorefun getImages(context: Context): ListUri { 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 mutableListOfUri() 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 13API 33以后细粒度权限的真正分水岭Android 13对存储权限的改动是颠覆性的READ_EXTERNAL_STORAGE被废弃拆成三个新权限uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO / uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO /如果只做图片功能就只申请READ_MEDIA_IMAGES用户也会觉得更合理。同时WRITE_EXTERNAL_STORAGE在API 33上完全失效——不再只是被忽略而是无论写多少行代码、怎么动态申请都不会触发弹窗直接静默拒绝。这就是为什么前面清单声明里写了android:maxSdkVersion32避免在Android 13上做无用功。如果targetSdkVersion33WRITE_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:nameandroid.permission.MANAGE_EXTERNAL_STORAGE tools:ignoreScopedStorage /申请这个权限时不能走常规的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:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /providerauthorities要求全局唯一一般用${applicationId}.fileprovider占位符保证不同应用不冲突。如果项目还没接入AndroidX用android.support.v4.content.FileProvider逻辑一样。第二步file_paths.xml映射?xml version1.0 encodingutf-8? paths files-path nameinternal_files path. / cache-path namecache_files path. / external-path nameexternal_files path. / external-files-path nameexternal_app_files path. / external-cache-path nameexternal_cache_files path. / /paths每个标签对应一种根目录files-pathcontext.getFilesDir()cache-pathcontext.getCacheDir()external-pathEnvironment.getExternalStorageDirectory()external-files-pathcontext.getExternalFilesDir()external-cache-pathcontext.getExternalCacheDir()第三步代码生成content URIfun 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 namedownload_export pathexports/ /但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_SELECTED5.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 你的包名看权限授受情况很多为什么功能没反应的问题其实早就在权限日志里写了答案。多看看这些原始输出比照着搜索引擎猜要快得多。