3、低功耗刷新策略:静态页面检测、差分刷新、区域刷新调度、帧率自适应控制
OLED 的低功耗刷新,说白了就是「能不刷就不刷,能少刷就少刷」。我刚开始做嵌入式 UI 时,总觉得屏幕刷新越快越流畅,结果电池续航直接腰斩。后来才明白——对于 OLED,每一帧的像素点亮都是在消耗能量,尤其是白色背景,功耗比黑色高好几倍。
这一章,我们来拆解四个核心策略:静态页面检测、差分刷新、区域刷新调度、帧率自适应控制。它们组合起来,能让你的 OLED 功耗降低 60% 以上,同时保持用户体验不降级。
3.1 静态页面检测:别做无用功
很多场景下,屏幕内容根本没变——比如用户在看一页文字,或者停在某个菜单。但如果你不管三七二十一,以 60fps 持续刷新,那就是在浪费电量。
静态页面检测的核心思路:比较当前帧与上一帧的帧缓冲数据,如果差异小于某个阈值,就判定为「静态」,然后停止刷新或进入极低刷新模式。
关键指标:
- 像素变化率:变化像素数 / 总像素数。低于 0.1% 可判定为静态。
- 检测间隔:每 10~30 帧做一次全缓冲比较,避免每帧都做增加开销。
- 去抖机制:连续 3 次检测均为静态,才真正进入低功耗模式。防止闪烁误判。
我的经验: 我曾经在一个智能手表项目里,直接用 memcmp() 比较两个帧缓冲,发现每次比较要花 2ms。后来改成「分块哈希比较」——把屏幕分成 16x16 的块,每块算一个 CRC,只比较 CRC 值。比较时间降到了 0.1ms,而且能精确定位变化区域。
3.2 差分刷新:只发变化的部分
静态页面检测告诉你「要不要刷」,差分刷新告诉你「刷哪里」。传统做法是每次刷新都发送整帧数据,哪怕只变了一个像素。差分刷新只传输变化区域的像素数据,配合 OLED 驱动 IC 的「窗口设置命令」(如 SSD1306 的 SetColumnAddress / SetPageAddress),可以大幅减少 SPI/I2C 传输量。
差分刷新的工作流程:
- 维护两个帧缓冲:
current_buffer和previous_buffer。 - 逐行或逐块比较,找出变化区域的最小矩形边界(dirty rectangle)。
- 只发送该矩形区域内的像素数据到 OLED。
- 更新
previous_buffer为当前内容。
// 差分刷新核心逻辑(伪代码)
typedef struct {
uint16_t x_min, y_min;
uint16_t x_max, y_max;
} DirtyRect;
DirtyRect find_dirty_rect(uint8_t *curr, uint8_t *prev,
uint16_t w, uint16_t h) {
DirtyRect rect = {w, h, 0, 0};
for (uint16_t y = 0; y < h; y++) {
for (uint16_t x = 0; x < w; x++) {
if (curr[y * w + x] != prev[y * w + x]) {
if (x < rect.x_min) rect.x_min = x;
if (x > rect.x_max) rect.x_max = x;
if (y < rect.y_min) rect.y_min = y;
if (y > rect.y_max) rect.y_max = y;
}
}
}
return rect;
}
void diff_refresh(uint8_t *curr, uint8_t *prev,
uint16_t w, uint16_t h) {
DirtyRect dr = find_dirty_rect(curr, prev, w, h);
if (dr.x_max < dr.x_min || dr.y_max < dr.y_min) {
return; // 无变化,跳过
}
// 设置 OLED 窗口为 dirty rect
oled_set_window(dr.x_min, dr.y_min, dr.x_max, dr.y_max);
// 只发送 dirty rect 内的数据
for (uint16_t y = dr.y_min; y <= dr.y_max; y++) {
oled_send_data(&curr[y * w + dr.x_min],
dr.x_max - dr.x_min + 1);
}
memcpy(prev, curr, w * h); // 更新 previous buffer
}
注意: 差分刷新在「全屏滚动」场景下反而更耗电——因为整个屏幕都在变,dirty rect 几乎等于全屏,还要额外做比较运算。我建议加一个开关:当 dirty rect 面积超过屏幕 80% 时,直接回退到全帧刷新,省掉比较开销。
3.3 区域刷新调度:分而治之
差分刷新找到了变化区域,但如果有多个不相邻的区域同时变化呢?比如时钟数字在变,同时电池图标也在变。如果把它们合并成一个大的 dirty rect,中间没变化的区域也会被刷新,浪费带宽。
区域刷新调度的做法:
- 将屏幕划分为固定大小的区域(如 8x8 像素的 tile)。
- 每个 tile 独立维护一个 dirty 标志位。
- 刷新时,只遍历 dirty 的 tile,分别设置窗口并发送数据。
- 支持优先级调度:高频变化区域(如动画)优先刷新,静态区域延后。
| 调度策略 | 适用场景 | 功耗节省 | 实现复杂度 |
|---|---|---|---|
| 全帧刷新 | 视频播放、游戏 | 0% | 低 |
| 单矩形差分 | 按钮点击、文字输入 | 30%~50% | 中 |
| 多区域 tile 调度 | 仪表盘、多控件 UI | 50%~70% | 高 |
| 混合模式(动态切换) | 通用场景 | 60%~80% | 高 |
我建议: 区域大小选 8x8 或 16x16 像素比较合适。太小了管理开销大,太大了又失去分块意义。另外,每个 tile 可以附带一个「年龄计数器」——连续多帧不变的 tile,优先级逐渐降低,最后进入「休眠」状态,不再参与比较。
3.4 帧率自适应控制:该快则快,该慢则慢
固定帧率是最简单的做法,但也是最浪费的。帧率自适应控制根据内容动态调整刷新率:
- 静态内容:降到 1~5 fps,甚至停止刷新(只保留显示 RAM 供电)。
- 慢速动画:10~15 fps,人眼基本无感知。
- 快速交互:30~60 fps,保证响应流畅。
- 视频播放:匹配视频源帧率(24/30/60 fps)。
实现上,我习惯用一个简单的状态机:
typedef enum {
REFRESH_STOP, // 停止刷新
REFRESH_SLOW, // 5 fps
REFRESH_NORMAL, // 15 fps
REFRESH_FAST, // 30 fps
REFRESH_TURBO // 60 fps
} RefreshMode;
RefreshMode determine_refresh_mode(DirtyRect *rects, int count) {
if (count == 0) return REFRESH_STOP;
int total_area = 0;
for (int i = 0; i < count; i++) {
total_area += (rects[i].x_max - rects[i].x_min) *
(rects[i].y_max - rects[i].y_min);
}
float change_ratio = (float)total_area / (SCREEN_W * SCREEN_H);
if (change_ratio < 0.001f) return REFRESH_STOP;
if (change_ratio < 0.05f) return REFRESH_SLOW;
if (change_ratio < 0.2f) return REFRESH_NORMAL;
if (change_ratio < 0.5f) return REFRESH_FAST;
return REFRESH_TURBO;
}
避坑指南: 我曾经在一个产品里,帧率切换太激进——用户刚触摸屏幕,帧率从 5fps 跳到 60fps,结果 SPI 总线瞬间拥堵,导致触摸响应反而变慢了。后来我加了「帧率平滑过渡」:每次只跳一档,并且用定时器做渐变,比如从 5→10→15→30,每档停留 2 帧。用户体验好了很多。
3.5 整体调度架构
把这四个策略串起来,就构成了一个完整的低功耗刷新调度器。下面这张图展示了数据流和决策路径:
这个调度器每帧执行一次。静态检测做第一道过滤,差分检测定位变化区域,区域调度决定刷新顺序,帧率控制决定刷新速度。四者配合,才能做到「该省则省,该快则快」。
我的习惯: 在实际项目中,我会把调度器的决策参数(如变化率阈值、tile 大小、帧率档位)做成可配置的,放在一个结构体里。这样在产品调试阶段,可以快速调整参数,找到功耗和流畅度的最佳平衡点。你想想看,如果这些参数写死在代码里,改一次就要重新编译烧录,多麻烦。