深入 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 堆拷贝
方案能用,但问题也不少:
- getFileDescriptor 是隐藏 API,Android 10+ 受限,反射调用随时可能被拦
- 读写走 Java 堆,数据从共享内存拷到堆内存,再拷回去——根本不是零拷贝
- 官方标记为
@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:
| 特性 | MemoryFile | ASharedMemory |
|---|---|---|
| API 层级 | Java(隐藏) | NDK 公开 API |
| 零拷贝 | ❌ 堆拷贝 | ✅ mmap 直接访问 |
| 进程同步 | 需自行实现 | 需自行实现 |
| NDK 编译 | 不需要 | 需要 CMakeLists |
ASharedMemory 是我的首选。两个进程 mmap 同一块物理内存后,操作起来就像读写同一个数组,没有多余的拷贝开销。需要自己处理的只有同步:怎么保证接收方读的时候发送方没在写?用 futex 或者信号量,Android 没有内置锁机制。
ParcelFileDescriptor + ContentProvider:适合文件场景
ContentProvider 的 openFile() 返回 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,天然解耦读写冲突。
没有银弹,根据数据特征和性能预算选合适的通道就行。