资讯动态

Android文件读写全攻略:从存储体系到方案选型

发布时间:2026/10/16 7:32:52 来源:尧图企业网站定制
在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还提供了两个便捷APIopenFileOutput()和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.IOKotlin标准库的扩展函数内部都用了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(): ListNews 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: DataStorePreferences by preferencesDataStore(name user_prefs) val userToken: FlowString 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细节最后分享三个经常被忽略的操作细节。第一凡是“删了也不心疼”的文件一律丢cacheDirfilesDir只放真正要长期保留的东西。第二文件名不要用中文、不要带空格推荐时间戳加UUID的组合避免路径解析和特殊字符的兼容问题。第三面向公共媒体目录时别手动拼路径用MediaStore的ContentResolver插入记录系统会帮你分配实际物理路径。这些细节平时不会出大问题但它们决定了你的App在不同厂商设备、不同版本上是否稳定。文件读写没有太多高深理论稳定靠的就是把每个环节的规则定清楚然后持续遵守。我自己最深的体会是文件读写的选型不要追求“最新最炫”而要追求“团队所有人都能看懂”。早期我也踩过硬往公共目录塞私有文件的坑后来把存储目录和访问方式统一成一套固定规则再接手新项目时只要照着规则填答案基本不会再翻车。开发里很多所谓的“疑难杂症”最后回头看多半都是最初没把文件放对地方。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑