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也只能读取媒体文件。如果你想访问其他应用的文件,必须用SAF(Storage 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中注册FileProvider:
<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>第二步,在res/xml目录下创建file_paths.xml,声明哪些目录可以共享:
<paths> <files-path name="internal_files" path="." /> <cache-path name="internal_cache" path="." /> <external-files-path name="external_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,或者让用户选择一个目录来保存文件,就需要用到SAF(Storage 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或SAF |
| FileNotFoundException: /storage/emulated/0/xxx | 分区存储限制 | 使用应用私有目录或MediaStore |
| SecurityException: Permission Denial | FileProvider未授权 | 添加FLAG_GRANT_READ_URI_PERMISSION |
| IllegalArgumentException: Failed to find configured root | file_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或23,targetSdk是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,如果没设置requestLegacyExternalStorage=true,直接路径访问也会失败。但如果你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升级了才去改存储代码,平时就按最新规范写,升级的时候会轻松很多。