和 AI 结对写一款原生 Mac 阅读器:先把边界划清再谈功能
少数派上有篇开发者创作谈,讲的是一件事:一个人和 AI 结对,写了一款 macOS 原生的本地阅读器。它值得单独拿出来讨论的不是那款产品做了什么、卖得怎样,而是这条路本身——个人工具加上生成式编码,产能忽然不再是瓶颈,瓶颈换成了”边界”。功能列表越写越长、每一句都对、没有一句能验收,是这类项目最常见的死法。

把这类项目拆开看,真正要回答的是两个问题:边界怎么划,以及和 AI 结对时控制权怎么留在人手里。两个问题的答案都不在提示词技巧里,在工程约束里。
一、先写”不做清单”,再列功能
划边界最直接的手法,是把每句功能翻译回一句约束。”支持我本地已有的书”翻译出来是”不联网、不上传、不改动源文件”;”打开要快”翻译出来是”任意书库规模下冷启动有一个可测阈值,超了就算失败”;”能记笔记”翻译出来是”标注必须能落回原文,改字号换窗口宽度之后仍然对得上”。翻译做完,功能列表会自己变短。
一份可用的不做清单,条目要短且能判定:
- 不做云端同步:跨设备不是这个工具的目标,一旦做,就要引入账号、冲突合并和数据形态问题。
- 不做书城与推荐:内容获取不是它解决的问题。
- 不做格式互转:只读,不写回源文件,转换是另一个项目。
- 只支持自己实际持有的那两类容器格式,其它格式明确拒绝并说明原因。
- 不引入任何需要密钥或远端模型的依赖,包括”只是加个摘要功能”的诱惑。
每条后面写一句为什么。为什么那一栏,就是下一次 AI 提议”顺手加个小功能”时的拒绝依据。
二、模块切分与每块的验收产物
边界要落到模块,模块要落到产物。下面这张表可以直接当项目结构用,右列是关键——没有验收产物的模块,等于没有边界。
| 模块 | 职责 | 一句话验收产物 |
|---|---|---|
| 书库索引 | 文件在哪、什么格式、上次读到哪 | 索引可整份删除并重建,重建后源文件无损 |
| 文件访问授权 | 用户选过的目录与文件,下次启动还能读 | 重启、外置盘拔插、文件被移动三种情况各有明确表现 |
| 解析 | 容器转成结构化章节与文本 | 同一本书两次解析出的章节树逐字节一致 |
| 排版渲染 | 在给定宽度与字号下分行分页 | 改窗口宽度或字号后不丢段落,翻页位置可复现 |
| 标注存储 | 高亮、笔记、书签 | 锚点在重排后能落回原文,可导出纯文本人工核对 |
| 检索 | 单书内与跨书查找 | 结果带命中位置,点进去能跳到那一处 |
再补一条依赖方向规则:只有授权层能产出”可持久读取句柄”,解析层只能接收它,索引层不许直接碰原始路径。这条规则看着严,实际是后面所有权限问题的分水岭。
三、沙盒与文件访问授权:书签化、存放与失效
macOS 上这类工具最容易低估的就是这一块。启用应用沙盒之后,程序默认只能待在自己的容器目录里,用户通过系统打开面板选中的文件,授权通常只针对这一次运行;想让”上次选的那批文件”下次启动还能读,就必须把授权本身持久化——通用做法是保存一份安全作用域书签(security-scoped bookmark),也就是把授权信息编码成一段可再解析的定位数据,下次启动先解析它再恢复访问,而不是保存一个路径字符串然后指望权限跟着路径走。
这里有三个细节容易漏:
- 存放位置。书签数据属于应用状态,要放在自己的容器或应用组目录里;放到临时目录,系统清理时就没了。
- 成对释放。恢复访问是”开始使用 / 结束使用”配对的动作,只开不关等于泄漏授权句柄,长时间运行会体现在异常行为上。
- 失效是常态。书签可能变旧:源卷被卸载或重新格式化、文件被移出授权范围、签名或权限声明变化。失效后的正确表现是提示用户重选,并只更新受影响的那批记录;最糟的实现是整库”书都不见了”,其实源文件一本没少,只是授权没了。
顺带一个常被忽略的点:文件引用地址不等于持久标识,路径会随挂载点变化。外置盘重新插一次,”同一本书”可能被识别成两本。识别重复要靠内容特征(比如对正文或元数据算哈希),不能靠路径。
四、书库索引、正文读取与全文检索的取舍
为什么不”每次直接扫描目录”?因为扫描只能回答”这里有什么文件”,回答不了”这本书上次读到第几页””这两本是同一个文件的副本””哪本的元数据是用户自己改过的”——这些事实不属于文件系统。成本上也划不来:每次启动遍历卷、逐个打开容器读元数据,时间随藏书量线性增长,外置盘和网络卷上更明显。
主流折中是:本地嵌入式数据库(SQLite 一类)做索引,全文检索用它的全文索引虚表,目录扫描退成”对账”角色。由此带来的取舍要一条条写清:
- 增量与漂移。文件系统事件监听能覆盖大部分变化,但丢事件是可能的,所以必须留一次定期全量对账;只做增量的索引,早晚和磁盘不一致。
- 中文检索。通用分词按空白与标点切词,对中文几乎不成立;要么按语言处理器切词,要么用 n-gram 或前缀索引,本质是拿索引体积换召回,需要明确选一个并留下重建路径。
- 索引必须是可丢弃的缓存。删掉能重建,绝不能让索引变成某些信息的唯一存放处——阅读进度这类用户数据要单独存,且能导出。
- 元数据会漂。容器里声明的书名和作者经常不规范,展示名要允许用户覆盖,覆盖表独立于解析结果保存。
- 隐私。全文索引等于把藏书内容写进本地库,要能说清存在哪、占多少空间、怎么彻底删除。
正文读取同理不能”整本载入”:只解析当前章节,相邻章节按需预取,图片按可见区域解码;对容器条目声明的尺寸要留一手,防止”声明很小、解压很大”的样本把内存吃光。标注锚点要存到逻辑结构上(章节序号加规范化后的字符范围),不要存屏幕坐标——坐标在改字号、改窗口宽度之后必然漂,而这是阅读器里最常见的”笔记错位”来源。
五、和 AI 结对怎么把控制权留在人手里
模型写代码的成本已经很低,贵的地方换成了”哪些东西不该被改”。实操下来有效的是四件事:任务卡粒度、验收标准写进卡、每轮要求自报改动与风险、禁止顺手重构。
一张卡只改一个边界内的事,卡面固定五栏:目标、允许改动的模块与文件清单、验收标准(必须是能跑的判定)、禁止事项、完成后要贴出的证据。验收标准写在卡里而不是事后口头确认,是这一轮和下一轮能否对账的关键。
| AI 容易越界的地方 | 为什么会发生 | 怎么拦 |
|---|---|---|
| 顺手改了别的模块的私有结构 | 上下文里出现的代码被它当成可改范围 | 卡上写允许改动清单,越界直接退回,不看内容 |
| 为省事绕过授权层直接拼路径读文件 | 补授权逻辑要额外代码,它选最短路径 | 依赖方向约束:只有授权层能产出持久句柄 |
| 把整本书一次读进内存 | 最短实现即可通过当前测试 | 验收项写”最大样本文件 + 内存上限” |
| 引入网络请求或云服务依赖 | 训练语料里默认做法就是调远端 | 依赖白名单 + 一条断网用例常驻 |
| 改了索引结构却不写迁移 | 旧数据不在它当前视野里 | 卡的验收项固定含”旧数据是否还能读” |
| 复制一份计算逻辑而不复用 | 复制比理解调用链便宜 | 单一权威函数 + 跨调用点的一致性测试 |
还有一条纪律值得单独说:模型倾向于把”读代码时看到的写法”当成”应该改成的目标”。路过一段它不喜欢的实现,它会顺手清理,于是这一轮的 diff 里混进了完全无关的重构。做法是每个任务开一份新上下文,收尾时只看改动文件清单,超出清单的改动一律要求拆回,哪怕那段重构看起来更好。架构文档(模块职责、依赖方向、公开接口、不做清单)作为常驻约束随每轮提供,比在对话里反复提醒可靠。
六、交付前自查与洹拓视角
发布前的自查按”能不能被证伪”来写,而不是按功能点:
- 冷启动:删掉全部本地状态,首次启动能在没有索引、没有网络的情况下走完引导。
- 异常文件不崩:故意喂进损坏容器、超大单章节、缺字体的排版样本,要求报错可读且不伤数据。
- 无网络可用:断网跑一遍主流程,任何隐藏的网络等待都会在这里现形。
- 授权失效:手动让书签失效一次,验证提示重选的范围是否只覆盖受影响记录。
- 外观与字号:深浅色、系统字号、窗口宽窄变化后,正文与标注位置是否仍对得上。
- 卸载不留垃圾:容器内的索引、书签数据、标注文件能一次清干净,源文件一本不动。
边界先行这套方法,洹拓科技在客户系统里同样在用:能验收的边界,比能演示的功能值钱。个人工具和项目交付在这里是同一件事——把不确定性关进模块,把每条不确定性换成一个能跑的判断。和 AI 结对没有改变这一点,它只是把”实现”的成本打到接近于零,于是”该做什么、不该做什么、做完怎么验”这三件事的分量被放大了。