"艺术与工程,本是一体"——达芬奇画《蒙娜丽莎》要先调好画布、颜料与画笔;这章我们把电子书的"画布"(帧缓冲)、"画笔"(渲染器)、"颜料"(内置位图)一支支摆上台面,再看每一页是怎么一笔笔画出来的。
核心方法论:艺术与工程——先有画布与画笔,才有画面;画得省、画得准,才是好画
「工匠作画,先磨画布与画笔:画布是那块帧缓冲,画笔是那套渲染接口,颜料是内置的位图——这三样没齐,什么都画不出来。画布备好,再谈每一页怎么落笔(10.1-10.2);颜料现成,再谈怎么摆进画面(10.3)。最后记住一句画训:墨水屏的每一笔都费电,只在真正该动笔时才动笔(10.4)。」
README 的「目录结构」对 lib/Epub/Renderer/ 只写了一句话:渲染器模块(帧缓冲、字体、图片合成)。六个字拆开看,正是这一章的三根柱子。先把术语铺平:帧缓冲(framebuffer)就是"屏幕所有像素在内存里的一张画布"——代码并不直接往屏幕写点,而是先把整页画进这块内存矩形,画完了再一次性地推给屏幕;合成(compositing)指把文字、位图、几何图形这些来源不同的元素,叠加画进同一块帧缓冲的过程。阅读器里所有的字、图、图标,最后都汇进同一张画布。
渲染器家族是一个三层继承链。最底层是抽象基类 Renderer(Renderer.h):它把"画"这个动作拆成一堆纯虚原语——draw_pixel、draw_text、draw_rect、fill_triangle、draw_image、show_img、clear_screen、flush_display 等等,外加四边边距(margin_top/bottom/left/right,第 9 章设置的边距最终落在这里)和页面尺寸查询。中间层 EpdiyFrameBufferRenderer 把这个接口实现在帧缓冲 + FreeType 字形之上:构造函数里 m_font_props = epd_font_properties_default()、缺字回退 fallback_glyph = '?'、旋转设成 EPD_ROT_INVERTED_PORTRAIT,还预生成一条 gamma_curve[256] 伽马校正表(GAMMA_VALUE = 1.0f / 0.8f),让每个像素先过一遍灰阶校正再落笔。它持有四套字体指针(regular/bold/italic/bold_italic,本移植版为省内存四格都指向同一款 regular,见 SF32Paper.cpp),draw_text 最终调 epd_write_string 把字符串写进 m_frame_buffer。
再往下是两块板级渲染器。SF32PaperRenderer(SF32 板)在 L2_NON_RET_BSS_SECT(断电即失的普通 RAM 段)里声明了帧缓冲 framebuffer1[EPD_WIDTH * EPD_HEIGHT / 2]——注意 / 2:这块屏幕是 4bpp 灰阶(每像素 4 位,16 级灰度,一个字节装两个像素),以 1032×758 的屏算,帧缓冲 ≈ 1032×758/2 ≈ 382 KiB(约 39 万字节);clear_screen 就是 memset(m_frame_buffer, 0xFF, ...)(0xFF 每字节两像素全白)。EpdiyRenderer(通用 EPDiy 板)则负责最终刷屏:flush_display 里 epd_hl_update_screen(&m_hl, needs_gray_flush ? MODE_GC16 : MODE_DU, temperature)——画面里有中间灰阶(抗锯齿文字、图片)就走 GC16 全刷,纯黑白就快得多走 DU 直接更新。needs_gray 这个标志,就是第 8 章那个 4bpp 字形缓存之外,渲染器自己记的"这页有没有灰"。
图片合成的入口在 Renderer::draw_image:先调 get_image_helper 根据文件后缀(.jpg/.jpeg/.png)或魔数(JPEG 开头 FF D8 FF、PNG 开头 89 'P' 'N' 'G')挑出对应的 JPEGHelper / PNGHelper,解出尺寸、按显示区域缩放,然后逐像素回调 renderer->draw_pixel 把图片"糊"进帧缓冲。真彩色图片要转灰阶:PNGHelper 里一行 gray = (r * 38 + g * 75 + b * 15) >> 7——这是把 RGB 按人眼亮度权重压成 0–255 灰度的标准公式。解码失败就退而求其次画一个占位矩形。至此"帧缓冲 + 字体 + 图片合成"三根柱子都立起来了:任何页面,都只是往这张画布上添字、添图、添几何图形而已。
/* lib/Epub/Renderer/Renderer.h:渲染抽象基类(节选) */
class Renderer
{
protected:
int margin_top = 0;
int margin_bottom = 0;
int margin_left = 0; // 第 9 章设置的边距落在这里
int margin_right = 0;
public:
virtual void draw_pixel(int x, int y, uint8_t color) = 0;
virtual int get_text_width(const char *text, bool bold = false, bool italic = false) = 0;
virtual void draw_text(int x, int y, const char *text, bool bold = false, bool italic = false) = 0;
virtual void draw_rect(int x, int y, int width, int height, uint8_t color = 0) = 0;
virtual void fill_triangle(int x0, int y0, int x1, int y1,
int x2, int y2, uint8_t color) = 0;
virtual void draw_image(const std::string &filename, const uint8_t *data,
size_t data_size, int x, int y, int width, int height);
virtual void show_img(int x, int y, int width, int height,
const uint8_t *img_buffer) = 0;
virtual void clear_screen() = 0;
virtual void flush_display(){};
virtual int get_page_width() = 0;
virtual int get_page_height() = 0;
};
/* EpdiyFrameBufferRenderer.h:帧缓冲、清屏与刷屏 */
uint8_t *m_frame_buffer; // 画布:4bpp 灰阶(每字节两像素)
void clear_screen()
{
memset(m_frame_buffer, 0xFF, EPD_WIDTH * EPD_HEIGHT / 2); // 全白
}
/* EpdiyRenderer.h:最终刷屏,按有无灰阶选模式 */
void flush_display()
{
epd_hl_update_screen(&m_hl, needs_gray_flush ? MODE_GC16 : MODE_DU, temperature);
needs_gray_flush = false;
}
/* SF32PaperRenderer.h:帧缓冲在断电即失 RAM 段声明(1032×758 屏 ≈ 382 KiB) */
L2_NON_RET_BSS_SECT_BEGIN(frambuf)
L2_NON_RET_BSS_SECT(frambuf, ALIGN(64) static uint8_t framebuffer1[EPD_WIDTH * EPD_HEIGHT / 2]);
L2_NON_RET_BSS_SECT_END
| 渲染器层 | 文件 | 职责 |
|---|---|---|
| 抽象基类 | Renderer.h / Renderer.cpp | 定义画图原语接口、四边边距、图片解码分派与占位回退 |
| 帧缓冲渲染器 | EpdiyFrameBufferRenderer.h | 帧缓冲(4bpp)、四套字体、伽马校正、灰阶检测 |
| 板级渲染器 | SF32PaperRenderer.h / EpdiyRenderer.h | 分配帧缓冲内存、驱动 LCD、按 GC16/DU 刷屏 |
| 图片解码 | PNGHelper.cpp / JPEGHelper.cpp | PNG/JPEG 解码、缩放、RGB→灰阶、逐像素合成 |
先有画布,后有画面。整个阅读器没有哪一行代码"直接画屏",全部画进 m_frame_buffer,画完才 flush 一次。这正是电子墨水屏的"绘一幅、裱一幅"工作方式——也让你所有页面都天然共享同一套画图接口。写任何 GUI 底层前,先想清楚:你的"画布"在哪?
画布和画笔备齐了,接下来看"每一页"怎么落笔。README 的「目录结构」说得清楚:src/epub_screen.cpp 负责各页面绘制与事件处理。翻它的源码,能看到一组"render_xxx 负责画、handleXxxPage 负责响应"的成对函数。主页面:render_main_page 画一个居中大标题 "S I F L I"、底部左右各一个 < / > 箭头和中间一行选项文本——选项由 MainOption 枚举决定:打开书库 / 进入设置 / 查看天气 / 继续阅读(无阅读记录时显示"无阅读记录");handleMainPage 响应 UP/DOWN 让选项循环切换,SELECT 则用 rt_kprintf 打印 1–4 表示选中哪个(真正的跳转由 main.cpp 的状态机接手)。功能设置页:render_settings_page 用 draw_setting_row 这个 lambda 逐行画"触控开关 / 超时关机 / 全刷周期 / 蓝牙 / 阅读设置 / 确认",选中行画五层嵌套矩形高亮;handleSettingsPage 用返回值向外汇报——0 继续留在设置页、1 回主页、2 进阅读设置(第 9 章就是靠这个 2 进入的)。
剩下的页面分散在几处,但"画 + 管"的套路完全一致。main.cpp 用状态机把页面串起来(第 11 章会总览):书库页 handleEpubList(每页 4 本 + 底部"上一页/主页面/下一页")、目录页 handleEpubTableContents(每页 6 项)、阅读页 handleEpub 里 reader->render() 交给 EpubReader(含覆盖操作层);reading_settings.cpp 管阅读设置页(第 9 章);天气页 handleWeatherPage / handleWeatherCityPage 也住在 epub_screen.cpp。整个 AppUIState 枚举(type.h)列出 12 个页面状态:主页面、书库、目录、阅读、阅读设置、功能设置、天气、城市选择、欢迎、低电量、充电、关机——每一页都对应一对"绘制函数 + 事件处理函数"。
页面上还有一根"公共状态栏":draw_status_bar 在页面顶部画电量与充电状态——电量画成右缘一块实时缩放的黑色矩形(draw_battery_level),充电时再叠一个闪电图标(draw_lightning 用两个三角形拼成),外加蓝牙开关与连接图标。它被几乎所有页面调用,是"合成"最直观的例子:电池、闪电、蓝牙、正文,全都在同一块画布上叠加。至于每个页面如何既画又登记触控区——render_main_page 里 add_area(...) 为箭头和选项登记可点击区域,事件来临时 UIRegionsManager 命中检测后转发给对应处理函数(第 5 章触控、第 13 章覆盖层都有它的戏份)。
/* src/epub_screen.cpp:主页面选项文本(节选) */
const char *opt_text = NULL;
bool has_continue_reading =
(epub_list_state.num_epubs > 0 && g_last_read_index >= 0 &&
g_last_read_index < epub_list_state.num_epubs);
switch (main_option)
{
case OPTION_OPEN_LIBRARY: opt_text = "打开书库"; break;
case OPTION_ENTER_SETTINGS: opt_text = "进入设置"; break;
case OPTION_WEATHER: opt_text = "查看天气"; break;
case OPTION_CONTINUE_READING:
opt_text = has_continue_reading ? "继续阅读" : "无阅读记录";
break;
}
/* 给箭头和选项登记触控区域(第 5 章的 UIRegionsManager) */
add_area(left_arrow_x, left_arrow_y, rect_w, rect_h);
add_area(right_arrow_x, right_arrow_y, rect_w, rect_h);
add_area(option_x, option_y, opt_w, opt_h);
/* src/epub_screen.cpp:功能设置页的返回值协议 */
// 返回值:0=继续,1=回主页,2=进阅读设置
int handleSettingsPage(Renderer *renderer, UIAction action, bool needs_redraw)
{
switch (action) {
case SELECT:
if (settings_selected_idx == SET_TOUCH) {
// 切换触控开关,同步触控硬件上电/下电 ...
}
if (settings_selected_idx == SET_READING_SETTINGS)
return 2; // 进阅读设置(第 9 章入口之一)
if (settings_selected_idx == SET_CONFIRM)
return 1; // 确认并回主页
break;
/* UP/DOWN / SELECT_BOX / PREV_OPTION / NEXT_OPTION ... */
}
return 0; // 默认:继续留在设置页
}
| 页面 | 绘制 / 事件处理函数 | 所在文件 |
|---|---|---|
| 主页面 | render_main_page / handleMainPage | epub_screen.cpp |
| 功能设置页 | render_settings_page / handleSettingsPage | epub_screen.cpp |
| 天气页 / 城市选择 | handleWeatherPage / handleWeatherCityPage | epub_screen.cpp |
| 书库页 | handleEpubList | main.cpp |
| 目录页 | handleEpubTableContents | main.cpp |
| 阅读页 | handleEpub + reader->render() | main.cpp / EpubReader.cpp |
| 阅读设置页 | reading_settings_draw / reading_settings_handle_action | reading_settings.cpp |
| 欢迎 / 低电量 / 充电 / 关机 | draw_welcome_page 等 | main.cpp |
每一页都是一张"小画":先 fill_rect 铺底色,再画标题、列表、图标,最后 flush_display 裱起来。页面之间靠状态机跳转、靠函数返回值说话(设置页的 0/1/2 就是它的"话")。你在画自己的一页时,也可以套这个公式:铺底 → 构图 → 落款 → 刷屏。
画到"欢迎 / 低电量 / 充电 / 关机"这几页时,画的不是文字,而是一整幅图。位图(bitmap)就是用像素点阵描述的画面数据——一格一个亮度值,跟文字用字形、图形用矢量完全不是一回事。这些图在 src/assets/ 下以 C 数组 形式编译进固件:welcome.c 的 welcome_map(约 650×150 横幅,48,750 字节)、low_power.c 的 low_power_map、chargeing.c 的 chargeing_map、shutdown.c 的 shutdown_map(后三者都是 200×200,各 20,000 字节),外加 bluetooth_icons.c 的 bluetooth_connected_map / bluetooth_disconnected_map(24×24)。它们和内置字体一样走"编译期转数组"的路子——只是这次用的是 scripts/imgconvert.py 而不是字体脚本。
先看 imgconvert.py 把 PNG 变成数组的四个步骤:PIL 打开原图 → im.convert(mode='L') 转灰度 → im.thumbnail(...) 等比缩到显示尺寸 → 逐像素打包成 4bpp(每字节两个像素,高 4 位装第一个像素、低 4 位装第二个)。这解释了为什么内存里看到的都是 0xFF / 0x00 这类字节:一个字节同时管着左右两个点的亮度。绘制时,main.cpp 的 draw_welcome_page / draw_low_power_page / draw_charge_page / draw_shutdown_page 套路完全一致:先铺满底色(欢迎页铺黑、关机页铺白),画上状态栏,再把 xxx_map 居中贴出——调用的是 EpdiyFrameBufferRenderer::show_img,底层是 epd_draw_rotated_transparent_image(以 0xE0 为透明色做合成),最后 flush_display / request_flush 一次性上屏。
还有一类"小图"藏在 lib/Images/:hourglass.h(100×100 沙漏)和 warning.h(100×100 警告)。SF32Paper.cpp 构造渲染器时把 hourglass_data 传给 busy_icon 参数,于是 show_busy() 在加载书、切字体等"忙"的时候,把沙漏居中画到画布上(第 9 章 reading_settings_apply 里那句 renderer->show_busy() 画的正是它)。这些 C 数组虽然看着又长又密,本质都是"颜料":经过工具链压缩成二进制,随固件烧进 Flash,开机即用、不占 PSRAM——和内置字体的思路一脉相承。
/* scripts/imgconvert.py:PNG → 灰度 → 4bpp C 数组(节选) */
from PIL import Image, ImageOps
im = Image.open(args.inputfile)
im = im.convert(mode='L') # 转灰度
im.thumbnail((args.max_width, args.max_height), Image.ANTIALIAS)
for y in range(0, im.size[1]):
for x in range(0, im.size[0]):
l = im.getpixel((x, y))
if x % 2 == 0:
byte = l >> 4 # 高 4 位 = 第一个像素
else:
byte |= l & 0xF0 # 低 4 位 = 第二个像素
f.write("0x{:02X}, ".format(byte))
/* src/main.cpp:关机页绘制(节选) */
void draw_shutdown_page()
{
renderer->fill_rect(0, 0, renderer->get_page_width(), renderer->get_page_height(), 255); // 铺白
renderer->set_margin_top(35);
draw_status_bar(renderer, battery); // 顶部状态栏
const int img_width = 200, img_height = 200;
EpdiyFrameBufferRenderer *fb_renderer =
static_cast<EpdiyFrameBufferRenderer*>(renderer);
fb_renderer->show_img(center_x - img_width / 2, center_y - img_height / 2,
img_width, img_height, shutdown_map); // 居中贴位图
/* ... 画"请长按 Key1 开机"文字,最后 flush_display() */
}
| 文件(src/assets/) | 数组符号 | 尺寸(4bpp 字节) | 用途 |
|---|---|---|---|
welcome.c | welcome_map | 48,750(约 650×150) | 欢迎 / 熄屏页横幅 |
low_power.c | low_power_map | 20,000(200×200) | 低电量页图标 |
chargeing.c | chargeing_map | 20,000(200×200) | 充电页图标 |
shutdown.c | shutdown_map | 20,000(200×200) | 关机页图标 |
bluetooth_icons.c | bluetooth_connected/disconnected_map | 288(24×24) | 状态栏蓝牙图标 |
lib/Images/hourglass.h | hourglass_data | 5,000(100×100) | 忙碌图标(show_busy) |
颜料要备好,更要"画得对、存得省"。位图统一压成 4bpp 灰阶、一个字节管两个像素——画面只有 16 级灰,却能省下一半内存;开机即从 Flash 直读,不占 PSRAM。下次你要往固件里塞图片,先问自己:真的需要真彩色吗?压成灰阶省一半,值不值?
画布、画笔、颜料都齐了,最后一道功课是"什么时候才该动笔"。电子墨水屏的每一帧刷新都贵:全屏刷新要数百毫秒、且耗电(第 1 章讲过静态零功耗、翻页才耗电),所以刷得越少,越省电、越耐用。第 6 章讲电量管理时提过"充电状态变化可触发页面刷新(仅在百分比或充电状态真正变化时刷屏,避免无效刷新)"——这一节就落到代码上,看 main.cpp 怎么把"变化才刷"做成纪律。
第一个防无效刷新的哨兵是 charge_full 标志。MSG_UPDATE_CHARGE_STATUS 消息到达时,先查电量:percentage >= 98 且 charge_full == false,才执行一次 clear_charge_icon + request_flush(把状态栏的闪电图标清掉),随即置 charge_full = true——之后同样的满电消息再来,直接 continue 跳过,不重复刷;只有电量掉回 < 98 才复位 charge_full 并重绘状态栏。README 说的"电量满时(≥98%)自动清除充电图标",背后就是"满了只刷一次"。
第二个哨兵是主循环末尾的电池比对。每一次循环都重读电量百分比和充电状态,但只有 cur_percent != last_battery_percent || cur_charging != last_battery_charging 时,才调用 draw_status_bar + request_flush,然后更新上次值。蓝牙也同理:current_bluetooth_connected != last_bluetooth_connected 才重画。这一整套"先比对、后刷新"的写法,把"状态有没有真变"当成刷屏的前置条件——状态没变,画面就不动,墨水屏继续零功耗定格,也不会有毫无意义的闪烁。
串起来看,这章就是达芬奇的"一幅画的诞生":帧缓冲是画布,渲染器是画笔,位图是颜料,页面函数是构图,刷新纪律是"惜墨如金"。而这一切最终都服务于阅读这件事本身——字画得清楚、图画得省、屏刷得少。第 11 章我们会把这些页面放进完整的 UI 状态机里,看它们如何被统一调度。
/* src/main.cpp:满电只清一次充电图标(MSG_UPDATE_CHARGE_STATUS) */
if (ui_action == MSG_UPDATE_CHARGE_STATUS)
{
if (battery) {
int percentage = battery->get_percentage();
if (percentage >= 98 && charge_full == false) {
clear_charge_icon(renderer); // 清掉闪电图标
request_flush(); // 只刷这一次
charge_full = true; // 之后满电消息直接跳过
} else if (percentage < 98) {
charge_full = false; // 掉出满电,重新允许刷新
draw_status_bar(renderer, battery);
request_flush();
}
}
continue;
}
/* src/main.cpp:主循环——状态真正变化才刷屏 */
// 电池状态 + 刷屏(仅在电量百分比或充电状态变化时才刷新)
if (battery) {
int cur_percent = battery->get_percentage();
bool cur_charging = battery->is_charging();
if (cur_percent != last_battery_percent || cur_charging != last_battery_charging) {
draw_status_bar(renderer, battery);
request_flush();
last_battery_percent = cur_percent;
last_battery_charging = cur_charging;
}
}
// 蓝牙:连接状态变化才重画(节选)
if (current_bluetooth_connected != last_bluetooth_connected) {
g_bluetooth_connected = current_bluetooth_connected;
draw_status_bar(renderer, battery);
request_flush();
last_bluetooth_connected = current_bluetooth_connected;
}
| 触发点 | 刷新条件 | 动作 |
|---|---|---|
| 满电消息 | percentage >= 98 且 !charge_full | 清充电图标 + 刷一次,置 charge_full |
| 掉出满电 | percentage < 98 | 复位 charge_full,重绘状态栏 + 刷屏 |
| 主循环电池 | 百分比或充电状态真正变化 | draw_status_bar + request_flush |
| 蓝牙连接 | 连接状态变化 | 重画状态栏 + 刷屏 |
惜墨如金,是电子墨水屏的美德。状态没变就不刷新,画面定格、电量为零——这是第 6 章低功耗哲学在绘制层的落点。写任何"轮询式刷新"的代码,都该先画一条分界线:哪些信号值得刷新,哪些直接丢弃?一条 charge_full,就省下了几百次本可避免的全屏刷新。
把 Renderer、EpdiyFrameBufferRenderer、SF32PaperRenderer、EpdiyRenderer 按"抽象基类 → 帧缓冲渲染器 → 板级渲染器"的顺序排好,并各自说一句职责。
对照 10.1 的表格:接口定义、画布 + 字体 + 灰阶、内存分配 + 刷屏。
Renderer(抽象基类)定义画图原语接口与边距;EpdiyFrameBufferRenderer 在 4bpp 帧缓冲上实现这些原语、管四套字体与伽马校正;SF32PaperRenderer(SF32 板)在断电即失 RAM 段分配帧缓冲并接 LCD 驱动;EpdiyRenderer(通用 EPDiy 板)负责按有无灰阶选 MODE_GC16/MODE_DU 刷屏。
为什么帧缓冲是 EPD_WIDTH * EPD_HEIGHT / 2 字节?以 1032×758 的屏幕算,帧缓冲约多大?clear_screen 为什么是 memset(..., 0xFF, ...)?
4bpp 每像素 4 位、每字节两像素;0xFF 即每字节两像素全白。
屏幕是 4bpp 灰阶(每像素 4 位 = 16 级灰度),一个字节装两个像素,所以字节数是像素数的一半。1032×758/2 = 391,128 字节 ≈ 382 KiB。clear_screen 把整块画布置成 0xFF——4 位全是 1 表示白色,一个字节的两个像素同时变白,即"整屏清成白纸"。
get_image_helper 如何判断一张图该用 JPEG 还是 PNG 解码器?真彩色图片进帧缓冲前为什么、以及如何转成灰阶?解码失败时渲染器怎么兜底?
先看文件后缀,再看数据头魔数(JPEG 为 FF D8 FF、PNG 为 89 'P' 'N' 'G');灰阶公式是 r*38 + g*75 + b*15 再右移 7 位;失败画占位矩形。
按优先级:文件名含 .jpg/.jpeg/.png,或数据开头是 JPEG 魔数 FF D8 FF / PNG 魔数 89 'P' 'N' 'G',命中即创建并复用对应 ImageHelper。墨水屏是灰阶的,所以解码后逐像素用 gray = (r*38 + g*75 + b*15) >> 7 把人眼加权亮度压成 0–255 灰阶,再 draw_pixel 画进帧缓冲;解码失败则在目标区域画一个占位矩形(draw_image 里的兜底)。
结合第 6 章,说明 charge_full 标志为什么能"避免无效刷新"。如果把主循环里"百分比或充电状态变化才刷新"改成"每次都刷新",会有什么后果?请给出完整推理链。
电子墨水屏全刷数百毫秒、且每次刷新都耗电;满电后状态不再变,反复刷只会白耗电并造成闪烁。
电子墨水屏刷新一帧要数百毫秒并耗电(第 1、6 章),静态定格零功耗。charge_full 让"满电"这个状态只生效一次:第一次 ≥98% 时清图标并刷屏、置位,之后同样的满电消息直接跳过,不再重复刷;只有掉到 98% 以下才复位,保证下次满电还能再清一次。若改成每次都刷新,充电状态不变时仍会反复全屏刷新——既白白消耗电池,又让屏幕无谓闪烁,还拖慢主循环。这条"先比对、后刷新"的纪律,本质是把低功耗的账算到每一帧上。