
1. 为什么Android存储路径这么让人头疼做过Android开发的人都有一个共同感受存储路径这件事看起来简单真上手了才发现到处都是坑。内部存储、外部存储、私有目录、公共目录、缓存目录、分区存储、Scoped Storage、MediaStore、SAF……光这些名词就够让人晕头转向了。更麻烦的是Android从早期版本一路演进到现在的Android 14、15每一代都在调整存储相关的API和权限模型很多网上搜到的方案放到新版本上直接跑不通。这篇文章要解决的问题很明确把Android应用文件存储的各种路径和方法做一次系统梳理让你搞清楚什么场景该用什么目录、什么API以及不同Android版本之间的差异怎么处理。不管你是刚接触Android开发的新手还是做了几年但存储这块一直“能用就行”的老手这篇内容都值得过一遍。我会从路径分类讲起然后逐个拆解内部存储、外部存储、缓存目录、公共媒体目录、SAF等方案的具体用法和适用场景最后给出一个实战中可以直接参考的选型决策表。先打个预防针这篇文章不会只告诉你“调这个API就行”而是会解释清楚每个路径背后的设计意图和权限模型。因为存储这块最大的坑就是——你照着某个博客写了代码在模拟器上跑得好好的一到真机就崩了或者一到Android 13就报权限错误。根因就是没搞明白“为什么”。2. Android存储体系的核心概念拆解2.1 内部存储与外部存储的本质区别很多人第一次听到“外部存储”这个词会以为是SD卡之类的可插拔存储。实际上在Android的语境里外部存储指的是“可以被其他应用访问的共享存储区域”它可能是手机内置的闪存分区也可能是物理SD卡系统并不保证它一定可插拔。内部存储则是应用私有的其他应用无法访问除非root。内部存储的路径通常是/data/data/包名/或者/data/user/0/包名/这个目录下的文件只有当前应用自己能读写。卸载应用时内部存储中的数据会被系统自动清除。外部存储又分为两块一块是应用私有的外部存储路径类似/storage/emulated/0/Android/data/包名/其他应用在Android 11之前可以通过路径访问Android 11之后被限制另一块是公共外部存储比如/storage/emulated/0/Download/、/storage/emulated/0/Pictures/这些是所有应用共享的区域。理解这个分类是后面所有内容的基础。你可以这样记内部存储是“自己家的保险柜”外部私有存储是“自己租的仓库”公共外部存储是“公共广场”。保险柜最安全但空间有限仓库空间大但别人也可能瞄一眼公共广场谁都能来但你要遵守规则。2.2 各版本存储权限模型的演进逻辑Android的存储权限模型经历了几个重要阶段每个阶段的变化都直接影响你的代码怎么写。Android 6.0之前存储权限是安装时一次性授予的用户没得选。6.0引入了运行时权限READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE需要在运行时动态申请。Android 10引入了分区存储Scoped Storage这是最大的一次变革。应用默认只能访问自己的私有目录和通过MediaStore插入的媒体文件不能直接通过路径访问公共目录下的其他文件。不过Android 10留了一个requestLegacyExternalStorage的开关可以临时退回旧模式。Android 11进一步收紧了权限。WRITE_EXTERNAL_STORAGE基本失效了READ_EXTERNAL_STORAGE也只能读取媒体文件。如果你想访问其他应用的文件必须用SAFStorage Access Framework让用户手动授权。同时引入了MANAGE_EXTERNAL_STORAGE权限但这个权限权限级别很高上架应用市场需要额外审核。Android 13又把媒体权限拆分了READ_EXTERNAL_STORAGE被细分为READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO按需申请。这一路看下来趋势非常明显Google在逐步收紧存储权限推动应用使用私有目录和MediaStore减少对公共存储的直接路径访问。你写代码的时候如果还停留在“申请个WRITE_EXTERNAL_STORAGE就到处写文件”的思路迟早要出问题。2.3 路径API的选型原则Android提供了多种获取路径的API常见的有getFilesDir()、getCacheDir()、getExternalFilesDir()、getExternalCacheDir()、Environment.getExternalStorageDirectory()、Environment.getExternalStoragePublicDirectory()等。选型的时候问自己三个问题这个文件是应用私有的还是需要共享的是临时缓存还是需要持久保存的目标用户的最低Android版本是多少应用私有的持久文件用getFilesDir()应用私有的缓存文件用getCacheDir()需要放在外部存储但仍然是应用私有的用getExternalFilesDir()需要共享给其他应用的媒体文件用MediaStore需要让用户选择保存位置的文档类文件用SAF。Environment.getExternalStorageDirectory()这个API从Android 10开始就废弃了不要再用了。Environment.getExternalStoragePublicDirectory()虽然还能用但推荐用MediaStore替代。3. 内部存储路径与实操方法3.1 getFilesDir与getCacheDir的正确用法getFilesDir()返回的是应用内部存储的文件目录路径类似/data/user/0/包名/files/。这个目录下的文件会一直保留直到应用被卸载或者你手动删除。适合存放用户配置、数据库文件、重要的持久化数据。getCacheDir()返回的是内部缓存目录路径类似/data/user/0/包名/cache/。系统在存储空间不足时会自动清理这个目录下的文件所以你绝对不能把重要的、不可恢复的数据放在这里。适合存放图片缓存、网络请求缓存、临时解压文件等。这两个目录都不需要任何权限直接读写就行。代码示例// 写入内部存储文件 val file File(context.filesDir, user_config.json) file.writeText(jsonString) // 读取内部存储文件 val content File(context.filesDir, user_config.json).readText() // 写入缓存文件 val cacheFile File(context.cacheDir, temp_image.jpg) cacheFile.writeBytes(imageBytes)有一个容易忽略的点getFilesDir()和getCacheDir()返回的目录在应用安装后就已经存在了你不需要手动创建。但如果你要在它们下面创建子目录需要自己调mkdirs()。注意内部存储的空间是有限的不同设备上可能只有几百MB到几GB。不要把大文件往getFilesDir()里塞否则用户手机存储一紧张系统可能直接杀你的应用。3.2 内部存储的文件共享与FileProvider内部存储的文件默认其他应用无法访问。但有时候你需要把内部存储的文件分享给其他应用比如调用系统相机拍照后把图片分享出去或者用第三方应用打开一个PDF。这时候就需要用到FileProvider。FileProvider是AndroidX提供的一个ContentProvider子类它可以把你的内部文件通过content://URI的形式暴露给其他应用而不需要把文件复制到公共目录。配置步骤分三步。第一步在AndroidManifest.xml中注册FileProviderprovider 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 / /provider第二步在res/xml目录下创建file_paths.xml声明哪些目录可以共享paths files-path nameinternal_files path. / cache-path nameinternal_cache path. / external-files-path nameexternal_files path. / /paths第三步在代码中生成content URI并授权val file File(context.filesDir, share_doc.pdf) val uri FileProvider.getUriForFile( context, ${context.packageName}.fileprovider, file ) val intent Intent(Intent.ACTION_SEND).apply { type application/pdf putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(intent, 分享文件))这里的关键点是FLAG_GRANT_READ_URI_PERMISSION没有这个标志位接收方应用没有权限读取你的文件会直接抛SecurityException。实操心得file_paths.xml中的name属性可以随便取但path属性要写对。.表示整个目录images/表示子目录。如果你写错了path运行时会报“Failed to find configured root”的错误排查起来很烦。建议一开始就把所有可能用到的目录都配上。4. 外部存储路径与版本适配4.1 getExternalFilesDir的适用场景与限制getExternalFilesDir()返回的是应用私有的外部存储目录路径类似/storage/emulated/0/Android/data/包名/files/。这个目录不需要任何存储权限就可以读写从Android 4.4开始就是这样。它和内部存储的区别在于第一它在外部存储上空间通常更大第二用户可以通过文件管理器看到这个目录虽然Android 11之后其他应用不能直接访问了第三卸载应用时这个目录也会被清除。getExternalFilesDir()可以传一个参数来指定子目录类型比如Environment.DIRECTORY_PICTURES、Environment.DIRECTORY_DOWNLOADS等。传null则返回files根目录。// 获取外部私有图片目录 val picturesDir context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) // 路径类似 /storage/emulated/0/Android/data/包名/files/Pictures // 获取外部私有缓存目录 val externalCache context.externalCacheDir // 路径类似 /storage/emulated/0/Android/data/包名/cache这里有一个重要的判断使用前必须检查外部存储是否可用。因为外部存储可能是SD卡用户可能已经拔掉了。val state Environment.getExternalStorageState() if (state Environment.MEDIA_MOUNTED) { // 外部存储可用可以读写 val dir context.getExternalFilesDir(null) } else { // 外部存储不可用降级到内部存储 val dir context.filesDir }注意虽然getExternalFilesDir()不需要权限但Android 11之后其他应用无法通过路径直接访问这个目录了。如果你之前依赖其他应用来读取你放在这里的文件需要改用FileProvider或MediaStore。4.2 公共目录访问与MediaStore实战公共目录才是真正意义上的“共享存储”。你拍的照片要出现在系统相册里你下载的音乐要能被音乐播放器扫描到这些都需要把文件写到公共目录。在Android 10之前你可以直接通过Environment.getExternalStoragePublicDirectory()获取公共目录路径然后直接写文件。但Android 10之后直接路径访问被限制必须通过MediaStore API来操作。MediaStore的核心思路是你不直接操作文件路径而是通过ContentResolver向MediaStore数据库插入一条记录系统会返回一个URI你通过这个URI的OutputStream来写数据。以保存一张图片到公共相册为例val values ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, my_photo.jpg) 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 resolver context.contentResolver val uri resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) uri?.let { resolver.openOutputStream(it)?.use { outputStream - outputStream.write(bitmapBytes) } if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { values.clear() values.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(it, values, null, null) } }这里有几个关键点。RELATIVE_PATH指定了文件在公共目录下的相对路径比如Pictures/MyApp这样你的图片就会出现在相册的MyApp文件夹里。IS_PENDING标志位在Android 10及以上必须设置写入时设为1写完后设为0这样其他应用在你写完之前看不到这个文件避免读到半截文件。读取公共媒体文件也需要通过MediaStore查询val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME ) val cursor resolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, ${MediaStore.Images.Media.DATE_ADDED} DESC ) cursor?.use { while (it.moveToNext()) { val id it.getLong(it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val name it.getString(it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) val contentUri ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ) // 使用contentUri加载图片 } }权限方面Android 13及以上读取图片需要READ_MEDIA_IMAGES读取视频需要READ_MEDIA_VIDEO读取音频需要READ_MEDIA_AUDIO。Android 12及以下统一用READ_EXTERNAL_STORAGE。写入公共媒体文件在Android 10及以上不需要写权限。4.3 SAF框架处理文档类文件MediaStore主要针对媒体文件图片、视频、音频但如果你要处理的是文档类文件比如PDF、Word、Excel或者让用户选择一个目录来保存文件就需要用到SAFStorage Access Framework。SAF的核心是ACTION_OPEN_DOCUMENT、ACTION_CREATE_DOCUMENT和ACTION_OPEN_DOCUMENT_TREE这三个Intent。让用户选择一个文件来读取val intent Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type application/pdf } startActivityForResult(intent, REQUEST_CODE_OPEN)让用户选择一个位置来保存新文件val intent Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type application/pdf putExtra(Intent.EXTRA_TITLE, report.pdf) } startActivityForResult(intent, REQUEST_CODE_CREATE)让用户选择一个目录比如让用户指定一个备份目录val intent Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) startActivityForResult(intent, REQUEST_CODE_TREE)拿到URI之后通过contentResolver.openInputStream(uri)和openOutputStream(uri)来读写。SAF返回的URI是持久化的你可以调contentResolver.takePersistableUriPermission(uri, flags)来长期保留访问权限这样应用重启后还能访问。实操心得SAF的URI在不同设备上表现可能不一致有些设备返回的URI在重启后会失效。如果你需要长期访问某个目录一定要在onActivityResult中调用takePersistableUriPermission并且把URI字符串保存到数据库或SharedPreferences里。另外SAF的文件操作性能比直接路径访问要低不适合高频读写场景。5. 缓存管理与文件清理策略5.1 缓存目录的选择与自动清理机制Android提供了两个缓存目录内部缓存getCacheDir()和外部缓存getExternalCacheDir()。两者的区别在于内部缓存一定可用外部缓存可能因为存储不可用而返回null。系统在存储空间不足时会自动清理缓存目录但清理的时机和范围是不确定的。你可能会遇到这种情况用户反馈说昨天还能看的离线内容今天打开就没了。一查发现是系统把缓存清了。所以缓存策略的核心原则是缓存可以丢丢了之后要能重新获取。如果你的业务逻辑依赖缓存必须存在那就不应该放在缓存目录而应该放在files目录。// 计算缓存大小 fun getCacheSize(dir: File): Long { var size 0L dir.listFiles()?.forEach { file - size if (file.isDirectory) getCacheSize(file) else file.length() } return size } // 清理缓存 fun clearCache(dir: File) { dir.listFiles()?.forEach { file - if (file.isDirectory) { clearCache(file) } else { file.delete() } } }5.2 缓存淘汰策略的落地实现系统自动清理缓存是被动的你还需要主动管理缓存。常见的策略有LRU最近最少使用、按时间过期、按总大小限制等。一个实用的做法是给缓存目录设一个总大小上限比如50MB每次写入新缓存前检查当前大小超过上限就按最后修改时间删除最旧的文件。class CacheManager(private val cacheDir: File, private val maxSize: Long 50 * 1024 * 1024) { fun trimCache() { val files cacheDir.listFiles()?.sortedBy { it.lastModified() } ?: return var totalSize files.sumOf { it.length() } for (file in files) { if (totalSize maxSize) break totalSize - file.length() file.delete() } } fun put(key: String, data: ByteArray) { trimCache() File(cacheDir, key).writeBytes(data) } fun get(key: String): ByteArray? { val file File(cacheDir, key) return if (file.exists()) file.readBytes() else null } }这个实现虽然简单但在大多数场景下够用了。如果你需要更精细的控制可以考虑引入DiskLruCache这样的成熟库。注意清理缓存时不要在主线程做文件遍历和删除操作文件多了会卡顿。放到子线程或者用协程处理。6. 常见问题与排查技巧实录6.1 权限相关报错与解决方案存储这块最常见的报错就是权限问题。我整理了一个速查表报错信息常见原因解决方案Permission Denial: reading MediaProvider没有申请READ_MEDIA_*权限Android 13申请细分媒体权限EACCES: permission denied直接路径访问公共目录改用MediaStore或SAFFileNotFoundException: /storage/emulated/0/xxx分区存储限制使用应用私有目录或MediaStoreSecurityException: Permission DenialFileProvider未授权添加FLAG_GRANT_READ_URI_PERMISSIONIllegalArgumentException: Failed to find configured rootfile_paths.xml配置错误检查path属性是否正确6.2 路径获取返回null的处理getExternalFilesDir()和getExternalCacheDir()在外部存储不可用时返回null。很多崩溃就是因为没做null检查。val dir context.getExternalFilesDir(null) ?: context.filesDir这行代码的意思是外部存储可用就用外部私有目录不可用就降级到内部存储。简单但有效。还有一种情况是getExternalFilesDir()返回的目录存在但不可写。这通常发生在外部存储挂载状态异常时。建议在写入前做一次可写检查fun isDirWritable(dir: File): Boolean { return try { val testFile File(dir, .write_test) testFile.createNewFile() testFile.delete() true } catch (e: IOException) { false } }6.3 文件URI暴露导致的崩溃Android 7.0之后直接通过file://URI分享文件会触发FileUriExposedException。必须用FileProvider生成content://URI。这个崩溃在调用系统相机、系统分享、第三方应用打开文件时特别常见。解决方案就是前面讲的FileProvider配置。还有一个容易忽略的点Intent.EXTRA_STREAM传URI时如果目标应用没有读取权限会静默失败或者报SecurityException。记得加FLAG_GRANT_READ_URI_PERMISSION。6.4 大文件读写的性能优化在存储路径上读写大文件时有几个性能要点。第一不要在主线程序列化大文件用协程或线程池。第二用BufferedInputStream/BufferedOutputStream包装流减少IO次数。第三如果只是复制文件用FileChannel.transferTo()比流复制快很多。// 高效文件复制 fun copyFile(src: File, dst: File) { FileInputStream(src).channel.use { inputChannel - FileOutputStream(dst).channel.use { outputChannel - inputChannel.transferTo(0, inputChannel.size(), outputChannel) } } }实操心得在Android 10以上的设备上通过MediaStore写入大视频文件时建议分片写入每写一段更新一次IS_PENDING状态。一次性写入几百MB的文件如果中途失败整个文件就废了。分片写入可以在失败时保留已写入的部分下次续传。7. 存储方案选型决策与实战建议7.1 按场景选路径的决策表把前面讲的内容浓缩成一张决策表实际开发中直接对照着选场景推荐方案需要权限卸载后保留用户配置、数据库getFilesDir()否否临时缓存getCacheDir()否否大文件缓存getExternalCacheDir()否否应用私有媒体文件getExternalFilesDir()否否保存图片到相册MediaStore.Images否Android 10是保存视频到相册MediaStore.Video否Android 10是下载文档让用户选择位置SAF ACTION_CREATE_DOCUMENT否是备份到用户指定目录SAF ACTION_OPEN_DOCUMENT_TREE否是读取公共图片MediaStore.Images READ_MEDIA_IMAGES是-7.2 跨版本兼容的代码组织方式实际项目中你的minSdk可能是21或23targetSdk是34。这意味着你需要同时处理新旧两套存储模型。我的建议是把存储操作封装成一个工具类内部根据Build.VERSION.SDK_INT做分支。对外暴露统一的接口调用方不需要关心版本差异。object StorageHelper { fun saveImage(context: Context, bitmap: Bitmap, name: String): Uri? { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { saveViaMediaStore(context, bitmap, name) } else { saveViaDirectPath(context, bitmap, name) } } private fun saveViaMediaStore(context: Context, bitmap: Bitmap, name: String): Uri? { // MediaStore方式 } private fun saveViaDirectPath(context: Context, bitmap: Bitmap, name: String): Uri? { // 直接路径方式需要WRITE_EXTERNAL_STORAGE权限 } }这样组织的好处是当未来Android版本再次调整存储API时你只需要改StorageHelper内部的实现业务代码不用动。7.3 几个容易踩的坑和避坑建议第一个坑在Android 10设备上即使你targetSdk是29如果没设置requestLegacyExternalStoragetrue直接路径访问也会失败。但如果你targetSdk升到30这个标志位就失效了。所以不要依赖这个过渡方案尽早迁移到MediaStore。第二个坑MediaStore的RELATIVE_PATH在不同厂商设备上表现可能不一致。有些设备会把文件放到Pictures/MyApp有些会放到Pictures根目录。如果你的业务依赖精确路径需要做兼容处理。第三个坑SAF选择的目录URI在应用卸载后会失效。如果你需要长期备份建议把文件复制到应用私有目录或者提示用户重新授权。第四个坑getExternalFilesDir()在Android 11上虽然其他应用不能直接访问但用户通过USB连接电脑还是能看到Android/data目录下的文件。如果你有敏感数据不要放在这里。第五个坑清理缓存时不要删除正在使用的文件。比如你正在播放一个缓存视频同时触发了缓存清理视频文件被删了就会播放失败。建议给正在使用的文件加锁或者标记。7.4 面向未来的存储适配建议从Android的演进趋势来看Google在持续推动应用减少对文件系统的直接访问转向基于URI的ContentResolver模型。未来可能会有更严格的限制。我的建议是新项目直接按Android 10的分区存储模型来设计不要考虑旧版本的兼容包袱。如果必须兼容旧版本把旧版本的支持代码隔离在独立的模块里方便未来移除。另外多关注MediaStore的新API。比如Android 11引入了MediaStore.createWriteRequest()和MediaStore.createDeleteRequest()可以让应用批量请求写入或删除媒体文件的权限用户确认后就能操作。这比一个个文件插入效率高很多。// Android 11 批量删除媒体文件 val uris listOf(uri1, uri2, uri3) val pendingIntent MediaStore.createDeleteRequest(contentResolver, uris) // 启动这个Intent让用户确认存储这块没有一劳永逸的方案Android每次大版本更新都可能带来变化。但只要你理解了内部存储、外部存储、MediaStore、SAF这几个核心概念的设计意图不管API怎么变你都能快速找到对应的方案。我在实际项目中最大的体会是不要等到targetSdk升级了才去改存储代码平时就按最新规范写升级的时候会轻松很多。