news 2026/10/16 1:12:11

Android文件读写全攻略:从Java IO到MediaStore与SAF

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android文件读写全攻略:从Java IO到MediaStore与SAF

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()不需要位于外部存储但属于应用私有,卸载即删除
公共媒体目录(图片、视频、音频)MediaStoreAPIAndroid 10以下需权限,10及以上不需要(但受分区存储约束)文件长期保留,其他应用可见,属于用户数据
公共文档/下载目录SAF (ACTION_CREATE_DOCUMENT/ACTION_OPEN_DOCUMENT)不需要(通过系统选择器授权)用户主动授权,可长期访问
传统公共目录直接路径(已废弃)/sdcard/xxx硬编码路径需要WRITE_EXTERNAL_STORAGEAndroid 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/DataStoreJSON文件查询效率高、类型安全
分享文件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函数,让团队里的同学少重复造轮子,你会感谢这些前期投资的。

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

双串联锂电池主动均衡设计与工程落地要点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:10:43

2024 Go面试高频题精讲:Goroutine与Channel实战指南

简介&#xff1a;2024年最新Go语言面试30题及答案讲解&#xff0c;聚焦Go核心考点&#xff0c;适合求职面试者、在校学生以及希望系统巩固Go基础的开发者。题目覆盖Go语言特点、Goroutine轻量级线程、Channel通道通信、select多路复用、接口与空接口、错误处理、defer执行顺序及…

作者头像 李华
网站建设 2026/10/16 1:10:29

金融数据竞赛小白实战复盘:从逻辑回归到LightGBM的提分之路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:09:52

Qoder Skill功能实战:从配置到进阶,打造AI编程上下文管理利器

1. 为什么我会花一整周去啃Qoder的Skill功能先说结论&#xff1a;Qoder的Skill功能是我最近半年里遇到的、少数让我觉得"这工具设计者是真的自己写代码"的功能模块。这东西表面上是一个"给AI预处理指令"的配置面板&#xff0c;但实际用下来&#xff0c;它解…

作者头像 李华
网站建设 2026/10/16 1:09:52

HarmonyOS 6.0企业安全办公App实战:窗口增强与数据防泄露指南

企业安全办公App这东西&#xff0c;真不是做个登录页面、存个密码就算“安全”了。我见过太多团队&#xff0c;移动端跑得挺好&#xff0c;一上PC就翻车&#xff1a;没有多窗口思维&#xff0c;窗口一最大化就把整个屏幕霸占&#xff1b;没有防截屏意识&#xff0c;核心数据被人…

作者头像 李华
网站建设 2026/10/16 1:09:47

Android自绘滚轮实现省市区三级联动城市选择器

简介&#xff1a;面向Android应用开发者的城市选择器组件&#xff0c;适合在电商、旅游、生活服务等场景中快速接入城市选择功能。该组件界面模仿美团&#xff0c;并借助高德地图API完成定位与城市数据展示&#xff0c;兼具美观度和实用性。压缩包内共285个文件&#xff0c;大小…

作者头像 李华