3、绘制基础:Canvas绘图API、颜色格式、抗锯齿原理

说实话,很多做TouchGFX的朋友一开始都绕过了Canvas。觉得它慢,觉得它复杂,不如直接用现成的控件。我以前也这么想,直到有一次客户要求做一个圆形的进度环,还得带渐变和阴影——标准控件根本搞不定。没办法,硬着头皮啃Canvas,结果发现这东西其实没那么玄乎。

这一章,我们就来把Canvas的底裤扒干净。你搞懂了这三个东西——绘图API、颜色格式、抗锯齿原理,后面做自定义控件就跟玩似的。

3.1 Canvas绘图API:你手里的“画笔”

TouchGFX的Canvas,说白了就是一块画布。你可以在上面画线、画圆、画弧、画路径。它的核心API都在touchgfx::Canvas这个类里。我习惯把它想象成一支带参数配置的笔——你告诉它画什么、怎么画,它就给你画出来。

3.1.1 基本绘图操作

先看几个最常用的API:

// 画一条直线
Canvas canvas(screenBuffer, CANVAS_BUFFER_SIZE);
canvas.moveTo(10, 10);
canvas.lineTo(100, 100);
canvas.stroke();

// 画一个矩形
canvas.moveTo(20, 20);
canvas.lineTo(80, 20);
canvas.lineTo(80, 80);
canvas.lineTo(20, 80);
canvas.closePath();
canvas.stroke();

// 画一个圆弧
canvas.arc(centerX, centerY, radius, startAngle, endAngle);
canvas.stroke();

这里有个坑——moveTo和lineTo是基础操作,但很多人忘了closePath。如果你画一个封闭图形但不调用closePath,它最后一条边就不会自动闭合。我曾经在一个仪表盘项目里漏了这个,指针尾部一直有个小缺口,找了半天才找到原因。

3.1.2 路径与填充

Canvas支持两种绘制模式:描边(stroke)和填充(fill)。描边就是画轮廓,填充就是涂颜色。你可以组合使用:

canvas.beginPath();
canvas.moveTo(50, 50);
canvas.lineTo(150, 50);
canvas.lineTo(100, 150);
canvas.closePath();

// 先填充,再描边
canvas.fill(Color::getColorFrom24BitRGB(0x34, 0x98, 0xDB));
canvas.stroke(Color::getColorFrom24BitRGB(0x29, 0x80, 0xB9));

注意顺序——先填充再描边,否则描边会被填充盖住。我刚开始做的时候顺序搞反了,描边线总是若隐若现,还以为是抗锯齿的问题。

3.1.3 线宽与样式

Canvas允许你设置线宽、线帽和连接样式:

属性 方法 说明
线宽 setLineWidth(width) 像素单位,默认1
线帽 setLineCap(LineCap::ROUND) BUTT/ROUND/SQUARE
连接样式 setLineJoin(LineJoin::ROUND) MITER/BEVEL/ROUND
我的习惯:画进度条或仪表盘指针时,线帽用ROUND,连接用ROUND。这样画出来的线条末端是圆润的,视觉上更舒服。如果是画网格或刻度线,用BUTT更干净。

3.2 颜色格式:别让颜色“失真”

TouchGFX支持多种颜色格式。你选错了格式,显示出来的颜色就会偏色、断层,甚至完全不对。我见过有人用RGB888的图片往RGB565的屏幕上怼,结果整个画面像蒙了一层纱。

3.2.1 常见颜色格式对比

格式 位数 颜色数 适用场景
RGB565 16位 65,536 大多数MCU屏幕
RGB888 24位 16,777,216 高性能MCU或RGB接口屏
ARGB8888 32位 16,777,216 + Alpha 需要透明效果的场景
BW 1位 2 低端单色屏

RGB565是嵌入式里最常用的。为什么?因为它占内存少,传输快。但代价是颜色精度低——你从RGB888转成RGB565,颜色会丢失一些细节。比如纯红色(0xFF0000)转成RGB565是0xF800,看起来没问题。但一些中间色,比如浅紫色,转过去可能就变成灰蒙蒙的了。

3.2.2 颜色转换与操作

TouchGFX提供了Color工具类来处理颜色转换:

// 从RGB888获取颜色
colortype c = Color::getColorFrom24BitRGB(0x34, 0x98, 0xDB);

// 从RGB565获取颜色
colortype c = Color::getColorFromRGB565(0x34DB);

// 获取颜色的RGB分量
uint8_t r = Color::getRedColor(c);
uint8_t g = Color::getGreenColor(c);
uint8_t b = Color::getBlueColor(c);
注意:在Canvas中绘图时,颜色参数必须使用colortype类型。不要直接传RGB值。我见过有人写canvas.stroke(0xFF0000),结果画出来是黑色——因为colortype是16位的,0xFF0000被截断了。

3.2.3 颜色格式对性能的影响

选颜色格式不只是视觉问题,还直接影响性能。RGB565的Canvas缓冲区大小是:宽度 × 高度 × 2字节。RGB888是宽度 × 高度 × 3字节。如果你的MCU内存只有64KB,用RGB888画一个320×240的Canvas,缓冲区就要230KB——直接爆了。

我一般这样选:

  • 内存紧张:RGB565,牺牲一点颜色精度
  • 需要透明:ARGB8888,但只在小区域用
  • 显示图片为主:RGB565,图片本身已经做了抖动优化

3.3 抗锯齿原理:让边缘不再“狗啃”

你想想看,屏幕上的像素是方形的。画一条斜线,如果不做处理,边缘就是锯齿状的——这就是“狗啃”效果。抗锯齿(Anti-Aliasing)就是通过模糊边缘像素来消除这种锯齿感。

3.3.1 抗锯齿是怎么工作的?

TouchGFX的Canvas默认开启了抗锯齿。它的原理很简单:

  1. 计算线条经过的像素区域
  2. 根据线条穿过像素的面积比例,决定该像素的透明度
  3. 边缘像素不是全黑或全白,而是半透明的过渡色

举个例子:一条45度斜线穿过一个像素,只覆盖了像素的30%。那么抗锯齿算法就会把这个像素填充为30%的线条颜色 + 70%的背景颜色。人眼看起来就是平滑的过渡。

3.3.2 抗锯齿的代价

抗锯齿不是免费的。它需要额外的计算和内存:

模式 计算量 内存占用 视觉效果
无抗锯齿 低 低 锯齿明显
2x抗锯齿 中 中 边缘平滑
4x抗锯齿 高 高 非常平滑

TouchGFX默认使用2x抗锯齿。对于大多数嵌入式屏幕来说,这个平衡点刚刚好。如果你发现绘制性能不够,可以考虑关闭抗锯齿:

canvas.setAntiAlias(false);

但我不建议这么做。除非你的屏幕分辨率极低(比如128×64),或者你画的是纯水平/垂直线——这些线本来就没有锯齿。

核心观点:抗锯齿的本质是用“模糊”换“平滑”。在嵌入式系统里,你需要在视觉效果和性能之间找平衡。我个人建议:圆形、弧形、斜线必须开抗锯齿;水平/垂直线可以关掉。

3.4 知识体系总览

下面这张图把本章的核心知识点串起来了。你可以看到Canvas绘图API、颜色格式、抗锯齿原理三者之间的关系:

Canvas绘图知识体系 Canvas绘图知识体系 Canvas绘图API 颜色格式 抗锯齿原理 moveTo/lineTo arc/closePath stroke/fill RGB565/RGB888 ARGB8888/BW 颜色转换 2x/4x抗锯齿 性能权衡 边缘模糊 三者协同:API决定画什么,颜色格式决定怎么存,抗锯齿决定怎么显示

3.5 实战:画一个带抗锯齿的圆弧

光说不练假把式。我们写一个完整的例子,画一个带抗锯齿的圆弧,同时演示颜色格式和Canvas API的配合:

// 假设屏幕颜色格式为RGB565
// Canvas缓冲区
uint16_t canvasBuffer[CANVAS_BUFFER_SIZE];

void drawArcWithAA(int16_t centerX, int16_t centerY, int16_t radius,
                   int16_t startAngle, int16_t endAngle, colortype color)
{
    Canvas canvas(canvasBuffer, CANVAS_BUFFER_SIZE);
    
    // 开启抗锯齿(默认就是开启的,但显式设置更安全)
    canvas.setAntiAlias(true);
    
    // 设置线宽和样式
    canvas.setLineWidth(4);
    canvas.setLineCap(Canvas::LineCap::ROUND);
    canvas.setLineJoin(Canvas::LineJoin::ROUND);
    
    // 画圆弧
    canvas.beginPath();
    canvas.arc(centerX, centerY, radius, startAngle, endAngle);
    canvas.stroke(color);
    
    // 刷新显示区域
    HAL::getInstance()->flushFrameBuffer(
        Rect(centerX - radius - 5, centerY - radius - 5,
             radius * 2 + 10, radius * 2 + 10));
}
避坑指南:Canvas的缓冲区大小要提前算好。公式是:缓冲区大小 = 绘制区域宽度 × 绘制区域高度 × 每个像素字节数。如果你画的是全屏,缓冲区可能很大。我一般只在小区域用Canvas,比如画一个100×100的仪表盘,缓冲区只需要100×100×2=20KB。