Android 进程间传递大数据 笔记

7 阅读12分钟

在安卓开发中,进程间传递大数据是一个很经典的问题。你可能会首先想到用 IntentAIDL,但很快就会发现 TransactionTooLargeException 这个老朋友。这背后是 Binder 机制的 1MB 限制在起作用 。

为什么不能直接用 Binder 传大数据?

根本原因:Binder 是 Android 的核心 IPC 机制,但它设计之初就不是为传输大量数据而生的 。每次通过 Binder 传输数据时,数据会被写入内核中的一个共享缓冲区,这个缓冲区的大小通常被限制在 1MB 左右

这里有几个关键点需要理解:

  1. 共享缓冲区:这个 1MB 的缓冲区是所有正在进行的 Binder 事务共享的。如果你的单个事务就占用了 900KB,留给其他事务的空间就很小了,容易导致系统不稳定 。
  2. 多次拷贝:Binder 传输涉及数据从用户区 → 内核区 → 目标进程用户区的拷贝过程,大数据量会带来显著的内存和性能开销 。
  3. 触发异常:一旦传输数据超过可用缓冲区大小,系统就会抛出 TransactionTooLargeException,导致应用崩溃 。

所以,当需要传递超过几百 KB 的数据(比如一张图片、一段视频或大量列表数据)时,我们就必须跳出 Binder 的思维定式,采用更合适的架构。

大数据 IPC 的解决方案全景

下面是几种主流且经过实战检验的方案,你可以根据具体场景选择:

方案原理优点缺点适用场景
文件共享 + FileProvider将数据写入文件,然后通过 FileProvider 将文件的 Uri 传递给另一个进程,接收方再读取文件 。简单可靠,几乎没有大小限制(受限于存储空间),适用于任何类型的数据。涉及磁盘 I/O,速度相对较慢;需要处理好并发读写和文件清理。单次、大批量数据传递,如拍照/选取图片后传递给另一个 Activity/App。
ContentProvider将大数据存储在数据库或文件中,通过 ContentProvider 封装数据访问接口,接收方通过 ContentResolverUri 按需查询数据 。官方推荐的数据共享方式,提供标准的 CRUD 接口,支持权限控制,适合结构化数据的共享 。需要实现 ContentProvider,代码量稍多;适合有数据访问模式的场景,而非简单的一次性传递。跨应用共享结构化数据集,如通讯录应用提供联系人给其他应用查询。
SharedMemory (匿名共享内存)通过 MemoryFileSharedMemory API 在进程间共享同一块物理内存 。性能极高,数据零拷贝,直接在内存中读写,适合高频、大数据流传输 。只能传递字节数组,需要自己处理同步(如用 Mutex)和序列化 ;实现相对复杂。高性能、持续的数据流,如传递实时音视频数据、传感器数据流。
Socket 通信 (LocalSocket)使用 Unix Domain Socket 在同一个系统的进程间进行通信 。稳定可靠,全双工,基于流式传输,没有 Binder 的 1MB 限制,适合长连接。需要自己定义通信协议,编程模型比 Binder 复杂。需要持续、双向、大数据量交互,如进程间传输文件流。

实战选型建议

面对不同的业务场景,我会这样选择:

  1. 场景一:Activity/Fragment 之间传大图或文件 首选方案:文件共享 + FileProvider。这是最简单且最安全的方式。例如,拍照后,相机 App 会将原图保存到文件,然后通过 Intent 传递这个文件的 Uri(通过 FileProvider 生成)给你的 App。你拿到 Uri 后再去解析读取。这完美避开了 Binder 的大小限制。

  2. 场景二:两个进程需要持续传输大量数据流(如实时视频帧) 首选方案:SharedMemory 或 Socket

    • 如果追求极致的性能和低延迟,且数据量巨大,用 SharedMemory 。例如,Camera2 的某些实现就可以通过 ImageReader 配合 SharedMemory 来传递图像数据。
    • 如果需要一个可靠、有序的字节流,用 LocalSocket。它就像在本地建了一个管道,非常灵活。
  3. 场景三:跨应用共享一个数据库或复杂的数据集 首选方案:ContentProvider。这是 Android 的标准数据共享契约 。例如,音乐播放器 App 可以通过 ContentProvider 向其他 App 提供当前的播放列表,调用方可以像查询数据库一样获取数据,而无需关心数据的物理存储。

总结

在 Android 中处理进程间大数据传递,核心思想就是 "绕过 Binder 缓冲区"

  • 对于单次大包,把它存到外部存储,然后只传一个指向它的 Uri
  • 对于持续数据流,开辟一块共享内存或建立一个 Socket 通道,让数据直接在进程间流动。

文件共享 + FileProvider

这个方案的核心逻辑可以概括为三步:把数据存成文件,把文件的访问权限包装成安全的 Uri,最后把这个 Uri 传给别的进程。下面我结合这十年的实战经验,把具体实现步骤拆解给你。

📝 第一步:在清单文件中注册 FileProvider

首先,需要在 AndroidManifest.xml<application> 标签内注册 FileProvider。这里有几个关键属性需要配置:

<application
    ... >
    ...
    <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>
</application>
  • android:name:直接使用 AndroidX 的 FileProvider 类即可。
  • android:authorities:这是一个唯一标识符,通常使用 应用包名 + 自定义后缀 来保证全局唯一性(例如 com.example.myapp.fileprovider)。这个字符串在生成 Uri 时会用到。
  • android:exported="false":必须设为 false,表示这个 FileProvider 本身不对外暴露,保证了安全性。
  • android:grantUriPermissions="true":允许我们临时授予其他应用访问这个 Uri 的权限。
  • <meta-data>:指向一个 XML 文件,这个文件用来声明我们允许共享哪些目录路径

📂 第二步:配置可共享的目录 (res/xml/file_paths.xml)

接着,在 res/xml/ 目录下创建 file_paths.xml 文件。这个文件定义了哪些路径下的文件可以通过 FileProvider 生成 Uri。你可以根据文件存放的位置选择对应的标签。

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 内部存储空间中的 files/ 目录,对应 Context.getFilesDir() -->
    <files-path name="my_files" path="." />

    <!-- 内部存储空间中的 cache/ 目录,对应 Context.getCacheDir() -->
    <cache-path name="my_cache" path="." />

    <!-- 外部存储的根目录,谨慎使用,会暴露很多文件,一般不推荐 -->
    <!-- <external-path name="external_files" path="." /> -->

    <!-- 外部存储中应用的私有目录,对应 Context.getExternalFilesDir(null) -->
    <external-files-path name="my_external_files" path="." />

    <!-- 外部存储中应用的缓存目录,对应 Context.getExternalCacheDir() -->
    <external-cache-path name="my_external_cache" path="." />
</paths>
  • name 属性:只是一个路径别名,可以随便取,它会成为最终 content:// Uri 路径的一部分。
  • path 属性:指定共享目录下的具体子路径。"." 代表共享整个根目录。为了安全,建议指定到具体的子目录,例如 path="Download/",而不要直接共享整个根目录。

🚀 第三步:在代码中生成并发送 Uri

这是最关键的一步。假设我们要把应用内部存储 files 目录下的一个 PDF 文件分享出去。

// 1. 准备要分享的文件
val file = File(context.filesDir, "reports/2025/summary.pdf")
// 确保文件存在,如果不存在先创建或写入内容
if (!file.exists()) {
    file.parentFile?.mkdirs()
    file.createNewFile()
    // ... 写入文件内容
}

// 2. 使用 FileProvider 生成安全的 content:// Uri
//    注意:第二个参数 authorities 必须和 Manifest 中定义的一致
val contentUri: Uri = FileProvider.getUriForFile(
    context,
    "${context.packageName}.fileprovider", // 例如 "com.example.myapp.fileprovider"
    file
)

// 3. 创建 Intent 并设置数据和类型
val intent = Intent(Intent.ACTION_VIEW).apply {
    setDataAndType(contentUri, "application/pdf")
    // 4. 授予接收方临时读取权限,这是安全性的关键!
    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

// 5. 启动 Intent
try {
    startActivity(Intent.createChooser(intent, "选择应用打开 PDF"))
} catch (e: ActivityNotFoundException) {
    // 处理没有合适应用打开的情况
    Toast.makeText(context, "没有找到可以打开 PDF 的应用", Toast.LENGTH_SHORT).show()
}

这段代码的核心在于 FileProvider.getUriForFile()addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)。前者将 file:///data/user/0/com.example.myapp/files/reports/2025/summary.pdf 这样的私有路径,转换成类似 content://com.example.myapp.fileprovider/my_files/reports/2025/summary.pdf 的安全 Uri。后者则临时授权给目标应用读取这个 Uri 指向的文件内容。

📥 第四步:在接收方进程中访问文件

接收方进程(比如一个 PDF 阅读器)从 Intentdata 中获取到这个 content:// Uri 后,并不能直接使用 File 对象访问,因为它没有文件路径的权限。正确的做法是通过 ContentResolver 来打开输入流。

// 在接收方应用的 Activity 中
val uri: Uri? = intent.data
if (uri != null) {
    try {
        // 通过 ContentResolver 打开输入流
        contentResolver.openInputStream(uri)?.use { inputStream ->
            // 现在可以像操作普通 InputStream 一样读取文件内容了
            // 例如,将文件复制到自己的私有目录,或者直接解析
            val destFile = File(cacheDir, "temp.pdf")
            FileOutputStream(destFile).use { outputStream ->
                inputStream.copyTo(outputStream)
            }
            // ... 然后打开 destFile
        }
    } catch (e: FileNotFoundException) {
        // 处理文件未找到异常
    }
}

ContentResolver.openInputStream() 是系统提供的标准方式,它会在底层处理好权限验证和数据传输。

💡 实战要点与避坑指南

  1. 路径配置必须精准file_paths.xml 中配置的路径必须与你实际文件存放的路径相匹配。例如,文件存在 getExternalFilesDir(null)/Download/ 下,你在 XML 中就应该用 <external-files-path name="name" path="Download/" />。如果路径不匹配,getUriForFile 会直接报错。
  2. 不要忘记添加权限 Flag:很多新手容易忘记 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION),导致目标应用打开文件时崩溃。这个 Flag 是临时授权的关键,离开了它,content:// Uri 对别的应用来说就是无效的。
  3. 处理 Android 10+ 的分区存储:从 Android 10 开始,即使有了 FileProvider,直接访问外部存储的公共目录(如 Environment.getExternalStoragePublicDirectory())也受到了更多限制。如果要在这些场景下共享文件,更推荐使用 MediaStore API 或者系统的文件选择器(Intent.ACTION_OPEN_DOCUMENT)让用户主动选择文件。
  4. 文件清理:文件共享出去后,特别是临时文件(比如拍照后存到缓存目录的图片),记得在不再需要时及时删除,避免占用用户存储空间。

SharedMemory

在 Android 中,使用 SharedMemory 传递实时音视频数据,确实是一个典型的高性能 IPC 场景。核心思路是:在共享内存上构建一个“环形缓冲区(Ring Buffer)”,并辅以跨进程的同步机制,来实现一个生产者-消费者模型。这样既能利用共享内存的零拷贝优势,又能解决流式数据的同步问题。

这里有两种主流的实现路径,一种是自己动手打造,另一种是走 Android 官方提供的“捷径”。我将从实战角度为你详细拆解这两种方案。

💡 方案一:基于 SharedMemory 的硬核方案

这个方案让你拥有完全的控制权,适合对性能、延迟有极致要求,或者数据格式特殊的场景。

核心架构
  • 数据结构:在共享内存中开辟一块连续空间,作为环形缓冲区。通常会配合一个用于同步的控制结构(如写指针/读指针)。
  • 同步机制:由于 SharedMemory 本身不提供同步,你需要自行实现。常见方案有:
    • 文件锁:使用 FileLock 对同一个锁文件加锁,序列化写操作,实现简单但性能稍低(微秒级)。
    • NativeHandle + 跨进程锁:通过 Bundle 传递 NativeHandle,在 Native 层使用信号量(Semaphore)或互斥锁(Mutex),性能最高,但实现复杂。
    • 无锁设计:适用于单生产者-单消费者场景。写操作只更新写指针,读操作只更新读指针,通过原子操作保证指针安全,性能极致。
实现流程
  1. 创建共享内存:在服务端(生产者)使用 SharedMemory.create() 创建一块足够大的内存。
  2. 初始化控制区:在共享内存头部,定义好数据结构和同步控制字段(如writeIndex)。
  3. 传递 ParcelFileDescriptor:通过 Binder (如 AIDL) 将 SharedMemoryParcelFileDescriptor 发送给客户端(消费者)。
  4. 映射内存:客户端接收到 ParcelFileDescriptor 后,通过 SharedMemory 的构造函数映射到自己的进程空间。
  5. 读写数据
    • 生产者
      // 伪代码:生产者写入数据
      fun writeFrame(data: ByteArray) {
          // 1. 获取锁 (例如 flock)
          lock.acquire()
          // 2. 从控制区读取当前写位置 writePos
          // 3. 计算下一块可用空间
          // 4. 将数据拷贝到共享内存的 writePos 处
          sharedMemory.writeBytes(data, 0, writePos, data.size)
          // 5. 更新控制区的写位置
          writeIndex += data.size
          // 6. 释放锁
          lock.release()
      }
      
    • 消费者
      // 伪代码:消费者读取数据 (可在循环中轮询)
      fun readFrames() {
          while (isRunning) {
              // 1. (可选) 获取读锁,或直接读取控制区的写位置
              val currentWriteIndex = readWriteIndexFromControl()
              // 2. 如果发现有新数据 (currentWriteIndex > lastReadIndex)
              if (currentWriteIndex > lastReadIndex) {
                  // 3. 从 lastReadIndex 处读取数据
                  val data = ByteArray(currentWriteIndex - lastReadIndex)
                  sharedMemory.readBytes(data, 0, lastReadIndex, data.size)
                  // 4. 处理数据 (如渲染、编码)
                  processData(data)
                  // 5. 更新本地读指针
                  lastReadIndex = currentWriteIndex
              } else {
                  // 6. 短暂休眠,避免空转 (例如 Thread.sleep(1))
              }
          }
      }
      
优缺点
  • 优点:性能天花板,完全可控,不引入额外依赖。
  • 缺点:实现复杂,容易出错(特别是内存同步和边界条件),需要处理大量的细节。

✨ 方案二:基于 ImageReader/ImageWriter 的捷径方案

这是 Android 官方针对图像数据流提供的现成解决方案。它底层封装了 BufferQueueSharedMemory,对开发者暴露的接口却非常简单。这是我在实际项目中更推荐的方式,尤其是当你的数据是 Image 格式时。

核心原理

ImageReaderImageWriter 是 Android 提供的用于高效处理图像帧的生产者-消费者 API。它们内部通过 SurfaceBufferQueue 机制,天然支持跨进程的共享内存传递,且处理了所有同步细节。

实现流程
  1. 消费者进程创建 ImageReader

    // 消费者进程 (例如播放器App)
    val imageReader = ImageReader.newInstance(width, height, ImageFormat.YUV_420_888, 2) // maxImages 建议 >=2
    imageReader.setOnImageAvailableListener({ reader ->
        reader.acquireLatestImage()?.use { image ->
            // 在这里获取到图像数据,进行处理 (如渲染)
            processImage(image)
        }
    }, backgroundHandler)
    // 获取 Surface 并通过 AIDL 传递给生产者
    val surface = imageReader.surface
    aidlBridge.sendSurface(surface)
    
  2. 传递 Surface:通过 AIDL 接口将 Surface 对象从消费者进程传递到生产者进程。Surface 本身是可以通过 Binder 传递的。

  3. 生产者进程创建 ImageWriter

    // 生产者进程 (例如 Camera 应用)
    // 从 AIDL 回调中接收到 Surface
    fun onSurfaceReceived(surface: Surface) {
        val imageWriter = ImageWriter.newInstance(surface, maxImages)
        // 准备一个双缓冲或循环,用于写入数据
    }
    
  4. 生产者写入数据:当有新的帧(如 Camera 数据)需要发送时,从 ImageWriterdequeueInputImage() 获取一个空的 Image,将数据填入其 ByteBuffer,然后 queueInputImage() 交还给队列。数据会自动通过 BufferQueue 到达消费者的 ImageReader

    // 生产者获取到一帧 Camera 数据 cameraFrameData
    fun onFrameAvailable(cameraFrameData: ByteArray) {
        val image = imageWriter.dequeueInputImage()
        // 将 cameraFrameData 填入 image.planes
        for ((index, plane) in image.planes.withIndex()) {
            plane.buffer.put(cameraFrameData, ...) // 需要处理 stride 等
        }
        imageWriter.queueInputImage(image)
    }
    
优缺点
  • 优点简单、可靠、高效。代码量极少(不到100行即可搭建完整通道),同步问题完全由系统处理,性能已针对图形栈优化。
  • 缺点:仅适用于 Image 格式的数据(YUV、RGBA等),不适合传递原始的 H.264/H.265 码流或其他自定义数据结构。

📊 方案选型对比

特性方案一:基于 SharedMemory 自定义方案二:基于 ImageReader/ImageWriter
适用数据任意类型(原始 YUV、编码流、PCM 音频等)Image 格式(YUV_420_888、RGBA 等)
开发难度极高(需要处理同步、内存布局、边界条件)极低(API 封装完善,系统处理同步)
性能理论最高(可针对场景极致优化)极高(底层是 BufferQueue + 共享内存)
灵活性完全灵活(可自定义协议、缓冲区策略)较低(受限于 Image 和 Surface 机制)
推荐指数⭐⭐⭐ (专家级场景)⭐⭐⭐⭐⭐ (绝大多数图像传输场景)

💎 总结与建议

作为有十年经验的老兵,我给你的建议是:

  1. 首选 ImageReader/ImageWriter:如果你传输的是 Camera 预览、图像处理后的帧等标准图像格式,这个方案是你最明智的选择。它能让你用最少的代码、最少的 Bug,获得系统级的高性能。
  2. 在硬核场景下才选自定义 SharedMemory:当你的数据无法表示为 Image(例如编码后的 H.264 流),或者你需要对内存布局有绝对控制权(如自定义的 AI 推理数据交换)时,再考虑方案一。此时,请务必仔细设计你的同步机制和环形缓冲区,可以参考 [uStreamer 的设计思路] 或 [Dum-E 项目的实现]来规避潜在的坑。