「包青天断案,铁面无私」——这一章把 README 承诺的"当前功能"一条条摆上公堂,与源码逐一核对:书从哪来(14.1)、靠什么翻页(14.2)、界面能干什么(14.3)、没电了怎么办(14.4)。每一项都要求有据可查,有差异就如实标注——最后用一句话给整台阅读器下结论(14.5)。
核心方法论:铁面无私——盘点的每一条都以源码为准,README 与代码不符处如实标注,绝不和稀泥
「本官断案,讲证据、看实情,绝不听一面之词。README 上的每一条"当前功能",都要拿到源码里去对质:书从哪里来(14.1)、靠什么翻页(14.2)、界面能干什么(14.3)、没电了怎么办(14.4)。README 说主页面有 3 个入口,可源码里明明坐着 4 位——本官如实记录,不偏不倚。铁面无私,是对代码的忠诚,也是对读者的坦诚。」
README「电子书与文件系统」第一条:支持从内置文件系统与 TF 卡读取 EPUB(优先使用 TF 卡)。这条"优先"在源码里有铁证——SF32Paper.cpp 的文件系统挂载循环:先尝试把 sd0(TF 卡,若检测到)挂到根目录 "/",dfs_mount 成功就 break;只有 TF 卡不可用才轮到 flash0(内置 Flash 的 FS_REGION 分区)。挂载顺序决定了"谁先来、谁优先",TF 卡在前,正是 README 那句话的落点。
书从哪扫?handleEpubList 创建书库时调 epub_list->load("/")——从根目录扫描所有 .epub 后缀文件:跳过 . 开头的隐藏文件与目录,每本加载成功就记入 epub_list[](容量上限 MAX_EPUB_LIST_SIZE = 20,超出报 "Too many epubs" 并中断),最后按书名排序。书的来源一实一虚:TF 卡是实打实的文件系统;内置 Flash 里 disk/ 目录的示例书(oebps.epub、no_oebps.epub、pg43-images.epub)打包进镜像后也会挂载进根目录,所以 load("/") 一并可见。README 二次开发也印证了分工:少量样书放 disk/ 随镜像打包,大量书籍用 TF 卡。
一句话盘下来的真实边界:书库只扫根目录、最多 20 本。README 没写"最多 20 本",这是源码里 State.h 的硬限制;子目录的书不会被发现(除非挂在根目录的某个子路径是单独的文件系统,如 TF 卡被整体挂到 "/" 时其根即书库根)。盘点章就该把这类"文档没说、代码存在"的边界如实补上。
/* src/boards/SF32Paper.cpp:挂载顺序 → TF 卡优先于内置 Flash */
#if defined(RT_USING_SDIO)
if (SD_Card_detect() == SD_CARD_INSERT) {
LOG_I("SD-Card plug in\n");
name[0] = (char *)"sd0";
}
#endif
name[1] = (char *)"flash0";
register_mtd_device(FS_REGION_START_ADDR, FS_REGION_SIZE, name[1]);
for (uint32_t i = 0; i < sizeof(name) / sizeof(name[0]); i++)
{
if (NULL == name[i]) continue;
if (dfs_mount(name[i], "/", "elm", 0, 0) == 0) // fs exist
{
LOG_I("mount fs on %s to root success\n", name[i]);
break; // 先挂上的优先 → sd0 在前
}
}
/* lib/Epub/EpubList/EpubList.cpp:load("/") 扫描根目录 .epub 文件 */
while ((ent = readdir(dir)) != NULL)
{
// ignore any hidden files starting with "." and any directories
if (ent->d_name[0] == '.' || ent->d_type == DT_DIR)
continue;
int name_length = strlen(ent->d_name);
if (name_length < 5 || strcmp(ent->d_name + name_length - 5, ".epub") != 0)
continue;
Epub *epub = new Epub(std::string("/") + ent->d_name);
if (epub->load())
{
/* 写入 path / title,num_epubs++ ... */
if (state.num_epubs == MAX_EPUB_LIST_SIZE)
{
ulog_e(TAG, "Too many epubs, max is %d", MAX_EPUB_LIST_SIZE);
break; // 上限 20 本
}
}
}
| 存储来源 | 挂载设备 | 优先级 | 用途 |
|---|---|---|---|
| TF 卡 | sd0(SDIO / SPI MSD 检测) | 1(先尝试) | 大量书籍,用户自备 |
| 内置 Flash | flash0(FS_REGION 分区) | 2(后备) | disk/ 打包的少量示例 EPUB |
| 书库扫描 | EpubList::load("/") | — | 根目录 .epub,上限 MAX_EPUB_LIST_SIZE = 20 |
证据链完整。README 说"优先 TF 卡",代码里是"先挂 sd0、失败才挂 flash0",一句话对应一行 break。盘点时要练出这种"一句话 → 一段代码"的对质能力:功能描述能指到具体实现,才叫"确实实现";指不到,就得标成存疑。
README「输入与显示」写了两条:支持按键与触控双输入、支持中文/英文显示。双输入在源码里是两套独立驱动,最终汇进同一个动作队列:SF32_ButtonControls 把 BUTTON_CLICKED 映射成 UP/DOWN/SELECT、把 BUTTON_LONG_PRESSED 映射成 UPGLIDE;SF32_TouchControls 把点击/上滑映射成区域命中后的动作(第 13 章那张区域表)。两条路径共用 ActionCallback_t 把动作塞进 ui_queue 消息队列,主循环 rt_mq_recv 统一消费——双输入单管道,这就是"支持按键与触控"的架构答案。
中英文显示靠 FreeType 字体栈:font_ft.c 封装 FreeType 光栅化,内置字体是 font/DroidSansFallback.ttf(README 明说编译时内置、存放于 Flash XIP,读写不占 PSRAM 缓存),它同时覆盖中英文;外挂字体由 font_manager.c 扫描 TF 卡 /fonts 目录(最多 FONT_MAX_COUNT = 64 个),.ttf/.otf 都能被 FreeType 直接从文件读取。阅读设置里的"字重"三档(正常/中粗/粗体)走 epd_font_ft_set_bold:若字体带可变字重轴就 apply_variable_weight,否则用 FreeType 的 embolden 合成(strength = y_ppem * EMBOLDEN_STRENGTH[level])。
这正好解释 README 那句"为控制资源占用,默认未启用粗体/斜体中文字库"——它不是说"没有粗体功能",而是说没有内置额外的粗体/斜体字体文件:粗体效果靠字重轴或合成实现,不占整套额外字库的存储。这是"能调出粗体"与"内置粗体字库"之间的微妙差异,盘点章要把话说明白。
/* src/boards/controls/SF32_ButtonControls.cpp:按键点击 → 动作(部分板型) */
if (pin == EPD_KEY1)
{
if (action == BUTTON_CLICKED) action_cbk(UIAction::DOWN);
}
else if (pin == EPD_KEY2)
{
if (action == BUTTON_CLICKED) action_cbk(UIAction::SELECT);
else if (action == BUTTON_LONG_PRESSED) action_cbk(UIAction::UPGLIDE);
}
else if (pin == EPD_KEY3)
{
if (action == BUTTON_CLICKED) action_cbk(UIAction::UP);
}
/* lib/epdiy/font_ft.c:粗体合成(无字重轴时用 embolden) */
void epd_font_ft_set_bold(int level) {
if (level < 0) level = 0; if (level > 2) level = 2;
if (level == g_bold_level) return;
epd_font_ft_preheat_stop(); g_bold_level = level;
cache_clear(); cmap_cache_clear();
if (g_has_weight_axis) {
apply_variable_weight(BOLD_WEIGHT_MAP[level]); // {400, 600, 700}
recalc_metrics();
}
}
/* 光栅化时:g_bold_level > 0 且无字重轴 → FT 合成加粗 */
static const int EMBOLDEN_STRENGTH[] = {0, 1, 2};
FT_Pos strength = (FT_Pos)(g_ft_face->size->metrics.y_ppem * EMBOLDEN_STRENGTH[g_bold_level]);
| 能力 | 实现 | 代码出处 |
|---|---|---|
| 按键输入 | SF32_ButtonControls:点击 → UP/DOWN/SELECT,长按 → UPGLIDE | SF32_ButtonControls.cpp |
| 触控输入 | SF32_TouchControls:区域命中 + 上滑(SWIPE_THRESHOLD=100) | SF32_TouchControls.cpp |
| 中文/英文显示 | FreeType + 内置 DroidSansFallback.ttf(Flash XIP) | font_ft.c、font/ |
| 外部字体 | TF 卡 /fonts 下 .ttf/.otf,最多 64 个 | font_manager.c |
| 字重三档 | 字重轴 apply_variable_weight 或 FT 合成加粗 | epd_font_ft_set_bold |
本官最看重"名实相符"。README 说"默认未启用粗体/斜体中文字库",容易误读成"没有粗体";一查代码,字重三档照常有,只是粗体靠合成而非额外字库。盘点的价值就在这:把"文档的模糊表述"翻译成"代码的精确事实",再如实写出来。
这一节要办一桩"证词不符"的案子。README「主页面」白纸黑字写"主页面包含 3 个入口:打开书库 / 继续阅读 / 进入设置"。可一查 epub_screen.h 的 MainOption 枚举,源码里是 4 个:OPTION_OPEN_LIBRARY = 0、OPTION_ENTER_SETTINGS、OPTION_WEATHER、OPTION_CONTINUE_READING——多了一个「查看天气」。渲染主页面时四个选项都能切到(case OPTION_WEATHER: opt_text = "查看天气"),选中后 handleUserInteraction 会进 WEATHER_PAGE。所以"3 入口"是 README 写早了,代码早已是 4 入口。还有一桩:README 的"功能设置页面"列了 5 项(触控开关/超时策略/全刷周期/阅读设置/确认保存),而 render_settings_page 实际画了 6 行——多出第 4 行「蓝牙:开/关」(SET_BLUETOOTH)。两处都是"文档比代码旧",盘点时照实记录。
撇开证词差异,UI 的三套设置是齐全的。功能设置页(SETTINGS_PAGE):触控开关、超时关机(5/10/30/60 分钟、不关机)、全刷周期(5/10/20/每次)、蓝牙、阅读设置入口、确认返回。阅读设置页(READING_SETTINGS):字体(Default + TF 卡 /fonts)、字号(24/28/32/36/40/44/48)、字重(正常/中粗/粗体)、行距(1.0x~2.0x)、边距(5~20px),key=value 文本持久化到 TF 卡 /settings.cfg,字体不可用自动回退内置。覆盖操作层:第 13 章那张 12 格面板,跳页/目录/书库/设置一步直达。
还有一个 README 没明说、但已在第 12 章验证过的架构事实:状态机里书库页、目录页、阅读页之外,还挂着 WEATHER_PAGE / WEATHER_CITY_PAGE(天气与城市选择)、WELCOME_PAGE(超时熄屏)、LOW_POWER_PAGE / CHARGING_PAGE / SHUTDOWN_PAGE(电量相关)。主页面是所有页面的总闸(ui_state = MAIN_PAGE 是默认态)。"能调"不止于阅读参数,还包括触控、超时、全刷、蓝牙这类设备级开关。
/* src/epub_screen.h:MainOption 枚举——README 说 3 入口,代码是 4 */
typedef enum
{
OPTION_OPEN_LIBRARY = 0,
OPTION_ENTER_SETTINGS,
OPTION_WEATHER, // 第 4 项:查看天气(README 未列出)
OPTION_CONTINUE_READING,
OPTION_COUNT
} MainOption;
/* epub_screen.cpp:主页面选项文本 */
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;
}
/* src/epub_screen.cpp:功能设置页实际绘制的 6 行(README 列了 5 项) */
// 1) 触控开关
draw_setting_row(SET_TOUCH, buf, y); // 触控开关:开/关
// 2) 超时关机
draw_setting_row(SET_TIMEOUT, buf, y); // 超时关机:N分钟/不关机
// 3) 全刷周期
draw_setting_row(SET_FULL_REFRESH, buf, y); // 全刷周期:N次/每次
// 4) 蓝牙开关(绑定真实蓝牙栈)
draw_setting_row(SET_BLUETOOTH, buf, y); // 蓝牙:开/关(README 未列出)
// 5) 阅读设置(进入子页面)
draw_setting_row(SET_READING_SETTINGS, "阅读设置", y);
// 6) 确认按钮
static_add_area(confirm_x, confirm_y, confirm_w, confirm_touch_h, SET_CONFIRM * 3 + 2);
renderer->draw_text(confirm_x + (confirm_w - c_w) / 2, confirm_y + (confirm_h - c_h) / 2, confirm, false, true);
| 项目 | README 所述 | 源码实况 | 判定 |
|---|---|---|---|
| 主页面入口 | 3 个(书库/继续阅读/设置) | 4 个(多了「查看天气」) | README 过旧,以代码为准 |
| 功能设置项 | 5 项 | 6 行(多了「蓝牙」) | README 过旧,以代码为准 |
| 阅读设置 | 字体/字号/字重/行距/边距 + 保存退出 | 一致,持久化 /settings.cfg | 一致 |
| 覆盖操作层 | 触控/全刷切换、跳页、目录/书库/设置 | 一致(第 13 章 12 格面板) | 一致 |
这是全教程最有价值的一次"文档漂移"现场:README 的「主页面 3 入口」「功能设置 5 项」都比代码旧,分别漏了天气与蓝牙两项。学到的是工程习惯:功能文档写出来就注定会过时,排查问题永远以源码为准;反过来,改功能时记得同步文档,否则下一个读文档的人就被你坑了。
README「电量与低功耗」列了五条,逐条对源码:页面顶部显示电量与充电状态(闪电图标),电量满时(≥98%)自动清除充电图标——状态栏由 draw_status_bar 绘制,MSG_UPDATE_CHARGE_STATUS 分支里 percentage >= 98 && !charge_full 就 clear_charge_icon() 并把 charge_full 置真,percentage < 98 再复位。低电量进入 LOW_POWER_PAGE 并抑制普通用户操作——主循环里 percentage < 2 && !is_charging 触发 MSG_DRAW_LOW_POWER_PAGE,而 handleUserInteraction 一进来先查 battery->get_low_power_state() == 1,是就直接 return——操作被整体抑制。
充电状态变化可触发页面刷新(仅百分比或充电状态真正变化时刷屏)——主循环末尾 if (cur_percent != last_battery_percent || cur_charging != last_battery_charging) 才 draw_status_bar + request_flush,否则不刷,避免无效刷新。用户无操作达到设置超时后进入 WELCOME_PAGE(类似熄屏)——超时策略 kTimeoutOptions[] = {5, 10, 30, 60, 0}(分钟,默认 30),主循环里 rt_tick_get_millisecond() - last_user_interaction >= 60 * 1000 * screen_get_timeout_shutdown_minutes() 就进欢迎页;再次交互唤醒回主页面并重置计时。
主循环默认 5 小时无交互进入 SHUTDOWN_PAGE——这是整条省电路径的尽头。#define TIMEOUT_SHUTDOWN_TIME 5(小时),主循环条件 while (rt_tick_get_millisecond() - last_user_interaction < 60 * 1000 * 60 * TIMEOUT_SHUTDOWN_TIME);5 小时一到,循环退出,renderer->dehydrate() 保存状态、board->stop_filesystem()、画关机页、board->prepare_to_sleep() 进入深睡眠。至此"能省电"闭环:超时先熄屏(欢迎页)→ 更久无操作则关机深睡,中间再配合触控硬件下电(13.3)与局刷省功耗。
/* src/main.cpp:低电量进 LOW_POWER_PAGE + 抑制用户操作 */
if (percentage < 2 && !is_charging && adc_battery->get_low_power_state() != 1) {
adc_battery->set_low_power_state(1);
UIAction msg = MSG_DRAW_LOW_POWER_PAGE;
...
}
/* handleUserInteraction 入口:低电量直接短路,不处理任何操作 */
if (battery && battery->get_low_power_state() == 1) {
rt_kprintf("low power state\n");
return;
}
/* src/main.cpp:超时欢迎页 + 5 小时关机深睡 */
#define TIMEOUT_SHUTDOWN_TIME 5 // 默认关机超时(小时);0 表示不关机
...
while ((rt_tick_get_millisecond() - last_user_interaction < 60 * 1000 * 60 * TIMEOUT_SHUTDOWN_TIME))
{
// 无操作达到设置超时 → 欢迎页(类似熄屏)
if (rt_tick_get_millisecond() - last_user_interaction >=
60 * 1000 * screen_get_timeout_shutdown_minutes() && ...)
{
renderer->set_margin_bottom(0);
draw_welcome_page(battery);
}
...
}
// 5 小时无交互 → 保存状态、关文件系统、画关机页、深睡眠
renderer->dehydrate();
board->stop_filesystem();
renderer->set_margin_bottom(0);
draw_shutdown_page();
board->prepare_to_sleep();
| README 承诺 | 代码证据 | 实现位置 |
|---|---|---|
| 顶部电量 + 充电闪电图标;≥98% 清除图标 | draw_status_bar / clear_charge_icon(percentage >= 98) | main.cpp |
低电量进 LOW_POWER_PAGE 并抑制操作 | percentage < 2 触发;get_low_power_state()==1 短路 | main.cpp |
| 充电状态变化才刷屏 | cur_percent != last || cur_charging != last | main.cpp 主循环末尾 |
| 超时进欢迎页 | >= 60*1000*screen_get_timeout_shutdown_minutes() | main.cpp 主循环 |
| 默认 5 小时关机深睡 | TIMEOUT_SHUTDOWN_TIME 5 + 循环条件 | main.cpp |
这五条全部对得上,一条不虚。注意省电的层次:触控硬件下电(13.3)→ 超时欢迎页熄屏(分钟级)→ 5 小时关机深睡(小时级),一级比一级省,一级比一级代价大。低功耗设计就是这么一层层摞起来的——盘点时把层次画出来,比单条罗列更能看清系统。
把 14.1-14.4 全部对上证的条目收拢,整台阅读器可以浓缩成一句话:「能读、能调、能省电」。能读——TF 卡与内置 Flash 双来源的书库、EPUB 解析与 FreeType 中英文渲染、书库→目录→阅读的完整闭环;能调——主页面四入口、功能设置(触控/超时/全刷/蓝牙)、阅读设置(字体/字号/字重/行距/边距)持久化到 /settings.cfg、阅读页 12 格覆盖层;能省电——电量状态栏、低电量抑制、超时欢迎页、5 小时关机深睡、触控硬件下电。三者叠加,才称得上"一本真正能用的电纸书"。
最后把"状态全景"亮出来——AppUIState 枚举里那 12 个状态,就是这台阅读器的全部页面地图:MAIN_PAGE 是总闸,SELECTING_EPUB / SELECTING_TABLE_CONTENTS / READING_EPUB 是阅读主线,READING_SETTINGS / SETTINGS_PAGE 是两套设置,WEATHER_PAGE / WEATHER_CITY_PAGE 是天气支线,WELCOME_PAGE / LOW_POWER_PAGE / CHARGING_PAGE / SHUTDOWN_PAGE 是电源与熄屏。对照第 1 章那张"领域地图",你会看到一条清晰的成长线:从"理解为什么"走到"逐行读懂每一页代码",再到本章"能逐条盘点、逐条求证"——这正是整个移植实战想教给你的能力。
/* src/type.h:AppUIState —— 这台阅读器的全部页面状态 */
typedef enum
{
MAIN_PAGE, // 主页面(四入口总闸)
SELECTING_EPUB, // 书库页
SELECTING_TABLE_CONTENTS, // 目录页
READING_EPUB, // 阅读页(含覆盖操作层)
READING_SETTINGS, // 阅读设置页
SETTINGS_PAGE, // 功能设置页
WEATHER_PAGE, // 天气页
WEATHER_CITY_PAGE, // 天气城市选择页
WELCOME_PAGE, // 欢迎页(超时熄屏)
LOW_POWER_PAGE, // 低电量页
CHARGING_PAGE, // 充电页
SHUTDOWN_PAGE // 关机页
} AppUIState;
/* 一句话结论:能读 · 能调 · 能省电(能力 → 章节映射) */
能读 → 14.1 书库/文件系统 · 7-8 章解析渲染 · 14.2 中英文显示
能调 → 14.3 四入口/功能设置/阅读设置/覆盖层(9、12、13 章已拆解)
能省电→ 14.4 电量/低电量/超时熄屏/关机深睡 · 13.3 触控硬件下电
| 能力 | 已实现功能 | 对应章节 |
|---|---|---|
| 能读 | TF 卡 + 内置双来源书库、EPUB 解析、中英文渲染、书库→目录→阅读 | 7-8、12、14.1-14.2 |
| 能调 | 四入口主页面、功能设置、阅读设置 + /settings.cfg 持久化、覆盖层 | 9、13、14.3 |
| 能省电 | 电量/充电状态栏、低电量抑制、超时欢迎页、5 小时关机深睡、触控下电 | 6、13.3、14.4 |
本官断案至此,卷宗已合。盘点章的收尾不是"我讲完了",而是"你有清单了":把这三行映射表留作自测——每一项你能否自己打开源码、说出它对应的文件与函数?能做到,这一路移植实战就没白学;做不到,就回到对应章节再对一次证据。铁面无私的最后一条原则:证据不是背下来的,是查出来的。
打开 README「当前功能」与对应源码,逐条核对并填写:(a) "优先使用 TF 卡"对应 SF32Paper.cpp 里的哪段逻辑?(b) 书库最多收录多少本书?这个限制定义在哪?(c) "电量满时自动清除充电图标"的阈值是多少,写在哪行代码?
三个问题分别指向 mount 循环、MAX_EPUB_LIST_SIZE、percentage >= 98 的 if 判断。
(a) SF32Paper.cpp 挂载循环:先挂 sd0 成功即 break,失败才挂 flash0;(b) 最多 20 本,MAX_EPUB_LIST_SIZE = 20,定义在 lib/Epub/EpubList/State.h;(c) 阈值 98,在 main.cpp 的 MSG_UPDATE_CHARGE_STATUS 分支:percentage >= 98 && charge_full == false 时 clear_charge_icon。
README 说主页面有 3 个入口、功能设置有 5 项。核实源码后,分别说出实际数量、多出来的那项是什么,并判断该以谁为准。
MainOption 枚举数一数;render_settings_page 里 draw_setting_row 数一数。
主页面实际 4 个入口(多了「查看天气」,OPTION_WEATHER);功能设置实际 6 行(多了「蓝牙:开/关」,SET_BLUETOOTH)。两处都是 README 比代码旧,以源码为准——README 是静态文档,代码是活的真相。
把 14.4 的省电手段按"从轻到重"排成层次,并说明每一层由什么条件触发、代价是什么。低电量抑制用户操作具体在哪一行生效?
触控下电(13.3)→ 超时欢迎页(分钟)→ 关机深睡(小时);handleUserInteraction 入口的 low_power_state 判断。
层次:① 触控硬件下电(软件开关翻转即断电,代价是失去触控);② 超时进欢迎页熄屏(无操作达 60*1000*screen_get_timeout_shutdown_minutes(),默认 30 分钟,代价是再看需唤醒);③ 5 小时无交互关机深睡(TIMEOUT_SHUTDOWN_TIME 5 主循环退出,代价是重新开机慢)。低电量抑制在 handleUserInteraction 第一行:if (battery && battery->get_low_power_state() == 1) return;。
不看本盘点章,自己为「能调」这个能力列一份清单:主页面、功能设置、阅读设置、覆盖层各有哪些可调项,每项写出它的代码证据(枚举/函数/常量)。再对照 14.3 的表格,看你有几处与源码一致。
枚举:MainOption、SettingItem、OverlayCenterMode、AppUIState;函数:handleSettingsPage / reading_settings_handle_action / screen_cycle_full_refresh_period。
「能调」清单参考:主页面四入口(MainOption);功能设置 6 行(SET_TOUCH/SET_TIMEOUT/SET_FULL_REFRESH/SET_BLUETOOTH/SET_READING_SETTINGS/SET_CONFIRM);阅读设置 6 项(SettingItem:字体/字号/字重/行距/边距/保存退出,SETTINGS_FILE_PATH="/settings.cfg");覆盖层 12 格(render_overlay 的 3+5+4 布局,OverlayCenterMode)。凡是你写的每一项能对上源码枚举与函数,就说明盘点能力已到手。