21、数据可视化:用Canvas实时绘制加速度波形图
传感器数据拿到手了,怎么让它「说话」?
我个人习惯,第一件事就是画出来。数字太抽象了,波形图一看就懂。加速度传感器每秒能产生几十上百个数据点,用Canvas实时绘制波形,能直观看到手机的运动状态。这节我们就来搞定这件事。
为什么选Canvas?
Android里画图的方式不少,但实时波形图,Canvas是首选。原因很简单:
- 轻量:不需要引入第三方库,原生API就能搞定
- 高效:配合SurfaceView或自定义View,帧率轻松跑满60fps
- 灵活:想怎么画就怎么画,坐标轴、网格线、图例全自己控制
我在项目里做过一个运动监测App,当时用Canvas画三轴加速度波形,实时性完全没问题。说白了,只要别在onDraw里做耗时操作,Canvas足够快。
核心思路:环形缓冲区 + 重绘
实时波形图的核心就两个东西:
- 数据存储:用一个固定长度的数组存最近N个数据点,新数据进来,旧数据被覆盖——这就是环形缓冲区
- 触发重绘:每次传感器回调来了新数据,就通知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,但那样耗电会明显增加。
波形图的知识体系
下面这张图帮你理清整个流程:
避坑指南
写实时波形图,有几个坑我踩过,分享给你:
- 不要在onDraw里new对象:Path、Paint这些尽量在初始化时创建好。onDraw每秒调用几十次,在里面new对象会导致频繁GC,画面会卡顿。
- 注意线程安全:传感器回调在子线程,而onDraw在主线程。环形缓冲区的读写操作要加锁,或者用AtomicInteger管理索引。我遇到过数据错乱,波形图出现「毛刺」,就是没加锁导致的。
- 坐标系映射要合理:加速度值的范围是[-20, 20](单位m/s²),画布高度有限。如果映射比例不对,波形要么顶天立地,要么缩成一团。建议动态计算最大值,或者固定一个合理的范围。
曾经有一次,我在一个项目里忘了在onPause里注销传感器监听。结果Activity退到后台,传感器还在跑,电量哗哗地掉。用户反馈说App耗电异常,查了半天才发现是这个原因。所以,register和unregister一定要成对出现。
让波形更好看
基础功能有了,还可以加点料:
- 渐变填充:在波形下方加一个半透明渐变,视觉上更立体
- 峰值标记:自动标注当前最大值和最小值
- 暂停/继续:用户点击时停止更新,方便观察特定波形
- 缩放:双指缩放时间轴,查看细节
这些功能实现起来都不难,核心就是操作环形缓冲区的数据范围和绘制参数。我在做运动监测App时,加了峰值标记功能,用户能一眼看出自己跳跃时的最大加速度,反馈特别好。
好了,实时波形图的核心就这些。代码量不大,但涉及的知识点挺全:传感器、自定义View、环形缓冲区、Canvas绘图。把这些吃透了,后面做更复杂的可视化就顺手多了。
公众号:蓝海资料掘金营,微信deep3321