12、数据存储之文件存储:内部存储与外部存储(SD卡)的读写,权限适配(Android 10+ 分区存储)

聊到数据存储,很多新手第一反应就是数据库。其实在日常开发中,文件存储才是用得最频繁的。你想想看,用户头像、日志文件、缓存数据、下载的文档……这些本质上都是文件。

我个人习惯把文件存储分成两类:内部存储外部存储。这两者的区别,说白了就是「私有」和「共享」的区别。内部存储是你的应用自己的小天地,别人碰不了;外部存储则是公共区域,所有应用都能读写——当然,前提是得有权限。

嗯,这里要注意,Android 10 之后,外部存储的玩法彻底变了。以前那种直接拿路径读写的方式,已经被「分区存储」取代。很多老项目升级到 targetSdk 29 以上时,文件读写直接崩掉,我就在项目中踩过这个坑。

Android 文件存储体系 内部存储 (Internal Storage) • 应用私有目录,其他应用无法访问 • 卸载应用时自动删除 • 无需任何权限声明 • 路径: /data/data/包名/ 外部存储 (External Storage) • 公共区域,所有应用可访问 • 卸载应用时文件保留 • 需要 READ/WRITE_EXTERNAL_STORAGE 权限 • 路径: /storage/emulated/0/ Android 10+ 分区存储 (Scoped Storage) • 应用只能访问自己专属的「沙盒目录」和公共媒体集合 • 公共目录: MediaStore (图片/视频/音频) + Documents (文档) • 不再支持直接使用 File API 操作公共目录 • 适配方案: 使用 MediaStore API 或 SAF (存储访问框架)

12.1 内部存储:你的应用专属空间

内部存储是最简单的存储方式。你不需要申请任何权限,直接调用 context.filesDircontext.cacheDir 就能拿到目录。我刚开始做 Android 时,最喜欢用内部存储——省心,不用管权限。

但有个坑:内部存储空间有限,尤其是低端机。我曾经在一个日志收集模块里,忘了清理缓存文件,结果用户手机存储爆了,直接导致应用崩溃。嗯,从那以后我养成了定期清理缓存目录的习惯。

核心方法:
  • context.filesDir — 应用私有文件目录,卸载时删除
  • context.cacheDir — 缓存目录,系统空间不足时可能自动清理
  • context.openFileOutput() / openFileInput() — 直接读写文件
// 写入内部存储
fun writeToInternalStorage(context: Context, fileName: String, content: String) {
    val file = File(context.filesDir, fileName)
    file.writeText(content)
}

// 读取内部存储
fun readFromInternalStorage(context: Context, fileName: String): String {
    val file = File(context.filesDir, fileName)
    return if (file.exists()) file.readText() else ""
}

// 缓存文件示例
fun saveCache(context: Context, data: String) {
    val cacheFile = File(context.cacheDir, "temp_cache.txt")
    cacheFile.writeText(data)
}
💡 个人经验: 内部存储的 cacheDir 适合存放临时文件,比如网络请求的缓存图片。但别把重要数据放进去——系统可能随时清理它。

12.2 外部存储:公共区域的读写

外部存储就是大家常说的「SD 卡」或「内置共享存储」。在 Android 10 之前,你只需要在 Manifest 里声明权限,然后直接拿路径读写就行。

但这里有个历史遗留问题:WRITE_EXTERNAL_STORAGE 权限在 Android 6.0 之后属于危险权限,需要动态申请。很多新手只加了 Manifest 声明,忘了运行时申请,结果一写文件就崩溃。我见过太多这样的案例了。

// Android 10 之前的传统写法(已过时)
val externalDir = Environment.getExternalStorageDirectory()
val file = File(externalDir, "my_file.txt")
file.writeText("Hello")  // 需要 WRITE_EXTERNAL_STORAGE 权限
⚠️ 注意: 从 Android 10(API 29)开始,Environment.getExternalStorageDirectory() 返回的路径不再可写。如果你 targetSdk 设为 29 以上,直接使用 File API 写入公共目录会抛出 FileNotFoundException

12.3 Android 10+ 分区存储:新世界的规则

分区存储(Scoped Storage)是 Android 10 引入的重大变化。说白了,Google 不想让应用随意读写用户的文件了——隐私和安全是第一位的。

我个人觉得这个改动其实挺好的。以前每个应用都在 SD 卡根目录建一堆文件夹,卸载后也不清理,用户的存储空间越来越乱。分区存储强制应用使用自己的「沙盒目录」,卸载时自动清理,干净利落。

12.3.1 应用专属外部存储目录

这是分区存储下最简单的方案。你不需要任何权限,直接使用 context.getExternalFilesDir()context.getExternalCacheDir()。这些目录在应用卸载时会被系统自动删除。

// 应用专属外部存储目录(无需权限)
fun saveToAppSpecificDir(context: Context) {
    val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
    val file = File(dir, "my_document.pdf")
    file.writeBytes(pdfData)
}

// 外部缓存目录
fun saveToExternalCache(context: Context) {
    val cacheDir = context.externalCacheDir
    val file = File(cacheDir, "temp_image.jpg")
    file.writeBytes(imageData)
}
关键点:
  • getExternalFilesDir() — 应用专属文件目录,卸载时删除
  • getExternalCacheDir() — 应用专属缓存目录,卸载时删除
  • 这些目录不需要任何权限声明
  • 其他应用无法访问这些目录(Android 11+ 更严格)

12.3.2 访问公共媒体文件:MediaStore API

如果你需要读写用户的图片、视频、音频文件,就得用 MediaStore API。这是分区存储的核心。

我曾经在做一个相册应用时,被 MediaStore 的 URI 机制折腾了好几天。说白了,你不能再用 File 路径了,得用 ContentResolver 配合 URI 来操作。

// 保存图片到公共相册(Android 10+)
fun saveImageToGallery(context: Context, bitmap: Bitmap, displayName: String) {
    val contentValues = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/MyApp")
    }
    
    val uri = context.contentResolver.insert(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        contentValues
    )
    
    uri?.let {
        context.contentResolver.openOutputStream(it)?.use { outputStream ->
            bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
        }
    }
}

// 读取公共图片
fun loadImageFromGallery(context: Context, uri: Uri): Bitmap? {
    return context.contentResolver.openInputStream(uri)?.use { inputStream ->
        BitmapFactory.decodeStream(inputStream)
    }
}
💡 避坑指南: 使用 MediaStore 时,RELATIVE_PATH 字段在 Android 10 上可能不生效,需要手动拼接路径。Android 11 之后才完全支持。我曾经因为这个 bug 被测试小姐姐追着骂了一周。

12.3.3 访问任意文件:存储访问框架(SAF)

如果你需要访问用户任意目录下的文件(比如文档、下载文件夹),就得用 SAF。它通过系统文件选择器让用户授权,应用拿到 URI 后通过 ContentResolver 读写。

// 启动文件选择器(Activity 中调用)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    type = "*/*"
}
startActivityForResult(intent, REQUEST_CODE_OPEN_FILE)

// 在 onActivityResult 中处理
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == REQUEST_CODE_OPEN_FILE && resultCode == Activity.RESULT_OK) {
        data?.data?.let { uri ->
            // 通过 ContentResolver 读取文件
            contentResolver.openInputStream(uri)?.use { inputStream ->
                val content = inputStream.readBytes()
                // 处理文件内容
            }
        }
    }
}
⚠️ 重要提醒: SAF 获取的 URI 权限是临时的。如果你需要长期访问,记得调用 contentResolver.takePersistableUriPermission() 持久化授权。我一开始没做这个,结果用户重启应用后所有文件都打不开了,被用户骂惨了。

12.4 权限适配总结

说了这么多,我整理了一个表格,方便你对照不同场景下的权限需求:

存储场景 所需权限 API 级别 推荐方案
内部存储 (filesDir/cacheDir) 所有 直接使用 File API
应用专属外部存储 (getExternalFilesDir) 所有 直接使用 File API
公共媒体文件 (图片/视频/音频) 无(Android 10+)
READ/WRITE_EXTERNAL_STORAGE(Android 9-)
Android 10+ MediaStore API
公共目录任意文件 MANAGE_EXTERNAL_STORAGE(Android 11+) Android 11+ SAF 或申请管理权限
旧版兼容(targetSdk < 29) READ/WRITE_EXTERNAL_STORAGE Android 9 及以下 传统 File API + 动态权限
核心原则:
  • 能用内部存储就别用外部存储
  • 能用应用专属目录就别碰公共目录
  • Android 10+ 优先使用 MediaStore 和 SAF
  • 不要滥用 MANAGE_EXTERNAL_STORAGE 权限——Google Play 审核很严

嗯,文件存储这块内容其实不难,但细节很多。尤其是分区存储的适配,稍不注意就会踩坑。我个人建议:新项目直接 targetSdk 33,统一用 MediaStore 和 SAF;老项目升级时,先在测试机上跑一遍所有文件读写场景,别等上线了才发现问题。

记住一句话:文件存储没有银弹。不同的场景选不同的方案,这才是成熟工程师的做法。


公众号:蓝海资料掘金营,微信deep3321