把三页 UI 从两万七千行里抽出来:页面投影(page model)三次抽取
前面几篇讲过从主文件里搬走”生存结算”和”跨世界快照”。这篇是同一场手术的另一条线:把三个界面从主文件里搬出去。
先给个规模感。这次重构之前,src/game/game.cpp 是 27045 行。背包页、装备页、状态页这三页的每一行文字、每一个颜色、每一条详情、每一个动作字符串,都直接写在里面,和渲染、输入、存档、战斗挤在同一个类里。
三次抽取,主文件净减 675 行,新增三个页面模型共 833 行(含头文件)。
一、抽出来的东西不叫”函数”,叫「投影」
最开始想得很朴素:把一页的构造代码包成一个函数,搬进新文件。真动手才发现问题——一页 UI 要读的东西太多了。背包页要读负重、容器内容、反装配进度、当前筛选模式、调色板;状态页要读属性、技能、植入体、突变、营养、伤口、八世界风格……照传统做法,这些全都要作为参数传进去,函数签名会变成一堵墙。
所以形态定成了三个东西:
// 运行参数:这次投影"在什么情境下"发生
struct Context {
Mode mode = Mode::Browse; // i 浏览 / a 使用 / d 丢弃 / w 手持 / W 穿戴 / T 脱下 / E 吃
bool has_stuff_target = false, cargo_stuff = false;
int total_weight_g = 0, carry_capacity_kg = 0;
std::string carry_label;
Palette palette;
Disassembly disassembly; // 反装配进度(含暂停、剩余动作数)
};
// 查询回调:仍在 Game 里的状态读取函数,通过 std::function 注入
struct Queries {
std::function<std::string(const content::ItemDef*)> kind_label, kind_note;
std::function<std::vector<std::string>(const std::string&, const engine::Item*)> detail;
std::function<int()> warmth, total_weight, overload, capacity, speed;
// ...
};
// 投影结果:屏幕上真正呈现出来的全部数据
struct Projection {
std::vector<Row> rows; // 符号 / 名称 / 数值 / 状态 / 副文本 / 颜色 / 缩进 / 分组
std::vector<std::vector<std::string>> details;
std::vector<std::string> actions;
std::vector<int> worn_sources;
std::string stats, hint, title;
};
关键在 Queries:状态一个字节都不搬。页面模型不知道 Game 长什么样,它只知道”我需要一个能算总负重的东西”。读状态这件事仍然留在主文件里,只是以回调的形式穿过来的。这跟上一篇讲代谢池时的约束是同一条:重构的目标是把代码搬走,不是把状态搬家。
二、三次抽取的规模账
| 提交 | 页面 | game.cpp | 新增模型 | 门禁 |
|---|---|---|---|---|
d213ff1 | 背包(含容器、反装配、存/取上下文) | −213 行 | 216 + 61 行 | verify_d1_inventory_page_model.py 93 行 |
004a7de | 装备(含穿戴来源映射) | −149 行 | 159 + 29 行 | verify_d1_equipment_page_model.py 66 行 |
c5f2600 | 状态(最大一页:属性/技能/植入/突变/营养/伤口/八世界) | −313 行 | 327 + 31 行 | verify_d1_status_page_model.py 78 行 |
主文件里的用法变得很短:
namespace model = inventory_page_model;
const auto projection = model::build(inv, items, attachments, enchants, context, queries);
编译单元也变了:集成后是 57 个对象(54 个基础 + 3 个 page model)。每抽一页,CMakeLists.txt 加一行——忘了加就是链不上,忘了写进对象清单就是门禁数不对,后者更烦,因为它是静默的。
三、真正的收益不是行数,是”投影可以被读出来”
这是这次重构里我最想讲的一点。
UI 的东西最难测:想知道”状态页那条热量行是不是对的”,传统手段只有截图 + 人眼看,或者把渲染层也一起测。抽成投影之后,多了一个开关:投影可以 dump 成文本行。
std::printf("[inv-model] mode=%d title=%s rows=%llu entries=%llu\n", ...);
std::printf("[inv-model] row=%llu source=%d worn=%d depth=%d group=%s disabled=%d sub=%s colors=%d,%d,%d,%d/...\n", ...);
std::printf("[status-model] title=%s rows=%llu details=%llu actions=%llu\n", ...);
门禁从此可以逐行断言一个页面:有多少行、每行的符号/名称/数值/状态、三种颜色分别是多少、缩进层级、哪一行属于哪个容器块、动作字符串是不是 activity:disassemble:... 且带正确的剩余移动数。不用截图、不用视觉模型、不用”我觉得渲染出来了”。
更进一步,它让旧版对照变成可执行的:
- 背包:旧二进制 13/14,恰好差的就是那条
missing rich projection;新二进制 441/441(80 次原生运行)。 - 装备:148/148(27 次原生运行)。
- 状态:28 个用例的全量 rich 投影,313/313。
这几组数字的含义不是”测试变多了”,而是**“这一页在屏幕上会显示什么”第一次变成了可判定的命题**。
四、坑:整文件移植被继承门禁直接否决
第一版移植(v1)的做法很省事:把那三个模型文件、game.cpp、game.hpp 整文件换到已发布的树上。
结果被独立继承门禁否了:已发布的父版本上 verify_a4_attribute_points.py 是 157/157 全绿,换上去之后成片失败(两轮全量都红在第 21 步)。排查时把候选的存档槽恢复到本轮之前的状态,还是红——排除存档槽状态问题,是代码本身的差异。
v2 改成外科式打法:从已发布的树出发,只打”投影抽取”这三个补丁:
game.cpp 3 hunk +60 / −213
game.hpp 3 hunk +3 / −18
CMakeLists 1 hunk +1
git apply 零 reject,模型两文件和门禁按原字节加入。然后全绿。
教训很直白:“整文件替换”看起来是同一个东西,但它顺手带走了你不想带的东西。 历史私有谱系里的 game.cpp 里躺着别的工作的中间态,换文件等于把它们一起放进来。要搬的是补丁,不是文件。
五、坑:门禁口径跟着架构变,红的是期望值不是素材
每次抽取,都有几条静态契约门禁跟着改(verify_terms.py、verify_item_primary_type.py、verify_container_state.py)。它们直接扫源码字面量,代码一搬家,它们就找不到原来那行了。
同类的还有一条更微妙的:抽取过程中必须确认已发布的语义在新模型里都还在。这三处是硬要求:
- C45 的容器块排序(容器头必须带着自己的子项)
- C46 的
context.total_weight_g(权威负重总数) - C47 的
queries.mass(单件实例质量助手)
漏掉任何一处,页面还能画出来,但数字悄悄错了——这类错误门禁不一定抓得住,所以是人工逐条核对的既定动作。
另外,门禁自己也得证明它是只读的。所有 dump 型门禁最后都断言同一件事:跑完之后,exe 的 SHA 没变、源码没变、正式存档目录一个字节没动。造出来的存档全落在私有临时目录里。原因是同一个:门禁是最容易改坏环境的脚本——它要起进程、要造场景、要存读档。
六、这一轮留下的东西
不是”主文件少了 675 行”。是**“屏幕上会显示什么”从审美判断变成了数据**:
- 一页 = 一个
Projection,可以从任何状态构造出来,不需要窗口、不需要渲染器; - 状态的读取以回调注入,页面模型的依赖是显式的、可枚举的;
- 每个页面的行为有一份带旧版对照的差分证据;
- 抽取用补丁而不是文件替换,因为文件里总有别人的东西。
下一步是把剩下的页面也走同一个模式。三页之后,套路已经固定了:先定 Context 和 Queries 的边界,再搬构造逻辑,最后补 dump 与差分门禁。