一、JNI调用开销分析:别让“桥”成了瓶颈

做Camera HAL优化的朋友都知道,JNI是个绕不开的坎。说白了,Java层和C++层之间通信,全靠这座“桥”。但这座桥的通行费,比你想象的要贵得多。

我个人习惯在项目初期就做一次JNI调用开销的摸底测试。你猜怎么着?一次空的JNI调用,在高端芯片上大概要花50-100纳秒。听起来不多?但如果你在预览流里每帧调用几百次,那帧率直接掉一截。

1.1 为什么JNI调用这么贵?

JNI开销主要来自三方面:

  • 线程切换与状态保存:从Java栈切换到Native栈,寄存器要压栈恢复
  • 参数校验与类型转换:JNI层会检查参数类型、数组长度等
  • GC屏障:JNI调用期间,GC可能被阻塞

核心结论:JNI调用次数越少越好,单次传递的数据量越大越好。

1.2 实测数据:不同调用方式的耗时对比

调用方式 单次耗时(ns) 1000次耗时(μs)
空JNI调用(无参数) ~60 ~60
传递1个int参数 ~80 ~80
传递1个ByteBuffer(Direct) ~120 ~120
传递1个byte[]数组 ~350 ~350

看到没?传递byte[]数组比DirectBuffer贵了将近3倍。为什么?因为JNI要拷贝数组内容。我在项目中遇到过,有人把每帧的YUV数据用byte[]传,结果CPU时间全耗在拷贝上了。

我的建议:能用DirectBuffer就别用byte[]。如果必须用数组,考虑用GetCriticalArrayElements绕过拷贝,但要注意临界区不能阻塞。

二、DirectBuffer vs ByteBuffer:谁才是性能之王?

这个问题我经常被问到。直接说结论:在Camera HAL场景下,DirectBuffer几乎是唯一选择

2.1 两者的本质区别

  • ByteBuffer(Heap):数据在Java堆上,JNI访问时需要拷贝到Native堆
  • DirectBuffer:数据在Native堆上,JNI可以直接访问指针,零拷贝

你想想看,Camera每帧的数据量有多大?1080p的YUV420就是一帧3MB左右。如果每帧都拷贝一次,30帧就是90MB/s的拷贝开销。这还没算上GC压力。

2.2 什么时候用ByteBuffer?

说实话,我只有在数据量很小(比如几百字节的控制参数)或者需要频繁修改的场景下,才会考虑Heap ByteBuffer。因为DirectBuffer的分配和释放成本比较高。

注意:DirectBuffer的内存不受Java堆控制,分配太多会导致Native内存泄漏。我曾经见过一个项目,每帧都new DirectBuffer,结果OOM了——不是Java OOM,是Native OOM。

2.3 最佳实践:复用DirectBuffer

// 在初始化时分配一次,后续复用
private DirectBuffer mBuffer;
private long mNativePtr;

public void init(int size) {
    mBuffer = ByteBuffer.allocateDirect(size);
    mNativePtr = ((DirectBuffer) mBuffer).getPointer();
}

// JNI端直接操作指针
JNIEXPORT void JNICALL
Java_com_example_CameraHal_processFrame(JNIEnv* env, jobject thiz, 
                                         jlong ptr, jint size) {
    uint8_t* data = reinterpret_cast<uint8_t*>(ptr);
    // 直接处理data,零拷贝
}

嗯,这里要注意:getPointer()方法在Android 9以上才公开,低版本需要反射或者用JNI的GetDirectBufferAddress。

三、C++层性能优化:把每一纳秒都榨干

到了C++层,优化空间就大了。我个人习惯把Camera HAL的C++代码分成三层:

  1. 接口层:处理JNI调用,尽量少做事
  2. 处理层:核心算法,用SIMD、多线程优化
  3. 驱动层:直接操作硬件,用mmap、DMA

3.1 内存分配:能复用就别new

我在项目中遇到过,有人每帧都new一个vector<uint8_t>,然后析构。这会导致频繁的malloc/free,产生内存碎片。正确的做法是:

  • 用对象池或环形缓冲区
  • 用std::array代替std::vector(如果大小固定)
  • 用placement new避免重新分配

3.2 算法优化:SIMD是王道

图像处理里,NEON指令集能带来4倍以上的加速。比如做YUV转RGB:

// 普通C++实现
for (int i = 0; i < size; i++) {
    dst[i] = yuv_to_rgb(src[i]);
}

// NEON优化
uint8x16_t y = vld1q_u8(src);
uint8x16_t u = vld1q_u8(src + offset);
uint8x16_t v = vld1q_u8(src + offset2);
// ... 一次处理16个像素

说实话,手写NEON汇编有点痛苦。我建议用ARM的Intrinsics函数,编译器会帮你做寄存器分配。

3.3 多线程:别让CPU闲着

Camera HAL里,Pipeline的每个阶段都可以并行。我常用的模式是:

  • 用std::thread或pthread创建固定线程池
  • 用无锁队列(如boost::lockfree)传递数据
  • 用条件变量做同步,避免忙等

避坑指南:我曾经在某个项目里用了std::async,结果发现它在Android上会创建大量线程,导致调度开销爆炸。后来换成固定线程池,性能提升30%。

四、NDK开发最佳实践:少走弯路

NDK开发坑不少,我踩过的坑能写本书。这里挑几个最关键的:

4.1 ABI选择:别一股脑全编

很多项目把armeabi、armeabi-v7a、arm64-v8a、x86全编了。其实大部分设备只需要arm64-v8a。我建议:

  • 只编arm64-v8a(覆盖95%以上设备)
  • 如果需要兼容32位,加armeabi-v7a
  • x86/x86_64只在模拟器测试时用

4.2 编译优化:打开-O3和LTO

在CMakeLists.txt里加上:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -flto -march=armv8-a+simd")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -flto")

嗯,这里要注意:LTO(链接时优化)能跨编译单元优化,但会显著增加编译时间。我一般在Release版本才开。

4.3 日志与调试:别用printf

在NDK里,printf不会输出到logcat。要用__android_log_print:

#include <android/log.h>
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "CameraHAL", __VA_ARGS__)

我曾经见过有人用cout调试NDK,结果找了半天没看到输出——因为cout默认输出到stdout,而Android没有终端。

4.4 异常处理:别用C++异常

Android的NDK对C++异常支持不太好,而且异常会破坏JNI的栈帧。我建议:

  • 用返回值表示错误(如返回int,0表示成功)
  • 用errno或自定义错误码
  • 实在要用异常,确保在JNI边界捕获并转换为错误码

我的习惯:在C++层定义一个Result<T>类型,类似Rust的Result,既安全又清晰。

五、知识体系总览

下面这张图总结了本章的核心内容,从JNI调用到C++优化,再到NDK实践,一条线串起来:

JNI/Native层优化知识体系 JNI调用开销分析 线程切换 · 参数校验 · GC屏障 DirectBuffer vs ByteBuffer 零拷贝 · 复用 · 内存管理 C++层性能优化 内存复用 · SIMD · 多线程 NDK开发最佳实践 ABI选择 · 编译优化 · 日志 · 异常处理 Camera HAL 性能最大化 低延迟 · 高帧率 · 低功耗 优化路径:从JNI边界到C++核心,再到NDK工程实践

这张图把优化路径理清楚了。从JNI调用开始,到Buffer选择,再到C++层的算法和内存优化,最后落到NDK工程实践上。每一步都踩过坑,每一步都有优化空间。

总结一句话:JNI/Native优化的核心就是减少拷贝、复用内存、榨干CPU指令集。做到这三点,Camera HAL的性能不会差。


公众号:蓝海资料掘金营,微信deep3321