刷屏策略优化:局部刷新、脏矩形算法、水平/垂直滚动

各位同学,今天我们来聊聊刷屏策略。说实话,这是我在手环项目里踩坑最多的地方。一开始我天真地以为,只要把LCD驱动写对就行了,结果一上电,画面卡得像幻灯片。嗯,那时候我才意识到——刷屏策略,才是决定用户体验的关键。

为什么需要刷屏策略?

你想想看,手环的屏幕虽然小,但刷新频率要求可不低。60fps的流畅度,意味着每16.6ms就要刷一帧。如果每次都是全屏刷新,那CPU和内存带宽的压力非常大。我见过不少新手,上来就搞全屏刷新,结果帧率掉到20fps,动画卡得不行。

说白了,全屏刷新是「笨办法」。你明明只改了一个数字,却要把整个屏幕的数据都重新传一遍。这就像你家里只换了一盏灯,却要把整栋楼的电线都拆了重装——浪费资源。

局部刷新:只改该改的地方

局部刷新的思路很简单:只更新变化的那部分像素。我在做手环表盘时,秒针每秒钟只动一小块区域,完全没必要刷新整个屏幕。

具体怎么做呢?你需要知道LCD的窗口设置命令。大多数LCD控制器都支持设置一个矩形区域,然后只往这个区域写数据。比如ST7789,通过CASET和RASET命令就能设定窗口。

// 设置局部刷新窗口
void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) {
    // 列地址范围
    lcd_write_cmd(0x2A);  // CASET
    lcd_write_data(x0 >> 8);
    lcd_write_data(x0 & 0xFF);
    lcd_write_data(x1 >> 8);
    lcd_write_data(x1 & 0xFF);
    
    // 行地址范围
    lcd_write_cmd(0x2B);  // RASET
    lcd_write_data(y0 >> 8);
    lcd_write_data(y0 & 0xFF);
    lcd_write_data(y1 >> 8);
    lcd_write_data(y1 & 0xFF);
    
    // 开始写入像素
    lcd_write_cmd(0x2C);  // RAMWR
}

我在项目中遇到过一个问题:局部刷新时,如果窗口边界没对齐,会出现残影。后来发现是LCD的GRAM在写入时,如果窗口外的数据没被覆盖,就会保留旧内容。所以每次局部刷新前,最好先清一下目标区域。

小技巧:局部刷新的窗口大小最好是8的倍数。很多LCD控制器的数据总线是8位或16位,对齐后能减少内部处理开销。

脏矩形算法:智能合并刷新区域

脏矩形算法,说白了就是「记录哪些地方变了,然后合并成几个矩形去刷新」。我最早做手环通知显示时,每次来消息都要刷新整个通知区域,后来发现其实只有文字部分在变。

算法核心思路是这样的:

  1. 维护一个脏矩形列表,记录所有需要刷新的区域
  2. 每次有新变化时,把变化区域加入列表
  3. 定期合并相邻或重叠的脏矩形
  4. 按合并后的矩形进行局部刷新

合并策略很关键。我习惯用「贪心合并法」:遍历脏矩形列表,如果两个矩形在水平或垂直方向上有重叠,或者间距小于某个阈值(比如4像素),就把它们合并成一个更大的矩形。

// 脏矩形合并示例
typedef struct {
    uint16_t x, y, w, h;
} Rect;

Rect merge_rects(Rect a, Rect b) {
    Rect merged;
    merged.x = min(a.x, b.x);
    merged.y = min(a.y, b.y);
    merged.w = max(a.x + a.w, b.x + b.w) - merged.x;
    merged.h = max(a.y + a.h, b.y + b.h) - merged.y;
    return merged;
}

// 判断两个矩形是否可合并
bool should_merge(Rect a, Rect b, uint16_t threshold) {
    // 水平或垂直方向有重叠,或间距小于阈值
    if (abs(a.x - b.x) <= threshold || abs(a.y - b.y) <= threshold) {
        return true;
    }
    return false;
}
注意:脏矩形合并不是越多越好。合并后的矩形太大,反而失去了局部刷新的优势。我曾经把整个屏幕都合并成一个矩形,那还不如直接全屏刷新呢。一般建议合并后的矩形面积不超过屏幕面积的1/4。

水平/垂直滚动:硬件加速的利器

这个功能,说实话,很多开发者都忽略了。大多数LCD控制器都内置了硬件滚动功能,你只需要设置滚动起始地址和滚动方向,硬件会自动完成像素移动,完全不需要CPU干预。

我在做手环的「消息列表滚动」时,就用到了垂直滚动。用户滑动屏幕时,只需要更新滚动起始地址,画面就丝滑地滚动了。

// 垂直滚动设置(ST7789示例)
void lcd_vertical_scroll(uint16_t top_fixed, uint16_t scroll_area, uint16_t bottom_fixed) {
    lcd_write_cmd(0x33);  // VSCRDEF
    lcd_write_data(top_fixed >> 8);
    lcd_write_data(top_fixed & 0xFF);
    lcd_write_data(scroll_area >> 8);
    lcd_write_data(scroll_area & 0xFF);
    lcd_write_data(bottom_fixed >> 8);
    lcd_write_data(bottom_fixed & 0xFF);
}

// 设置滚动起始行
void lcd_scroll_start(uint16_t line) {
    lcd_write_cmd(0x37);  // VSCRSADD
    lcd_write_data(line >> 8);
    lcd_write_data(line & 0xFF);
}

水平滚动也类似,不过用得少一些。我只有在做「跑马灯」效果时用过水平滚动,比如手环上的通知滚动条。

核心要点:硬件滚动比软件滚动快10倍以上。因为硬件滚动是在LCD控制器内部完成的,不占用MCU的DMA和内存带宽。如果你的LCD支持,尽量用硬件滚动。

三种策略如何选择?

我个人的经验是这样的:

场景 推荐策略 原因
秒针/数字变化 局部刷新 变化区域小,直接刷新最快
复杂UI动画 脏矩形算法 多个元素同时变化,合并后减少刷新次数
列表滚动/跑马灯 硬件滚动 CPU零负担,帧率最高
全屏切换 全屏刷新 变化太大,局部刷新反而更慢

你可能会问:「能不能三种混着用?」当然可以。我在手环项目里就是混合使用的:表盘用局部刷新,通知列表用脏矩形算法,消息滚动用硬件滚动。关键是做好状态管理,别让它们冲突。

避坑指南:我曾经在硬件滚动和局部刷新之间切换时,忘记重置LCD的滚动设置,结果画面错位了半小时才找到原因。记住:使用硬件滚动前,一定要先关闭滚动模式(写入0x00到滚动控制寄存器)。

好了,刷屏策略就讲到这里。下一章我们会聊聊帧率优化,到时候会结合这些策略,教你如何把手环的帧率从30fps提升到60fps。嗯,那才是真正考验功底的地方。