22、数据可视化:使用 Canvas 实时绘制陀螺仪数据曲线

说实话,做传感器开发最头疼的是什么?

不是拿不到数据,而是拿到了数据,你盯着那一串串数字,根本看不出门道。我刚开始做陀螺仪项目时,就靠 Logcat 打印数值,看得眼睛都快瞎了。后来我学乖了——数据必须画出来,一眼就能看出问题。

这一章,我们就来搞定这件事。用 Android 的 Canvas,把陀螺仪的 X、Y、Z 三轴数据实时画成曲线。说白了,就是做一个手机上的“心电图”,只不过心跳换成了角速度。

22.1 核心思路:数据怎么变成曲线?

先理一下逻辑。你要在屏幕上画一条会动的曲线,需要做三件事:

  1. 存数据——用一个队列,不断往里塞最新的传感器数值
  2. 画数据——遍历队列里的每个点,用 Canvas 的 drawLine 连起来
  3. 刷新画面——每次有新数据,就重绘一次 View

嗯,就这么简单。我见过很多新手一上来就想着用 SurfaceView 或者 OpenGL,其实完全没必要。对于这种简单的曲线绘制,自定义 View + invalidate() 就足够了。

关键点:数据队列的长度决定了曲线显示的时间窗口。我一般保留 200~300 个点,对应大约 3~5 秒的数据。太短了看不出趋势,太长了画面会卡。

22.2 数据结构:用队列管理数据流

我习惯用 LinkedList 来做数据缓存。为什么?因为队列满了之后,从头部移除旧数据、尾部添加新数据,LinkedList 的 add/remove 操作是 O(1) 的,性能好。

// 用一个内部类来存三轴数据
private static class SensorDataPoint {
    float x, y, z;
    long timestamp;

    SensorDataPoint(float x, float y, float z, long timestamp) {
        this.x = x;
        this.y = y;
        this.z = z;
        this.timestamp = timestamp;
    }
}

// 数据队列,最大保留 300 个点
private LinkedList<SensorDataPoint> dataQueue = new LinkedList<>();
private static final int MAX_DATA_POINTS = 300;

// 添加新数据
public void addData(float x, float y, float z, long timestamp) {
    if (dataQueue.size() >= MAX_DATA_POINTS) {
        dataQueue.removeFirst();  // 移除最旧的数据
    }
    dataQueue.addLast(new SensorDataPoint(x, y, z, timestamp));
    invalidate();  // 触发重绘
}

这里有个小坑——invalidate() 必须在主线程调用。传感器回调是在另一个线程里跑的,所以你需要用 Handler 或者 View.post() 来切线程。我曾经偷懒直接调,结果 app 直接崩了,画面卡死。嗯,血的教训。

我的习惯:在 onSensorChanged 里用 postInvalidate(),它可以在非 UI 线程安全调用。省事又安全。

22.3 绘制曲线:Canvas 的基本操作

接下来是重头戏——画曲线。我们需要在 onDraw() 方法里,把队列里的数据点映射到屏幕坐标上。

为什么要映射?因为传感器数值是 -20~20 rad/s 这种范围,而屏幕是 1080x1920 像素。你得把数据“翻译”成像素位置。

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    if (dataQueue.isEmpty()) return;

    float width = getWidth();
    float height = getHeight();

    // 计算每个数据点之间的水平间距
    float stepX = width / MAX_DATA_POINTS;

    // 找到当前数据中的最大值和最小值,用于垂直缩放
    float maxVal = Float.MIN_VALUE;
    float minVal = Float.MAX_VALUE;
    for (SensorDataPoint p : dataQueue) {
        maxVal = Math.max(maxVal, Math.max(p.x, Math.max(p.y, p.z)));
        minVal = Math.min(minVal, Math.min(p.x, Math.min(p.y, p.z)));
    }
    float range = maxVal - minVal;
    if (range == 0) range = 1;  // 防止除零

    // 绘制网格线(可选,但建议加上)
    drawGrid(canvas, width, height, minVal, maxVal);

    // 分别绘制 X、Y、Z 三条曲线
    drawAxisLine(canvas, dataQueue, stepX, height, minVal, range, 
                 Color.RED,   // X 轴用红色
                 p -> p.x);
    drawAxisLine(canvas, dataQueue, stepX, height, minVal, range, 
                 Color.GREEN, // Y 轴用绿色
                 p -> p.y);
    drawAxisLine(canvas, dataQueue, stepX, height, minVal, range, 
                 Color.BLUE,  // Z 轴用蓝色
                 p -> p.z);
}

// 绘制单条曲线
private void drawAxisLine(Canvas canvas, LinkedList<SensorDataPoint> data,
                          float stepX, float height, float minVal, float range,
                          int color, Function<SensorDataPoint, Float> axisGetter) {
    Paint paint = new Paint();
    paint.setColor(color);
    paint.setStrokeWidth(3f);
    paint.setStyle(Paint.Style.STROKE);
    paint.setAntiAlias(true);

    Path path = new Path();
    boolean first = true;

    for (int i = 0; i < data.size(); i++) {
        SensorDataPoint p = data.get(i);
        float value = axisGetter.apply(p);

        // 映射到屏幕坐标
        float x = i * stepX;
        float y = height - ((value - minVal) / range) * height;

        if (first) {
            path.moveTo(x, y);
            first = false;
        } else {
            path.lineTo(x, y);
        }
    }

    canvas.drawPath(path, paint);
}

你可能会问:为什么要用 Path 而不是一条条 drawLine?

因为 Path 可以一次性把所有点连成一条连续的线,性能更好,而且画出来的曲线更平滑。drawLine 是一条一条画,中间会有断点,看起来像虚线。

22.4 让曲线动起来:实时刷新机制

曲线画好了,但它是静态的。怎么让它动起来?

答案很简单:每次传感器来新数据,就调用 invalidate() 重绘一次。Android 的 View 系统会自动处理刷新频率,你不需要自己开线程循环。

但这里有个性能问题——传感器每秒可能来 50~200 次数据,如果每次都重绘,CPU 和 GPU 会累死。我建议做一下限流:

private long lastInvalidateTime = 0;
private static final long INVALIDATE_INTERVAL_MS = 16; // 约 60fps

public void addData(float x, float y, float z, long timestamp) {
    // ... 数据入队逻辑同上 ...

    long now = System.currentTimeMillis();
    if (now - lastInvalidateTime >= INVALIDATE_INTERVAL_MS) {
        postInvalidate();
        lastInvalidateTime = now;
    }
}

这样即使传感器来了 200 次数据,我们也只刷新 60 次,画面依然流畅,但 CPU 负担大大降低。我在一个低端机上试过,不加限流直接卡成 PPT,加了之后稳如老狗。

22.5 知识体系:一张图看懂

下面这张 SVG 图,把整个流程串起来了。你照着这个思路写代码,不会乱。

Canvas 实时绘制陀螺仪数据曲线 - 核心流程 传感器回调 onSensorChanged() 数据队列 LinkedList<SensorDataPoint> 限流刷新 postInvalidate() 60fps onDraw() 绘制曲线 Canvas + Path + drawLine 坐标映射 数值 → 像素坐标 三轴分别绘制 X红 / Y绿 / Z蓝 循环:传感器数据 → 入队 → 限流刷新 → 重绘 → 显示曲线 💡 提示:数据队列长度建议 200~300 点,限流频率 60fps 即可。不要追求 120fps,人眼根本看不出来。

22.6 完整代码骨架

最后,我把上面所有东西拼成一个完整的自定义 View。你直接复制就能用:

public class GyroCurveView extends View {
    private LinkedList<SensorDataPoint> dataQueue = new LinkedList<>();
    private static final int MAX_POINTS = 300;
    private long lastInvalidateTime = 0;
    private static final long INVALIDATE_INTERVAL = 16;

    public GyroCurveView(Context context, AttributeSet attrs) {
        super(context, attrs);
        setBackgroundColor(Color.parseColor("#1a1a2e")); // 深色背景更酷
    }

    public void addData(float x, float y, float z, long timestamp) {
        if (dataQueue.size() >= MAX_POINTS) {
            dataQueue.removeFirst();
        }
        dataQueue.addLast(new SensorDataPoint(x, y, z, timestamp));

        long now = System.currentTimeMillis();
        if (now - lastInvalidateTime >= INVALIDATE_INTERVAL) {
            postInvalidate();
            lastInvalidateTime = now;
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (dataQueue.isEmpty()) return;

        float w = getWidth();
        float h = getHeight();
        float stepX = w / MAX_POINTS;

        // 计算动态范围
        float maxVal = Float.MIN_VALUE, minVal = Float.MAX_VALUE;
        for (SensorDataPoint p : dataQueue) {
            maxVal = Math.max(maxVal, Math.max(p.x, Math.max(p.y, p.z)));
            minVal = Math.min(minVal, Math.min(p.x, Math.min(p.y, p.z)));
        }
        float range = maxVal - minVal;
        if (range == 0) range = 1;

        // 画网格
        drawGrid(canvas, w, h, minVal, maxVal);

        // 画三条曲线
        drawCurve(canvas, dataQueue, stepX, h, minVal, range, Color.RED, p -> p.x);
        drawCurve(canvas, dataQueue, stepX, h, minVal, range, Color.GREEN, p -> p.y);
        drawCurve(canvas, dataQueue, stepX, h, minVal, range, Color.BLUE, p -> p.z);
    }

    private void drawCurve(Canvas canvas, LinkedList<SensorDataPoint> data,
                           float stepX, float height, float minVal, float range,
                           int color, Function<SensorDataPoint, Float> getter) {
        Paint paint = new Paint();
        paint.setColor(color);
        paint.setStrokeWidth(3f);
        paint.setStyle(Paint.Style.STROKE);
        paint.setAntiAlias(true);

        Path path = new Path();
        boolean first = true;

        for (int i = 0; i < data.size(); i++) {
            float val = getter.apply(data.get(i));
            float x = i * stepX;
            float y = height - ((val - minVal) / range) * height;

            if (first) {
                path.moveTo(x, y);
                first = false;
            } else {
                path.lineTo(x, y);
            }
        }
        canvas.drawPath(path, paint);
    }

    private void drawGrid(Canvas canvas, float w, float h, float min, float max) {
        Paint gridPaint = new Paint();
        gridPaint.setColor(Color.parseColor("#33ffffff"));
        gridPaint.setStrokeWidth(1f);

        // 画 4 条水平网格线
        for (int i = 0; i <= 4; i++) {
            float y = h * i / 4;
            canvas.drawLine(0, y, w, y, gridPaint);
        }
    }

    private static class SensorDataPoint {
        float x, y, z;
        long timestamp;
        SensorDataPoint(float x, float y, float z, long timestamp) {
            this.x = x; this.y = y; this.z = z; this.timestamp = timestamp;
        }
    }
}

注意:这段代码里用了 Java 8 的 Function 接口。如果你的 minSdkVersion 低于 24,需要改成手动判断,或者用 Lambda 表达式 + desugar 支持。

22.7 避坑指南

  • 坐标映射搞反了——屏幕的 Y 轴是向下为正,但数据是向上为正。记得用 height - y 来翻转,不然曲线是倒着的。
  • 数据队列溢出——如果不限制队列大小,内存会越占越多。我见过一个同事没加限制,跑了半小时 app 直接 OOM 崩溃。
  • 颜色看不清——深色背景配亮色曲线效果最好。白色背景配红绿蓝,对比度不够,看着费眼。
  • 忘记抗锯齿——setAntiAlias(true) 一定要加,不然曲线像锯齿一样,特别丑。

好了,这一章的内容就到这里。你把这个 View 加到 Activity 里,再把传感器数据喂进去,就能看到三条彩色的曲线在屏幕上实时跳动了。那种感觉,说实话,挺有成就感的。


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