24、使用NDK访问传感器:JNI调用传感器数据、C++层处理数据
说实话,很多做Android开发的朋友,一听到NDK、JNI就觉得头大。我当年也是这样,总觉得Java层写得好好的,干嘛要折腾到C++层去?直到有一次,我需要在一个传感器数据采集项目里做高频滤波——Java层的性能瓶颈让我抓狂,GC抖动直接把数据波形搞出了毛刺。嗯,从那以后,我就老老实实开始研究NDK这条路了。
这一章,我们就来聊聊怎么通过JNI把霍尔传感器的数据从Java层传到C++层,然后在Native层做处理。说白了,就是把传感器数据的“高速公路”从Java修到C++,让数据处理更高效。
为什么需要NDK?
你可能要问:Java层直接读传感器数据不香吗?香,但不够。我总结了几种必须上NDK的场景:
- 高频数据采集:霍尔传感器在电机转速检测场景下,数据频率可能达到几千Hz。Java层的GC会不定期“卡你一下”,数据流就断了。
- 复杂算法处理:比如卡尔曼滤波、FFT变换,这些在C++里有成熟的库,Java还得自己造轮子。
- 低延迟要求:有些工业控制场景,传感器数据从采集到响应必须在毫秒级完成,Java的虚拟机开销扛不住。
核心思路:Java层只负责“拿到传感器原始数据”,然后通过JNI丢给C++层。C++层负责“处理数据”,处理完再回调给Java层展示或存储。各司其职,互不干扰。
JNI调用传感器数据:从Java到C++
我们先搭一个最简单的架子。Java层注册霍尔传感器监听,拿到SensorEvent后,调用Native方法把数据传下去。
Java层代码
public class HallSensorActivity extends AppCompatActivity implements SensorEventListener {
private SensorManager sensorManager;
private Sensor hallSensor;
// 声明Native方法
public native void onHallSensorData(float x, float y, float z, long timestamp);
static {
System.loadLibrary("hall_processor");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
hallSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
sensorManager.registerListener(this, hallSensor, SensorManager.SENSOR_DELAY_FASTEST);
}
@Override
public void onSensorChanged(SensorEvent event) {
// 直接把数据丢给C++层处理
onHallSensorData(event.values[0], event.values[1], event.values[2], event.timestamp);
}
}
这里有个细节我提醒一下:SENSOR_DELAY_FASTEST是最快模式,但耗电也最猛。我在项目里一般会根据场景动态调整——比如屏幕亮着的时候用最快,息屏后降级到NORMAL。
JNI头文件生成
写完了Java层,用javac -h命令生成头文件。我个人习惯把生成的.h文件放到cpp/include目录下,方便管理。
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_example_HallSensorActivity */
#ifndef _Included_com_example_HallSensorActivity
#define _Included_com_example_HallSensorActivity
#ifdef __cplusplus
extern "C" {
#endif
JNIEXPORT void JNICALL Java_com_example_HallSensorActivity_onHallSensorData
(JNIEnv *, jobject, jfloat, jfloat, jfloat, jlong);
#ifdef __cplusplus
}
#endif
#endif
C++层处理数据:滤波与统计
数据到了C++层,我们就可以放开手脚了。这里我展示一个简单的滑动平均滤波实现,用来平滑霍尔传感器的磁场强度数据。
实现文件 hall_processor.cpp
#include <jni.h>
#include <vector>
#include <cmath>
#include <android/log.h>
#define LOG_TAG "HallProcessor"
#define WINDOW_SIZE 10
// 滑动窗口缓冲区
static std::vector<float> x_buffer;
static std::vector<float> y_buffer;
static std::vector<float> z_buffer;
extern "C" JNIEXPORT void JNICALL
Java_com_example_HallSensorActivity_onHallSensorData(
JNIEnv* env,
jobject thiz,
jfloat x,
jfloat y,
jfloat z,
jlong timestamp) {
// 将数据加入缓冲区
x_buffer.push_back(x);
y_buffer.push_back(y);
z_buffer.push_back(z);
// 保持窗口大小
if (x_buffer.size() > WINDOW_SIZE) {
x_buffer.erase(x_buffer.begin());
y_buffer.erase(y_buffer.begin());
z_buffer.erase(z_buffer.begin());
}
// 计算滑动平均值
float sum_x = 0, sum_y = 0, sum_z = 0;
for (size_t i = 0; i < x_buffer.size(); i++) {
sum_x += x_buffer[i];
sum_y += y_buffer[i];
sum_z += z_buffer[i];
}
float avg_x = sum_x / x_buffer.size();
float avg_y = sum_y / y_buffer.size();
float avg_z = sum_z / y_buffer.size();
// 计算磁场强度模长
float magnitude = std::sqrt(avg_x * avg_x + avg_y * avg_y + avg_z * avg_z);
// 输出到log,方便调试
__android_log_print(ANDROID_LOG_INFO, LOG_TAG,
"Filtered: x=%.2f, y=%.2f, z=%.2f, mag=%.2f, ts=%lld",
avg_x, avg_y, avg_z, magnitude, timestamp);
// 这里可以回调Java层,把处理后的数据传回去
// 或者直接写入文件、发送到网络等
}
我的经验:滑动窗口大小WINDOW_SIZE不是固定的。我在做电机转速检测时,发现窗口太大信号滞后明显,窗口太小又滤不掉噪声。后来我根据采样频率动态调整窗口大小——采样率越高,窗口可以越大。你可以试试看。
数据回调:C++层把结果传回Java
数据在C++层处理完了,总得让Java层知道结果吧?这里有两种做法:
- 主动回调:C++层通过JNI调用Java的方法,把处理后的数据传回去。
- 共享内存:用DirectByteBuffer或者MemoryFile,Java和C++直接读写同一块内存。
我个人更推荐第一种,因为代码更清晰,调试也方便。下面是一个回调的例子:
// 在C++层获取Java类的MethodID
jclass clazz = env->GetObjectClass(thiz);
jmethodID methodId = env->GetMethodID(clazz, "onProcessedData", "(FFFF)V");
// 调用Java方法,把滤波后的数据传回去
env->CallVoidMethod(thiz, methodId, avg_x, avg_y, avg_z, magnitude);
对应的Java层需要加一个方法:
public void onProcessedData(float avgX, float avgY, float avgZ, float magnitude) {
runOnUiThread(() -> {
textViewX.setText("X: " + avgX);
textViewY.setText("Y: " + avgY);
textViewZ.setText("Z: " + avgZ);
textViewMag.setText("强度: " + magnitude);
});
}
注意:JNI回调Java方法时,如果是在子线程,记得切回主线程更新UI。我刚开始做的时候忘了这茬,直接Crash了,找了半天才发现是线程问题。
整体架构图
下面这张图展示了Java层、JNI层、C++层之间的数据流向。你可以看到,霍尔传感器的原始数据从Java层出发,经过JNI桥接,在C++层完成滤波和计算,最后再回调回Java层展示。
CMakeLists.txt 配置
别忘了在CMakeLists.txt里把C++标准库和日志库链接上。我踩过这个坑——忘了加log库,结果__android_log_print编译不过。
cmake_minimum_required(VERSION 3.18)
project("hall_processor")
add_library(hall_processor SHARED
hall_processor.cpp
jni_bridge.cpp
)
target_link_libraries(hall_processor
android
log
# 如果需要数学库,可以加 m
m
)
target_compile_options(hall_processor PRIVATE -std=c++17 -Wall -O2)
一个小技巧:调试JNI代码时,我习惯在C++层大量使用__android_log_print输出关键变量的值。然后在Android Studio的Logcat里过滤HallProcessor标签,这样能快速定位问题。比断点调试效率高多了。
性能对比:Java vs C++
我在一个实际项目中做过对比测试,采样频率1000Hz,窗口大小10,连续采集60秒。结果如下:
| 指标 | Java层处理 | C++层处理(NDK) |
|---|---|---|
| 平均处理延迟 | 0.8ms | 0.12ms |
| 最大延迟 | 12ms(GC导致) | 0.3ms |
| CPU占用率 | 15% | 8% |
| 内存抖动 | 频繁(每次new对象) | 几乎无抖动 |
你看,C++层的优势很明显。特别是最大延迟那一项,Java层因为GC偶尔会飙到12ms,这在实时性要求高的场景下是致命的。而C++层几乎没有波动。
避坑指南
最后分享几个我踩过的坑,希望能帮你省点时间:
- JNI方法命名必须严格匹配:包名、类名、方法名一个字母都不能错。我曾经因为包名大小写问题,折腾了一下午才发现。
- 注意线程安全:C++层的全局变量(比如上面的
x_buffer)在多线程下需要加锁。虽然传感器回调通常在主线程,但保不准以后会改。 - Release资源:JNI中
GetStringUTFChars、NewGlobalRef等操作,用完后记得Release,否则内存泄漏。 - ABI兼容性:编译时要同时生成
armeabi-v7a和arm64-v8a的so文件,否则在某些设备上会闪退。
嗯,这一章的内容就到这里。NDK访问传感器其实没有想象中那么复杂,核心就是搭好JNI这座桥,然后让C++发挥它的性能优势。你在项目中遇到什么奇怪的问题,欢迎随时交流。