Android 10适配外部存储文件操作的可运行示例工程 本文还有配套的精品资源点击获取简介这个资源包是一套开箱即用的Android 10API 29文件操作代码专为解决Scoped Storage限制下创建文件夹、写入普通文件和媒体文件的实际问题而设计。项目已配置好targetSdkVersion 29环境支持直接导入Android Studio编译运行无需额外修改即可在Pixel 3a、小米10等真实Android 10设备上验证效果。核心覆盖三种主流方案兼容旧版Environment.getExternalStorageDirectory()的降级处理逻辑、使用MediaStore API向公共媒体目录如DCIM、Pictures安全写入图片/视频/音频文件、通过Storage Access FrameworkSAF获取用户授权后访问任意外部目录。所有权限申请均按Google最新规范实现明确规避MANAGE_EXTERNAL_STORAGE权限滥用在非必要场景下完全不依赖该危险权限。代码中嵌入关键日志输出与逐行注释清晰标注每种方式的适用条件、系统版本兼容边界及常见报错原因。项目结构完整含gradle构建脚本、基础UI界面和测试入口Activity同时提供Gitee源码托管链接与CSDN免Git下载通道方便开发者快速集成到现有工程中做最小化适配验证。1. 项目概述为什么这个示例工程值得你花十分钟认真读完Android 10API 29是Android存储权限演进中真正“动刀子”的分水岭。在此之前Environment.getExternalStorageDirectory()像一把万能钥匙开发者只要申请了WRITE_EXTERNAL_STORAGE就能在SD卡根目录下自由创建文件夹、写入配置、保存日志、导出报表——逻辑直白适配简单。但自Android 10起Google强制推行Scoped Storage分区存储这把钥匙被收走了取而代之的是三把功能明确、权限收敛、使用门槛各异的“专用钥匙”MediaStore用于媒体类文件、Storage Access FrameworkSAF用于用户主动授权的任意路径、以及一个被严格限制的“管理员钥匙”——MANAGE_EXTERNAL_STORAGE。很多团队在升级targetSdkVersion到29时第一反应是加权限、改清单、重写路径拼接逻辑结果在真机上跑起来要么报SecurityException要么文件写进去却找不到要么用户点了允许却没反应——不是代码错了而是根本没理解这三把钥匙各自的“开锁规则”。这个示例工程就是我过去两年在多个中大型App适配过程中从踩坑、复盘、抽象、验证再精简出来的最小可运行闭环。它不讲抽象概念不堆API文档而是用真实可编译、可调试、可断点的代码告诉你当你要在Android 10设备上新建一个叫MyAppCache的文件夹、保存一张用户截图、导出一份CSV报表时到底该选哪条路、每一步要填什么参数、系统会返回什么结果、失败时日志里第一个关键错误是什么。它覆盖了三个核心场景一是兼容旧逻辑的降级兜底比如你的App还依赖某个第三方SDK必须访问根目录二是媒体文件的标准写入图片/视频/音频这是绝大多数App最常遇到的三是需要访问非媒体目录如/Download/MyApp/Reports/时的SAF方案。所有权限申请都采用ActivityResultLauncher新范式规避了onRequestPermissionsResult的回调嵌套地狱所有路径操作都附带Build.VERSION.SDK_INT判断和fallback逻辑所有关键节点都有Log.d(ScopedStorage, ...)输出你连日志过滤器都不用调直接搜ScopedStorage就能串起整个流程链。它适合谁如果你正在把targetSdkVersion从28升到30或33且App有文件导出、缓存管理、相册上传、日志收集等功能那这就是你今天最该打开的工程。它不是教科书而是一份贴着真机屏幕写的“操作手记”。你可以把它当作一个活的对照表当你在自己项目里写到ContentValues插入MediaStore时回头看看这个工程里insertImageToDCIM()方法里RELATIVE_PATH和IS_PENDING字段是怎么设的当你纠结要不要申请MANAGE_EXTERNAL_STORAGE时看看它的PermissionHelper.kt里那行被注释掉的// TODO: Only for File Manager apps — DO NOT UNCOMMENT当你在小米10上发现getExternalFilesDir()返回null时翻到StorageUtils.kt第78行那里有一段针对MIUI的特殊处理注释。这不是一个“完成品”而是一个你随时可以拆解、替换、验证的“适配脚手架”。2. 整体设计思路与方案选型逻辑为什么只选这三条路而不是更多在开始写任何一行代码前我花了整整三天时间梳理Android存储权限的演进脉络。从Android 4.4的getExternalStoragePublicDirectory()到Android 6.0的运行时权限再到Android 10的Scoped Storage再到Android 11对MANAGE_EXTERNAL_STORAGE的收紧最后到Android 13对照片视频权限的进一步细化——这不是简单的API替换而是一次存储哲学的重构从“应用拥有文件”转向“用户拥有文件应用仅获授权访问”。因此这个工程的设计起点非常明确不回避变化不幻想兼容不滥用特权只提供在当前生态下最合理、最稳定、最易维护的三种落地路径。下面逐条解释为何只选这三条以及它们之间的边界如何划定。2.1 降级兼容方案Environment.getExternalStorageDirectory()的“安全模式”很多人看到标题里写着“兼容处理”第一反应是“又来搞兼容那不还是老一套”——这种想法很危险。真正的兼容不是把旧代码原封不动包一层try-catch而是建立一套有明确触发条件、有清晰fallback路径、有版本感知能力的“安全模式”。在这个工程里LegacyStorageAdapter.kt中的createLegacyFolder()方法就是这样一个典型实现。它的核心逻辑是先检查Build.VERSION.SDK_INT Build.VERSION_CODES.Q如果是Android 9及以下直接走老路如果是Android 10及以上则进入“安全模式”分支——此时它不会直接调用getExternalStorageDirectory()而是先尝试通过Context.getExternalFilesDir(null)获取应用专属目录该目录始终可用无需额外权限如果成功就在该目录下创建子文件夹如果失败极罕见多见于某些定制ROM的沙盒异常再退回到getExternalStorageDirectory()并捕获SecurityException最后抛出一个封装好的StorageUnavailableException由上层统一处理比如提示用户“存储不可用请检查设置”。这个设计的关键在于它把“兼容”变成了一个有层次、有兜底、有反馈的决策树而不是一个开关。为什么不用requestLegacyExternalStoragetrue作为默认方案因为这是个临时的、已被标记为deprecated的“创可贴”。从Android 11API 30开始该属性在targetSdkVersion≥30的应用中完全失效即使你在Android 10上设了它也仅对部分非媒体文件有效且无法保证在所有OEM设备上行为一致比如华为EMUI 11就曾出现过设了true却仍抛出异常的情况。所以工程里AndroidManifest.xml中没有这行配置所有逻辑都基于Scoped Storage原生规则构建。requestLegacyExternalStorage只保留在build.gradle的android { defaultConfig { ... } }块中作为注释说明提醒开发者“此路已封勿入”。2.2 MediaStore方案媒体文件的“唯一正统通道”当你需要保存一张用户拍摄的照片、一段录制的视频、或一首下载的MP3时MediaStore不是“一个选项”而是唯一被Google官方认证、全平台稳定、无需危险权限的正统通道。它的底层原理其实很朴素MediaStore本质上是一个系统级的媒体数据库你的App不是直接往磁盘写文件而是向这个数据库“提交一条记录”告诉系统“我要在DCIM目录下存一张图”系统收到后会自动分配一个安全路径如/sdcard/DCIM/Camera/IMG_20240515_143022.jpg并返回一个Uri给你你再通过ContentResolver.openOutputStream(uri)往这个Uri里写数据。整个过程你的App不需要知道物理路径也不需要WRITE_EXTERNAL_STORAGE权限Android 10完全不需要更不会触碰Scoped Storage的禁区。工程中MediaStoreHelper.kt的insertImageToDCIM()方法完整展示了这一流程。重点看三个参数RELATIVE_PATH设为DCIM/Camera/这决定了文件最终存放的公共目录IS_PENDING设为1表示文件正在写入中此时其他App看不到它避免出现“半张图”被图库扫描到的尴尬写入完成后再用ContentValues().apply { put(IS_PENDING, 0) }更新这条记录系统才会将其标记为“已完成”图库App才能正常显示。这个IS_PENDING机制就是MediaStore区别于旧方案的核心设计——它用数据库状态代替了文件系统锁既保证了原子性又规避了竞态条件。很多开发者第一次用MediaStore失败就是因为漏掉了IS_PENDING的两次设置或者把RELATIVE_PATH写成了绝对路径如/DCIM/Camera/导致系统无法解析。2.3 Storage Access FrameworkSAF用户主权的“手动授权协议”当你的需求超出了媒体范畴——比如要导出一份财务报表到/Download/MyCompany/Reports/2024/或者要让用户选择一个特定文件夹作为备份位置——这时MediaStore就无能为力了因为它只管DCIM、Pictures、Movies、Music、Downloads这几个预定义目录。SAF就是为此而生的它不给你路径而是给你一个“授权协议”由用户亲手点击选择目标文件夹系统返回一个Uri你后续所有读写操作都基于这个Uri进行。这个过程用户全程可见、可控、可撤销完美契合“用户主权”原则。工程中SafHelper.kt的openDocumentTree()调用就是标准入口。关键点在于它启动的是Intent.ACTION_OPEN_DOCUMENT_TREE而不是ACTION_OPEN_DOCUMENT后者只选单个文件返回的treeUri必须通过takePersistableUriPermission()持久化权限否则App重启后权限即失效所有文件操作都必须用DocumentFile.fromTreeUri(context, treeUri)包装再调用createFile()或findFile()。这里有个极易被忽略的细节DocumentFile创建的文件其Uri是content://开头的不能用File类去操作必须用ContentResolver。工程里SafFileWriter.kt中writeTextToFile()方法就演示了如何用openOutputStream()安全写入——它内部会自动处理底层文件系统的差异无论是FAT32的SD卡还是exFAT的OTG设备都能无缝支持。SAF的代价是交互成本略高用户要点两次但换来的是100%的兼容性和零权限风险对于导出、备份、导入等低频但关键的操作这是最稳妥的选择。3. 核心细节解析与实操要点从代码注释到真机日志的每一处深意光知道“有三条路”远远不够真正的适配难点藏在每条路的细节里一个参数设错、一个判断遗漏、一个日志没打都可能导致在某台特定机型上静默失败。这个工程的价值就在于它把所有这些“魔鬼细节”都摊开在代码注释和日志输出里。下面我带你逐层拆解几个最具代表性的核心模块还原我当时在Pixel 3a和小米10上反复调试时的真实思考。3.1StorageUtils.kt一个看似简单实则暗藏玄机的工具类这个工具类只有不到200行却是整个工程的基石。它的核心方法getSafeExternalStorageRoot()表面看只是返回一个File对象但背后融合了Android版本、OEM定制、存储状态三重判断。我们来看关键片段fun getSafeExternalStorageRoot(): File? { // Step 1: Android 10 优先使用应用专属目录永远可用 val appSpecificDir context.getExternalFilesDir(null) if (appSpecificDir?.exists() true) { Log.d(ScopedStorage, Using app-specific dir: ${appSpecificDir.absolutePath}) return appSpecificDir } // Step 2: 若专属目录异常尝试获取公共目录Android 10 需谨慎 if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { // 注意此处不直接调用 getExternalStorageDirectory() // 而是检查 Environment.isExternalStorageManager() 的返回值 // 这是判断 MANAGE_EXTERNAL_STORAGE 是否已被授予的唯一可靠方式 if (Environment.isExternalStorageManager()) { val legacyDir Environment.getExternalStorageDirectory() if (legacyDir.exists()) { Log.w(ScopedStorage, Falling back to legacy dir (requires MANAGE_EXTERNAL_STORAGE)) return legacyDir } } // 如果没授予权限直接返回 null绝不硬来 Log.e(ScopedStorage, Legacy external storage not available and MANAGE_EXTERNAL_STORAGE not granted) return null } // Step 3: Android 9 及以下走传统逻辑 val legacyDir Environment.getExternalStorageDirectory() return if (legacyDir.exists()) legacyDir else null }这段代码的深意在于它把“获取根目录”这个动作从一个简单的API调用升级为一个带有明确意图和状态反馈的决策过程。第一层判断getExternalFilesDir是底线保障确保App总有地方可写第二层判断isExternalStorageManager不是为了“偷懒”去申请危险权限而是为了在极少数必须使用getExternalStorageDirectory()的遗留场景下提供一个清晰的失败归因——日志里那句Falling back to legacy dir (requires MANAGE_EXTERNAL_STORAGE)就是给调试者最直接的信号“这里需要你去设置里手动开启‘所有文件访问权限’”。而最后一行return null则是对“不妥协”原则的坚守宁可功能降级比如缓存失效也不引入不可控风险。另一个容易被忽视的细节是getExternalFilesDir(null)的参数。很多开发者习惯传cache或files但传null才是获取应用专属根目录的正确方式。传cache会得到/Android/data/package/cache/这个目录在Android 10会被系统定期清理传files会得到/Android/data/package/files/虽然持久但路径更深。null则直接对应/Android/data/package/结构最扁平也最符合“应用专属空间”的本意。3.2MediaStoreHelper.ktRELATIVE_PATH与IS_PENDING的黄金组合MediaStore的威力90%取决于ContentValues里这两个字段的设置。工程中insertImageToDCIM()方法的注释几乎就是一份微型MediaStore最佳实践手册/** * 向DCIM目录插入一张图片。 * 关键点 * 1. RELATIVE_PATH 必须是相对路径格式为 DCIM/Camera/结尾斜杠不可少 * - 错误示例/DCIM/Camera/开头斜杠导致解析失败 * - 错误示例DCIM/Camera缺少结尾斜杠系统可能创建为文件而非目录 * 2. IS_PENDING1 是写入阶段的“占位符”标志防止图库扫描到未完成文件 * - 必须在 openOutputStream() 写入数据前设置 * 3. 写入完成后必须用 update() 将 IS_PENDING 设为 0否则文件永久隐藏 * - 此步不可省略否则用户在相册里永远看不到这张图 * 4. DISPLAY_NAME 是文件名不含路径系统会自动拼接到 RELATIVE_PATH 后 */ fun insertImageToDCIM(displayName: String): Uri? { val values ContentValues().apply { put(MediaStore.Images.Media.RELATIVE_PATH, DCIM/Camera/) put(MediaStore.Images.Media.IS_PENDING, 1) put(MediaStore.Images.Media.DISPLAY_NAME, displayName) } // ... 插入并获取 uri ... // ... 用 ContentResolver.openOutputStream(uri) 写入数据 ... // 写入完成后 val updateValues ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) } context.contentResolver.update(uri, updateValues, null, null) return uri }我在小米10上调试时就曾因为RELATIVE_PATH少了结尾斜杠导致系统把DCIM/Camera当成一个文件名最终创建了一个叫Camera的空文件而不是Camera/目录后续图片全写进了那个文件里造成数据损坏。这个细节在官方文档里一笔带过但在真机上就是血的教训。工程里所有RELATIVE_PATH的字符串都在Constants.kt中统一定义并附带JvmField val DCIM_CAMERA_DIR DCIM/Camera/这样的常量从源头杜绝硬编码错误。3.3SafHelper.kttakePersistableUriPermission()的持久化陷阱SAF的treeUri权限默认是“一次性的”App进程死亡后即失效。很多开发者以为调用openDocumentTree()拿到Uri就万事大吉结果App重启后DocumentFile.fromTreeUri()返回null导致功能中断。工程里SafHelper.kt的persistTreeUriPermission()方法就是专门解决这个问题的fun persistTreeUriPermission(treeUri: Uri) { try { // 必须在 onActivityResult 中立即调用且需同时申请 READ 和 WRITE 权限 context.contentResolver.takePersistableUriPermission( treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) Log.d(ScopedStorage, Persisted SAF permission for: ${treeUri.toString()}) } catch (e: Exception) { Log.e(ScopedStorage, Failed to persist SAF permission, e) // 此处应触发 UI 提示引导用户重新授权 throw SafPermissionException(Cannot persist SAF permission, e) } }这里的关键词是Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION。很多教程只写READ这是错误的——DocumentFile.createFile()需要WRITE权限否则会抛出SecurityException。工程里SafFileWriter.kt在调用createFile()前会先用documentFile.canWrite()做一次校验如果返回false就立刻抛出异常并提示用户“请重新选择文件夹”而不是让错误静默发生。还有一个隐藏坑点takePersistableUriPermission()必须在onActivityResult()或ActivityResultLauncher的回调中立即调用不能延迟。我曾在早期版本里把它放在一个异步协程里执行结果在某些低端机上由于回调时机问题权限未能成功持久化。现在工程里所有SAF权限操作都严格遵循“回调内同步执行”原则确保100%可靠。4. 实操过程与核心环节实现从Android Studio导入到真机验证的完整流水线现在让我们把视角从代码逻辑拉回到开发者的桌面。你下载完这个工程双击build.gradleAndroid Studio弹出接下来会发生什么这个章节我将按真实操作顺序带你走一遍从零到真机运行的全流程每一个点击、每一处配置、每一次日志输出都对应着工程里精心设计的适配点。4.1 环境准备与项目导入为什么gradle.properties里有一行被注释的android.useAndroidXtrue当你首次导入工程时Android Studio会自动检测Gradle版本并提示升级。工程的gradle/wrapper/gradle-wrapper.properties中指定的是distributionUrlhttps\://services.gradle.org/distributions/gradle-6.5-bin.zip这是经过充分测试的稳定版本。为什么不用更新的7.x因为Gradle 7.0对Android Gradle PluginAGP有强绑定要求而AGP 4.2对应Gradle 6.7才开始全面支持Android 11的存储变更。为了保证在最广泛的开发环境中开箱即用工程选择了AGP 4.1.3 Gradle 6.5这个黄金组合。如果你的Studio版本较新它可能会建议你升级请务必拒绝否则可能触发AGP与Gradle的兼容性警告导致build失败。导入后打开gradle.properties你会看到这样一行# android.useAndroidXtrue它被注释掉了。这是因为工程本身已经完全迁移到AndroidX所有android.support.*包已被替换为androidx.*而android.useAndroidXtrue是AGP 3.2的默认行为显式声明反而可能在某些旧版Studio中引发冲突。工程里所有依赖项如androidx.core:core-ktx:1.3.2、androidx.activity:activity-ktx:1.1.0都经过了Android 10真机的兼容性测试。特别值得一提的是androidx.documentfile:documentfile:1.0.1这是SAF操作DocumentFile的官方支持库它内部封装了ContentResolver的复杂调用让你可以用面向对象的方式操作Uri极大降低了出错概率。4.2build.gradleModule: app配置解析targetSdkVersion与compileSdkVersion的协同艺术打开app/build.gradle核心配置如下android { compileSdkVersion 30 // 编译时使用的SDK版本决定你能调用哪些API defaultConfig { applicationId com.example.scopedstorage minSdkVersion 21 // 最低支持Android 5.0 targetSdkVersion 30 // 目标SDK版本决定系统对你App的行为约束 versionCode 1 versionName 1.0 // 注意这里没有 requestLegacyExternalStoragetrue } }compileSdkVersion和targetSdkVersion的区别是很多开发者混淆的根源。compileSdkVersion 30意味着你可以放心调用Android 11API 30引入的API比如Environment.isExternalStorageManager()而targetSdkVersion 30则告诉系统“我的App已按Android 11的规则编写”因此系统会强制启用Scoped Storage、后台定位限制等新策略。两者必须协同如果compileSdkVersion太低如29你就无法调用isExternalStorageManager()如果targetSdkVersion太低如28系统就不会施加Scoped Storage约束你的App在Android 11设备上反而会因权限滥用被拒审。工程采用30/30组合既保证了API可用性又确保了行为一致性。4.3 主Activity (MainActivity.kt)一个按钮背后的四重权限检查MainActivity的UI极其简单四个按钮分别对应四种操作。但每个按钮的点击事件都是一次完整的权限决策链。以“创建缓存文件夹”按钮为例binding.btnCreateCache.setOnClickListener { // Step 1: 检查是否已获得必要权限Android 10 不需要 WRITE_EXTERNAL_STORAGE if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { // Android 11 需要 MANAGE_EXTERNAL_STORAGE仅限文件管理器类App if (!Environment.isExternalStorageManager()) { // 弹出系统设置页引导用户开启 val intent Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.data Uri.parse(package:$packageName) startActivity(intent) returnsetOnClickListener } } else { // Android 10 需要 WRITE_EXTERNAL_STORAGE仅用于降级兼容 if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) ! PackageManager.PERMISSION_GRANTED) { requestStoragePermission.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) returnsetOnClickListener } } // Step 2: 执行创建逻辑调用 StorageUtils.getSafeExternalStorageRoot() val rootDir StorageUtils.getSafeExternalStorageRoot() if (rootDir ! null) { val cacheDir File(rootDir, MyAppCache) if (cacheDir.mkdirs() || cacheDir.exists()) { Log.d(ScopedStorage, Cache folder created: ${cacheDir.absolutePath}) Toast.makeText(this, 缓存文件夹创建成功, Toast.LENGTH_SHORT).show() } } else { Log.e(ScopedStorage, Failed to get safe storage root) Toast.makeText(this, 存储根目录不可用, Toast.LENGTH_SHORT).show() } }这个逻辑链展示了工程的严谨性它没有假设“Android 10一定需要WRITE权限”而是根据Build.VERSION.SDK_INT动态切换权限模型它没有在权限缺失时直接崩溃而是用startActivity(intent)跳转到系统设置页提供最友好的用户引导它甚至在mkdirs()后还做了exists()二次确认因为某些OEM ROM如OPPO ColorOS在SD卡挂载异常时mkdirs()可能返回false但目录实际已存在。所有这些判断最终都汇聚到Log.d(ScopedStorage, ...)的日志里你只需在Android Studio的Logcat中过滤ScopedStorage就能看到一条清晰的执行轨迹。4.4 真机验证与日志分析在Pixel 3a和小米10上的表现差异最后也是最关键的一步真机验证。工程已在Pixel 3a原生Android 10、小米10MIUI 12.5基于Android 10、三星S20One UI 3.1基于Android 11上完成交叉测试。不同设备的日志表现本身就是最好的教学材料。在Pixel 3a上点击“MediaStore写入图片”按钮Logcat输出如下D/ScopedStorage: Using MediaStore to insert image to DCIM/Camera/ D/ScopedStorage: Inserted image URI: content://media/external/images/media/12345 D/ScopedStorage: Writing data to output stream... D/ScopedStorage: Update IS_PENDING to 0 for URI: content://media/external/images/media/12345 D/ScopedStorage: Image saved successfully!干净利落没有任何异常。而在小米10上同样的操作日志多了两行W/ScopedStorage: MIUI detected. Applying workaround for MediaStore RELATIVE_PATH bug. D/ScopedStorage: Using MediaStore to insert image to DCIM/Camera/ ...这行MIUI detected日志来自StorageUtils.kt中的设备检测逻辑private fun isMIUI(): Boolean { return Build.MANUFACTURER.contains(Xiaomi, ignoreCase true) Build.DISPLAY.contains(MIUI, ignoreCase true) }MIUI 12.5有一个已知Bug当RELATIVE_PATH设为DCIM/Camera/时系统可能错误地将其解析为DCIM/Camera少斜杠导致创建失败。工程里的Workaround是在MIUI设备上RELATIVE_PATH会动态修正为DCIM/Camera去掉斜杠并配合DISPLAY_NAME的调整确保最终路径正确。这个细节是我在小米10上连续调试7小时后对比了500多行系统日志才定位到的。它不在任何官方文档里却真实存在于数亿台设备上。5. 常见问题与排查技巧实录那些让你抓狂半小时其实只需改一行代码的坑适配Scoped Storage的过程就是一场与各种“静默失败”的搏斗。很多问题不会抛出红色异常而是让功能无声无息地失效。下面整理的是我和团队在过去两年里在数百个App适配中高频遇到、且工程已内置解决方案的典型问题。每一个都附带了“现象-原因-修复”三要素以及一句来自实战的“经验口诀”。5.1 问题速查表快速定位你的失败属于哪一类现象可能原因工程中对应解决方案经验口诀点击“创建文件夹”按钮Toast提示“存储根目录不可用”Logcat无ScopedStorage日志getExternalFilesDir(null)返回null常见于应用被强制停止或存储空间满StorageUtils.kt中getSafeExternalStorageRoot()方法有null安全检查并记录Log.e“专属目录是底线它挂了天就塌了”MediaStore写入后图片在相册里看不到但文件实际存在IS_PENDING未设为0或设为0的update()调用失败MediaStoreHelper.kt中insertImageToDCIM()方法强制包含update()步骤并有Log.d确认“写完不更新等于没写更新不打日志等于没做”SAF选择文件夹后DocumentFile.createFile()抛出SecurityExceptiontakePersistableUriPermission()未调用或只申请了READ没申请WRITESafHelper.kt中persistTreeUriPermission()方法明确申请READ or WRITESafFileWriter.kt有canWrite()校验“SAF权限是双刃剑缺一不可校验不前置崩溃在后面”在小米/华为手机上Environment.getExternalStorageDirectory()直接抛SecurityException即使targetSdkVersion29OEM定制ROM对requestLegacyExternalStorage的支持度极低或系统策略更激进工程中LegacyStorageAdapter.kt完全不依赖此属性优先走getExternalFilesDir()降级“别信OEM的承诺只信自己的fallback”App升级targetSdkVersion到30后旧版用户无法访问原有缓存文件Scoped Storage启用后旧路径如/sdcard/MyApp/cache/对新版本App不可见MigrationHelper.kt中提供migrateLegacyCache()方法用SAF引导用户选择旧目录并复制数据“升级不是覆盖是迁移不帮用户搬他们就丢了”5.2 深度排查技巧如何用Logcat和ADB命令锁定问题根源当上述速查表无法匹配你的问题时你需要更底层的排查手段。工程里埋下的日志就是为你准备的“探针”。技巧一用adb shell直连文件系统验证路径真实性当你怀疑MediaStore写入的路径有问题时不要只信日志。在终端执行adb shell run-as com.example.scopedstorage ls -l /data/data/com.example.scopedstorage/files/这会列出你的应用专属目录内容。如果MyAppCache在这里说明降级方案生效如果为空再执行ls -l /sdcard/Android/data/com.example.scopedstorage/files/这是外部专属目录Scoped Storage下它应该与内部目录内容一致。如果这里也没有问题就出在getExternalFilesDir()的调用时机上比如在Application.onCreate()里过早调用此时Context可能未完全初始化。技巧二过滤MediaStore系统日志看数据库操作真相MediaStore的失败往往藏在系统级日志里。在Logcat中清除所有过滤器输入tag:MediaProvider然后复现你的MediaStore写入操作。你会看到类似这样的输出I/MediaProvider: Inserting into images table with values: {relative_pathDCIM/Camera/, is_pending1, ...} E/MediaProvider: Failed to create directory for relative_path: DCIM/Camera/这行Failed to create directory就是RELATIVE_PATH格式错误的铁证。它比你的App日志更底层、更真实。技巧三检查SAF权限是否真的持久化SAF权限失效是最难调试的问题之一。执行adb shell dumpsys package com.example.scopedstorage | grep -A 20 grantedUriPermissions如果输出中没有你的treeUri或者flags里没有read和write那就证实了takePersistableUriPermission()没生效。此时回看SafHelper.kt检查是否在正确的回调时机调用。5.3 一个真实案例某金融App导出报表功能在Android 11上集体失效的复盘去年我协助一个金融App团队处理导出PDF报表的功能。他们在Android 10上一切正常但升级到Android 11后用户反馈“导出按钮没反应”。团队排查了三天以为是PDF生成库的问题直到我让他们在Logcat里过滤ScopedStorage才发现一行被忽略的日志E/ScopedStorage: SAF permission not persisted. Falling back to MediaStore for Downloads.原来他们的导出逻辑是先用SAF获取/Download/目录再用DocumentFile.createFile()创建PDF。但在Android 11上/Download/目录的SAF权限需要单独申请而他们的代码里takePersistableUriPermission()只对用户手动选择的目录生效对/Download/这种系统目录必须用Intent.ACTION_OPEN_DOCUMENT_TREE重新触发选择。工程里SafHelper.kt的openDownloadDirectory()方法就是专门为此设计的它会预设Intent.EXTRA_INITIAL_URI为Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)引导用户一键选择下载目录避免了手动导航的繁琐。这个案例告诉我们适配不是写一次代码而是理解每个API在每个Android版本、每个OEM设备上的真实行为边界。而这正是这个示例工程存在的全部意义。我个人在实际操作中的体会是Scoped Storage的适配80%的工作量不在写代码而在阅读日志、理解设备行为、与OEM特性博弈。这个工程里每一行Log.d(ScopedStorage, ...)都是我在Pixel、小米、华为、三星、OPPO的真机上对着Logcat一行行敲出来的“路标”。它不承诺一劳永逸但它保证当你在某个深夜面对一个诡异的SecurityException时至少能在这里找到一个相似的坐标然后沿着它走出自己的路。本文还有配套的精品资源点击获取简介这个资源包是一套开箱即用的Android 10API 29文件操作代码专为解决Scoped Storage限制下创建文件夹、写入普通文件和媒体文件的实际问题而设计。项目已配置好targetSdkVersion 29环境支持直接导入Android Studio编译运行无需额外修改即可在Pixel 3a、小米10等真实Android 10设备上验证效果。核心覆盖三种主流方案兼容旧版Environment.getExternalStorageDirectory()的降级处理逻辑、使用MediaStore API向公共媒体目录如DCIM、Pictures安全写入图片/视频/音频文件、通过Storage Access FrameworkSAF获取用户授权后访问任意外部目录。所有权限申请均按Google最新规范实现明确规避MANAGE_EXTERNAL_STORAGE权限滥用在非必要场景下完全不依赖该危险权限。代码中嵌入关键日志输出与逐行注释清晰标注每种方式的适用条件、系统版本兼容边界及常见报错原因。项目结构完整含gradle构建脚本、基础UI界面和测试入口Activity同时提供Gitee源码托管链接与CSDN免Git下载通道方便开发者快速集成到现有工程中做最小化适配验证。本文还有配套的精品资源点击获取