30、综合实战:开发一个完整的陀螺仪数据记录与分析 App

终于到了最后一章。说实话,我自己写这个系列的时候,最期待的就是这一节。前面讲了那么多传感器原理、API 调用、数据滤波,都是零散的零件。今天咱们就把这些零件组装起来,造一台能跑的车。

这个 App 的目标很明确:实时记录陀螺仪数据,然后能回放、能分析。我当年刚接触传感器时,最想要的就是这样一个工具——调试算法时不用每次都连电脑看 log,手机上就能搞定。

整体架构设计

先看整体结构。我习惯在动手写代码前,先把模块图画清楚。这样写起来不会跑偏。

陀螺仪数据记录与分析 App 架构 数据采集层 SensorManager TYPE_GYROSCOPE 采样率:SENSOR_DELAY_GAME 数据处理层 低通滤波 角度积分 数据缓存 数据存储层 CSV 文件写入 Room 数据库 文件管理 展示层 实时曲线图 历史数据回放 统计摘要 MPAndroidChart 自定义 View RecyclerView

说白了就是四层:采集 → 处理 → 存储 → 展示。每一层各干各的活,互不干扰。我早期写代码喜欢把所有逻辑塞到一个 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 的操作流程串一下:

  1. 新建记录:点击「开始记录」,输入备注(比如「走路时口袋里的手机」)
  2. 实时采集:界面显示三条实时曲线(X红、Y绿、Z蓝),底部显示当前数值
  3. 停止保存:点击停止,自动生成 CSV 文件,元数据写入 Room
  4. 查看列表:主界面显示所有历史记录,按时间排序,显示采样数和时长
  5. 回放分析:点击某条记录,进入回放界面,可以调速播放,查看统计摘要
  6. 导出分享:CSV 文件可以分享给其他人,或者用电脑做进一步分析

这个 App 我一直在用。调试滤波算法时,录一段真实数据,然后在电脑上用 Python 跑不同算法对比效果。比在手机上反复编译运行快多了。

好了,整个系列到这里就结束了。从传感器原理到 API 调用,从数据滤波到实战 App,咱们把陀螺仪从里到外捋了一遍。希望这些内容能帮你少走一些弯路——毕竟我当年踩过的坑,你没必要再踩一遍。

有什么问题,欢迎交流。我是蓝海,咱们下个项目见。


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