「运筹帷幄之中,决胜千里之外」——指挥一场战役,先要看清整张棋盘:据点有几个(状态)、棋子怎么走(事件分发)、从哪走到哪(迁移)。这章我们像排兵布阵一样,把整套 UI 的骨架——十二个页面状态、一条主循环、一张迁移图——一次性摆上沙盘。
核心方法论:运筹帷幄——先看清十二个据点、一条传令线、一张迁移图,再谈各个击破
「行军布阵,首重全局。这套 UI 有十二个据点,分守三处:阅读主线上的书库、目录、阅读、阅读设置,是进攻的主力(第 12 章);主页面、设置、天气两页,是殿后的辎重;欢迎、低电量、充电、关机四页,是守夜的精锐。主将传令靠一条主循环(11.3),落子靠迁移表(11.4)。先把这张图背熟,再谈各个击破。」
一台设备开机之后要面对多少种"屏幕"?数一数:主页面、书库、目录、阅读、阅读设置、功能设置、天气、城市选择、欢迎、低电量、充电、关机——整整十二种。如果每段代码都"想画谁就画谁",页面之间随意乱跳,很快就会失控:从阅读页能不能直接进天气?从低电量页还能不能翻书?这些规则必须被明确写下来。状态机(state machine)就是干这个的。说人话:把"此刻我显示在哪个页面"单独拎出来存成一个变量,再把"在某个页面上遇到某个动作该转到哪"全部列成一张规则表,设备任何时刻处于且仅处于其中一个状态,页面切换只能按表来。前几章讲过"状态机"这个词,这里把它彻底铺开。
状态机的好处,第一条是页面多、迁移清晰。十二个页面如果靠散落各处的 if/else 判断"现在在哪、该去哪",代码会纠缠成一团;而把所有跳转规则收敛到一处——`handleUserInteraction` 里的一个大 `switch (ui_state)`——任何一个页面在任何一个动作下会去往哪里,一眼就能查。第二条是防止非法迁移:低电量状态下,即使有按键消息进来,外层先 `return` 拦掉,阅读页根本收不到——非法操作在入口就被挡下(11.3 会看到这段代码)。第三条,`ui_state` 这个变量是切换页面的"唯一真相":它一改,下一次事件里整个界面就换到新页面;想看现在在哪一页,读它就行。
其实状态机的骨架是从上游 ESP32 项目(atomic14/diy-esp32-epub-reader)继承来的:上游只有三个状态——`SELECTING_EPUB → SELECTING_TABLE_CONTENTS → READING_EPUB`,用来串"找书 → 目录 → 阅读"这条主线。本项目把它扩展到十二个状态,主线原样保留,前后又接上了系统页与低功耗页。理解了这条演进,你就明白为什么主线三态的函数名、变量名全都沿用了上游的命名:`handleEpubList`、`handleEpubTableContents`、`handleEpub`——第 12 章我们和这三个老熟人还会反复照面。
状态机里还有两个词先铺平。事件分发(dispatch)说人话,就是把"发生了什么"交给"当前该管这件事的页面":按键、触控、电池消息统统先汇成统一的 `UIAction`,再由主循环转给当前状态的处理函数。迁移(transition)就是"从状态 A 跳到状态 B"这一次切换,通常伴随一次重绘。后文凡是说"xx 页迁到 xx 页",指的就是 `ui_state` 这个变量被改成新值的那一瞬间。
/* src/type.h:AppUIState——整套 UI 的"据点清单"(节选) */
typedef enum {
MAIN_PAGE, // 新主页面
SELECTING_EPUB, // 电子书列表页面(书库)
SELECTING_TABLE_CONTENTS, // 电子书目录页面
READING_EPUB, // 阅读页面
READING_SETTINGS, // 阅读设置页面(字体/字号/行距/边距)
SETTINGS_PAGE, // 通用功能设置页面
WELCOME_PAGE, // 欢迎页面
LOW_POWER_PAGE, // 低电量页面
CHARGING_PAGE, // 充电页面
SHUTDOWN_PAGE // 关机页面
} AppUIState;
/* src/main.cpp:一个状态变量 + 一台"总开关"(事件分发的一瞥) */
AppUIState ui_state = MAIN_PAGE; // 当前状态:同一时刻只有一个
void handleUserInteraction(Renderer *renderer, UIAction ui_action, bool needs_redraw)
{
if (battery && battery->get_low_power_state() == 1)
{
rt_kprintf("low power state\n");
return; // 低电量:任何普通操作都在入口被拦下
}
switch (ui_state)
{
case MAIN_PAGE:
handleMainPage(renderer, ui_action, needs_redraw);
break;
case READING_EPUB:
handleEpub(renderer, ui_action);
break;
/* SELECTING_EPUB / SETTINGS_PAGE / WEATHER_PAGE ... 完整分发见 11.3 */
}
}
| 关键词 | 人话解释 | 在本项目里的对应物 |
|---|---|---|
| 状态 | 设备此刻所处的界面,同一时刻有且只有一个 | AppUIState 枚举变量 ui_state |
| 事件 | 一次输入或系统通知,统一成一种类型上报 | UIAction(按键 / 触控 / 电池消息) |
| 迁移 | 从状态 A 跳到状态 B 的一次切换 | ui_state = 新值 + 重绘 |
| 事件分发 | 把动作交给当前该管它的页面去处理 | handleUserInteraction 的 switch |
把状态机当成棋盘来记:状态是据点,事件是棋子的走法,迁移是落子。写代码之前,先在纸上把十二个据点、每一条走法画清楚——棋盘画对了,写代码只是照着棋谱抄一遍。这也是"运筹帷幄"的本来意思:先算全局,再动子。
README 的「UI 页面与状态机」一节列了一份状态清单,我们直接对着源码验证——`src/type.h` 里的 `AppUIState` 枚举才是权威。它一共十二个状态,恰好分成三组。阅读主线:`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`(关机)。
注意一处文档与代码的差异:README 的「当前 UI 状态」只列了十项,把 `WEATHER_PAGE` 和 `WEATHER_CITY_PAGE` 漏掉了——但 `type.h` 里它们赫然在列,主循环里也确有 `case WEATHER_PAGE` 的分支。这是全书反复出现的"文档漂移"老毛病:以源码为准。真正权威的清单永远在枚举定义里,README 只能当速查卡片。
每个状态背后都有一个"绘制 + 处理"的配对函数,第 10 章已经逐个见过:主页面 `render_main_page` / `handleMainPage`,功能设置页 `render_settings_page` / `handleSettingsPage`,天气页 `handleWeatherPage` / `handleWeatherCityPage`(以上都在 `epub_screen.cpp`);书库页 `handleEpubList`、目录页 `handleEpubTableContents`、阅读页 `handleEpub`(内部再交给 `reader->render()`,都在 `main.cpp`);阅读设置页 `reading_settings_draw` / `reading_settings_handle_action`(`reading_settings.cpp`);欢迎 / 低电量 / 充电 / 关机四页则是 `draw_welcome_page` / `draw_low_power_page` / `draw_charge_page` / `draw_shutdown_page`(`main.cpp`)。可见"一个状态 = 一个页面 = 一对函数"的映射非常整齐。
怎么把这些枚举变成串口日志里看得懂的字符串?`main.cpp` 里有现成的 `getCurrentPageName()`:一个 `switch` 把每个 `AppUIState` 映射回它的名字,方便在日志里追踪"现在在哪一页"。调试状态机时,这个函数几乎是第一件武器——先知道自己在哪,才知道下一步该往哪走。
/* src/type.h:AppUIState 全量枚举(共 12 个状态) */
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;
/* src/main.cpp:把状态枚举变成日志里的字符串 */
const char* getCurrentPageName() {
switch (ui_state)
{
case MAIN_PAGE: return "MAIN_PAGE";
case SELECTING_EPUB: return "SELECTING_EPUB";
case READING_EPUB: return "READING_EPUB";
case WELCOME_PAGE: return "WELCOME_PAGE";
case LOW_POWER_PAGE: return "LOW_POWER_PAGE";
/* ... READING_SETTINGS / SHUTDOWN_PAGE 等其余分支 ... */
default: return "UNKNOWN_PAGE";
}
}
| 分组 | 状态 | 一句话职责 |
|---|---|---|
| 阅读主线 | MAIN_PAGE | 新主页面:四个入口,默认开机进入 |
| 阅读主线 | SELECTING_EPUB | 书库:每页 4 本,选书进入目录 |
| 阅读主线 | SELECTING_TABLE_CONTENTS | 目录:每页 6 项,选章节跳读 |
| 阅读主线 | READING_EPUB | 阅读:逐页翻书 + 覆盖操作层 |
| 阅读主线 | READING_SETTINGS | 阅读设置:字体 / 字号 / 行距 / 边距 |
| 系统服务 | SETTINGS_PAGE | 功能设置:触控、超时、全刷、蓝牙 |
| 系统服务 | WEATHER_PAGE / WEATHER_CITY_PAGE | 天气与城市选择(README 漏列的两位) |
| 低功耗与电源 | WELCOME_PAGE | 欢迎 / 熄屏:空闲超时进入 |
| 低功耗与电源 | LOW_POWER_PAGE | 低电量:抑制普通操作 |
| 低功耗与电源 | CHARGING_PAGE | 充电:插电且处于低电量时进入 |
| 低功耗与电源 | SHUTDOWN_PAGE | 关机:长时间无交互,随后深睡眠 |
据点要分清主次。阅读主线五页是你天天要见的,低功耗四页是守夜的,天气两页是客串的。画状态图时先画主线,再挂系统页,最后挂电源页——顺序对了,图就不乱。至于那两位"漏列"的天气状态,记住一条铁律:文档会过期,枚举不会。
有了据点和迁移规则,谁来"驱动"这台机器?答案是 `main.cpp` 里的 `main_task` 主循环。启动顺序很直白:`Board::factory()` 造出板子 → `power_up()` 上电 → 创建渲染器 → 启动文件系统 → 初始化 FreeType 字体管理器 → 建消息队列 ui_queue = rt_mq_create("ui_act", sizeof(UIAction), 10, 0)(RT-Thread 的消息队列,深度 10,一条消息就是一个 `UIAction`)→ 再拿到电池、按键、触控三个控制对象。第 5 章讲过,按键、触控、电池都不是直接调用 UI 函数,而是把动作 `rt_mq_send` 进这个队列——这就是"事件"统一上报的入口。
主循环的骨架是"阻塞等消息 + 收到就处理"。`rt_mq_recv(ui_queue, ...)` 最多等 500ms;取到消息后,先处理两类电池事件:`MSG_UPDATE_CHARGE_STATUS`(满电清图标,第 10 章的 `charge_full` 就住在这里)和 `MSG_BATTERY_CHECK`(周期电量巡检,若电量 <2% 且未充电,就置低电量状态并 `rt_mq_send` 出 `MSG_DRAW_LOW_POWER_PAGE` 等页面消息)——这两类都以 `continue` 跳过普通操作。真正的用户事件(按键、触控、覆盖层)走另一个分支:`board->wakeup_filesystem()` 唤醒 SD 卡 → 若当前在 `WELCOME_PAGE` 就先 `back_to_main_page()` 回主页 → 刷新 `last_user_interaction` → `touch_controls->renderPressedState(...)` → 最后交给 `handleUserInteraction(renderer, ui_action, false)`。
`handleUserInteraction` 就是那台"总开关"——一个巨大的 `switch (ui_state)`,把当前状态分发给对应的页面处理函数;页面函数内部再改 `ui_state` 完成迁移。它还顺带做两件跨页面的事:一是在入口拦掉低电量状态下的普通操作(11.1 提过);二是用 `rt_kprintf("Renderer time=%d \r\n", rt_tick_get() - start_tick)` 打印这一帧 UI 处理耗时,让你在串口里直接看到每页渲染有多贵。以功能设置页为例:`handleSettingsPage` 的返回值就是它跟总开关"说话"的暗号——`0` 继续留在设置页、`1` 回主页、`2` 进阅读设置,总开关据此改 `ui_state`。
主循环的"休息"逻辑也在这层。循环顶部先查一次超时:距上次交互达到 `screen_get_timeout_shutdown_minutes()`(默认值来自 `screen_init(TIMEOUT_SHUTDOWN_TIME)`),就 `draw_welcome_page(battery)` 进入熄屏;而整个 `while` 循环的边界条件是 60 * 1000 * 60 * TIMEOUT_SHUTDOWN_TIME(= 5 小时)——5 小时内始终无交互,循环退出,画关机页、进深睡眠。一层是"熄屏",一层是"关机",两层超时在同一段代码里各司其职。注意一个有意思的细节:`screen_init` 的形参名在头文件里写成 `default_timeout_hours`(小时),实现却按分钟使用,`screen_init(TIMEOUT_SHUTDOWN_TIME)` 实参是 5——默认熄屏超时实际上是 5 分钟,而宏注释写的却是"小时"。又一个注释与实现不符的活例子。
/* src/main.cpp:建队列,等消息(主循环骨架) */
ui_queue = rt_mq_create("ui_act", sizeof(UIAction), 10, 0); // 深度 10 的动作队列
while ((rt_tick_get_millisecond() - last_user_interaction < 60 * 1000 * 60 * TIMEOUT_SHUTDOWN_TIME))
{
uint32_t msg_data;
if (rt_mq_recv(ui_queue, &msg_data, sizeof(uint32_t), rt_tick_from_millisecond(500)) == RT_EOK)
{
UIAction ui_action = (UIAction)msg_data;
if (ui_action == MSG_UPDATE_CHARGE_STATUS) { /* 满电清图标 */ continue; }
if (ui_action == MSG_BATTERY_CHECK) { /* ADC 电量巡检 */ continue; }
if (ui_action == MSG_DRAW_LOW_POWER_PAGE ||
ui_action == MSG_DRAW_CHARGE_PAGE ||
ui_action == MSG_DRAW_WELCOME_PAGE) { /* 画电源页 */ }
else
{
board->wakeup_filesystem();
if (ui_action != NONE)
{
if (ui_state == WELCOME_PAGE) { back_to_main_page(); continue; }
last_user_interaction = rt_tick_get_millisecond();
handleUserInteraction(renderer, ui_action, false); // 交给总开关
}
}
}
}
/* src/main.cpp:handleUserInteraction 的分发骨架(节选) */
switch (ui_state)
{
case SETTINGS_PAGE:
{
int settings_result = handleSettingsPage(renderer, ui_action, needs_redraw);
if (settings_result == 1)
{
ui_state = MAIN_PAGE; // 回主页
handleMainPage(renderer, NONE, true);
}
else if (settings_result == 2)
{
g_state_before_settings = SETTINGS_PAGE;
ui_state = READING_SETTINGS; // 进阅读设置
reading_settings_draw(renderer);
}
break;
}
case READING_SETTINGS:
{
bool still_in_settings = reading_settings_handle_action(renderer, ui_action);
if (!still_in_settings) {
if (g_state_before_settings == READING_EPUB) {
ui_state = READING_EPUB; // 从阅读页进来 → 回阅读页
/* 用锚点恢复阅读位置(第 9 章)... */
} else {
ui_state = SETTINGS_PAGE; // 从设置页进来 → 回设置页
(void)handleSettingsPage(renderer, NONE, true);
}
}
return;
}
case SELECTING_EPUB:
default:
handleEpubList(renderer, ui_action, needs_redraw);
break;
}
rt_kprintf("Renderer time=%d \r\n", rt_tick_get() - start_tick);
| 消息 / 动作 | 来源 | 主循环处理 |
|---|---|---|
MSG_UPDATE_CHARGE_STATUS | 电池满电 / 掉出满电 | 清充电图标 / 重绘状态栏,continue |
MSG_BATTERY_CHECK | 周期电量巡检 | ADC 计算,按电量发页面消息,continue |
MSG_DRAW_LOW_POWER_PAGE 等三个 | 巡检时按电量发出 | draw_xxx_page,切到对应电源页 |
用户 UIAction(UP/DOWN/SELECT/SELECT_BOX/UPGLIDE…) | 按键 / 触控 | handleUserInteraction 分发给当前状态页面 |
主循环是全军的中军大帐:消息是军情,`switch` 是把军情分发给各营主将(页面函数)。排查"按了键没反应",按这个顺序问三句:军情到了没有(队列收到消息了吗)?分到哪一营了(`ui_state` 现在是谁)?主将办完没有(页面函数有没有改对状态)?三步查完,多半就有答案。
现在把全部迁移画成一张图。开机:`main_task` 里 `ui_state = MAIN_PAGE`(若是深睡眠唤醒则先从 `hydrate()` 恢复),默认落在主页面——README 明说"默认开机进入 MAIN_PAGE"。(对比上游 ESP32 项目默认进 `SELECTING_EPUB`,这是移植时改的,第 1 章提过。默认进主页面,是因为本项目把主页面当成了"总入口",书库只是它的一个分岔。)
主页面四个入口(`handleUserInteraction` 的 `MAIN_PAGE` 分支):选中"打开书库"→ `SELECTING_EPUB`;选中"进入设置"→ `SETTINGS_PAGE`;选中"查看天气"→ `WEATHER_PAGE`;选中"继续阅读"(有记录时)→ `READING_EPUB`。设置页与天气页还能继续下钻:`SETTINGS_PAGE → READING_SETTINGS`(进入阅读设置子页)、`WEATHER_PAGE → WEATHER_CITY_PAGE`(切换城市),退出时再逐级返回。
阅读主线是最长的一条链:`SELECTING_EPUB → SELECTING_TABLE_CONTENTS → READING_EPUB`(书库选书 → 目录选章节 → 阅读),这是第 12 章的主角。阅读页里通过覆盖操作层还能"回退":进目录(→ `SELECTING_TABLE_CONTENTS`)、返回书库(→ `SELECTING_EPUB`)、进阅读设置(→ `READING_SETTINGS`,退出后按锚点回到原阅读位置)。阅读页按下 SELECT,或从书库页底部按"主页面",都经 `back_to_main_page()` 回到 `MAIN_PAGE`;目录页底部中间按钮是"书库"而不是"主页面",它回的是 `SELECTING_EPUB`(第 12 章细讲)。
电源四页由电池状态和超时驱动,基本不靠用户按键(第 6 章详讲过):空闲达到超时 → `WELCOME_PAGE`(熄屏,任意按键回主页);电量 <2% 且未充电 → `LOW_POWER_PAGE`;插入充电且此前处于低电量 → `CHARGING_PAGE`;电量回升则 → `WELCOME_PAGE`;5 小时完全无交互 → `SHUTDOWN_PAGE` 并深睡眠。把这张图完整列成迁移表,就是下面这样。
/* src/main.cpp:主页面四个入口(MAIN_PAGE 分支) */
case MAIN_PAGE:
handleMainPage(renderer, ui_action, needs_redraw);
if (ui_action == SELECT && screen_get_main_selected_option() == OPTION_ENTER_SETTINGS)
{
ui_state = SETTINGS_PAGE;
int r = handleSettingsPage(renderer, NONE, true);
if (r == 2) { ui_state = READING_SETTINGS; reading_settings_draw(renderer); }
}
else if (ui_action == SELECT && screen_get_main_selected_option() == OPTION_WEATHER)
{
ui_state = WEATHER_PAGE;
(void)handleWeatherPage(renderer, NONE, true);
}
else if (ui_action == SELECT && screen_get_main_selected_option() == OPTION_CONTINUE_READING)
{
/* 有记录才进:重建 reader、恢复章节,详见第 12 章 */
}
else if (ui_action == SELECT && screen_get_main_selected_option() == OPTION_OPEN_LIBRARY)
{
ui_state = SELECTING_EPUB;
handleEpubList(renderer, NONE, true);
}
break;
/* src/main.cpp:两层超时——熄屏与关机 */
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() &&
battery && battery->get_low_power_state() != 1 &&
ui_state != WELCOME_PAGE && ui_state != CHARGING_PAGE && ui_state != LOW_POWER_PAGE &&
screen_get_timeout_shutdown_minutes())
{
renderer->set_margin_bottom(0);
draw_welcome_page(battery);
}
/* ... 取消息、处理动作(11.3) ... */
}
// 第二层:while 条件不满足(5 小时无交互)→ 关机页 + 深睡眠
renderer->dehydrate();
board->stop_filesystem();
draw_shutdown_page();
board->prepare_to_sleep();
/* src/main.cpp:MSG_BATTERY_CHECK 巡检,按电量迁移到电源页 */
ADCBattery* adc_battery = static_cast<ADCBattery*>(battery);
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; // 低电量 → LOW_POWER_PAGE
rt_mq_send(ui_queue, &msg, sizeof(UIAction));
} else if (is_charging && adc_battery->get_low_power_state() == 1) {
UIAction msg = MSG_DRAW_CHARGE_PAGE; // 插电充电 → CHARGING_PAGE
rt_mq_send(ui_queue, &msg, sizeof(UIAction));
} else if (percentage >= 2 && adc_battery->get_low_power_state() == 1) {
adc_battery->set_low_power_state(0);
UIAction msg = MSG_DRAW_WELCOME_PAGE; // 电量回升 → WELCOME_PAGE
rt_mq_send(ui_queue, &msg, sizeof(UIAction));
}
| 起点 | 终点 | 触发条件 | 代码位置 |
|---|---|---|---|
| (开机) | MAIN_PAGE | 默认值 / 深睡眠唤醒后 hydrate() | main_task |
MAIN_PAGE | SELECTING_EPUB | 选中"打开书库" + SELECT | handleUserInteraction |
MAIN_PAGE | SETTINGS_PAGE | 选中"进入设置" + SELECT | handleUserInteraction |
MAIN_PAGE | WEATHER_PAGE | 选中"查看天气" + SELECT | handleUserInteraction |
MAIN_PAGE | READING_EPUB | 选中"继续阅读" + SELECT(有记录) | handleUserInteraction |
SELECTING_EPUB | SELECTING_TABLE_CONTENTS | 书库页 SELECT 选中书 | handleEpubList |
SELECTING_TABLE_CONTENTS | READING_EPUB | 目录页 SELECT 选中章节 | handleEpubTableContents |
READING_EPUB | READING_SETTINGS | 覆盖层"设置" | handleEpub |
READING_EPUB | SELECTING_EPUB / SELECTING_TABLE_CONTENTS | 覆盖层"书库"/"目录"或 SELECT 回主页 | handleEpub |
| (任意) | WELCOME_PAGE | 空闲达到超时设置(默认 5 分钟) | 主循环顶部 |
| (任意) | LOW_POWER_PAGE | 电量 <2% 且未充电 | MSG_BATTERY_CHECK |
LOW_POWER_PAGE | CHARGING_PAGE | 插入充电 | MSG_BATTERY_CHECK |
LOW_POWER_PAGE | WELCOME_PAGE | 电量回升 ≥2% | MSG_BATTERY_CHECK |
| (任意) | SHUTDOWN_PAGE | 5 小时完全无交互 | while 退出后 |
表里"空闲达到超时设置(默认 5 分钟)"指的是熄屏超时,来自 screen_get_timeout_shutdown_minutes();而"5 小时"是整机关机超时,来自 while 条件里的 TIMEOUT_SHUTDOWN_TIME。两者同名近义却完全不同——一个管"熄屏",一个管"关机"。还有,头文件把 screen_init 形参注释成"小时",实现却按分钟用:这些都是源码里如实存在的不一致,以行为表现为准。
棋谱要背熟,更要会"读谱"。这张迁移表不用死记:主线是书库→目录→阅读,入口在主页面,退路是 back_to_main_page,守夜是那四张电源页。真要在代码里追一次迁移,就在 handleUserInteraction 的 switch 里打日志,看 ui_state 怎么从一个 case 跳到另一个 case——"运筹"的最高境界,是让状态自己告诉你它要去哪。
读 src/type.h,把 AppUIState 的十二个状态全部列出,并按"阅读主线 / 系统服务 / 低功耗与电源"三组归类。README 的「当前 UI 状态」漏掉了哪两个?
对着 11.2 的表格和枚举看;README 只列了十项。
阅读主线: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。README 漏掉了 WEATHER_PAGE 和 WEATHER_CITY_PAGE 两个天气状态——以源码枚举为准。
在书库页按一下 SELECT 进入目录页,把从"按键"到"页面切换"的完整链路写下来:消息队列、主循环、handleUserInteraction 分别扮演什么角色?哪一行代码真正完成了"迁移"?
按键 → rt_mq_send 进 ui_queue → 主循环 rt_mq_recv 取出 → handleUserInteraction → switch 分发到 handleEpubList → 内部执行 ui_state = SELECTING_TABLE_CONTENTS。
按键控件把动作打包成 UIAction 用 rt_mq_send 送进 ui_queue;主循环用 rt_mq_recv(500ms 超时)取出;handleUserInteraction 里 switch(ui_state) 分发给书库页 handleEpubList;handleEpubList 的 SELECT 分支执行 ui_state = SELECTING_TABLE_CONTENTS 并新建 EpubToc、重绘目录页。真正"完成迁移"的就是那一行 ui_state = SELECTING_TABLE_CONTENTS。
handleUserInteraction 为什么在最开头检查 battery->get_low_power_state() == 1 就 return?这样设计的目的是什么?如果去掉这行,会出现什么问题?给出完整推理链。
低电量时墨水屏刷新耗电又缓慢;阅读器要继续撑住最后一点电(第 6 章讲过)。
电量过低时,墨水屏每一次刷新都既耗电又慢,反复翻页可能让最后一点电瞬间耗尽、甚至来不及进入关机页就断电。所以在入口统一拦截:低电量状态下任何普通操作都直接 return,既不让页面被误唤醒,也抑制翻页消耗,把电留给关机流程和充电检测。若去掉这行,用户在低电量时仍能翻页、反复全刷,屏幕会在断电前无谓耗电——这正是"防止非法迁移"的落地:不是页面拒绝某个按键,而是整个状态机在入口就拒绝一切普通操作。
本章发现两处文档与代码不一致:(1) README 列 10 个状态、代码有 12 个;(2) screen_init 头文件注释写"小时"、实现按"分钟"用。请分别说明"以哪个为准、为什么",并给出你排查这类偏差的通用方法。
代码是"行为本身",文档是"人对行为的描述";永远以源码为权威,再反向修正文档。
(1) 以 type.h 枚举为准:运行时的迁移、分发全都走 AppUIState,README 只是少抄了两行;weather 两态在 handleUserInteraction 里确实有 case 分支,是真实存在的行为。(2) 以实现为准:screen_init 把实参当作分钟存进 timeout_shutdown_minutes,行为就是"默认熄屏超时 5 分钟";头文件的"小时"注释是作者意图,不是行为。通用方法:凡是"行为"和"描述"冲突,先读代码确认行为,再决定是修文档还是修代码——在本项目里,教学习惯是如实标注差异,当作活教材。