22、数据可视化:使用 Canvas 实时绘制陀螺仪数据曲线
说实话,做传感器开发最头疼的是什么?
不是拿不到数据,而是拿到了数据,你盯着那一串串数字,根本看不出门道。我刚开始做陀螺仪项目时,就靠 Logcat 打印数值,看得眼睛都快瞎了。后来我学乖了——数据必须画出来,一眼就能看出问题。
这一章,我们就来搞定这件事。用 Android 的 Canvas,把陀螺仪的 X、Y、Z 三轴数据实时画成曲线。说白了,就是做一个手机上的“心电图”,只不过心跳换成了角速度。
22.1 核心思路:数据怎么变成曲线?
先理一下逻辑。你要在屏幕上画一条会动的曲线,需要做三件事:
- 存数据——用一个队列,不断往里塞最新的传感器数值
- 画数据——遍历队列里的每个点,用 Canvas 的 drawLine 连起来
- 刷新画面——每次有新数据,就重绘一次 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 图,把整个流程串起来了。你照着这个思路写代码,不会乱。
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