「费曼说:你真正懂一件事,不是能复述它,而是能从证据里重建它。」这一章给这台阅读器做一次"未实现体检"——不是 git 仓库意味着什么(15.1)、天气功能到底写了多少(15.2)、18 处 TODO 与 RT_ASSERT(0) 都藏在哪(15.3)、如果你是维护者先动哪里(15.4)。不批评,只盘点,然后给你一份"先做哪个"的清单。
核心方法论:第一性原理——判断"做没做完"不看文档承诺,只看代码证据
「把『这真的做完了吗』当成一个问题问到底,就是这一章的方法。一台机器有没有做完,不能问它的说明书,要问它的证据:有没有历史(15.1)?说明书跟不跟得上机器(15.1)?零件上有没有写着『此处未完工』的标签(15.3)?我们不是来批评这台阅读器的——每一处『没做完』都是最诚实的一课,因为真正的工程师不是把产品吹成完美,而是能把『缺什么、差在哪、先补哪』说得一清二楚。」
用第一性原理判断"一个项目做完了吗",答案不在 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 的数量。花十分钟看这四样,你对一个陌生仓库的理解,会比读两小时文档更深。
用第一性原理拆"一次天气请求",最少需要五件事:联网、发 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.cpp 有 WEATHER_PAGE 和 3 张天气卡片(weather_sync_cards_from_snapshot)。五脏俱全,甚至还有重试循环(3 次 × 1.5 秒)和一个改城市的 FINSH 命令 weather_city。那它为什么还排在"未实现"这一章?
第一层缺口:它绑死了 PAN。weather_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() | 必须手机蓝牙网关,否则"蓝牙未连接" |
| 发 HTTP | webclient_request(GET_URI) | 设备无 WiFi 模块,只能借手机网络 |
| 收数据 | weather_check_internet_access(DNS) | 域名解析失败即放弃 |
| 解析 JSON | http_weather_data_parse(cJSON) | 字段不全会置 invalid |
| 展示 | WEATHER_PAGE + 3 张卡片 | snapshot 不 valid 时只显示"尚未获取天气" |
把"天气没做完"翻译成一句话,就是依赖链断了一头:阅读器 → 手机蓝牙网关 → 手机的网络 → 心知天气 API。链上任何一环断掉,结果都是同一个"尚未获取天气"。这是判断"功能半成品"的通用办法——画一条从"用户按按钮"到"数据上屏"的链路,数一数每一环依赖什么、缺什么。链越长、外部依赖越多,功能就越"悬"。你想真正理解一个功能,就该把它的链路画出来,而不是只看它有没有代码。
对项目自己的源码做一次 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.c 的 CopyToMixedGrayBuffer 里,当图层格式是 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.c | SDIO 电源、TP 断电引脚、HEAP_END 等 |
读 TODO 的第一课是先分类,再恐慌:RT_ASSERT(0)//Not implemented yet 这类"功能缺口"决定你能不能走某条路径,撞上就是崩溃,优先级最高;"优化待办"只是影响好不好用,优先级次之;板级占位 TODO(SDIO 电源、TP 断电引脚)往往是"当前板子没用到,先留个口子",真实影响要看对应外设开没开。学会一眼把 TODO 分成这三类,你读陌生代码的恐惧就少一半。
把前两节的缺口收拢成一份"待办清单",按对用户体验的影响分三梯队。第一梯队(影响能不能用):① 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 的清单合上,自己排一次序,再和上面的三梯队对一遍——差在哪里,哪里就是你还没读懂的地方。这就是第一性原理的用法:不是记住结论,而是能独立推出结论。
在 src/boards/SF32PaperRenderer.h、src/boards/controls/TouchControls.h、src/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.c 的 CopyToMixedGrayBuffer MONO 分支:RT_ASSERT(0);//Not implemented yet——1bpp 混合灰度没实现,撞上就崩,属于功能缺口。
读 src/wheather.c:weather_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 计费,属于敏感凭证泄露风险。
epd_display.c 的 RT_ASSERT(0);//Not implemented yet 在什么图层格式下会触发?它与 Kconfig.proj 里哪个二选一宏(EPDIY_EPUB_1BPP / 4BPP)对应?
看 CopyToMixedGrayBuffer 的 data_format == LCDC_PIXEL_FORMAT_MONO 分支;再回 Kconfig.proj 查 1BPP 宏的生效条件。
当图层格式为 LCDC_PIXEL_FORMAT_MONO(1bpp 单色)时触发;LCDC_PIXEL_FORMAT_A4(4bpp 灰阶)路径有完整实现。Kconfig.proj 里 EPDIY_EPUB_1BPP 在 LCD_USING_EPD_OPMO37A3(SPI 小屏)时被默认选中——即选 1bpp 帧缓冲的配置会走到这段断言,属于功能缺口而非代码风格问题。
假设你是这台阅读器的维护者,只有一周时间。把 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 的判断前加一个"预留行数"的阈值判断。