21、数据可视化:用Canvas实时绘制加速度波形图

传感器数据拿到手了,怎么让它「说话」?

我个人习惯,第一件事就是画出来。数字太抽象了,波形图一看就懂。加速度传感器每秒能产生几十上百个数据点,用Canvas实时绘制波形,能直观看到手机的运动状态。这节我们就来搞定这件事。

为什么选Canvas?

Android里画图的方式不少,但实时波形图,Canvas是首选。原因很简单:

  • 轻量:不需要引入第三方库,原生API就能搞定
  • 高效:配合SurfaceView或自定义View,帧率轻松跑满60fps
  • 灵活:想怎么画就怎么画,坐标轴、网格线、图例全自己控制

我在项目里做过一个运动监测App,当时用Canvas画三轴加速度波形,实时性完全没问题。说白了,只要别在onDraw里做耗时操作,Canvas足够快。

核心思路:环形缓冲区 + 重绘

实时波形图的核心就两个东西:

  1. 数据存储:用一个固定长度的数组存最近N个数据点,新数据进来,旧数据被覆盖——这就是环形缓冲区
  2. 触发重绘:每次传感器回调来了新数据,就通知View重绘

你想想看,如果每次来数据都往List里add,内存迟早爆掉。环形缓冲区正好解决这个问题。

关键点:缓冲区长度决定了波形图显示的时间范围。比如传感器频率是50Hz,缓冲区设500,那就显示最近10秒的数据。

代码实现:自定义WaveformView

我们直接写一个自定义View,继承自View。这样用起来最灵活。

public class WaveformView extends View {
    private static final int BUFFER_SIZE = 500;
    private float[] bufferX = new float[BUFFER_SIZE];
    private float[] bufferY = new float[BUFFER_SIZE];
    private float[] bufferZ = new float[BUFFER_SIZE];
    private int currentIndex = 0;
    private int dataCount = 0;

    private Paint gridPaint;
    private Paint axisPaint;
    private Paint linePaintX, linePaintY, linePaintZ;

    public WaveformView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initPaints();
    }

    private void initPaints() {
        gridPaint = new Paint();
        gridPaint.setColor(0x22000000);
        gridPaint.setStrokeWidth(1f);

        axisPaint = new Paint();
        axisPaint.setColor(0x88000000);
        axisPaint.setStrokeWidth(2f);

        linePaintX = new Paint();
        linePaintX.setColor(0xFFE53935); // 红色 - X轴
        linePaintX.setStrokeWidth(3f);
        linePaintX.setAntiAlias(true);

        linePaintY = new Paint();
        linePaintY.setColor(0xFF43A047); // 绿色 - Y轴
        linePaintY.setStrokeWidth(3f);
        linePaintY.setAntiAlias(true);

        linePaintZ = new Paint();
        linePaintZ.setColor(0xFF1E88E5); // 蓝色 - Z轴
        linePaintZ.setStrokeWidth(3f);
        linePaintZ.setAntiAlias(true);
    }

    public void addData(float x, float y, float z) {
        bufferX[currentIndex] = x;
        bufferY[currentIndex] = y;
        bufferZ[currentIndex] = z;
        currentIndex = (currentIndex + 1) % BUFFER_SIZE;
        if (dataCount < BUFFER_SIZE) dataCount++;
        postInvalidate(); // 触发重绘
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = getWidth();
        int height = getHeight();
        int padding = 40;

        // 绘制背景网格
        drawGrid(canvas, width, height, padding);

        // 绘制三条波形
        drawWave(canvas, bufferX, linePaintX, width, height, padding);
        drawWave(canvas, bufferY, linePaintY, width, height, padding);
        drawWave(canvas, bufferZ, linePaintZ, width, height, padding);

        // 绘制图例
        drawLegend(canvas, width, padding);
    }

    private void drawWave(Canvas canvas, float[] buffer, Paint paint,
                          int width, int height, int padding) {
        int drawWidth = width - 2 * padding;
        int drawHeight = height - 2 * padding;
        int centerY = height / 2;

        // 找到有效数据的起始点
        int start = 0;
        int count = dataCount;
        if (count < 2) return;

        // 环形缓冲区的遍历需要小心
        int offset = (currentIndex - count + BUFFER_SIZE) % BUFFER_SIZE;

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

        for (int i = 0; i < count; i++) {
            int idx = (offset + i) % BUFFER_SIZE;
            float x = padding + (float) i / (count - 1) * drawWidth;
            // 将加速度值映射到画布Y坐标,范围假设为[-20, 20]
            float y = centerY - (buffer[idx] / 20f) * (drawHeight / 2f);

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

        canvas.drawPath(path, paint);
    }

    // 网格、图例等辅助方法略...
}

小技巧:环形缓冲区遍历时,一定要处理好索引取模。我刚开始写的时候忘了取模,结果数据错乱,波形图像鬼画符一样。后来加了个单元测试才排查出来。

在Activity中接入传感器

View写好了,接下来就是把传感器数据喂给它。代码很简单:

public class MainActivity extends AppCompatActivity implements SensorEventListener {
    private WaveformView waveformView;
    private SensorManager sensorManager;
    private Sensor accelerometer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        waveformView = findViewById(R.id.waveformView);
        sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
        accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
    }

    @Override
    protected void onResume() {
        super.onResume();
        sensorManager.registerListener(this, accelerometer,
                SensorManager.SENSOR_DELAY_GAME);
    }

    @Override
    protected void onPause() {
        super.onPause();
        sensorManager.unregisterListener(this);
    }

    @Override
    public void onSensorChanged(SensorEvent event) {
        float x = event.values[0];
        float y = event.values[1];
        float z = event.values[2];
        waveformView.addData(x, y, z);
    }

    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy) {
        // 一般不用处理
    }
}

这里要注意,SENSOR_DELAY_GAME大约是50Hz,够用了。如果你需要更精细的波形,可以用SENSOR_DELAY_FASTEST,但那样耗电会明显增加。

波形图的知识体系

下面这张图帮你理清整个流程:

实时波形图核心流程 加速度传感器 50Hz 数据回调 环形缓冲区 固定长度 500 WaveformView onDraw 重绘 postInvalidate() 触发下一帧 关键设计要点 • 缓冲区长度 = 采样频率 × 显示时长(如 50Hz × 10s = 500) • 数据映射:加速度值 [-20, 20] → 画布Y坐标 [padding, height-padding]

避坑指南

写实时波形图,有几个坑我踩过,分享给你:

  • 不要在onDraw里new对象:Path、Paint这些尽量在初始化时创建好。onDraw每秒调用几十次,在里面new对象会导致频繁GC,画面会卡顿。
  • 注意线程安全:传感器回调在子线程,而onDraw在主线程。环形缓冲区的读写操作要加锁,或者用AtomicInteger管理索引。我遇到过数据错乱,波形图出现「毛刺」,就是没加锁导致的。
  • 坐标系映射要合理:加速度值的范围是[-20, 20](单位m/s²),画布高度有限。如果映射比例不对,波形要么顶天立地,要么缩成一团。建议动态计算最大值,或者固定一个合理的范围。

曾经有一次,我在一个项目里忘了在onPause里注销传感器监听。结果Activity退到后台,传感器还在跑,电量哗哗地掉。用户反馈说App耗电异常,查了半天才发现是这个原因。所以,register和unregister一定要成对出现

让波形更好看

基础功能有了,还可以加点料:

  • 渐变填充:在波形下方加一个半透明渐变,视觉上更立体
  • 峰值标记:自动标注当前最大值和最小值
  • 暂停/继续:用户点击时停止更新,方便观察特定波形
  • 缩放:双指缩放时间轴,查看细节

这些功能实现起来都不难,核心就是操作环形缓冲区的数据范围和绘制参数。我在做运动监测App时,加了峰值标记功能,用户能一眼看出自己跳跃时的最大加速度,反馈特别好。

好了,实时波形图的核心就这些。代码量不大,但涉及的知识点挺全:传感器、自定义View、环形缓冲区、Canvas绘图。把这些吃透了,后面做更复杂的可视化就顺手多了。


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