用 native 项目读懂《A Philosophy of Software Design》:从 Markdown 阅读笔记看深度模块与 TEA 架构实践

原创2026-09-26 17:50:24112 阅读
文章标签:桌面应用跨平台

用 native 项目读懂《A Philosophy of Software Design》:从 Markdown 阅读笔记看深度模块与 TEA 架构实践

本文以 open source 仓库 gh_mirrors/ze/native 中 markdown-viewer 示例项目自带的一篇读书笔记 notes.md 为主线,把 Ousterhout《A Philosophy of Software Design》里"复杂度是增量积累的""模块应该深""通用模块更深"等核心论点,落地到该示例应用的 Model/Msg/update 架构、effects 通道、<markdown> 渲染器与容量上限设计中,帮助读者同时理解软件设计思想与一套可运行、可测试的 native 桌面应用工程实践。

笔记说了什么:复杂度在评审中失守

notes.md 开篇就点出全书的核心论断:复杂度是增量积累的(complexity is incremental),与复杂度的斗争胜负取决于代码评审而非架构评审。这个判断之所以成立,是因为复杂度很少来自某一次大决策,而是来自一次次"顺手"的小改动——每处都看似局部、看似合理,累积起来却让整个系统难以修改。

仓库中的 markdown-viewer 示例(examples/markdown-viewer/)是一个很好的对照样本:它刻意用大量**固定容量(Fixed capacities)**把"增量复杂度"挡在门外——main.zig 中明确声明文档上限 max_document_bytes = 16 * 1024、路径上限 max_path_bytes = 512、最近文件列表上限 max_recent = 6、<details> 展开标志上限 max_details = 16,以及预览图片上限 max_preview_images。这些常量不是随意的,而是由底层运行时预算推导出来的(详见下文"容量预算"一节)。这正体现了笔记里"在评审中守住防线"的工程化做法:把边界写进代码,让超限行为可预测、可测试,而不是靠人肉评审时"记得别加东西"。

深度模块:Unix write() 与 textarea 的一体两面

笔记第二章"模块应当深"给出了经典的抽象定义:模块 = 接口(成本)/ 实现(收益)。深度模块拥有"小接口 + 背后大量功能",浅模块则相反——只增加表面积,却让代码库"看起来井井有条"。

笔记中的两个例证在仓库里都能找到直接对应:

  • write() 一个调用隐藏缓冲、调度与设备:markdown-viewer 中,fx.writeFile 一个 effect 调用就完成了"把编辑器全文写回磁盘 + 更新路径状态 + 加入最近列表 + 持久化最近列表"等一系列工作(main.zig 中 savePath 与 .file_done 的 .write 分支)。
  • 反例:每字段一个类 + getter/setter:对应到本应用,就是如果把 current_path、pending_path、recent_storage 等字段各自暴露为可独立读写的方法,状态流转会立刻散落。而这里的状态封装是**Elm 风格(TEA)**的:所有状态集中在 Model,所有变更必须经过 Msg 到达 update。

update 是唯一会修改状态的地方(main.zig 中 pub fn update),视图 viewer.native 只做绑定与派发、从不直接改状态。这正是笔记"接口远简单于实现"在架构层面的体现:对视图层暴露的"接口"只是一组 Msg 标签(edit、open_doc、save_doc、toggle_details……),而背后是文件 I/O、最近列表、图片加载、浏览器打开等一整套逻辑。

通用模块更深:从 backspace() 到 insert(text)

笔记第六章的观点略带反直觉:把模块做得通用,通常反而让它更小。评判标准是那个经典问题——"这个 API 会不会因为 UI 变化而改变?"如果会,说明 API 提到了调用方的领域,就是浅的。

笔记里给出的例子是编辑器 API 设计:

insert(text) 和 delete(range) 优于 backspace()、deleteSelection()、pasteFromClipboard()。

这条设计原则在仓库里被完整践行。Model 中的编辑器是一个 canvas.TextBuffer(max_document_bytes)(main.zig),文本变更统一走 model.editor.apply(edit)——edit 是 canvas.TextInputEvent,包含 insert_text、move_caret、.clear 等通用原语。update 只认 edit 一个消息,由文本输入事件驱动,而不是为"退格""粘贴""删除选区"各造一个 Msg。测试 tests.zig 中的 "editing the textarea updates the preview and derived counts through dispatch" 也直接通过 msgForTextEdit(editor.id, .{ .insert_text = "curious words" }) 这一通用插入原语驱动真实派发路径,验证预览与派生计数同步更新。

同理,工具栏的 Open / Save / Save As 三个按钮在 viewer.native 中最终都落到两个通用函数 openPath 与 savePath,而非三个重复的专用流程;打开与"另存为"在 update 里共享同一个 .file_done 分支,只是 Save 写 currentPath、Save As 写路径字段内容。UI 将来无论把按钮改成快捷键还是菜单,这些 Msg 与 effect 通道都不需要变——接口没有提到"按钮"这个调用方领域。

周四要讨论的问题,仓库里的答案

笔记末尾列了三个待讨论问题,恰好都可以在仓库里找到对应实现作为讨论素材。

问题 1:深与臃肿的界线在哪里?

仓库的答案藏在容量常量里。main.zig 顶部的一串 pub const 定义了每个模块的边界:

常量 值 语义
max_document_bytes 16 KiB 文档上限:预览同时保留编辑器与渲染两份文本
max_path_bytes 512 B 路径字段与当前路径上限
max_recent 6 侧栏最近文件条数
max_details 16 <details> 展开标志数组长度
max_preview_images min(16, 16-4) = 12 预览远程图片上限
max_image_source_bytes 2 KiB 图片 URL 上限,与 fx.loadImage 通道一致
max_note_bytes 192 B 状态栏提示文字上限

这些上限绝大多数能追溯到底层运行时预算(见下节)。也就是说,"深模块"在工程上意味着:接口小、边界明确、超限行为被显式定义(如 editor.truncated 标志、.truncated 结果),而不是无限膨胀的"万能接口"。

问题 2:effects 通道是深模块吗?一个调用、五个结果枚举?

笔记提到"一个调用,五个结果枚举"。对应到仓库:文件 effect 的结果类型 EffectFileResult 有 ok、truncated、not_found 等多种 outcome,而 update 中的 .file_done 分支对每个 outcome 都有明确处理——读取成功则复制字节、重置详情/滚动、采纳路径、加入最近列表;truncated 则载入截断副本并提示"超过 16 KiB 文档上限";失败则仅更新状态栏提示,不触碰当前文档(tests.zig 的 "open and save round-trip through the fake executor" 验证了失败后文档保持原样)。从笔记的评判标准看:API 没有提到 UI 领域、调用方只需理解结果枚举,是深度模块的合格候选。

问题 3:第 9 章"better together vs. better apart"如何应用于 sync RFC?

侧栏的第三份样本文档 spec.md 正是一份"会话同步协议 RFC",它本身就是一次"合/分"权衡练习:增量同步复用"现有快照 diff——不引入新线上格式",同时把负载限制在 256 KiB 以保证不阻塞 UI 线程。这份文档同时是 <markdown> 渲染器各项能力的展示(任务清单、表格、<details> 折叠块等),详见下文。

渲染链路:笔记如何在 <markdown> 元素里变成原生控件

notes.md 作为侧栏四份内置样本之一(Model.samples 中 id=4、标题 "Reading notes",main.zig),通过 @embedFile("samples/notes.md") 编译进二进制。它的渲染由 viewer.native 中唯一一个 <markdown> 元素完成:

<scroll grow="1" value="{doc_scroll}" on-scroll="doc_scrolled" label="Preview">
  <column padding="24">
    <markdown source="{document}" on-link="open_url" on-details="toggle_details" details-expanded="{details_expanded}" images="{markdownImages}" />
  </column>
</scroll>

左栏 textarea 的每一次输入都通过 on-input="edit" 镜像进 Model.editor,右栏 <markdown> 绑定的是同一份字节,因此预览"逐键跟踪、无防抖、无缓存、无漂移"(README 原话)。底层渲染器在 markdown.zig:没有 DOM、没有 CSS、没有脚本执行,标题按 heading_scales = .{2.0, 1.5, 1.25} 相对正文 token 缩放;内联样式(粗体、斜体、代码、删除线、链接、GFM 自动链接且裁剪尾部标点)与 GitHub 风格的安全展示型 HTML 子集(<details>/<summary>、<a>、表格 align 等)被降级为普通原生控件。

笔记中使用的元素——## 标题、列表、> 引用块、<strike>、链接——全部在渲染器的支持清单内,且这份笔记还示范了渲染器文档注释中"Malformed input degrades to literal text"的行为:即使未来有人向笔记里加入渲染器不支持的语法(如脚注、setext 标题),也会退化为纯文本,而不会构建失败。

<details> 折叠块:状态归模型,不归渲染器

notes.md 没有用到折叠块,但同目录的 spec.md 与 tour.md 大量使用,而底层协议(markdown.zig 文档注释)明确:<details> 块的展开状态由调用方的模型持有——渲染器通过 details_expanded(按文档内块序索引的布尔数组)与 on_details(携带块序的 Msg 构造器)协作,应用在 update 中切换标志。

main.zig 中 Model.details_expanded: [max_details]bool 由视图直接绑定,update 的 .toggle_details => |index| 分支负责翻转;渲染器在 markdown.zig 中按 max_markdown_details_per_document = 16 截断超出部分,与应用的 max_details = 16 精确对齐。测试 tests.zig 的 "preview links open through fx.spawn and details expand through the model" 通过自动化 widget-click 驱动真实点击,验证折叠/展开、两个块互不干扰、以及切换样本后标志重置。

远程图片:应用 effect 与模型自有映射

tour.md 里有一行 ![Images render as their alt text](https://example.com/diagram.png)。Markdown 渲染器不隐式加载任何远程图片——图片发现(canvas.markdown.collectImageSources)与应用模型中的 PreviewImage 记录配合:应用在 refreshPreviewImages 中收集源、校验 http(s):// 前缀与 2 KiB 长度上限、通过 fx.loadImage 发起有界加载,成功后把 source -> ImageId + 尺寸 的映射放回模型,视图经 images="{markdownImages}" 拿到解析结果。未解析的图片保持 alt 文本回退。tests.zig 的 "remote images flow from markdown discovery through the resolved preview mapping" 完整走通了"发现 → 发起 effect → 成功映射 → 图片控件出现 → 编辑后回收"链路。

真实文件 I/O:无对话框的诚实路径字段

笔记没有讲文件对话框,但承载它的 markdown-viewer 应用有一个重要设计事实:Native SDK 没有文件对话框服务。因此应用的 Open / Save / Save As 使用"诚实的模式"——工具栏里一个可编辑的路径字段就是文件选择器(main.zig 文档注释原话)。每次操作的结果是一个带显式 outcome 的类型化 Msg;失败落在状态栏,绝不弹对话框。

  • Open:读取路径字段内容(fx.readFile),成功后复制字节进编辑器、清空样本归属、重置折叠/滚动、采纳路径、pushRecent 加入最近列表并持久化;
  • Save:把编辑器写回当前文档(fx.writeFile,key 为 save_key);
  • Save As:写入路径字段内容并采纳为新当前路径。

最近列表同样通过文件 effect 持久化:main 里用 app_dirs.resolveOne(.{ .name = "markdown-viewer" }, …, .data, …) 解析到每应用数据目录(macOS 即 ~/Library/Application Support/markdown-viewer),拼出 recent.txt 路径;boot(TEA 的 init)在首帧前用 fx.readFile 恢复列表,persistRecent 在每次打开/保存后用 fx.writeFile 写回。解析失败只是禁用持久化,绝不构成启动错误。

容量预算:16 KiB 文档上限为什么是 16 KiB

main.zig 注释解释了这些数字的来源:视图同时保留编辑器的文本副本与预览渲染后的文本,而运行时的每视图 widget 文本预算为 64 KiB(max_canvas_widget_inline_text_bytes_per_view,canvas_limits.zig),因此 16 KiB 文档保留 2 倍余量给界面文字与 span 负载。同理:

  • 路径 512 B 对应 effect 通道自身的 1 KiB 上限(max_effect_file_path_bytes,effects.zig);
  • 预览图片上限 min(max_registered_canvas_images, max_effects - 4) 保证在图片密集的文档中,仍为 open/save/recent/link 四个共享 effect 槽保留位置——max_effects = 16(effects.zig)、max_registered_canvas_images = 16(canvas_limits.zig);
  • 图片 URL 上限 2 KiB 与 fx.loadImage 的 URL 通道一致,超长源直接停留在 alt 文本回退,不发起注定失败的 effect;
  • 渲染器侧还有一批独立的确定截断上限:max_markdown_blocks_per_container = 64、max_markdown_list_items_per_list = 64、max_markdown_list_depth = 4、max_markdown_table_columns = 8、max_markdown_table_rows = 64、max_markdown_paragraph_bytes = 8192(markdown.zig)。

超限从不静默:打开超大文件时结果带 .truncated 标志,编辑器 truncated 时状态栏提示"Document is full (16 KiB cap)"。这套"预算即契约"的设计,正是笔记主题"把复杂度挡在评审之外"的可执行版本。

如何运行与验证

markdown-viewer 是仓库中"零配置"示例之一:一个 app.zon 清单 + src/ 目录,无构建文件,可直接从该目录运行(README.md):

cd examples/markdown-viewer
native dev        # 开发运行,viewer.native 支持热重载
native test       # 驱动真实派发路径的测试

根目录等价测试命令为 zig build test-example-markdown-viewer。示例仅声明 macOS 平台(app.zon 中 .platforms = .{"macos"},GPU 后端为 Metal),在 macOS 上以 native dev 打开后:左栏编辑 notes.md 的任意字符,右栏预览逐键跟随;点击笔记中的链接会通过 fx.spawn 调用系统浏览器命令(macOS 为 open,Windows 为 cmd /c start,其余为 xdg-open,见 openInBrowser);切换系统外观,窗口会经 on_appearance 实时重新推导 stone/indigo 明暗 token(main.zig 的 viewerTokens),窗口内不提供主题开关。

测试覆盖(tests.zig)包括:open/save/save-as 往返与最近列表持久化(fake effect 执行器)、链接点击 spawn 浏览器命令、details 折叠的自动化点击、编辑器编辑驱动预览与派生计数(word/line/byte 在状态栏实时计算、从不存储)、系统外观双向实时改 token、受控预览滚动往返、编译视图与热重载解释器构建出完全相同的控件树,以及布局/无障碍审计扫描。

Follow-ups 的仓库落地版

笔记最后列出三项后续动作,其中两项可以直接在本仓库里找到"以代码作答"的位置:

  • 重读第 5 章 information leakage:对应泄漏与否的判据就是 Model/Msg/update 边界是否被遵守——viewer.native 只绑定与派发、main.zig 独占状态变更、markdown.zig 不持有任何应用状态(连 <details> 展开标志都由调用方模型提供),三层之间没有信息渗透通道。
  • 对照 Parnas《On the Criteria》:Parnas 的信息隐藏原则在仓库中体现为 Model 的"私有存储 + 视图绑定函数"模式——current_path_storage/pending_path_storage/recent_storage 等原始缓冲区不直接暴露,视图通过 document()、path()、recentDocs()、statusLine() 等派生函数读取,未来改存储布局不影响视图层。
  • 起草 depth review 清单:可直接从仓库提炼条目——"接口是否提到调用方领域(UI/按钮/文件对话框)?""是否有不受控的容量增长?""超限行为是否被显式定义?""状态是否只有一条变更路径?"——以 notes.md 的判断题形式应用到下一次评审。

小结

一篇只有三十余行的阅读笔记,之所以值得展开成文,是因为它恰好描述了这个仓库反复实践的原则:深度模块(<markdown> 一个元素隐藏整个渲染器)、通用原语(insert/delete 而非专用命令)、状态单一变更路径(TEA 的 Model/Msg/update)、以及用显式容量预算把增量复杂度挡在代码评审之外。读这份笔记,再看 main.zig、viewer.native 与 tests.zig,软件设计的抽象原则就变成了可以运行、可以测试、可以逐行引用的工程事实。

登录后查看全文
native