1. 先别急着写代码:搞懂Android的存储体系和路径选择
刚接触Android开发那会儿,我经常被文件读写搞得焦头烂额。明明在桌面上跑Java时一句new File("test.txt")就能搞定的事,到了Android上不是报FileNotFoundException,就是明明写入成功却怎么都找不到文件,或者换个机型就直接崩溃。后来才明白,Android的文件读写之所以让新人一头雾水,根源在于它有一套独特的存储体系和生命周期规则。
这篇内容就是把我这些年用过的、踩过坑的、重新整理过思路的Android文件读写方案,按照不同的场景和目标,系统性地梳理一遍。从最底层的Java IO流,到Context提供的便捷方法,再到Kotlin的扩展函数、协程封装,乃至Android 10之后分区存储下的MediaStore和SAF文件选择器,一次讲透。适合刚入门的初学者建立知识框架,也适合已经写过不少业务代码但想系统梳理的老手查漏补缺。
在往下看任何一种方法之前,你首先得在脑子里建立一张这样的地图——文件到底能放到哪里,每个地方的生命周期和可见性是什么。选错存储位置,后面写得再花哨也是白搭。
| 存储位置 | 获取方式 | 是否需要权限 | 特点与生命周期 |
|---|---|---|---|
| 应用内部存储(私有目录) | context.filesDir/context.cacheDir | 不需要 | 应用卸载即删除,对其他应用不可见,最安全 |
| 应用外部专属目录 | context.getExternalFilesDir() | 不需要 | 位于外部存储但属于应用私有,卸载即删除 |
| 公共媒体目录(图片、视频、音频) | MediaStoreAPI | Android 10以下需权限,10及以上不需要(但受分区存储约束) | 文件长期保留,其他应用可见,属于用户数据 |
| 公共文档/下载目录 | SAF (ACTION_CREATE_DOCUMENT/ACTION_OPEN_DOCUMENT) | 不需要(通过系统选择器授权) | 用户主动授权,可长期访问 |
| 传统公共目录直接路径(已废弃) | /sdcard/xxx硬编码路径 | 需要WRITE_EXTERNAL_STORAGE | Android 10及以上基本失效,强烈不推荐 |
这张表表达的核心信息其实就一句话:优先使用应用专属目录,公共目录必须通过系统推荐的方式访问。理解了路径选择的逻辑,我们再来看具体每种方法怎么写。
2. 最经典的Java IO流读写,以及为什么它依然是基础
2.1 从FileInputStream到FileOutputStream的原始读写
先聊最原生的写法。在Android里读写文件,本质上和标准Java没有区别,FileInputStream负责读,FileOutputStream负责写,二者配合File对象指定路径。
// 写入文件 File file = new File(context.getFilesDir(), "demo.txt"); try (FileOutputStream fos = new FileOutputStream(file)) { String content = "这是要写入的内容"; fos.write(content.getBytes(StandardCharsets.UTF_8)); } catch (IOException e) { e.printStackTrace(); } // 读取文件 File file = new File(context.getFilesDir(), "demo.txt"); try (FileInputStream fis = new FileInputStream(file)) { byte[] buffer = new byte[(int) file.length()]; int readLen = fis.read(buffer); String content = new String(buffer, 0, readLen, StandardCharsets.UTF_8); }这段代码现在看起来很简单,但有几个细节值得说道说道。
第一,一定要指定编码。content.getBytes()不传参的话会用平台默认编码,不同厂商ROM的默认编码可能有差异,统一用UTF-8能避免“在我手机上正常、在他手机上乱码”的经典Bug。
第二,用try-with-resources或者finally关闭流。上面的代码用了try-with-resources,这是Java 7之后的语法,Android Studio的lint也会强制提醒。流不关闭的后果不只是内存泄漏,写入的数据可能还残留在缓冲区里没有真正落盘,文件看起来写成功了,一断电就全没了。
第三,fis.read(buffer)的返回值代表实际读到的字节数,并不一定等于buffer.length,尤其是网络流和管道流场景下,一次read不一定能把整个文件读完。小文件这么写问题不大,但稳妥起见应该循环读取,直到返回-1。
2.2 用BufferedReader和BufferedWriter处理文本文件的痛点
如果读写的是文本、日志、配置这类内容,直接用字节流操作字符串其实很别扭。每次都要手动做byte数组和String的转换,代码难看而且容易错。这时候就该BufferedReader和BufferedWriter登场了。
// 写入文本文件 File file = new File(context.getFilesDir(), "log.txt"); try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { writer.write("第一行日志"); writer.newLine(); writer.write("第二行日志"); } catch (IOException e) { e.printStackTrace(); } // 读取文本文件 File file = new File(context.getFilesDir(), "log.txt"); try (BufferedReader reader = new BufferedReader(new FileReader(file))) { String line; StringBuilder sb = new StringBuilder(); while ((line = reader.readLine()) != null) { sb.append(line).append("\n"); } String content = sb.toString(); }BufferedReader最大的价值在于readLine()方法,逐行读取文本内容非常自然。而且内部默认有8192字节的缓冲区,相比逐个字节读取,性能差距非常明显。在我实测的项目里,读取一个10MB左右的日志文件,用FileInputStream配合单字节循环读可能要一两秒,而BufferedReader基本是毫秒级完成。
有一个老生常谈的坑是:读文件时尽量不要用StringBuilder毫无节制地拼接。如果文件很大(几十MB甚至上百MB),会频繁触发数组扩容复制,内存和性能都很糟糕。这时候要么改用流式逐行处理,要么估算文件大小后给StringBuilder设置初始容量。
2.3 Reader和Writer的编码统一细节
FileReader和FileWriter默认使用平台字符集(也就是所在系统的语言环境对应的编码),这在Android上简直是灾难。同一台设备切换系统语言后,读写同一个文件可能就乱码了。更稳妥的做法是显式指定InputStreamReader和OutputStreamWriter:
// 显式指定UTF-8编码写入 BufferedWriter writer = new BufferedWriter( new OutputStreamWriter( new FileOutputStream(file), StandardCharsets.UTF_8 ) ); // 显式指定UTF-8编码读取 BufferedReader reader = new BufferedReader( new InputStreamReader( new FileInputStream(file), StandardCharsets.UTF_8 ) );这种“层层套娃”的写法看起来很繁琐,但它其实就是Stream流式处理的核心思想:底层字节流负责传输,上层包装字符流负责解码,再上层缓冲流负责效率。你可以在中间任何一层插入自己的逻辑,比如加解密、压缩、过滤等。理解了这个套娃结构,后面遇到任何复杂的IO场景,都能理清思路。
3. 善用Context提供的文件操作API,少写一半样板代码
3.1 openFileInput与openFileOutput的正确使用姿势
FileOutputStream配合context.getFilesDir()手拼路径的方式,已经算是比较标准的写法了。但其实Android的Context本身就封装了一套更便捷的操作API,也就是openFileInput和openFileOutput。这两个方法直接操作的就是应用的内部存储filesDir目录,不需要你自己去拼路径。
// 写入 try (FileOutputStream fos = context.openFileOutput("config.json", Context.MODE_PRIVATE)) { fos.write("{\"name\":\"test\"}".getBytes(StandardCharsets.UTF_8)); } // 读取 try (FileInputStream fis = context.openFileInput("config.json")) { byte[] buffer = new byte[fis.available()]; fis.read(buffer); String content = new String(buffer, StandardCharsets.UTF_8); }注意第二个参数mode,传MODE_PRIVATE表示仅当前应用可读写。以前还有MODE_WORLD_READABLE和MODE_WORLD_WRITEABLE这两个常量,允许其他应用访问你的私有文件,这在早期版本中偶尔有人为了跨进程共享数据而用,但从Android 4.2开始已经被废弃并抛出安全异常。如果你想分享文件给其他应用,千万别再打这两个常量主意,老老实实走FileProvider或者ContentProvider。
openFileOutput还有个特点,每次调用都会以“截断模式”打开文件,也就是说旧内容会被覆盖。如果你想要的是追加写入,需要自己传入一个可追加的FileOutputStream,或者在拿到fos之后手动把指针移到文件末尾。这个API的优势是内部帮你处理了路径拼接和目录创建,少了很多mkdirs()之类的样板代码,适合内部私有文件的读写场景。
3.2 读写cache目录:临时文件的生命周期管理
缓存目录是很多人会忽略的“重要区域”。context.getCacheDir()对应内部存储的缓存目录,context.getExternalCacheDir()对应外部存储的缓存目录。这两个目录适合存放临时生成的文件:下载的临时分片、图片压缩过程中的中间态结果、解压zip时的临时输出等。
// 写入缓存文件 File cacheFile = new File(context.getCacheDir(), "temp_data.tmp"); try (FileOutputStream fos = new FileOutputStream(cacheFile)) { // 写入临时数据 } // 读取缓存文件 File cacheFile = new File(context.getCacheDir(), "temp_data.tmp"); byte[] data = new byte[(int) cacheFile.length()]; try (FileInputStream fis = new FileInputStream(cacheFile)) { fis.read(data); }关于缓存目录,有两点经验想分享。
一是系统在存储空间不足的时候会清空缓存目录,也可能在用户手动清理“清理缓存”时被全部干掉,所以这里绝对不能存放任何不可再生的数据。用户登录态、收藏记录这类东西放这里,清一次就等着被用户骂吧。
二是应用卸载时缓存目录也会被清掉,所以不要指望靠缓存目录做“卸载后重装恢复数据”这种操作。如果你的App有卸载保留需求,得另想办法(比如备份到公共目录或云端)。
3.3 filesDir、数据库、SharedPreferences的边界划分
很多初学者容易把“文件读写”和“数据存储”混为一谈。其实在Android中有四类常见的数据持久化方式,它们的适用场景差异很大:
| 方式 | 数据类型 | 推荐场景 | 不建议的场景 |
|---|---|---|---|
| 文件读写(IO流) | 任意二进制/文本 | 日志、导出文件、图片、多媒体大文件 | 结构化的小数据频繁读写 |
| SharedPreferences | 键值对(XML) | 轻量级配置、用户偏好设置 | 大量数据、敏感数据(明文存储) |
| Room/SQLite | 结构化表格数据 | 业务数据、列表、复杂查询 | 超大文件、纯Key-Value |
| DataStore(Jetpack) | 键值对 / 流式协议 | 替代SharedPreferences的新方案 | 大批量数据 |
这里面很多项目爱犯的毛病是,喜欢把一些临时状态塞进SharedPreferences,高频读写导致严重卡顿,或者把一堆对象序列化后塞进文件里假装数据库。实际上结构化数据应该交给Room管理,文本日志交给文件IO,用户偏好设置交给DataStore,各自干各自擅长的事,系统才不会出问题。
4. Kotlin时代的文件读写:扩展函数、协程与Flow
4.1 少写循环:readText和writeText带来的极简体验
如果你项目已经切到Kotlin了,那再手写BufferedReader循环逐行读就有点没必要了。Kotlin在java.io.File上提供了一批非常实用的扩展函数,几行代码就能完成文件读写。
// 写入文本(覆盖写) val file = File(context.filesDir, "notes.txt") file.writeText("Hello, Kotlin!", Charsets.UTF_8) // 追加写入 file.appendText("下一行内容", Charsets.UTF_8) // 读取全部文本 val content = file.readText(Charsets.UTF_8) // 读取所有行 val lines = file.readLines(Charsets.UTF_8)使用上极其丝滑,对不对?但我要提醒一句:这几个扩展函数虽然写起来爽,它们本质上还是“一次性读取/写入全部内容”的实现,内部会把整个文件加载到内存中。所以,它们只适合处理中小型文件。对于几十MB以上的大文件,或者你明确知道文件可能会非常大,还是老老实实回到BufferedReader逐行处理的路子上,别偷懒。
4.2 use扩展函数:让流的关闭自动发生
Kotlin的use函数是我认为最“优雅”的特性之一。任何实现了Closeable接口的对象都可以调用use,它确保在lambda块执行完毕后自动调用close(),无论正常返回还是抛出异常。
FileOutputStream(file).use { fos -> fos.write(data) } FileInputStream(file).use { fis -> val buffer = ByteArray(file.length().toInt()) fis.read(buffer) }对比Java的try-with-resources,Kotlin的use更简洁一些,而且它有一个奇妙的特性:use会返回lambda内最后一行的值。也就是说你可以写出这样的代码:
val content = FileInputStream(file).use { fis -> val buffer = ByteArray(file.length().toInt()) fis.read(buffer) String(buffer, Charsets.UTF_8) }变量的生命周期和流的关闭完全绑定,逻辑清晰又不会忘关流。我在项目里见到过很多因为忘记关闭流的诡异Bug——明明写入成功了,但内容不落盘、文件被占用无法删除等等,最终都是靠这种“自动关闭”机制解决的。
4.3 协程中的IO调度:不要阻塞主线程的底线思维
前面的所有代码都在讲“怎么写”,但在Android应用的实战中,文件读写天然是耗时操作。尤其是在低端机上读取一个稍大的文件,主线程卡顿是肉眼可见的。于是“放到子线程”就成了必选项。
Kotlin协程给了我们非常优雅的解决方案:
suspend fun readFileContent(file: File): String = withContext(Dispatchers.IO) { file.readText(Charsets.UTF_8) } // 调用方 lifecycleScope.launch { val content = readFileContent(file) // 回到主线程更新UI textView.text = content }这里面有几个容易踩的坑,统一说明一下。
第一,Dispatchers.IO专为IO密集型任务设计,适合文件读写、网络请求、数据库操作。不要用Dispatchers.Default去执行文件读写,它的线程池参数是针对CPU密集型任务调优的。
第二,lifecycleScope只能在Activity或Fragment中使用,因为它绑定生命周期。在ViewModel中应该用viewModelScope。
第三,也是我最想强调的:协程里的文件读写依然要小心大文件。把readText()放进协程,只解决了“不卡UI”的问题,并没有解决“内存吃紧”的问题。100MB的文件你用readText()读,照样会OOM。真正的大文件,请流式处理加流式解析,别指望协程帮你兜底。
5. 图片等多媒体文件读写:Bitmap与MediaStore
5.1 Bitmap的compress与decode:图片文件读写的基础操作
图片文件读写的核心就是BitmapFactory.decodeXxx和Bitmap.compress这对组合。前者负责把文件解析成Bitmap对象,后者负责把Bitmap压缩成指定格式写入文件。
// 从文件读取Bitmap val bitmap = BitmapFactory.decodeFile(file.absolutePath) // 压缩写入文件(覆盖) val file = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "photo.jpg") file.parentFile?.mkdirs() val success = bitmap.compress(Bitmap.CompressFormat.JPEG, 90, FileOutputStream(file))这里想重点提醒两个问题。
第一个是**decodeFile对大图的OOM风险**。很多新手直接拿BitmapFactory.decodeFile去加载一张相机拍摄的高清大图,在低内存手机上几乎必崩。正确做法是先解析inJustDecodeBounds拿到图片尺寸,然后计算采样率,用BitmapFactory.Options.inSampleSize做降采样,最后再真正解码。
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(path, options) val imageWidth = options.outWidth val imageHeight = options.outHeight // 计算采样率,使解码后的图片不超过目标宽高 var sampleSize = 1 while (imageWidth / sampleSize > targetWidth && imageHeight / sampleSize > targetHeight) { sampleSize *= 2 } val finalOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize } val bitmap = BitmapFactory.decodeFile(path, finalOptions)第二个是**compress的压缩质量参数只对JPEG和WebP生效**,对PNG格式无效。PNG是无损压缩,quality参数传多少都不会影响文件大小和画质。很多人想把PNG压缩小一点,怎么调参数文件大小都没变化,就是这个原因。如果要压缩PNG,只能靠减少颜色位数或降低分辨率。
5.2 Android 10分区存储后的MediaStore写法
Android 10(API 29)开始强制分区存储,直接通过/sdcard/DCIM/...这种路径访问文件的方式基本走不通了。公共目录下的媒体文件必须通过MediaStore这个内容提供者来读写。
写入公共图片目录的姿势如下:
// 插入一条记录到MediaStore,获得一个Uri val contentValues = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, "IMG_001.jpg") put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) } val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) // 通过Uri打开输出流写数据 uri?.let { contentResolver.openOutputStream(it)?.use { os -> bitmap.compress(Bitmap.CompressFormat.JPEG, 90, os) } }读取公共图片的方式也变了,不再用绝对路径,而是通过ContentResolver查询:
val projection = arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME ) val cursor = contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, null ) cursor?.use { val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID) while (it.moveToNext()) { val id = it.getLong(idColumn) val uri = ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ) // 用uri加载图片 } }这个api设计背后的意图,大家要品味一下:它把“文件路径”这个概念从应用层抹掉了,把所有媒体文件收编到一个统一的资源索引里。好处是系统可以统一管理、权限隔离,坏处是开发者不能再想当然地拼接路径了。我见过太多老项目在Android 10上崩在FileNotFoundException上,基本都是没适配这套新规则。
如果你要维护的老项目还停留在反射硬编码路径那一套,尽早改造。Android 14及后续版本对公共目录的路径访问限制只会越来越严,越往后拖代价越大。
5.3 读取相册图片的实战:从Uri获取Bitmap的全链路
用户从相册选图这个需求,几乎每个App都有。到了分区存储时代,完整的选图和读取链路变成了这样:
// 1. 通过ActivityResultContracts.OpenDocument或PickVisualMedia触发系统选择器 val launcher = rememberLauncherForActivityResult( ActivityResultContracts.PickVisualMedia() ) { uri: Uri? -> uri?.let { loadBitmapFromUri(it) } } // 2. 从Uri读取Bitmap suspend fun loadBitmapFromUri(uri: Uri): Bitmap? = withContext(Dispatchers.IO) { try { // 先只读取尺寸信息 val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } contentResolver.openInputStream(uri)?.use { stream -> BitmapFactory.decodeStream(stream, null, options) } // 计算采样率 var sampleSize = 1 while (options.outWidth / sampleSize > 2000 && options.outHeight / sampleSize > 2000) { sampleSize *= 2 } val finalOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize } contentResolver.openInputStream(uri)?.use { stream -> BitmapFactory.decodeStream(stream, null, finalOptions) } } catch (e: IOException) { e.printStackTrace() null } }这个过程中最大的坑在于:有些相册应用(尤其是某些品牌手机自带的相册)返回的Uri并不是我们预期的MediaStore路径,也可能是file://开头的文件Uri,甚至可能是content://但指向的是云端的缩略图。所以拿到Uri之后不要想当然地转成FilePath,一定要通过ContentResolver.openInputStream()这种方式统一处理。
6. 外部存储与SD卡的现代读写:SAF框架与FileProvider
6.1 存储访问框架SAF:为什么它才是访问任意目录的正道
Android 4.4开始引入了Storage Access Framework(SAF),到Android 11之后它基本成了访问公共目录的“唯一正道”。SAF的设计思路是:应用不直接请求整个存储空间的读写权限,而是通过系统文件选择器,让用户挑一个具体的文件或目录授权给你。
这种方式有几个很明显的优点:用户有完全的控制权、不需要申请敏感的存储权限、适配所有厂商ROM不会出幺蛾子。代价就是你的应用只能操作“用户选中的那一个文件或目录”,不能像以前那样随意扫全盘。
SAF的核心是两个Intent Action:
// 创建一个新文件并写入 val createIntent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "text/plain" putExtra(Intent.EXTRA_TITLE, "export.txt") } startActivityForResult(createIntent, REQ_CODE_CREATE) // 打开已有文件读取 val openIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "*/*" } startActivityForResult(openIntent, REQ_CODE_OPEN)拿到系统返回的Uri之后,通过ContentResolver.openOutputStream(uri)和ContentResolver.openInputStream(uri)读写即可。因为走的是Uri授权,不需要任何存储权限声明。这个方案的稳定性在所有Android版本上都很出色,是我目前对外部文件操作的首选方案。
6.2 在root等特殊场景下使用File路径直读
虽然写了这么多现代方案,但也不得不承认一个事实:在调试、自动化测试、以及处理外部传入的file://Uri这些场景下,直接用File和绝对路径依然是最省事的。
比如读取从蓝牙收到的文件、从某个第三方App通过file://共享过来的文件,这些文件的Uri其实就是一个本地路径,Uri.getPath()拿到的就是一个可以直接new File的绝对路径。这种情况没必要强行转成content来解析,直接File操作反而更顺利。
再比如特权应用或系统级应用,在绕过分区存储的情况下,直接用绝对路径访问外部存储仍然可行。但这需要额外的权限声明和平台签名支持,普通应用不要尝试。毕竟这条路的代价是:过不了Google Play的审核、在部分OEM ROM上会被系统杀掉,维护成本极高。
我个人在正常业务代码中的建议是:能用SAF就用SAF,能走MediaStore就走MediaStore,File直读只保留在缓存目录或应用私有目录里。这不是保守,这是用无数个线上crash换来的教训。
6.3 通过FileProvider共享文件给其他App
刚才提到私有目录下的文件其他应用直接访问不到,但业务场景里经常需要把私有目录的文件分享出去,比如“分享一张刚生成的分享图到微信”。这时候就需要FileProvider了。
FileProvider的核心原理是:系统给你签发一个临时的content URI,其他应用通过这个临时Uri在你的App进程内读取文件,底层是跨进程Binder通信,而不是直接访问路径。
使用步骤分三段:
第一步,在AndroidManifest.xml里注册:
<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="my_files" path="." /> <cache-path name="my_cache" path="." /> <external-files-path name="my_external_files" path="." /> </paths>第三步,生成content Uri并授予临时权限:
val shareUri = FileProvider.getUriForFile(context, "${packageName}.fileprovider", file) val intent = Intent(Intent.ACTION_SEND).apply { type = "image/jpeg" putExtra(Intent.EXTRA_STREAM, shareUri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }这里有个细节经常被忽略:grantUriPermissions="true"只能保证Provider有这个能力,真正让接收方能够读文件,还是要在Intent上加上FLAG_GRANT_READ_URI_PERMISSION这个flag。很多新人漏了这个flag,导致目标App收到Uri后打开就报错,还怎么排查都查不出原因。
7. 不同方案的性能对比与选型指南
7.1 文本、图片、数据库:什么场景用什么方案
经历了这么多API的演进,我总结出一个相对成熟的选型思路,可以当作决策参考:
| 业务场景 | 推荐方案 | 不推荐方案 | 核心原因 |
|---|---|---|---|
| 小文本配置(<1MB) | readText/writeText | 原生IO流手写循环 | 简洁、可读性好 |
| 大文本日志(几十MB) | BufferedReader逐行处理 | readText()一次性读入 | 避免内存溢出 |
| 应用私有大文件 | openFileOutput/Input+ 协程IO | 直接写公共目录 | 不需要权限、安全 |
| 图片文件 | Bitmap+MediaStore | 硬编码DCIM路径 | 兼容分区存储 |
| 用户选中的文件 | SAF (ACTION_OPEN_DOCUMENT) | getExternalStorageDirectory | 无需权限、兼容性佳 |
| 结构化数据 | Room/DataStore | JSON文件 | 查询效率高、类型安全 |
| 分享文件 | FileProvider | 直接传file://Uri | 安全、跨应用可见 |
从我参与过的多个项目来看,大部分“文件读写出问题”的case,都不是代码写错,而是方案选错。比如在需要频繁随机读写的场景用了普通文件,结果越写越慢;在需要数据完整性的场景用了截断写模式,一崩溃数据就全没了;在需要跨进程共享的场景里硬写私有目录,然后百思不解为什么对方打不开。
7.2 从性能角度看普通IO、缓冲IO与内存映射的取舍
最后聊一个进阶话题:性能调优时,File的普通读写、Buffered系列、MappedByteBuffer这三者的差异在哪里。
普通IO(比如直接FileOutputStream.write)每次调用都会触发系统调用,在小文件写入上问题不大,但文件越大、写入次数越多,系统调用开销越明显。
Buffered IO(BufferedOutputStream/BufferedReader)引入了内存缓冲区,把多次小写入合并成一次大写入,大幅降低系统调用次数。对于文本类和大多数业务场景,这个方案已经足够快,而且代码简单,是最推荐的默认选择。
MappedByteBuffer(内存映射)把文件直接映射到进程地址空间,读写操作变成了内存操作,性能理论上是最高的。但它有个很尴尬的坑:写入的数据不会立刻落盘,需要手动force(),而且处理不当容易在文件关闭时抛异常。我在一个日志SDK中实测过它的写入性能,确实比缓冲IO快不少,但代码复杂度和崩溃风险也高了一个量级。
给个实用性的结论:追求稳定首选缓冲IO,追求极限性能再考虑内存映射,但一定做好异常兜底。真正的瓶颈往往不是IO本身,而是你前后的编解码、序列化操作。先优化算法,再优化IO,顺序千万别反。
8. 常见问题速查与避坑手册
8.1 文件明明写成功,为什么读取为空或乱码?
这个问题的高低频程度,几乎每个开发者都遇到过。排查思路按顺序来:
- 检查是否关闭了输出流。写入内容还在缓冲区,没有flush到磁盘。调用
flush()或close()之前数据不保证落盘。 - 检查编码是否统一。写入用了
UTF-8,读取用了默认编码或GBK,中文必然乱码。全链路统一点可以根除。 - 检查是否写入到了错误的路径。
context.filesDir、cacheDir、externalFilesDir是不同的位置,检查是否拿混了。 - 检查是否被覆盖了。
FileOutputStream(file)默认截断写,如果同时有两个线程在写同一个文件,内容可能会互相覆盖。
8.2 权限已声明,为什么还是Permission Denied?
Android 6.0以后权限分成了安装时权限和运行时权限。WRITE_EXTERNAL_STORAGE属于危险权限,除了AndroidManifest.xml里声明,还要在代码中动态申请:
val permission = Manifest.permission.WRITE_EXTERNAL_STORAGE if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(activity, arrayOf(permission), REQ_CODE) }但这里要特别强调:从Android 11开始,申请WRITE_EXTERNAL_STORAGE权限几乎不会授予了,除非你的应用满足特定条件(比如文件管理类应用)。所以如果你的目标版本是Android 11+,还在用这个权限去写公共目录,等你的不仅是Permission Denied的异常,还有Google Play的下架警告。
8.3 卸载重装后文件还在吗?
很多人被这个问题绕晕,我直接给你一个结果表:
| 存放位置 | 卸载后 | 清缓存后 | 说明 |
|---|---|---|---|
filesDir | 删除 | 保留 | 卸载后清除 |
cacheDir | 删除 | 清除 | 系统或用户可随时清理 |
externalFilesDir | 删除 | 保留 | 卸载后清除 |
| 公共媒体目录(MediaStore) | 保留 | 保留 | 属于用户数据 |
| SAF授权的文件 | 保留 | 保留 | 属于用户数据 |
一句话归纳:想要数据在卸载后保留,必须放进公共目录;不想要数据乱七八糟留在用户手机上,就放进应用专属目录。
8.4 大文件读写导致内存溢出的常见解法
读写大文件时遇到OutOfMemoryError,几乎都是因为一次性把整个文件加载进内存。在Android这个内存受限的环境下,大文件必须分块处理。思路无非三种:
- 流式读取:用
BufferedReader逐行读,不全部加载进内存。 - 分块读写:固定8KB或16KB的缓冲区,用
while循环读一块写一块。 - 采样解码:图片文件用
inSampleSize降采样,不把原图全量加载。
我在处理一个视频缓存下载的功能时,用8KB缓冲区分块写入,下载100MB文件内存占用始终稳定在几十MB左右。如果当时图省事,直接把整个字节数组toByteArray()再写文件,可能早就OOM了。
9. 最后说点我的个人体会
文件读写这件事,单看任何一个API都觉得很简单,真正难的是“在合适的场景选择合适的方法”。我把Android文件读写分了几个时代去理解:最初级的File加IO流,到Context封装的便捷API,再到Kotlin的函数式扩展,然后是分区存储后的MediaStore与SAF,每个阶段的演进背后都是安全性和易用性的平衡。
如果你现在还处在一个“只会用File硬拼路径”的阶段,我的建议是:先从应用私有目录开始练手,熟练filesDir和cacheDir的读写,然后逐步迁移到MediaStore和SAF这两个新体系。同时一定把“编码统一、关闭流、不阻塞主线程、不加载超大文件”这几条底线刻在脑子里,所有文件读写代码都遵守这四条红线,基本可以避开我当年踩过的80%的坑。
这套方案没有放之四海而皆准的最佳答案,只有最适合你当前场景的选择。多在我的代码规范化工具链里沉淀一些封装好的IO函数,让团队里的同学少重复造轮子,你会感谢这些前期投资的。