文件共享 IPC 全面笔记

基于磁盘文件的跨进程通信机制详解

文件读写 流式处理 原子写入 文件锁

目录导航

一、基本原理与核心思想

1 核心原理

文件共享 IPC(进程间通信)的原理非常简单:一个进程将数据序列化后写入文件,另一个进程从同一个文件读取并反序列化

它不依赖 Android 框架特有的 Binder 机制,因此数据量不受 1MB 限制,非常适合传输大文件(视频、图片、大 JSON)。

2 核心要点

媒介:磁盘上的持久化文件,或基于共享内存的临时文件(如 Ashmem)。

无主动通知:文件本身不会主动告诉读取方"数据已就绪",需要额外引入轮询、文件观察者、广播等通知机制。

必须处理并发:多进程同时读写需要文件锁、原子写入等保护手段。

二、普通文件共享:写入与读取

1 基础写入(小数据)
fun writeDataToFile(targetDir: File, fileName: String, data: ByteArray) {
    val tempFile = File(targetDir, "$fileName.tmp")
    val targetFile = File(targetDir, fileName)
    
    // 流式写入(即使 data 不大,也习惯用流)
    FileOutputStream(tempFile).use { it.write(data) }
    
    // 原子替换,防止读到不完整文件
    tempFile.renameTo(targetFile)
}
2 基础读取
fun readDataFromFile(targetDir: File, fileName: String): ByteArray? {
    val targetFile = File(targetDir, fileName)
    return if (targetFile.exists()) targetFile.readBytes() else null
}

三、大文件处理:流式复制,避免 OOM

1 流式复制实现

对于上百 MB 甚至 GB 级别的文件,绝对不能一次性加载到内存,必须使用缓冲区流式搬运。

suspend fun copyStreamToFile(
    inputStream: InputStream,
    targetDir: File,
    fileName: String,
    bufferSize: Int = 8 * 1024
) = withContext(Dispatchers.IO) {
    val tempFile = File(targetDir, "$fileName.tmp")
    val targetFile = File(targetDir, fileName)

    try {
        FileOutputStream(tempFile).use { output ->
            inputStream.use { input ->
                val buffer = ByteArray(bufferSize)
                var bytesRead: Int
                while (input.read(buffer).also { bytesRead = it } != -1) {
                    output.write(buffer, 0, bytesRead)
                }
            }
        }
        tempFile.renameTo(targetFile)  // 原子替换
    } catch (e: Exception) {
        tempFile.delete()
        throw e
    }
}
2 为什么用 tmp + rename?
  • 写入过程中其他进程读到的永远是旧文件或不存在,不会读到半成品
  • renameTo在同文件系统下是原子操作,瞬间完成替换

四、大 JSON / 结构化数据:流式序列化

1 流式写入(使用 Moshi / Gson 的 JsonWriter)

当需要跨进程共享一个巨大的对象列表时,绝不能把所有对象转成内存 List,再一次性序列化成巨大的 JSON 字符串,那会直接 OOM。

应该一边获取数据,一边直接写文件,内存中永远只保留一条数据的大小。

suspend fun writeBooksAsJson(
    booksFlow: Flow,  // 一条一条发射数据
    targetDir: File,
    fileName: String
) = withContext(Dispatchers.IO) {
    val tempFile = File(targetDir, "$fileName.tmp")
    val targetFile = File(targetDir, fileName)

    tempFile.bufferedWriter().use { writer ->
        val jsonWriter = JsonWriter(writer)
        jsonWriter.beginArray()
        booksFlow.collect { book ->
            // 每收到一本书,立刻写入,不堆积在内存
            gson.toJson(book, Book::class.java, jsonWriter)
        }
        jsonWriter.endArray()
    }
    tempFile.renameTo(targetFile)
}
2 Flow 的核心思想
  • Flow<Book>不是列表,而是一条一条 异步发射的数据流
  • emit(book)发一条 → collect收一条 → 处理一条 → 释放内存,再等下一条
  • collect会自动挂起等待,直到所有数据发射完毕才继续执行后续代码
3 流式读取(同样避免全量加载)
suspend fun readBooksFromJson(
    targetDir: File,
    fileName: String
): Flow<Book> = flow {
    val targetFile = File(targetDir, fileName)
    val reader = targetFile.bufferedReader()
    val jsonReader = JsonReader(reader)
    
    jsonReader.beginArray()
    while (jsonReader.hasNext()) {
        var title = ""
        var author = ""
        jsonReader.beginObject()
        while (jsonReader.hasNext()) {
            when (jsonReader.nextName()) {
                "title" -> title = jsonReader.nextString()
                "author" -> author = jsonReader.nextString()
                else -> jsonReader.skipValue()
            }
        }
        jsonReader.endObject()
        emit(Book(title, author))  // 读出一条,发射一条
    }
    jsonReader.endArray()
    jsonReader.close()
}

五、并发控制与文件锁

1 方案一:只允许一个写者

设计上规定只有进程 A 写入,其他进程只读。最简单安全。

2 方案二:文件锁(FileLock)
val raf = RandomAccessFile(file, "rw")
val lock = raf.channel.lock()  // 阻塞,直到获取到锁
try {
    // 执行写操作
} finally {
    lock.release()
    raf.close()
}
3 文件锁关键结论

channel.lock()没有超时参数,会无限等待,因此不适合在主线程或长时间 IO 操作中直接使用。

  • 阻塞的是调用线程,不会直接导致 ANR,但若持有锁时间过长(如大文件写入),会导致业务假死
  • 锁绑定进程:进程退出时所有锁自动释放,因此重启 App 可以 100% 解决死锁
  • 适用场景:本地小文件、快速写入、追加写(日志、埋点)

绝对禁忌:网络下载、大文件、视频等长时间 IO,必须改用 tmp + rename原子写入方案。

六、原子替换与临时文件命名

1 为什么临时文件名要基于目标文件名衍生?
  • 如果所有操作都共用同一个临时文件名 temp.tmp,多个目标文件并发写入时会互相覆盖
  • 每个目标文件拥有独立的临时文件,才能保证并发安全
  • 如果只写一个固定文件,可以直接写死临时文件名,省略 fileName参数
2 原子替换原理

tempFile.renameTo(targetFile)在同一个文件系统下会瞬间完成:旧文件被删除(或覆盖),新文件名直接指向临时文件的数据。整个过程不可分割,读取方不会看到中间状态。

七、存储位置与权限

1 存储位置对比
位置 路径示例 同应用多进程 不同应用 用户可见 需要权限
内部私有存储 /data/data/包名/files 不可见
应用专属外部存储 /sdcard/Android/data/包名/files/ 理论可见,路径深
外部公有存储 /sdcard/ 可见,可删除 需存储权限
ContentProvider+内部存储 content://... 不可见
2 推荐方案
  • 同一应用内多个进程context.getExternalFilesDir(type),无需权限,安全便捷
  • 不同应用间共享→ 使用 FileProvider或自定义 ContentProvider暴露 URI,文件仍保存在内部私有目录,安全且用户不可见

八、通知机制:告诉对方文件已就绪

1 文件系统本身不触发事件,需要主动通知接收方
方式 实现 适用场景
轮询 定时检查文件是否存在/修改时间 简单但耗电,不推荐
FileObserver 在接收进程注册,监听文件事件 高效,但仅限同 UID
广播 写完后发送全局/本地广播,携带文件路径 跨进程,简单易用
ContentObserver 配合 ContentProvider 标准跨应用通知
Messenger / AIDL 回调 写入方主动回调接收方 高性能,双向通信

九、Kotlin 集合基础(辅助理解列表类型)

1 MutableList 接口关系
MutableList(接口:定义增删改查规则)
↑ ↑ ↑
│ │ │
ArrayList LinkedList ArrayDeque
  • MutableList<T>是接口,只能用来声明变量类型,不能直接实例化
  • mutableListOf()是一个工厂函数,等价于 ArrayList(),是创建可变列表的最简洁方式
  • 推荐使用 val list: MutableList<String> = mutableListOf(),符合"面向接口编程"原则

十、优缺点总结与适用场景

优点

  • 数据量无 Binder 1MB 限制,适合大文件
  • 数据持久化,重启后仍存在
  • 原理简单,不依赖复杂框架
  • 天然支持跨语言、跨平台

缺点

  • 磁盘 IO 慢,不适合高频小数据通信
  • 无内置通知机制,需额外实现
  • 并发控制较复杂,易出现竞态
  • 外部存储可能被用户篡改或删除
1 最适用场景
  • 同一应用内多个进程交换大视频、大图片
  • 解耦的生产者-消费者模型(如日志收集、埋点上报)
  • 需要持久化且允许多进程访问的配置文件、缓存数据
2 不适用场景
  • 高频、小数据量、实时性要求高的 IPC → 优先使用 Bundle/Messenger/AIDL
  • 不同应用间需要精细权限控制 → 使用 ContentProvider 而非直接暴露文件

十一、核心思想总结

文件共享 IPC 核心要点