6、仪表盘开发实战(上):圆形仪表盘绘制、Canvas 2D渲染、指针动画与刻度线生成

各位同学,欢迎来到仪表盘开发实战的第一部分。说实话,仪表盘是车载HMI里最见功力的模块之一。我早年做第一个仪表项目时,踩了不少坑,今天把这些经验揉碎了讲给你听。

这一章我们聚焦三个核心:圆形仪表盘的绘制、Canvas 2D渲染、指针动画与刻度线生成。说白了,就是让你从零手撸一个能转起来的仪表盘。

本章核心知识点

  • 圆形仪表盘的坐标系与绘制流程
  • Canvas 2D 在 Qt Quick 中的使用方式
  • 刻度线的数学生成算法
  • 指针动画的平滑实现

6.1 圆形仪表盘的坐标系与绘制流程

先聊坐标系。仪表盘本质上是一个极坐标系的应用。圆心在 (width/2, height/2),半径决定了表盘大小。角度从 -135° 到 +135° 是常见的布局(也就是左下到右下,留出底部空间给信息显示)。

我习惯把绘制流程拆成四层:

  1. 背景层:圆环、底色、渐变效果
  2. 刻度层:主刻度、次刻度、数字标签
  3. 指针层:指针本体、中心圆盖
  4. 装饰层:高光、阴影、外圈

为什么要分层?因为每一层的刷新频率不同。指针动画需要60fps,刻度线只需要绘制一次。混在一起画,性能会崩。我在项目中见过有人把所有东西塞到一个 onPaint 里,结果指针一抖一抖的——嗯,这就是不分层的后果。

我的习惯:用 CanvasrequestAnimationFrame 驱动指针层,刻度层用 Canvas 的静态绘制,只画一次。

6.2 Canvas 2D 在 Qt Quick 中的使用

Qt Quick 里的 Canvas 元素,本质上是对 HTML5 Canvas 的封装。API 几乎一样,但有几个坑要注意。

先看一个最简的仪表盘骨架:

Canvas {
    id: dashboardCanvas
    width: 400; height: 400

    onPaint: {
        var ctx = getContext("2d");
        ctx.clearRect(0, 0, width, height);

        // 绘制外圈
        ctx.beginPath();
        ctx.arc(width/2, height/2, 180, 0, Math.PI * 2);
        ctx.strokeStyle = "#333";
        ctx.lineWidth = 4;
        ctx.stroke();

        // 绘制刻度线(后面细讲)
        drawTicks(ctx);
    }

    function drawTicks(ctx) {
        // 刻度生成逻辑
    }
}

这里有个细节:ctx.clearRect 必须放在最前面,否则上一帧的残留会叠加上去。我刚开始做的时候忘了这步,指针拖出了一条长长的尾巴——像彗星一样,还挺好看,但客户不买账。

Canvas 2D 的坐标系默认是左上角为原点,x向右,y向下。画圆形仪表盘时,记得把原点平移到中心:

ctx.translate(width/2, height/2);

这样后面所有的坐标计算都基于圆心,省心很多。

6.3 刻度线生成算法

刻度线是仪表盘的「骨架」。生成算法其实很简单:遍历角度,计算起点和终点坐标

假设仪表盘范围是 -135° 到 +135°,总共 240° 的跨度。主刻度每 30° 一个,次刻度每 10° 一个。

function drawTicks(ctx) {
    var centerX = width/2;
    var centerY = height/2;
    var radius = 160;
    var startAngle = -135 * Math.PI / 180;
    var endAngle = 135 * Math.PI / 180;
    var totalAngle = endAngle - startAngle;

    // 主刻度:每30度一个
    for (var i = 0; i <= 8; i++) {
        var angle = startAngle + (totalAngle / 8) * i;
        var x1 = centerX + (radius - 15) * Math.cos(angle);
        var y1 = centerY + (radius - 15) * Math.sin(angle);
        var x2 = centerX + radius * Math.cos(angle);
        var y2 = centerY + radius * Math.sin(angle);

        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.lineTo(x2, y2);
        ctx.strokeStyle = "#fff";
        ctx.lineWidth = 3;
        ctx.stroke();
    }

    // 次刻度:每10度一个
    for (var j = 0; j <= 24; j++) {
        var angle = startAngle + (totalAngle / 24) * j;
        var x1 = centerX + (radius - 8) * Math.cos(angle);
        var y1 = centerY + (radius - 8) * Math.sin(angle);
        var x2 = centerX + radius * Math.cos(angle);
        var y2 = centerY + radius * Math.sin(angle);

        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.lineTo(x2, y2);
        ctx.strokeStyle = "#aaa";
        ctx.lineWidth = 1.5;
        ctx.stroke();
    }
}

你想想看,为什么主刻度线比次刻度长?因为人眼需要快速区分层级。主刻度长一些、粗一些,视觉上更突出。这是UI设计的基本功。

我曾经踩过的坑:角度单位搞混了。JavaScript 的 Math 函数全部用弧度,但我一开始写成了角度,结果刻度线全挤在右下角。调试了半小时才发现。建议你在代码开头加个注释:// 所有角度均为弧度

6.4 指针动画与平滑实现

指针动画是仪表盘的灵魂。说白了,就是让指针从当前值平滑过渡到目标值。

我推荐用 线性插值 + 定时器 的方式。不要直接用 Rotation 动画,因为那样很难控制加速度和阻尼效果。

property real currentAngle: -135
property real targetAngle: -135
property real speed: 3.0  // 每帧增加的角度

function updatePointer() {
    if (Math.abs(currentAngle - targetAngle) < 0.5) {
        currentAngle = targetAngle;
        return;
    }

    var diff = targetAngle - currentAngle;
    currentAngle += diff * 0.15;  // 缓动效果

    canvas.requestPaint();
    // 下一帧继续
    if (Math.abs(currentAngle - targetAngle) > 0.5) {
        canvas.afterFrame(updatePointer);
    }
}

这里用了 diff * 0.15 的缓动系数。系数越小,指针越「肉」,看起来更稳重。系数越大,指针越「贼」,适合运动模式。我在做某款电动车的仪表时,经济模式用 0.1,运动模式用 0.3,用户反馈说手感很真实。

绘制指针本身很简单:

function drawPointer(ctx, angle) {
    ctx.save();
    ctx.translate(width/2, height/2);
    ctx.rotate(angle * Math.PI / 180);

    // 指针主体
    ctx.beginPath();
    ctx.moveTo(0, -140);   // 指针尖
    ctx.lineTo(-6, 20);    // 左侧
    ctx.lineTo(6, 20);     // 右侧
    ctx.closePath();
    ctx.fillStyle = "#ff4444";
    ctx.fill();

    // 中心圆盖
    ctx.beginPath();
    ctx.arc(0, 0, 12, 0, Math.PI * 2);
    ctx.fillStyle = "#222";
    ctx.fill();

    ctx.restore();
}

注意 ctx.save()ctx.restore() 的成对使用。如果不恢复坐标系,后面的绘制全乱套了。

6.5 性能优化小贴士

仪表盘是实时渲染的,性能至关重要。我总结了几条经验:

优化点 做法 效果
减少绘制调用 刻度线只画一次,存为离屏缓存 减少 70% 的绘制时间
避免频繁创建对象 onPaint 外定义颜色、路径等常量 减少 GC 压力
使用 afterFrame 代替 Timer 驱动动画 与屏幕刷新率同步,更流畅
降低抗锯齿 非关键元素关闭 antialiasing 提升低端设备帧率

我的建议:在开发阶段用 console.profile() 分析绘制耗时。我曾经发现一个项目里,刻度线每帧都在重新计算三角函数,改成预计算后帧率从 30fps 飙到了 60fps。

6.6 本章知识体系

下面这张图帮你梳理本章的核心逻辑:

仪表盘开发实战(上)知识体系 坐标系与分层 Canvas 2D 渲染 刻度线生成 指针动画 背景层 / 刻度层 指针层 / 装饰层 onPaint 事件 ctx 操作 / 坐标系平移 主刻度 / 次刻度 角度遍历 / 坐标计算 线性插值 缓动系数 / 帧驱动 核心目标:从零实现一个可交互的圆形仪表盘

这张图把本章的四个核心模块串起来了。从左到右,从坐标系到动画,每一步都依赖前一步。刻度线生成是数学基础,Canvas 2D 是渲染工具,指针动画是交互灵魂。

好了,这一章的内容就到这里。代码示例你可以在本地跑跑看,调调缓动系数,感受一下指针的「手感」。下一章我们继续深入,聊仪表盘的交互事件和数值绑定。


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