30、综合实战:开发一个完整的陀螺仪数据记录与分析 App
终于到了最后一章。说实话,我自己写这个系列的时候,最期待的就是这一节。前面讲了那么多传感器原理、API 调用、数据滤波,都是零散的零件。今天咱们就把这些零件组装起来,造一台能跑的车。
这个 App 的目标很明确:实时记录陀螺仪数据,然后能回放、能分析。我当年刚接触传感器时,最想要的就是这样一个工具——调试算法时不用每次都连电脑看 log,手机上就能搞定。
整体架构设计
先看整体结构。我习惯在动手写代码前,先把模块图画清楚。这样写起来不会跑偏。
说白了就是四层:采集 → 处理 → 存储 → 展示。每一层各干各的活,互不干扰。我早期写代码喜欢把所有逻辑塞到一个 Activity 里,后来维护起来真想抽自己。分层之后,改滤波算法不用动 UI,换图表库不用动数据逻辑,舒服多了。
第一步:搭建项目骨架
新建一个 Android 项目,包名我用 com.example.gyrorecorder。你需要添加几个依赖:
// build.gradle (app)
dependencies {
// 图表库
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
// Room 数据库
implementation 'androidx.room:room-runtime:2.5.0'
annotationProcessor 'androidx.room:room-compiler:2.5.0'
// 文件操作
implementation 'commons-io:commons-io:2.11.0'
}
第二步:传感器采集核心
写一个 GyroRecorder 类,专门负责采集。注意,这里我用了 生产者-消费者模式——传感器回调是生产者,数据处理是消费者。为什么要这样?因为传感器回调跑在 UI 线程,你不能在里面做耗时操作。
public class GyroRecorder implements SensorEventListener {
private SensorManager sensorManager;
private Sensor gyroscope;
private BlockingQueue<GyroData> dataQueue;
private volatile boolean isRecording = false;
public GyroRecorder(Context context) {
sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
gyroscope = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
dataQueue = new LinkedBlockingQueue<>(1024);
}
@Override
public void onSensorChanged(SensorEvent event) {
if (!isRecording) return;
// 只取前三个值:x, y, z 角速度
GyroData data = new GyroData(
System.currentTimeMillis(),
event.values[0],
event.values[1],
event.values[2]
);
dataQueue.offer(data); // 非阻塞入队
}
public void startRecording() {
isRecording = true;
sensorManager.registerListener(this, gyroscope, SensorManager.SENSOR_DELAY_GAME);
// 启动消费者线程
new Thread(new DataConsumer()).start();
}
public void stopRecording() {
isRecording = false;
sensorManager.unregisterListener(this);
}
// 消费者:从队列取数据,写入文件
private class DataConsumer implements Runnable {
@Override
public void run() {
// 写入 CSV 文件
try (FileWriter fw = new FileWriter(getDataFile())) {
fw.write("timestamp,x,y,z\n");
while (isRecording || !dataQueue.isEmpty()) {
GyroData data = dataQueue.poll(100, TimeUnit.MILLISECONDS);
if (data != null) {
fw.write(data.toCsvLine() + "\n");
}
}
} catch (Exception e) {
Log.e("GyroRecorder", "写入失败", e);
}
}
}
}
⚠️ 关键点:BlockingQueue 的容量设成 1024 够用吗?我实测过,SENSOR_DELAY_GAME 大约 50ms 回调一次,一秒 20 个数据。1024 的队列能缓冲约 50 秒的数据。如果你的消费者线程卡了超过 50 秒,那说明写入逻辑有问题,该优化了。
第三步:数据模型与存储
数据模型很简单,就四个字段:时间戳、x、y、z。但我建议你同时用 Room 数据库存一份元数据,方便管理多条记录。
@Entity(tableName = "recordings")
public class Recording {
@PrimaryKey(autoGenerate = true)
public long id;
public String fileName; // CSV 文件名
public long startTime; // 开始时间
public long endTime; // 结束时间
public int sampleCount; // 采样数
public String description; // 备注
}
// DAO
@Dao
public interface RecordingDao {
@Insert
long insert(Recording recording);
@Query("SELECT * FROM recordings ORDER BY startTime DESC")
List<Recording> getAllRecordings();
@Delete
void delete(Recording recording);
}
为什么不用 Room 直接存数据点?嗯,这里有个性能问题。一秒 20 个点,录 10 分钟就是 12000 条。Room 的批量插入虽然快,但查询时如果数据量大了,UI 线程会卡。CSV 文件 + 数据库索引的方式,是我踩过坑之后的选择。
第四步:实时曲线展示
用 MPAndroidChart 画实时曲线。这里有个坑:图表刷新不能太频繁。传感器 50ms 来一个数据,你要是 50ms 刷新一次图表,CPU 直接拉满,手机发烫。
public class RealTimeChart {
private LineChart chart;
private LineData lineData;
private static final int MAX_VISIBLE_POINTS = 200; // 只显示最近200个点
public RealTimeChart(LineChart chart) {
this.chart = chart;
setupChart();
}
public void addDataPoint(float x, float y, float z) {
// 每 200ms 刷新一次 UI
// 用 Handler 或者 LiveData 做节流
// 这里省略节流代码,核心是添加数据点
lineData.addEntry(new Entry(time, x), 0); // X轴曲线
lineData.addEntry(new Entry(time, y), 1); // Y轴曲线
lineData.addEntry(new Entry(time, z), 2); // Z轴曲线
// 只保留最近 MAX_VISIBLE_POINTS 个点
if (lineData.getEntryCount() > MAX_VISIBLE_POINTS) {
lineData.removeEntry(0, 0);
lineData.removeEntry(0, 1);
lineData.removeEntry(0, 2);
}
chart.notifyDataSetChanged();
chart.invalidate();
}
}
⚠️ 性能警告:我曾经在项目里每 50ms 刷新一次图表,结果手机温度从 30°C 飙到 45°C。后来改成 200ms 刷新一次,温度降了 8°C,肉眼完全看不出延迟。记住:人眼对 200ms 以内的变化不敏感。
第五步:数据回放与分析
回放功能说白了就是把 CSV 读出来,按时间戳模拟播放。我加了一个「速度控制」滑块,可以 0.5x、1x、2x 播放。这个在调试算法时特别有用——慢放能看清细节,快放能快速浏览长记录。
public class DataPlayer {
private List<GyroData> dataList;
private int currentIndex = 0;
private float speed = 1.0f;
public void loadFromFile(File file) {
// 读取 CSV,解析成 GyroData 列表
dataList = parseCsv(file);
}
public void play(OnDataCallback callback) {
if (dataList == null || dataList.isEmpty()) return;
long baseTime = dataList.get(0).timestamp;
currentIndex = 0;
new Thread(() -> {
while (currentIndex < dataList.size()) {
GyroData data = dataList.get(currentIndex);
long delay = (long)((data.timestamp - baseTime) / speed);
// 计算当前帧应该等待的时间
// 这里用 Thread.sleep 模拟时间轴
try {
Thread.sleep(Math.max(10, delay));
} catch (InterruptedException e) {
break;
}
callback.onData(data);
currentIndex++;
}
}).start();
}
}
第六步:统计摘要
记录完了,总得给用户看个总结吧。我做了三个核心指标:
| 指标 | 计算方式 | 说明 |
|---|---|---|
| 最大角速度 | max(|x|, |y|, |z|) | 记录期间的最大瞬时角速度 |
| 平均角速度 | sum(|value|) / count | 反映整体运动剧烈程度 |
| 累计旋转角度 | ∫ ω dt(数值积分) | 近似总旋转量,单位度 |
累计角度这个,我用的是梯形积分法。说白了就是把每个小梯形面积加起来。代码很简单:
public double calculateTotalAngle(List<GyroData> data) {
double totalAngle = 0;
for (int i = 1; i < data.size(); i++) {
GyroData prev = data.get(i - 1);
GyroData curr = data.get(i);
double dt = (curr.timestamp - prev.timestamp) / 1000.0; // 转成秒
// 梯形积分: (ω1 + ω2) / 2 * dt
double avgX = (prev.x + curr.x) / 2.0;
double avgY = (prev.y + curr.y) / 2.0;
double avgZ = (prev.z + curr.z) / 2.0;
double magnitude = Math.sqrt(avgX*avgX + avgY*avgY + avgZ*avgZ);
totalAngle += magnitude * dt;
}
return Math.toDegrees(totalAngle);
}
💡 避坑指南:积分会累积误差。陀螺仪有零偏,长时间积分后角度会漂移。我曾经录了一个小时的手机静止数据,积分结果显示转了 180 度——手机明明没动!解决办法是:记录前先校准零偏,或者加高通滤波。
完整工作流程
最后,我把整个 App 的操作流程串一下:
- 新建记录:点击「开始记录」,输入备注(比如「走路时口袋里的手机」)
- 实时采集:界面显示三条实时曲线(X红、Y绿、Z蓝),底部显示当前数值
- 停止保存:点击停止,自动生成 CSV 文件,元数据写入 Room
- 查看列表:主界面显示所有历史记录,按时间排序,显示采样数和时长
- 回放分析:点击某条记录,进入回放界面,可以调速播放,查看统计摘要
- 导出分享:CSV 文件可以分享给其他人,或者用电脑做进一步分析
这个 App 我一直在用。调试滤波算法时,录一段真实数据,然后在电脑上用 Python 跑不同算法对比效果。比在手机上反复编译运行快多了。
好了,整个系列到这里就结束了。从传感器原理到 API 调用,从数据滤波到实战 App,咱们把陀螺仪从里到外捋了一遍。希望这些内容能帮你少走一些弯路——毕竟我当年踩过的坑,你没必要再踩一遍。
有什么问题,欢迎交流。我是蓝海,咱们下个项目见。
公众号:蓝海资料掘金营,微信deep3321