「狄仁杰断案,重流程、重证据」——读完一本书,像破一件案子:先过书库这道"案卷架"(12.2),再翻目录这本"卷宗索引"(12.3),最后才进阅读页这个"庭审现场"(12.1)。中间的每个按钮、每下触控,都有代码作证;案子的尾声,还有一桩"旧案重审"——继续阅读(12.4)。
核心方法论:系统分析——把阅读流程拆成"书库 → 目录 → 阅读"三个环节,逐环取证,最后回看"继续阅读"这份记录
「查案重流程,办案重证据。这套阅读流程就是一条完整的证据链:书库页是案卷架,目录页是卷宗索引,阅读页是庭审现场——三者一环扣一环(12.1),每次翻页、每个按钮、每下触控,都能在源码里找到对应的证词(12.2-12.3)。最后还有一桩旧案重审:继续阅读(12.4),要翻开上次没读完的那一页,得先弄清那份"卷宗"从哪写、从哪读、何时失效。」
README 的「书库页与目录页」开门见山:阅读流程:书库页 → 目录页 → 阅读页。在书库页选中书籍后,将先进入目录页,再由目录页选择章节跳入阅读。也就是说,读一本书要过两道"门":先在书库里挑书,再在目录里挑章节,然后才真正进入阅读。这不是多此一举——对一本动辄几十章的书,直接进阅读页让用户从第一页翻起反而低效,先在目录里定位章节,阅读体验好得多。
状态迁移上,这条主线对应 `handleEpubList` 和 `handleEpubTableContents` 两个函数的 `SELECT` 分支。书库页里按下 SELECT:`ui_state = SELECTING_TABLE_CONTENTS`,新建 `EpubToc`、`load()`、重绘目录页。目录页里再按 SELECT:`ui_state = READING_EPUB`,新建 `EpubReader`,用 set_state_section(contents->get_selected_toc()) 把阅读起点定位到选中的章节,然后 `load()` 进入阅读。两个"关口"各司其职:书库管"读哪本",目录管"从哪章起"。
这条主线和上游 ESP32 项目一脉相承——上游的三态状态机(`SELECTING_EPUB → SELECTING_TABLE_CONTENTS → READING_EPUB`)就是这三页,连两个状态结构体也原样继承:`EpubListState`(书库状态:当前选中项、书的总数、最多 20 本书的路径 / 标题 / 当前章节 / 当前页码)和 `EpubTocState`(目录状态:当前选中项、上次渲染页),在 `main.cpp` 里都声明成普通全局变量。值得一提的小差异:`State.h` 里还留着上游 "this is held in the RTC memory" 的注释,但本移植版并没有把它们放进 RTC 内存——又一次"注释跟着代码搬了家、含义却没跟上"。
另一个差异在入口:上游开机直接进书库页,本项目默认进主页面,再由主页面"打开书库"进入这条主线(第 11 章的迁移表已经画过)。所以完整的一条阅读路径是:主页面 → 书库 → 目录 → 阅读——主页面是多出来的一道总闸,书库只是它的一个分岔。
/* src/main.cpp:书库页选中书籍 → 进入目录页(handleEpubList 的 SELECT) */
else
{
ui_state = SELECTING_TABLE_CONTENTS;
contents = new EpubToc(epub_list_state.epub_list[epub_list_state.selected_item],
epub_index_state, renderer);
contents->load();
contents->set_needs_redraw();
handleEpubTableContents(renderer, NONE, true);
return;
}
break;
/* src/main.cpp:目录页选中章节 → 跳入阅读页(handleEpubTableContents 的 SELECT) */
else
{
ui_state = READING_EPUB;
reader = new EpubReader(epub_list_state.epub_list[epub_list_state.selected_item], renderer);
reader->set_state_section(contents->get_selected_toc()); // 从选中的章节开始
reader->load();
g_last_read_index = epub_list_state.selected_item; // 记下"最近一次阅读"(12.4)
delete contents;
handleEpub(renderer, NONE);
return;
}
| 环节 | 对应状态 | 做什么 | 处理函数 |
|---|---|---|---|
| 书库页 | SELECTING_EPUB | 挑书:每页 4 本 | handleEpubList |
| 目录页 | SELECTING_TABLE_CONTENTS | 挑章节:每页 6 项 | handleEpubTableContents |
| 阅读页 | READING_EPUB | 逐页读书 + 覆盖操作层 | handleEpub / reader->render() |
破案先看流程。书库→目录→阅读这条链是整套 UI 的"脊椎",所有页面都挂在它旁边——画流程图时先画这条链,再把设置、天气、电源页当成支线挂上去。记住一句口诀:书库管"读哪本",目录管"从哪章起",阅读管"读到哪"。
书库页的数据在 `EpubListState`(`EpubList/State.h`):`num_epubs` 是书的总数,`selected_item` 是当前选中的书,`epub_list[]` 最多装 20 本(`MAX_EPUB_LIST_SIZE = 20`),每本记录路径、标题、当前章节、当前页码。显示时按 `EPUBS_PER_PAGE = 4` 分页(`EpubList.cpp` 顶部 #define EPUBS_PER_PAGE 4):cell_height = (屏高 - 底部按钮区 - 底部间距) / 4,每个单元格画封面(`get_cover_image_item` + `draw_image`)加书名,并为每一项注册一个触控区域(static_add_area(..., (i % 4)),页内偏移 0–3)。
底部是一排三个等宽按钮:上一页 / 主页面 / 下一页,注册为触控区域 4 / 5 / 6。选中的按钮画五层嵌套边框高亮,未选中的画细描边。交互逻辑全在 `handleEpubList`:上下键移动选中项,走到页首 / 页尾继续按,就切入底部按钮选择模式(`library_bottom_mode = true`),`library_bottom_idx` 取 0 / 1 / 2 对应三个按钮;再按 SELECT,`library_bottom_idx == 1` 回主页面(`back_to_main_page()`),`== 0` / `== 2` 翻上一页 / 下一页。翻页的本质是重设 selected_item = 新页的起始索引 并 `set_needs_redraw()`。
触控走的是"先选中再确认"机制(README 明说"以降低误触"),代码在 `SF32_TouchControls.cpp`。第一次点某本书:把 `book_index` 记为这一页内的偏移(0–3),置 `waiting_for_confirmation = true`,发出 `SELECT_BOX` 动作——书库页收到后 `epub_list->switch_book(global_index)` 把选中框移到这本书。第二次点同一本:这次才发出 `SELECT`,书库页转入目录页。想换书?点另一本,`waiting_for_confirmation` 重新记为那本书。两次点击必须命中同一本才生效——这就是"先选中,再确认"。
/* lib/Epub/EpubList/EpubList.cpp:书库页,每页 4 本 + 底部三按钮 */
#define EPUBS_PER_PAGE 4
...
int current_page = state.selected_item / EPUBS_PER_PAGE;
int cell_height = (renderer->get_page_height() - bottom_area_height - bottom_margin) / EPUBS_PER_PAGE;
int start_index = current_page * EPUBS_PER_PAGE;
for (int i = start_index; i < start_index + EPUBS_PER_PAGE && i < state.num_epubs; i++)
{
/* 画封面 + 书名 ... */
static_add_area(area_x, area_y_start, area_w, area_h, (i % 4)); // 触控区域:页内偏移 0-3
}
/* 底部三按钮:上一页 / 主页面 / 下一页 */
draw_button(btn_x0, "上一页", m_bottom_mode && m_bottom_idx == 0);
draw_button(btn_x1, "主页面", m_bottom_mode && m_bottom_idx == 1);
draw_button(btn_x2, "下一页", m_bottom_mode && m_bottom_idx == 2);
static_add_area(btn_x0, btn_y, btn_w, btn_h, 4); // 上一页
static_add_area(btn_x1, btn_y, btn_w, btn_h, 5); // 主页面
static_add_area(btn_x2, btn_y, btn_w, btn_h, 6); // 下一页
/* src/boards/controls/SF32_TouchControls.cpp:书库页触控"先选中再确认" */
if (waiting_for_confirmation && last_clicked_book_index == clicked_book_index)
{
book_index = clicked_book_index;
library_bottom_mode = false;
action = SELECT; // 第二次点同一本 → 确认,进目录
waiting_for_confirmation = false;
last_clicked_book_index = -1;
}
else
{
book_index = clicked_book_index;
last_clicked_book_index = clicked_book_index;
waiting_for_confirmation = true;
action = SELECT_BOX; // 第一次点 → 先选中(移选框)
}
| 参数 | 值 |
|---|---|
| 每页项数 | EPUBS_PER_PAGE = 4 |
| 底部按钮 | 上一页 / 主页面 / 下一页 |
| 底部按钮触控区域 | 4 / 5 / 6 |
| 底部选择模式 | library_bottom_mode / library_bottom_idx(0 / 1 / 2) |
| 触控页内偏移 | book_index(0–3) |
| 列表项触控区域 | (i % 4) |
断案讲证据:两次点击必须是同一本,才配得上"确认"二字。这套"先选中再确认"是为墨水屏量身定做的——它刷新慢、点错成本高,宁可按两下,也不让你随手误触就跳页。任何"容易误触的确认动作",都值得借鉴这一招。
目录页与书库页长得几乎一样,只是项更小、更密:`ITEMS_PER_PAGE = 6`(`EpubToc.cpp` 顶部 #define ITEMS_PER_PAGE 6),每行画一个章节标题,并为每一项注册触控区域 (i % 6)。底部三按钮变成 上一页 / 书库 / 下一页,注册为触控区域 6 / 7 / 8——注意中间按钮从"主页面"换成了"书库":从目录再回主页要绕远路,回书库更常用,所以 README 明说"底部支持'上一页 / 书库 / 下一页'"。
交互在 `handleEpubTableContents`,套路与书库页几乎逐行一致:`toc_bottom_mode` / `toc_bottom_idx` 管底部三按钮,上下键走到页首 / 页尾切入底部模式;`toc_bottom_idx == 1` 回书库(`ui_state = SELECTING_EPUB`),`== 0` / `== 2` 翻页。触控同样先选中再确认:第一次点某项发出 `SELECT_BOX`,`toc_index` 记下页内偏移(0–5),把 epub_index_state.selected_item = global_index 并 `contents->switch_book(global_index)` 移选框;第二次点同一项才发出 `SELECT`。
目录页 `SELECT` 的"正事"是跳读:`ui_state = READING_EPUB`,新建 `EpubReader`,`set_state_section(contents->get_selected_toc())` 把阅读起点设为选中的章节,`load()` 后进入阅读,并顺手更新 `g_last_read_index`。`get_selected_toc()` 返回当前选中目录项对应的章节号——这正是"选中章节跳读"的落点,和 12.1 那段代码是同一段。目录页与书库页共享同一套"列表 + 底部按钮 + 先选中再确认"的骨架,只换了每页项数、中间按钮和触控区域编号。
/* lib/Epub/EpubList/EpubToc.cpp:目录页,每页 6 项 + 底部三按钮 */
#define ITEMS_PER_PAGE 6
...
int cell_height = (renderer->get_page_height() - bottom_area_height - bottom_margin) / ITEMS_PER_PAGE;
int start_index = current_page * ITEMS_PER_PAGE;
for (int i = start_index; i < start_index + ITEMS_PER_PAGE && i < epub->get_toc_items_count(); i++)
{
/* 画章节标题 ... */
static_add_area(area_start_x, area_start_y, area_end_x - area_start_x, area_end_y - area_start_y, (i % ITEMS_PER_PAGE));
}
/* 底部三按钮:上一页 / 书库 / 下一页(与书库页不同,中间是"书库") */
draw_button(btn_x0, "上一页", m_bottom_mode && m_bottom_idx == 0);
draw_button(btn_x1, "书库", m_bottom_mode && m_bottom_idx == 1);
draw_button(btn_x2, "下一页", m_bottom_mode && m_bottom_idx == 2);
static_add_area(btn_x0, btn_y, btn_w, btn_h, 6); // 上一页
static_add_area(btn_x1, btn_y, btn_w, btn_h, 7); // 书库
static_add_area(btn_x2, btn_y, btn_w, btn_h, 8); // 下一页
/* src/main.cpp:目录页触控"选中"(handleEpubTableContents 的 SELECT_BOX) */
case SELECT_BOX:
if (toc_bottom_mode) { toc_bottom_mode = false; }
current_page = epub_index_state.selected_item / 6;
start_index = current_page * 6;
global_index = start_index + toc_index; // 全局索引 = 页起点 + 页内偏移
if (global_index < contents->get_items_count() && contents->get_items_count() > 0)
{
epub_index_state.selected_item = global_index;
switch (toc_index)
{
case 0: case 1: case 2:
case 3: case 4: case 5:
contents->switch_book(global_index); break; // 把选中框移过去
default: break;
}
}
break;
| 参数 | 值 |
|---|---|
| 每页项数 | ITEMS_PER_PAGE = 6 |
| 底部按钮 | 上一页 / 书库 / 下一页 |
| 底部按钮触控区域 | 6 / 7 / 8 |
| 底部选择模式 | toc_bottom_mode / toc_bottom_idx(0 / 1 / 2) |
| 触控页内偏移 | toc_index(0–5) |
| 列表项触控区域 | (i % 6) |
目录页是卷宗索引:项更小、更密,一个屏幕能装下六条线索。两页长得像不是巧合——书库与目录共用同一套"列表 + 底部按钮 + 先选中再确认"的骨架,只换了每页项数和中间按钮。识别这种"同一骨架、不同参数"的复用,正是系统分析的基本功。
README「主页面」交代了"继续阅读"的规则:基于本次运行期间最近一次成功进入阅读的书籍索引,并自动恢复到上次所在章节;若当前无可用记录,则显示"无阅读记录"。关键词是"本次运行期间"——实现它的 `g_last_read_index` 只是一个普通全局变量(int g_last_read_index = -1,`-1` 表示无记录),不是 `RTC_DATA_ATTR` 那种掉电保留的内存,所以每次重启都复位为 `-1`,记录只在一次开机内有效。这和第 9 章的阅读设置(持久化到 `/settings.cfg`)正好形成对照:设置跨重启,续读记录不跨重启。
它什么时候被写入?两处。`handleEpub` 首次创建 `reader` 时:g_last_read_index = epub_list_state.selected_item;目录页 `SELECT` 跳读时也赋一次(12.1 那段代码里就有)。总之每次真正"进入阅读",索引都会刷新为当前这本书。主页面 `render_main_page` 据此决定选项文本:num_epubs > 0 && g_last_read_index >= 0 && g_last_read_index < num_epubs 为真显示"继续阅读",否则显示"无阅读记录"。
选中它之后,`handleUserInteraction` 的 `MAIN_PAGE` 分支这样接:先复查 `g_last_read_index` 是否合法(越界就 `return`,什么都不做),然后从 epub_list[g_last_read_index] 重建 `EpubReader`,set_state_section(last_item.current_section) 恢复到最后一次进入的章节,`load()`,`ui_state = READING_EPUB`。`current_section` 是 `EpubListItem` 里记录"当前阅读章节编号(从 0 开始)"的字段——"自动恢复到上次所在章节"靠的就是它。注意:这里恢复的是"章节",结构体里的页码字段 `current_page` 在此路径并不读取——要精确定位到段内某一页,还得靠第 9 章的锚点机制(`save_anchor` / `restore_by_anchor`)。
/* src/epub_screen.cpp:主页面选项文本——有记录才显示"继续阅读" */
extern EpubListState epub_list_state;
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;
}
/* src/main.cpp:选中"继续阅读"→ 恢复上次章节并进入阅读 */
else if (ui_action == SELECT && screen_get_main_selected_option() == OPTION_CONTINUE_READING)
{
if (!(g_last_read_index >= 0 && g_last_read_index < epub_list_state.num_epubs)) {
return; // 无记录 / 越界:直接忽略
}
if (reader) { delete reader; reader = nullptr; }
int last_idx = g_last_read_index;
EpubListItem &last_item = epub_list_state.epub_list[last_idx];
reader = new EpubReader(last_item, renderer);
reader->set_state_section(last_item.current_section); // 恢复上次章节
reader->load();
ui_state = READING_EPUB;
handleEpub(renderer, NONE);
}
g_last_read_index 取值 | 含义 | 主页面显示 |
|---|---|---|
-1(初始值) | 本次运行还没有过阅读记录 | 无阅读记录 |
≥ 0 且 < num_epubs | 最近一次进入阅读的书索引 | 继续阅读 |
| 越界 | 记录失效(书库内容已变化) | 显示"无阅读记录",且拒绝跳转(直接 return) |
旧案重审要看卷宗,继续阅读要看记录。`g_last_read_index` 这份"卷宗"只存在于本次开机——想让它跨重启,就得挪进 RTC 内存或写进文件,跟第 9 章把设置写进 `/settings.cfg` 是同一个道理。读懂了这份卷宗从哪写、从哪读、何时失效,你就掌握了"状态要不要持久化"的取舍:设置值得存,临时续读记录未必。
按 README「书库页与目录页」,写出阅读主流程的三步,并说明每一步对应哪个状态、由哪个处理函数负责。
书库 → 目录 → 阅读,对应 SELECTING_EPUB / SELECTING_TABLE_CONTENTS / READING_EPUB。
书库页(SELECTING_EPUB,handleEpubList)→ 目录页(SELECTING_TABLE_CONTENTS,handleEpubTableContents)→ 阅读页(READING_EPUB,handleEpub / reader->render())。在书库页选中书籍后先进入目录页,再由目录页选择章节跳入阅读。
书库页触控为什么用"先选中再确认"?第一次点击和第二次点击分别发出哪个 UIAction?代码是怎么保证"两次必须是同一本"才生效的?
SF32_TouchControls.cpp 里用 waiting_for_confirmation 和 last_clicked_book_index 两个变量;第一次发 SELECT_BOX,第二次发 SELECT。
墨水屏刷新慢、误触代价高,先点一次把选中框移过去、再点一次确认,能显著降低误触跳页。第一次点击发 SELECT_BOX(置 waiting_for_confirmation = true、记下 last_clicked_book_index);只有第二次点击的 clicked_book_index 等于 last_clicked_book_index 时才发 SELECT 进入目录页;若点了另一本,waiting 状态被重置为那本书,仍需再点一次确认。
书库页与目录页共享哪些"骨架"?在哪些参数上不同(每页项数、底部按钮、触控区域编号)?这种复用有什么好处?
对比 12.2 与 12.3 的两张参数表:底部按钮的"中间键"一个写主页面、一个写书库。
共享骨架:列表单元格布局、底部三按钮选择模式(xxx_bottom_mode / xxx_bottom_idx)、触控"先选中再确认"、翻页即重设 selected_item。差异:书库每页 4 项(EPUBS_PER_PAGE=4)、底部"上一页 / 主页面 / 下一页"、区域 4/5/6;目录每页 6 项(ITEMS_PER_PAGE=6)、底部"上一页 / 书库 / 下一页"、区域 6/7/8。复用让两页的交互心智模型一致,用户学会一页就等于学会两页,代码也便于对照排查。
「继续阅读」为什么只恢复到"章节"而不是"页码"?如果要求跨重启也能继续阅读,需要改哪些地方?请给出方案,并说明它与第 9 章设置持久化的异同。
g_last_read_index 是普通变量、重启即 -1;current_section 在 EpubListItem 里,但 CONTINUE_READING 只 set_state_section;要跨重启需把索引写进文件或 RTC 内存。
继续阅读走的是 set_state_section(last_item.current_section),只恢复章节;段内精确到页要动用第 9 章的锚点机制(save_anchor / restore_by_anchor),普通续读不读 current_page 字段。要跨重启:把 g_last_read_index(以及需要的话 current_section / 锚点)写进 RTC 内存(RTC_DATA_ATTR)或仿照 /settings.cfg 持久化到文件,开机加载并校验索引仍在 epub_list 范围内。与第 9 章的异同:都是"把状态存下来、下次恢复",但设置是全局配置、跨重启必须保留,续读是临时便利、作者选择只在本次运行有效——这本身就是一次"值不值得持久化"的工程取舍。