在Android开发里,“读写文件”可能是我见过最容易被低估的操作。看起来不就是File、流、路径老三样,可真到线上出问题时,多半就卡在这几件小事上:路径拼错、目录没建、编码不对、用完没关流、主线程读大文件导致卡顿。前阵子我接手某个旧项目,发现同一个App里居然同时存在三四种文件读写写法,有人往内部目录写配置,有人往公共下载目录塞缓存,还有人把几十KB的JSON硬塞进SharedPreferences,结果启动时ANR和文件损坏轮着来。排查到最后,根因不在某一行代码,而是整个团队对Android的存储体系缺乏统一认知。
这篇内容我就把Android里常用的几种文件读写方法完整串一遍:先理清内部存储、外部存储和分区存储的大框架,再分别讲Java IO流、Kotlin扩展函数、SharedPreferences/DataStore、SQLite/Room这几种读写方案的适用场景和坑点,最后给一份可以直接照着用的选型清单。不管是刚入门还是写过一段时间Android,按这个思路整理代码,文件读写基本不会再出幺蛾子。
1. 先搞清楚文件放在哪:内部存储、外部存储与分区存储的限制
很多时候“不知道怎么读文件”是假问题,“不知道该把文件放在哪里”才是真问题。路径决定权限,权限决定你该用哪套API。先把存储地图理清楚,后面所有代码才有意义。
1.1 内部存储:应用专属私有目录的两个入口
内部存储指的是每个应用默认拥有的私有目录,系统会给每个App分配独立的沙盒空间。这个目录不需要任何权限,应用卸载后目录连同内容一起被系统回收。常见的入口有两个:context.getFilesDir()和context.getCacheDir()。前者适合保存用户主动生成的数据,比如草稿、下载的离线包;后者适合存放临时缓存,比如下载中的分片、图片缩略图。
很多初学者会误以为内部存储就是手机ROM,其实不一定,在绝大多数设备上这里指的主要是“被隔离的、只属于当前应用的空间”。你可以把filesDir理解成你的专属工作目录,cacheDir则像一个随时可能被系统清空的临时仓库。系统存储空间不足时,cacheDir里的文件可能被自动清理,所以不能把不可再生的数据放进去,比如用户授权信息、手动保存的重要导出文件。
在这个基础上,Android还提供了两个便捷API:openFileOutput()和openFileInput()。它们直接以文件名操作,路径自动定位到filesDir,省去手动拼接。示例代码如下:
FileOutputStream fos = openFileOutput("notes.txt", Context.MODE_PRIVATE); fos.write("hello".getBytes(StandardCharsets.UTF_8)); fos.close();底层本质上还是File那一套,只是帮你把路径省了。我个人不常用这个API,更喜欢直接用File配合目录拼接,因为项目中经常要处理相对复杂的子目录结构,直接用File更好理解和统一。
1.2 外部存储:专属目录免权限,公共目录要交给系统
外部存储这个词迷惑性很强,它不代表插在手机外面的TF卡,在当前绝大多数手机上它就是机身存储的一个分区。Android把这段空间分成两个层次:应用专属目录getExternalFilesDir()和公共目录getExternalStorageDirectory()。
应用专属外部目录不需要申请任何运行时权限,只要App还在,里面的文件就不会被其他应用随意访问,适合放大文件,比如用户拍摄的照片、下载的离线包、日志分片。需要提醒的是,这个目录会随着应用卸载被删除,和内部存储的命运一样,别指望它在App卸载后还残留。
而公共目录,像Download、DCIM这些,属于真正的共享区域。去读写它们时,Android 10以前要申请WRITE_EXTERNAL_STORAGE权限,Android 10之后还引入了分区存储:就算你拿到了权限,也不能随便按文件夹路径访问别的App创建的文件。正确的姿势是使用MediaStore或存储访问框架(SAF),让系统来帮你管理公共资源的读写。
1.3 分区存储前后,读写权限的思维变化
我把这个变化单独拎出来,是因为它直接影响了很多老代码。Android 10开始,App对外部公共目录不再拥有自由路径访问权,取而代之的是按媒体类型访问:图片、视频、音频可以通过MediaStore的ContentResolver读写,文档类用SAF让用户手动选目录。这些都改变了传统“拼一个绝对路径然后new File”的写法。
如果你的targetSdkVersion是30或更高,项目里还残留着Environment.getExternalStorageDirectory()拼路径的代码,那运行时会异常或直接拿不到文件。所以遇到“明明有文件却读不到”的问题时,先别急着写循环重试,应该去查看目标SDK版本和目标目录类型,再决定是换MediaStore还是改用应用专属目录。
2. 传统Java IO流:简单粗暴但细节决定成败
无论Kotlin扩展函数多方便,底层依然是Java IO。我始终觉得,搞懂IO流会让你写出来的代码更稳,因为你能判断某个封装到底做了什么,出问题时也知道该往哪一层排查。
2.1 FileOutputStream与FileInputStream的读写套路
最基础的文件写入就这么几行:
File file = new File(context.getFilesDir(), "records.txt"); try (FileOutputStream fos = new FileOutputStream(file, true); OutputStreamWriter osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8); BufferedWriter writer = new BufferedWriter(osw)) { writer.write("新记录\n"); }这里有两个关键点:一是构造FileOutputStream时第二个参数true表示追加写,默认是false,也就是覆盖写;二是不要用file.getAbsolutePath()去硬编码到别的地方,直接用目录对象和文件名拼接,最稳妥。读取同理,用FileInputStream包上InputStreamReader和BufferedReader,按行读取文本。
很多人会问为什么要包这么多层。一句话解释:FileOutputStream负责和磁盘交互,OutputStreamWriter负责把字符编码成字节,BufferedWriter负责攒批写入,减少底层IO调用次数。层数多不是炫技,而是每一层都有它的职责,少了任何一层都要在代码里手动处理编码或缓冲问题。
2.2 Buffered系列与字符编码:文本文件乱码的根源
文本文件乱码,多半是编码不一致。比如写入时用了默认编码,读取时又按另一套编码解码。默认编码在不同厂商、不同系统版本上可能不一样,最好的做法永远是在流的构造器里显式指定StandardCharsets.UTF_8。
二进制的场景相对简单:复制图片、压缩包、缓存文件,直接用字节流即可,不要试图用ReadLine去读二进制内容,否则会得到一堆乱码或直接抛异常。判断方法很简单:如果文件本质是文本,就用字符流;如果是图片、音频、序列化对象,就用字节流。这个边界保持清晰,代码不会乱。
我还见过一个隐蔽问题:写入字符串时直接调String.getBytes(),不传编码。在旧设备上默认可能是GBK,在新设备上变成了UTF-8,同一个文件拷来拷去就乱码了。所以代码审查时看到getBytes()没有参数,我一定会要求改成getBytes(StandardCharsets.UTF_8)。
2.3 流没关闭、目录不存在:IO读写最常见的故障排查
实际开发里,我自己遇到过、也帮别人排查过最多的三个问题:
- 文件读出来是空的:大概率是写入流的close在别的分支里没执行,数据还在缓冲区就被程序中断了。
- 明明目录存在却报FileNotFoundException:先把绝对路径打出来看一遍,大概率是拼路径时少了分隔符,或者文件名带非法字符。
- 主线程操作大文件导致卡顿:文件操作是磁盘IO,几MB的文件也可能造成几十毫秒的停顿,在UI线程反复执行会掉帧甚至ANR。
排查顺序我非常固定:先打印路径,看目标文件到底在哪;再检查父目录是否手动创建过;最后看读写是否在IO线程。三步走完,八成问题都浮出水面。这个习惯帮我节省了大量时间,也推荐给所有同学。
3. Kotlin时代更省事的写法:扩展函数、use与协程
如果你的项目已经全面Kotlin,再手动写BufferedReader那一套就有点繁琐了。Kotlin标准库给File写了不少扩展方法,能用尽量用,代码会干净很多。
3.1 readText/writeText/appendText:三行代码完成文本读写
val target = File(context.filesDir, "user_cache.json") target.parentFile?.mkdirs() target.writeText(jsonString, Charsets.UTF_8) val cached = target.readText()writeText默认是覆盖写,写入前会先清空原内容。appendText则是在文件尾部追加。这里有个隐藏细节:如果文件的父目录不存在,writeText会直接抛FileNotFoundException,并不会自动建目录。所以写入文件前最好先调用parentFile?.mkdirs()。
readText会把整个文件内容一次性读入内存,所以只适合配置文件、缓存JSON这类体积不大的文件。它内部就是readBytes().toString(charset),碰到几百MB的文件就别这么干,老老实实流式读写。
3.2 自动关闭资源别硬扛:use{}与Dispatchers.IO
Kotlin标准库的扩展函数内部都用了use扩展。use是Closeable的扩展,可以在代码块执行完后自动调用close,相当于Java的try-with-resources。你自己写流操作时也应养成这个习惯:
File(context.cacheDir, "tmp.bin").outputStream().use { out -> out.write(largeBytes) }配合协程,文件读写的主线程卡顿问题能彻底解决。最简单的写法是这样:
suspend fun saveLargeFile(bytes: ByteArray) { withContext(Dispatchers.IO) { File(context.cacheDir, "large.bin").outputStream().use { it.write(bytes) } } }注意Dispatchers.IO是让代码块在线程池里执行。如果在Dispatchers.Main里调用这个挂起函数,函数内部切到IO线程,UI线程就能立刻返回,用户不会感受到卡顿。但别把大文件的读操作直接写在协程启动块之外,那跟写主线程没区别。
3.3 一个接口JSON缓存落地的完整示例
我经常做的接口缓存长这样:网络请求成功后,把原始JSON字符串写到磁盘;下次进入页面时先读磁盘,没有缓存再请求网络。用Kotlin扩展函数实现,代码非常收敛:
class NewsRepository(private val context: Context) { private val cacheFile get() = File(context.filesDir, "news_cache.json") suspend fun getNews(): List<News> = withContext(Dispatchers.IO) { val cached = runCatching { cacheFile.readText() }.getOrNull() if (!cached.isNullOrBlank()) { parseNews(cached) } else { val fresh = api.fetchNews() cacheFile.writeText(fresh.toString()) parseNews(fresh) } } }这里用runCatching兜底读缓存文件,避免文件损坏时直接崩溃。实际的JSON解析会用一个序列化框架,比如Moshi或Gson,代码思路一致。写这段代码的目的不是让你直接抄,而是演示三个习惯:IO线程切换、异常兜底、先查缓存再走网络。
4. 键值对文件的读写:SharedPreferences还在用,但新项目建议DataStore
严格说SharedPreferences和DataStore都算不上传统意义的文件读写,但它们底层就是XML文件或Proto文件,而且很多开发者在需要“持久化少量数据”时会纠结到底该用哪个,所以必须聊透。
4.1 SharedPreferences的提交方式与多进程大坑
SharedPreferences的使用非常简单:
val prefs = context.getSharedPreferences("app_settings", Context.MODE_PRIVATE) prefs.edit().putString("token", token).apply() val token = prefs.getString("token", null)但坑就在提交方式上。apply()是异步落盘,它先把值更新到内存,然后异步写文件。如果紧接着进程被系统杀死,内存里的最新值可能还没写进磁盘,重启后就丢了。commit()虽然同步落盘,但如果在主线程调用,写入大量键时会明显卡顿。轻量配置用apply没太大问题,但存登录态这种关键数据时,建议你评估一下风险。
SharedPreferences还有个大坑就是多进程。很多老项目尝试用MODE_MULTI_PROCESS实现跨进程读配置,官方后来都承认这个模式不靠谱:多个进程同时写一个XML文件,轻则数据互相覆盖,重则文件损坏解析失败。新项目别再用这个思路,跨进程通信请走ContentProvider或别的IPC方案。
4.2 DataStore的读写模型和落地姿态
DataStore是Jetpack提供的替代方案,分Preferences DataStore和Proto DataStore两种。前者读起来和SharedPreferences很像,但底层基于协程和事务性IO,读写过程更安全。
Preferences DataStore用法如下:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_prefs") val userToken: Flow<String> = context.dataStore.data .map { prefs -> prefs[TOKEN_KEY] ?: "" } suspend fun saveToken(token: String) { context.dataStore.edit { prefs -> prefs[TOKEN_KEY] = token } }DataStore的读取通过Flow暴露,天然支持数据变化的实时观察,UI层可以订阅Flow实现配置变更自动刷新;写入是挂起函数,必须在协程里调用。好处是类型安全、事务性好、能避开SharedPreferences的同步卡顿和半写状态。代价是第一次接触Flow有点学习成本,另外它目前也不能跨进程使用,跨进程场景还是得另想办法。
如果你是在维护老项目,不一定要立刻迁移DataStore。SharedPreferences在小规模、低频写入场景下依然可用,重点是别在关键配置上依赖它,更别把它当成跨进程方案。
5. 当数据变复杂:SQLite与Room才是“文件读写”的另一种答案
很多文件问题最终都会演化成数据库问题。这里的“文件读写”不单指文本和二进制流,还包括结构化的数据表。当数据量上来,单个文件就成了拖后腿的方案。
5.1 该用数据库就别硬写文件:三种存储方式的边界
我把选择逻辑总结成一句话:少量配置用SharedPreferences/DataStore,整块内容用文件读写,需要查询、索引、事务更新的结构化数据用数据库。如果列表数据有几千条,还要按条件筛选,那写一个JSON文件再在内存里遍历过滤,性能和可维护性都会出问题。
SQLite就是Android内置的数据库引擎,一个数据库对应一个文件。这个文件同样可以备份、迁移,也能放到filesDir里。你甚至可以把它看作“有索引结构的文件”,只不过访问方式从流变成了SQL语句。
5.2 Room:从Entity到DAO到Repository的读写流程
直接用SQLiteOpenHelper写SQL,代码很容易膨胀,也不好维护。所以我建议用Room,它是SQLite之上的一层ORM封装。一个最基本的读写流如下:
@Entity(tableName = "articles") data class ArticleEntity( @PrimaryKey val id: String, val title: String, val content: String ) @Dao interface ArticleDao { @Query("SELECT * FROM articles WHERE id = :id") suspend fun getArticle(id: String): ArticleEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(article: ArticleEntity) } @Database(entities = [ArticleEntity::class], version = 1) abstract class AppDatabase : RoomDatabase() { abstract fun articleDao(): ArticleDao }这里几个细节要留意:suspend函数让SQL操作自动在IO线程执行;@Insert(onConflict = OnConflictStrategy.REPLACE)表示主键冲突时直接覆盖,省去先查询再更新的麻烦;@Query返回Flow还能实现UI层的实时刷新。这些都能让数据库读写变得比手写SQL简洁很多。
实际接入时,还需要在Application里初始化数据库实例,然后通过Repository把它暴露给ViewModel。但核心思路就是上面这几步:定义表结构、定义操作接口、创建数据库入口。
5.3 BLOB与大文件的混合方案
有人喜欢把图片、音频的二进制直接存成BLOB字段,我强烈不建议。数据库里的BLOB会让db文件越滚越大,查询变慢,备份变重。正确思路是先把大文件写到磁盘,数据库只存文件路径和元信息:
suspend fun saveArticleWithImages() { val localPath = File(context.filesDir, "images/${article.id}.jpg") localPath.outputStream().use { it.write(imageBytes) } dao.upsert(ArticleEntity(id = article.id, title = article.title, localPath = localPath.absolutePath)) }删除的时候也一样,先删数据库记录,再根据路径删物理文件。如果顺序反了,文件删了但数据库记录还在,会成为无效引用;数据库记录删了而文件没删,则会变成长期占用存储空间的孤儿文件。两条规则:写文件先于写DB,删除记录先于删文件。
6. 实际项目中的方案选型:图片、日志、缓存与数据的组合拳
这节更像是我个人项目的复盘。理论再多,最后都要落到具体场景里才能看出价值。
6.1 常见场景与推荐方案对照表
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 用户设置项 | SharedPreferences 或 DataStore | 更新频繁、结构简单、需要快速读写 |
| 接口JSON缓存 | 文件+Kotlin扩展函数 | 整块内容直接落盘,读取简单 |
| 图片/视频缓存 | 应用专属外部目录 | 体积大,放cacheDir可能被清,放filesDir又占内部空间 |
| 日志记录 | 文件流写入 | 需要追加写,按行记录,支持轮转 |
| 收藏列表/消息列表 | Room | 需要查询排序过滤,结构化程度高 |
| 公共媒体库 | MediaStore | 分区存储下直接路径访问受限 |
这个表格是我平时项目里默认的答案,不会每个项目重新纠结一次。
6.2 组合拳示例:按文件类型分配目录
单个方案往往不够用,一个工程里通常是多种方案配合。我接手的某跨平台系统项目里,目录结构基本是这样:
filesDir/ db/ cache/ media/db里放Room数据库文件,cache里放临时下载的压缩包,media里放用户上传的图片。临时文件放在cacheDir,被系统清理也不心疼;用户主动选择的文件放filesDir,卸载前一直保留;较大的媒体缓存放getExternalFilesDir,既免权限又不占内部私有空间。
写日志时,我会用“按天命名+定期清理”的方式:日志文件名带日期,每天新建一个文件;启动时扫描目录,删除7天前的日志。清理逻辑用File.listFiles()配合lastModified判断,代码不超过10行,却很实用。
6.3 临时文件、命名规范、公共目录的MediaStore细节
最后分享三个经常被忽略的操作细节。第一,凡是“删了也不心疼”的文件,一律丢cacheDir,filesDir只放真正要长期保留的东西。第二,文件名不要用中文、不要带空格,推荐时间戳加UUID的组合,避免路径解析和特殊字符的兼容问题。第三,面向公共媒体目录时,别手动拼路径,用MediaStore的ContentResolver插入记录,系统会帮你分配实际物理路径。
这些细节平时不会出大问题,但它们决定了你的App在不同厂商设备、不同版本上是否稳定。文件读写没有太多高深理论,稳定靠的就是把每个环节的规则定清楚,然后持续遵守。
我自己最深的体会是,文件读写的选型不要追求“最新最炫”,而要追求“团队所有人都能看懂”。早期我也踩过硬往公共目录塞私有文件的坑,后来把存储目录和访问方式统一成一套固定规则,再接手新项目时只要照着规则填答案,基本不会再翻车。开发里很多所谓的“疑难杂症”,最后回头看,多半都是最初没把文件放对地方。