深入 Android 跨进程大数据传输全链路:从 Binder 1MB 限制到 ASharedMemory 零拷贝的进程间数据通道设计

去年做 Camera 预览流跨进程传输时,我碰到一个问题:从 CameraService 出来的 YUV 帧大约 8MB,通过 Binder 回传给 App 进程时直接崩了。日志里就一行:!!! FAILED BINDER TRANSACTION !!!

Binder 的单次事务硬上限是 1MB - 8KB,大约 1016KB。超过这个值,驱动层直接拒掉。而很多实际场景的数据量远超这个量级:Camera 帧、大图片、语音流、文件传输,都得解决这个问题。

Binder 限制的技术根源

这个限制的核心目的是防止内存碎片化和事务阻塞,并非设计缺陷。

Binder 驱动在内核空间为每次事务分配一块连续物理内存,总上限 BINDER_VM_SIZE 默认 4MB,单次 1MB。如果放开,一个大数据事务就能耗尽整个 Binder 线程池——其他进程的调用全部排队等这次拷贝完成。

解决思路本质上只有一个:数据不经过 Binder 驱动内存,进程间共享同一块物理内存

MemoryFile:Java 层的共享内存

MemoryFile 是最早的共享内存 API,基于 ashmem 驱动创建共享内存区域,本质是文件描述符(fd)+ mmap

发送方:

MemoryFile memoryFile = new MemoryFile("shared", 10 * 1024 * 1024);
memoryFile.writeBytes(data, 0, 0, data.length);
Method method = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
FileDescriptor fd = (FileDescriptor) method.invoke(memoryFile);
// 通过 Binder 传递 ParcelFileDescriptor(仅 fd,≈100 字节)

接收方:

FileDescriptor fd = parcelFileDescriptor.getFileDescriptor();
FileInputStream fis = new FileInputStream(fd);
byte[] buffer = new byte[size];
fis.read(buffer); // 唉,又变成了一次 Java 堆拷贝

方案能用,但问题也不少:

  1. getFileDescriptor 是隐藏 API,Android 10+ 受限,反射调用随时可能被拦
  2. 读写走 Java 堆,数据从共享内存拷到堆内存,再拷回去——根本不是零拷贝
  3. 官方标记为 @hide,Google 已明确推荐迁移

Android 8.0 之前这几乎是唯一选择。现在我只在维护老项目时还会碰到这种写法。

ASharedMemory:NDK 真正的零拷贝方案

Android 8.0 引入的 NDK API,头文件 <android/sharedmem.h>。底层同样是 ashmem 驱动,但接口设计更现代,能做到真正的零拷贝。

发送端(C++):

int fd = ASharedMemory_create("camera_frame", 8 * 1024 * 1024);
uint8_t *ptr = static_cast<uint8_t *>(
    mmap(nullptr, 8 * 1024 * 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0));
memcpy(ptr, raw_frame, frame_size); // 仅写入
// 仅传 fd 和 size 给 Binder

接收端(C++):

uint8_t *ptr = static_cast<uint8_t *>(
    mmap(nullptr, size, PROT_READ, MAP_SHARED, fd, 0));
// ptr 直接指向发送方写入的同一块物理内存,零拷贝读取
process_frame(ptr, size);

对比 MemoryFile:

特性MemoryFileASharedMemory
API 层级Java(隐藏)NDK 公开 API
零拷贝❌ 堆拷贝✅ mmap 直接访问
进程同步需自行实现需自行实现
NDK 编译不需要需要 CMakeLists

ASharedMemory 是我的首选。两个进程 mmap 同一块物理内存后,操作起来就像读写同一个数组,没有多余的拷贝开销。需要自己处理的只有同步:怎么保证接收方读的时候发送方没在写?用 futex 或者信号量,Android 没有内置锁机制。

ParcelFileDescriptor + ContentProvider:适合文件场景

ContentProvideropenFile() 返回 ParcelFileDescriptor,本质还是传 fd。

// Provider 端
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
    val file = File(cacheDir, uri.lastPathSegment!!)
    return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}

Binder 只传 fd 描述符,数据通过 pipe 或文件 IO 传输。这适合静态数据:一张大图、一份缓存文件。而 Camera 帧这类动态流数据,频繁打开/关闭文件描述符的开销太大,不适合。

一个容易踩的坑:ParcelFileDescriptor 跨进程传递时,Binder 会 dup 一个 fd 给对端,两端各自管理生命周期。如果发送端关早了,接收端拿到的就是死 fd。

Surface:为图形流而生

传输图像/视频帧,Surface + SurfaceTexture 是最优解。它的共享内存机制内建在 GraphicBuffer 体系中,Android 图形栈本身就靠这套机制运行。

// 生产者进程
val surface = MediaCodec.createInputSurface()
// surface 通过 Binder 传给消费者进程(fd 传递)
// 消费者进程
val surfaceTexture = SurfaceTexture(textureId)
val surface = Surface(surfaceTexture)
surfaceTexture.setOnFrameAvailableListener {
    // 帧就绪,GL 渲染即可
    surfaceTexture.updateTexImage()
}

Surface 强依赖 EGL/GLES 上下文,纯数据处理场景——比如传递二进制配置、音频 PCM——用它属于杀鸡用牛刀。

工程选型决策

踩过这些坑后,我整理了一个快速决策流程:

第一问:数据是流式还是静态?

  • 流式数据(Camera 帧、音频流)→ 第二问
  • 静态数据(用户头像、缓存文件)→ ContentProvider + ParcelFileDescriptor

第二问:数据是图像/视频帧吗?

  • 是 → Surface(天然绑定 GPU 渲染管线)
  • 否 → 第三问

第三问:项目能否引入 NDK?

  • 能 → ASharedMemory(零拷贝、公开 API、性能最佳)
  • 不能 → 降级方案:MemoryFile(受限反射)或应用层分片传输

实际项目中我更倾向 ASharedMemory,即便加 NDK 编译配置也值得。8MB 的 Camera 帧 mmap 映射后,接收端直接在 native 层做图像预处理(缩放、格式转换),全程不碰 Java 堆,延迟从 30ms 降到 2ms 以内。

同步机制上,我的实践:简单场景用原子标志位 + futex 自旋等待;复杂场景用环形缓冲区(Ring Buffer),生产者写一个 slot、消费者读下一个 slot,天然解耦读写冲突。

没有银弹,根据数据特征和性能预算选合适的通道就行。