第14章:已实现功能盘点

「包青天断案,铁面无私」——这一章把 README 承诺的"当前功能"一条条摆上公堂,与源码逐一核对:书从哪来(14.1)、靠什么翻页(14.2)、界面能干什么(14.3)、没电了怎么办(14.4)。每一项都要求有据可查,有差异就如实标注——最后用一句话给整台阅读器下结论(14.5)。

⚖️

本章导师:包青天

核心方法论:铁面无私——盘点的每一条都以源码为准,README 与代码不符处如实标注,绝不和稀泥

「本官断案,讲证据、看实情,绝不听一面之词。README 上的每一条"当前功能",都要拿到源码里去对质:书从哪里来(14.1)、靠什么翻页(14.2)、界面能干什么(14.3)、没电了怎么办(14.4)。README 说主页面有 3 个入口,可源码里明明坐着 4 位——本官如实记录,不偏不倚。铁面无私,是对代码的忠诚,也是对读者的坦诚。」

14.1 电子书与文件系统:内置 + TF 卡,优先 TF 卡

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.epubno_oebps.epubpg43-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(先尝试)大量书籍,用户自备
内置 Flashflash0FS_REGION 分区)2(后备)disk/ 打包的少量示例 EPUB
书库扫描EpubList::load("/")根目录 .epub,上限 MAX_EPUB_LIST_SIZE = 20
包青天提示

证据链完整。README 说"优先 TF 卡",代码里是"先挂 sd0、失败才挂 flash0",一句话对应一行 break。盘点时要练出这种"一句话 → 一段代码"的对质能力:功能描述能指到具体实现,才叫"确实实现";指不到,就得标成存疑。

14.2 输入与显示:按键 + 触控,中英文

README「输入与显示」写了两条:支持按键与触控双输入支持中文/英文显示。双输入在源码里是两套独立驱动,最终汇进同一个动作队列:SF32_ButtonControlsBUTTON_CLICKED 映射成 UP/DOWN/SELECT、把 BUTTON_LONG_PRESSED 映射成 UPGLIDESF32_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,长按 → UPGLIDESF32_ButtonControls.cpp
触控输入SF32_TouchControls:区域命中 + 上滑(SWIPE_THRESHOLD=100SF32_TouchControls.cpp
中文/英文显示FreeType + 内置 DroidSansFallback.ttf(Flash XIP)font_ft.cfont/
外部字体TF 卡 /fonts.ttf/.otf,最多 64 个font_manager.c
字重三档字重轴 apply_variable_weight 或 FT 合成加粗epd_font_ft_set_bold
包青天提示

本官最看重"名实相符"。README 说"默认未启用粗体/斜体中文字库",容易误读成"没有粗体";一查代码,字重三档照常有,只是粗体靠合成而非额外字库。盘点的价值就在这:把"文档的模糊表述"翻译成"代码的精确事实",再如实写出来。

14.3 UI 与设置:主页面入口与三套设置

这一节要办一桩"证词不符"的案子。README「主页面」白纸黑字写"主页面包含 3 个入口:打开书库 / 继续阅读 / 进入设置"。可一查 epub_screen.hMainOption 枚举,源码里是 4 个OPTION_OPEN_LIBRARY = 0OPTION_ENTER_SETTINGSOPTION_WEATHEROPTION_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 项」都比代码旧,分别漏了天气与蓝牙两项。学到的是工程习惯:功能文档写出来就注定会过时,排查问题永远以源码为准;反过来,改功能时记得同步文档,否则下一个读文档的人就被你坑了。

14.4 电量与低功耗:一路省到底

README「电量与低功耗」列了五条,逐条对源码:页面顶部显示电量与充电状态(闪电图标),电量满时(≥98%)自动清除充电图标——状态栏由 draw_status_bar 绘制,MSG_UPDATE_CHARGE_STATUS 分支里 percentage >= 98 && !charge_fullclear_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_iconpercentage >= 98main.cpp
低电量进 LOW_POWER_PAGE 并抑制操作percentage < 2 触发;get_low_power_state()==1 短路main.cpp
充电状态变化才刷屏cur_percent != last || cur_charging != lastmain.cpp 主循环末尾
超时进欢迎页>= 60*1000*screen_get_timeout_shutdown_minutes()main.cpp 主循环
默认 5 小时关机深睡TIMEOUT_SHUTDOWN_TIME 5 + 循环条件main.cpp
包青天提示

这五条全部对得上,一条不虚。注意省电的层次:触控硬件下电(13.3)→ 超时欢迎页熄屏(分钟级)→ 5 小时关机深睡(小时级),一级比一级省,一级比一级代价大。低功耗设计就是这么一层层摞起来的——盘点时把层次画出来,比单条罗列更能看清系统。

14.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
包青天提示

本官断案至此,卷宗已合。盘点章的收尾不是"我讲完了",而是"你有清单了":把这三行映射表留作自测——每一项你能否自己打开源码、说出它对应的文件与函数?能做到,这一路移植实战就没白学;做不到,就回到对应章节再对一次证据。铁面无私的最后一条原则:证据不是背下来的,是查出来的。

章末练习

练习 1:逐项对质 入门

打开 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.cppMSG_UPDATE_CHARGE_STATUS 分支:percentage >= 98 && charge_full == falseclear_charge_icon

练习 2:证词不符 进阶

README 说主页面有 3 个入口、功能设置有 5 项。核实源码后,分别说出实际数量、多出来的那项是什么,并判断该以谁为准。

提示

MainOption 枚举数一数;render_settings_page 里 draw_setting_row 数一数。

参考答案

主页面实际 4 个入口(多了「查看天气」,OPTION_WEATHER);功能设置实际 6 行(多了「蓝牙:开/关」,SET_BLUETOOTH)。两处都是 README 比代码旧,以源码为准——README 是静态文档,代码是活的真相。

练习 3:省电层次 进阶

把 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;

练习 4:为自己出一份盘点清单 挑战

不看本盘点章,自己为「能调」这个能力列一份清单:主页面、功能设置、阅读设置、覆盖层各有哪些可调项,每项写出它的代码证据(枚举/函数/常量)。再对照 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)。凡是你写的每一项能对上源码枚举与函数,就说明盘点能力已到手。