第15章:未实现与待完善

「费曼说:你真正懂一件事,不是能复述它,而是能从证据里重建它。」这一章给这台阅读器做一次"未实现体检"——不是 git 仓库意味着什么(15.1)、天气功能到底写了多少(15.2)、18 处 TODO 与 RT_ASSERT(0) 都藏在哪(15.3)、如果你是维护者先动哪里(15.4)。不批评,只盘点,然后给你一份"先做哪个"的清单。

🔬

本章导师:费曼

核心方法论:第一性原理——判断"做没做完"不看文档承诺,只看代码证据

「把『这真的做完了吗』当成一个问题问到底,就是这一章的方法。一台机器有没有做完,不能问它的说明书,要问它的证据:有没有历史(15.1)?说明书跟不跟得上机器(15.1)?零件上有没有写着『此处未完工』的标签(15.3)?我们不是来批评这台阅读器的——每一处『没做完』都是最诚实的一课,因为真正的工程师不是把产品吹成完美,而是能把『缺什么、差在哪、先补哪』说得一清二楚。」

15.1 为什么说「没做完」:信号盘点

用第一性原理判断"一个项目做完了吗",答案不在 README 里,而在三个问题里:它有版本历史吗?文档跟得上代码吗?代码里有没有"占位 / 跳过"的痕迹?这三问,这台阅读器全都露出马脚。第一条:整个 EPD_Reader-main 目录不是一个 git 仓库——在根目录执行 git rev-parse --is-inside-work-tree,直接回答 fatal: not a git repository。没有提交历史、没有标签、没有 blame,你就无从知道"这段代码是什么时候、因为什么、由谁写成这样"——对一个要拿来做二次开发的项目,这是最大的考古障碍。

第二条是文档与代码的漂移。第 14.3 章已经对质过两处:README 说主页面 3 个入口,代码枚举 MainOption 里是 4 个(多了一个「查看天气」OPTION_WEATHER);README 的功能设置列了 5 项render_settings_page 实际画了 6 行(多了一行「蓝牙」SET_BLUETOOTH)。文档写出来就注定过时,这个道理第 14 章讲过;把它当成"没做完"的信号,理由很朴素:一个还在快速改动的项目,文档才总是追不上代码。文档滞后本身不是罪,但它揭示的是"项目仍处于演进中,而不是冻结发布"。

第三条最直观,也是全文最好的线索:src/ 里有个功能叫"天气",文件却叫 wheather.c / wheather.h——weather 的拼写错误。一个拼错的文件名能一直留在仓库里,说明这里缺少 code review、CI、lint 这类"外部约束":没人逐行看过、没人因为"这名字一看就不对"而拦住它。更妙的是,README 的「目录结构」树里根本没有天气这条(15.2 会展开),而 src/SConscript 的编译列表里明明写着 'wheather.c'——代码在编、文档没写。三个信号合在一起,指向同一个判断:这更接近一份"可运行的工作原型",而不是一份"发布态的产品"。

# 信号 1:整个项目不是 git 仓库 —— 没有历史、没有 blame、没有标签
$ git rev-parse --is-inside-work-tree
fatal: not a git repository (or any of the parent directories): .git

# 信号 2:README 说主页面 3 个入口,代码枚举里是 4 个(14.3 已对质)
$ grep -n "主页面包含" README.md
41:主页面包含 3 个入口:
$ grep -n "OPTION_WEATHER" src/epub_screen.h
13:  OPTION_WEATHER,   # 第 4 个入口,README 没提

# 信号 3:功能文件连名字都是拼错的(weather → wheather)
$ ls src/ | grep -i wheather
wheather.c   wheather.h
# src/SConscript:天气文件确实在编译列表里 —— 代码在编,文档却没写
src = ['main.cpp', 'UIRegionsManager.cpp', 'epub_screen.cpp',
       'epub_mem.c', 'epub_fonts.c', 'font_manager.c',
       'ft_dfs_bridge.c', 'reading_settings.cpp',
       'pan.c', 'wheather.c']   ← 拼错名的天气模块

# README「当前功能」里 grep 不到任何 weather / 天气 —— 写了代码,忘了写文档
$ grep -in "weather\|天气" README.md
(无输出)
信号证据含义
不是 git 仓库git rev-parse → fatal没有历史 / blame / 标签,无法考古
README 比代码旧主页面 3→4 入口、功能设置 5→6 行项目还在快速变动,文档追不上
文件名拼错wheather.c/.h缺 code review / CI 等外部约束
文档漏了已编代码SConscript 编了 wheather.c,README 目录树没有代码与文档各说各话
费曼提示

把"信号"想成一件事的证据链:一个信号可能被多种解释,但四个信号指向同一个方向时,结论就很稳了。判断项目成熟度,最便宜的方法不是读文档,而是看这四样——git 历史、README 与代码的吻合度、拼写/命名一致性、代码里 TODO 的数量。花十分钟看这四样,你对一个陌生仓库的理解,会比读两小时文档更深。

15.2 天气功能:wheather.c 写了多少,缺了什么

用第一性原理拆"一次天气请求",最少需要五件事:联网、发 HTTP、收 JSON、解析 JSON、把结果画到界面。逐一对 src/wheather.c 查账:联网靠 pan_service_*(PAN,即"手机当网关"的蓝牙网络服务,第 14 章提过它的开关);发请求用 webclient_request 拼 GET URL;收数据有 weather_check_internet_access 先做 DNS 检查;解析用 http_weather_data_parse 走 cJSON;界面侧 epub_screen.cppWEATHER_PAGE 和 3 张天气卡片(weather_sync_cards_from_snapshot)。五脏俱全,甚至还有重试循环(3 次 × 1.5 秒)和一个改城市的 FINSH 命令 weather_city。那它为什么还排在"未实现"这一章?

第一层缺口:它绑死了 PANweather_run_once 的第一步就是 if (!pan_service_is_pan_connected())——没连上手机网关,直接 weather_set_status("蓝牙未连接"/"网络未连接")return -1。这台阅读器本身没有 WiFi 模块,上网必须借一部手机的蓝牙中转;在"离线电纸书"的定位下,天气是一个依赖外部设备才能触发的可选功能。从"产品功能"角度看它写得不算少,从"独立阅读器"角度看它始终悬空——设备上不接手机,主页面那个「查看天气」入口永远只会显示 g_weather_snapshot 的默认状态"尚未获取天气"。

第二层缺口:配置全部写死在代码里WEATHER_KEY_ID(心知天气的 API key)、默认城市 nanjing、语言 zh-Hans&unit=c 都是 #define 常量。API key 是敏感凭证,写进固件等于随镜像分发给所有人;换城市要么重新烧固件,要么在串口敲 FINSH 命令 weather_city <城市>。更要命的是 README「当前功能」根本没有天气这一条——代码在、页面在、文档却没有。一个"写了但没被文档承认"的功能,是"半成品"最典型的定义:它不崩,但它也不属于产品。

/* src/wheather.c:天气功能的全部"配置"都写死在这几行 #define 里 */
#define GET_URL_LEN_MAX   256
#define GET_URI           "http://%s/v3/weather/now.json?key=%s&location=%s&language=%s"
#define WEATHER_HOST          "api.seniverse.com"    // 心知天气 API 域名
#define WEATHER_KEY_ID        "SO23_Gmly2oK3kMf4"  // API key:明文躺在固件里
#define WEATHER_CITY_ID_DEFAULT "nanjing"          // 默认城市:南京
#define WEATHER_LANGUAGE_ID   "zh-Hans&unit=c"      // 中文 + 摄氏度

/* 全局快照:valid 为假,status 默认"尚未获取天气" */
static weather_snapshot_t g_weather_snapshot = {
    RT_FALSE,
    -1,
    "", "", "", "", "", "", "", "", "",
    "尚未获取天气"
};
/* src/wheather.c:weather_run_once —— 第一步就卡在 PAN(手机蓝牙网关)上 */
int weather_run_once(void)
{
    int result = -1; int retry;
    const int retry_count = 3;      // 最多重试 3 次
    const int retry_delay_ms = 1500;

    for (retry = 0; retry < retry_count; ++retry) {
        if (!pan_service_is_pan_connected()) {
            weather_set_status(pan_service_is_bt_connected()
                                 ? "网络未连接" : "蓝牙未连接");
            return -1;   // 没有手机网关,直接放弃
        }
        /* 中间省略:get_weather() 发 HTTP → http_weather_data_parse() 解析 */
        /* 失败则 weather_set_status("等待网络就绪") 并 mdelay 重试 */
        ...
    }
    return result;
}
/* src/wheather.c:解析心知天气 JSON —— 只有走通到这儿,snapshot 才变"有效" */
static int http_weather_data_parse(char *json_data)
{
    cJSON *root = cJSON_Parse(json_data);
    if (!root) { weather_set_status("天气数据解析失败"); return -1; }

    cJSON *results = cJSON_GetObjectItem(root, "results");
    if (!cJSON_IsArray(results) || cJSON_GetArraySize(results) <= 0) { /* 返回为空 */ }

    /* 逐字段取出:location 的 name/country/path/timezone,now 的 text/code/temperature */
    weather_copy_json_string(parsed_snapshot.location, ..., cJSON_GetObjectItem(location, "name"));
    weather_copy_json_string(parsed_snapshot.weather_text, ..., cJSON_GetObjectItem(now, "text"));
    weather_copy_json_string(parsed_snapshot.temperature, ..., cJSON_GetObjectItem(now, "temperature"));
    ...
    parsed_snapshot.valid = RT_TRUE;     // 唯一的"成功"标记
    parsed_snapshot.last_result = 0;
    g_weather_snapshot = parsed_snapshot;
    cJSON_Delete(root);                  // 每次 cJSON_Parse 都要手动释放
    return 0;
}
天气链路五环实现(wheather.c)依赖 / 缺口
联网pan_service_is_pan_connected()必须手机蓝牙网关,否则"蓝牙未连接"
发 HTTPwebclient_request(GET_URI)设备无 WiFi 模块,只能借手机网络
收数据weather_check_internet_access(DNS)域名解析失败即放弃
解析 JSONhttp_weather_data_parse(cJSON)字段不全会置 invalid
展示WEATHER_PAGE + 3 张卡片snapshot 不 valid 时只显示"尚未获取天气"
费曼提示

把"天气没做完"翻译成一句话,就是依赖链断了一头:阅读器 → 手机蓝牙网关 → 手机的网络 → 心知天气 API。链上任何一环断掉,结果都是同一个"尚未获取天气"。这是判断"功能半成品"的通用办法——画一条从"用户按按钮"到"数据上屏"的链路,数一数每一环依赖什么、缺什么。链越长、外部依赖越多,功能就越"悬"。你想真正理解一个功能,就该把它的链路画出来,而不是只看它有没有代码。

15.3 代码里的 TODO 与 Not implemented yet

对项目自己的源码做一次 grep -rn "TODO",排除第三方库 miniz,能数出 18 处。它们不是等量齐观的:有的只是"下一步想做的事"(优化待办),有的却是"功能缺口"(撞上就崩或根本没有),还有一处 RT_ASSERT(0)//Not implemented yet 是作者用"断言崩掉"代替"实现"的诚实表达。先把三处最"功能化"的挑出来说。

第一处在渲染器。SF32PaperRenderer.h 的析构函数只有一行注释 // TODO: cleanup and shutdown?——渲染器没有真正的析构清理:rt_device_find("lcd") 打开的 LCD 设备,之后没有对应的关闭回收;同类的还有 TouchControls.h 类头部的自白——// TODO - we should move the rendering out of this class...,"渲染应该移出这个类,让它只做触控检测",但基类 render() / renderPressedState() 仍是空实现,绘制的活实际落在了别处(第 13 章说过命中检测在 SF32_TouchControls)。两处都是职责没有收口的痕迹:类是开好的口子,活没干完。

最硬的一处在显示驱动。epd_display.cCopyToMixedGrayBuffer 里,当图层格式是 LCDC_PIXEL_FORMAT_MONO(1bpp 单色)时,直接 RT_ASSERT(0);//Not implemented yet。第 4.3 章讲过它的邻居——A4(4bpp 灰阶)路径有完整实现;换句话说,"1 位深度的混合灰度路径根本没写,撞上就崩"。结合 Kconfig.proj 里的 EPDIY_EPUB_1BPP / 4BPP 二选一,含义是:选 1bpp 帧缓冲的配置会走到这段死路。驱动里能留下 RT_ASSERT(0),是作者用"崩掉"代替"实现"的诚实表达——但这条路确实是断的,这就是"功能缺口",不是代码风格问题。

/* src/boards/SF32PaperRenderer.h:析构函数只有一个问号 */
class SF32PaperRenderer : public EpdiyFrameBufferRenderer {
private:
  rt_device_t lcd_device = NULL;
  uint8_t idle_mode_on = 0;
public:
  ...
  ~SF32PaperRenderer()
  {
    // TODO: cleanup and shutdown?   ← 设备打开后没有对应的关闭清理
  }
  ...
};

/* src/boards/controls/TouchControls.h:职责没收口的自白 */
// TODO - we should move the rendering out of this class so that it's only doing the touch detection
class TouchControls {
public:
  virtual void render(Renderer *renderer) {}                     // 空实现
  virtual void renderPressedState(Renderer *renderer, UIAction action, bool state = true) {}
  ...
};
/* src/boards/display_dbi/epd_display.c:CopyToMixedGrayBuffer 里的"没写完" */
static void CopyToMixedGrayBuffer(LCDC_HandleTypeDef *hlcdc, const uint8_t *RGBCode,
                                uint16_t Xpos0, uint16_t Ypos0, uint16_t Xpos1, uint16_t Ypos1)
{
    uint32_t total_pixels = LCD_HOR_RES_MAX * LCD_VER_RES_MAX;
    RT_ASSERT((total_pixels % 4) == 0); // 必须是 4 像素的倍数

    if (hlcdc->Layer[HAL_LCDC_LAYER_DEFAULT].data_format == LCDC_PIXEL_FORMAT_MONO)
    {
        RT_ASSERT(0);//Not implemented yet   ← 1bpp 单色路径:撞上就崩
    }
    else if (hlcdc->Layer[HAL_LCDC_LAYER_DEFAULT].data_format == LCDC_PIXEL_FORMAT_A4)
    {
        /* 4bpp 灰阶路径:正常实现(16 位 → 32 位像素打包)*/
        ...
    }
    else
        RT_ASSERT(0);   // 其他格式也是断言兜底
}
/* 其余 TODO 的位置与性质(grep -rn TODO,排除 miniz,共 18 处) */
lib/Epub/EpubList/EpubToc.cpp:64   // 目录渲染"基本是列表渲染的复制",未复用
lib/Epub/RubbishHtmlParser/blocks/TextBlock.cpp:53  // "还有别的空白符要处理吗?"
sf32-oed-epd_base/bsp_power.c:100/108   // SDIO 电源上/下电 —— 占位 TODO
sf32-oed-epd_base/bsp_lcd_tp.c:58        // "Setup TP power down pin" —— 触控断电引脚
sf32-oed-epd_base/bsp_board.h:37/40     // HEAP_END 宏后挂着一个 TODO:
sf32-oed-epd_base/bsp_init.c:166/172   // FLASH1 时钟切换 / pin 未恢复 两处 TODO
// 注意:以上 4 个板级文件在 sf32-oed-epd_v12_spi 里各有 7 行相同 TODO
// (18 = 3 处功能 TODO + 4 个 lib/boards 文件 + 基板 7 行 + v12_spi 7 行)
类别位置性质
功能缺口epd_display.c 的 MONO 分支 RT_ASSERT(0)1bpp 混合灰度没实现,撞上就崩
职责没收口SF32PaperRenderer.h 析构 / TouchControls.h清理与渲染职责悬空,只有注释
优化待办EpubToc.cpp / TextBlock.cpp渲染复用、空白符边界
板级占位bsp_power.c / bsp_lcd_tp.c / bsp_board.h / bsp_init.cSDIO 电源、TP 断电引脚、HEAP_END 等
费曼提示

读 TODO 的第一课是先分类,再恐慌RT_ASSERT(0)//Not implemented yet 这类"功能缺口"决定你能不能走某条路径,撞上就是崩溃,优先级最高;"优化待办"只是影响好不好用,优先级次之;板级占位 TODO(SDIO 电源、TP 断电引脚)往往是"当前板子没用到,先留个口子",真实影响要看对应外设开没开。学会一眼把 TODO 分成这三类,你读陌生代码的恐惧就少一半。

15.4 可完善的清单:如果你是维护者

把前两节的缺口收拢成一份"待办清单",按对用户体验的影响分三梯队。第一梯队(影响能不能用):① 1bpp 混合灰度路径——CopyToMixedGrayBuffer 的 MONO 分支一进就崩,等于把 EPDIY_EPUB_1BPP 这个配置位直接废掉;② 天气的 PAN 依赖——没有手机蓝牙网关,主页面第 4 个入口永远显示"尚未获取天气"。这两条,一条是"会崩的路径",一条是"看起来有、其实没接通的承诺"。

第二梯队(影响好不好用):③ 分页断行质量——layout_one_block 是逐行硬排的,一行放不下就 pages.push_back(new Page()) 另起一页,没有任何"段落保持 / 孤行控制":标题可能孤零零落在页尾,段落第一行可能悬在页面最底行;④ 封面加载开销——书库页每次重绘,都对当前页每一本 new Epub(path) + epub->load() 重新解包、取封面、解码、画完再释放,有可见的内存峰值与延迟;封面缺失(Epub.cpp 只打 "Missing cover")或解码失败时,只能得到 Renderer::draw_image 画的一个占位矩形。这两条不致命,但直接决定"翻书库"顺不顺畅。

第三梯队(工程卫生):⑤ CSS——RubbishHtmlParser 是"不做 CSS、只认标准标签"的(第 1 章就说过),段间距全靠 m_layout_y += m_layout_line_height * 0.8 这个固定值糊弄,依赖 CSS 排版的 EPUB 读起来会挤;⑥ 蓝牙——功能设置里的开关能切 pan_service_set_enabled,但整机除了天气,没有第二个功能用到蓝牙,投入产出比很低;⑦ 文档同步——README 的入口数、设置项数、天气、深睡眠时长全部漂移,值得统一改一次(第 14 章已经抓了几处)。最后,把 bsp_power.c / bsp_lcd_tp.c 里那几处板级占位 TODO 补掉。如果你是维护者,第一性原理的排序是:先止血(①),再治本(②),最后美容(③④⑤⑥⑦)——先保证不崩,再让名实相符,最后才谈体验与卫生。

/* lib/Epub/RubbishHtmlParser/RubbishHtmlParser.cpp:逐行硬排,放不下就开新页 */
void RubbishHtmlParser::layout_one_block(Block *block)
{
    block->layout(m_layout_renderer, m_layout_epub);   // 阶段 1:先排版
    ...
    if (block->getType() == BlockType::TEXT_BLOCK)
    {
        TextBlock *textBlock = (TextBlock *)block;
        for (int i = 0; i < (int)textBlock->line_breaks.size(); i++)
        {
            if (m_layout_y + 2 * m_layout_line_height > m_layout_page_height)
            {
                pages.push_back(new Page());   // 行放不下 → 另起一页
                m_layout_y = 0;
            }
            pages.back()->elements.push_back(new PageLine(textBlock, i, m_layout_y));
            m_layout_y += m_layout_line_height;   // 逐行累加,没有段落保持/孤行控制
        }
        m_layout_y += m_layout_line_height * 0.8;   // 粗糙的段间距(代替 CSS margin)
    }
    ...
}
/* lib/Epub/EpubList/EpubList.cpp:书库页每次重绘都重新解包封面 */
for (int i = start_index; i < start_index + EPUBS_PER_PAGE && i < state.num_epubs; i++)
{
    if (current_page != state.previous_rendered_page) {
        Epub *epub = new Epub(state.epub_list[i].path);
        epub->load();                          // 整本解包,只为拿一张封面
        size_t image_data_size = 0;
        uint8_t *image_data = epub->get_item_contents(
            epub->get_cover_image_item(), &image_data_size);
        renderer->draw_image(epub->get_cover_image_item(),
            image_data, image_data_size, image_xpos, image_ypos, image_width, image_height);
        epub_mem_free(image_data);             // 画完即释放
    }
    ...
}

/* lib/Epub/Renderer/Renderer.cpp:封面解不出来 → 画一个占位矩形兜底 */
void Renderer::draw_image(const std::string &filename, const uint8_t *data, size_t data_size, ...)
{
    ImageHelper *helper = get_image_helper(filename, data, data_size);
    if (!helper || !helper->render(data, data_size, this, x, y, width, height))
        draw_rect(x + 20, y + 20, width - 40, height - 40);   // 占位矩形
}
梯队待办代码证据
第一(能用)补 1bpp 混合灰度路径CopyToMixedGrayBuffer 的 MONO 分支 RT_ASSERT(0)
第一(能用)天气的 PAN 依赖取舍(去或兜底)weather_run_once 首行 pan_service_is_pan_connected
第二(好用)分页断行质量(段落/孤行控制)layout_one_block 逐行硬排
第二(好用)封面加载开销(缓存 / 懒加载)EpubList.cpp 每本 new Epub + load
第三(卫生)CSS / 蓝牙 / 文档同步RubbishHtmlParser 不做 CSS、SET_BLUETOOTH、README 漂移
注意

这份清单里的每一条都可以在源码里指认到具体位置——这就是它比泛泛的"优化建议"值钱的地方。维护真实项目的顺序几乎总是"先修会崩的,再补没接通的,最后才谈体验",而"先做什么"的判断标准不是代码量,而是用户会不会在某个路径上直接撞墙。照 15.4 的三梯队顺序走,就是一次标准的排障优先级训练。

费曼提示

费曼讲物理时最爱说:"如果你不能把它解释清楚,你就还没真正懂它。"对维护者这个身份也一样:如果你不能把"为什么先做这一条"解释清楚,你就还没真正懂这个项目。把 15.4 的清单合上,自己排一次序,再和上面的三梯队对一遍——差在哪里,哪里就是你还没读懂的地方。这就是第一性原理的用法:不是记住结论,而是能独立推出结论。

章末练习

练习 1:找三个 TODO 入门

src/boards/SF32PaperRenderer.hsrc/boards/controls/TouchControls.hsrc/boards/display_dbi/epd_display.c 各找一个 TODO 或 "Not implemented yet",并分别说清它意味着什么(功能缺口 / 职责没收口 / 优化待办)。

提示

grep -n "TODO" 定位;对照 15.3 的三分类(功能缺口 / 职责没收口 / 优化待办)。

参考答案

SF32PaperRenderer.h 析构函数:// TODO: cleanup and shutdown?——渲染器没有真正的清理逻辑,属于职责没收口;TouchControls.h 头部:渲染应移出本类,但基类 render() 仍是空实现,同样属职责没收口;epd_display.cCopyToMixedGrayBuffer MONO 分支:RT_ASSERT(0);//Not implemented yet——1bpp 混合灰度没实现,撞上就崩,属于功能缺口。

练习 2:天气的依赖链 进阶

src/wheather.cweather_run_once 的第一步判断是什么?为什么没有手机蓝牙网关,天气就永远拿不到数据?API key 写在哪里、有什么风险?

提示

pan_service_is_pan_connected()pan_service_is_bt_connected() 的分支;API key 在 WEATHER_KEY_ID 宏里。

参考答案

第一步判断 pan_service_is_pan_connected(),为假就 weather_set_status("蓝牙未连接"/"网络未连接") 并返回 -1。因为设备没有 WiFi 模块,联网必须经手机蓝牙网关(PAN),网关不连,后面发 HTTP、解析全无从谈起。API key 写在 WEATHER_KEY_ID "SO23_Gmly2oK3kMf4",随固件一起烧进镜像、随镜像分发——key 等于公开,任何人都能拿着它调用心知天气 API 计费,属于敏感凭证泄露风险。

练习 3:RT_ASSERT(0) 何时触发 进阶

epd_display.cRT_ASSERT(0);//Not implemented yet 在什么图层格式下会触发?它与 Kconfig.proj 里哪个二选一宏(EPDIY_EPUB_1BPP / 4BPP)对应?

提示

CopyToMixedGrayBufferdata_format == LCDC_PIXEL_FORMAT_MONO 分支;再回 Kconfig.proj 查 1BPP 宏的生效条件。

参考答案

当图层格式为 LCDC_PIXEL_FORMAT_MONO(1bpp 单色)时触发;LCDC_PIXEL_FORMAT_A4(4bpp 灰阶)路径有完整实现。Kconfig.projEPDIY_EPUB_1BPPLCD_USING_EPD_OPMO37A3(SPI 小屏)时被默认选中——即选 1bpp 帧缓冲的配置会走到这段断言,属于功能缺口而非代码风格问题。

练习 4:为项目排一次优先级 挑战

假设你是这台阅读器的维护者,只有一周时间。把 15.4 的三梯队清单排成"每天做什么"的具体计划,并给出排序理由。针对"分页断行"这一条,你能在 layout_one_block 里加什么最小的改动,避免标题孤零零落在页尾?

提示

先保"不崩",再保"名实相符",最后体验;孤行问题可以想到"断页前先看下一行是不是段落尾 / 标题行至少带两行正文"这类启发式。

参考答案

排序参考:第 1-2 天补 CopyToMixedGrayBuffer 的 MONO 路径(会崩);第 3-4 天处理天气(去掉入口或给离线兜底"天气需连接手机",二者取一,名实相符);第 5-6 天做分页启发式与封面缓存(体验);第 7 天统一改 README 并补板级占位 TODO(卫生)。分页最小改动:在 layout_one_block 断页前,若下一行是某段落的最后一行、或当前页只剩一行容量时,提前换页("keep-with-next"思路),代码上只需在 m_layout_y + 2 * line_height > page_height 的判断前加一个"预留行数"的阈值判断。