news 2026/6/12 20:31:13

Android 10适配外部存储文件操作的可运行示例工程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android 10适配外部存储文件操作的可运行示例工程

本文还有配套的精品资源,点击获取

简介:这个资源包是一套开箱即用的Android 10(API 29)文件操作代码,专为解决Scoped Storage限制下创建文件夹、写入普通文件和媒体文件的实际问题而设计。项目已配置好targetSdkVersion 29+环境,支持直接导入Android Studio编译运行,无需额外修改即可在Pixel 3a、小米10等真实Android 10设备上验证效果。核心覆盖三种主流方案:兼容旧版Environment.getExternalStorageDirectory()的降级处理逻辑、使用MediaStore API向公共媒体目录(如DCIM、Pictures)安全写入图片/视频/音频文件、通过Storage Access Framework(SAF)获取用户授权后访问任意外部目录。所有权限申请均按Google最新规范实现,明确规避MANAGE_EXTERNAL_STORAGE权限滥用,在非必要场景下完全不依赖该危险权限。代码中嵌入关键日志输出与逐行注释,清晰标注每种方式的适用条件、系统版本兼容边界及常见报错原因。项目结构完整,含gradle构建脚本、基础UI界面和测试入口Activity,同时提供Gitee源码托管链接与CSDN免Git下载通道,方便开发者快速集成到现有工程中做最小化适配验证。

1. 项目概述:为什么这个示例工程值得你花十分钟认真读完

Android 10(API 29)是Android存储权限演进中真正“动刀子”的分水岭。在此之前,Environment.getExternalStorageDirectory()像一把万能钥匙,开发者只要申请了WRITE_EXTERNAL_STORAGE,就能在SD卡根目录下自由创建文件夹、写入配置、保存日志、导出报表——逻辑直白,适配简单。但自Android 10起,Google强制推行Scoped Storage(分区存储),这把钥匙被收走了,取而代之的是三把功能明确、权限收敛、使用门槛各异的“专用钥匙”:MediaStore用于媒体类文件、Storage Access Framework(SAF)用于用户主动授权的任意路径、以及一个被严格限制的“管理员钥匙”——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_PATHIS_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,由上层统一处理(比如提示用户“存储不可用,请检查设置”)。这个设计的关键在于:它把“兼容”变成了一个有层次、有兜底、有反馈的决策树,而不是一个开关。

为什么不用requestLegacyExternalStorage=true作为默认方案?因为这是个临时的、已被标记为deprecated的“创可贴”。从Android 11(API 30)开始,该属性在targetSdkVersion≥30的应用中完全失效;即使你在Android 10上设了它,也仅对部分非媒体文件有效,且无法保证在所有OEM设备上行为一致(比如华为EMUI 11就曾出现过设了true却仍抛出异常的情况)。所以工程里AndroidManifest.xml没有这行配置,所有逻辑都基于Scoped Storage原生规则构建。requestLegacyExternalStorage只保留在build.gradleandroid { 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.ktinsertImageToDCIM()方法完整展示了这一流程。重点看三个参数: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 Framework(SAF):用户主权的“手动授权协议”

当你的需求超出了媒体范畴——比如要导出一份财务报表到/Download/MyCompany/Reports/2024/,或者要让用户选择一个特定文件夹作为备份位置——这时MediaStore就无能为力了,因为它只管DCIMPicturesMoviesMusicDownloads这几个预定义目录。SAF就是为此而生的:它不给你路径,而是给你一个“授权协议”,由用户亲手点击选择目标文件夹,系统返回一个Uri,你后续所有读写操作都基于这个Uri进行。这个过程,用户全程可见、可控、可撤销,完美契合“用户主权”原则。

工程中SafHelper.ktopenDocumentTree()调用就是标准入口。关键点在于:它启动的是Intent.ACTION_OPEN_DOCUMENT_TREE,而不是ACTION_OPEN_DOCUMENT(后者只选单个文件);返回的treeUri必须通过takePersistableUriPermission()持久化权限,否则App重启后权限即失效;所有文件操作都必须用DocumentFile.fromTreeUri(context, treeUri)包装,再调用createFile()findFile()。这里有个极易被忽略的细节:DocumentFile创建的文件,其Uricontent://开头的,不能用File类去操作,必须用ContentResolver。工程里SafFileWriter.ktwriteTextToFile()方法就演示了如何用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_PATHIS_PENDING的黄金组合

MediaStore的威力,90%取决于ContentValues里这两个字段的设置。工程中insertImageToDCIM()方法的注释,几乎就是一份微型MediaStore最佳实践手册:

/** * 向DCIM目录插入一张图片。 * 关键点: * 1. RELATIVE_PATH 必须是相对路径,格式为 "DCIM/Camera/"(结尾斜杠不可少!) * - 错误示例:"/DCIM/Camera/"(开头斜杠导致解析失败) * - 错误示例:"DCIM/Camera"(缺少结尾斜杠,系统可能创建为文件而非目录) * 2. IS_PENDING=1 是写入阶段的“占位符”标志,防止图库扫描到未完成文件 * - 必须在 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.ktpersistTreeUriPermission()方法,就是专门解决这个问题的:

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.gradle,Android Studio弹出,接下来会发生什么?这个章节,我将按真实操作顺序,带你走一遍从零到真机运行的全流程,每一个点击、每一处配置、每一次日志输出,都对应着工程里精心设计的适配点。

4.1 环境准备与项目导入:为什么gradle.properties里有一行被注释的android.useAndroidX=true

当你首次导入工程时,Android Studio会自动检测Gradle版本并提示升级。工程的gradle/wrapper/gradle-wrapper.properties中指定的是distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip,这是经过充分测试的稳定版本。为什么不用更新的7.x?因为Gradle 7.0+对Android Gradle Plugin(AGP)有强绑定要求,而AGP 4.2+(对应Gradle 6.7+)才开始全面支持Android 11的存储变更。为了保证在最广泛的开发环境中开箱即用,工程选择了AGP 4.1.3 + Gradle 6.5这个黄金组合。如果你的Studio版本较新,它可能会建议你升级,请务必拒绝,否则可能触发AGP与Gradle的兼容性警告,导致build失败。

导入后,打开gradle.properties,你会看到这样一行:

# android.useAndroidX=true

它被注释掉了。这是因为工程本身已经完全迁移到AndroidX(所有android.support.*包已被替换为androidx.*),而android.useAndroidX=true是AGP 3.2+的默认行为,显式声明反而可能在某些旧版Studio中引发冲突。工程里所有依赖项,如androidx.core:core-ktx:1.3.2androidx.activity:activity-ktx:1.1.0,都经过了Android 10真机的兼容性测试。特别值得一提的是androidx.documentfile:documentfile:1.0.1,这是SAF操作DocumentFile的官方支持库,它内部封装了ContentResolver的复杂调用,让你可以用面向对象的方式操作Uri,极大降低了出错概率。

4.2build.gradle(Module: app)配置解析:targetSdkVersioncompileSdkVersion的协同艺术

打开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" // 注意:这里没有 requestLegacyExternalStorage=true! } }

compileSdkVersiontargetSdkVersion的区别,是很多开发者混淆的根源。compileSdkVersion 30意味着你可以放心调用Android 11(API 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) return@setOnClickListener } } 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) return@setOnClickListener } } // 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)、小米10(MIUI 12.5,基于Android 10)、三星S20(One 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.ktgetSafeExternalStorageRoot()方法有null安全检查,并记录Log.e“专属目录是底线,它挂了,天就塌了”
MediaStore写入后,图片在相册里看不到,但文件实际存在IS_PENDING未设为0,或设为0的update()调用失败MediaStoreHelper.ktinsertImageToDCIM()方法强制包含update()步骤,并有Log.d确认“写完不更新,等于没写;更新不打日志,等于没做”
SAF选择文件夹后,DocumentFile.createFile()抛出SecurityExceptiontakePersistableUriPermission()未调用,或只申请了READ没申请WRITESafHelper.ktpersistTreeUriPermission()方法明确申请READ or WRITESafFileWriter.ktcanWrite()校验“SAF权限是双刃剑,缺一不可;校验不前置,崩溃在后面”
在小米/华为手机上,Environment.getExternalStorageDirectory()直接抛SecurityException,即使targetSdkVersion=29OEM定制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_path=DCIM/Camera/, is_pending=1, ...} 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里没有readwrite,那就证实了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.ktopenDownloadDirectory()方法,就是专门为此设计的:它会预设Intent.EXTRA_INITIAL_URIEnvironment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),引导用户一键选择下载目录,避免了手动导航的繁琐。这个案例告诉我们:适配不是写一次代码,而是理解每个API在每个Android版本、每个OEM设备上的真实行为边界。而这,正是这个示例工程存在的全部意义。

我个人在实际操作中的体会是:Scoped Storage的适配,80%的工作量不在写代码,而在阅读日志、理解设备行为、与OEM特性博弈。这个工程里每一行Log.d("ScopedStorage", ...),都是我在Pixel、小米、华为、三星、OPPO的真机上,对着Logcat一行行敲出来的“路标”。它不承诺一劳永逸,但它保证,当你在某个深夜面对一个诡异的SecurityException时,至少能在这里找到一个相似的坐标,然后沿着它,走出自己的路。

本文还有配套的精品资源,点击获取

简介:这个资源包是一套开箱即用的Android 10(API 29)文件操作代码,专为解决Scoped Storage限制下创建文件夹、写入普通文件和媒体文件的实际问题而设计。项目已配置好targetSdkVersion 29+环境,支持直接导入Android Studio编译运行,无需额外修改即可在Pixel 3a、小米10等真实Android 10设备上验证效果。核心覆盖三种主流方案:兼容旧版Environment.getExternalStorageDirectory()的降级处理逻辑、使用MediaStore API向公共媒体目录(如DCIM、Pictures)安全写入图片/视频/音频文件、通过Storage Access Framework(SAF)获取用户授权后访问任意外部目录。所有权限申请均按Google最新规范实现,明确规避MANAGE_EXTERNAL_STORAGE权限滥用,在非必要场景下完全不依赖该危险权限。代码中嵌入关键日志输出与逐行注释,清晰标注每种方式的适用条件、系统版本兼容边界及常见报错原因。项目结构完整,含gradle构建脚本、基础UI界面和测试入口Activity,同时提供Gitee源码托管链接与CSDN免Git下载通道,方便开发者快速集成到现有工程中做最小化适配验证。


本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/12 20:20:41

Go/Rust 系统编程:协程调度与异步运行时的性能对比

Go/Rust 系统编程&#xff1a;协程调度与异步运行时的性能对比一、并发模型之争&#xff1a;Goroutine 与 Tokio 的底层博弈 Go 和 Rust 是当前系统编程领域最受关注的两种语言&#xff0c;它们在并发模型上选择了截然不同的路径。Go 的 Goroutine 采用 M:N 调度模型&#xff0…

作者头像 李华
网站建设 2026/6/12 20:16:53

C/C++写的轻量WebSocket双端工程:Windows一键编译,含SSL和内存池

本文还有配套的精品资源&#xff0c;点击获取 简介&#xff1a;一套开箱即用的C/C WebSocket通信实现&#xff0c;服务端与客户端代码齐全&#xff0c;专为Windows平台优化&#xff0c;VS2019及以上可直接加载.sln工程调试。内置OpenSSL 1.1动态库&#xff08;libcrypto-1_1…

作者头像 李华
网站建设 2026/6/12 20:14:01

MSC7104 GPON SoC:一颗芯片如何驱动光纤入户革命

1. 项目概述&#xff1a;一颗芯片驱动的光纤入户革命如果你拆开过家里那个白色或黑色的光猫&#xff08;ONT&#xff09;&#xff0c;可能会对里面那块最大的主芯片感到好奇。在宽带光纤入户&#xff08;FTTH&#xff09;大规模普及的早期&#xff0c;这个盒子里的核心往往是一…

作者头像 李华
网站建设 2026/6/12 20:09:18

远程服务器codex使用本地cc-switch的deepseek api

远程服务器codex使用本地cc-switch的deepseek api 本地配置cc-switch 配置远程服务器codex 本地启动SSH隧穿 本地配置cc-switch 配置远程服务器codex 修改./codex/config.toml: model_provider = "custom" model = "deepseek-v4-flash" model_reasoning…

作者头像 李华
网站建设 2026/6/12 20:08:58

如何用React力导向图快速构建交互式3D网络可视化:完整入门指南

如何用React力导向图快速构建交互式3D网络可视化&#xff1a;完整入门指南 【免费下载链接】react-force-graph React component for 2D, 3D, VR and AR force directed graphs 项目地址: https://gitcode.com/gh_mirrors/re/react-force-graph 你是否曾经面对复杂的网络…

作者头像 李华