25、传感器数据可视化:使用Canvas绘制实时磁场强度波形图

数据可视化,说白了就是把枯燥的数字变成眼睛能看懂的东西。

做霍尔传感器开发时,我最头疼的就是调试阶段。你拿着手机晃来晃去,Logcat里刷出一堆数字,根本看不出磁场变化规律。后来我干脆写了个实时波形图,把磁场强度画成曲线,问题一下子清晰了。

这一节,我们就来搞定这件事。

25.1 为什么需要实时波形图

你想想看,传感器数据是连续变化的。单个数值只能告诉你「现在磁场多强」,但你看不到趋势。

  • 磁场是平稳的,还是在波动?
  • 是突然跳变,还是缓慢变化?
  • 噪声有多大?

这些问题,看数字很难判断。但画成波形图,一眼就能看出来。

核心思路:每次传感器回调新数据,就往数组末尾追加一个值,同时把数组最前面的值丢掉。然后重绘Canvas,把数组里的数据连成线。

25.2 数据结构设计

我习惯用一个固定长度的队列来存数据。为什么用固定长度?因为波形图宽度有限,存太多数据也没意义。

// 最大显示点数,对应屏幕宽度
private static final int MAX_DATA_POINTS = 200;
// 存储磁场强度值的队列
private float[] magneticData = new float[MAX_DATA_POINTS];
// 当前有效数据个数
private int dataCount = 0;

每次新数据来了,做两件事:

  1. 如果数据还没满,直接追加到末尾
  2. 如果数据已满,把所有数据左移一位,新数据放在最后
public void addDataPoint(float value) {
    if (dataCount < MAX_DATA_POINTS) {
        magneticData[dataCount] = value;
        dataCount++;
    } else {
        // 左移一位,丢掉最旧的数据
        System.arraycopy(magneticData, 1, magneticData, 0, MAX_DATA_POINTS - 1);
        magneticData[MAX_DATA_POINTS - 1] = value;
    }
    // 触发重绘
    invalidate();
}

嗯,这里要注意:System.arraycopy 比手动循环快得多。我在早期项目里吃过亏,用for循环左移,结果界面卡顿。后来换成这个,流畅多了。

25.3 Canvas绘制波形

绘制波形图,核心就三步:

  1. 画背景和网格线
  2. 把数据映射到屏幕坐标
  3. 用Path把点连成线

25.3.1 画背景和网格

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    
    float width = getWidth();
    float height = getHeight();
    
    // 背景
    canvas.drawColor(Color.parseColor("#1a1a2e"));
    
    // 画网格线
    Paint gridPaint = new Paint();
    gridPaint.setColor(Color.parseColor("#2d2d5e"));
    gridPaint.setStrokeWidth(1);
    
    // 水平网格线(5条)
    for (int i = 0; i <= 4; i++) {
        float y = height * i / 4;
        canvas.drawLine(0, y, width, y, gridPaint);
    }
    
    // 垂直网格线(10条)
    for (int i = 0; i <= 10; i++) {
        float x = width * i / 10;
        canvas.drawLine(x, 0, x, height, gridPaint);
    }
}

25.3.2 数据映射到屏幕坐标

传感器返回的磁场强度单位是微特斯拉(μT),范围大概在 -100 到 100 之间。但屏幕坐标是像素,需要做映射。

// 假设磁场范围 -80μT ~ 80μT
private static final float MIN_VALUE = -80f;
private static final float MAX_VALUE = 80f;

private float mapValueToY(float value, float height) {
    // 先把值归一化到 0~1
    float normalized = (value - MIN_VALUE) / (MAX_VALUE - MIN_VALUE);
    // 翻转Y轴(屏幕Y向下为正)
    return height * (1 - normalized);
}

为什么会翻转Y轴?因为Canvas的坐标系Y轴朝下,而我们习惯Y轴朝上表示数值增大。所以用 1 - normalized 做个翻转。

25.3.3 画波形线

private void drawWaveform(Canvas canvas, float width, float height) {
    if (dataCount < 2) return;
    
    Paint linePaint = new Paint();
    linePaint.setColor(Color.parseColor("#00d2ff"));
    linePaint.setStrokeWidth(3);
    linePaint.setStyle(Paint.Style.STROKE);
    linePaint.setAntiAlias(true);
    
    Path path = new Path();
    float stepX = width / MAX_DATA_POINTS;
    
    // 第一个点
    float firstY = mapValueToY(magneticData[0], height);
    path.moveTo(0, firstY);
    
    // 连接后续点
    for (int i = 1; i < dataCount; i++) {
        float x = i * stepX;
        float y = mapValueToY(magneticData[i], height);
        path.lineTo(x, y);
    }
    
    canvas.drawPath(path, linePaint);
}

小技巧:用 Path.lineTo 画折线就够了。如果想更平滑,可以用 Path.cubicTo 画贝塞尔曲线。但我个人觉得,实时波形图用折线更清晰,能看出每个采样点的真实值。

25.4 完整的绘制流程

把上面几块拼起来,就是完整的 onDraw 方法:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    
    float width = getWidth();
    float height = getHeight();
    
    // 1. 画背景
    canvas.drawColor(Color.parseColor("#1a1a2e"));
    
    // 2. 画网格
    drawGrid(canvas, width, height);
    
    // 3. 画波形
    drawWaveform(canvas, width, height);
    
    // 4. 画当前值标注
    drawCurrentValue(canvas, width, height);
}

25.5 与霍尔传感器对接

有了自定义View,接下来就是把它和传感器数据连起来。

public class MainActivity extends AppCompatActivity {
    
    private WaveformView waveformView;
    private SensorManager sensorManager;
    private Sensor magneticSensor;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        waveformView = new WaveformView(this);
        setContentView(waveformView);
        
        sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
        magneticSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
    }
    
    @Override
    protected void onResume() {
        super.onResume();
        sensorManager.registerListener(sensorEventListener, 
            magneticSensor, SensorManager.SENSOR_DELAY_UI);
    }
    
    @Override
    protected void onPause() {
        super.onPause();
        sensorManager.unregisterListener(sensorEventListener);
    }
    
    private SensorEventListener sensorEventListener = new SensorEventListener() {
        @Override
        public void onSensorChanged(SensorEvent event) {
            // 取X轴磁场强度
            float xValue = event.values[0];
            // 添加到波形图
            waveformView.addDataPoint(xValue);
        }
        
        @Override
        public void onAccuracyChanged(Sensor sensor, int accuracy) {
            // 精度变化时可以做些处理
        }
    };
}

注意SENSOR_DELAY_UI 大约是 60ms 回调一次,对波形图来说足够了。如果用 SENSOR_DELAY_FASTEST,数据更新太快,反而看不清趋势,而且耗电。

25.6 知识体系总览

下面这张图,把整个流程串起来了:

霍尔传感器实时波形图知识体系 霍尔传感器 TYPE_MAGNETIC_FIELD 数据队列 固定长度200 Canvas绘制 onDraw 绘制流程详解 ① 画背景色 ② 画网格线 ③ 数据映射 ④ 连线 数据映射公式:y = height × (1 - (value - min) / (max - min)) 采样频率:SENSOR_DELAY_UI ≈ 60ms/次

25.7 避坑指南

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

  • 不要在 onDraw 里 new 对象:Paint、Path 这些对象,在构造函数里创建好,重复使用。否则GC频繁触发,界面会卡顿。
  • 数据队列用数组,别用 ArrayList:ArrayList 的 remove(0) 操作是 O(n) 的,而且会触发内存拷贝。用数组加 System.arraycopy 效率高得多。
  • 注意线程安全:传感器回调在子线程,onDraw 在主线程。我习惯用 invalidate() 触发重绘,而不是直接在回调里画图。
  • Y轴范围要动态调整:如果磁场强度突然超出预设范围,波形会跑到屏幕外面。我后来加了个自适应范围,根据最近100个点的最大值最小值自动调整。

我曾经遇到过一个问题:波形图在低端手机上画出来有锯齿。后来发现是没开抗锯齿。加上 setAntiAlias(true) 之后,曲线平滑多了。

25.8 扩展思路

基础波形图做好了,还可以加些实用功能:

  • 多通道显示:同时画 X、Y、Z 三轴磁场,用不同颜色区分
  • 峰值标记:自动标出当前窗口内的最大值和最小值
  • 暂停/继续:点击屏幕暂停波形,方便分析某个瞬间的数据
  • 导出数据:把波形数据导出成 CSV 文件,用 Excel 做进一步分析

这些功能我在实际项目里都用过。特别是多通道显示,调试地磁传感器时,能同时看到三个轴的变化,定位问题快很多。

好了,波形图这部分就讲到这里。代码量不大,但实用性很强。你把它跑起来,拿手机在磁铁旁边晃一晃,就能看到磁场强度实时变化的曲线了。


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