<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>SHIINAYANE (zh-CN)</title><description>YK&apos;s Blog</description><link>https://www.shiinayane.com/</link><language>zh-CN</language><item><title>我把 AI 工作流写成了代码，然后亲手拆掉了它</title><link>https://www.shiinayane.com/zh/posts/i-turned-my-ai-workflow-into-code-then-i-tore-it-apart/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/i-turned-my-ai-workflow-into-code-then-i-tore-it-apart/</guid><description>《我把自己的 AI 工作流变成了代码》发布后的第二天，BiliKit M5.0 就给了我一个反例。</description><pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;7 月 25 日，我发了一篇文章，叫&lt;a href=&quot;/zh/posts/i-turned-my-ai-workflow-into-code/&quot;&gt;《我把自己的 AI 工作流变成了代码》&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;一天后，我把它的核心前提推翻了。&lt;/p&gt;
&lt;p&gt;这事发生得很快，甚至有点好笑。那篇文章刚写完的时候，我还觉得 BiliKit 迫使我形成的工程工作流总算稳定下来了：风险分级、质量门、独立审查、复杂度预算，再把这些经验整理成可以复用的 Codex Skills。&lt;/p&gt;
&lt;p&gt;结果第二天，BiliKit 自己就给了我一个反例。&lt;/p&gt;
&lt;h2&gt;一个被越做越完整的滚动位置&lt;/h2&gt;
&lt;p&gt;当时正在做 M5.0，需求本身并不复杂：&lt;/p&gt;
&lt;p&gt;从浏览页进入播放页，再返回时，尽量回到进去前看到的位置。&lt;/p&gt;
&lt;p&gt;BiliKit 当时使用一套自定义路由。进入播放页时，原来的浏览 View 会离开视图树；返回后重新创建，所以浏览状态和滚动位置都需要手动恢复。&lt;/p&gt;
&lt;p&gt;最开始只保存选中的视频 ID。后来发现这只能把对应卡片滚回屏幕，不能还原原来的 viewport。&lt;/p&gt;
&lt;p&gt;于是又开始记录原始 offset。&lt;/p&gt;
&lt;p&gt;接下来出现的问题都很合理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;内容高度变化后，旧 offset 可能已经不可达；&lt;/li&gt;
&lt;li&gt;旧 &lt;code&gt;ScrollView&lt;/code&gt; 的回调可能污染新的 snapshot；&lt;/li&gt;
&lt;li&gt;异步请求返回时需要检查 request identity 和 generation；&lt;/li&gt;
&lt;li&gt;用户主动滚动后，要解除程序恢复状态；&lt;/li&gt;
&lt;li&gt;还要补确定性测试和 XCUI，确认返回后确实是进去时的样子。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;每解决一个问题，这套实现就完整一点。&lt;/p&gt;
&lt;p&gt;而且它真的能通过测试。&lt;/p&gt;
&lt;p&gt;回头看，最麻烦的地方也正在这里。测试、审查和质量门都在证明这套恢复机制有没有写对，没有人继续问：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么浏览页一定要被销毁？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;后来我停下来做了一个很小的 SwiftUI probe，把条件替换、自定义恢复和原生 &lt;code&gt;NavigationStack&lt;/code&gt; 放在一起比较。&lt;/p&gt;
&lt;p&gt;结果很直接。&lt;/p&gt;
&lt;p&gt;使用 &lt;code&gt;NavigationStack&lt;/code&gt; 时，根 View 没有被重新创建，原来的 state identity 也还在。之前花了大量时间解决的恢复问题，有相当一部分来自我们自己选择的导航结构。&lt;/p&gt;
&lt;p&gt;于是 BiliKit 改成了原生 &lt;code&gt;TabView(.sidebarAdaptable)&lt;/code&gt;，每个 Tab 使用自己的 &lt;code&gt;NavigationStack(path:)&lt;/code&gt;。自定义的 &lt;code&gt;AppRoute&lt;/code&gt; 和 &lt;code&gt;AppReturnSnapshot&lt;/code&gt; 被删掉，浏览工作集继续保留，请求取消和播放器生命周期也继续保留。滚动位置改由 SwiftUI 的语义 ID 管理。&lt;/p&gt;
&lt;p&gt;真实滚动验证中，两次热门页往返分别是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0.195066 → 0.194867
0.389877 → 0.389447
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;已经没有必要让 ViewModel 保存一套原始数值 offset，再负责 clamp、恢复 latch 和旧回调隔离。&lt;/p&gt;
&lt;h2&gt;所有流程都正常工作了&lt;/h2&gt;
&lt;p&gt;这次没有哪个 Agent 明显偷懒。&lt;/p&gt;
&lt;p&gt;项目规范被读取了，风险被识别了，测试层级也选得没什么问题。实现过程中确实抓到了不少真实 bug，像旧回调污染、不可达 offset、生命周期清理和 XCUI 驱动问题。&lt;/p&gt;
&lt;p&gt;麻烦在于，所有流程都接受了同一个前提：继续使用现有导航结构，然后把状态恢复做好。&lt;/p&gt;
&lt;p&gt;一旦这个前提进入任务契约，后面的 Agent 就很擅长沿着它工作。&lt;/p&gt;
&lt;p&gt;Reviewer 会检查边界，red reviewer 会找失败路径，测试会继续增加，文档会越来越完整。每一层都能提高当前方案的正确性，同时也让它看上去越来越像唯一应该继续做下去的方案。&lt;/p&gt;
&lt;p&gt;我之前把 &lt;code&gt;project-governance-bootstrap&lt;/code&gt; 形容成一个编译器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;仓库事实
+ 架构文档
+ 测试
+ 安全边界
→ 项目规则
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在觉得这个说法有个很大的问题。&lt;/p&gt;
&lt;p&gt;编译器只能处理已经输入的东西。&lt;/p&gt;
&lt;p&gt;如果仓库里的架构、任务契约和现有实现共同漏掉了一个更简单的方向，它只会把原来的方向编译得更严格。输入中的路径依赖不会消失，反而会被整理成更正式的文档、更多检查项和更强的执行惯性。&lt;/p&gt;
&lt;h2&gt;连纠错都差点被我做成另一套流程&lt;/h2&gt;
&lt;p&gt;发现问题后，我的第一反应仍然是把这次经验总结成规则。&lt;/p&gt;
&lt;p&gt;比如要求每个高风险任务先列替代方案，强制安排 challenger，建立 decision ledger，再增加一个专门检查问题定义的 reviewer。&lt;/p&gt;
&lt;p&gt;写着写着又不对劲了。&lt;/p&gt;
&lt;p&gt;为了防止流程堆得太多，我正在继续增加流程。为了避免 Agent 机械执行任务契约，我又准备写一份更长的任务契约。&lt;/p&gt;
&lt;p&gt;这和刚刚推翻的东西其实没差多少。&lt;/p&gt;
&lt;p&gt;所以这次我没有做 workflow v2。&lt;/p&gt;
&lt;p&gt;BiliKit 的协作规则直接精简了。一个提交删除了 766 行配置和文档，5 个固定 Agent 定义也一起删掉。现在的 &lt;code&gt;AGENTS.md&lt;/code&gt; 只保留项目事实、架构边界、安全约束、实际可用的验证命令和提交规范。&lt;/p&gt;
&lt;p&gt;质量门还在。&lt;/p&gt;
&lt;p&gt;SwiftPM、&lt;code&gt;xcodebuild&lt;/code&gt;、生命周期测试、安全边界检查也都还在。它们确实能回答构建有没有坏、取消是否正确、资源有没有释放、真实 App 能不能运行。&lt;/p&gt;
&lt;p&gt;固定风险颜色、任务契约格式、reviewer 链和复杂度预算不再是项目的默认仪式。&lt;/p&gt;
&lt;p&gt;需要的时候照样可以用，没必要每次都先证明自己遵守了流程。&lt;/p&gt;
&lt;h2&gt;那两个 Skills 怎么办&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;apple-dev-loop&lt;/code&gt; 目前没有太大问题。&lt;/p&gt;
&lt;p&gt;它负责选择和执行 Apple 平台的验证手段：什么时候跑 SwiftPM，什么时候需要 &lt;code&gt;xcodebuild&lt;/code&gt;，什么时候应该查看 &lt;code&gt;.xcresult&lt;/code&gt;、启动签名 App、跑 XCUI 或 Instruments。&lt;/p&gt;
&lt;p&gt;这些工具能提供什么证据，边界相对明确。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;project-governance-bootstrap&lt;/code&gt; 就不一样了。&lt;/p&gt;
&lt;p&gt;它原本试图根据仓库生成治理方式，里面天然带着一种暗示：只要读取的信息足够完整，就能得到适合这个项目的工作流。&lt;/p&gt;
&lt;p&gt;BiliKit 这次说明，仓库本身也可能在很认真地描述一条走偏的路线。&lt;/p&gt;
&lt;p&gt;这个 Skill 我准备重新看一遍。可能会缩到只整理现有事实和验证入口，也可能直接归档。现在还没决定。&lt;/p&gt;
&lt;h2&gt;暂时没有新答案&lt;/h2&gt;
&lt;p&gt;按目前主要源码和测试目录统计，BiliKit 已经有接近 2.6 万行 Swift。&lt;/p&gt;
&lt;p&gt;项目继续变大，靠一条 prompt 加一次 diff review 肯定还是不够。之前那些规则也不是完全没用，它们确实帮我抓到过不少问题。&lt;/p&gt;
&lt;p&gt;但我不再觉得可以把这些经验整理成一套通用流程，然后让它自动替下一个项目选择正确路线。&lt;/p&gt;
&lt;p&gt;至少这次不行。&lt;/p&gt;
&lt;p&gt;7 月 25 日那篇文章我会留着。它写的是我当时真的相信的东西，删掉或者悄悄改写都没什么意思。&lt;/p&gt;
&lt;p&gt;这篇则记录第二天发生的事。&lt;/p&gt;
&lt;p&gt;后面怎么做，等项目再逼我一次再说。&lt;/p&gt;
</content:encoded></item><item><title>我把自己的 AI 工作流变成了代码</title><link>https://www.shiinayane.com/zh/posts/i-turned-my-ai-workflow-into-code/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/i-turned-my-ai-workflow-into-code/</guid><description>BiliKit V1 还没完成，代码已经超过了 2.1 万行。我把它迫使我形成的工作流拆成了两个可以复用的 Codex Skills。</description><pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;**2026-07-27 更新：**这篇文章发布后的第二天，BiliKit M5.0 暴露了这套工作流最严重的问题：它能把已经选择的路线做得越来越严谨，却可能一直没有检查问题本身是否值得这样解决。我保留了原文，并另外写了一篇后续：&lt;a href=&quot;/zh/posts/i-turned-my-ai-workflow-into-code-then-i-tore-it-apart/&quot;&gt;《我把 AI 工作流写成了代码，然后亲手拆掉了它》&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;BiliKit V1 还没做完，Swift 代码已经超过了 2.1 万行。&lt;/p&gt;
&lt;p&gt;项目小的时候，我基本上就是给 AI 写个长一点的 prompt，再自己检查一遍 diff。就算偶尔写错了，涉及的文件也不多，很容易看出来。&lt;/p&gt;
&lt;p&gt;但代码越来越多后，这种方法开始不太够用。&lt;/p&gt;
&lt;p&gt;同一句“先阅读项目规范再修改”，换个会话可能就会得到完全不同的结果。有时候测试全绿，但实际上改错了模块；有时候只是想修个小问题，最后却又多出一套新的抽象。&lt;/p&gt;
&lt;p&gt;BiliKit 还有不少单靠测试很难说明问题的地方，比如 Keychain、播放器生命周期、并发取消、弹幕渲染和本地服务器。App 能编译、单元测试能通过，不代表这些功能在签名 App 里或者实际使用时就没问题。&lt;/p&gt;
&lt;p&gt;所以我开始往仓库里加各种规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;风险分级&lt;/li&gt;
&lt;li&gt;质量门&lt;/li&gt;
&lt;li&gt;独立审查&lt;/li&gt;
&lt;li&gt;复杂度预算&lt;/li&gt;
&lt;li&gt;针对不同任务的验证方式&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;折腾了一段时间后，这套工作流在 BiliKit 里算是基本稳定了。&lt;/p&gt;
&lt;p&gt;然后我发现，下一个项目还得重新来一遍。&lt;/p&gt;
&lt;p&gt;这些规则有的写在 &lt;code&gt;AGENTS.md&lt;/code&gt; 里，有的在质量门文档和脚本里，还有一些只是我自己逐渐养成的习惯。如果换个项目，要么靠记忆重写，要么直接复制 BiliKit 的文件。&lt;/p&gt;
&lt;p&gt;靠记忆肯定会漏。直接复制也不行。&lt;/p&gt;
&lt;p&gt;静态网站显然不需要 BiliKit 那套播放器、Keychain、本地服务器和媒体重定向规则。反过来说，一个不到一千行但会处理密码的小工具，也不能因为代码少就随便改。&lt;/p&gt;
&lt;p&gt;我想复用的是生成这些规则的方法。&lt;/p&gt;
&lt;p&gt;于是我新建了一个仓库，叫 &lt;code&gt;codex-engineering-skills&lt;/code&gt;，目前有两个 Skill：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;project-governance-bootstrap&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;apple-dev-loop&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;前者负责根据项目本身生成工程规则，后者负责 Apple 平台的构建、测试和验证。&lt;/p&gt;
&lt;h2&gt;不能直接复制 BiliKit 的 AGENTS.md&lt;/h2&gt;
&lt;p&gt;一开始我的想法很简单：把 BiliKit 的 &lt;code&gt;AGENTS.md&lt;/code&gt; 抽象一下，再做成一个通用模板。&lt;/p&gt;
&lt;p&gt;实际写起来发现不太行。&lt;/p&gt;
&lt;p&gt;BiliKit 会把认证、重定向、本地服务器、播放链路、并发、渲染器和破坏性迁移列为高风险区域。这些分类放在 BiliKit 里没什么问题，因为它确实可能在这些地方翻车。&lt;/p&gt;
&lt;p&gt;但如果把同一份列表放进静态网站里，就有点形式主义了。更离谱一点，如果一个命令行解析器也被要求运行签名 App 验证 Keychain，那这套流程基本上已经失去意义。&lt;/p&gt;
&lt;p&gt;因此 &lt;code&gt;project-governance-bootstrap&lt;/code&gt; 的第一步是先读项目：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;仓库里原本的说明&lt;/li&gt;
&lt;li&gt;架构决策&lt;/li&gt;
&lt;li&gt;manifest 和依赖&lt;/li&gt;
&lt;li&gt;测试&lt;/li&gt;
&lt;li&gt;CI&lt;/li&gt;
&lt;li&gt;安全边界&lt;/li&gt;
&lt;li&gt;发布方式&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;之后再判断这个项目大概需要什么程度的治理。&lt;/p&gt;
&lt;p&gt;目前分成三档：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;light&lt;/code&gt;：规模小、修改容易回滚，而且主要只有一种验证方式；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;standard&lt;/code&gt;：包含多个模块、公共边界、持久化、CI 或多层测试；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;critical&lt;/code&gt;：涉及凭据、权限、破坏性迁移、不可信输入、本地服务器、媒体生命周期或者生产基础设施。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;代码量只能作为参考。十万行生成代码不一定危险，但几十行删除数据库的迁移代码肯定不能随便处理。&lt;/p&gt;
&lt;p&gt;最后生成的仍然是每个项目自己的规则。Skill 负责的是中间的判断过程。&lt;/p&gt;
&lt;h2&gt;比起模板，它更像一个编译器&lt;/h2&gt;
&lt;p&gt;我后来觉得，&lt;code&gt;project-governance-bootstrap&lt;/code&gt; 有点像一个小型编译器。&lt;/p&gt;
&lt;p&gt;输入大概是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;代码
+ manifest
+ 架构文档
+ 测试
+ CI
+ 安全边界
+ 发布规则
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出则是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;项目事实的优先级
+ 架构边界
+ 风险区域
+ 验证入口
+ 授权限制
+ 可选的审查角色
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;仓库里当然还是有模板，但它只能作为起点。项目里没有对应内容的部分应该直接删掉，而不是想办法填满。&lt;/p&gt;
&lt;p&gt;通用模板很容易出现这个问题：只要小节已经写在那里，人和 AI 就不太愿意删。最后生成一份什么都有、看起来很完整，但实际上没人会认真执行的文档。&lt;/p&gt;
&lt;p&gt;如果项目已经有现成的测试入口，就没必要为了统一格式再造一个 gate 脚本。项目没有需要特别处理的安全边界，也不用硬写几段放在哪里都正确的安全规范。&lt;/p&gt;
&lt;p&gt;够用就行。&lt;/p&gt;
&lt;h2&gt;为什么拆成两个 Skills&lt;/h2&gt;
&lt;p&gt;BiliKit 原来的工作流里，其实混着两个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;这个仓库要求什么？&lt;/li&gt;
&lt;li&gt;这次 Apple 平台的修改要怎么验证？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;因为 BiliKit 本身就是 Apple 平台项目，所以它们经常一起出现。不过做成通用 Skill 后，我还是把两部分拆开了。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;project-governance-bootstrap&lt;/code&gt; 只负责项目规则。它可以判断某个项目需要签名 App、UI 测试或者性能记录，但不会把所有 Xcode 操作都塞进去。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;apple-dev-loop&lt;/code&gt; 负责实际验证。它知道什么时候该用 SwiftPM、&lt;code&gt;xcodebuild&lt;/code&gt;、&lt;code&gt;.xcresult&lt;/code&gt;、XCUI、签名 App、Computer Use 或者 Instruments，但不会替项目决定架构和风险分类。&lt;/p&gt;
&lt;p&gt;两个 Skill 可以配合使用，但彼此不依赖。&lt;/p&gt;
&lt;p&gt;这样前者也能用于 Rust、TypeScript 或者文档项目；后者也能直接用于一个已经有完善工程规范的 Swift 项目。&lt;/p&gt;
&lt;p&gt;经常一起用，不代表一定要塞在同一个东西里。&lt;/p&gt;
&lt;h2&gt;Skill 什么时候不该启动&lt;/h2&gt;
&lt;p&gt;做 Skill 后，我才意识到触发条件也得认真写。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;apple-dev-loop&lt;/code&gt; 看到 Swift 就启动，那么用户只是问一句语法，AI 可能就开始检查 Xcode、找 scheme、跑构建。流程确实很严格，但显然没什么必要。&lt;/p&gt;
&lt;p&gt;所以除了“什么时候使用”，还要写清楚“什么时候不要使用”。&lt;/p&gt;
&lt;p&gt;只有任务真的需要 Apple 工具链、签名、设备、UI 或性能验证时，才应该启动完整流程。单纯解释一段 Swift，或者修改一个不需要运行环境的小 package，用不到这些东西。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;project-governance-bootstrap&lt;/code&gt; 也一样。不能因为仓库里刚好有个 &lt;code&gt;AGENTS.md&lt;/code&gt;，就决定重新设计整个项目治理。&lt;/p&gt;
&lt;p&gt;以前我会觉得，多给 AI 一点说明总没坏处。实际做下来发现，多出来的内容不只是占上下文，它还会影响工具选择。&lt;/p&gt;
&lt;p&gt;比如一个普通的 package test，如果提前把 Instruments 的完整说明塞进去，AI 反而更有可能觉得性能分析也是一个可选方案。&lt;/p&gt;
&lt;p&gt;手里多一把锤子，确实更容易到处找钉子。&lt;/p&gt;
&lt;h2&gt;不要把所有内容放进一个 SKILL.md&lt;/h2&gt;
&lt;p&gt;最初版本差点变成两个超大的 &lt;code&gt;SKILL.md&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;治理 Skill 里包含全部风险分类、模板、Agent 角色和平台例外；Apple Skill 里则放下所有 Xcode、XCTest、签名、模拟器、UI 和 Instruments 的操作方法。&lt;/p&gt;
&lt;p&gt;真这么做的话，每次使用都得把一堆当前任务根本用不到的内容加载进来。&lt;/p&gt;
&lt;p&gt;现在的做法是只保留核心流程，需要什么再读什么。&lt;/p&gt;
&lt;p&gt;项目不是 Apple 平台，就不读取 Apple 相关规则；不需要独立审查角色，就不读取 Agent routing；只是跑 package test，也不会提前加载 Instruments 的使用方法。&lt;/p&gt;
&lt;p&gt;一般把这个叫 progressive disclosure。我觉得也可以理解成给上下文做依赖管理。&lt;/p&gt;
&lt;p&gt;上下文里的内容会互相竞争。某种工具写得越详细，它看上去就越值得使用，即使当前任务根本不需要。&lt;/p&gt;
&lt;p&gt;所以这里也没必要贪多。&lt;/p&gt;
&lt;h2&gt;Apple 平台要验证到哪一步&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;apple-dev-loop&lt;/code&gt; 里面有一个证据阶梯：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;阅读源码和静态约束&lt;/li&gt;
&lt;li&gt;SwiftPM、单元测试和集成测试&lt;/li&gt;
&lt;li&gt;&lt;code&gt;xcodebuild&lt;/code&gt; 和 &lt;code&gt;.xcresult&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Xcode 内的诊断和操作&lt;/li&gt;
&lt;li&gt;XCUI&lt;/li&gt;
&lt;li&gt;签名 App 和实际 UI 操作&lt;/li&gt;
&lt;li&gt;&lt;code&gt;xctrace&lt;/code&gt; 或 Instruments&lt;/li&gt;
&lt;li&gt;CI、真机矩阵和独立审查&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不是每次都要从第一层跑到最后一层。当前结论在哪一层已经能被证明，就停在哪里。&lt;/p&gt;
&lt;p&gt;比如截图不能代替单元测试，unsigned build 不能证明 Keychain 能正常访问，App 成功启动也不能证明对象生命周期没有问题。&lt;/p&gt;
&lt;p&gt;反过来，如果只是修改一个 Swift Package，也没必要为了显得验证充分，专门启动 Xcode 再录一遍 Instruments。&lt;/p&gt;
&lt;p&gt;不同工具能证明的东西不一样。&lt;/p&gt;
&lt;p&gt;Skill 里还有一些辅助脚本。比如在运行比较贵的任务前，先记录当前仓库、workspace、scheme、Xcode 和 Developer Directory；或者直接读取 &lt;code&gt;.xcresult&lt;/code&gt;，而不是让 AI 对着几千行终端输出判断测试到底过没过。&lt;/p&gt;
&lt;p&gt;我没有打算用 shell 再造一个 Xcode。脚本只负责那些稳定、明确，而且自动化后确实能减少歧义的部分。&lt;/p&gt;
&lt;h2&gt;安装脚本也可能把事情弄坏&lt;/h2&gt;
&lt;p&gt;仓库里有个脚本，可以通过符号链接把 Skill 安装到 Codex 能发现的位置。&lt;/p&gt;
&lt;p&gt;如果目标位置已经是正确链接，就什么都不做；如果那里是另一个链接或者真实目录，脚本会拒绝覆盖。&lt;/p&gt;
&lt;p&gt;本来只是一个很普通的安装脚本，但这里如果偷懒，确实可能把用户原来装好的内容直接替换掉。&lt;/p&gt;
&lt;p&gt;类似的情况还有为了让一次构建通过，直接修改全局 &lt;code&gt;xcode-select&lt;/code&gt;。眼前的问题可能解决了，之后其他项目用哪个 Xcode 就不好说了。&lt;/p&gt;
&lt;p&gt;所以这两个 Skill 会尽量使用当前任务内的配置。遇到含糊状态就停下来，可选能力缺失也不会直接算成任务失败。&lt;/p&gt;
&lt;p&gt;该问的时候还是得问。&lt;/p&gt;
&lt;h2&gt;写成 Skill 后，原来的很多规则都不够具体&lt;/h2&gt;
&lt;p&gt;以前我会写：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;重要修改需要独立审查。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;放在 BiliKit 里，大概知道是什么意思。但做成通用 Skill 后，问题马上就来了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;什么叫重要修改？&lt;/li&gt;
&lt;li&gt;改了多个文件就算重要吗？&lt;/li&gt;
&lt;li&gt;审查者应该看到多少实现过程？&lt;/li&gt;
&lt;li&gt;两个审查结论不一致怎么办？&lt;/li&gt;
&lt;li&gt;什么时候审查是在提高质量，什么时候只是在增加流程？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后只能继续缩小范围。&lt;/p&gt;
&lt;p&gt;绿色任务不用机械地走审查流程。黄色任务如果不是简单的机械修改，可以增加一次只读审查。红色任务则重点检查失败路径、取消、所有权、安全、清理和回滚，同时还要限制复杂度，免得流程越堆越多。&lt;/p&gt;
&lt;p&gt;其他规则也是一样。&lt;/p&gt;
&lt;p&gt;“运行完整测试”不够准确，因为最高级的确定性验证可能已经包含下面所有模式，没必要换几个命令重复跑。&lt;/p&gt;
&lt;p&gt;“使用真机验证”也不够准确。只有本地和确定性测试证明不了当前结论时，真机才有必要。&lt;/p&gt;
&lt;p&gt;“使用 Xcode MCP”还得先确认连接的是哪个 Xcode、哪个窗口、哪个 workspace 和 scheme。不然工具本身能正常执行，也可能在错误的项目里执行。&lt;/p&gt;
&lt;p&gt;以前依赖上下文就能理解的话，做成通用 Skill 后都得补上适用条件。&lt;/p&gt;
&lt;p&gt;不补的话，下次大概率就会被用错。&lt;/p&gt;
&lt;h2&gt;Skill 本身也要检查&lt;/h2&gt;
&lt;p&gt;Skills 仓库有自己的 validator，目前会检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;metadata&lt;/li&gt;
&lt;li&gt;内部链接&lt;/li&gt;
&lt;li&gt;未替换的占位符&lt;/li&gt;
&lt;li&gt;shell 语法&lt;/li&gt;
&lt;li&gt;空白和格式问题&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;安装脚本则单独处理目标位置冲突。&lt;/p&gt;
&lt;p&gt;不过这些检查只能证明文件结构没有坏。&lt;/p&gt;
&lt;p&gt;一个 Skill 完全可以 metadata 正确、链接有效、shell 也没有语法错误，但用起来还是很离谱。比如触发范围太广、生成的规则太重、漏掉项目原本的约束，或者推荐了一种根本不能证明当前结论的验证方式。&lt;/p&gt;
&lt;p&gt;所以后面还是得拿其他项目试。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;project-governance-bootstrap&lt;/code&gt; 可以分别找一个小工具、一个普通的多模块应用，再找一个真的涉及安全或者生命周期风险的项目。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;apple-dev-loop&lt;/code&gt; 则要看它能不能在合适的位置停下来：package 问题跑 package test，Xcode 项目问题使用 &lt;code&gt;xcodebuild&lt;/code&gt;，Keychain 问题运行签名 App，性能问题再开 Instruments。&lt;/p&gt;
&lt;p&gt;如果最后生成的还是换了项目名的 BiliKit 规则，那就回去继续改。&lt;/p&gt;
</content:encoded></item><item><title>AI 让代码变得廉价，却让工程变得更昂贵</title><link>https://www.shiinayane.com/zh/posts/ai-made-code-cheap/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/ai-made-code-cheap/</guid><description>BiliKit 在 V1 之前就写到了 21,300 行 Swift。原来那套写 prompt、跑测试、看 diff 的方法不够用了，我也被迫换了一套工作流。</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;BiliKit V1 还没做完，Swift 代码已经有 21,300 行了。&lt;/p&gt;
&lt;p&gt;其中大约 12,700 行是生产代码，8,400 行是测试，一共 125 个 Swift 文件。目前已经能浏览和搜索视频、扫码登录、用 AVPlayer 播放 DASH 视频、显示字幕、按照播放时间轴调度弹幕，也做过持续高负载下的渲染验证。&lt;/p&gt;
&lt;p&gt;当然离完成还早。UI 需要重新收拾，Mac 特有的交互也没做完，签名、公证、发布流程和最终回归测试都还在后面。&lt;/p&gt;
&lt;p&gt;这也是第一个把我原来那套 AI 工作流搞得不太够用的项目。&lt;/p&gt;
&lt;p&gt;刚开始其实很简单：告诉 AI 要做什么，让它读一下相关文件，写完以后跑测试，最后我再看 diff。项目小的时候基本够用，偶尔把代码放错地方也很好改。&lt;/p&gt;
&lt;p&gt;后来代码越来越多，问题开始变得没那么明显了。&lt;/p&gt;
&lt;h2&gt;能编译不代表写对了&lt;/h2&gt;
&lt;p&gt;BiliKit 的播放链路会经过网络请求、HTTP Range、SIDX 解析、HLS 播放列表、本地 HTTP Server、AVPlayer 生命周期、取消、Seek 和 CDN fallback。&lt;/p&gt;
&lt;p&gt;登录也不只是扫个码。里面还有远端状态机、ephemeral URLSession、重定向策略、Keychain、请求授权、登出清理和 UI 状态。弹幕则会碰到 protobuf 解码、分段预取、去重、统一时间轴、轨道分配、Core Animation 和对象生命周期。&lt;/p&gt;
&lt;p&gt;这些东西分散在不同模块里，但实际运行时又是连在一起的。&lt;/p&gt;
&lt;p&gt;AI 很少给我写出一眼就能看出来的错误 Swift。更常见的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;类型放进了最顺手的模块，而不是应该拥有它的模块；&lt;/li&gt;
&lt;li&gt;测试刚好靠一次 &lt;code&gt;Task.yield()&lt;/code&gt; 通过；&lt;/li&gt;
&lt;li&gt;取消后 UI 不再更新了，但底层资源还活着；&lt;/li&gt;
&lt;li&gt;匿名媒体请求不小心带上了认证 Header；&lt;/li&gt;
&lt;li&gt;Benchmark 还没回答原来的问题，就先长成了一个小框架；&lt;/li&gt;
&lt;li&gt;文档把 Roadmap 里的计划写成了当前已经能用的功能。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些改动单独看都挺像那么回事，快速扫一遍 diff 还真不一定能发现。有些甚至整套测试都能过。&lt;/p&gt;
&lt;p&gt;我一开始的解决方法是把 prompt 写得越来越长。产品范围、模块边界、历史决定、危险区域、怎么验证，全都塞进去。&lt;/p&gt;
&lt;p&gt;短期确实有点用。不过每开一个新会话又要解释一遍，而且当天任务很容易和长期规则混在一起。写到后面，继续加 prompt 已经有点像拿聊天记录维护项目了。&lt;/p&gt;
&lt;h2&gt;把该记的东西放回仓库&lt;/h2&gt;
&lt;p&gt;后来 BiliKit 里逐渐多了 Roadmap、ADR、Threat Model、带日期的验证记录和工程指南。&lt;/p&gt;
&lt;p&gt;当前代码和构建配置说明现在到底有什么；ADR 记录哪些决定不能随便推翻；Roadmap 区分 V1 正在做什么、以后可能做什么；验证记录只负责保存某个时间和环境里实际观察到了什么。&lt;/p&gt;
&lt;p&gt;这个区分对 BiliKit 还挺有用。下载、转码、直播、多账号、区域解锁，随便挑一个都能让 AI 写出一份看起来相当完整的计划，但它们目前都不属于 V1。&lt;/p&gt;
&lt;p&gt;非简单任务开始前，我也会先写一个很短的任务契约：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Goal&lt;/strong&gt;：这次到底要改变哪个可观察行为；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context&lt;/strong&gt;：相关入口、已有决定和限制；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Constraints&lt;/strong&gt;：依赖方向、安全、取消、生命周期和范围；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Done when&lt;/strong&gt;：用什么测试、探针、测量或者实际行为证明完成。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果 Goal 一句话说不清楚，通常是一个任务里夹了两件事。如果 Done when 只有“测试通过”，那多半还没想清楚真正容易出问题的地方。&lt;/p&gt;
&lt;p&gt;换会话以后也不用继承前面所有聊天记录了。读契约，再读仓库里已经存在的事实，基本上就能开始。&lt;/p&gt;
&lt;h2&gt;一次只接通一条真实路径&lt;/h2&gt;
&lt;p&gt;让 AI 横着铺代码实在太容易了。&lt;/p&gt;
&lt;p&gt;先生成全部 Domain Model，再生成 Repository、Use Case 和 View，看起来非常整齐。但这样很容易出现没有调用方的 Protocol、暂时用不到的 Target，以及为了想象中的以后准备好的各种抽象。&lt;/p&gt;
&lt;p&gt;BiliKit 现在还是分层：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Scene → View → ViewModel → Use Case → Repository Port → Adapter
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过实现时会从上到下接通一个真实行为。&lt;/p&gt;
&lt;p&gt;登录先做 QR 状态、凭据所有权、一个需要授权的 Endpoint、一条 UI 流程、重启恢复和登出。弹幕先固定唯一媒体时间轴和分段契约，再做有界调度，最后把 Renderer 接进真实播放链路。&lt;/p&gt;
&lt;p&gt;这样写没有一次生成整个子系统那么爽，但后面要删的空架构少很多。&lt;code&gt;Common&lt;/code&gt;、&lt;code&gt;Shared&lt;/code&gt;、&lt;code&gt;Utils&lt;/code&gt; 这种等着变成垃圾场的模块也尽量不留。&lt;/p&gt;
&lt;h2&gt;五行代码也可能是红区&lt;/h2&gt;
&lt;p&gt;我以前也会觉得 diff 小就比较安全。BiliKit 里显然不是这样。&lt;/p&gt;
&lt;p&gt;重定向策略改五行可能把凭据带到不该去的地方；Task ownership 改几行可能出现泄漏或者旧结果覆盖；很短的持久化迁移也可能把用户数据删掉。反过来，自动生成的 protobuf 文件就算很大，也未必有什么风险。&lt;/p&gt;
&lt;p&gt;所以现在按失败代价分三类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;绿区&lt;/strong&gt;：局部 UI 和范围很窄的机械修改；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;黄区&lt;/strong&gt;：普通 Feature、Use Case、跨文件重构和公共 API；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;红区&lt;/strong&gt;：认证、Keychain、媒体、重定向、本地服务器、并发、资源生命周期、Renderer、迁移、删除和不可逆操作。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;绿区跑基础检查，再看一下对应 diff。黄区先写清任务契约，通常再开一个新的只读上下文审查。红区要先决定值不值得做；路线不确定时先做有范围限制的实验，最后用和风险对应的实际测量收尾。&lt;/p&gt;
&lt;p&gt;这么分以后，普通改动反而不用走一大堆没必要的流程。改 CSS 和改 Keychain 本来就不应该用同一张 Checklist。&lt;/p&gt;
&lt;p&gt;不确定的实验还会先定复杂度预算。比如比较弹幕渲染方案，并不需要十个候选、五种输出格式和一套以后也许能复用的 Benchmark Framework，只要能回答这次产品选择就行。&lt;/p&gt;
&lt;p&gt;弹幕 Renderer 当时就单独放在不可合并的 Spike 分支里，用合成负载比较少量方案。路线选完以后，生产实现重新开始。实验代码能跑，也不代表它应该顺手变成项目基础设施。&lt;/p&gt;
&lt;h2&gt;测试通过具体证明了什么&lt;/h2&gt;
&lt;p&gt;字幕有个测试曾经假设，调用一次 &lt;code&gt;Task.yield()&lt;/code&gt; 以后，异步 Stream 就来得及发布状态。&lt;/p&gt;
&lt;p&gt;平时基本都能过，统一 Gate 在另一种时序下跑完整测试时才把竞态暴露出来。这个时候反复重跑到绿色没什么用，测试应该等待真正关心的可观察状态。&lt;/p&gt;
&lt;p&gt;BiliKit 现在有一个统一入口负责静态检查、Package 测试和完整 App 构建。具体风险再补对应的证据：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;协议行为用固定 Fixture；&lt;/li&gt;
&lt;li&gt;来源和重定向策略补负向测试；&lt;/li&gt;
&lt;li&gt;Keychain 用签名 App 做 Smoke Test；&lt;/li&gt;
&lt;li&gt;播放走真实 AVPlayer Probe；&lt;/li&gt;
&lt;li&gt;弹幕用受控高密度负载；&lt;/li&gt;
&lt;li&gt;内存和清理问题延长测量时间；&lt;/li&gt;
&lt;li&gt;自动化很难判断的 UI 体验再手动确认。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Unsigned Build 证明不了 Keychain，截图也证明不了取消路径，App 能启动更不代表 Renderer 跑 30 分钟没问题。&lt;/p&gt;
&lt;p&gt;当然，也没必要每次把所有工具都跑一遍。改 Swift Package 的问题，Package Test 可能就够了；真的碰到性能问题再开 Instruments。&lt;/p&gt;
&lt;h2&gt;现在怎么做&lt;/h2&gt;
&lt;p&gt;目前处理 BiliKit 普通任务，大概是这个顺序：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;读当前代码、Roadmap、相关决策和测试。&lt;/li&gt;
&lt;li&gt;写清一个可观察 Goal，以及什么结果算完成。&lt;/li&gt;
&lt;li&gt;按失败代价判断任务风险。&lt;/li&gt;
&lt;li&gt;路线不确定时先限制实验复杂度。&lt;/li&gt;
&lt;li&gt;接通最小的一条纵向路径。&lt;/li&gt;
&lt;li&gt;重要改动换一个新的只读上下文 Review。&lt;/li&gt;
&lt;li&gt;跑统一自动化 Gate。&lt;/li&gt;
&lt;li&gt;只补这次风险实际需要的环境验证。&lt;/li&gt;
&lt;li&gt;更新当前文档，不去改写以前的验证记录。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;周末随手写个小工具显然用不到这些。BiliKit 有认证、播放、本地服务器、并发状态和 Renderer，原来那套“写 prompt、跑测试、看 diff”比我想象中更早到了极限。&lt;/p&gt;
&lt;p&gt;项目现在还没到 V1，这套流程后面大概也会继续变。&lt;/p&gt;
&lt;p&gt;至少下次开新会话时，不需要再指望它凭聊天记录记住整个项目了。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;后来我把这里面一部分工作流拆成了可以复用的 Codex Skills。续篇：&lt;a href=&quot;/zh/posts/i-turned-my-ai-workflow-into-code/&quot;&gt;我把自己的 AI 工作流变成了代码&lt;/a&gt;。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>Swift 新特性：WWDC26 笔记</title><link>https://www.shiinayane.com/zh/posts/whats-new-in-swift-wwdc26/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/whats-new-in-swift-wwdc26/</guid><description>WWDC26《What&apos;s New in Swift》笔记，记录 Swift 6.3 与 6.4 中我之后可能会用到的变化。</description><pubDate>Thu, 11 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;看完今年的 &lt;em&gt;What&apos;s New in Swift&lt;/em&gt;，顺手把之后可能会查的内容记一下。&lt;/p&gt;
&lt;p&gt;这场由 Swift 团队的 Becca 和 Evan 主讲，讲的是 Swift 6.3 和 6.4。内容很多，从几处语法小改动，一路讲到 Android、Wasm 和所有权。下面基本按照 Session 的顺序整理，代码示例也保留下来了。&lt;/p&gt;
&lt;h2&gt;日常写 Swift 能碰到的改动&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;some&lt;/code&gt; 和 &lt;code&gt;any&lt;/code&gt; 外面的 Optional 括号可以省略&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// 以前
func delegate() -&amp;gt; (any Renderer)?

// 现在
func delegate() -&amp;gt; any Renderer?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改动很小，不过后者确实更好读。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;weak let&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;过去弱引用只能写成 &lt;code&gt;var&lt;/code&gt;，放在 &lt;code&gt;Sendable&lt;/code&gt; 类型里比较麻烦，有时最后只能用 &lt;code&gt;@unchecked Sendable&lt;/code&gt; 绕过去。&lt;/p&gt;
&lt;p&gt;Swift 6.4 允许不可变弱引用使用 &lt;code&gt;weak let&lt;/code&gt;，这种情况终于可以正常表达 &lt;code&gt;Sendable&lt;/code&gt; 了。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;~Sendable&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;~Sendable&lt;/code&gt; 可以明确标记一个类型本身不是 &lt;code&gt;Sendable&lt;/code&gt;，同时不妨碍它的子类自行成为 &lt;code&gt;Sendable&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;忽略 Task 错误会收到警告&lt;/h3&gt;
&lt;p&gt;如果直接忽略 &lt;code&gt;Task&lt;/code&gt; 抛出的错误，Swift Concurrency 现在会给警告。非结构化任务里的错误确实很容易就这样丢掉，这个检查早该有了。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;defer&lt;/code&gt; 中可以调用异步函数&lt;/h3&gt;
&lt;p&gt;之前不能在 &lt;code&gt;defer&lt;/code&gt; 里调用 &lt;code&gt;async&lt;/code&gt; 函数的限制已经移除。&lt;/p&gt;
&lt;h3&gt;两个成员初始化器&lt;/h3&gt;
&lt;p&gt;结构体同时包含 &lt;code&gt;internal&lt;/code&gt; 和 &lt;code&gt;private&lt;/code&gt; 存储属性时，编译器可以按照对应的访问级别合成两个成员初始化器。不用再因为属性可见性不同，专门手写一个初始化器。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;@diagnose&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;@diagnose&lt;/code&gt; 可以只调整某个声明上的一项诊断：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@diagnose(DeprecatedDeclaration, as: ignored, reason: &quot;Flying with surplus hardware&quot;)
func makeApolloSoyuzMission() -&amp;gt; Mission { ... }

@diagnose(StrictMemorySafety, as: warning)
func uplinkCommand(from receiver: inout Receiver, to computer: inout Computer) { ... }

@diagnose(ErrorInFutureSwiftVersion, as: error)
func fetchPosition() -&amp;gt; (x: Double, y: Double, z: Double) { ... }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是这批小改动里我最喜欢的一个。碰到特殊调用点，不用再修改整个项目的警告级别，&lt;code&gt;reason:&lt;/code&gt; 也可以顺便解释为什么这里要例外处理。&lt;/p&gt;
&lt;h3&gt;模块选择器 &lt;code&gt;::&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Swift 6.3 新增的 &lt;code&gt;::&lt;/code&gt; 用来解决不同模块中的同名符号：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import Rocket
import GiftShopToys

let r1 = SaturnV()          // 有歧义
let r2 = Rocket::SaturnV()  // 使用 Rocket 模块中的类型
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;成员也可以这样选择，例如 &lt;code&gt;technician.HumanResources::fire()&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;库更新&lt;/h2&gt;
&lt;h3&gt;标准库&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;withTaskCancellationShield { ... }&lt;/code&gt;：即使外围任务已经取消，里面的关键操作仍然会完成。Session 里的例子是发送最后一条 SOS 数据包。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Dictionary.mapKeyedValues&lt;/code&gt;：转换字典值时也能拿到对应的键：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;missions.mapKeyedValues { mission, window in
    makeDisplayName(for: mission, in: window)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;FilePath&lt;/code&gt;：跨平台的路径类型，可以直接操作结构化的 &lt;code&gt;components&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var path: FilePath = &quot;/var/www/static&quot;
path.components.append(&quot;WWDC&quot;)
// [ &quot;var&quot;, &quot;www&quot;, &quot;static&quot;, &quot;WWDC&quot; ]
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Swift Testing&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Issue.record(..., severity: .warning)&lt;/code&gt; 可以记录警告，但不会让测试失败。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;try Test.cancel(&quot;reason&quot;)&lt;/code&gt; 可以在参数化用例不适用时带着原因退出。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;swift test&lt;/code&gt; 可以在指定次数内重复运行测试，直到通过或失败。查偶发测试问题时应该挺实用。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;XCTestCase&lt;/code&gt; 里现在可以使用 &lt;code&gt;#expect&lt;/code&gt;，XCTest 的断言失败也会显示成 Swift Testing Issue，所以旧测试不需要一次性全部迁移。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Subprocess 1.0&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Subprocess&lt;/code&gt; 包正式到了 1.0。新版整理了 API 和错误处理，也支持跨平台逐行读取输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let result = try await Subprocess.run(
    .name(&quot;ls&quot;),
    input: .none,
    output: .sequence,
    error: .string(limit: 4096)
) { execution in
    execution.standardOutput.strings().filter { $0.hasSuffix(&quot;.obj&quot;) }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不同平台的文件描述符和进程退出状态由包内部处理。&lt;/p&gt;
&lt;h3&gt;Foundation 的 &lt;code&gt;ProgressManager&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ProgressManager&lt;/code&gt; 是针对 &lt;code&gt;async&lt;/code&gt;／&lt;code&gt;await&lt;/code&gt; 设计的新进度 API。父任务通过 &lt;code&gt;subprogress(assigningCount:)&lt;/code&gt; 把总进度的一部分分给子任务，子任务只管自己的几个阶段，不需要知道外面的总数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let manager = ProgressManager(totalCount: 100)
try await rocket.launch(manager.subprogress(assigningCount: 100))

extension Rocket {
    func launch(_ progress: consuming Subprogress? = nil) async throws {
        let stage = progress?.start(totalCount: 3)
        try await ignite();          stage?.complete(count: 1)
        try await liftoff();         stage?.complete(count: 1)
        try await stageSeparation(); stage?.complete(count: 1)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Foundation 还在继续迁移到纯 Swift 实现。Session 里提到了更快的 &lt;code&gt;Data&lt;/code&gt; 操作和桥接，以及 &lt;code&gt;NSURL&lt;/code&gt;／&lt;code&gt;CFURL&lt;/code&gt; 背后统一的 Swift 实现。&lt;/p&gt;
&lt;h2&gt;Apple 平台之外&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;anyAppleOS&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;以前要把 Apple 平台全部列出来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 以前
@available(macOS 27, iOS 27, watchOS 27, tvOS 27, visionOS 27, *)
func showStatus() { ... }

// 现在
@available(anyAppleOS 27, *)
func showStatus() { ... }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;anyAppleOS&lt;/code&gt; 也可以写在 &lt;code&gt;#if os(anyAppleOS)&lt;/code&gt; 里。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;@C&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;@C&lt;/code&gt; 可以把 Swift 函数直接暴露给 C，也可以让 Swift 实现一个 C 函数。签名需要使用整数、指针、导入的 C 结构体和 raw-value 枚举等 C 兼容类型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@C
func averageLaunchWindowLength(_ windows: Span&amp;lt;LaunchWindow&amp;gt;) -&amp;gt; TimeInterval { ... }
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Java 和 Android&lt;/h3&gt;
&lt;p&gt;Java 现在可以调用 Swift 的 &lt;code&gt;async&lt;/code&gt; 和 &lt;code&gt;throws&lt;/code&gt; 函数，Java 类也可以遵循 Swift 协议。另外，swift.org 已经提供官方 Swift Android SDK。&lt;/p&gt;
&lt;h3&gt;WebAssembly&lt;/h3&gt;
&lt;p&gt;Swift 可以通过开源工具链编译成 Wasm。JavaScriptKit 的类型安全桥接路径现在比动态路径快 &lt;strong&gt;35–40 倍&lt;/strong&gt;。Session 中举的例子是 Goodnotes，它把原生 iOS App 里的 Swift 代码通过 Wasm 带到了 Web。&lt;/p&gt;
&lt;h3&gt;Embedded Swift&lt;/h3&gt;
&lt;p&gt;Embedded Swift 新增了 existential type、无类型 &lt;code&gt;throws&lt;/code&gt;，以及用于受限设备 coredump 调试的 DWARF 信息。新的 &lt;code&gt;EmbeddedRestrictions&lt;/code&gt; 警告组会标出不能在嵌入式环境里使用的功能。&lt;/p&gt;
&lt;h3&gt;编辑器支持&lt;/h3&gt;
&lt;p&gt;Swift VS Code 扩展集成了 Swiftly，可以管理工具链，并且已经发布到 OpenVSX。Cursor、VSCodium 之类的编辑器也能直接使用；扩展里还加了新手入门检查清单。&lt;/p&gt;
&lt;h2&gt;性能和所有权&lt;/h2&gt;
&lt;h3&gt;优化器提示&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;@inline(always)&lt;/code&gt; 现在是正式支持的属性。用在类方法上时应当配合 &lt;code&gt;final&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Swift 6.3 的 &lt;code&gt;@specialized&lt;/code&gt; 可以针对已知的热点类型预特化泛型函数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@specialized(where Values == [UInt8])
func histogram&amp;lt;Values&amp;gt;(of values: Values) -&amp;gt; [256 of Int]
    where Values: Sequence&amp;lt;UInt8&amp;gt; { ... }
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;标准库里的所有权支持&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Equatable&lt;/code&gt;、&lt;code&gt;Comparable&lt;/code&gt; 和 &lt;code&gt;Hashable&lt;/code&gt; 现在支持 noncopyable 类型；&lt;code&gt;Equatable&lt;/code&gt; 和 &lt;code&gt;Comparable&lt;/code&gt; 也支持 non-escapable 类型。关联类型可以声明为 &lt;code&gt;~Copyable&lt;/code&gt; 或 &lt;code&gt;~Escapable&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;新的 &lt;code&gt;Iterable&lt;/code&gt; 协议会在 &lt;code&gt;for&lt;/code&gt; 循环中借用元素，而不是复制。&lt;/p&gt;
&lt;p&gt;自定义 &lt;code&gt;borrow&lt;/code&gt; 和 &lt;code&gt;mutate&lt;/code&gt; 访问器可以把这种语义暴露给容器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public struct UniqueBox&amp;lt;Value: ~Copyable&amp;gt;: ~Copyable {
    private let valuePointer: UnsafeMutablePointer&amp;lt;Value&amp;gt;

    public var value: Value {
        borrow { valuePointer.pointee }
        mutate { &amp;amp;valuePointer.pointee }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;新的低开销类型&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;UniqueBox&lt;/code&gt; 和 &lt;code&gt;UniqueArray&lt;/code&gt;：没有引用计数开销的 noncopyable 存储。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Continuation&lt;/code&gt;：在编译期检查只恢复一次，安全性相当于 &lt;code&gt;CheckedContinuation&lt;/code&gt;，成本相当于 &lt;code&gt;UnsafeContinuation&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Ref&lt;/code&gt; 和 &lt;code&gt;MutableRef&lt;/code&gt;：安全借用集合中的某个位置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var countRef = MutableRef(&amp;amp;counts[key, default: 0])
countRef.value += 1
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;withTemporaryAllocation&lt;/code&gt; 现在给闭包提供 &lt;code&gt;OutputSpan&lt;/code&gt;，不再直接暴露 &lt;code&gt;UnsafeMutableBufferPointer&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我大概会先用到 &lt;code&gt;@diagnose&lt;/code&gt;、Task 错误警告和 Swift Testing 的几个改动。所有权相关的新类型更有意思，不过还是得等项目里真的碰到对应问题再说。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;完整 Session：&lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2026/262/&quot;&gt;What&apos;s new in Swift — WWDC26&lt;/a&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>配置不难，难的是一直维护</title><link>https://www.shiinayane.com/zh/posts/maintenance/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/maintenance/</guid><description>干净的开发环境会在日常使用中悄悄偏移。这些简单检查能让我及时看见问题，不至于把清理本身做成另一个工程。</description><pubDate>Sat, 30 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我整理过的开发环境，通常只能干净半年左右。上一次认真检查时，我发现本来以为只有一个的 Python 实际有三个版本，Brewfile 和机器上的软件已经对不上，&lt;code&gt;mise list&lt;/code&gt; 里还留着一批为某些实验安装、如今连用途都想不起来的 runtime。系统并没有坏，只是在每天使用的过程中慢慢偏离了原先的状态。&lt;/p&gt;
&lt;p&gt;这是系列的最后一篇。前六篇写的是分层、设置和 dotfiles，这些配置花一个下午就能完成。一年后环境是否还干净，取决于另一件不起眼但要反复做的事：看见偏移，然后删掉已经不需要的东西。&lt;/p&gt;
&lt;h2&gt;偏移通常从哪里来&lt;/h2&gt;
&lt;p&gt;我遇到的情况大致有三种。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;工具悄悄自动安装。&lt;/strong&gt; &lt;a href=&quot;/zh/posts/python/&quot;&gt;Python 那篇&lt;/a&gt;里，&lt;code&gt;uv&lt;/code&gt; 就曾自行下载一套 Python。整个过程没有提示，机器上却多出了并非由我选择的状态，因此这种偏移最容易漏掉。有些问题可以用声明式设置拦住，例如 &lt;code&gt;python-preference = only-system&lt;/code&gt; 会把静默下载变成明确报错。但不可能提前管住所有工具，所以最后还是得检查机器的实际状态。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;安装了，却忘了同步清单。&lt;/strong&gt; 软件是我主动装的，只是没有补进声明文件，于是&lt;a href=&quot;/zh/posts/apps/&quot;&gt;应用管理那篇&lt;/a&gt;里的 Brewfile 逐渐落后于真实机器。平时不一定有影响，迁移时才会连续遇到“原来这个也装过”。因此不能等到恢复环境时才看 Brewfile，而要提前核对清单和机器。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;用完以后留下的东西越来越多。&lt;/strong&gt; 某个 runtime、全局工具或软件包确实曾经有用，只是用过一次后再也没有删除。这种偏移反而最难清理：单看任何一项都没错，也不会触发报错；只有把整张列表摆出来，才看得出累积本身已经成了问题。&lt;/p&gt;
&lt;h2&gt;健康检查只负责把问题摆出来&lt;/h2&gt;
&lt;p&gt;我为此保留了几个 shell 函数。前面的文章已经写过 &lt;code&gt;brewdiff&lt;/code&gt;，用来比较已安装应用和 Brewfile；也写过 &lt;code&gt;zhealth&lt;/code&gt;，用来找出家目录里散落的 zsh 文件。Python 还需要单独检查，因为不同工具很容易各自带上一套解释器，平时又看不出它们并不一致：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 30-functions.zsh — surface Python version drift across tools
pyversions() {
  echo &quot;shell PATH   : $(command -v python)&quot;
  echo &quot;  reports    : $(python --version 2&amp;gt;&amp;amp;1)&quot;
  echo &quot;mise current : $(mise current python 2&amp;gt;/dev/null || echo &apos;—&apos;)&quot;
  echo &quot;uv would use : $(uv run python --version 2&amp;gt;&amp;amp;1)&quot;
  echo &quot;mise list    :&quot;
  mise list python 2&amp;gt;/dev/null | sed &apos;s/^/  /&apos;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pyversions&lt;/code&gt; 会把 shell 里的 &lt;code&gt;python&lt;/code&gt;、&lt;code&gt;mise current&lt;/code&gt;、&lt;code&gt;uv run python&lt;/code&gt; 和 &lt;code&gt;mise&lt;/code&gt; 已安装的版本放在一起。前三项一致时，这一层基本正常；不一致时，就说明某个工具掌握了一套我没预期到的 Python。这样可以直接重复 Python 那篇里的调查，不必每次重新想一遍该查哪些命令。&lt;/p&gt;
&lt;p&gt;其他层也是同一个办法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用 &lt;code&gt;mise list&lt;/code&gt; 对照我实际使用的 &lt;code&gt;mise.toml&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;brew bundle check&lt;/code&gt; 对照机器和 Brewfile；&lt;/li&gt;
&lt;li&gt;检查 &lt;code&gt;$HOME&lt;/code&gt; 的内容是否符合“这里只留一个 zsh 文件”的预期。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些命令不会自动修复任何东西，这是刻意的。一个只负责报告的小命令容易理解，也容易执行。它把偶然发现的意外，变成了我可以自行判断的差异。&lt;/p&gt;
&lt;h2&gt;30 天没有用，就重新判断一次&lt;/h2&gt;
&lt;p&gt;对我来说，成熟的环境里应该很少留下已经不用的工具。因此维护做得最多的其实是减法。&lt;/p&gt;
&lt;p&gt;遇到可疑项目时，我会问：过去 30 天真的用过它吗？一个月没碰过的 runtime、全局工具或应用会进入待删除名单。不是立刻自动卸载，而是从“默认保留”变成“需要给出保留理由”。&lt;/p&gt;
&lt;p&gt;检查 &lt;code&gt;mise&lt;/code&gt; runtime 时，实际用的就是这几条：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ mise list                 # what&apos;s installed
$ mise uninstall python@3.11 # remove a version no project uses
$ mise prune                 # drop versions nothing references
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上一篇里的 &lt;a href=&quot;/zh/posts/polyglot/&quot;&gt;Ruby 取舍&lt;/a&gt;其实是在安装前做同样的判断。系统 Ruby 很旧，并不等于一台没有 Ruby 项目的机器必须再装一套由 &lt;code&gt;mise&lt;/code&gt; 管理的 Ruby。等项目真的需要时再安装，也就少了一个以后要检查、要清理的 runtime。&lt;/p&gt;
&lt;p&gt;我会定期做这件事，而不是等自己突然想整理。安装新工具很容易产生“有进展”的感觉；删除工具却像损失，也像承认当初装错了，沉没成本又让继续放着显得更轻松。固定流程反而简单：运行检查，看看哪些东西 30 天没有用，再删掉说不出保留理由的项目。&lt;/p&gt;
&lt;p&gt;所以 &lt;code&gt;mise list&lt;/code&gt; 少一行、Brewfile 很短，或者家目录里只有一个 dotfile，并不代表配置不完整。有时这就是整理后的正确状态。&lt;/p&gt;
&lt;h2&gt;别把维护做成另一套系统&lt;/h2&gt;
&lt;p&gt;清理流程本身也可能过度设计。完全可以为它做一个仪表盘，再配上定时任务和一大批健康检查，最后却多出一套需要维护的新系统。&lt;/p&gt;
&lt;p&gt;两秒能跑完的 &lt;code&gt;brewdiff&lt;/code&gt;，我会真的去跑；需要照顾的监控系统，多半不会。只有当核对成本低于它避免的混乱时，这套办法才划算。因此这些工具应当保持简单，只负责报告，把删除与否留给人判断。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/zh/posts/manifesto/&quot;&gt;系列开篇&lt;/a&gt;把环境分成四层：System、Runtime version、Package manager 和 Project dependencies。除此之外还有一件任何一层配置都无法代替的事：偶尔看看机器，把实际状态和声明状态放在一起，在真正出故障前重新对齐。&lt;/p&gt;
&lt;p&gt;如果一台机器很久没检查，我会先运行 &lt;code&gt;which python&lt;/code&gt; 和 &lt;code&gt;uv run python --version&lt;/code&gt;，再从 &lt;code&gt;mise list&lt;/code&gt; 里找出一个月没碰过的东西。通常做到这里，就能知道记忆中的环境和眼前这台机器还是不是同一个状态。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;本文是 Sovereign Tools 系列的最后一篇。完整阅读顺序见&lt;a href=&quot;/zh/series/sovereign-tools&quot;&gt;系列索引&lt;/a&gt;。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>一套架构，多种语言</title><link>https://www.shiinayane.com/zh/posts/polyglot/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/polyglot/</guid><description>把同一套四层架构实际用在 Node、Java、Swift、Rust、Go 与 Ruby 项目中。</description><pubDate>Sat, 30 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;每次碰到不熟悉的语言生态，我最先想确认的其实只有几件事：运行时由谁安装、依赖交给谁管理、哪个锁文件要进 git，以及哪些生成物不该提交。这篇把 Node、Java、Swift、Rust、Go 和 Ruby 的答案集中在一起，适合需要时直接跳到对应章节查。&lt;/p&gt;
&lt;p&gt;这里继续沿用&lt;a href=&quot;/zh/posts/manifesto/&quot;&gt;系列第一篇&lt;/a&gt;的四层划分：&lt;/p&gt;
&lt;p&gt;&amp;lt;figure class=&quot;my-6&quot;&amp;gt;
&amp;lt;svg viewBox=&quot;0 0 600 330&quot; role=&quot;img&quot; aria-labelledby=&quot;diagram-layers-title-6&quot; style=&quot;width:100%;height:auto;color:inherit&quot;&amp;gt;
&amp;lt;title id=&quot;diagram-layers-title-6&quot;&amp;gt;The four-layer stack: System, Runtime version, Package manager, Project dependencies&amp;lt;/title&amp;gt;
&amp;lt;g font-family=&quot;ui-sans-serif, system-ui, sans-serif&quot;&amp;gt;
&amp;lt;rect x=&quot;10&quot; y=&quot;10&quot; width=&quot;580&quot; height=&quot;66&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;38&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; fill=&quot;var(--primary)&quot;&amp;gt;Layer 3&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;58&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&amp;gt;Project dependencies&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;570&quot; y=&quot;44&quot; font-size=&quot;13&quot; text-anchor=&quot;end&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;lockfiles in git&amp;lt;/text&amp;gt;
&amp;lt;rect x=&quot;10&quot; y=&quot;86&quot; width=&quot;580&quot; height=&quot;66&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;114&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; fill=&quot;var(--primary)&quot;&amp;gt;Layer 2&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;134&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&amp;gt;Package manager&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;570&quot; y=&quot;120&quot; font-size=&quot;13&quot; text-anchor=&quot;end&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;uv, pnpm, cargo&amp;lt;/text&amp;gt;
&amp;lt;rect x=&quot;10&quot; y=&quot;162&quot; width=&quot;580&quot; height=&quot;66&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;190&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; fill=&quot;var(--primary)&quot;&amp;gt;Layer 1&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;210&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&amp;gt;Runtime version&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;570&quot; y=&quot;196&quot; font-size=&quot;13&quot; text-anchor=&quot;end&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;mise, or a sovereign tool&amp;lt;/text&amp;gt;
&amp;lt;rect x=&quot;10&quot; y=&quot;238&quot; width=&quot;580&quot; height=&quot;66&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.2&quot;/&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;266&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; fill=&quot;var(--primary)&quot;&amp;gt;Layer 0&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;286&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&amp;gt;System&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;570&quot; y=&quot;272&quot; font-size=&quot;13&quot; text-anchor=&quot;end&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;Homebrew + Xcode CLT&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;/svg&amp;gt;
&amp;lt;/figure&amp;gt;&lt;/p&gt;
&lt;p&gt;对于 &lt;strong&gt;Layer 1 — Runtime version&lt;/strong&gt;，判断方法来自&lt;a href=&quot;/zh/posts/sovereignty/&quot;&gt;讨论工具主权的那篇文章&lt;/a&gt;：如果语言有成熟的官方工具，就让官方工具负责；没有的话再交给 &lt;code&gt;mise&lt;/code&gt;。下面不再重复论证，只写具体怎么配，以及最容易踩到哪里。&lt;/p&gt;
&lt;h2&gt;Node.js / TypeScript&lt;/h2&gt;
&lt;p&gt;Node 没有自己的主权版本管理器，所以 Node 版本由 &lt;code&gt;mise&lt;/code&gt; 管。包管理器我用 &lt;code&gt;pnpm&lt;/code&gt;，但不会另做一次 &lt;code&gt;brew install&lt;/code&gt;，而是通过 &lt;code&gt;corepack&lt;/code&gt; 启用。这样项目可以用 &lt;code&gt;package.json&lt;/code&gt; 的 &lt;code&gt;packageManager&lt;/code&gt; 字段固定 &lt;code&gt;pnpm&lt;/code&gt; 版本。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# mise.toml
[tools]
node = &quot;22&quot;

[settings]
# let corepack manage the pnpm version from package.json&apos;s packageManager field
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;corepack enable      # ships with Node; activates pnpm/yarn shims
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;brew install node&lt;/code&gt; 和 &lt;code&gt;brew install pnpm&lt;/code&gt; 都不要用。前者把 Layer 1 的所有权交给了 Homebrew，后者则绕开了项目对包管理器版本的固定。Node 也不需要另建虚拟环境：&lt;code&gt;node_modules&lt;/code&gt; 本来就是项目级目录，Python 靠 &lt;code&gt;.venv&lt;/code&gt; 获得的隔离，在 Node 里已经是默认行为。&lt;/p&gt;
&lt;p&gt;偶尔运行一次用 Node 写的命令行工具时，我会用 &lt;code&gt;pnpm dlx&lt;/code&gt;，不留下安装。如果确实要长期安装，就明确设置一个加入 &lt;code&gt;PATH&lt;/code&gt; 的 &lt;code&gt;PNPM_HOME&lt;/code&gt;，而不是让 &lt;code&gt;npm install -g&lt;/code&gt; 越积越多。&lt;/p&gt;
&lt;p&gt;提交 &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;，忽略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;node_modules/
*.tsbuildinfo
.turbo/
dist/
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Java&lt;/h2&gt;
&lt;p&gt;Java 没有统一的主权版本工具，JDK 本身也有多个发行版。我让 &lt;code&gt;mise&lt;/code&gt; 安装 Temurin，它是相对中立且维护良好的默认选择：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# mise.toml
[tools]
java = &quot;temurin-21&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只确认 &lt;code&gt;PATH&lt;/code&gt; 里的 &lt;code&gt;java&lt;/code&gt; 还不够，还要检查 &lt;code&gt;mise&lt;/code&gt; 是否正确设置了 &lt;code&gt;JAVA_HOME&lt;/code&gt;。很多 Java 工具会直接读取它；旧安装残留的 &lt;code&gt;JAVA_HOME&lt;/code&gt; 可能悄悄覆盖你原本想用的 JDK。&lt;code&gt;mise where java&lt;/code&gt; 和 &lt;code&gt;echo $JAVA_HOME&lt;/code&gt; 应该指向同一处：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mise where java
echo $JAVA_HOME
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;构建时优先使用仓库内的 wrapper，也就是 &lt;code&gt;./gradlew&lt;/code&gt; 或 &lt;code&gt;./mvnw&lt;/code&gt;，不要依赖全局安装的 Gradle 或 Maven。wrapper 会把构建工具版本固定在仓库里，相当于把 Layer 3 的做法延伸到了构建工具。&lt;/p&gt;
&lt;p&gt;Android 要单独看待。Android Studio 自带 JDK 和 SDK，让它自己管理即可，就像 Swift 工具链交给 Xcode 一样。&lt;/p&gt;
&lt;p&gt;Gradle 与 Maven 在构建文件里声明依赖；这些文件和 wrapper 都要提交。构建产物则忽略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.gradle/
build/
target/
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Swift / iOS&lt;/h2&gt;
&lt;p&gt;在 macOS 上，Xcode 就是 Swift 的主权工具，而且同时跨过了 &lt;strong&gt;Layer 0 — System&lt;/strong&gt; 与 &lt;strong&gt;Layer 1 — Runtime version&lt;/strong&gt;：工具链、SDK 和构建系统都装在同一个应用里。我从 Mac App Store 安装 Xcode，所以&lt;a href=&quot;/zh/posts/apps/&quot;&gt;应用管理那篇&lt;/a&gt;把它交给 &lt;code&gt;mas&lt;/code&gt;；项目依赖则优先使用 Swift Package Manager。&lt;/p&gt;
&lt;p&gt;不要在 macOS 上 &lt;code&gt;brew install swift&lt;/code&gt;。这样会凭空多出另一个工具链所有者，但构建过程的其他部分仍然由 Xcode 决定，最后通常只会得到更隐蔽的冲突。CocoaPods 还会引入一套多数新项目已经不需要的 Ruby 依赖；只要相关包支持 SPM，就可以把依赖管理留在 Apple 工具链内部。&lt;/p&gt;
&lt;p&gt;我自己的一个项目正好有一条很清楚的跨语言边界：KotobaLab 用 Swift，配套的 DictionaryBuilder 用 Python。两边不共享运行时、包管理器或构建系统，只通过一个 SQLite 文件连接——DictionaryBuilder 写数据库，KotobaLab 读数据库。使用中立的数据接口之后，两套工具链都不用了解对方内部怎么工作。&lt;/p&gt;
&lt;p&gt;提交 SPM 的锁文件 &lt;code&gt;Package.resolved&lt;/code&gt;，忽略用户级文件与构建产物：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;xcuserdata/
DerivedData/
.build/
*.xcuserstate
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Rust&lt;/h2&gt;
&lt;p&gt;这几种语言里，Rust 的所有权关系最简单。&lt;code&gt;rustup&lt;/code&gt; 管 Layer 1，随它安装的 &lt;code&gt;cargo&lt;/code&gt; 管 Layer 2，同时还负责构建、测试、依赖管理与发布。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# rustup installs the toolchain; cargo comes with it
rustup default stable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果一个多语言仓库已经有 &lt;code&gt;mise.toml&lt;/code&gt;，也可以在里面写 &lt;code&gt;rust = &quot;1.78&quot;&lt;/code&gt;，让 &lt;code&gt;mise&lt;/code&gt; 代理 &lt;code&gt;rustup&lt;/code&gt;。这就是&lt;a href=&quot;/zh/posts/sovereignty/&quot;&gt;工具主权一文&lt;/a&gt;里的 &lt;strong&gt;mise as proxy&lt;/strong&gt;。两种方法都可以，但同一个项目不要同时用两套方式管理 Rust 版本。&lt;/p&gt;
&lt;p&gt;Cargo 原生按项目管理依赖，所以 Rust 不需要虚拟环境。至于 &lt;code&gt;ripgrep&lt;/code&gt;、&lt;code&gt;fd&lt;/code&gt;、&lt;code&gt;bat&lt;/code&gt; 这类用 Rust 写成、但面向整台机器使用的命令行工具，我会优先安装 &lt;code&gt;brew&lt;/code&gt; formula。Homebrew 提供预编译二进制；&lt;code&gt;cargo install&lt;/code&gt; 则会从源码编译，花更多时间却没有实际收益。还没有被打包的工具再交给 &lt;code&gt;cargo install&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;应用和二进制项目要提交 &lt;code&gt;Cargo.lock&lt;/code&gt;。库项目长期以来的惯例是不提交，让下游使用者自行解析版本。忽略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/target/
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Go&lt;/h2&gt;
&lt;p&gt;Go 官方的 &lt;code&gt;dl&lt;/code&gt; 安装器算不上完整的版本管理器，因此把 Go 版本交给 &lt;code&gt;mise&lt;/code&gt; 很合适：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# mise.toml
[tools]
go = &quot;1.23&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现代 Go 使用 modules，项目可以放在磁盘上的任意位置，不再需要旧的 &lt;code&gt;GOPATH&lt;/code&gt; 工作区布局。不过我仍会设置 &lt;code&gt;GOBIN&lt;/code&gt;，让 &lt;code&gt;go install&lt;/code&gt; 把可执行文件写进一个自己控制、也已经加入 &lt;code&gt;PATH&lt;/code&gt; 的目录。我用的是 &lt;code&gt;~/.local/bin&lt;/code&gt;，与 &lt;a href=&quot;/zh/posts/python/&quot;&gt;Python 那篇&lt;/a&gt;存放个人脚本的位置相同：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 00-env.zsh
export GOBIN=&quot;$HOME/.local/bin&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;声明依赖的 &lt;code&gt;go.mod&lt;/code&gt; 与固定校验和的 &lt;code&gt;go.sum&lt;/code&gt; 都要提交。Go 项目很少产生零散文件；编译出的二进制可以按文件名忽略，也可以统一输出到被忽略的目录。&lt;/p&gt;
&lt;h2&gt;Ruby&lt;/h2&gt;
&lt;p&gt;Ruby 没有主权版本工具，因此 Layer 1 可以交给 &lt;code&gt;mise&lt;/code&gt;，但只有项目确实需要 Ruby 时我才会安装。&lt;/p&gt;
&lt;p&gt;不要拿 macOS 自带的系统 Ruby 装项目依赖。它版本老旧，Apple 也不建议修改；对它执行 &lt;code&gt;sudo gem install&lt;/code&gt;，很容易直接破坏系统环境。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# mise.toml — only when a project genuinely needs it
[tools]
ruby = &quot;3.3&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CocoaPods 仍是 macOS 开发者安装 Ruby 的常见原因，不过很多 Swift 项目改用 SPM 后已经没有这项需求。SPM 能覆盖项目时，Ruby 完全可以不装。这也符合&lt;a href=&quot;/zh/posts/maintenance/&quot;&gt;维护篇&lt;/a&gt;所说的按需安装：系统 Ruby 很旧，并不代表一台没有 Ruby 项目的机器也该立刻添加一份由 &lt;code&gt;mise&lt;/code&gt; 管理的 Ruby。&lt;/p&gt;
&lt;p&gt;确实使用 Ruby 时，提交 &lt;code&gt;Gemfile.lock&lt;/code&gt;。如果 Bundler 使用 &lt;code&gt;vendor/bundle/&lt;/code&gt; 之类的本地路径，就忽略该目录。&lt;/p&gt;
&lt;h2&gt;跨语言时仍然不变的部分&lt;/h2&gt;
&lt;p&gt;项目应该自己声明运行时版本，而不是依赖某位开发者的全局默认值。把 &lt;code&gt;mise.toml&lt;/code&gt; 或 &lt;code&gt;.tool-versions&lt;/code&gt; 提交到仓库后，新 clone 可以直接用 &lt;code&gt;mise install&lt;/code&gt; 配好所需版本；全局默认值留给临时实验即可。&lt;/p&gt;
&lt;p&gt;锁文件进 git，派生产物不进。也就是说，&lt;code&gt;pnpm-lock.yaml&lt;/code&gt;、&lt;code&gt;Cargo.lock&lt;/code&gt;、&lt;code&gt;go.sum&lt;/code&gt;、&lt;code&gt;Package.resolved&lt;/code&gt;、&lt;code&gt;uv.lock&lt;/code&gt; 和 &lt;code&gt;Gemfile.lock&lt;/code&gt; 要提交，&lt;code&gt;node_modules&lt;/code&gt;、&lt;code&gt;target/&lt;/code&gt;、&lt;code&gt;DerivedData/&lt;/code&gt;、&lt;code&gt;.venv&lt;/code&gt; 等生成目录则放进 &lt;code&gt;.gitignore&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;一个 &lt;code&gt;mise.toml&lt;/code&gt; 可以同时列出 &lt;code&gt;node&lt;/code&gt;、&lt;code&gt;python&lt;/code&gt; 和 &lt;code&gt;go&lt;/code&gt;。多语言项目不需要为每种语言各放一份运行时配置，一条命令就能准备整套工具。即使部分语言最终委托给自己的主权工具，统一入口仍然有用。&lt;/p&gt;
&lt;p&gt;同一个事实不该在不同层重复声明，不过看起来相似的配置不一定表达同一件事。Python 的 &lt;code&gt;pyproject.toml&lt;/code&gt; 里，&lt;code&gt;requires-python&lt;/code&gt; 表示代码支持的版本范围；&lt;code&gt;mise.toml&lt;/code&gt; 则选择当前机器实际使用的那个版本。两者都保留并不重复，合并反而会丢失信息。真正重复的声明会制造两个事实来源，这正是&lt;a href=&quot;/zh/posts/manifesto/&quot;&gt;系列第一篇&lt;/a&gt;想避免的问题。&lt;/p&gt;
&lt;p&gt;这套模型也有明确的边界。C 与 C++ 没有一个能负责“C 语言版本”的同类工具，系统编译器、SDK 和各种构建系统会把层次搅在一起。我不会强行把它们塞进同一套结构。它们以原生扩展或构建依赖出现时，就让引入它们的上层工具处理。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/zh/posts/maintenance/&quot;&gt;系列最后一篇&lt;/a&gt;接着讨论配置完成之后的事：怎样维持环境可用，又不让维护本身变成另一个项目。&lt;/p&gt;
</content:encoded></item><item><title>什么时候该让官方工具接管</title><link>https://www.shiinayane.com/zh/posts/sovereignty/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/sovereignty/</guid><description>不是所有语言都该交给 mise。判断运行时版本由谁管理，先看这门语言有没有足够强、真正属于官方生态的工具。</description><pubDate>Sat, 30 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;/zh/posts/python/&quot;&gt;上一篇&lt;/a&gt;里，我让 &lt;code&gt;mise&lt;/code&gt; 单独管理 Python 版本，把 &lt;code&gt;uv&lt;/code&gt; 留在依赖管理这一层。但在同一台机器上，Rust 却归 &lt;code&gt;rustup&lt;/code&gt; 管，尽管 &lt;code&gt;mise&lt;/code&gt; 也能安装 Rust。&lt;/p&gt;
&lt;p&gt;这不是给 Rust 特设的例外。每次决定由谁管理 Layer 1（运行时版本）时，我都会先问：&lt;strong&gt;这门语言有没有一个足够强的官方主权工具？&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;什么算主权工具&lt;/h2&gt;
&lt;p&gt;这里的&lt;strong&gt;主权工具&lt;/strong&gt;，是指语言项目自己提供的版本与工具链管理器。Rust 的 &lt;code&gt;rustup&lt;/code&gt;、Swift 的 Xcode 是最清楚的例子。如果这样的工具存在，而且能力足够强，我会把这一层交给它；如果没有，或者官方方案太弱，再让 &lt;code&gt;mise&lt;/code&gt; 补位。&lt;/p&gt;
&lt;p&gt;只看“官方出品”还不够，我实际会检查四件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;它属于语言项目本身。&lt;/strong&gt; 工具和编译器由同一批人发布，不是第三方在后面追赶语言变化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;它管理的不只是版本切换。&lt;/strong&gt; 例如工具链组件、stable/beta/nightly 发布通道、交叉编译目标，以及整个生态都会承认的项目级版本固定。单纯切换当前版本，本来就是 &lt;code&gt;mise&lt;/code&gt; 擅长的事。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;社区已经形成共识。&lt;/strong&gt; “这门语言怎么安装”应该有一个普通到近乎统一的答案。如果同时流行三套做法，就还谈不上主权工具。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;升级时能守住兼容契约。&lt;/strong&gt; 想把一层长期交给某个工具，它就不能在日常升级后频繁弄坏旧项目。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;rustup&lt;/code&gt; 四项都满足：它属于 Rust 项目，能管理工具链、编译目标和发布通道，是标准安装方式，而且多年来契约稳定。Go 官方的 &lt;code&gt;dl&lt;/code&gt; 安装器正好可以拿来对照。它能下载特定 Go 版本，但除此之外做得不多，也没有成为社区公认的版本管理入口。它虽然官方，却没达到第 2、3 条，所以我仍然把它看作较弱的方案。&lt;/p&gt;
&lt;p&gt;&amp;lt;figure class=&quot;my-6&quot;&amp;gt;
&amp;lt;svg viewBox=&quot;0 0 600 300&quot; role=&quot;img&quot; aria-labelledby=&quot;diagram-sov-title&quot; style=&quot;width:100%;height:auto;color:inherit&quot;&amp;gt;
&amp;lt;title id=&quot;diagram-sov-title&quot;&amp;gt;判断一门语言是否拥有强大的官方主权工具&amp;lt;/title&amp;gt;
&amp;lt;g font-family=&quot;ui-sans-serif, system-ui, sans-serif&quot;&amp;gt;
&amp;lt;!-- root --&amp;gt;
&amp;lt;rect x=&quot;140&quot; y=&quot;20&quot; width=&quot;320&quot; height=&quot;58&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.2&quot;/&amp;gt;
&amp;lt;text x=&quot;300&quot; y=&quot;45&quot; font-size=&quot;13.5&quot; font-weight=&quot;600&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot;&amp;gt;有强大的官方主权工具？&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;300&quot; y=&quot;65&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.55&quot;&amp;gt;语言项目所属 · 管理工具链 · 社区共识 · 契约稳定&amp;lt;/text&amp;gt;
&amp;lt;!-- branch labels --&amp;gt;
&amp;lt;text x=&quot;150&quot; y=&quot;108&quot; font-size=&quot;12&quot; font-weight=&quot;700&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--primary)&quot;&amp;gt;有&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;450&quot; y=&quot;108&quot; font-size=&quot;12&quot; font-weight=&quot;700&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--primary)&quot;&amp;gt;没有 / 太弱&amp;lt;/text&amp;gt;
&amp;lt;!-- connectors --&amp;gt;
&amp;lt;path d=&quot;M260 78 L150 120&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.3&quot; fill=&quot;none&quot;/&amp;gt;
&amp;lt;path d=&quot;M340 78 L450 120&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.3&quot; fill=&quot;none&quot;/&amp;gt;
&amp;lt;!-- leaf left --&amp;gt;
&amp;lt;rect x=&quot;30&quot; y=&quot;125&quot; width=&quot;240&quot; height=&quot;150&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;150&quot; y=&quot;152&quot; font-size=&quot;13&quot; font-weight=&quot;600&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot;&amp;gt;交给主权工具&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;150&quot; y=&quot;176&quot; font-size=&quot;12&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;由它管理 Layer 1&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;150&quot; y=&quot;210&quot; font-size=&quot;12.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.7&quot;&amp;gt;rustup  →  Rust&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;150&quot; y=&quot;232&quot; font-size=&quot;12.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.7&quot;&amp;gt;Xcode  →  Swift&amp;lt;/text&amp;gt;
&amp;lt;!-- leaf right --&amp;gt;
&amp;lt;rect x=&quot;330&quot; y=&quot;125&quot; width=&quot;240&quot; height=&quot;150&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;450&quot; y=&quot;152&quot; font-size=&quot;13&quot; font-weight=&quot;600&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot;&amp;gt;由 mise 补位&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;450&quot; y=&quot;176&quot; font-size=&quot;12&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;由 mise 管理 Layer 1&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;450&quot; y=&quot;208&quot; font-size=&quot;12.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.7&quot;&amp;gt;Python · Node · Java&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;450&quot; y=&quot;230&quot; font-size=&quot;12.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.7&quot;&amp;gt;Ruby · Go（较弱）&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;/svg&amp;gt;
&amp;lt;/figure&amp;gt;&lt;/p&gt;
&lt;h2&gt;放到我使用的语言里&lt;/h2&gt;
&lt;p&gt;下面所有判断都只针对 Layer 1，也就是运行时版本。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Python&lt;/strong&gt;：没有主权工具。&lt;code&gt;pyenv&lt;/code&gt;、&lt;code&gt;mise&lt;/code&gt;、&lt;code&gt;uv&lt;/code&gt;、系统 Python 等方案同时存在。因此我让 &lt;code&gt;mise&lt;/code&gt; 管 Layer 1，&lt;code&gt;uv&lt;/code&gt; 只管 Layer 2，具体配置见 &lt;a href=&quot;/zh/posts/python/&quot;&gt;Python 那篇文章&lt;/a&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rust&lt;/strong&gt;：&lt;code&gt;rustup&lt;/code&gt; 就是主权工具。我的机器由它安装 Rust，不交给 &lt;code&gt;mise&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Node.js&lt;/strong&gt;：&lt;code&gt;nvm&lt;/code&gt;、&lt;code&gt;fnm&lt;/code&gt;、&lt;code&gt;volta&lt;/code&gt;、&lt;code&gt;mise&lt;/code&gt; 彼此竞争，没有一个官方答案。我用 &lt;code&gt;mise&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Java&lt;/strong&gt;：没有主权工具，而且 Java 还有多个发行版。我用 &lt;code&gt;mise&lt;/code&gt;，默认选 Temurin；这是一个相对中性的选择，不必每个项目都重新争论发行版。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Swift / iOS&lt;/strong&gt;：Xcode 由 Apple 发布，同时管理工具链、SDK 和构建系统。在 macOS 上绕过它安装 Swift 基本是在和平台较劲，所以我完全交给 Xcode。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Go&lt;/strong&gt;：官方方案能力不足，不适合接管这一层。我和许多用户一样，把 Go 交给 &lt;code&gt;mise&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ruby&lt;/strong&gt;：&lt;code&gt;rbenv&lt;/code&gt;、&lt;code&gt;rvm&lt;/code&gt;、&lt;code&gt;chruby&lt;/code&gt;、&lt;code&gt;mise&lt;/code&gt; 都有人用，没有主权工具。需要 Ruby 时，我用 &lt;code&gt;mise&lt;/code&gt; 管版本。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;code&gt;mise&lt;/code&gt; 也可以只做一层代理&lt;/h2&gt;
&lt;p&gt;把 Rust 交给 &lt;code&gt;rustup&lt;/code&gt;，不等于每个操作都必须直接输入 &lt;code&gt;rustup&lt;/code&gt;。项目仍然可以在 &lt;code&gt;mise.toml&lt;/code&gt; 里声明 Rust：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# mise.toml
[tools]
rust = &quot;1.78&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时 &lt;code&gt;mise&lt;/code&gt; 会调用底层的 &lt;code&gt;rustup&lt;/code&gt;，并没有重新实现 Rust 工具链管理。我把这种用法叫作 &lt;strong&gt;&lt;code&gt;mise&lt;/code&gt; 代理模式&lt;/strong&gt;：对外提供统一入口的是 &lt;code&gt;mise&lt;/code&gt;，真正拥有工具链、作为事实来源的仍是 &lt;code&gt;rustup&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;因此有两种都合理的配置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;直接使用 &lt;code&gt;rustup&lt;/code&gt;&lt;/strong&gt;：纯 Rust 项目没有需要统一的多语言环境，直接操作 &lt;code&gt;rustup&lt;/code&gt; 最省事。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让 &lt;code&gt;mise&lt;/code&gt; 作为项目入口&lt;/strong&gt;：多语言项目已经在 &lt;code&gt;mise.toml&lt;/code&gt; 里固定 Python 和 Node 时，可以再加上 &lt;code&gt;rust = &quot;1.78&quot;&lt;/code&gt;。这样一条 &lt;code&gt;mise install&lt;/code&gt; 就能按同一份声明准备整个项目，Rust 部分仍委托给 &lt;code&gt;rustup&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真正的坑，是在同一个项目里混用两套入口，最后两份文件都说不清哪个才算数。对外入口可以选 &lt;code&gt;mise&lt;/code&gt; 或 &lt;code&gt;rustup&lt;/code&gt;，但 Rust 工具链的所有权仍然只能归 &lt;code&gt;rustup&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;这些判断以后可能会变&lt;/h2&gt;
&lt;p&gt;我第一次看 Zig 时也用了同一套标准。当时它还没有一个社区形成共识的强官方管理器，所以交给 &lt;code&gt;mise&lt;/code&gt;，或者简单手动安装，都算合理。Mojo 也属于这种仍在早期的生态。Haskell 是个值得注意的边界案例：GHCup 已经足够强，交给它很合理。Lua 则没有共识工具，可以由 &lt;code&gt;mise&lt;/code&gt; 补位。&lt;/p&gt;
&lt;p&gt;这些只是当前判断，不是永久分配。如果以后 Python 推出强大的官方管理器，社区也真正迁移过去，我会把 Python 从 &lt;code&gt;mise&lt;/code&gt; 中移出，像今天对待 &lt;code&gt;rustup&lt;/code&gt; 一样让位。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/zh/posts/manifesto/&quot;&gt;系列第一篇&lt;/a&gt;提出，每一类资源只能有一个所有者；这里的标准，是用来决定运行时版本该归谁。&lt;a href=&quot;/zh/posts/polyglot/&quot;&gt;下一篇&lt;/a&gt;会把这些选择落到具体技术栈，继续写每种语言的陷阱、锁文件和 &lt;code&gt;gitignore&lt;/code&gt; 配置。&lt;/p&gt;
</content:encoded></item><item><title>让 mise 和 uv 使用同一个 Python</title><link>https://www.shiinayane.com/zh/posts/python/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/python/</guid><description>uv 安装了一个 mise 完全不知道的 Python。把 python-preference 设为 only-system 后，运行时重新只由一个工具管理，但旧项目也需要随之处理。</description><pubDate>Sat, 30 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我是在两个目录里运行同一条命令时发现问题的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/project &amp;amp;&amp;amp; uv run python --version
Python 3.12.7
$ cd ~ &amp;amp;&amp;amp; uv run python --version
Python 3.13.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;项目目录里出现不同版本很正常，毕竟每个项目都可以固定自己的运行时。奇怪的是第二个结果：我不记得自己安装过 Python 3.13.1，而我用来统一管理运行时版本的 &lt;code&gt;mise&lt;/code&gt; 也不知道它。按照这个系列&lt;a href=&quot;/zh/posts/manifesto/&quot;&gt;第一篇文章&lt;/a&gt;里的约定，机器上有哪些 Python 本来应该只由 &lt;code&gt;mise&lt;/code&gt; 决定。&lt;/p&gt;
&lt;h2&gt;找出另一个 Python&lt;/h2&gt;
&lt;p&gt;我先确认两个上下文里的 &lt;code&gt;python&lt;/code&gt; 到底指向哪里。Shell 和 &lt;code&gt;uv run&lt;/code&gt; 用的并不是同一个可执行文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ which python
/Users/me/.local/share/mise/installs/python/3.12.7/bin/python
$ uv run python -c &apos;import sys; print(sys.executable)&apos;
/Users/me/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/bin/python3.13
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Shell 找到的是 &lt;code&gt;mise&lt;/code&gt; 安装并放到 &lt;code&gt;PATH&lt;/code&gt; 上的解释器；离开固定版本的项目后，&lt;code&gt;uv run&lt;/code&gt; 却选中了 &lt;code&gt;~/.local/share/uv/python&lt;/code&gt; 下的解释器。这个目录并不是我主动创建的。&lt;/p&gt;
&lt;p&gt;再让 &lt;code&gt;uv&lt;/code&gt; 列出它眼中的已安装版本，来源就很清楚了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ uv python list --only-installed
cpython-3.13.1-macos-aarch64-none    /Users/me/.local/share/uv/python/cpython-3.13.1-.../bin/python3.13
cpython-3.12.7-macos-aarch64-none    /Users/me/.local/share/mise/installs/python/3.12.7/bin/python3.12
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;机器上确实有两套独立的 CPython。用 &lt;code&gt;readlink -f&lt;/code&gt; 追到真实文件后也能确认，它们并不是同一个二进制文件的两个路径：一套归 &lt;code&gt;mise&lt;/code&gt; 管，另一套由 &lt;code&gt;uv&lt;/code&gt; 下载并保存在自己的目录里。&lt;/p&gt;
&lt;p&gt;这不是 &lt;code&gt;uv&lt;/code&gt; 的 bug，而是默认行为。它的 &lt;code&gt;python-preference&lt;/code&gt; 默认值是 &lt;code&gt;managed&lt;/code&gt;：优先使用 uv 管理的 Python；如果现有解释器都不满足项目的 &lt;code&gt;requires-python&lt;/code&gt;，就自动下载一套。若只用 &lt;code&gt;uv&lt;/code&gt; 管理运行时和依赖，这个默认值很省事；但在我的配置里，Python 版本应该只由 &lt;code&gt;mise&lt;/code&gt; 决定，于是两个工具的职责重叠了。&lt;/p&gt;
&lt;h2&gt;解释器、虚拟环境和缓存不是一回事&lt;/h2&gt;
&lt;p&gt;修改配置前，我先把三个看起来都像“藏在隐藏目录里的 Python 文件”的东西分开。它们的用途、生命周期和负责人其实都不一样。&lt;/p&gt;
&lt;p&gt;&amp;lt;figure class=&quot;my-6&quot;&amp;gt;
&amp;lt;svg viewBox=&quot;0 0 640 300&quot; role=&quot;img&quot; aria-labelledby=&quot;diagram-factory-title&quot; style=&quot;width:100%;height:auto;color:inherit&quot;&amp;gt;
&amp;lt;title id=&quot;diagram-factory-title&quot;&amp;gt;蓝图、样品与共享仓库：mise 安装、.venv 和 uv 缓存&amp;lt;/title&amp;gt;
&amp;lt;g font-family=&quot;ui-sans-serif, system-ui, sans-serif&quot;&amp;gt;
&amp;lt;!-- Blueprint --&amp;gt;
&amp;lt;rect x=&quot;10&quot; y=&quot;60&quot; width=&quot;180&quot; height=&quot;180&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;100&quot; y=&quot;40&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--primary)&quot;&amp;gt;蓝图&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;100&quot; y=&quot;92&quot; font-size=&quot;13&quot; font-weight=&quot;600&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot;&amp;gt;mise install&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;100&quot; y=&quot;116&quot; font-size=&quot;12&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;python 3.14.0&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;100&quot; y=&quot;150&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.5&quot;&amp;gt;每个版本号对应&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;100&quot; y=&quot;168&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.5&quot;&amp;gt;一个完整解释器&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;100&quot; y=&quot;200&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.5&quot;&amp;gt;多个版本可以&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;100&quot; y=&quot;218&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.5&quot;&amp;gt;同时存在&amp;lt;/text&amp;gt;
&amp;lt;!-- Sample unit --&amp;gt;
&amp;lt;rect x=&quot;230&quot; y=&quot;60&quot; width=&quot;180&quot; height=&quot;180&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;320&quot; y=&quot;40&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--primary)&quot;&amp;gt;样品&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;320&quot; y=&quot;92&quot; font-size=&quot;13&quot; font-weight=&quot;600&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot;&amp;gt;project/.venv&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;320&quot; y=&quot;124&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;bin/python → 符号链接&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;320&quot; y=&quot;142&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;指回蓝图&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;320&quot; y=&quot;186&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;site-packages =&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;320&quot; y=&quot;204&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;唯一的项目私有部分&amp;lt;/text&amp;gt;
&amp;lt;!-- Shared library --&amp;gt;
&amp;lt;rect x=&quot;450&quot; y=&quot;60&quot; width=&quot;180&quot; height=&quot;180&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;540&quot; y=&quot;40&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--primary)&quot;&amp;gt;共享仓库&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;540&quot; y=&quot;92&quot; font-size=&quot;13&quot; font-weight=&quot;600&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot;&amp;gt;uv cache&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;540&quot; y=&quot;124&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;按内容寻址&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;540&quot; y=&quot;160&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;每个包版本只存一份&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;540&quot; y=&quot;178&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;供所有项目&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;540&quot; y=&quot;196&quot; font-size=&quot;11.5&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;共享&amp;lt;/text&amp;gt;
&amp;lt;!-- arrows --&amp;gt;
&amp;lt;text x=&quot;210&quot; y=&quot;135&quot; font-size=&quot;18&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.4&quot;&amp;gt;←&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;210&quot; y=&quot;152&quot; font-size=&quot;10&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.5&quot;&amp;gt;符号链接&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;430&quot; y=&quot;135&quot; font-size=&quot;18&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.4&quot;&amp;gt;→&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;430&quot; y=&quot;152&quot; font-size=&quot;10&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.5&quot;&amp;gt;链接进来&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;/svg&amp;gt;
&amp;lt;/figure&amp;gt;&lt;/p&gt;
&lt;p&gt;我把 &lt;code&gt;mise install python@3.14&lt;/code&gt; 安装的版本理解成一张&lt;strong&gt;蓝图&lt;/strong&gt;：每个版本号对应一个完整的解释器。&lt;code&gt;mise&lt;/code&gt; 的安装目录可以同时容纳很多版本，因此 3.12.7 和 3.14.0 只是两张互不干扰的蓝图。&lt;/p&gt;
&lt;p&gt;项目里的 &lt;strong&gt;&lt;code&gt;.venv&lt;/code&gt;&lt;/strong&gt; 则是从蓝图做出来的&lt;strong&gt;样品&lt;/strong&gt;。虚拟环境的大部分内容是符号链接，&lt;code&gt;bin/python&lt;/code&gt; 会指回原来的解释器，并不再复制一套 Python；真正由项目独占的是 &lt;code&gt;site-packages&lt;/code&gt;。因此 &lt;code&gt;.venv&lt;/code&gt; 创建得又快又小，但删除 &lt;code&gt;mise&lt;/code&gt; 管理的某个 Python 后，所有指向它的虚拟环境也会一起坏掉。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;uv 缓存&lt;/strong&gt;更像共享仓库。它按内容寻址，同一个包版本只保存一次，再链接到各个项目的 &lt;code&gt;site-packages&lt;/code&gt;，而不是反复复制。十个项目若依赖同一版本的 &lt;code&gt;numpy&lt;/code&gt;，可以共同引用缓存里的那一份。&lt;code&gt;uv&lt;/code&gt; 之所以快，很大一部分原因就是它经常只需建立链接，不必重复下载和解包。&lt;/p&gt;
&lt;p&gt;按照这个分工，&lt;code&gt;mise&lt;/code&gt; 提供解释器；&lt;code&gt;uv&lt;/code&gt; 用这些解释器建立项目环境，再从包缓存填充依赖。&lt;code&gt;uv&lt;/code&gt; 没有必要再额外安装一套解释器。&lt;/p&gt;
&lt;h2&gt;只允许 uv 使用系统 Python&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;uv&lt;/code&gt; 会读取 &lt;code&gt;~/.config/uv/uv.toml&lt;/code&gt; 里的全局配置。我加入了这一行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ~/.config/uv/uv.toml
python-preference = &quot;only-system&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;python-preference&lt;/code&gt; 一共有四种取值：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;only-managed&lt;/code&gt;：只使用 uv 管理的 Python，忽略系统解释器。隔离最彻底，也最不适合由 &lt;code&gt;mise&lt;/code&gt; 管理运行时的结构。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;managed&lt;/code&gt;：默认值。优先使用 uv 管理的 Python，其次才用系统 Python；两边都不满足 &lt;code&gt;requires-python&lt;/code&gt; 时，自动下载 uv 管理的解释器。我的意外安装就来自这里。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;system&lt;/code&gt;：优先使用 &lt;code&gt;PATH&lt;/code&gt; 上已有的 Python，但找不到合适版本时仍可下载 uv 管理的解释器。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;only-system&lt;/code&gt;：只使用系统 Python，包括 &lt;code&gt;mise&lt;/code&gt; 放到 &lt;code&gt;PATH&lt;/code&gt; 上的版本，而且绝不自动下载。没有满足要求的解释器时，命令会报错。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我需要的正是最后一种失败方式：缺少某个版本时，问题会一直摆在眼前，直到我明确地用 &lt;code&gt;mise&lt;/code&gt; 安装它。如果只是想优先使用 &lt;code&gt;mise&lt;/code&gt;、同时保留 &lt;code&gt;uv&lt;/code&gt; 的自动兜底，可以选较宽松的 &lt;code&gt;system&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;改完配置后，我删除了 &lt;code&gt;uv&lt;/code&gt; 安装的 Python 3.13.1，并重建受影响的环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ uv python uninstall 3.13.1
$ cd ~/project &amp;amp;&amp;amp; rm -rf .venv &amp;amp;&amp;amp; uv sync
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此后，&lt;code&gt;uv run python&lt;/code&gt; 和 Shell 里的 &lt;code&gt;python&lt;/code&gt; 会落到同一个由 &lt;code&gt;mise&lt;/code&gt; 管理的解释器上。&lt;/p&gt;
&lt;h2&gt;改完全局规则，旧项目可能会报错&lt;/h2&gt;
&lt;p&gt;全局选择规则改变后，以前按旧规则建立的环境也会受到影响。我有一些旧项目，&lt;code&gt;mise.toml&lt;/code&gt; 仍要求某个已经从 &lt;code&gt;mise&lt;/code&gt; 删除的 Python 版本。过去运行 &lt;code&gt;uv sync&lt;/code&gt; 时，&lt;code&gt;uv&lt;/code&gt; 可以悄悄下载缺失版本后继续；换成 &lt;code&gt;only-system&lt;/code&gt; 后，它会停下来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;error: No interpreter found for Python 3.11 in system path
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条错误的实际含义很直接：项目要求 Python 3.11，但机器上没有。修复也应该明确完成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ mise install python@3.11   # provision the blueprint, deliberately
$ uv sync                    # now succeeds, using mise&apos;s Python
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在重新打开旧项目时，我会：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;查看 &lt;code&gt;pyproject.toml&lt;/code&gt; 的 &lt;code&gt;requires-python&lt;/code&gt;，以及 &lt;code&gt;mise.toml&lt;/code&gt; 固定的版本。&lt;/li&gt;
&lt;li&gt;如果缺少对应解释器，运行 &lt;code&gt;mise install&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;运行 &lt;code&gt;rm -rf .venv &amp;amp;&amp;amp; uv sync&lt;/code&gt;，让环境重新指向已安装的解释器。&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;uv run python --version&lt;/code&gt; 确认最终版本。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这确实比默认配置多了一次主动运行 &lt;code&gt;mise install&lt;/code&gt; 的步骤。默认行为可能会静默补齐缺失版本，而我宁愿看到不一致再决定怎么处理；严格并不是没有成本。&lt;/p&gt;
&lt;p&gt;我还会像&lt;a href=&quot;/zh/posts/dotfiles/&quot;&gt;dotfiles 那篇文章&lt;/a&gt;里处理其他配置一样，把 &lt;code&gt;uv.toml&lt;/code&gt; 放进 chezmoi 的源仓库，让这条规则跟着我去下一台机器。&lt;/p&gt;
&lt;h2&gt;小脚本不必建完整项目&lt;/h2&gt;
&lt;p&gt;如果只是运行一个依赖 &lt;code&gt;httpx&lt;/code&gt; 的 40 行脚本，为它准备 &lt;code&gt;pyproject.toml&lt;/code&gt;、&lt;code&gt;.venv&lt;/code&gt; 和锁文件就太重了。&lt;code&gt;uv&lt;/code&gt; 有更轻量的方式，而且同样遵守 &lt;code&gt;only-system&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;临时运行时，&lt;code&gt;--with&lt;/code&gt; 会把依赖放进一次性的环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ uv run --with httpx --with rich script.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;准备长期保留的脚本可以使用 PEP 723，把 Python 版本要求和依赖直接写进文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /// script
# requires-python = &quot;&amp;gt;=3.12&quot;
# dependencies = [&quot;httpx&quot;, &quot;rich&quot;]
# ///
import httpx
from rich import print
print(httpx.get(&quot;https://example.com&quot;).status_code)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行 &lt;code&gt;uv run script.py&lt;/code&gt; 时，&lt;code&gt;uv&lt;/code&gt; 会读取这些元数据，从缓存组装环境并运行，不需要项目级 &lt;code&gt;.venv&lt;/code&gt;。再配上 &lt;code&gt;uv&lt;/code&gt; shebang、&lt;code&gt;chmod +x&lt;/code&gt;，并把文件放进 &lt;code&gt;~/.local/bin&lt;/code&gt;，这个脚本就能像其他 &lt;code&gt;PATH&lt;/code&gt; 命令一样使用，依赖仍记录在文件内部。这比我以前用 &lt;code&gt;pip install&lt;/code&gt; 把个人工具塞进全局解释器、过一阵又忘掉来源要干净得多。&lt;/p&gt;
&lt;p&gt;真正的 Python 命令行工具应该待在各自隔离的工具环境里。&lt;code&gt;uv tool install&lt;/code&gt; 用于持续安装，&lt;code&gt;uvx&lt;/code&gt; 则负责单次运行；像 &lt;code&gt;ruff&lt;/code&gt;、&lt;code&gt;httpie&lt;/code&gt; 这样的工具都比全局 &lt;code&gt;pip install&lt;/code&gt; 更适合放在这里。&lt;/p&gt;
&lt;p&gt;如果根本不用 &lt;code&gt;mise&lt;/code&gt;，让 &lt;code&gt;uv&lt;/code&gt; 同时管理 Python 和依赖完全说得通，默认的 &lt;code&gt;managed&lt;/code&gt; 也可能就是正确选择。我的问题只是两个工具同时决定运行时版本。这个系列的&lt;a href=&quot;/zh/posts/sovereignty/&quot;&gt;下一篇文章&lt;/a&gt;会继续谈什么时候应该把这一层交给官方工具。&lt;/p&gt;
</content:encoded></item><item><title>Brewfile 的妥协：用最终一致性管理 Mac 应用</title><link>https://www.shiinayane.com/zh/posts/apps/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/apps/</guid><description>不是每一层都值得严格地声明式管理。只要偏差始终可见，应用这一层允许最终一致就够了。</description><pubDate>Sat, 30 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我的 Brewfile 经常和 Mac 上实际安装的应用差一点。我觉得没问题。&lt;/p&gt;
&lt;p&gt;这比 &lt;a href=&quot;/zh/posts/python/&quot;&gt;Python 那一篇&lt;/a&gt;的做法宽松得多。Python 环境由一个工具严格控制；到了 &lt;a href=&quot;/zh/posts/dotfiles/&quot;&gt;dotfiles 那一篇&lt;/a&gt;，散落在外的配置文件也需要逐一交代。但应用出问题的方式不一样，我没必要照搬同一套强度。&lt;/p&gt;
&lt;h2&gt;为什么这里可以暂时不一致&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;第 3 层——项目依赖&lt;/strong&gt;需要严格一致。lockfile 写的是一个版本，开发机上实际用的却是另一个版本，项目可能直接构建失败，也可能只在本机正常、到了 CI 就表现不同。问题会立刻出现，而且经常要让别人一起承担，所以锁定精确版本、保证可复现安装完全值得。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第 0 层——系统&lt;/strong&gt;放的是 Homebrew 安装的应用和命令行工具。假设我今天运行了 &lt;code&gt;brew install&lt;/code&gt;，却忘记把它写进 Brewfile，眼前什么都不会坏，这台机器照样能用。通常要等到下一次迁移，我根据 Brewfile 恢复环境时，才会发现少了一个工具。到时候重新安装，再补上忘掉的那一行就行。&lt;/p&gt;
&lt;p&gt;这个代价确实存在，但出现得晚，而且通常很小。所以这一层可以让日常状态暂时有偏差，再定期对账：用&lt;strong&gt;最终一致性&lt;/strong&gt;，而不是管理项目依赖时的严格一致。&lt;/p&gt;
&lt;h2&gt;安装渠道怎么选&lt;/h2&gt;
&lt;p&gt;在 macOS 上，我按下面的顺序选安装渠道：&lt;/p&gt;
&lt;p&gt;&amp;lt;figure class=&quot;my-6&quot;&amp;gt;
&amp;lt;svg viewBox=&quot;0 0 600 120&quot; role=&quot;img&quot; aria-labelledby=&quot;diagram-channels-title&quot; style=&quot;width:100%;height:auto;color:inherit&quot;&amp;gt;
&amp;lt;title id=&quot;diagram-channels-title&quot;&amp;gt;Install channel priority: brew, then mas, then dmg&amp;lt;/title&amp;gt;
&amp;lt;g font-family=&quot;ui-sans-serif, system-ui, sans-serif&quot;&amp;gt;
&amp;lt;rect x=&quot;20&quot; y=&quot;35&quot; width=&quot;150&quot; height=&quot;50&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.2&quot;/&amp;gt;
&amp;lt;text x=&quot;95&quot; y=&quot;58&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--primary)&quot;&amp;gt;brew&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;95&quot; y=&quot;75&quot; font-size=&quot;11&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;formula / cask&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;200&quot; y=&quot;65&quot; font-size=&quot;16&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.4&quot;&amp;gt;→&amp;lt;/text&amp;gt;
&amp;lt;rect x=&quot;225&quot; y=&quot;35&quot; width=&quot;150&quot; height=&quot;50&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;300&quot; y=&quot;58&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot;&amp;gt;mas&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;300&quot; y=&quot;75&quot; font-size=&quot;11&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;App Store&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;405&quot; y=&quot;65&quot; font-size=&quot;16&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.4&quot;&amp;gt;→&amp;lt;/text&amp;gt;
&amp;lt;rect x=&quot;430&quot; y=&quot;35&quot; width=&quot;150&quot; height=&quot;50&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.04&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.12&quot;/&amp;gt;
&amp;lt;text x=&quot;505&quot; y=&quot;58&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot;&amp;gt;dmg&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;505&quot; y=&quot;75&quot; font-size=&quot;11&quot; text-anchor=&quot;middle&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;last resort&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;/svg&amp;gt;
&amp;lt;/figure&amp;gt;&lt;/p&gt;
&lt;p&gt;我会先找 &lt;strong&gt;Homebrew&lt;/strong&gt;。命令行工具用 formula，图形应用用 cask。这个环境的第 0 层本来就归 Homebrew 管，而且两种 &lt;code&gt;brew&lt;/code&gt; 安装都很容易写进 Brewfile。&lt;/p&gt;
&lt;p&gt;其次才是通过 &lt;code&gt;mas-cli&lt;/code&gt; 使用 &lt;strong&gt;Mac App Store&lt;/strong&gt;。如果一个应用依赖 iCloud 同步、家人共享或 App Store 收据，或者它只在商店分发，我会选择 &lt;code&gt;mas&lt;/code&gt;。其他应用用 cask 更省事：更新时和其余软件共用同一条 &lt;code&gt;brew&lt;/code&gt; 命令，也不依赖已经登录的 App Store 账号。&lt;/p&gt;
&lt;p&gt;厂商网站提供的 &lt;strong&gt;&lt;code&gt;.dmg&lt;/code&gt; 放在最后&lt;/strong&gt;。这种安装没法写进 Brewfile，我会另外维护一份 &lt;code&gt;manual-installs.md&lt;/code&gt;，记下应用名称和下载来源。否则 Brewfile 没覆盖到什么，就只能靠自己记住了。&lt;/p&gt;
&lt;h2&gt;用一个文件记录目标状态&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;mas-cli&lt;/code&gt; 的好处，是能用 &lt;code&gt;mas&lt;/code&gt; 条目让 App Store 应用和 formula、cask 共用同一份 Brewfile：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Brewfile
tap &quot;homebrew/bundle&quot;

brew &quot;ripgrep&quot;
brew &quot;mas&quot;

cask &quot;visual-studio-code&quot;
cask &quot;rectangle&quot;

# App Store apps, by their numeric ID (mas list to find them)
mas &quot;Things 3&quot;, id: 904280696
mas &quot;Xcode&quot;, id: 497799835
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样一来，&lt;code&gt;brew bundle&lt;/code&gt; 一次就能安装 formula、cask 和 App Store 应用。这份文件按照&lt;a href=&quot;/zh/posts/manifesto/&quot;&gt;系列第一篇&lt;/a&gt;的归属原则，统一声明这台机器应该有哪些软件。它可以暂时落后于机器的实际状态，但目标状态仍然只有一份。&lt;/p&gt;
&lt;h2&gt;我怎么定期对账&lt;/h2&gt;
&lt;p&gt;说是最终一致，就得真的有收敛的动作。我的起点是一个只读健康检查函数 &lt;code&gt;brewdiff&lt;/code&gt;，同时显示两个方向的偏差：已经安装但没有声明，以及已经声明但没有安装。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 30-functions.zsh — show drift between the Brewfile and the machine
brewdiff() {
  local brewfile=&quot;${HOMEBREW_BUNDLE_FILE:-$HOME/.config/homebrew/Brewfile}&quot;
  echo &quot;== Installed but NOT in Brewfile (undeclared) ==&quot;
  brew bundle cleanup --file=&quot;$brewfile&quot; 2&amp;gt;/dev/null \
    | grep -E &apos;^(Would uninstall|brew|cask|mas)&apos; || echo &quot;  (none)&quot;
  echo
  echo &quot;== In Brewfile but NOT installed (missing) ==&quot;
  brew bundle check --file=&quot;$brewfile&quot; --verbose 2&amp;gt;/dev/null \
    | grep -v &apos;^The Brewfile&apos; || echo &quot;  (all installed)&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个函数只报告，不会修改机器。我可以逐项检查未声明的内容：究竟是打算长期保留、只是忘记写进去的工具，还是应该删掉的一次性实验。&lt;/p&gt;
&lt;p&gt;对于普通 formula，我用 &lt;code&gt;brewadd&lt;/code&gt; 把最常见的操作再缩短一点：安装成功后，顺手把同一个包追加到 Brewfile。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# install AND declare in one step
brewadd() {
  local brewfile=&quot;${HOMEBREW_BUNDLE_FILE:-$HOME/.config/homebrew/Brewfile}&quot;
  brew install &quot;$@&quot; || return 1
  for pkg in &quot;$@&quot;; do
    grep -q &quot;\&quot;$pkg\&quot;&quot; &quot;$brewfile&quot; || echo &quot;brew \&quot;$pkg\&quot;&quot; &amp;gt;&amp;gt; &quot;$brewfile&quot;
  done
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我还需要提醒，不然写好了对账命令却一直不运行，和没有也差不多。每次启动 shell 时，下面的函数会检查时间戳文件；超过 30 天，就提醒我运行一次：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# remind me to reconcile if it has been &amp;gt; 30 days
_brewdiff_reminder() {
  local stamp=&quot;$HOME/.cache/brewdiff-last&quot;
  if [[ ! -f &quot;$stamp&quot; ]] || \
     [[ $(find &quot;$stamp&quot; -mtime +30 2&amp;gt;/dev/null) ]]; then
    print -P &quot;%F{yellow}brewdiff:%f it&apos;s been a while — run &apos;brewdiff&apos; to reconcile&quot;
  fi
}
_brewdiff_reminder
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行 &lt;code&gt;brewdiff&lt;/code&gt; 后，还会在结尾 &lt;code&gt;touch&lt;/code&gt; 这个时间戳，重新开始计时。这套东西故意做得很小；再麻烦一点，每月检查就很容易被我拖到下个月。&lt;/p&gt;
&lt;p&gt;Brewfile 的应用方式沿用了 &lt;a href=&quot;/zh/posts/dotfiles/&quot;&gt;dotfiles 那一篇&lt;/a&gt;里的 chezmoi 做法。&lt;code&gt;run_onchange_&lt;/code&gt; 脚本会在新机器上安装软件，也会在 Brewfile 内容变化后重新运行 &lt;code&gt;brew bundle&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# run_onchange_brew-bundle.sh.tmpl
#!/bin/sh
# Brewfile hash: {{ include &quot;dot_config/homebrew/Brewfile&quot; | sha256sum }}
brew bundle --file=&quot;$HOME/.config/homebrew/Brewfile&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正触发脚本的是注释里的哈希。Brewfile 的校验和一变，这一行也会变化，chezmoi 因此知道编辑后需要重新执行命令。&lt;/p&gt;
&lt;h2&gt;让 AI 帮我审计&lt;/h2&gt;
&lt;p&gt;每月检查时，我有时会把 &lt;code&gt;brewdiff&lt;/code&gt; 的输出交给 AI agent。它要做的是解释每个未声明的包是什么，再给出保留或删除的建议和理由，但不会直接编辑 Brewfile。&lt;/p&gt;
&lt;p&gt;例如，“&lt;code&gt;pngquant&lt;/code&gt; 是一个图片压缩工具，看起来像一次性安装，可以考虑删除”这种回答能省掉我查资料的时间，最后的决定仍然清楚可见。如果让 agent 直接改文件，就多了第二个可以修改事实来源的角色，Brewfile 也不再只表达我的意图。这里适合交出去的是判断所需的整理工作，不是修改权。&lt;/p&gt;
&lt;h2&gt;不交给自动化的命令&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;brew bundle cleanup&lt;/code&gt;（包括 &lt;code&gt;--cleanup&lt;/code&gt; 参数）会卸载 Brewfile 里没有的所有内容。等声明已经完整时，它确实方便；但如果常用工具还有一半没写进去，就很危险。在 &lt;code&gt;brewdiff&lt;/code&gt; 只剩一份很短、而且每项我都理解的未声明清单之前，我不会加 &lt;code&gt;--force&lt;/code&gt; 运行它。&lt;/p&gt;
&lt;p&gt;这次每月对账，也是&lt;a href=&quot;/zh/posts/maintenance/&quot;&gt;维护篇&lt;/a&gt;里那套日常维护的一个例子：先让偏差可见，检查清楚，再按周期收敛。对 Mac 应用来说，这已经够用了。我不要求每次安装都立刻更新 Brewfile，只要求遗漏不会藏起来，而且之后修正起来很便宜。&lt;/p&gt;
</content:encoded></item><item><title>不用 ~/.zshrc 的 dotfiles：ZDOTDIR 与 chezmoi</title><link>https://www.shiinayane.com/zh/posts/dotfiles/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/dotfiles/</guid><description>用 ZDOTDIR 把 zsh 配置移出主目录，拆成模块，再交给 chezmoi 管理。</description><pubDate>Sat, 30 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我的 &lt;code&gt;~/.zshrc&lt;/code&gt; 用了很久以后，已经分不清哪些行是自己写的，哪些是安装器自动加的。&lt;/p&gt;
&lt;p&gt;很多安装脚本都会提示一句“已添加初始化到 &lt;code&gt;~/.zshrc&lt;/code&gt;”，然后那几行就一直留在那里。工具删了，初始化代码不一定会跟着消失。最后文件虽然还能工作，但基本没人敢动。&lt;/p&gt;
&lt;p&gt;我现在只在主目录保留一个 &lt;code&gt;~/.zshenv&lt;/code&gt;，其他 zsh 配置全部放到 &lt;code&gt;~/.config/zsh/&lt;/code&gt;，再用 chezmoi 管理。这样主目录由谁负责、磁盘上的状态有没有偏离声明，都比较容易看出来。这也是&lt;a href=&quot;/zh/posts/manifesto/&quot;&gt;系列第一篇&lt;/a&gt;里分层方案的一部分。&lt;/p&gt;
&lt;h2&gt;用 ZDOTDIR 移走配置&lt;/h2&gt;
&lt;p&gt;macOS 上的交互式 login shell 大致按这个顺序读取文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/etc/zshenv      →  ~/.zshenv      (always, every shell)
/etc/zprofile    →  ~/.zprofile    (login shells)
/etc/zshrc       →  ~/.zshrc       (interactive shells)
/etc/zlogin      →  ~/.zlogin      (login shells)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;~/.zshenv&lt;/code&gt; 每次启动 zsh 都会最先读取，也可以在这里设置 &lt;code&gt;ZDOTDIR&lt;/code&gt;。设置后，后续的 &lt;code&gt;.zprofile&lt;/code&gt;、&lt;code&gt;.zshrc&lt;/code&gt; 和 &lt;code&gt;.zlogin&lt;/code&gt; 都会从 &lt;code&gt;$ZDOTDIR&lt;/code&gt; 读取。&lt;/p&gt;
&lt;p&gt;所以我的 &lt;code&gt;~/.zshenv&lt;/code&gt; 只有一行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ~/.zshenv — the only zsh file allowed to live in $HOME
export ZDOTDIR=&quot;${XDG_CONFIG_HOME:-$HOME/.config}/zsh&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;figure class=&quot;my-6&quot;&amp;gt;
&amp;lt;svg viewBox=&quot;0 0 600 300&quot; role=&quot;img&quot; aria-labelledby=&quot;diagram-home-title-zh&quot; style=&quot;width:100%;height:auto;color:inherit&quot;&amp;gt;
&amp;lt;title id=&quot;diagram-home-title-zh&quot;&amp;gt;使用 ZDOTDIR 前后的主目录&amp;lt;/title&amp;gt;
&amp;lt;g font-family=&quot;ui-monospace, SFMono-Regular, Menlo, monospace&quot;&amp;gt;
&amp;lt;text x=&quot;20&quot; y=&quot;28&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; font-family=&quot;ui-sans-serif, system-ui, sans-serif&quot; fill=&quot;currentColor&quot;&amp;gt;之前&amp;lt;/text&amp;gt;
&amp;lt;rect x=&quot;20&quot; y=&quot;40&quot; width=&quot;250&quot; height=&quot;240&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;36&quot; y=&quot;66&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.85&quot;&amp;gt;~/&amp;lt;/text&amp;gt;
&amp;lt;g font-size=&quot;12.5&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;
&amp;lt;text x=&quot;48&quot; y=&quot;90&quot;&amp;gt;.zshenv&amp;lt;/text&amp;gt;&amp;lt;text x=&quot;48&quot; y=&quot;110&quot;&amp;gt;.zshrc&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;48&quot; y=&quot;130&quot;&amp;gt;.zprofile&amp;lt;/text&amp;gt;&amp;lt;text x=&quot;48&quot; y=&quot;150&quot;&amp;gt;.bash_profile&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;48&quot; y=&quot;170&quot;&amp;gt;.npmrc&amp;lt;/text&amp;gt;&amp;lt;text x=&quot;48&quot; y=&quot;190&quot;&amp;gt;.gitconfig&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;48&quot; y=&quot;210&quot;&amp;gt;.python_history&amp;lt;/text&amp;gt;&amp;lt;text x=&quot;48&quot; y=&quot;230&quot;&amp;gt;.zsh_history&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;48&quot; y=&quot;250&quot;&amp;gt;.cargo/  .rustup/  …&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;48&quot; y=&quot;270&quot; fill-opacity=&quot;0.4&quot;&amp;gt;(继续增长)&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;text x=&quot;330&quot; y=&quot;28&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; font-family=&quot;ui-sans-serif, system-ui, sans-serif&quot; fill=&quot;currentColor&quot;&amp;gt;之后（ZDOTDIR）&amp;lt;/text&amp;gt;
&amp;lt;rect x=&quot;330&quot; y=&quot;40&quot; width=&quot;250&quot; height=&quot;70&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;346&quot; y=&quot;66&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.85&quot;&amp;gt;~/&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;358&quot; y=&quot;90&quot; font-size=&quot;12.5&quot; fill=&quot;var(--primary)&quot;&amp;gt;.zshenv  → 设置 ZDOTDIR&amp;lt;/text&amp;gt;
&amp;lt;rect x=&quot;330&quot; y=&quot;125&quot; width=&quot;250&quot; height=&quot;155&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;346&quot; y=&quot;151&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.85&quot;&amp;gt;~/.config/zsh/&amp;lt;/text&amp;gt;
&amp;lt;g font-size=&quot;12.5&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;
&amp;lt;text x=&quot;358&quot; y=&quot;173&quot;&amp;gt;.zshrc&amp;lt;/text&amp;gt;&amp;lt;text x=&quot;358&quot; y=&quot;193&quot;&amp;gt;00-env.zsh&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;358&quot; y=&quot;213&quot;&amp;gt;20-aliases.zsh&amp;lt;/text&amp;gt;&amp;lt;text x=&quot;358&quot; y=&quot;233&quot;&amp;gt;30-functions.zsh&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;358&quot; y=&quot;253&quot;&amp;gt;35-tools.zsh  …&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;/svg&amp;gt;
&amp;lt;/figure&amp;gt;&lt;/p&gt;
&lt;p&gt;这是 zsh 官方支持的机制，不是什么软链接技巧。VS Code 的集成终端会读取 &lt;code&gt;~/.zshenv&lt;/code&gt;，所以也能正常使用。少数老工具会死认 &lt;code&gt;~/.zshrc&lt;/code&gt;，遇到时再单独判断要不要迁就它。&lt;/p&gt;
&lt;h2&gt;把一个长文件拆开&lt;/h2&gt;
&lt;p&gt;移动路径后，如果只是把原来的几百行原样搬过去，也没解决多少问题。我的 &lt;code&gt;.zshrc&lt;/code&gt; 只负责按顺序加载片段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ~/.config/zsh/.zshrc — loads every fragment in numeric order
for _file in &quot;${ZDOTDIR}&quot;/conf.d/*.zsh(N); do
  source &quot;$_file&quot;
done
unset _file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;(N)&lt;/code&gt; 是 zsh 的 glob qualifier，目录为空时会展开为空，而不是报错。片段按数字排序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.config/zsh/conf.d/
├── 00-env.zsh          # exported env vars, PATH base
├── 10-completion.zsh   # compinit and completion styles
├── 20-aliases.zsh      # short renames of existing commands
├── 30-functions.zsh    # shell functions that do real work
├── 35-tools.zsh        # eval-hooks for external CLIs (mise, zoxide, …)
├── 40-lang.zsh         # language/runtime-specific setup
├── 50-plugins.zsh      # zsh-ecosystem plugins
└── 90-local.zsh        # machine-specific, not tracked in git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;环境变量先加载；函数放在后面；&lt;code&gt;mise activate&lt;/code&gt;、&lt;code&gt;zoxide init&lt;/code&gt; 之类工具生成的 hook 单独放到 &lt;code&gt;35-tools.zsh&lt;/code&gt;；语言配置依赖这些 hook，所以排在 40。插件最后加载，其中 syntax highlighting 必须放到最后，否则其他修改 line editor 的插件可能和它冲突。&lt;/p&gt;
&lt;h2&gt;PATH 不要越叠越长&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;~/.zshenv&lt;/code&gt; 会在子 shell 里重复执行。直接这样写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export PATH=&quot;$HOME/.local/bin:$PATH&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每开一层 shell 都会再加一次。tmux、&lt;code&gt;exec zsh&lt;/code&gt; 和 subshell 用久后，PATH 里会出现很多重复项。&lt;/p&gt;
&lt;p&gt;我用一个幂等的 prepend：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Prepend to PATH only if not already present.
path_prepend() {
  case &quot;:$PATH:&quot; in
    *&quot;:$1:&quot;*) ;;            # already there — do nothing
    *) PATH=&quot;$1:$PATH&quot; ;;
  esac
}

path_prepend &quot;$HOME/.local/bin&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前后加冒号是为了让首尾元素也能按完整路径匹配。这段放在 &lt;code&gt;00-env.zsh&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;chezmoi 的源目录不是主目录&lt;/h2&gt;
&lt;p&gt;我不直接把一个 Git 仓库软链接进 &lt;code&gt;$HOME&lt;/code&gt;，而是使用 &lt;a href=&quot;https://www.chezmoi.io/&quot;&gt;chezmoi&lt;/a&gt;。它保存一份 source directory，再把内容渲染到主目录。文件名前缀同时声明目标属性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dot_&lt;/code&gt;：目标文件名前面加点，例如 &lt;code&gt;dot_zshenv&lt;/code&gt; 变成 &lt;code&gt;~/.zshenv&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;private_&lt;/code&gt;：目标权限为 &lt;code&gt;0600&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;executable_&lt;/code&gt;：设置可执行位。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;exact_&lt;/code&gt;：目录中不在声明里的内容会被移除。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;run_onchange_&lt;/code&gt;：内容变化时运行脚本。&lt;a href=&quot;/zh/posts/apps/&quot;&gt;应用篇&lt;/a&gt;用它在 Brewfile 变化后执行 &lt;code&gt;brew bundle&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;映射大概是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chezmoi source repo            →  rendered into $HOME
├── dot_zshenv                 →  ~/.zshenv
├── dot_config/
│   └── zsh/
│       ├── dot_zshrc          →  ~/.config/zsh/.zshrc
│       └── conf.d/
│           ├── 00-env.zsh     →  ~/.config/zsh/conf.d/00-env.zsh
│           └── 20-aliases.zsh →  ~/.config/zsh/conf.d/20-aliases.zsh
└── dot_local/
    └── bin/
        └── executable_zhealth →  ~/.local/bin/zhealth  (chmod +x)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;用 zhealth 找回偷偷出现的文件&lt;/h2&gt;
&lt;p&gt;完成后，&lt;code&gt;$HOME&lt;/code&gt; 里应该只有一个 zsh 文件：&lt;code&gt;~/.zshenv&lt;/code&gt;。如果又出现 &lt;code&gt;~/.zshrc&lt;/code&gt;，大概率是某个安装器写的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 30-functions.zsh — flag stray zsh files in $HOME
zhealth() {
  local stray=(~/.zshrc(N) ~/.zprofile(N) ~/.zlogin(N) ~/.zshrc.*(N))
  if (( ${#stray} )); then
    print -u2 &quot;zhealth: unexpected zsh files in \$HOME:&quot;
    printf &apos;  %s\n&apos; &quot;${stray[@]}&quot; &amp;gt;&amp;amp;2
    return 1
  fi
  print &quot;zhealth: \$HOME is clean — only ~/.zshenv expected&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装新工具后跑一下 &lt;code&gt;zhealth&lt;/code&gt;，比过几个月再从 shell 异常里猜原因简单得多。&lt;a href=&quot;/zh/posts/maintenance/&quot;&gt;维护篇&lt;/a&gt;会继续用这种 health check。&lt;/p&gt;
&lt;h2&gt;哪些东西不要跟踪&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;~/.zsh_history&lt;/code&gt; 是状态而不是配置，还可能包含隐私；&lt;code&gt;.zcompdump*&lt;/code&gt; 是可以重新生成的缓存；凭据和 token 更不应该直接进普通 dotfiles 仓库。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# .chezmoiignore
.config/zsh/.zcompdump*
.zsh_history
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;90-local.zsh&lt;/code&gt; 专门放每台机器不同、或者不应该上传的配置，例如公司代理和本地 alias。它最后加载，允许覆盖前面的设置，但不会进入 Git。&lt;/p&gt;
&lt;p&gt;最后 &lt;code&gt;$HOME&lt;/code&gt; 里只剩一个入口文件，实际配置拆成有顺序的小片段，chezmoi 记录目标状态，&lt;code&gt;zhealth&lt;/code&gt; 负责告诉我什么时候又被安装器污染了。&lt;/p&gt;
</content:encoded></item><item><title>我用四层结构重装了开发环境</title><link>https://www.shiinayane.com/zh/posts/manifesto/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/manifesto/</guid><description>一次 DFU 抹除让我重新决定：开发环境里的每类东西究竟归哪个工具管，以及以后要怎样把它完整重建出来。</description><pubDate>Sat, 30 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;几周前，我把一台 Mac mini 置入 DFU 模式（Device Firmware Update，此时机器本身没有可用的操作系统，要等另一台 Mac 把系统写进去），然后彻底抹掉了它。&lt;/p&gt;
&lt;p&gt;当时确实有个很顽固的问题要修，但我也只是想让这台机器重新“干净”一点。本来以为这会是一个不断重装软件的下午，结果真正花时间的，是先想清楚我说的“干净”到底是什么。&lt;/p&gt;
&lt;p&gt;以前我会尽量少装工具。但所谓的极简环境通常撑不过半年：&lt;code&gt;~/.zshrc&lt;/code&gt; 里出现了我已经看不懂的配置，机器上有三个不记得何时选过的 Python，还有一次 2023 年执行的 &lt;code&gt;pip install&lt;/code&gt; 留在一个根本不该放东西的位置。问题不在于工具太多，而在于我已经说不清每件东西归谁管理。&lt;/p&gt;
&lt;p&gt;这是这个系列的第 1 篇，后面还有 6 篇，会分别处理具体工具。这里先记下我在那次重装后采用的四层结构。&lt;/p&gt;
&lt;h2&gt;每一步都合理，叠起来就乱了&lt;/h2&gt;
&lt;p&gt;环境通常不是被某一个离谱操作搞坏的。&lt;/p&gt;
&lt;p&gt;需要 Python 时，顺手执行 &lt;code&gt;brew install python&lt;/code&gt;；后来项目要求另一个版本，于是装上 &lt;code&gt;pyenv&lt;/code&gt;。接着 Node 项目带来 &lt;code&gt;nvm&lt;/code&gt;，Ruby 又带来 &lt;code&gt;rbenv&lt;/code&gt;。这些安装器都想往 &lt;code&gt;~/.zshrc&lt;/code&gt; 里添几行，而且常常安静到我根本没有留意。再来一次 &lt;code&gt;pip install --user&lt;/code&gt;，包被放到另一个工具意料之外的位置，还可能抢先被加载。&lt;/p&gt;
&lt;p&gt;单看每一步都没什么大错。真正麻烦的是，等我输入 &lt;code&gt;python&lt;/code&gt; 时，必须先调查自己的电脑，才能回答现在跑的是哪一个 Python、为什么是它。&lt;/p&gt;
&lt;p&gt;我希望这个答案由环境结构决定，而不是看当前的 &lt;code&gt;PATH&lt;/code&gt; 恰好让谁赢了。&lt;/p&gt;
&lt;h2&gt;四层结构&lt;/h2&gt;
&lt;p&gt;我把 Mac mini 的开发环境分成四层，从系统一直到随仓库保存的项目依赖。&lt;/p&gt;
&lt;p&gt;&amp;lt;figure class=&quot;my-6&quot;&amp;gt;
&amp;lt;svg viewBox=&quot;0 0 600 330&quot; role=&quot;img&quot; aria-labelledby=&quot;diagram-layers-title&quot; style=&quot;width:100%;height:auto;color:inherit&quot;&amp;gt;
&amp;lt;title id=&quot;diagram-layers-title&quot;&amp;gt;The four-layer stack: System, Runtime version, Package manager, Project dependencies&amp;lt;/title&amp;gt;
&amp;lt;g font-family=&quot;ui-sans-serif, system-ui, sans-serif&quot;&amp;gt;
&amp;lt;!-- Layer 3 --&amp;gt;
&amp;lt;rect x=&quot;10&quot; y=&quot;10&quot; width=&quot;580&quot; height=&quot;66&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;38&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; fill=&quot;var(--primary)&quot;&amp;gt;Layer 3&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;58&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&amp;gt;Project dependencies&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;570&quot; y=&quot;44&quot; font-size=&quot;13&quot; text-anchor=&quot;end&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;pyproject.toml + uv.lock, package.json + pnpm-lock.yaml&amp;lt;/text&amp;gt;
&amp;lt;!-- Layer 2 --&amp;gt;
&amp;lt;rect x=&quot;10&quot; y=&quot;86&quot; width=&quot;580&quot; height=&quot;66&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;114&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; fill=&quot;var(--primary)&quot;&amp;gt;Layer 2&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;134&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&amp;gt;Package manager&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;570&quot; y=&quot;120&quot; font-size=&quot;13&quot; text-anchor=&quot;end&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;uv, pnpm, cargo&amp;lt;/text&amp;gt;
&amp;lt;!-- Layer 1 --&amp;gt;
&amp;lt;rect x=&quot;10&quot; y=&quot;162&quot; width=&quot;580&quot; height=&quot;66&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.05&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.15&quot;/&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;190&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; fill=&quot;var(--primary)&quot;&amp;gt;Layer 1&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;210&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&amp;gt;Runtime version&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;570&quot; y=&quot;196&quot; font-size=&quot;13&quot; text-anchor=&quot;end&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;mise (or a language&apos;s sovereign tool)&amp;lt;/text&amp;gt;
&amp;lt;!-- Layer 0 --&amp;gt;
&amp;lt;rect x=&quot;10&quot; y=&quot;238&quot; width=&quot;580&quot; height=&quot;66&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.2&quot;/&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;266&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot; fill=&quot;var(--primary)&quot;&amp;gt;Layer 0&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;30&quot; y=&quot;286&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&amp;gt;System&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;570&quot; y=&quot;272&quot; font-size=&quot;13&quot; text-anchor=&quot;end&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&amp;gt;Homebrew + Xcode Command Line Tools&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;/svg&amp;gt;
&amp;lt;/figure&amp;gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第 0 层：系统。&lt;/strong&gt; Homebrew 和 Xcode Command Line Tools 负责安装命令行工具与图形应用，不负责安装语言运行时。只要 Python 也交给 Homebrew，第 0 层就已经伸进了第 1 层。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第 1 层：运行时版本。&lt;/strong&gt; 这里决定使用哪个解释器或编译器，例如 Python 3.14、Node 22。我通常把这一层交给 &lt;code&gt;mise&lt;/code&gt;，由它根据当前所在的项目目录切换运行时。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第 2 层：包管理器。&lt;/strong&gt; Python 用 &lt;code&gt;uv&lt;/code&gt;，Node 用 &lt;code&gt;pnpm&lt;/code&gt;，Rust 用 &lt;code&gt;cargo&lt;/code&gt;。它们在已经选定的运行时里安装包，不负责选择运行时版本。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第 3 层：项目依赖。&lt;/strong&gt; Manifest 和 Lockfile 跟着仓库走，例如 &lt;code&gt;pyproject.toml&lt;/code&gt; 与 &lt;code&gt;uv.lock&lt;/code&gt;，或者 &lt;code&gt;package.json&lt;/code&gt; 与 &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;。换一台机器以后，应该仍然能靠这些文件复现同一组依赖。&lt;/p&gt;
&lt;p&gt;依赖方向也是固定的：项目依赖要靠包管理器，包管理器要靠运行时，运行时再落到系统上。每个工具可以管好自己那一层，但不应该悄悄接管上下层。&lt;/p&gt;
&lt;h2&gt;每类资源只交给一个工具&lt;/h2&gt;
&lt;p&gt;我给自己的实际规则是：同一类资源只能有一个负责人。&lt;/p&gt;
&lt;p&gt;运行时版本归一个工具，项目里的库归一个工具，系统应用也归一个工具。以后再问某个 Python 从哪里来、依赖版本由什么锁定、某个应用为什么会装在机器上，就只需要去一个地方找答案。&lt;/p&gt;
&lt;p&gt;所以工具数量本身不是一个有用指标。两个工具同时觉得 Python 归自己管，会比五个各管一件事的工具更麻烦。真正需要避免的是所有权重叠。&lt;/p&gt;
&lt;p&gt;不过第 1 层也不一定总归 &lt;code&gt;mise&lt;/code&gt;。Rust 有 &lt;code&gt;rustup&lt;/code&gt;，Swift 有 Xcode，它们都是语言生态本身提供、而且足够成熟的官方工具链管理器。遇到这种情况，我会让官方工具直接管理运行时和工具链，不再在前面套一层 &lt;code&gt;mise&lt;/code&gt;。这个系列的第 5 篇会专门写我怎样判断要不要这么做。&lt;/p&gt;
&lt;p&gt;因此，“单一事实来源”说的是所有权，不是指定某个永远正确的产品。工具可以换，这个分工仍然成立。&lt;/p&gt;
&lt;h2&gt;用能否重建来检查&lt;/h2&gt;
&lt;p&gt;那次抹除以后，我开始用“能不能恢复”检查环境。&lt;/p&gt;
&lt;p&gt;理想情况下，我可以把所有缓存和构建产物都删掉——也就是整批 &lt;code&gt;rm -rf&lt;/code&gt;——然后用一条命令重新构建机器上的任意项目。能做到，说明缓存真的只是可丢弃内容；做不到，就表示某个被我当作“派生结果”的目录其实偷偷保存了没有声明过的状态。我会把它当成分层问题，继续找到缺失的事实来源。&lt;/p&gt;
&lt;p&gt;真正需要保存的是 Manifest、Lockfile 和 Dotfiles。只靠这些声明，应该就能从几乎空白的系统恢复出可工作的状态。平时排查问题也会更直接：在怀疑 &lt;code&gt;PATH&lt;/code&gt; 之前，我知道该先找哪个工具。&lt;/p&gt;
&lt;p&gt;这套做法有明确边界。它来自一台由单个用户完全控制的 macOS 机器。团队环境、共享服务器、受公司策略限制的电脑或其他操作系统，都会明显改变取舍。&lt;code&gt;mise&lt;/code&gt; 和 &lt;code&gt;uv&lt;/code&gt; 也都是比较新的工具，以后很可能被别的东西替代。这些限制只是说明我在什么环境里使用它，并不表示所有人都该照搬。&lt;/p&gt;
&lt;h2&gt;后面六篇写什么&lt;/h2&gt;
&lt;p&gt;接下来的文章会分别展开这套环境的不同部分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不把 Dotfiles 塞进 &lt;code&gt;.zshrc&lt;/code&gt;&lt;/strong&gt;：用 &lt;code&gt;ZDOTDIR&lt;/code&gt; 和 &lt;code&gt;chezmoi&lt;/code&gt; 声明 Shell 配置、暴露漂移，也不再把 Home 目录当作配置垃圾场。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Brewfile 的妥协&lt;/strong&gt;：为什么系统层可以接受最终一致性，以及怎样通过 Reconcile 保持它可信，而不是假装它非常严格。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Python、mise 与 uv&lt;/strong&gt;：&lt;code&gt;uv&lt;/code&gt; 会绕过 &lt;code&gt;mise&lt;/code&gt; 悄悄安装自己的 Python，以及解决冲突所需的那一个设置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;什么时候该让官方工具接管&lt;/strong&gt;：怎样判断第 1 层应该交给语言自己的版本管理器，而不是 &lt;code&gt;mise&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一套结构，多种语言&lt;/strong&gt;：把四层模型具体应用到 Node、Java、Swift、Rust、Go 和 Ruby。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;配置很容易，维护才是工作&lt;/strong&gt;：健康检查、持续做减法，以及怎样让环境在长期使用后仍然容易理解。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a href=&quot;/zh/series/sovereign-tools/&quot;&gt;系列索引&lt;/a&gt;里有完整阅读顺序和当前状态。&lt;/p&gt;
</content:encoded></item><item><title>我是怎么学会读 Swift 函数签名的</title><link>https://www.shiinayane.com/zh/posts/reading-swift-function-signatures/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/reading-swift-function-signatures/</guid><pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;刚开始学 Swift 时，我打开 Apple 的 DocC 文档，只要看到下面这种东西，就会直接往下翻示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func compactMap&amp;lt;ElementOfResult&amp;gt;(
    _ transform: (Self.Element) throws -&amp;gt; ElementOfResult?
) rethrows -&amp;gt; [ElementOfResult]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当时最真实的想法就是：一个函数为什么非得写得这么吓人？&lt;/p&gt;
&lt;p&gt;我之前更习惯 Python 这种看起来很直接的 API：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;map(func, arr)
filter(func, arr)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而 Swift 会把泛型、&lt;code&gt;Optional&lt;/code&gt;、关联类型、协议约束、&lt;code&gt;throws&lt;/code&gt; 和 &lt;code&gt;rethrows&lt;/code&gt; 一起塞进函数签名里。我把它们都当成了妨碍阅读的语法，能跳过就跳过。照着示例抄代码当然没问题，但一旦碰到不熟悉的 API，这种读法就不太够用了。&lt;/p&gt;
&lt;h2&gt;先找输入和输出&lt;/h2&gt;
&lt;p&gt;后来我给自己定了一个很简单的读法：先别管尖括号里有多少东西，只看值从哪里进、变成什么、又从哪里出来。&lt;/p&gt;
&lt;p&gt;比如 &lt;code&gt;map&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func map&amp;lt;T&amp;gt;(
    _ transform: (Element) throws -&amp;gt; T
) rethrows -&amp;gt; [T]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;沿着箭头拆开，就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;闭包每次接收一个 &lt;code&gt;Element&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;它把这个元素转换成某种类型 &lt;code&gt;T&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;map&lt;/code&gt; 把所有结果收集成 &lt;code&gt;[T]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;闭包可以抛出错误；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rethrows&lt;/code&gt; 表示只有传入的闭包抛错时，&lt;code&gt;map&lt;/code&gt; 才会抛错。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;T&lt;/code&gt; 到底是什么，要到调用时才知道。但输入元素和输出结果不必同型，这件事已经写在签名里了。&lt;/p&gt;
&lt;p&gt;读得多了以后，我发现不少吓人的签名不过是在组合几种常见形状：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(Element) -&amp;gt; T
(Element) -&amp;gt; T?
Sequence&amp;lt;Element&amp;gt;
where T : BinaryInteger
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它们分别表示“把 &lt;code&gt;Element&lt;/code&gt; 变成 &lt;code&gt;T&lt;/code&gt; 的函数”、“可能产生 &lt;code&gt;T&lt;/code&gt; 的函数”、“元素类型为 &lt;code&gt;Element&lt;/code&gt; 的序列”，以及“必须符合 &lt;code&gt;BinaryInteger&lt;/code&gt; 协议的泛型 &lt;code&gt;T&lt;/code&gt;”。先认出这些零件，比背住某一个调用示例更有用；标准库和第三方 API 都会反复使用同样的类型关系。&lt;/p&gt;
&lt;h2&gt;把 &lt;code&gt;map&lt;/code&gt;、&lt;code&gt;compactMap&lt;/code&gt; 和 &lt;code&gt;flatMap&lt;/code&gt; 放在一起看&lt;/h2&gt;
&lt;p&gt;我最初只记得 &lt;code&gt;map&lt;/code&gt; 的调用方式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;arr.map { ... }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正分清这三个方法，是把闭包的返回类型和方法的返回类型放在一起比较之后。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;compactMap&lt;/code&gt; 最关键的信息，就是闭包返回值上的那个 &lt;code&gt;?&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func compactMap&amp;lt;ElementOfResult&amp;gt;(
    _ transform: (Element) throws -&amp;gt; ElementOfResult?
) rethrows -&amp;gt; [ElementOfResult]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;转换闭包可以得到一个 &lt;code&gt;ElementOfResult&lt;/code&gt;，也可以得到 &lt;code&gt;nil&lt;/code&gt;；最终数组里却只有非可选的 &lt;code&gt;ElementOfResult&lt;/code&gt;。所以它会尝试转换每个输入，并丢掉结果为 &lt;code&gt;nil&lt;/code&gt; 的项。以前我只笼统地记成“遇到 Optional 就用它”，签名表达得其实更准确。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;flatMap&lt;/code&gt; 针对序列的这个重载要长一些：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func flatMap&amp;lt;SegmentOfResult&amp;gt;(
    _ transform: (Element) throws -&amp;gt; SegmentOfResult
) rethrows -&amp;gt; [SegmentOfResult.Element]
where SegmentOfResult : Sequence
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;where&lt;/code&gt; 子句要求每次转换得到的 &lt;code&gt;SegmentOfResult&lt;/code&gt; 本身也是一个 &lt;code&gt;Sequence&lt;/code&gt;。再看返回类型：结果不是“序列组成的数组”，而是“这些内部序列的元素组成的数组”。&lt;/p&gt;
&lt;p&gt;于是，这样的嵌套值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[[1], [2,2], [3,3,3]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以被摊平成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[1,2,2,3,3,3]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看懂这层关系以后，&lt;code&gt;flatMap&lt;/code&gt; 就不再是什么“高级版 map”。至少对这个重载而言，它做的是把每个元素转换成序列，再把这些序列合成一个数组。&lt;/p&gt;
&lt;h2&gt;返回类型也会写出边界条件&lt;/h2&gt;
&lt;p&gt;函数签名不一定要很长才有用。下面这一行已经把空集合怎么处理写清楚了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func popLast() -&amp;gt; Element?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回值是可选类型，集合为空时会得到 &lt;code&gt;nil&lt;/code&gt;。调用者不需要猜“没有最后一个元素”是不是一种正常结果。&lt;/p&gt;
&lt;p&gt;再比较这两个名字：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;removeLast()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;和：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;popLast()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;removeLast()&lt;/code&gt; 要求集合非空，否则会触发运行时错误；&lt;code&gt;popLast()&lt;/code&gt; 则用 &lt;code&gt;nil&lt;/code&gt; 表示空集合。命名给出了提示，可选返回值又把这条规则明确写进了类型。&lt;/p&gt;
&lt;h2&gt;复杂度没有消失，只是提前了&lt;/h2&gt;
&lt;p&gt;Swift 经常要求我接受：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;more complexity during compilation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而不是把更多不确定性留到运行时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;more uncertainty at runtime
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这不是绝对的交换，不过编译器和类型系统确实能提前发现或表示其中一部分不确定性。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Optional&lt;/code&gt;、泛型、面向协议的 API、类型约束和显式错误处理，都会让函数签名变长，也正是这些东西最初让我不想读它。它们不能消灭所有非法状态，函数签名也替代不了完整文档；但输入、输出、失败方式和类型之间的关系，通常在运行代码之前就能看到。&lt;/p&gt;
&lt;p&gt;现在遇到陌生类型，我还是会打开示例。只是顺序变成了先读签名，形成一个判断，再用示例确认自己有没有理解错。&lt;/p&gt;
</content:encoded></item><item><title>Raptor 的两难</title><link>https://www.shiinayane.com/zh/posts/the-raptor-dilemma/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/the-raptor-dilemma/</guid><description>SwiftUI 式理想与 Web 现实</description><pubDate>Sat, 02 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在&lt;a href=&quot;/zh/posts/swift-for-static-sites/&quot;&gt;用 Swift 做静态网站&lt;/a&gt;里，我比较了 Astro、Saga、Toucan、Publish、Ignite 和 Raptor。当时的判断是：Swift 更适合负责内容建模和生成逻辑，界面还是应该交给 HTML、CSS 和 JavaScript。&lt;/p&gt;
&lt;p&gt;那个判断有一部分来自文档和示例。后来我真的用 Raptor 做了一套主题，问题就具体了很多。&lt;/p&gt;
&lt;p&gt;页面简单时，Raptor 写起来确实很舒服；但开始还原一套定制博客主题的细节以后，我经常一边用 Swift 写组件，一边按 CSS 的方式思考，最后再去浏览器里调试。真正让我犹豫的不是某一个 bug，而是这几层之间的距离。&lt;/p&gt;
&lt;h2&gt;一开始为什么很好用&lt;/h2&gt;
&lt;p&gt;Raptor 不只是把 Markdown 转成 HTML。它给页面、布局、主题、样式和组件都提供了 Swift 优先的表达方式。原本要写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;article class=&quot;post-card&quot;&amp;gt;
  &amp;lt;h2&amp;gt;Title&amp;lt;/h2&amp;gt;
&amp;lt;/article&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 Raptor 里可以写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VStack {
    Text(post.title)
}
.style(PostCardStyle())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;类型安全、组件组合、统一使用 Swift，再加上接近 SwiftUI 的开发体验，这些优点都很直接。理论上，SwiftUI 在 Apple 平台上带来的那套生产力，也可以延伸到网站开发。&lt;/p&gt;
&lt;p&gt;对于简单页面，它也确实做到了。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Text(&quot;Hello&quot;)
Button(&quot;Read more&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用来做 Landing Page、文档站或结构简单的博客都很顺手。如果界面大致可以归纳为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Text + Button + Grid + Card
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Raptor 的抽象既简洁又高效。&lt;/p&gt;
&lt;h2&gt;定制主题开始暴露边界&lt;/h2&gt;
&lt;p&gt;问题不是从什么偏门的浏览器特性开始的，而是博客主题里很普通的几个细节。&lt;/p&gt;
&lt;h3&gt;元信息横排&lt;/h3&gt;
&lt;p&gt;例如分类在左、时间在右的布局，用 CSS 可以直接写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.meta {
  display: flex;
  justify-content: space-between;
}
.meta time {
  float: right;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;换到 Raptor，就是把同一个布局意图改写成组件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HStack {
    categories
    Spacer().axis(.horizontal)
    time
}
.style(Property.width(.percent(100)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一段没有什么问题，甚至更清楚。此时抽象和实际需求仍然对得上。&lt;/p&gt;
&lt;h3&gt;用伪元素做装饰&lt;/h3&gt;
&lt;p&gt;接下来是标题下面的一条强调色短线：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.recent-info::after {
  content: &apos;&apos;;
  width: 13%;
  height: 5px;
  background: var(--accent);
  position: relative;
  bottom: -6px;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我在 Raptor 里把它做成了一个真正的组件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RecentInfoAccentBar()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果当然能显示，但含义变了。在 CSS 里，它只是附着在现有元素上的装饰层；放进组件树以后，它成了页面结构的一部分。为了复现一种样式写法，我开始调整文档结构，而不只是给已有结构加样式。&lt;/p&gt;
&lt;h3&gt;跨元素的 Hover 状态&lt;/h3&gt;
&lt;p&gt;CSS 选择器还可以直接描述两个元素之间的关系：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.card:hover .read-more {
  background-color: var(--bg-hover);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Raptor 的组件模型里没有同样干净的表达。我只能重新设计交互，或者手动协调相关样式。浏览器原本就理解“父元素 Hover 时改变后代元素”这件事，换成 Swift 抽象以后反而更难写清楚。&lt;/p&gt;
&lt;h3&gt;负边距&lt;/h3&gt;
&lt;p&gt;就连很常见的布局微调，本质上也还是 CSS：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.read-more {
  margin-top: -21px;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Raptor 可以表示同一个属性：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.style(Property.marginTop(.px(-21)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但写到这里，我并没有摆脱 CSS，只是把 CSS 翻译成了 Swift 语法。&lt;/p&gt;
&lt;p&gt;实际出现的错位可以概括成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;They think in Swift,
but debug in CSS.

They write components,
but fight layout at the DOM level.

They define styles,
but still rely on raw CSS properties.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;一套主题，两套思维方式&lt;/h2&gt;
&lt;p&gt;卡片结构一开始很适合组件化：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PostListItem {
    PostMeta(...)
    PostTitle(...)
    PostExcerpt(...)
    PostReadMore(...)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;公共样式也可以收进一个类型里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.style(PostCardStyle())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正开始对视觉细节时，代码又慢慢变成了这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.style(Property.marginTop(.px(12)))
.style(Property.paddingLeft(.px(8)))
.style(Property.fontSize(.px(14)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生成结果最终仍然运行在 HTML、CSS 和 JavaScript 上，所以浏览器行为才是最后的准则。实际工作时，我脑子里的分工是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Structure → Swift
Styling → CSS concepts
Layout debugging → Browser DevTools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;整个体验也就成了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Half SwiftUI
Half traditional Web
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;复杂度没有消失，只是换了位置。我在 Swift 里写组件，去 DOM 里看生成结果，再靠 CSS 知识修布局。&lt;/p&gt;
&lt;h2&gt;Saga 的边界更直接&lt;/h2&gt;
&lt;p&gt;Saga 采用的是另一种做法。它仍然让 Swift 负责组合，但生成的 HTML 和 class 一眼就能看出来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;article(class: &quot;mx-auto max-w-3xl px-6 py-12&quot;) {
  h1(class: &quot;text-4xl font-bold tracking-tight&quot;) {
    item.title
  }
  div(class: &quot;prose&quot;) {
    raw(item.body)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分工没有被藏起来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Swift → structure + composition
HTML → structure
CSS → styling
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从源代码到浏览器里实际调试的页面，中间少了一层翻译。对视觉要求高的网站，我觉得这比在 Web 上再套一层 SwiftUI 式模型更容易理解。&lt;/p&gt;
&lt;h2&gt;我现在会把 Raptor 用在哪里&lt;/h2&gt;
&lt;p&gt;Swift 当然能描述 Web UI，真正要判断的是：离浏览器原生渲染层多远，抽象才仍然值得。&lt;/p&gt;
&lt;p&gt;对于通用界面：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Abstraction helps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于高度定制的界面：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Abstraction fights the platform
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Raptor 最适合前一种情况：页面由常见组件组成，结构清楚，类型安全和组合能力都能带来实际收益。到了后一种情况，选择器、伪元素和细小的布局修正越来越重要，它的抽象反而开始碍手。&lt;/p&gt;
&lt;p&gt;整个技术栈仍然分成两层：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Rendering layer → HTML / CSS / JS
Authoring layer → Swift / React / Astro
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;React 和 Astro 离渲染层比较近。Raptor 有意走得更远，因此才有独特的 SwiftUI 式体验，也因此会出现我在做主题时遇到的错位。&lt;/p&gt;
&lt;p&gt;做完这个主题以后，我给自己的边界是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Swift for logic → great
Swift for UI abstraction → situational
HTML/CSS/JS → still the source of truth
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结构简单、组件通用的网站，我仍然愿意用 Raptor。要做视觉细节很多的主题，我会更倾向 Saga 这类方案，让浏览器原本的 UI 模型直接露出来。&lt;/p&gt;
</content:encoded></item><item><title>用 Swift 生成静态网站</title><link>https://www.shiinayane.com/zh/posts/swift-for-static-sites/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/swift-for-static-sites/</guid><description>SwiftUI 式抽象与 Web 原生实现之间的取舍</description><pubDate>Fri, 01 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我最近想把一个视觉细节很多的博客主题迁移到 Swift 静态网站生成器，于是实际看了一遍 &lt;a href=&quot;https://github.com/loopwerk/Saga&quot;&gt;Saga&lt;/a&gt;、&lt;a href=&quot;https://github.com/toucansites/toucan&quot;&gt;Toucan&lt;/a&gt;、&lt;a href=&quot;https://github.com/JohnSundell/Publish&quot;&gt;Publish&lt;/a&gt;、&lt;a href=&quot;https://github.com/twostraws/Ignite&quot;&gt;Ignite&lt;/a&gt; 和 &lt;a href=&quot;https://github.com/raptor-build/raptor&quot;&gt;Raptor&lt;/a&gt;，也拿它们和 &lt;a href=&quot;https://github.com/withastro/astro&quot;&gt;Astro&lt;/a&gt;、&lt;a href=&quot;https://github.com/hexojs/hexo&quot;&gt;Hexo&lt;/a&gt;、&lt;a href=&quot;https://github.com/gohugoio/hugo&quot;&gt;Hugo&lt;/a&gt;、&lt;a href=&quot;https://github.com/jekyll/jekyll&quot;&gt;Jekyll&lt;/a&gt; 这些成熟的 Web 原生工具做了比较。&lt;/p&gt;
&lt;p&gt;一开始只是想选个工具，实际读过源码和示例、自己试过以后，问题却变成了 Swift 应该放在 Web 技术栈的哪一层。小页面里，SwiftUI 风格的组件 API 和“用 Swift 生成普通 HTML”看起来差别不大；等主题开始依赖大量定制 CSS，这两条路线的摩擦就完全不同了。&lt;/p&gt;
&lt;h2&gt;浏览器才是 UI 的运行时&lt;/h2&gt;
&lt;p&gt;SwiftUI 在 iOS 上好用，是因为它的抽象直接接到了系统 UI 运行时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;iOS:
System UI runtime
→ SwiftUI Button / VStack / NavigationStack
→ Your Swift code
Web:
Browser runtime (DOM + CSS + JS)
→ HTML &amp;lt;button&amp;gt; / &amp;lt;div&amp;gt; / &amp;lt;article&amp;gt;
→ CSS grid / flex / selectors
→ Your HTML/CSS/JS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SwiftUI 的 &lt;code&gt;Button&lt;/code&gt; 会直接获得系统平台的无障碍、焦点、动画和输入行为。换成 Raptor 或 Ignite，Swift 里的按钮最后还是会变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;button class=&quot;...&quot;&amp;gt;Save&amp;lt;/button&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再加上一些 CSS。浏览器并不知道生成它的 Swift 组件是什么，只会执行最后收到的 HTML、CSS 和 JavaScript。&lt;/p&gt;
&lt;p&gt;问题并不在声明式 UI 本身。React、Vue 和 Astro 也都是声明式的，区别在于“声明的对象”离浏览器实际运行的模型有多远。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;框架&lt;/th&gt;
&lt;th&gt;实际声明的内容&lt;/th&gt;
&lt;th&gt;与平台的距离&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SwiftUI&lt;/td&gt;
&lt;td&gt;原生 UI 树&lt;/td&gt;
&lt;td&gt;很近&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React / Vue&lt;/td&gt;
&lt;td&gt;DOM / 组件树&lt;/td&gt;
&lt;td&gt;很近&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Astro&lt;/td&gt;
&lt;td&gt;HTML + Islands&lt;/td&gt;
&lt;td&gt;很近&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saga / Publish&lt;/td&gt;
&lt;td&gt;HTML 输出树&lt;/td&gt;
&lt;td&gt;近&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ignite&lt;/td&gt;
&lt;td&gt;Swift 组件（类似 Bootstrap）&lt;/td&gt;
&lt;td&gt;中等&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raptor&lt;/td&gt;
&lt;td&gt;SwiftUI 风格 UI + 自有样式系统&lt;/td&gt;
&lt;td&gt;更远&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;SwiftUI 的抽象与它所在的平台一致；React 仍然围绕 DOM 工作；Astro 则直接把 HTML 当作组件的一等内容。Ignite 和 Raptor 又向上加了一层 Swift 组件树。简单页面里这层抽象很舒服，但主题越定制，来回翻译的成本就越明显。&lt;/p&gt;
&lt;h2&gt;组件 DSL 从哪里开始失效&lt;/h2&gt;
&lt;p&gt;Ignite 这类组件在普通页面里写起来很快：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Text(&quot;Hello&quot;)
Button(&quot;Read More&quot;)
Grid {
  Card { ... }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;底层有 Bootstrap，布局、间距、响应式和基本视觉层级都不用从零开始。文档站、作品集、普通博客，以及本来就接近 Bootstrap 风格的页面都很适合。&lt;/p&gt;
&lt;p&gt;但从现有视觉主题迁移时，真正要还原的往往是这些 CSS：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.card::before
.sidebar:has(.active)
grid-template-columns: minmax(0, 1fr) 18rem
position: sticky
backdrop-filter
mask-image
container queries
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;内置组件表达不了以后，Swift 代码又会退回到底层 HTML 包装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Tag(&quot;aside&quot;) { ... }
.class(&quot;layout-shell__sidebar&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CSS 依然得单独写。结果是一半 SwiftUI 风格、一半 HTML 包装器，抽象没有少掉多少工作，反而把一个页面拆成了两套表达方式。&lt;/p&gt;
&lt;p&gt;这套路线当然有明确的舒适区：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Simple sites
Docs
Portfolios
Basic blogs
Bootstrap-like layouts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正困难的是高度定制的主题，尤其是原来就用 Astro 或 Hexo 编写、依赖精确选择器、伪元素、布局规则和浏览器行为的页面。这是抽象的适用边界，不等于框架本身做错了。&lt;/p&gt;
&lt;h2&gt;Saga 把边界留在明面上&lt;/h2&gt;
&lt;p&gt;Saga 的取舍不一样。Swift 负责擅长的部分，浏览器相关的内容继续用 Web 原生方式表达：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Swift:
Content model
Pipeline
Generation logic
Type safety

Web:
HTML structure
CSS styling
JavaScript behavior
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模板大概会写成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;article(class: &quot;mx-auto max-w-3xl px-6 py-12&quot;) {
  h1(class: &quot;text-4xl font-bold tracking-tight&quot;) {
    item.title
  }
  div(class: &quot;prose prose-slate dark:prose-invert&quot;) {
    raw(item.body)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这不是把 SwiftUI 搬进浏览器，而是老老实实用 Swift 生成 HTML。两边各自负责什么也很直观：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Swift-native:
Types, functions, composition

Web-native:
HTML, CSS, browser semantics
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对这次的博客主题来说，这种直接性比再做一层完整的 UI 抽象更有用。&lt;/p&gt;
&lt;h2&gt;Tailwind 让 Saga 更顺手&lt;/h2&gt;
&lt;p&gt;不用 Tailwind 时，Saga 模板就是比较传统的 HTML 加命名 class：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;article(class: &quot;post-card&quot;) {
  h2(class: &quot;post-card__title&quot;) {
    item.title
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;加上 Tailwind 后，布局和样式可以直接留在生成 HTML 的位置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;article(class: &quot;group rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg&quot;) {
  h2(class: &quot;text-2xl font-semibold tracking-tight&quot;) {
    a(href: post.url) {
      post.title
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里不需要把每个 CSS 概念重新翻译成一套 Swift modifier。Tailwind 仍然是 CSS，只是换成 utility class 来写，最后面对浏览器时还是熟悉的模型。&lt;/p&gt;
&lt;h2&gt;Raptor 和 Ignite 适合的任务不同&lt;/h2&gt;
&lt;p&gt;Raptor 想做的比普通 SSG 更多。它定义了一整套站点模型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Site
Page
PostPage
CategoryPage
Layout
Theme
Style
PostWidget
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它还集成了 Vapor，可以做服务端渲染。对于 Swift-first 的内容平台、动静态混合站点，或者本来就需要后端集成的项目，这些能力很有价值。&lt;/p&gt;
&lt;p&gt;不过，站点模型并不会自动解决前端表达问题。主题一旦超出它的 UI 与样式系统，最后还是会回到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Tag + Div + Class + CSS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;走到这里时，我会希望上层抽象至少解决了另一个明确问题，比如内容架构或服务端集成，因为它已经没有替我减少前端工作了。&lt;/p&gt;
&lt;p&gt;Ignite 的取舍更务实：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Swift API + Bootstrap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拿它快速做小型网站、作品集或文档站很合适。代价是当网站需要很强的视觉个性时，Bootstrap 的结构和默认审美会越来越难藏。&lt;/p&gt;
&lt;h2&gt;为什么复杂主题我还是会选 Astro&lt;/h2&gt;
&lt;p&gt;如果项目本身没有“必须使用 Swift”这个条件，这次的复杂视觉主题我仍然会优先选 Astro：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HTML、CSS、JavaScript 都是一等公民；&lt;/li&gt;
&lt;li&gt;组件仍然贴近浏览器原语；&lt;/li&gt;
&lt;li&gt;Tailwind 集成顺手；&lt;/li&gt;
&lt;li&gt;Content Collections 能提供内容结构；&lt;/li&gt;
&lt;li&gt;生态已经比较成熟。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这并不是说 Swift 不能做网站，而是使用 Swift 带来的抽象成本，应该换回项目真正需要的东西。&lt;/p&gt;
&lt;p&gt;这次比较的两条 Swift 路线可以简单写成：&lt;/p&gt;
&lt;h3&gt;SwiftUI 风格的 Web DSL（Raptor / Ignite）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Swift expresses UI
→ translated into HTML/CSS
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Swift 原生生成 + Web 原生 UI（Saga）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Swift handles logic and structure
HTML/CSS/JS express the UI
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于高度定制的视觉网站，我更愿意用第二种。Swift 继续提供类型、函数、组合、内容建模和生成逻辑，UI 则交给 HTML、CSS 和 JavaScript。&lt;/p&gt;
&lt;p&gt;最后留下的实际选型很简单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Not using Swift: Astro + Tailwind
Using Swift seriously: Saga + Tailwind
Quick Swift site: Ignite
Exploring Swift Web frameworks: Raptor
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>用 Swift 搭建个人网站</title><link>https://www.shiinayane.com/zh/posts/building-personal-website-in-swift/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/building-personal-website-in-swift/</guid><description>我以为很简单……直到发现并不是</description><pubDate>Tue, 21 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;用 Raptor 做这个网站时，我想分别设置文章日期、导航标签、分类和其他小字的样式。本来以为只是补几项主题配置，结果一路改到了框架内部，最后才发现：问题不在功能做不出来，而在于我选错了抽象层。&lt;/p&gt;
&lt;p&gt;Raptor 是一个用 Swift 编写的静态网站生成器。页面布局不需要写 HTML 模板或 JSX，而是直接用 Swift 描述：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VStack {
    Text(&quot;Hello, world!&quot;)
    Text(&quot;Welcome to my site&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;字体和颜色则可以放进内置的主题系统：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.font(.title1)
.fontSize(36, for: .title1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法很直观，也有类型检查。用熟悉的 Swift 写网站本身确实挺有意思，麻烦出在网站开始需要更细的文字样式之后。&lt;/p&gt;
&lt;h2&gt;真实网站不只有标题和正文&lt;/h2&gt;
&lt;p&gt;Raptor 的排版系统预设了几种固定角色：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;body&lt;/li&gt;
&lt;li&gt;title1 … title6&lt;/li&gt;
&lt;li&gt;codeBlock&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但实际页面里还有文章日期与作者、导航标签、标签与分类，以及按钮和链接之类的小型 UI 文字。&lt;/p&gt;
&lt;p&gt;换成 Hugo、Hexo 或普通 HTML 模板，这些东西通常不值得专门设计一套框架 API。加上 class，再交给 CSS 就行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;span class=&quot;post-meta&quot;&amp;gt;April 20&amp;lt;/span&amp;gt;
&amp;lt;a class=&quot;nav-label&quot;&amp;gt;Archive&amp;lt;/a&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;.post-meta {
  font-size: 12px;
  color: gray;
}
.nav-label {
  font-weight: bold;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可是 Raptor 现有的文字角色里没有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.postMeta
.navLabel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;于是我最先想到的，是自己把它们加进去。&lt;/p&gt;
&lt;h2&gt;一套能运行的自定义角色&lt;/h2&gt;
&lt;p&gt;我希望调用方式像这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Text(&quot;April 20&quot;).textRole(.postMeta)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后在 Theme 里统一配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.fontSize(12, for: .postMeta)
.fontWeight(.medium, for: .postMeta)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生成对应的 CSS：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.text-role-post-meta {
  font-size: 12px;
  font-weight: 500;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后把 class 挂到输出的 HTML 上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;p class=&quot;text-role-post-meta&quot;&amp;gt;April 20&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为此我翻了不少框架代码，修改主题配置，补上 CSS 生成，再调整渲染逻辑。它最后的确跑通了：自定义角色可以经过主题系统，支持明暗模式，自动生成 CSS，也会出现在最终 HTML 中。&lt;/p&gt;
&lt;p&gt;但能运行不等于这个设计就合适。&lt;/p&gt;
&lt;p&gt;首先，描述文字的方式变成了两套：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.font(.title1)        // built-in
.textRole(.postMeta)  // custom
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更关键的是，两套 API 管的事情并不相同。Raptor 中的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.font(.title1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;既会决定 &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; 这样的 HTML 标签，也会应用样式；而我新增的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.textRole(.navLabel)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只负责样式。HTML 结构和视觉样式本来就是两件事，我却在没有理清这条边界的情况下，又塞进了一套排版 API。&lt;/p&gt;
&lt;p&gt;写到后来，调用变成了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.tag(.h1)
.textRole(.navLabel)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应的其实还是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;h1 class=&quot;nav-label&quot;&amp;gt;&amp;lt;/h1&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原本想借助类型系统消除复杂度，结果只是绕了一圈重新发明 HTML。&lt;/p&gt;
&lt;h2&gt;真正需要的是 Style&lt;/h2&gt;
&lt;p&gt;我又去看了其他静态网站生成器怎么处理。比如 Hugo 并不会在框架层为这种需求建立角色系统，它只让你写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;p class=&quot;post-meta&quot;&amp;gt;&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;剩下的交给 CSS。&lt;/p&gt;
&lt;p&gt;对照着重新阅读 Raptor 的设计和源码后，边界就清楚了：Theme 用来定义字体、颜色、间距等全局设计令牌；“文章元信息”这种带语义的局部样式，并不需要成为 Theme 里的新文字角色。&lt;/p&gt;
&lt;p&gt;Raptor 已经为此准备了另一个抽象：&lt;code&gt;Style&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct PostMetaStyle: Style {
    func style(content: Content, environment: EnvironmentConditions) -&amp;gt; Content {
        content
            .font(.caption)
            .foregroundStyle(.secondary)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用时直接把它应用到内容上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Text(&quot;April 20&quot;)
    .style(PostMetaStyle())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它承担的作用相当于：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;p class=&quot;post-meta&quot;&amp;gt;April 20&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但仍然是可复用、可组合且类型安全的 Swift 代码。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Style&lt;/code&gt; 还能读取环境条件，包括明暗模式、当前主题、对比度设置和布局条件。因此，语义样式可以按环境变化，而不用挤进 Theme 的角色系统：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct PostMetaStyle: Style {
    func style(content: Content, environment: EnvironmentConditions) -&amp;gt; Content {
        if environment.colorScheme == .dark {
            content.foregroundStyle(.gray)
        } else {
            content.foregroundStyle(.secondary)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我之前漏掉的分工其实很简单：Theme 管全局设计令牌，&lt;code&gt;Style&lt;/code&gt; 封装可复用的语义样式。需要的能力原本就在 Raptor 里，只是我一开始先改了框架内部，没先找到它为这类问题准备好的入口。&lt;/p&gt;
</content:encoded></item><item><title>在 SwiftUI 中复现 Apple Music 的滚动隐藏导航标题</title><link>https://www.shiinayane.com/zh/posts/apple-music-style-navigation-titles/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/apple-music-style-navigation-titles/</guid><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;iOS 26 的 Apple Music 里有一个挺细的变化：向下滚动后，大标题不会像以往那样缩成小标题，而是连同相关的工具栏内容一起从视觉上消失。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/apple-music-style-navigation-titles/navigation-title-collapse.png&quot; alt=&quot;Apple Music 中的效果&quot; /&gt;&lt;/p&gt;
&lt;p&gt;iOS 11 以来常见的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Large Title
   ↓ scroll
Small Navigation Title
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apple Music 现在看起来更接近：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Large Title
   ↓ scroll
(no title)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SwiftUI 目前没有一个公开的 modifier 可以直接打开这种效果。实际实现时可以分成两种情况：要准确控制整个顶部区域，就自己做 header 并监听滚动；只是想让折叠后的小标题不显示，则有一个很省事的办法。&lt;/p&gt;
&lt;h2&gt;需要完整控制时，自己管理 header&lt;/h2&gt;
&lt;p&gt;比较完整的实现可以由几部分组成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用 &lt;code&gt;safeAreaInset(edge: .top)&lt;/code&gt; 放置自定义 header&lt;/li&gt;
&lt;li&gt;通过 &lt;code&gt;ScrollGeometry&lt;/code&gt; 读取滚动位置&lt;/li&gt;
&lt;li&gt;分别控制标题和其他工具栏元素的显示状态&lt;/li&gt;
&lt;li&gt;用 opacity、offset 完成过渡动画&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SwiftUI 已经提供了这些基础能力：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.onScrollGeometryChange(...)
.safeAreaInset(...)
.toolbar(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只是它们还没有被封装成一个专门的导航栏选项。最简化的写法，是记录纵向 offset，超过阈值后隐藏 header：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct CollapsingHeaderView: View {

    @State private var headerHidden = false

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..&amp;lt;50) { i in
                    Text(&quot;Row \(i)&quot;)
                        .frame(maxWidth: .infinity)
                        .padding()
                }
            }
        }
        .onScrollGeometryChange(for: CGFloat.self) { geometry in
            geometry.contentOffset.y
        } action: { _, offset in
            headerHidden = offset &amp;gt; 40
        }
        .safeAreaInset(edge: .top) {
            header
                .opacity(headerHidden ? 0 : 1)
                .animation(.easeInOut, value: headerHidden)
        }
    }

    private var header: some View {
        HStack {
            Text(&quot;Library&quot;)
                .font(.largeTitle.bold())

            Spacer()

            Image(systemName: &quot;person.crop.circle&quot;)
        }
        .padding()
        .background(.ultraThinMaterial)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式可以配合 &lt;code&gt;ScrollView&lt;/code&gt;、&lt;code&gt;List&lt;/code&gt; 或 &lt;code&gt;LazyVStack&lt;/code&gt; 使用。header 的布局、隐藏时机和动画都能自己定，页面需要更复杂的转场时也容易继续扩展。&lt;/p&gt;
&lt;p&gt;代价也很直接：滚动逻辑从此要由应用负责。阈值、滚动方向、工具栏排版、下拉刷新，以及嵌套 &lt;code&gt;NavigationStack&lt;/code&gt; 的边界情况，都可能需要单独处理。对于确实要还原整个顶部交互的页面，这些成本可以接受；如果只是不想显示小标题，就有点重了。&lt;/p&gt;
&lt;h2&gt;只想隐藏小标题时，可以把 principal 留空&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;.principal&lt;/code&gt; 位置放一个空标题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.toolbar {
    ToolbarItem(placement: .principal) {
        Text(&quot;&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时保留普通的大标题设置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.navigationTitle(&quot;Library&quot;)
.navigationBarTitleDisplayMode(.large)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;滚动时看到的结果就会变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Large Title
   ↓ scroll
(empty)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;列表顶部的大标题照常显示；进入折叠状态后，principal item 提供的是一个空的小标题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NavigationStack {
    List(items) { item in
        Text(item.title)
    }
    .navigationTitle(&quot;Library&quot;)
    .navigationBarTitleDisplayMode(.large)
    .toolbar {
        ToolbarItem(placement: .principal) {
            Text(&quot;&quot;)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不需要增加任何滚动状态，普通的 &lt;code&gt;NavigationStack&lt;/code&gt; 就能做出很接近 Apple Music 的视觉效果。&lt;/p&gt;
&lt;h2&gt;这个办法隐藏了什么&lt;/h2&gt;
&lt;p&gt;SwiftUI 的导航标题通常会在两个状态之间切换：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Large Navigation Title
        ↓
Compact Navigation Title
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;principal toolbar item 会替换紧凑状态下显示的内容。把它留空，相当于把原来的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Large Title
   ↓
Small Title
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Large Title
   ↓
(blank space)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以导航栏并没有真的消失，只是紧凑标题不再渲染任何内容。这也解释了为什么几行代码就能得到相当接近的效果，以及它为什么不能代替完整实现。&lt;/p&gt;
&lt;p&gt;具体限制有三点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;导航栏仍然存在&lt;/li&gt;
&lt;li&gt;其他 toolbar item 仍可能占据空间&lt;/li&gt;
&lt;li&gt;它依赖 SwiftUI 当前的渲染行为，未来版本可能改变&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果需求是让整个 header 和相关布局一起消失，应该用前面的滚动监听方案；如果空白的小标题已经满足视觉要求，这个 toolbar 小技巧会轻得多。&lt;/p&gt;
&lt;h2&gt;以后也许会有正式 API&lt;/h2&gt;
&lt;p&gt;Apple 过去确实有过先在系统应用里使用交互，再开放相关 API 的情况，例如 &lt;code&gt;.searchable&lt;/code&gt;、大标题导航栏，以及 Apple Music 里出现的标签栏最小化行为。&lt;/p&gt;
&lt;p&gt;以后 SwiftUI 也许会提供类似这样的接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.navigationBarCollapseBehavior(.onScroll)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.toolbarScrollVisibility(.hidden)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两个 modifier 只是用于说明可能的 API 形态，目前并不存在。&lt;/p&gt;
</content:encoded></item><item><title>使用 Docker 部署 Minecraft 服务器</title><link>https://www.shiinayane.com/zh/posts/deploy-minecraft-server-with-docker/</link><guid isPermaLink="true">https://www.shiinayane.com/zh/posts/deploy-minecraft-server-with-docker/</guid><pubDate>Thu, 15 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近想在 Mac 上开一个 Minecraft 服务器。&lt;/p&gt;
&lt;p&gt;直接在服务器目录里运行 &lt;code&gt;./run.bash&lt;/code&gt; 当然很简单，但一直把终端和 Java 进程挂在后台显然不太优雅。Screen 倒是可以把终端藏起来，不过它并不知道 Java 服务本身是什么情况。&lt;/p&gt;
&lt;p&gt;既然 Docker 本来就很适合部署这些服务，我就想能不能顺便把 Minecraft 服务器也放进去。&lt;/p&gt;
&lt;p&gt;然后很幸运地找到了 &lt;code&gt;itzg/minecraft-server&lt;/code&gt;！&lt;/p&gt;
&lt;h2&gt;简介&lt;/h2&gt;
&lt;p&gt;GitHub 链接：&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;itzg/docker-minecraft-server&quot;}&lt;/p&gt;
&lt;p&gt;它的&lt;a href=&quot;https://docker-minecraft-server.readthedocs.io/en/latest/&quot;&gt;文档&lt;/a&gt;里有一段简单介绍：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;这个 Docker 镜像会在启动时自动下载 Minecraft Server 的最新稳定版，也可以运行或升级到指定版本以及最新快照。更多信息可以查看 Versions 一节。&lt;/p&gt;
&lt;p&gt;如果只想运行最新稳定版，可以使用：&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;docker run -d -it -p 25565:25565 -e EULA=TRUE itzg/minecraft-server
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;这里会把标准服务器端口 &lt;code&gt;25565&lt;/code&gt; 暴露到宿主机上。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Docker Compose&lt;/h2&gt;
&lt;p&gt;我不太推荐直接用上面的命令运行镜像。和其他普通项目一样，用 Docker Compose 会方便得多。&lt;/p&gt;
&lt;p&gt;官方给出的步骤是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;新建一个目录&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;把下面的内容保存为 &lt;code&gt;compose.yaml&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在这个目录中运行 &lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;完成！在客户端中输入宿主机的名称或 IP 地址以及端口 &lt;code&gt;25565&lt;/code&gt; 即可连接。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;# docker.yaml
services:
  mc:
    image: itzg/minecraft-server:latest
    pull_policy: daily
    tty: true
    stdin_open: true
    ports:
      - &quot;25565:25565&quot;
    environment:
      EULA: &quot;TRUE&quot;
    volumes:
      # attach the relative directory &apos;data&apos; to the container&apos;s /data path
      - ./data:/data
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;修改 Compose 文件后，再运行一次 &lt;code&gt;docker compose up -d&lt;/code&gt; 即可应用。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;docker compose logs -f&lt;/code&gt; 可以持续查看容器日志，&lt;code&gt;docker compose ps&lt;/code&gt; 可以检查状态，&lt;code&gt;docker compose stop&lt;/code&gt; 则会停止容器。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;如果刚开始接触 Docker，可能会对 volumes 有点迷惑。其实很简单，目录大概可以长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Your-MC-Server-Folder
├── data
│   ├── config
│   ├── eula.txt
│   ├── kubejs
│   ├── mods
│   ├── server.properties
│   └── world
└── docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Your-MC-Server-Folder&lt;/code&gt; 里面的 &lt;code&gt;data&lt;/code&gt; 就是放 Mod 和世界存档的地方。也可以先运行 Docker Compose，让容器自己生成这些数据，再把已有的数据移动进 &lt;code&gt;data&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;Loader 和 Mod&lt;/h2&gt;
&lt;p&gt;只要修改 &lt;code&gt;compose.yaml&lt;/code&gt; 中的变量，也可以直接启动带 Mod 的服务器。下面是我自己的配置。你也可以用 &lt;a href=&quot;https://setupmc.com/java-server/&quot;&gt;SetupMC&lt;/a&gt; 辅助生成一份。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  mc:
    image: itzg/minecraft-server:latest
    tty: true
    stdin_open: true
    ports:
      - &quot;25565:25565&quot;
      - &quot;24454:24454/udp&quot;
    environment:
      EULA: &quot;TRUE&quot;
      TYPE: &quot;NEOFORGE&quot;
      VERSION: &quot;1.21.1&quot;
      INIT_MEMORY: &quot;4G&quot;
      MAX_MEMORY: &quot;12G&quot;
      MOTD: &quot;A Minecraft Server&quot;
      TZ: &quot;Asia/Tokyo&quot;
      DIFFICULTY: &quot;hard&quot;
    volumes:
      - &quot;./data:/data&quot;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;顺便一提，&lt;code&gt;24454/udp&lt;/code&gt; 是给 Mod“Simple Voice Chat”开的。这种情况下，还需要在 Simple Voice Chat 的配置中把 IP 改成 &lt;code&gt;0.0.0.0&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;然后……&lt;/h2&gt;
&lt;p&gt;玩得开心！&lt;/p&gt;
</content:encoded></item></channel></rss>