普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月12日iOS

正念冥想全解惑:从原始出厂设置到大脑物理重塑

2026年5月12日 08:00

正念、冥想与正念冥想:先把概念理清

核心概念解析

正念冥想并不是「停止思考」,而是一个不断循环的认知训练过程。在展开所有讨论之前,有必要先厘清三个经常被混用的词。

  • 冥想(Meditation) 是一个宏观的练习统称,类似于「体育锻炼」,它包含超觉冥想、慈悲冥想等众多流派,共同点是训练注意力、改变意识状态、引发深度宁静。
  • 正念(Mindfulness) 是指有意识地、不带评判地将注意力集中在当下体验的一种心理状态。它是通过反复训练后获得的结果,并且可以随时应用在日常生活之中。
  • 正念冥想(Mindfulness Meditation) 是冥想的一种具体形式,类似于「举重训练」,它有明确的操作方法(呼吸冥想、身体扫描等),目标是训练注意力的专注与觉察。

正念的四大核心要素

当我们在谈论「处于正念状态」时,实际上是指心理活动同时具备以下四个维度的特征:

  • 有意识的注意(Awareness): 刻意且主动地将注意力带回当下的身心状态,夺回大脑的控制权,而非被自动化思维或潜意识习惯所操控。
  • 不带评判(Non-judging): 对当下发生的任何经历(无论主观感受是好是坏),都采取一种接纳、观察的客观态度。不急于给体验贴标签,不立即进行对错、优劣的价值评判。
  • 全然当下(Present Moment): 将觉知锚定在「此时此刻」。不沉溺于对过去事件的反刍(Rumination),也不陷入对未来未发生之事的焦虑与过度模拟。
  • 接纳(Acceptance): 允许当下的体验(包括痛苦、烦躁等负面情绪)如其所是地存在,不产生心理上的抗拒或逃避。

正念的核心练习途径

将正念融入生活,主要通过以下两种维度的途径进行交替训练:

1. 正念冥想

这是「正式」的训练时间,通常需要专门拨出时间与空间。通过高度聚焦的练习,针对性地训练大脑的注意力控制系统。

  • 专注于呼吸: 将呼吸作为锚点,训练注意力的深度与持久度。
  • 身体扫描: 系统性地觉察身体各部位的感觉,训练注意力的灵活性与全景觉察能力。

2. 日常正念

这是将训练成果(肌肉力量)应用到生活中的「非正式」练习。要求在进行日常活动时,做到「人在心在」。

  • 在吃饭、走路、洗澡、做家务时,全神贯注地体会当下的感官体验(如水流的温度、食物的咀嚼感、脚步的重心变化),将碎片化的「垃圾时间」转化为神经系统的放松与觉察契机。

大脑的出厂设置:为什么我们天生容易焦虑和走神

在讨论正念冥想怎么起作用之前,先要理解为什么我们需要它,大脑的出厂设置本身就内置了两个让我们反复陷入痛苦的机制。

默认模式网络(DMN):永不停歇的焦虑后台

当大脑没有明确的外部任务时,一组被称为「默认模式网络(DMN)」的脑区会自动激活。它负责心智游移、回忆过去和模拟未来。然而,进化决定了 DMN 的漫游通常不是轻松散步,而是带有强烈的负面偏好。DMN 最喜欢在后台做的事情是「排雷」和预测生存威胁:

  • 反刍过去: 纠结昨天说错的话、别人异样的眼神。
  • 担忧未来: 焦虑截止日期、经济压力或尚未发生的灾难。
  • 自我审视: 不断评估自身的社会地位,产生自我怀疑和批判。

更棘手的是,DMN 极其擅长「自动滑坡」。哪怕一开始只是无害的「晚上吃什么」,几分钟内就可能演变成「我最近胖了→我太没自制力了→我的生活一团糟」。这种漫游不仅没让大脑休息,反而在后台持续消耗认知资源、悄悄积攒压力。

在大脑的运行机制中,以 DMN 为核心的「叙事网络」(负责想过去、想未来、想自己)和以脑岛等区域为核心的「体验网络」(负责感知当下的呼吸、温度、触觉)呈现出强烈的相互抑制关系。当一个网络活跃时,另一个就被压制。正念冥想的训练本质,就是主动激活体验网络、压制叙事网络的过程。

抑制 DMN 会扼杀想象力吗?

DMN 的心智游移 (Mind-wandering, 也就是我们常说的「白日梦」或「走神」) ,是大脑在不同的想法、图像和记忆之间自由跳跃,它是创造力和灵感的重要来源。

既然正念冥想会抑制 DMN,那会不会破坏想象力和创造力? 答案是:不会。它带来的是一种正向的优化与提纯。冥想主要抑制的是 DMN 中与过度自我参照 (Self-referential thoughts)无意识情绪卷入相关的部分。它清理了精神带宽,去除了自我审查和反刍思维的噪音。

同时高质量的创造力不仅需要 DMN 发散灵感,还需要执行控制网络 (ECN) 来聚焦和评估。冥想能增强网络间的灵活切换,解除“创意阻塞”,让深层的直觉和建设性的想象更容易浮出水面。

杏仁核:过度敏感的「火警报警器」

大脑中的杏仁核是情绪反应的核心枢纽,功能类似于一个火警报警器。问题在于,在未经训练的大脑中,这个报警器被设定得过于敏感,一点点烟雾(日常压力)就会触发疯狂的鸣叫。

更糟糕的是,杏仁核和 DMN 中的背内侧前额叶(dmPFC,负责自我指涉和编造「受害者故事」的区域)之间存在正相关的共激活关系:杏仁核一报警,dmPFC 就立刻兴奋,开始编故事(「我真没用」、「这太可怕了」),然后故事进一步刺激杏仁核。两者像放大器回路一样互相强化,把微小的不适放大成压倒性的痛苦。

双通道模型:刺激与反应之间几乎没有缓冲

当外部刺激(比如老板的责骂)进入大脑时,信息首先到达丘脑,然后被分成两路:

  • 低速通道(杏仁核直达): 毫秒级反应,你还没意识到发生了什么,心跳就已经加速。这是进化赋予我们的本能保护,但在现代社会中,它导致我们对非致命威胁也产生过激的生理反应。
  • 高速通道(经由皮层): 信息先被送到感觉皮层分析,再送到前额叶进行理性评估。这个过程更慢,但能做出更合理的判断。

核心困境在于:低速通道太快,高速通道太慢。情绪反应往往在理智介入之前就已经完成了。正念练习可以增强高速通道的效率,争取那个至关重要的「觉察时间差」。

正念冥想的神经回路:它到底在训练什么

从微观解剖学角度来看,正念冥想绝不仅仅是心理学上的自我安慰,而是一场真实发生的大脑资源「再分配」运动。

通过反复的认知训练,正念将大脑的能量和带宽从负责自我反刍和焦虑的默认模式网络 (DMN)杏仁核中抽离,转移并投资到以下关键区域:

  • 前扣带皮层 (ACC): 负责注意力控制与冲突监测。
  • 前额叶 (PFC): 负责理智决策与情绪踩刹车。
  • 脑岛 (Insula): 负责当下高精度感知的「温度计」。

觉察系统的唤醒:ACC,一切正念的起点

前扣带皮层(ACC)是大脑的「冲突探测器」。它的功能是实时监测身体状态与意图之间的不一致。

没有 ACC 的介入,正念根本无法启动。以焦虑为例:如果不先意识到自己在焦虑, 你与情绪就是「融合 」的,你会沦为焦虑的「受害者」而非「调节者」。ACC 介入的瞬间是这样的——它突然探测到异常:期待状态是: 我想要平静 / 我正在专心工作,实际状态是:我的心跳在狂飙 / 我的手正在下意识拿手机,于是发出信号:「检测到身体状态与意图不符!」。这就是「觉察」产生的那个瞬间。

这个觉察的瞬间,是从自动化反应切换到有意识调节的关键转折点。每一次正念练习中的「察觉走神」,都是在锻炼 ACC 的肌肉。

Tip

ACC 还与前脑岛(AI)共同构成了大脑的「突显网络(Salience Network)」。前脑岛负责生成主观感受(雷达预警),ACC 负责将这种感受转化为行动和注意力调度(行动指挥)。两者之间存在一种特殊的纺锤体神经元(VENs),像超高速光纤一样将直觉和感受在几毫秒内传递给行动系统。

前额叶皮层(PFC)管理团队的协作

前额叶皮层是大脑的最高管理层,在正念的语境下,它的三个子区域各司其职:

dlPFC(背外侧前额叶)—— CEO

负责注意力的调度和执行控制。在正念练习中,每一次「把走神的注意力拉回呼吸」都是 dlPFC 在发号施令。它也是争取「觉察时间差」的关键力量:在情绪风暴中踩下离合器,暂停对威胁标签的自动沉浸,为理性评估争取空间。

vlPFC(腹外侧前额叶)—— 首席分析师(情绪翻译与紧急刹车)

负责将情绪概念化、语言化。命名即驯服 就是它的绝技。当你在心里给情绪贴上标签(如「这是焦虑」)时,vlPFC 被高度激活。而仅仅是将情绪语言化、概念化,就能显著降低杏仁核的活跃度。

当情绪风暴袭来时,vlPFC 的工作过程如下:杏仁核拉响警报,最初的感受是混沌的、弥漫的,心跳加速、肌肉紧绷、大脑空白。对大脑来说,这种未知是最可怕的,因为未知意味着不可控。而 vlPFC 做了一件根本性的事:它把一团混沌的生理反应,强行塞进了一个叫做「愤怒」的抽屉里并贴上标签:这不是未知的灭顶之灾,这仅仅是一个被命名为「愤怒」的标准心理现象,已被识别,已归档,不需要维持最高级别的生存警报。让你从情绪本身变成观察情绪的旁观者。

vmPFC(腹内侧前额叶)—— 政委

vmPFC 是真正负责评估安全、消退恐惧,并向杏仁核发送抑制信号的区域。它是 dlPFC 下达指令的直接执行者,也是 vlPFC 分析结果的接收方。当 vlPFC 完成情绪标签化后,vmPFC 接收到「这是已知的、非致命的心理现象」的信号,随即向杏仁核发出「降低警报级别」的抑制指令。

重塑神经连接:「共激活」变弱与「下行抑制」变强

正念冥想不仅改变了各个脑区的功能强度,更重构了它们之间的连接模式

变弱的:功能性去耦合,打断病态的共激活

在未练习者的大脑中,杏仁核和 dmPFC 常常正相关共激活。杏仁核一报警(恐惧),dmPFC 就兴奋(「我真没用」),两者互相刺激形成放大器回路。正念打破的正是这种「只要有情绪,就必然卷入自我评判」的自动化共生关系。

变强的:下行抑制,强化单向刹车

dlPFC → vmPFC → 杏仁核的抑制性通路在正念练习中被强化(变厚、变快)。这是一种负相关的抑制信号,vmPFC 踩下刹车、让杏仁核降低活性的物理能力得到实质性增强。

两种变化的方向相反但协同作用:前者让情绪不再自动卷入无意义的自我叙事,后者让理性调节情绪的能力变得更高效。

两种核心练习的差异:呼吸专注与身体扫描

虽然「专注于呼吸」和「身体扫描」在神经机制上都属于利用体验网络来抑制 DMN 走神,但它们在注意力运作方式和具体效果上存在显著差异。可以将它们理解为注意力肌肉的不同「器械训练」。

锚点性质:单点深潜 vs. 移动巡视

专注于呼吸是「单点锚定」:把船固定在一个锚点上,注意力始终聚焦在一个固定的、有节律的生理过程上(鼻腔边缘的触觉、腹部的起伏)。目标是训练注意力的深度和持久度。

身体扫描是「移动巡视」:拿着手电筒在黑暗的房间里依次照亮不同的角落,注意力按照特定路径(从脚趾到头顶)主动转移。目标是训练注意力的灵活性和全景觉察能力。

抗干扰能力

呼吸是一个单调、重复的锚点,大脑很快会感到无聊。对初学者来说,DMN 非常容易在后台悄悄重启:你以为自己还在观察呼吸,其实脑子早已飞到十万八千里外。

身体扫描需要前额叶不断下达新指令(现在感受小腿、现在转移到膝盖),大脑一直有具体的小任务要执行,认知带宽被占据得更满,DMN 很难找到空隙插足。在杂念极其纷飞、心烦意乱时,身体扫描往往比观呼吸更容易让人「着陆」。

生理反馈

呼吸不仅是注意力的锚,它本身直接连接着自主神经系统。专注呼吸(尤其是拉长呼气)能直接通过迷走神经向心脏发送信号,快速降低心率,激活副交感神经,产生即时的生理镇定感。正念呼吸缓解焦虑的机制也正是这种「自上而下」与「自下而上」的精妙结合:先由 ACC 觉察、dlPFC 意志性地改变呼吸节奏(自上而下),然后呼吸肌通过迷走神经强制让心脏减速(自下而上)。

身体扫描更侧重于激活躯体感觉皮层和脑岛,让你发现身体里那些未被察觉的隐性紧张:紧咬的下颌、耸起的肩膀、紧绷的胃部。将觉察带到这些部位后,不需要刻意放松,神经系统往往会自动释放局部张力。

情绪处理

专注于呼吸更像一种「避风港」,当情绪风暴袭来时,紧紧抓住呼吸这个单一锚点,可以防止情绪继续恶化。

身体扫描则是「情绪的解码器」,情绪在身体上都有具体的物理映射(焦虑时的胸闷、愤怒时的胃部抽搐)。身体扫描训练你跳出情绪的故事内容(“他凭什么这么说我”),转而观察情绪的物理实质(“我感到胸口有一阵发热和紧缩”)。这种将情绪「物理化」的过程,是瓦解强烈情绪非常有效的方式。

脑岛的蜕变:从被动到主动

在未练习状态下,脑岛(高精度温度计)被杏仁核(报警器)的噪音淹没。你感知到的不是细腻的身体变化,而是被冲击的粗糙感觉。

练习正念后,杏仁核阈值提高、不再因小事乱叫,脑岛的灰质增厚、分辨率升级。你能清晰地读出:胃部有一点点紧缩感(可能是紧张),指尖有点凉。对身体的「觉察精度」提高了,但对压力的「过度反应」降低了。

身体扫描的意义远不止「停止当前的走神」。每一次完整的循环(走神 → 察觉走神 → 把注意力拉回身体的一小部分),都是背外侧前额叶在做一次「哑铃弯举」。这种长期训练会实质性地增加大脑皮层厚度,强化对注意力的掌控权,使你在日常生活中不再轻易被情绪或杂念劫持。

实战应用:从手机成瘾到日常觉察

下意识刷手机:一部习惯回路的形成史

「下意识刷手机」这个案例完美展示了大脑的奖赏系统和习惯系统如何协同工作,以及正念如何介入打断这个链条。

第一阶段:随机奖赏的诱惑——腹侧系统点火

社交媒体和短视频的算法采用了间歇性强化——你不知道下一次滑屏会出现无聊的广告还是极其有趣的视频。这种「盲盒效应」极大地刺激腹侧被盖区(VTA)分泌多巴胺,多巴胺倾注到伏隔核(NAc)中,产生强烈的期待感和「想要」的冲动,赋予手机极高的诱因显著性。与此同时,杏仁核处理着对错过信息的焦虑(FOMO)或当下的无聊感,这种负面情绪成为触发刷手机行为的内部提示。

第二阶段:控制权的转移——从「追求快乐」到「形成习惯」

当「无聊/看到手机 → 拿起滑动 → 获得新鲜内容」这个循环被重复成千上万次后,大脑开始改变神经通路的权重。多巴胺不再仅在看到有趣视频时释放,而是在看到手机的那一瞬间就开始释放。神经活动的重心从腹侧的奖赏驱动网络,逐渐移交给背侧纹状体主导的习惯和运动控制网络。行为的动机被剥离了,你不再是为了「想看有趣的内容」而刷手机,而是因为「处于这个情境中」就触发了预设的动作程序。

第三阶段:自动驾驶模式——基底核绕过前额叶

习惯彻底形成后,刷手机变成了下意识的。感觉运动皮层接收到视觉或触觉刺激,信号直接发送给基底核。背侧纹状体强烈激活直接通路,经苍白球和黑质网状部解除对丘脑的抑制,丘脑立即将兴奋信号传回运动皮层。在这个自动化循环中,负责冲动控制的前额叶皮层处于相对抑制或旁观的状态——基底核促成了「刺激-反应」的硬连接。

正念干预:把控制权拿回来

正念冥想的作用,就是通过激活前额叶,强行切断这个自动化回路。

步骤 A:觉察——ACC 打断自动化

手伸向手机的那一瞬间,或者刚感到无聊时,前扣带皮层突然亮起。内心台词:等一下,我注意到我有一种想拿手机的冲动。自动化的纹状体回路被打断,大脑从「自动模式」切换回「监控模式」。

步骤 B:暂停与去耦合——拉开距离

你没有立刻去拿手机,而是停下来感受这种「想拿」的感觉。vlPFC 发出抑制信号,物理上阻止手部肌肉的运动。脑岛开始工作,它不去想「手机里有什么」,而是观察身体的感觉:这种想看手机的冲动在身体里是什么感觉?胸口发紧?指尖发痒?还是喉咙干渴?这将多巴胺驱动的心理渴望转化为单纯的生理感觉:去耦合。

步骤 C:消退——冲动自行平息

你看着这种冲动像波浪一样升起、落下,最后没有拿手机,继续做手头的事。由于没有执行动作,多巴胺的预测落空(预测误差)。vmPFC 更新了价值评估:原来不看手机我也没事,那种焦虑感自己会消失。赫布定律逆向作用:「无聊」与「拿手机」之间的神经连接被削弱了。

冲动冲浪:极短暂的否决窗口

当冲动产生到最终行为发生之间,存在一个极短暂的「黄金时间窗口」。从大脑产生行动冲动,到意识到这个冲动,再到肌肉真正运动,大约有 200 毫秒的「否决窗口」,前额叶在此拥有最终的否决权。

行为被拦截后,冲动不会立刻消失。脑岛持续监测身体内部的紧张感和渴望感。如果运用正念,只是单纯地观察这种感觉而不评判,不再给杏仁核添加新刺激,基底核和丘脑的放电频率会逐渐降低,这股「海浪」最终因失去后续能量供给而平息。

Tip

如果无法在几百毫秒内提速前额叶,另一条策略就是人为拉长「刺激」到「反应」之间的时间差:

  • 打断运动序列: 关闭 Face ID / 指纹解锁,换成 6 位密码,几秒的输入过程足够前额叶发出疑问:我到底想干嘛?
  • 阻断视觉触发: 多巴胺系统对视觉线索极其敏感。将手机放在抽屉、背包深处或另一个房间,起身、拉开抽屉才能拿到手机的 5-10 秒,是前额叶介入评估的绝佳窗口。至少做到屏幕朝下放置,切断随机视觉刺激。
  • 削弱诱因显著性: 开启屏幕灰阶模式。高饱和度的红色通知角标和鲜艳画面是多巴胺系统的最爱。黑白屏幕极其无聊,能在源头上大幅降低大脑对「拿起手机」的预期快感。

更广泛的无意识习惯

下意识刷手机只是冰山一角。同样的底层模式:内在出现微小的不适感(无聊、焦虑、空虚),然后一个自动化反应迅速启动来消除它,驱动着大量无意识的习惯性行为:

  • 消磨型: 持续玩游戏只是为了获得某个奖励/达到某个目标,即使已经完全不享受过程。
  • 逃避型: 该写报告时反复整理桌面、该回重要邮件时先处理无关消息。
  • 安抚型: 压力大时暴饮暴食、心情不好时冲动购物、焦虑时咬指甲。
  • 确认型: 发了朋友圈后不停刷新看点赞数、发完消息反复检查对方有没有回。
  • 惯性型: 到家就打开播客当背景音、坐下来就翘二郎腿,没有「选择」过,它就自动发生了。

正念练习的价值正是在于拉长那个中间地带,让你有机会看见不适感、看见冲动,然后有意识地决定:我现在要不要回应它,还是就让它待一会儿。

超越焦虑:认知重评与感受的跃迁

正念不仅能帮助我们与负面情绪拉开距离,还能主动改写情绪的性质。最典型的例子是将焦虑转化为兴奋。两者的生理唤醒状态几乎相同(心跳加速、手心出汗),区别仅在于大脑给这些生理信号贴上了什么标签。

默认反应:杏仁核劫持

当你面临压力事件(如即将上台演讲),后脑岛疯狂接收身体信号(心率 120,手心出汗),杏仁核和 PFC 默认网络出于对社会性评价失败的本能恐惧,迅速贴上「社会性威胁」的标签。前脑岛将「威胁」与「高唤醒」缝合在一起,生成了强烈的焦虑感。

认知重评的三棒接力

第一棒:dlPFC——踩下离合器

dlPFC 被激活,像踩下离合器一样暂时打断了对「威胁」标签的自动沉浸。它构建出一个新的认知框架:我的心跳加速,是因为身体在为演讲供能。这不是生存危机,是展示成果的机会。

第二棒:vmPFC——覆写标签

vmPFC 接收新的逻辑叙事,调取历史记忆和个人目标进行比对,最终认可了这个新框架。它将当前环境的预测标签从「社会性威胁」改写为「高价值的机遇」。

第三棒:前脑岛——重新计算感受

前脑岛的办公桌上放着两份文件:来自后脑岛的生理数据(心跳 120,极度活跃)和来自 vmPFC 的最新预测(当前环境充满机遇)。前脑岛将两者重新缝合,不再把高唤醒解释为灾难前的恐慌,而是迎接挑战的兴奋。你依然紧张,但这是充满能量、随时准备行动的积极紧张。

长期练习:从「状态」到「特质」的质变

一开始,正念是一种「状态」,需要刻意调用 ACC 和 PFC 去管理大脑。长期练习后,正念变成一种「特质」,大脑的物理结构发生改变,调节变成了自动化的出厂设置。

硬件升级:物理结构的改变

根据神经可塑性原理,长期冥想者的大脑灰质密度和体积发生了可测量的变化:

  • 杏仁核萎缩 → 基准焦虑降低。 报警器的灵敏度下调了。面对同样的压力源,生理应激反应显著降低。你变得钝感于压力,却更敏感于生活。
  • 前额叶增厚 → 执行力增强。 dlPFC 和 vmPFC 的皮层变厚,大脑的 CEO 拥有了更多资源。做决策、抑制冲动、规划未来时拥有更强的算力。意志力不再是稀缺资源,而变成了常态能力。
  • 海马体强壮 → 复原力提升。 更好的背景识别能力。当坏事发生时,你能更快意识到「这只是暂时的」、「这只是局部的问题」,不再陷入灾难化思维。心理弹性变强了。
  • 脑岛高清化 → 直觉的提升。 脑岛折叠度增加,表面积变大,内感受分辨率极大提高。你能捕捉到微弱的情绪火苗,甚至在意识形成之前就感知到身体的直觉。这让你更了解自己,也更能共情他人。

软件优化:连接方式的重组

长期练习不仅改变了脑区本身,更改变了脑区之间的连接。

  • DMN 与 TPN 的反相关增强 → 专注更轻松。 以前你想专注(TPN),但脑子里还在放背景音乐(DMN),两者纠缠不清。训练后,开关变得灵敏,一旦进入专注模式,DMN 被瞬间抑制,杂念变少,心流状态触手可及。
  • 去耦合深化 → 情绪不再等于「我」。 杏仁核一响,自我中心的 mPFC 不再跟着响。你可以客观地看着痛苦升起,却不觉得它属于「你」。
  • vmPFC-杏仁核通路自动化 → 情绪调节无需刻意努力。 以前调节情绪需要走「土路」,反应慢且费力。训练后,vmPFC 到杏仁核之间建立了高速光纤,情绪调节变成了自动化的潜意识反应。你不是「忍住」了怒火,而是怒火刚冒头就被自动平复了。

行为表现:生活质量的质变

这些大脑变化最终落实为三种可感知的能力:

  • 暂停的能力。 在刺激和反应之间,你拥有一个宽广的选择空间。别人骂你一句,你不会立刻骂回去,而是能在一瞬间决定:他今天心情不好,我不接这个招。
  • 冲动冲浪的能力。 面对手机、甜食、游戏的诱惑,你不再被多巴胺牵着鼻子走。你能感觉到欲望在身体里像波浪一样翻滚,然后看着它平息,完全不需要动用痛苦的意志力去对抗。
  • 情绪颗粒度的提升。 你不再只会说「我心情不好」,而是精准地感知到:我有点焦虑,混合了一些兴奋,还有点胃部紧张。这种精准的识别,本身就是一种治愈。

结语

正念冥想不是玄学,而是一场有据可循的大脑资源再分配运动。它将能量从负责焦虑和自我反刍的默认模式网络和杏仁核中抽离,转移并投资到负责注意力控制的前扣带皮层、负责理智决策的前额叶以及负责当下感知的脑岛上。

通过这种反复的神经回路训练,大脑的物理结构发生重塑,「平静」不再是一种稍纵即逝的状态,而成为一种稳定的神经特质。正念冥想的终极承诺不是让你停止思考,而是让你拥有选择如何回应的自由。

昨天以前iOS

CocoaPods 正在退场,SwiftPM 才刚到第二章 - 肘子的 Swift 周报 #135

作者 Fatbobman
2026年5月11日 22:00

谷歌近期宣布,从下一个 Flutter 稳定版 3.44 开始,Swift Package Manager 将在默认路径上取代 CocoaPods,成为 iOS 和 macOS 应用的默认依赖管理器。CocoaPods 的 Trunk 仓库计划于 2026 年 12 月 2 日正式进入只读状态——这个时间点我们在 2024 年的周报中就讨论过了,但当 Flutter 真正开始在默认路径上用 SPM 替换 CocoaPods 时,还是引发了社区的广泛热议。

Android Studio 新版本与 Gradle 7.x 构建报错解决方案

作者 wyanassert
2026年5月11日 17:39

问题现象

在 Android Studio 中 Build / Sync 报错:

1
2
3
4
5
6
7
8
Could not compile initialization script '.../ijMapper1.gradle'.
> startup failed:
General error during conversion: Unsupported class file major version 65

java.lang.IllegalArgumentException: Unsupported class file major version 65
at groovyjarjarasm.asm.ClassReader.<init>(ClassReader.java:199)
...
at com.intellij.gradle.toolingExtension.impl.modelAction.GradleModelFetchAction...

从命令行直接执行 ./gradlew tasks 完全正常,只有通过 Android Studio 触发构建时才报错。


根本原因

这是一个 Android Studio 版本 × Gradle 版本 的二进制不兼容问题,与项目代码无关。

层级 说明
class file major version 65 Java 21 编译产物的标识(Java 11 = 55,Java 17 = 61,Java 21 = 65)
Android Studio Koala(2024.1)及更新版本 自带 JDK 21,其 Tooling Extension JARs(如 GradleOpenTelemetryGradleModelFetchAction)使用 Java 21 编译
Gradle 7.4.2 内置 Groovy 3.0.9 / ASM 9.1 ASM 9.1 最高只能解析 Java 17(class version 61)的 class 文件,无法读取 Java 21 产物

Android Studio 每次构建会向 Gradle 注入一个临时 init script(ijMapper1.gradle),Groovy 编译这个脚本时需要解析 classpath 上的 Android Studio Tooling JARs。新版 AS 的这些 JAR 是 Java 21 编译的,Gradle 7.x 内置的 ASM 读不了,直接崩溃。

命令行不受影响的原因:直接执行 ./gradlew 不经过 Android Studio 的 Tooling API,不会注入这个 init script。


解决方案

✅ 方案一:在 Android Studio 中指定 Gradle JDK 为 JDK 11(本文实际采用,推荐)

修改项目的 .idea/gradle.xml,将 gradleJvm 改为 JDK 11 的路径(路径需已在 Android Studio 的 JDK Table 中注册):

1
2
<option name="gradleJvm"
value="$USER_HOME$/Library/Java/JavaVirtualMachines/corretto-11.0.19/Contents/Home" />

注意:这里必须使用 $USER_HOME$ 变量形式,而非绝对路径,否则 Android Studio 在 jdk.table.xml 中匹配不到对应条目,报 “Undefined jdk.table.xml entry” 错误。

配置完成后,Android Studio 会:

  1. 用 JDK 11 启动 Gradle daemon
  2. 选用与 JDK 11 兼容的 Tooling Extension JARs(class version ≤ 61),避免 ASM 解析失败

此方案不影响其他同事(.idea/gradle.xml 中的路径是各自机器上注册过的条目,不同机器只要都注册了 JDK 11 即可)。


方案二:升级 Gradle 到 8.4+(需团队协调)

Gradle 8.x 采用 Groovy 4.x / ASM 9.5+,原生支持 Java 21 class 文件,从根本上消除兼容问题。

1
2
# gradle/wrapper/gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip

代价:AGP 7.x 不兼容 Gradle 8.x,需同步将 AGP 升级到 8.x,改动较大,需要团队统一升级。


方案三:降级 Android Studio(临时方案)

降回 Android Studio Jellyfish(2023.3.1) 或更早版本,其 Tooling Extension JARs 基于 Java 17 编译(class version 61),Gradle 7.x 的 ASM 9.1 可以正常解析。


排查思路总结

遇到此类问题时,快速定位的关键线索:

  1. 只在 IDE 内报错,命令行正常 → 问题出在 IDE 注入的 init script,而非项目构建脚本本身
  2. Unsupported class file major version 65 → 某个依赖/工具 JAR 用 Java 21 编译,运行时的 ASM/JVM 版本不支持
  3. 错误堆栈含 com.intellij.gradle.toolingExtension → 确认是 Android Studio Tooling Extension 和 Gradle 的版本兼容性问题

兼容性速查表

Android Studio 版本 内置 JDK Tooling JAR class version 最低兼容 Gradle
Jellyfish 2023.3.1 及以前 JDK 17 61(Java 17) Gradle 7.x 可用
Koala 2024.1.1 及以后 JDK 21 65(Java 21) 需 Gradle 8.4+ 或手动指定 Gradle JDK 为 17/11

Fighting Hyrum's Law in LLVM

作者 MaskRay
2026年5月10日 15:00

With a sufficient number of users of an API, it does not matterwhat you promise in the contract: all observable behaviors of yoursystem will be depended on by somebody. — Hyrum's Law

In a compiler, the most common form of Hyrum's Law is dependence onunspecified behavior — hash bucket order, the order of equalelements after std::sort, padding offsets. The same framingcovers a few cases that are technically undefined behavior (use of aninvalidated iterator) or plain incidental properties (ABI struct layout,ELF section offsets).

When the compiler itself harbors such a dependency, the symptom isusually output that varies build-to-build: an unstable sort that landsdifferently after the standard library changes, a hash map whoseiteration order shifts when the hash function does. Occasionally thevariation is run-to-run within a single build —DenseMap<void *, X> keys with an ASLR-derived seedreorder buckets each invocation. Either way, reproducible builds,bisection, and bug reports all assume same input → same output, and astealth Hyrum dependency breaks that.

This post surveys some mechanisms that perturb the contract's blindspots so dependencies cannot quietly form.

Hash seed perturbation

The first line of defense is the hash function itself.llvm/include/llvm/ADT/Hashing.h:

1
2
3
4
5
6
7
8
inline uint64_t get_execution_seed() {
#if LLVM_ENABLE_ABI_BREAKING_CHECKS
return static_cast<uint64_t>(
reinterpret_cast<uintptr_t>(&install_fatal_error_handler));
#else
return 0xff51afd7ed558ccdULL;
#endif
}

The seed XORed into every llvm::hash_value is theruntime address of install_fatal_error_handler — underASLR, different every process. The header comment is explicit:

the seed is non-deterministic per process (address of a functionin LLVMSupport) to prevent having users depend on the particular hashvalues.

Every hash_combine / hash_integer_valuecall picks up the seed, and every DenseMap<K, V>keyed by a hash_value-using type then reorders its bucketsper run. MD5, BLAKE3, SHA1, SHA256 stay byte-stable — those are theright tools when you actually want a digest.

My commitce80c80dca45 introduced the seed in 2024.

Container iteration order

Code can grow dependencies on the iteration order.LLVM_ENABLE_REVERSE_ITERATION walks hash containersbackwards to flag violations.llvm/include/llvm/Support/ReverseIteration.h:

1
2
3
4
5
6
7
template <class T = void *> constexpr bool shouldReverseIterate() {
#if LLVM_ENABLE_REVERSE_ITERATION
return detail::IsPointerLike<T>::value;
#else
return false;
#endif
}

DenseMap flips its BucketItTy tostd::reverse_iterator<pointer>;SmallPtrSet swaps begin() andend(); StringMap bitwise-NOTs the hash beforebucket selection — the only thing that perturbs StringMap,since its hash bypasses get_execution_seed.

Unlike the hash seed, reverse iteration isn't auto-on withassertions; -DLLVM_REVERSE_ITERATION=ON opts in explicitly.In 2026 has already merged fixes triggered by it: 7f703cabf728(MLIR SSA-value completion order), 0b3afd35c41d(MLIR SROA alloca order), and f5e2c5ddcec7(a clang test).

Iterator invalidation

Orthogonal to iteration order: what happens to an existing iteratorafter a mutation. llvm/include/llvm/ADT/EpochTracker.h:

1
2
3
4
5
6
7
8
9
10
11
12
class DebugEpochBase {
uint64_t Epoch = 0;
public:
void incrementEpoch() { ++Epoch; }
~DebugEpochBase() { incrementEpoch(); } // catches use-after-free

class HandleBase {
bool isHandleInSync() const {
return *EpochAddress == EpochAtCreation;
}
};
};

DenseMap and friends inherit fromDebugEpochBase. Mutations bump the epoch; iterators captureit at construction and assert on mismatch. The destructor bumps too, sostale iterators into destroyed containers assert rather than read freedmemory.

Without it, mutate-during-iteration "happens to work" depending onbucket layout — and bucket layout is what the hash seed and reverseiteration above perturb. The epoch check turns the latent bug into aclean assert regardless of which "lucky" layout the run lands on.Collapses to a no-op under NDEBUG.

Pre-shuffling unstable sorts

The same defensive pattern shows up twice in the monorepo, indifferent sub-projects, years apart.

llvm::sort underEXPENSIVE_CHECKS

llvm/include/llvm/ADT/STLExtras.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#ifdef EXPENSIVE_CHECKS
namespace detail {
inline unsigned presortShuffleEntropy() {
static unsigned Result(std::random_device{}());
return Result;
}

template <class IteratorTy>
inline void presortShuffle(IteratorTy Start, IteratorTy End) {
std::mt19937 Generator(presortShuffleEntropy());
llvm::shuffle(Start, End, Generator);
}
} // end namespace detail
#endif

template <typename IteratorTy, typename Compare>
inline void sort(IteratorTy Start, IteratorTy End, Compare Comp) {
#ifdef EXPENSIVE_CHECKS
detail::presortShuffle<IteratorTy>(Start, End);
#endif
std::sort(Start, End, Comp);
}

std::sort and qsort are unstable; codeobserving the order of equal elements is depending on undocumentedbehavior. Pre-shuffling makes that observation different every run. commit5a3d47fabcb6 added the wrapper in 2018, motivated by PR35135.

LLVM also ships its own llvm::shuffle rather thancalling std::shuffle, "so that LLVM behaves the same whenusing different standard libraries." A reproducibility tool whosereproducibility depends on the host stdlib is worse than no tool — andthe linker section below relies on this.

llvm::stable_sort deliberately does not pre-shuffle; itis the explicit opt-in for code that legitimately needs ordering ofequal elements.

libc++_LIBCPP_DEBUG_RANDOMIZE_UNSPECIFIED_STABILITY

libc++ has a near-perfect parallel mechanism, designed for downstreamusers rather than the project's own internals.libcxx/include/__debug_utils/randomize_range.h:

1
2
3
4
5
6
7
8
9
10
template <class _AlgPolicy, class _Iterator, class _Sentinel>
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX14
void __debug_randomize_range(_Iterator __first, _Sentinel __last) {
#ifdef _LIBCPP_DEBUG_RANDOMIZE_UNSPECIFIED_STABILITY
if (!__libcpp_is_constant_evaluated())
std::__shuffle<_AlgPolicy>(__first, __last, __libcpp_debug_randomizer());
#else
(void)__first; (void)__last;
#endif
}

Three callsites:

  • std::sort — pre-shuffles the input.
  • std::partial_sort — pre-shuffles the input andre-shuffles the unsorted tail afterward.
  • std::nth_element — pre-shuffles, then re-shuffles eachside of the partition.

Seed handling rhymes with get_execution_seed: ASLR orstatic std::random_device for per-process variation, with_LIBCPP_RANDOMIZE_UNSPECIFIED_STABILITY_SEED=<n> as afixed-seed escape hatch. Off by default; C++11 and later only.

libcxx/docs/DesignDocs/UnspecifiedBehaviorRandomization.rstexplains the motivation:

Google has measured couple of thousands of tests to be dependenton the stability of sorting and selection algorithms. As we also plan onupdating (or least, providing under flag more) sorting algorithms, thiseffort helps doing it gradually and sustainably.

It cites PR20837 — aworst-case O(n²) std::sort — as the upgradelibc++ specifically wanted to ship. The shuffle is the gating tool: ifdownstream tests pass with it enabled, they will pass after thealgorithm change too.

Comparing the two is more interesting than either alone:

  • llvm::sort's wrapper is internal hygiene: LLVM is itsown primary user, so the shuffle lives in STLExtras.hbehind a build flag with no docs.
  • libc++'s wrapper is user-facing — DesignDocs/ page,public macro, public seed override, explicit "Patches welcome."invitation. It has to be: libc++'s users are not libc++, and thecontract being defended is the C++ standard itself.
  • libc++ generalizes the primitive:__debug_randomize_range applies at three callsites, eachdeclaring which sub-range the algorithm leaves unspecified. LLVM'swrapper only covers the simpler equal-element case.
  • Hashed containers — std::unordered_* iteration order —are unspecified in both, but libc++ does not randomize them.LLVM-the-library does; on this one surface LLVM is ahead of its ownstdlib.
Linkeroutput: --shuffle-sections and--randomize-section-padding

Two ELF-only lld knobs perturb layout details that no contractcovers.

--shuffle-sections=<glob>=<seed>

lld/ELF/Writer.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (const auto &patAndSeed : ctx.arg.shuffleSections) {
...
const uint32_t seed = patAndSeed.second;
if (seed == UINT32_MAX) {
// If --shuffle-sections <section-glob>=-1, reverse the section order.
// The section order is stable even if the number of sections changes.
// This is useful to catch issues like static initialization order
// fiasco reliably.
std::reverse(matched.begin(), matched.end());
} else {
std::mt19937 g(seed ? seed : std::random_device()());
llvm::shuffle(matched.begin(), matched.end(), g);
}
}

Three regimes in one option:

  • seed = -1 — deterministic reverse, stable even as newsections appear. Glob .init_array* to -1,rebuild, run the test suite: anything that breaks is a realstatic-init-order bug. One flag, no Frankenstein link script.
  • seed > 0 — deterministic random shuffle,reproducible across runs and hosts (because llvm::shuffleis host-independent). Useful in CI without breaking bisection.
  • seed = 0std::random_device()-seeded.Fresh nondeterminism every link.

History: 423cb321dfaeintroduced the =-1 reverse mode; 16c30c3c23efgeneralized to per-glob seeds, which is what makes the.init_array*=-1 recipe possible; c135a68d426ffixed a bug where the feature itself produced an invalid dynamicrelocation order — even Hyrum mitigations have correctness traps.

--randomize-section-padding=<seed>

The sister option perturbs section offsets by insertingpadding between input sections and at segment starts(lld/ELF/Writer.cpp):

1
2
3
4
static void randomizeSectionPadding(Ctx &ctx) {
std::mt19937 g(*ctx.arg.randomizeSectionPadding);
// Insert padding between input sections and at segment starts.
}

Callers grow dependencies on padding-induced offsets the linker neverpromised — profile-guided pipelines, side-channel research, exploittoolchains pinning to specific addresses. A seeded perturbation makesthose dependencies visible.

Both options are ELF-only; MachO and COFF ports have nothingequivalent.

ABI break detection

llvm/include/llvm/Config/abi-breaking.h.cmake:

1
2
3
4
5
6
7
8
9
#if LLVM_ENABLE_ABI_BREAKING_CHECKS
ABI_BREAKING_EXPORT_ABI extern int EnableABIBreakingChecks;
LLVM_HIDDEN_VISIBILITY
__attribute__((weak)) int *VerifyEnableABIBreakingChecks =
&EnableABIBreakingChecks;
#else
ABI_BREAKING_EXPORT_ABI extern int DisableABIBreakingChecks;
...
#endif

Every TU including the header takes a weak reference toEnableABIBreakingChecks orDisableABIBreakingChecks depending on its own build flag.Mixing the two against the same libLLVM produces anunresolved symbol at link time. MSVC gets the same guarantee via#pragma detect_mismatch.

Out-of-tree users routinely compile against headers from one tree andlink against a different libLLVM. Without this gate,whichever struct layout the link happens to pick silently miscompiles;with it, the link fails.

What LLVM is not doing

The mechanisms above all target surfaces no stable consumer shouldcare about: bucket order, equal-element sort order, init-array order.Debuggers, profilers, sanitizers, and reproducible-build infrastructureconsume those outputs and need them stable.

In some cases, stronger guarantee is only provided with explicitoptions. For example, Bitcode and textual IR preserve use-list orderonly under -preserve-bc-uselistorder /-preserve-ll-uselistorder.

A near-cousin: clang's -frandomize-layout-seed /__attribute__((randomize_layout)). Mechanically the same —seeded std::shuffle on struct fields — and it doescoincidentally invalidate offsetof dependencies. But theintent is exploit mitigation, cribbed from GrSecurity's Randstruct GCCplugin: per-build kernel hardening, not a developer tool.

老司机 iOS 周报 #370 | 2026-05-11

作者 ChengzhiHuang
2026年5月10日 22:13

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

新闻

🐕 DanceUI 开源

@Kyle-Ye: DanceUI 是 ByteDance 开源的类 SwiftUI 声明式 UI 框架,目标是在 SwiftUI 风格的 DSL 和更低系统版本兼容之间做一层工程化实现。项目提供了与 SwiftUI 对齐的基础能力,包括 StateBindingEnvironmentPreferenceKeyNavigationViewScrollView 等,同时通过 DanceUIRuntime、DanceUIGraph、OpenCombine 和 DanceUIObservation 相关模块支撑状态更新与渲染链路。整体预期是可以直接复用 SwiftUI 生态且最低支持 iOS 13。

文章

🐢 Trust, Then Verify

@Cooper Chen:以这篇文章《Trust, Then Verify》开头,你会很快意识到 : 真正拉开 AI 编程差距的,不是“写得快”,而是“能自证”。作者围绕 iOS 场景搭建了一套完整验证闭环 : 构建侧用 xcodebuild + xcbeautify 控噪并保留原始日志兜底,交互侧用 AXe 与 simctl 将启动、点击、读值、断言流程脚本化,让 Agent 从“像是对了”走向“可验证地对了”。更可贵的是文中沉淀出的工程方法论 : 入口宽松、断言严格;在最靠近原因的位置让失败可见;把经验性步骤持续机械化;通过使用驱动迁移而非一次性大审计。对 iOS 团队和所有实践 Agentic Coding 的开发者来说,这篇文章都值得细读。

🐕 Anthropic 产品团队为何快过所有人

@zhangferry:Anthropic 的成功不能简单归功于 Claude Code、Co-Work 这类明星级产品,而是应该着重关注这类产品迭代背后的方法论。这篇文章的访谈嘉宾 Cat Woo 是 Claude Code 和 Co-work 的产品负责人,她系统性地分享了 Claude Code 团队的实战经验。他们的产品是 AI,他们的工作方式也因为 AI 被全面重塑;访谈涵盖了一个核心命题:当模型能力每隔几个月就跃升一次,产品经理的角色该如何重新定义?

🐎 Why Your pbxproj Is Bloated (and How to Fix It)

@david-clang:为什么 .pbxproj 容易臃肿和冲突?因为它记录了所有文件的 UUID,而使用 Groups 会让 Xcode 追踪每一个文件变动。文章介绍的解决办法很简单,用 Folders 代替 Groups,Xcode 只引用目录,不追踪单文件。另外,使用 XcodeGen 也能解决,它将 .xcodeproj 移出 Git,全靠本地按需生成,实现零冲突。

🐕 Six Years Perfecting Maps on watchOS

@Barney:David Smith 在这篇文章里复盘了 Pedometer++ 为 watchOS 打磨地图体验的六年历程,内容很适合拿来理解小屏设备上的产品设计为什么往往是工程能力、交互约束和视觉语言一起决定的。作者最初用服务端生成地图验证方向,随后因为离线能力、性能和交互需求,转向自研基于 SwiftUI 的地图渲染引擎;在界面层面,则经历了大量失败方案,最终收敛到“地图作为顶部主页面,指标信息层叠覆盖”的结构,并通过点击进入浏览模式来解决地图交互与 watchOS 手势冲突。文章后半段也很有意思:为了适配 watchOS 26 的 Liquid Glass,他甚至定制了新的底图与深色模式样式,并解释了为什么最终没有直接采用 MapKit。整体来看,这是一篇把平台限制、设计迭代和技术取舍讲得非常完整的实战复盘。

🐕 Synchronization in Swift: Actors vs Queues vs Locks

@Smallfly:这篇是 Swift 并发同步机制的深度指南,系统对比四类核心同步工具的设计哲学、适用场景与权衡取舍,为开发者构建线程安全代码提供全面决策框架。文章从语言级到硬件级逐层拆解各工具特性:Actors 适合异步环境下的缓存、服务类等状态组件,DispatchQueue 适配依赖执行上下文协调的场景,Locks/Mutexes 性能开销低适合同步小操作,Atomics 则适配计数器、状态标志等单操作场景。文中还给出各工具典型误用的修复方案,打破性能优先的误区,综合安全性、API 设计清晰度、认知负荷等维度给出选择指导,通过场景化分析与可运行示例,帮助开发者理解 Swift 并发同步本质,在复杂系统中做出合理技术选择。

工具

🐎 Cupertino v1.0.0 "First Light"

@阿权:Cupertino MCP 发布 v1.0.0 "First Light" 版本。Cupertino 是一款本地 Apple 文档爬虫 MCP,使用 Swift 编写。用于解决 LLM 对 Apple API 回答幻觉问题,让模型推理时能获取精准、排名合理的 Apple 官方文档,本次版本实现了全流程(爬取、索引、排名、服务、分发)稳定,具体如下:

  1. 搜索准确度大幅提升。优化语料库和权重策略;apple-docs 内部使用 BM25F 算法结合 AST 符 号索引,叠加多个搜索规则以精准命中目标;提升 SQL 性能。
  2. 分发与部署简化。合并三个数据库为单包。
  3. 架构与兼容性提升。数据库升级、MCP 协议升级、爬虫强化。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

为什么要写作

2026年5月8日 08:00

Paul Graham 在 Writes and Write-Nots 中预见了一个分裂为「写作者」与「非写作者」的世界。他说,这远比听起来的要危险, 因为它本质上是一个「思考者」与「非思考者」的世界。文中他做了一个类比:

在工业革命之前,大多数人的工作都能强健体魄。现在如果你想变得强壮,你得主动去健身。强壮的人依然存在,但只有那些”选择”强壮的人才会如此。写作(即思考)正在经历同样的转变。

为什么写作跟思考会强相关?因为人的思维是网状的、跳跃的,而文字是线性的。写作本质上是一个将混沌思想进行结构化的过程,它强迫你梳理逻辑、暴露盲区、发现矛盾。我们时常以为自己想明白了,但很可能只是产生了一种理解的幻觉。脑子里那些「感觉很清楚」的想法,一旦要落到纸上,就会变得支离破碎。所以写作最深层的价值不在于输出,而是倒逼你更清晰地思考。

当你以为自己已经足够了解某个话题,觉得没有必要再写出来时,创作冲动就在这种「我已经懂了」的幻觉中消解了。只有当真正坐下来写的时候,才会发现自己的理解漏洞百出。

Tip

这其实就是认知心理学中的 “解释深度错觉”(Illusion of Explanatory Depth)。人们通常会高估自己对某个事物的理解程度,直到被要求把它完整地写下来或讲出来时,才会暴露出无知。不写作的人,往往终生被困在这种错觉中。

假如你养成了写作的习惯,你和世界的关系会发生一个微妙但深刻的转变:一切经历都变成了素材。这会带来一种视角转换:你不再只是经历生活,同时也在观察和收集。哪怕是生活中的低谷、摩擦与困境,经过文字的咀嚼,也能转化为绝佳的素材。我在李娟的文字中深刻地感受到了这点,当她用平实的文字包裹真挚的感情来描写她与妈妈、妹妹、外婆还有「叔叔」发生在阿勒泰的点点滴滴时,不禁感慨,会写作真好。

如果不写作,今天想通的道理,三个月后可能就忘了。强迫自己写下来,可以看到认知轨迹的变化:回头看几年前写的东西,哪些观点被推翻了,哪些直觉被验证了。这种「和过去的自己对话」的体验,是其他方式很难替代的。更重要的是,这些文字是只属于你自己的数据,它们构成了你独一无二的思想档案,在 AI 时代,这些数据投喂给 AI 后,可以让它更懂你,成为真正个性化的思维伙伴。

如果持续写作,你写下的每一篇文章,都是一个可以被搜索、被发现、被传播的节点。短期来看,它帮你完成了思考;长期来看,它有可能产生意想不到的连接 — 吸引志同道合的人、打开新的机会、甚至改变你的职业轨迹。持续写作会提升驾驭文字的能力,这会带来一种掌控感和踏实感。把心里想的东西完整、细腻地写出来,这种满足感是刷短视频无法比拟的。

写作除了能帮你整理思维、记录生活,它还是一项通用且重要的能力。 37Signals 在 Getting Real 里有一章专门聊了写作: Hire good writers

如果你在几个候选人之间犹豫不决,请务必聘用那个文笔更好的人。无论对方是设计师、程序员、市场营销还是销售人员,优秀的写作能力都会显现出其价值。因为简洁、高效的写作与编辑,往往意味着同样简洁、高效的代码、设计、邮件及日常沟通。

这本书出版于 2006 年,距今已有 20 年,这个观点过时了吗?我觉得没有,一个擅长写作的人依旧能在职场中获得额外的加分。

既然写作是一件收益很高的事,为什么真正写作的人却越来越少了呢?因为它本质上就是一件难事。

首先,它要求你同时做好两件截然不同的事:思考和表达。想要写得好,必须先想得清,而清晰的思考绝非易事,它需要你静下心来去啃透问题的方方面面,确保没有错误和遗漏,然后再形成一个大纲。但如果按照这个大纲去写,又容易掉进另一个陷阱:忽视了读者视角。gwern 在 First, Make Me Care 这篇文章专门讲了这个问题,他以威尼斯这座城市为例,展示了读者视角和非读者视角在内容呈现上的差异。前期准备工作完成后,精准的语言表达也极具挑战,如果没有经过刻意训练,文字和真实想表达的内容之间就可能出现错配。

除此之外,还容易被自己的品味打压。作为阅读者,你的品味往往远高于你当前的写作能力,当你写出第一稿时,你的高品味会立刻觉察到它的粗糙,这种落差会带来极大的自我怀疑和挫败感。要缩小这个差距,唯一的办法就是持续创作。_why 的这段话,用在写作上也一样合适:

当你停止创造,你的才能就不再重要,你所拥有的只剩下你的品味。而品味会裹挟你,让你排斥他人、变得狭隘。所以,去创造吧。

除了过程痛苦,结果的不确定性也会劝退很多人。它不像学编程,可以通过做 App 来营收,也不像内容创作(视频、播客)那样有相对成熟的变现路径。写作的回报是高度延迟和间接的:思维更清晰、表达更精准、认知更深刻 — 这些好处真实但不可量化,而且很难在短期内看到立竿见影的效果。所以除非你从写作的过程本身获得满足,否则很难坚持下去。

既然写作这么难,又这么重要,而 AI 又很擅长文字,可以让 AI 来代劳吗?这是我们下意识会想到的解决方案,但写作的过程就是思考的过程,而思考是不应该被外包的。让 AI 替你写,等于让别人替你想 — 你得到了一段文字,但失去了思考本身带来的所有价值。

Tip

虽然不应该让 AI 来写作,但可以让 AI 来帮助自己完善思维盲区和误区,前提是得先有自己的想法、框架、初稿,然后让 AI 在此基础上帮你查漏补缺、拓展视角。

这就是为什么要写作,它是无需天赋的思维健身,具有普遍性、重要性、高门槛、复利效应的特性,而且只要你愿意,可以一直写下去。

HyperFrames 实战:用 HTML 写一支 41 秒的产品介绍视频

作者 唐巧
2026年5月6日 23:03

介绍

HyperFrames 把视频当成 HTML 来写。 一个 index.html 就是一支视频:

  • data-* 属性控制时间
  • GSAP(一个老牌的 JavaScript 动画库)控制动画
  • CSS 控制外观
  • 借助 FFmpeg 生成 MP4

它由 HeyGen 开源,配套 CLI、Skills、Studio 预览器和 13 个相关 skill 包,安装命令:

1
npx skills add heygen-com/hyperframes

为什么值得试

做产品介绍视频,常见的三类工具各有痛点:

路径 优势 痛点
Premiere / After Effects 视觉上限高 工程文件不可版本控制、模板化扩展难
Remotion 程序化 + React 需要搭工程、依赖链长
文生视频模型 上手快 数据准确性不保证、定制化弱

HyperFrames 的吸引点是:保留”代码即源”的可维护性,但把心智模型压缩到只有 HTML / CSS / GSAP 三件事 —— 适合不需要太复杂动效,偏内容呈现类的视频生成。

实战尝试

我用它做了一支介绍斑马思维机发展历程的视频。

claude code 的提示词如下:

帮我使用 npx skills add heygen-com/hyperframes 来安装 hyperframes 这个 skill,然后读取网上关于的斑马思维机的介绍,帮我做一个 30s-45s 的介绍斑马思维机发展历程的视频,里面要涵盖机器和题卡上升的时间线。

视频要有配音,可以找一些开放版权的背景音乐。

它做出来是横版的,我又让它生成了一个竖版的,提示词如下:

帮我另外再生成一个适合在手机上呈现的竖版的版本

效果视频

这是横版生成的效果:

参考链接

安庆之旅

作者 唐巧
2026年5月5日 09:37

这个五一节和家人去安徽旅游了一趟,30 号出发,先在合肥玩了一天,然后在安庆玩了 4 天。

安庆给我的第一印象是一个类似绵阳的非省会城市。小巧精致,环境干净。不管是打车,还是亚朵酒店,还是吃饭,还是去旅游景点,都很方便有序。

安庆好吃的很多,比较有特色的是一种用红薯粉做的丸子,放到鸡汤或者鱼汤中,把汤汁的鲜味都吸进去了,很美味。另外,每家店都有牛肉锅贴或者牛肉煎包,这也是当地人很喜欢的小吃。小吃街(当地人叫七街)上的美食主要集中在炒饭,炸串(当地人叫油炸),烧烤。物价不贵,点餐一顿饭人均不超过 70。

安庆紧临长江,很多历史名人出自安庆,最有名的可能就是中国共产党早期的核心领导人陈独秀了。陈独秀的两个孩子陈延年,陈乔年都为革命牺牲了,在安庆,我们参观了他们的纪念馆。这次参观,我又有新的思考,我在想:陈独秀虽然留过学,但是他也没有显赫的家世背景,他是如何聚集起大家,形成一个巨大的有凝聚力的政党的呢?最终我发现了一个一直被我忽视的事情:他创办了《新青年》。《新青年》作为一个媒体渠道,在那个年代可以极大化个体的声音,不但可以激发大家反抗,也使得志趣相投的人士被召唤起来。所以,陈独秀在那个时代,选择了一个极其有效的启动模式,让共产党能够逐步发展壮大。

安庆是黄梅戏的发源地。我们全家去听了一场黄梅戏。我对这种艺术表演形式一直不太感冒,但是这次提前做了一些功课,倒也理解了黄梅戏。黄梅戏的成功还是因为它获得了当时劳动人民的喜爱,因为它的内容讲的都是劳动人民的生活,唱腔又容易理解,形式又不复杂,这些都利于劳动人民在劳作之余作为娱乐消遣的形式。

在安庆旅游期间,正值斯诺克世锦赛期间,吴宜泽最终击败了墨菲,拿到了世锦赛冠军。吴宜泽的比赛跌宕起伏,多次陷入失败的边缘,又多次神奇地反转。特别是半决赛中,他的对手在赛点打丢了本可致胜的黑球,而那一球的难度并不大,让人唏嘘。吴宜泽在和墨菲的最后一场中,从容冷静,最后依赖一个后斯诺让墨菲给自己留了一个机会球,最终他利用这个机会上手,完全超分和最后的胜利。

在观看这场神奇的比赛时,我也在感叹命运,在一个变化的时代,拥有好心态,努力做好当下,其实就拥有了无限的可能。

让 AI 从称手到称心 - 肘子的 Swift 周报 #134

作者 Fatbobman
2026年5月4日 22:00

从开始深度使用 AI 工具至今已有三年。三年间,我亲历了 AI 能力的飞跃,也越来越清晰地触摸到它的边界。截至目前,AI 早已是非常出色的效率工具,但如何让它写出真正“称心”——符合我个人风格、想法与设计哲学——的代码,仍是一个不小的挑战。

Agent Loop 简介

作者 唐巧
2026年5月2日 08:10

一、一个反直觉的事实

先说一个看起来有点反常识的事:LLM 本身是无状态的

每次调用模型,本质上就是一次”文本补全”——你扔一段 prompt 进去,它根据这段 prompt 续写一段输出,然后整个过程结束。下一次再调用,模型对上一次的事一无所知。从机制上讲,它和 2020 年的 GPT-3 没有本质区别,都是一次性的补全器。

但 2024 年之后,我们看到的 Claude Code、Cursor Agent、各种 deep research 工具,明明可以连续工作几十分钟、调用几十个工具、修改几百个文件,看起来”自主”得不得了。

这两件事怎么对得上?

答案藏在外面那个 while 循环里。

Agent ≠ 模型
Agent = 模型 + Loop + Tools + Context 管理

模型本身没有变,变的是包在它外面的那层东西。这层东西现在常被称作 harness(脚手架),而 harness 里最核心的部件,就是 Agent Loop

这篇文章想回答三个问题:

  1. 这个 loop 长什么样?
  2. 它为什么这样设计?
  3. 它什么时候会失效?

二、最小可运行的 Agent Loop

把所有花哨的东西都剥掉,一个 Agent Loop 的本质大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
messages = [{"role": "user", "content": user_input}]

while True:
response = llm(messages, tools=available_tools)
messages.append(response)

if response.stop_reason != "tool_use":
# 模型没要求调用工具,说明它认为任务结束了
return response.text

# 模型要求调用一个或多个工具
for tool_call in response.tool_calls:
result = execute(tool_call)
messages.append({
"role": "tool",
"content": result
})
# 进入下一轮,让模型看到工具结果,决定下一步

就这么二十行。这就是 Claude Code、Cursor、几乎所有 coding agent 的核心。

拆解一下,里面只有四个动作:

  1. 模型推理:把当前 messages 丢给 LLM,让它产出下一步的 response
  2. 工具调用判断:如果 response 里有 tool_use,就执行;如果没有,循环结束
  3. 工具执行:在沙箱/真实环境里跑这个工具,拿到结果
  4. 结果回灌 context:把工具结果塞回 messages,进入下一轮

到这里,请允许我强调一个关键认知:

所谓”Agent 的自主性”,本质就是模型在每一轮看到更新后的 context,自己决定下一步。没有任何魔法。

不是模型变得”会规划”了,是循环让它有机会根据上一步的结果,再做一次补全。它的”思考”只发生在每一次模型调用的那一瞬间,loop 只是一遍遍把它叫醒,告诉它”环境又变了,你再看看”。

理解了这一点,后面所有的工程设计,都只是这个最小循环的变体。


三、Loop 的关键设计决策

最小循环能跑,但不够用。一旦把它放到真实场景里,会立刻撞到一堆问题:循环什么时候停?context 越涨越长怎么办?工具调用错了怎么办?要不要并行?

围绕这些问题做的工程取舍,决定了一个 Agent 框架的性格。下面是五个最关键的决策维度。

1. 终止条件:什么时候跳出 loop

最朴素的写法是”模型不再要求调用工具就停”,但这在生产里远远不够。常见的多重终止条件:

  • 模型主动 stop:response 里没有 tool_use,正常出口
  • 达到 max_iterations:硬性步数上限(比如 50 步),防止失控
  • 检测到循环:连续几次调用相同工具+相同参数,强制中断
  • 用户中断:Ctrl+C、关闭对话窗口
  • 预算耗尽:token 数或时间超限

每个出口背后都是一次工程权衡:上限太小,复杂任务做不完;上限太大,一旦模型卡住就烧钱。

2. Context 增长策略:长任务下怎么办

工具结果一律塞回 context,会带来一个朴素但致命的问题——context 是线性增长的

一个改 50 个文件的任务,可能要读 100 次文件,每次读取的内容都进 context。跑到一半,context 已经塞了几十万 token,模型注意力开始稀释,关键信息被淹没。

工程上有几种常见思路:

  • 全量回灌:最简单,短任务够用,长任务必崩
  • 滑动窗口:只保留最近 N 轮,老的丢掉,但可能丢关键信息
  • 摘要压缩:触发阈值后,让模型自己总结前面的内容,用摘要替换原文
  • 分层压缩:Claude Code 的 /compact 机制就属于这一类——保留最近上下文 + 历史摘要 + 关键信息(如已修改的文件列表)

这一项是目前差异最大的设计点。后面会看到 learn-claude-code 项目专门有一节叫 s06_context_compact,就是为了解决这件事。

3. 工具选择机制:模型怎么”选工具”

两种主流做法:

  • 原生 function calling:通过 API 把工具 schema 一并传给模型,模型在 response 里直接产出结构化的 tool_use 块。Claude、GPT、Gemini 都支持。优点是稳定,几乎不会出格式错误。
  • 提示词约定格式:在 system prompt 里告诉模型”想调用工具就输出 <tool>...</tool> 这样的 XML”,外层用正则解析。早期 ReAct 论文就是这么做的,胜在通用,任何模型都能用。

现在新项目基本都默认用 function calling,但提示词约定法在一些场景仍有价值——比如要让一个本地小模型当 agent,或者要做更细粒度的格式控制。

4. 错误处理:工具调用失败怎么办

工具会出错。文件不存在、API 超时、参数类型不对、权限不足……

两种处理思路,本质是信任谁

  • 塞回 context 让模型自我纠正:把 error message 当作普通的 tool_result 回灌,相信模型能看懂错误并调整。优点是灵活,模型常常能从”file not found”反推出”我应该先 ls 一下”。
  • 外层拦截:harness 直接处理特定错误类型,比如重试、降级、报警。优点是可预测。

实践里通常是混合策略:致命错误外层拦截,业务错误丢给模型。这件事的判断需要工程经验。

5. 并行 vs. 串行:单 Agent 还是多 Agent

单 Agent 的极致是一轮内并行调用多个工具。Claude 现在原生支持一次返回多个 tool_use 块,harness 并行执行后一次性把所有结果回灌。这能显著降低延迟。

更复杂的是多 Agent 协作:主 agent 派发子任务给子 agent,子 agent 独立 loop,结束后回报。这里立刻冒出一个新问题——路由:主 agent 怎么知道哪个子 agent 适合这个任务?是基于 metadata 标签匹配,还是让主 agent 读子 agent 的描述自己判断?这两种思路的优劣,是另一篇文章的话题了。


四、从 50 行到 1000 行:一个 Agent 是怎么长出来的

讲完抽象的设计维度,看一个具体的项目——开源项目 learn-claude-code

这个项目的好处是:它把一个 nano 版 Claude Code 的演化拆成了 12 个递进的 Python 脚本,每一步只引入一个新机制。从 s01 到 s12,代码从大约 50 行长到 1000+ 行。

读它,相当于把上一节的设计决策亲眼看一遍怎么落到代码里。

下面挑几个最关键的节点:

s01 Agent Loop:朴素的 while

1
2
3
4
5
6
while True:
response = model(messages, tools)
if response.stop_reason != "tool_use":
return response.text
results = execute(response.tool_calls)
messages.append(results)

这就是上一章伪代码的真实版本。50 行,能跑,能调用 bash 完成简单任务。这是一切的起点

s02 Tool Use:从一个工具到多个工具

引入 read_filewrite_filebashgrep 等多个工具。重点不在工具本身,而在 wire——怎么把工具 schema 注册给模型,怎么 dispatch 到真实函数。

到这里,agent 已经能完成”读文件、改文件、跑测试”这种基础编程任务了。

s03 TodoWrite:对抗”目标漂移”的第一道防线

一个有意思的设计:让 agent 自己维护一个 todo list。

为什么?因为长任务里,模型很容易跑偏。任务一大,model 在第 30 轮已经忘了第 1 轮的目标是什么。TodoWrite 工具强制 agent 在开工前把任务拆成清单,每完成一项划掉一项。

这本质上是用工具调用替代记忆——不指望模型记住,而是把目标固化成 context 里随时可见的状态。Claude Code 现在就是这么做的,效果非常显著。

s04 Subagent:什么时候该拆出去

主 agent 不是万能的。当一个子任务的 context 会污染主 agent 的判断(比如要读一大堆代码才能定位 bug),就该把它丢给子 agent。

子 agent 有自己独立的 loop、独立的 context,跑完只把结论返回给主 agent。这是用 context 隔离换取主 loop 的清晰

s06 Context Compact:长任务的生存策略

直接对应第三章讲的”context 增长策略”。当 messages 长度超过阈值,触发 compact:让模型把前面的对话总结成一段摘要,用摘要替换原始消息,保留最近几轮原文。

这是目前所有长任务 agent 的共同方案。没有 compact,agent 就走不远

s07–s12:再往后

任务系统、后台任务、多 agent 团队、worktree 隔离……每一层都是在同一个 loop 上叠加工程能力。但本质都没变:还是那个 while 循环


读完这个项目,最值得记住的是它的核心宣言:

The model is the agent. The code is the harness.
模型才是 Agent,代码只是脚手架。

这句话听起来像废话,其实暗藏一个反直觉的判断——你写的那一千行 harness 代码,不是在让 Agent “更聪明”,只是在帮模型别搞砸。模型本身已经具备 Agent 能力,harness 的工作是给它工具、管好上下文、防止失控。

Harness 越薄,说明模型越强。


五、Loop 的边界与失效模式

Agent Loop 不是银弹。在生产里,它会以几种典型方式翻车:

1. 上下文窗口爆炸

最常见。长任务跑到一半,context 涨到几十万 token,模型注意力被稀释,开始重复读同一个文件、忽略关键约束。Compact 是缓解,但不是根治——压缩本身也会丢信息。

2. 工具调用幻觉

模型有时会编造不存在的工具,或者给真实工具传错误参数(比如发明了一个本不存在的 flag)。这件事在小模型上尤其严重。缓解办法是收紧 tool schema 的描述、用 function calling 而不是提示词约定,以及在 harness 里做参数校验。

3. 死循环

模型反复调用同一个工具拿同样的结果,不收敛。常见于”修一个 bug 但根本没想清楚”的场景:跑测试 → 失败 → 改一行 → 跑测试 → 失败 → 改回来。需要在 harness 里检测这种模式并强制中断。

4. 目标漂移

多轮之后忘了原始任务。前面提到的 TodoWrite 是一种缓解,更激进的做法是定期”自检”:让 agent 每隔 N 轮 reflect 一次,对照原始目标审视当前进展。

工程上常见的缓解组合是:context 压缩 + 工具白名单 + step budget + 显式 reflection 节点。每一项都不彻底,但叠在一起能撑很久。


六、Agent Loop 是临时方案,还是终极形态

最后留一个开放问题。

回看现在所有的 Agent 框架——Claude Code、Cursor、LangGraph、OpenClaw、learn-claude-code——本质都在围绕同一个 while 循环做工程优化。终止条件、context 压缩、子 agent、todo 管理……每一项都是因为模型本身做不到,所以 harness 替它做

但模型还在变强。

Claude 已经支持 extended thinking——模型在一次调用里能做更长的内部推理。原生的 tool use 在每一代都更稳。multi-step 的 planning 能力肉眼可见地在涨。

那么一个不那么好回答的问题是:

当模型本身具备足够长的推理链和原生工具使用能力时,外部那个 while 循环还需要存在吗?

也许某一天,你只需要一次 API 调用,模型在内部就完成了全部规划、工具调用、上下文管理。harness 被吸收进了模型本身。我们今天精心设计的这些 loop 控制机制,会变成一段历史。

也可能不会。也许 harness 永远存在——因为外部环境永远是 harness 的边界,模型再强,也需要一个东西替它和真实世界对接。

不知道。但这就是现在做 Agent 最有意思的地方:你不知道自己写的这一千行 harness 代码,到底是产品的核心资产,还是即将过时的过渡方案

唯一确定的是,所有故事的开头,都还是那个最朴素的 while 循环。


参考项目:shareAI-lab/learn-claude-code,一个把 Claude Code 拆成 12 个递进版本的开源教学项目。建议从 s01_agent_loop.py 开始读。

我对《缺氧》的游戏理解

作者 云风
2026年4月28日 21:06

最近一个月,我一直在玩《缺氧》(Oxygen Not Included) 。前几年玩过 100 多小时,算是比较熟悉了。但这个月又高强度的玩了 300 多小时,目前总游戏时长为 485 小时,感觉对这款游戏有了一些新的理解。

最初喜欢上这个游戏,是想找一个类似《异星工厂》的以自动化为核心玩法的基地建设类游戏。Factorio 是我最喜欢的游戏之一,游戏总时长达 2905 小时,是放置类游戏之外我花的时间最多的游戏。我很想看看类似游戏还能向什么不同方向发展。这两个游戏的目标都非常类似:在无人星球上殖民,建设一个基地发射火箭逃出升天。它们的拓展玩法有相似之处:发射第一枚火箭只是游戏的开始,需要继续探索星空和不同的星球,面对更复杂的挑战。所以,我一开始是从 Factorio 的角度去看待 ONI ,随着对游戏的理解,才发现它们其实有不同的内核。

ONI 初看的确像是 Factorio 和 Rimworld 的结合体(btw, Rimworld 我也有 123 小时的游戏时长,对它也有初步了解)。和 Factorio 的传送带特色不同,ONI 是基于类似 Rimworld 的工人驱动基地运作的。但 ONI 里的工人没有 Rimworld 中复杂的社会关系和社会情感联系,更像是一群无情工作的机器人。所以我认为它们像是 Factorio 里的无人机加上了细致编排任务的能力。

但玩了这么长时间后,我认为 ONI 和 Factorio 有着巨大的区别。

Factorio 的运作方式是简单清晰明确的,玩家可以在明确规则下不断扩大生产规模,而不同规模下的自动化需要解决不同的问题。所以,Factorio 玩家常说 The Factory Must Grow 。所以,Factorio 鼓励蓝图的使用、Mod 和游戏本体之间相互促进、不断完善更丰富的自动化手段。游戏除了标志性的机械爪传送带外,还有流体、电力和热量系统,它们都以相当简单的规则运作。其中略复杂的流体系统,在 2.0 也被简化为超级水箱,把“流动”去掉了。

ONI 的底层逻辑或许也很简洁。但它模型并非基于确定性规则的物流。相对比 Factorio ,玩家首先理解的是物品怎么在传送带上移动、如何被机械爪抓取;液体如何被传递,这些都和物流有关;但 ONI 首先传达给玩家的是气体的扩散和液体的流动,它们都是在环境中自动进行的:不需要玩家铺设轨道,玩家也难以精确控制它们。稍微深入游戏后,玩家还会发现,贯穿游戏的难题是热量。热同样以某种规则在环境中以单元格为单位交换,但热却无法作为一个实体直接操控。玩家需要去控制某个区域的温度,但却没有直接的手段。游戏后期最大的挑战是制备液氢制造远程火箭,这需要极低的温度;还需要驯服金属火山和岩浆,这又需要处理上千度的高温。

在缺氧中,资源在初期丰富但却有限。从游戏中期开始,玩家就会发现资源越来越紧缺,玩家的绝大部分手段都是在做资源转换:将 A 转换为 B 并可能伴随着质量损失。而绝大部分原始质量就是地图板块上的那些砖块,并不会凭空变多;相比而言 Factorio 的地图趋于无限,只要你肯向远方发展,永远有采不完的矿,解决好物流即可。同时,随着 ONI 中的生产活动,花掉的能量全部转换为热量。大多数游戏手段都是把热从 A 传递给 B ,而让热净减少的手段却极其有限,且藏得很深。

不看攻略的话,从游戏内对各种设施的字面解释很难直接找到减少热的方案。这也是新手通常都会在中期把基地变成 40 度以上的蒸笼而束手无策。初见游戏时,看到游戏界面中的文字大篇幅的罗列每种材料的比容、热传导率、热特性、固态液态气态的转换温度等会觉得离自己很远,但熟悉游戏后会发现,这些才是核心要素。

我最初玩 ONI 完全不得章法,基地盖得奇形怪状。这倒是和最早玩 Factorio 很像。但和 Factorio 不同,我并不完全靠自己摸索理清条理。看了几篇 ONI 的攻略后,我照着攻略指示修建基地,知道每个阶段要解决什么问题,大致怎么做。和 Factorio 明确的科技树驱动不同,ONI 的科技树其实爬得很快。玩家很少被卡在科技上,甚至在游戏中期就能解锁大部分科技,整个游戏过程也不会被科技进度卡住。真正困难的是,大部分科技解锁的物件,从字面理解上都很难想到它能做什么,有什么副作用。我感觉从这点上,ONI 的门槛比 Factorio 要高,很需要攻略引导。

前几年,我最初的 100 小时游戏就是按某篇攻略引导玩进去的,并深得其乐。但最近几百小时,我发现自己琢磨能玩出非常不一样的感受。游戏流程也和之前攻略引导的体验截然不同。最显著的差异就是:我最新的一盘直到在第三星球开荒,一共只养了四个小人。其中三个是开局选的,第四个是在第二星球上系统送的。也就是整个游戏过程,我都没有在传送门要一个新的小人。

绝大部分 ONI 的攻略都不会介绍这样的玩法。玩家或许把不加人手的玩法视为高手的挑战,但我是在理解了这个游戏的内核后,发现这是推进游戏进程的最佳手段之一,而且游戏过程会非常轻松。我来解释一下这种游戏思路的内在逻辑:

前面说到,游戏的大部分资源都是地图上的方块。只有喷泉和流星雨是从外部补充的净增加质量,对眼冒金星 DLC 而言,母星去掉了流星雨就只剩喷泉。游戏过程的生产活动,本质上都是资源转换。例如,你可以把小人看成将氧气加食物转换为二氧化碳和废水的转换器;食物则通常是由动物或植物将泥土转换而来,烹饪过程可能有净水参与。把两者联合起来看,小人把氧气 + 泥土 + 水转换为了二氧化碳和废水。

最大的例外是科研,基础科技是对水和泥土的净消耗。也就是水和泥土消失了,点亮了科技树。

同时,所有的生产活动都需要消耗能量。这是一个能量到热量的转换过程,最终反映为地图温度的升高。这个游戏本质上是在治理混乱,即减少地图的熵。把地图上的不同砖块转换为有序的基地,有效的维持玩家主动导向的转换过程,同时系统以某种内在规则让物质在地图上自然流动:这包括了重力作用下的液体流动、开采的砖块碎片自然掉落、气体分层等。由于一切转换器(工人、动植物、机器)都有适用环境,生物需要对应的气(液)体环境、光照、温度;机器相比生物对环境的要求没那么苛刻,但也是存在的。所以玩家建设基地就是分两个阶段处理问题:一开始的建设阶段把对应的材料搬运到位、随后的维护阶段维持环境的稳定性。

无论玩家养多大的工人规模,科研的总净开销是一样的。游戏的前半段,需要的核心转换是 1200 kg 的钢,用于制造第一台制冷机。因为制冷机+蒸汽机组合是游戏最稳定的将热净减少的方式。铁转换为钢的过程受限于石灰的产能,通常在初期是蛋壳。需求和产能也是恒定的,也和工人规模无关。

而且,游戏里大量的资源转换环境其实起的作用更大,并不需要花特别多的人力,而玩家只要用小人下达指令后,更多的等下去静待花开。

更少的工人意味着在产出第一台制冷机前,更少的生产活动,更少的做资源转换。维持工人的核心在于平衡氧气到二氧化碳的转换过程。这里分两个问题:制备氧气和处理二氧化碳。

制备氧气在前期主要是两个途径:用藻类转换或分解水。

藻类是相对有限的,但养活三个工人和八个工人其实区别不大(通常不会消耗完),细微的差别在于挖空地图导致的空间扩大导致的气体扩散。虽然总量不变,但熵增加了。新手很容易到处开挖,但我的经验是越早把基地封起来有选择的逐步扩展才会减少要处理的问题。

电解水制氧看起来干净的多:不需要挖藻类,而初期基地周围的环境水本身就需要治理(否则无法按规则规划基地)。但游戏隐藏了一个副作用是新手很难注意到:电解水制氧会产生额外的热。前面说到,游戏本质上的核心挑战就是热治理。所以我认为把这个问题推迟(到科技树基本爬完)有极大好处。所以,保持一个极小团队,有利于推迟电解水制氧。事实上我最近一盘游戏直到游戏后期需要氢气之前都没有电解水。

另一个问题是处理二氧化碳。在发射近程二氧化碳火箭之前,二氧化碳几乎没用。有两种手段处理它:用碳素脱离器处理掉,或存起来。因为中后期一定会适用二氧化碳火箭,我认为存起来比较好。但在开发太空前,很难找到低温区液化或固态化二氧化碳,保存气态二氧化碳非常占空间。所以,二氧化碳转换得越少越好。早期在开发太空前一定会用煤炭发电过渡,这是部分二氧化碳源头,另一部分就是工人的日常呼吸了。更少的工人意味着呼出越少的二氧化碳。电力消耗也会因为工人数量减少而略微减少,但少的不多。人数增加而增加的电耗主要是在食物制备。科研、生产石灰、精炼金属这些基本需求倒是和工人规模相关性较少。

工人偏少最明显的劣势是干活的人少了,玩家可能会觉得游戏节奏无意义的变慢,实则不然。在 Factorio 里,新手通常不太愿意扩大生产规模,因为那意味着脱离已经经营好的舒适区。但 ONI 不同,规模化生产在游戏大部分时段几乎难以带来好处。玩家在中前期要解决的问题并不太多,一步步总能做完,它们并不能靠扩大生产规模提升效率。相反,人越少要做的维持生存方面的工作越少,专心做推进科研和基地发展的步骤就可以了。用三人团队发展,从游戏内时间看,迈入游戏中期的总周期数比一个八人团队明显要长,但实际游戏时间却不会增加太多。这是因为,游戏内小人干得慢了,但可以用最高速度推进游戏时间;而大规模团队通常会用最慢速度玩游戏,甚至还要时常暂停。本质上来说,维持最小团队,推进游戏需要(点鼠标)的操作数量变少了。小团队也会大量减少中后期工人闲置的时间。

另一个优势在于:工人干活是会加经验升级的。升级带来了能力的成长,提高了工作效率。因为总的工作量差不多,所以越小的团队,经验越集中,就能更快的得到几个高素质的全能工人。劣势或许是人数太少发展需要的技能不够,在多人团队中,这往往是不同发展方向的人承担的。无论开局怎么刷,三个人都无法全部覆盖需要的专长。但我的经验时,在中期洗点,只要规划好每个阶段需要做什么,完全够用。例如:只有在装修和做化石勘探任务时才需要大师艺术,做完就洗掉即可;同理,铺设传输轨道需要的高级技能,也可以在需要时再点出来,做完项目就洗掉即可。

最近玩 ONI 给我的感受是:玩游戏不能着急,需要规划好,一次做一个工程。这其实是一个慢节奏游戏,让小人生存并不难。下指令容易,但执行需要很长的游戏内时间。相比 Factorio 会发现,修建一个设施需要极长的时间:改造场地环境、远距离搬运材料、建造;改建(拆除)甚至比建新的还久。但 ONI 一盘游戏必须要做的工程并不算太多,几乎都是一次性的。所以,这个游戏不像 Factorio 那样依赖蓝图,反而因地制宜处理问题更多一些。尤其是,环境的自然变化:液体流动、气体扩散都需要很长的时间,把游戏节奏慢下来,利用好环境的自然变化反而要做的总工作量会减少。欲速不达是新手常犯的错误。例如,不把基地封好就出门到处乱挖,导致后期治理要花更多时间。尤其是病毒进入基地、不可呼吸气体混入氧气环境都是一瞬间,但再想处理干净却是及其费事的。

这些小问题(环境的恶化)并非致命,但会潜在削弱长期的工作效率,或增加远期治理的工程量。新手和老手基地往往在视觉上就有极大不同:整齐规划的干干净净。装修房间,清理杂物是看起来短期收益最小的工作,装饰度提高的长期收益很容易被忽略,尤其是人手不足的时候不想先做。但实际上,这种迟早要完成的工程,只要不影响生存,反而应该早点完成。


ONI 对我来说,最重要的游戏体验是不断发现小问题并提出解决方法。这得益于游戏内的物理规则制造的环境让同样的问题有不同的解决方案。每种方案都很难做到完美,总有一些副作用,而游戏者对游戏理解越多,就越能清楚如何承担这些副作用。

比方说,制备氧气是游戏的基础,游戏名就叫做 Oxygen Not Included 。但所有的制氧方案都是把氧气排放到环境中的。好在小人生存需要的氧气也是从环境中摄取。但一旦需要提取氧气使用:比如冲入氧气面罩或太空服,就需要把氧气放进管道,从环境中分离氧气就麻烦的多。直接的方法是用抽气机加气体分离器。看起来很彻底,但需要的能耗却不应忽视。不想 Factorio 那样,缺电就想办法扩展电网,ONI 里要考虑烧煤导致的二氧化碳治理问题,能量消耗带来的热量问题,这些都是短期看不到的问题,但长期游戏必将受到影响。

藻类制氧可以制造一个纯氧房间,这样就能节省一个分离器。但人工添加藻类时可能带入的二氧化碳就可能是一个干扰因素。运输轨道和无人机运输都是解决方法。环境气体元素信号器不耗电,可以用信号控制减少制氧室混入的其它气体,也能解决一部分问题,但不彻底。不过,ONI 中其实不需要彻底解决问题。因为和 Factorio 不同,在 Factorio 的传送带上混入杂质会堵塞整条流水线,必须手工清理;而 ONI 偶尔在氧气管道中混入一点杂质气体,只会引起设备的损坏,小人会自动修理。只需要权衡这个维修开销是否能值回票价:剩下的气体分离器的开销。为了让优化掉气体分离器更有价值,ONI 里大部分机器其实是不太耗电的,或是有极短的工作时间,大部分闲置,所以整个机器需要的总电量在优化得当时并不高。而气体分离器这种只要通气就得需要长期工作的机器反而显得功率占比很大。对比 Factorio ,传送带筛选器是不耗电的,除了太空上的空间限制,都是鼓励你使用。这个差异导向了不同的游戏体验。

同理,电解水制氧,你可以在管道中分离氢气和氧气(以及环境中可能存在的杂气),也可以设计好房间利用气体的自然环境分层。但依赖环境一则需要用时间来换,二是气体扩散过程的随机因素导致不能 100% 确定。

凡涉及气体隔离和液体分离都有类似问题。最常见的是制作真空室,它是做氯气消毒室的前置,也是做辐射管道的基础,还可以用于隔热。从多道气闸的信号控制,再或不同水门(用液体隔开不同的空间,同时让人可以穿行)的搭建方法,都伴随着很多隐晦的副作用。例如看似完美解决问题(隔离真空室)的水门可能带来一瞬间让小人湿身的负面 buf ,或是可能让无人机浸水,还可能因为温度变化液体发生相变。ONI 中并没有直接提供一个可以完全隔离两个空间的气闸门,而是设计成开门会有一小段时间漏气或漏水,这留下了很多的操作空间。

ps. 如果你真的想不耗电过滤气体,在充分理解 ONI 的流体系统后,可以用气阀和管桥巧妙的搭建出一个机构解决这个问题。有兴趣可以在 youtube 上找 3 Ways To Filter Gas! Oxygen Not Included Tutorial / Guide 这个视频来看。


最后,介绍一下我的游戏开荒流程,可以作为针对网络上其它常见攻略流程的一份补充。开荒指基本开发完母星和第二星,用短程火箭开发第三星,并研究出中程火箭,可以去更远的星球。

2.0 眼冒金星的标准模式中,第二星和母星有传送器互联,可以双向传输人和物资,所以可认为是一体的。如果玩经典模式,即更大的母星则需要做一些调整。

如前文所述,我的游戏流程最大的不同是只用系统给的工人,不招募任何新人。所以初期一直用三个人,在第二星上获取第四个。如果有“神秘隐士”这个故事特质,可以在最后招募一个高属性小人作为补充。但最好不要选“梦境合成器”故事特质,因为需要通过延长睡眠时间(甚至专门的做梦团队)获得全员属性提升很不划算。毕竟全员也没几个人。

可以把游戏开荒过程看成是若干个小的项目,因为人手少,所以大致串行完成这些项目即可。

第一个项目是挖出基本空间,并开发初级科技。

开发初级科技只需要泥巴(一级)和水(二级),这是一切的基础,所以必须最先完成。挖出最小空间额外建两房间,其中一间卧室,一间临时厕所。初始传送门自带光源,所以可以就地改造成科研室。房间全部用 16 * 4 的规整空间,可规划为以中间通道为轴堆成,每层左右两间,纵向发展。我倾向于左侧生活区,即科研室、卧室、卫生间、食堂、温室,后期保持 25 度以下环境温度;右侧偏生产,放置更多热源。左右两侧之间留两格的通道即可,一列纵梯,预留一列滑杆。

由于高压电缆和变压器有极高的负面装饰,所以我倾向于放在工作区的更右侧并用墙隔开,然后每层靠墙设一个变压器,然后是检修用的第二梯子加纵向高压电缆。高压电缆的右侧可以留下未来的无人值守区,用于发电、蒸气室等。进入无人区需要留一个房间放氧气面罩站。

综上,基地横向每层三个 16 * 4 的房间,两个纵向通道。

在这个阶段,厕所是临时的,可以扔在右侧工业区,未来会拆掉。而生活区的卧室是永久的,所以可以建在科研室的正上方(初期氧气充沛)。至于水源,早期基地附近肯定有,可能面临的问题是占据了规划中的房间位置。所以需要留出足够位置,不用破环规划。

在第一个阶段,如果克制的开挖空间,是不需要制氧的。因为不招募新人,所以地图上的氧石挥发氧气就足够用了。食物也不需要补充,开局送的营养棒和挖土翻出来的淤泥根够吃,所以不需要修建食物压缩机。唯一要建的是人力发电机和科研台(唯一耗电设备)。

第二个项目是建造卫生间。

我之前看的攻略大多是快速建立煤炭发电来取代人力发电机以节省人力。但我认为人少的时候初期生存压力也少(因为系统开局送的生存资源是一样多的),人力其实完全够用。三个人大致的分工是一个科研,一个发电,一个建设。相比烧煤发电,通旱厕反而是更浪费人力的工作。如果顺利的话,完全可以在两个旱厕都堵住前,让自动化卫生间投入使用。

卫生间的水是可以自循环的。即冲厕所和吸收用的水远少于小人排除的废水,配合净水器反而有废水的净产出。需要考虑的是如何处理多余的废水不要堵塞管道的问题。一般的解法是让多余的废水送去液培砖种芦苇。之后做太空服正好需要芦苇。

至于地图附近有没有芦苇可以拔来种要看随机刷的运气,通常是有的只是远近问题。采芦苇时应该采取最小空间破坏原则,挖到就把路重新堵上,避免带入过多病毒,以及不必要的氧气扩散。

卫生间和净水房分开,我试过两个方案,其一是和卫生间上下两层,净水房后面兼做农场;后来发现更好的是左右两间,兼做仓库。

注意这里卫生间产生的废水净化后不要引入净水储备,因为其中有食物中毒病毒。让它们自循环和种芦苇即可,和基地其它用水完全隔离。如果节奏安排得当,还可以点出装桶和倒桶科技,同样放在净水房中。这时就可以拆掉一开始的手压水泵,并把拆掉旱厕扔出来的废水投入卫生间的水循环中。这可以省掉基地外额外挖一个坑倒废水的工作。

废水最好能尽快处理,尤其是在它挥发太多的污染氧之前完成。基地中混入一些污染氧虽不致命,但影响工作效率。

这个阶段,工作量其实是不均等的。科研的活最多,但当然不能让小人闲下来。但原则是整理基地,即使是收拾杂物也比向外开挖更重要。

第三个项目是修建米虱壁虎农场和哈奇煤炭生产间。

米虱是重要的食物来源,人少的话可以吃很久,而且腌制米虱由于保质期很长,还可以在其后用于短途太空旅行。不少攻略建议这时开始种蘑菇,我认为在人少够吃的情况下完全不必。倒不是种蘑菇麻烦,是因为处理菌泥带来的病毒需要的步骤较多(需要收集氯气消毒)。如果不处理病毒的话,就涉及后面会面临的病毒治理问题。

普通壁虎很好捉,但养出产塑料的变种比较花时间,所以要尽早养。如果运气好在附近挖出小动物变异器这个故事特质就更省事一些,不然多生几次蛋也能出来。塑料不是很着急,开荒需求也不多,完全可以等养出滑鳞壁虎产出。不需要特地去建石油产线做塑料。

哈奇可以把砂岩转化为煤炭,开荒期电力省点用的话,就不需要出去挖煤了。而且哈奇产蛋量较大,蛋壳是开荒要的那 1200kg 钢的原料,石灰的稳定来源;而且少量的生蛋可以作为食物补充。对于稍微有点规模的基地,比如传统的 8 人基地,这点生蛋肯定不够吃,但超小的 3 人基地,则不容忽视。这也是不需要种蘑菇,后期也不需要种冰霜小麦的原因之一。

如果运气不错在地图中间找到同伴芽的话,可以挖回来种上传播花香。但这属于锦上添花。

这个阶段如果氧气不足,可以随便加两个藻类制氧机。

第四个项目是装修基地,扩建出氧气室和发电房。

随着基地的扩大,为了提高物流效率,早点点出滑杆科技是有价值的。因为煤炭发电出的比较晚,所以二氧化碳问题不会太严重。空出一个房间专门制氧是有必要的。通常放在基地上方右侧的工业区,因为一般而言制氧过程都伴随着热量产出(单纯藻类制氧不严重)。为了减少后面分离出纯氧的难度,早点在上方留出纯氧室比较好。

这时不推荐电解水制氧,原因前文已经阐述了。但养壁虎需要一点氢气,推荐在地图上抽过来,否则电解一点水也也是可以的。

发电房放在基地最右下,后面会和其它部分隔离开,所以要留出一个房间用于内部的氧气检查站柜。

第五个项目是出门前的准备,包括密封基地,氯气室,氧气站、太空服等。

这个项目的目标室把基地和外部完全隔离开,出门带上氧气面罩,最好是太空服。氯气室用来消毒。但不需要一步到位,一开始只需要抽取附近环境中的氯气。扩建基地一定会遇到氯气区,这时需要先在入口先建好气泵,然后密封抽真空。这个过程漏一点氯气无所谓,反正随着时间会自然分层,到时候在基地下方和堆积的二氧化碳一起分离即可。抽出的氯气装箱后,通到基地的出口洗矿。这是很多新手会忽略的开发步骤,因为病毒的危害并不会立刻显现,但是处理病毒的过程会比较漫长。

如果病毒进入基地,处理起来也不算麻烦。如果前面卫生间水循环搭建正确,应该不会有食物中毒的问题,主要会遇到的是粘液肺,多见于挖开菌泥区。如果种蘑菇的话,不洗掉菌泥上的粘液肺,就很容易在基地蔓延开。粘液肺在纯氧环境会慢慢消失,所以除了隔绝病毒外,重要的是净化掉基地内的污染氧。同时,吸入一口污染氧还会给小人一个短期的负面 buf 。所以在基地口的氧气面罩检查站外,需要和出门气闸间留一点空间,避免开着门换衣服。

前面几个项目按部就班的话,因为只有三个小人,所以生存完全不会有压力。操作强度也不大,或许游戏内的周期过了不少,但大部分时间都是在加速运行的,真实游戏时间不需要太长。

接下来要做的事情主要有两个,都是需要出门完成的:为开发第二星做周全的准备以及开发星球表面发展太阳能和火箭基地。

开发二星一般需要挖通三个设施,分别是小人传送站和发送以及接收物资的站点。我觉得把物流提前打通,也就是把管道都修好再去二星会让后面的工作简单很多。这样一到二星,就立刻可以利用母星的资源。

眼冒金星 DLC 的开局母星非常小,所以都不会在很远的位置,应该马上就能看见。挖路要尽量少挖,用最短距离挖过去,然后把管线拉通即可。穿好太空服再做这个工程可以提升不少效率。顺便还可以把附近的故事特质完成了,尤其是小动物变异器对获取滑鳞壁虎很方便。

但是,铺设固体传输管道需要大量金属,所以可能需要专门开采铜矿。采矿机就非常有用了,可以节省大量人力。但如果从机器拉电缆可能比较费事,比较简单的方法是做电池,使用两个袖珍放电器就可以带动一个采矿机。电池还可以用于物流无人机,早点做两三个无人机,完全就不会有物流负担,基地的杂物也会自动被整理的干干净净。

另一方面,直接向上挖通地表即可,也穿上太空服。到了地表后第一件事就是铺太阳能板。早点关掉煤炭发电可以省去好多麻烦。路上如果遇到低温区,可以把玻璃和金属精炼等热量大户先临时塞进去,这样就不会破坏基地内部的温度。后面建好蒸汽房还可以搬回来。

一旦攒出 1200kg 的钢,就可以开始搭建蒸汽房了。蒸汽机加冷凝机是最通用的热量消除机构。因为蒸汽机是唯一一个确定且直接的设备,可以热量转换为能量。它吸入 125 度以上的蒸汽,转换为 95 度的水,同时发电。这里发电是次要的,最重要的用途是这个过程热量消失了。但为了获得 125 度的蒸汽,除了在后期可以利用环境外,稳定的主动手段就是使用冷凝机。它的工作原理是输入高温液体,输出低温液体(可以用于基地其它的降温用途),其中的温差变成热量有机器本身散发到环境种。所以,冷凝机本身不消除热量,它只搬运热量。虽然系统本身热量减少了,但冷凝机的工作过程会产生大量的环境热,它正好用于把水烧为超过 125 度的蒸汽。但这样,冷凝机本身必然处于高温中,所以必须用耐高温的钢来制作。这就是开荒需要 1200kg 钢的原因——制造第一台用于烧开水的钢制冷凝机。

怎么搭蒸气房网上有很多介绍,这里就不细讲了。但我想说的是,可以参考攻略,但完全不需要抄攻略中的图纸。一旦明白原理,自然会有很多想法,肯定会做出不同的蒸汽房设计。ONI 和 Factorio 不同,它更难存在最优解,一切都和游戏过程相关。

如果是三人基地,其实搭蒸汽房降温的需求并不强烈。比如我玩的最新一盘,搭好蒸汽房后,基地平均温度才不到 20 度,要解决的是略微增温而不是降温。但温度调节迟早是需要的,工业化温控这是必须完成的基地设施。当然这不是唯一的路径,有兴趣的话还可以试试用冰霜萝卜控温,或是将高温二氧化碳到地表固体化带走基地的热量。

一般来说,开发第二星的主要目标是建立起石油产线。表面上看起来,石油是工业化生产塑料的基础。但其实游戏的开荒期塑料需求并不大:装修完基地,改造地板和梯子,建立通向地表的载人管道,这些用壁虎产出就足够了,完全不需要通过石油生产。

石油除了中后期做石油引擎的中程火箭外,最重要的用途是用于金属精炼的冷却剂。所以我们只需要做一点点出来就够用。

一开始只能用水做金属精炼冷却剂。如果背靠冷源,比如附近就有低温喷泉,那么这种天然冷却源就可以稳定的工作很久。但如果自己在基地内部做冷却循环,就会发现经常需要修机器。因为金属精炼,尤其是炼钢,会放出大量的热,让冷却液迅速升温。而水超过 100 度就会气化,太低温度会结冰,这些相变都会破坏管道。放置温度巨变要么需要一个相当大的热容器,比如上面提到的大水池,尤其是天然冷源;要么就需要很复杂的自动化控制机构。虽然把玩自动化机构也值得玩很久,但更简单的方法是换成石油做冷凝剂。油的比热容比水小,炼钢时温差更大。但这反而是优势。因为超过 125 度的油就可以用来烧开水,用蒸汽机带走热量,同时还能回收部分电能。

所以,游戏中蒸汽机加炼钢也是一套基础的机构。懂得原理的话,也可以玩出很多很多不同的设计。

第三个星球就需要找出火箭去了。它通常很近,所以用二氧化碳引擎最简单。这时,游戏前期存的那些二氧化碳就用得上了,而且二星上的石油工业副产品也是二氧化碳,可以直接传送回母星,基本是不缺燃料的。

二氧化碳引擎速度快,尾焰温度低,对环境破坏最小。唯一的缺点是不能造大火箭。但小小的单人空间把弄起来也格外有趣。火箭部分我完全没看过攻略,有了前面足够的游戏经验,我感觉自己摸索更为有趣。火箭上主要需解决的问题是怎么让小人在里面舒服的活上几天。燃料和航程在这个阶段都不需要考虑。

而小人的需求无非是食物、卫生和氧气,以及避免高压力。

这个时候,因为人少的优势,每个人都会成长的很好,所以洗掉不必要的技能点,只点出驾驶的话,压力完全不会是问题。短途并不需要储备太多的食物,如果是两三天往返的话,随便扔点食物在火箭内就不会挨饿。

氧气用藻类制氧机就能解决,只要在出发前排空舱内的二氧化碳即可。如果肯盯着高气压的负面 buf 的话,把高压氧气压入舱内也能用很久,这样也可以不必设制氧机。所以这里也有很多不同的解决方案。舱内空间非常的小,所以需要做很多空间上的选择。

最后是舱内上厕所的问题。无疑需要用壁挂强排厕所最省空间,但充厕所的水怎么办?我第一反应是装个水箱,但一个水箱(3x2)就占掉了一半的有效空间。随之发现,其实排灌器就是用来这里的。1x2 的空间可以存 200kg 的水,只是用于冲厕所搓搓有余。

等开发完第三星球,以及搜罗完太空的数据卡,基本上科技树就爬完了。这时可以拆掉基地的科研设施,开始转石油火箭去更远的星球拿石墨做富勒烯,制造超级制冷剂。利用它降温才能制备液态氢,然后就是做液态氢引擎达到最大航程通关游戏了。

我暂时还没有玩到最后,所以这里就无法介绍后期的游戏体验。

Swift 并发正被更广泛地接纳 - 肘子的 Swift 周报 #133

作者 Fatbobman
2026年4月27日 22:00

从 Swift 5.5 引入符合现代编程思想的新并发模型算起,一转眼快 5 年了。从 5.5 到目前的 6.3,Swift 社区一直在采用小步迭代的方式,积极推进并发 API 的演进。但在应对过多的新关键字、复杂的隔离概念以及一些容易引发困扰的“反模式”时,这个过程对开发者来说并不算顺利。

独立开发了一款健康记录 App,聊聊几个让我纠结很久的设计决策

作者 SameX
2026年4月25日 07:57

上线一周,下载量是零。

说实话这很正常,冷启动就是这样。但我还是想把「健康手账」这个项目的一些设计思路写出来,因为做的过程中有几个决策点我觉得挺有意思,适合和做 iOS 工具类 App 的朋友聊聊。

为什么又做了一个健康记录 App

这类 App 的竞品多到数不清。我当时下载了七八个,用下来有个共同问题:录入太麻烦。

打开 App、点击新建、手动输入收缩压、再输舒张压、再输脉搏——三个字段,最快也要 15 秒。对于每天早晚要测两次血压的高血压患者,这个摩擦力不小。更别说帮父母操作,老人对数字键盘并不友好。

我想解决的核心问题就一个:把「记一次数据」压缩到 3 秒以内。

拨轮交互:试了三个方案,前两个全删了

最直接的想法是自动识别——用手机摄像头拍血压计,OCR 识别数值。我试了一周,识别率在不同光线下差异很大,而且用户还得配合把手机对准屏幕,反而更麻烦。

第二个方案是预设范围的快捷选择,类似「上次是 128/82,这次有变化吗?」。问题是这个交互对新用户完全不直觉,而且首次录入没有历史数据根本跑不起来。

最后用的方案是物理拨轮(Picker 风格,但带阻尼感的自定义实现)。收缩压、舒张压、脉搏各一个拨轮,默认值锁定在上次录入附近,打开就能拨,拨完直接存。实际操作下来,熟悉之后真的 3-5 秒能完成一次录入。

这个交互特别适合老年用户,因为拨轮比点击键盘容错率高——拨过了再拨回来,不需要删除重输。

数据模型:tagIDs 关联干预行为

健康数据好记,但「今天血压偏高是因为没睡好还是昨晚喝酒了」这种因果关系很难追踪。

我在 HealthRecord 上加了一个 tagIDs: [String] 字段,对应一套可自定义的状态标签(StatusTag)。内置的有「降压药」「运动」「好睡眠」「黑咖啡」等,用户每次录入时可以顺手打几个印章。

@Model
final class HealthRecord {
    var id: UUID
    var timestamp: Date
    var systolic:  Int?
    var diastolic: Int?
    var pulse:     Int?
    var weight:    Double?
    var tagIDs:    [String]   // 关联当次的干预行为
    var profileID: String = "default"
}

趋势图里,这些标签会作为事件标记叠加在折线上。比如连续几天运动后血压数值的变化,一眼就能看出来。这个设计借鉴了运动 App 里「训练日志」的思路,但放在健康场景下我觉得更有价值,因为慢病管理真正需要的是「行为-数据」的对照。

PDF 就医报告:一个被低估的功能

大多数健康 App 的数据只能在 App 里看,或者最多导出 CSV。但去医院看诊时,医生没时间看你手机屏幕,更不可能帮你分析折线图。

我加了一个「生成就医报告」功能,一键输出标准格式 PDF:患者基本信息、最近 N 天的血压/体重数据表格、趋势图、备注。打印出来或者直接发给医生。

这个功能在开发时我有点犹豫要不要做,感觉实现成本不低(PDF 布局、图表渲染都要搞一遍)。但想想「数据记了,但医生看不懂」这个痛点,还是做了。说实话现在觉得这是产品里最有差异化的地方。

Siri 快捷指令:让录入更快一步

做完拨轮之后我想,录入的最大摩擦其实不是界面操作,而是「打开 App 这个动作本身」。

用 AppIntents 实现了一个 Siri 快捷指令,说「用健康手账记录健康数据」直接跳到录入界面,不需要找图标、不需要滑动。实现上用了一个 NotificationCenter 的广播机制——intent perform 之后 post 一个通知,主视图监听到就弹出录入 sheet。

struct LogHealthRecordIntent: AppIntent {
    static let title: LocalizedStringResource = "记录健康数据"
    static let openAppWhenRun: Bool = true

    @MainActor
    func perform() async throws -> some IntentResult {
        try await Task.sleep(for: .milliseconds(200))
        NotificationCenter.default.post(name: .healthLogShowInputSheet, object: nil)
        return .result()
    }
}

延迟 200ms 是因为 App 冷启动时视图层级还没就绪,直接 post 通知会丢失。这个 bug 我在真机上踩了才发现,模拟器里完全复现不了。

本地存储 + 可选 iCloud 同步

数据全部用 SwiftData 存在本地。这是一个主动决策,不是因为懒得做后端。

健康数据比较敏感,尤其是帮父母记录的场景,很多用户对「数据上云」有顾虑。本地存储让这个顾虑直接消失,也不需要注册账号、不需要联网。

iCloud 同步作为可选项保留,用 CloudKit 实现,在设置里手动开启。代码里的处理也很直接:

let config = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: icloud ? .automatic : .none
)

买断制也是基于同样的逻辑——慢病患者要长期用,订阅制的心理负担对他们不友好。

多人档案:一个 App 管全家

HealthRecord 上有个 profileID 字段,支持创建多个独立档案。这个场景是子女帮父母管理健康数据时用的:爸妈各一个档案,切换一下就能看各自的趋势。

下次陪父母去复诊,不用临时整理数据,直接切到对应档案导出 PDF 就完事了。

一些还没做好的地方

趋势图的异常检测现在还比较简陋,只是超过阈值就标红,没有考虑到「白大衣高血压」这种场景下连续几次都偏高但实际没问题的情况。这块我想引入一个滑动窗口均值,但暂时还没动。

血压分类标准支持切换(国内标准 vs ACC/AHA 2017),但界面上没有做到位,大多数用户根本发现不了这个设置在哪。


这个项目目前还在冷启动阶段,有兴趣的朋友可以在 App Store 搜「健康手账」试试——特别是家里有需要记血压的长辈的,帮他们装一个比较实在。

如果你也在做类似的健康或工具类 App,欢迎在评论区聊聊你在数据录入和用户习惯培养上的做法,我挺好奇不同产品的解法有什么差异的。

数据持久化与缓存策略:在离线与在线间架起桥梁

2026年4月24日 14:50

引言:数据无处不在,存储何去何从?

在现代移动应用中,数据如同血液般流淌于每个功能模块之间。然而,网络并非永远可靠,用户期待的是无缝的体验——无论在地铁隧道中、飞行模式下,还是在信号微弱的乡村。这种期待催生了对数据持久化与缓存策略的深度思考。一次关于本地数据丢失的故障排查,让我们意识到:数据的生命周期管理远比简单的"保存与读取"复杂得多。本文将从实际案例出发,探讨如何构建一个既能保证数据一致性,又能提供流畅离线体验的存储架构。

一、存储方案的选择:从UserDefaults到数据库的演进之路

// 初级做法:滥用UserDefaults
UserDefaults.standard.set(userProfile, forKey: "currentUser")
UserDefaults.standard.set(accessToken, forKey: "authToken")
UserDefaults.standard.set(products, forKey: "cachedProducts")

然而,UserDefaults本质上是一个plist文件,适合存储配置信息和小量数据,但不适合存储复杂对象或大量数据。当应用需要存储用户聊天记录、商品目录或离线文章时,我们需要更专业的解决方案。

下图展示了不同存储方案的选择路径,帮助开发者根据数据特性做出合理决策:

image.png

二、架构核心:构建统一的数据访问层

随着应用复杂度增加,直接在各种业务模块中操作不同存储方案会导致代码高度耦合。更好的做法是构建一个统一的数据访问层(Data Access Layer),为上层业务提供一致的接口。

// 统一存储协议
protocol DataStorageProtocol {
    associatedtype T
    
    func save(_ item: T, forKey key: String) -> AnyPublisher<Void, StorageError>
    func load(forKey key: String) -> AnyPublisher<T, StorageError>
    func delete(forKey key: String) -> AnyPublisher<Void, StorageError>
    func clear() -> AnyPublisher<Void, StorageError>
}

// 具体实现:UserDefaults存储
class UserDefaultsStorage<T: Codable>: DataStorageProtocol {
    private let userDefaults: UserDefaults
    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()
    
    func save(_ item: T, forKey key: String) -> AnyPublisher<Void, StorageError> {
        return Future<Void, StorageError> { promise in
            do {
                let data = try self.encoder.encode(item)
                self.userDefaults.set(data, forKey: key)
                promise(.success(()))
            } catch {
                promise(.failure(.encodingFailed))
            }
        }.eraseToAnyPublisher()
    }
}

这种抽象带来了多重好处:业务代码无需关心底层是使用UserDefaultsCore Data还是文件系统;存储实现可以独立替换;统一的错误处理;以及易于测试的接口。

三、缓存策略:智能数据的生命周期管理

缓存不仅仅是"保存一份数据副本",而是需要精心设计的策略。一个完整的缓存系统需要考虑以下维度:

  1. 缓存粒度:是按页面缓存、按接口缓存,还是按数据实体缓存?
  2. 失效策略:基于时间(TTL)、基于事件(数据更新),还是混合策略?
  3. 存储位置:内存缓存、磁盘缓存,还是多级缓存?
  4. 同步机制:如何保证缓存与服务器数据的一致性?

让我们设计一个支持多级缓存的智能系统:

class SmartCacheManager {
    // 内存缓存(快速但易失)
    private let memoryCache = NSCache<NSString, NSData>()
    
    // 磁盘缓存(持久但较慢)
    private let diskStorage: DataStorageProtocol<Data>
    
    // 网络层用于刷新数据
    private let networkService: NetworkServiceProtocol
    
    func fetchData<T: Codable>(for key: String,
                              maxAge: TimeInterval = 300, // 默认5分钟
                              forceRefresh: Bool = false) -> AnyPublisher<T, Error> {
        // 1. 检查是否需要强制刷新
        guard !forceRefresh else {
            return fetchFromNetwork(key: key)
        }
        
        // 2. 检查内存缓存
        if let cachedData = memoryCache.object(forKey: key as NSString) as Data?,
           let cachedItem = decodeData(cachedData) as T? {
            return Just(cachedItem)
                .setFailureType(to: Error.self)
                .eraseToAnyPublisher()
        }
        
        // 3. 检查磁盘缓存
        return diskStorage.load(forKey: key)
            .tryMap { data in
                // 检查缓存是否过期
                if self.isCacheValid(for: key, maxAge: maxAge) {
                    return try JSONDecoder().decode(T.self, from: data)
                } else {
                    throw CacheError.expired
                }
            }
            .catch { _ in
                // 4. 缓存无效或不存在,从网络获取
                return self.fetchFromNetwork(key: key)
            }
            .eraseToAnyPublisher()
    }
}

下图展示了智能缓存系统的工作流程,从数据请求到返回的完整决策链:

image.png

## 四、数据同步:离线优先的架构哲学 在需要离线能力的应用中,我们常常采用"离线优先"(`Offline-First`)策略。这意味着应用优先使用本地数据,同时在后台同步最新数据。这种策略需要解决几个关键问题:
  1. 冲突解决:当本地修改与服务器数据冲突时如何处理?
  2. 增量同步:如何高效地只同步变化的数据?
  3. 同步状态管理:如何向用户展示同步进度和状态?

我们可以设计一个基于操作队列的同步管理器:

class SyncManager {
    private let operationQueue = OperationQueue()
    private let pendingOperationsStorage: DataStorageProtocol<[SyncOperation]>
    
    // 记录待同步的操作
    func enqueueOperation(_ operation: SyncOperation) {
        // 保存到本地,确保即使应用崩溃也不会丢失
        var pendingOps = (try? pendingOperationsStorage.load(forKey: "pending")) ?? []
        pendingOps.append(operation)
        pendingOperationsStorage.save(pendingOps, forKey: "pending")
        
        // 添加到操作队列
        operationQueue.addOperation(operation)
    }
    
    // 监听网络状态变化
    func setupNetworkObserver() {
        NotificationCenter.default.publisher(for: .networkReachable)
            .sink { [weak self] _ in
                self?.retryPendingOperations()
            }
            .store(in: &cancellables)
    }
}

这种设计确保了即使用户在离线状态下进行操作,这些操作也会被安全地保存,并在网络恢复时自动同步。

五、性能优化:存储的效率与安全平衡

数据持久化不仅关乎功能,更直接影响应用性能。我们需要在多个维度上寻找平衡点:

  1. 读写性能:大量小文件 vs 少数大文件
  2. 内存占用:缓存大小限制与淘汰策略
  3. 电池消耗:磁盘IO对电池寿命的影响
  4. 数据安全:敏感信息的加密存储

对于敏感数据如用户凭证,我们应使用iOS的Keychain服务:

class SecureStorage {
    func saveSecureItem(_ item: String, forKey key: String) -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: item.data(using: .utf8)!
        ]
        
        SecItemDelete(query as CFDictionary) // 先删除旧项
        let status = SecItemAdd(query as CFDictionary, nil)
        return status == errSecSuccess
    }
}

对于大量数据的存储,我们需要考虑分页加载和懒加载策略,避免一次性加载过多数据导致内存压力。

六、总结:构建可靠的数据基石

数据持久化与缓存策略是移动应用架构中最为基础也最为复杂的一环。它不仅仅是技术选择的问题,更是对用户体验、性能表现和安全保障的综合考量。

通过构建统一的数据访问层,我们实现了存储实现的解耦;通过智能缓存策略,我们平衡了性能与数据新鲜度;通过离线优先的同步机制,我们确保了应用的可用性;通过性能优化措施,我们保障了应用的流畅运行。

这再次印证了本系列文章的核心思想:优秀的架构设计在于预见复杂性并提前规划应对策略。当数据层稳固可靠时,上层业务开发便能够专注于创造价值,而不必担心数据丢失、同步冲突或性能瓶颈。在数据驱动的时代,一个精心设计的数据持久化架构,是应用成功的基石,也是技术卓越的体现。

iOS 足迹 App 的成就系统,我推倒重做了一次——踩了3个坑之后

作者 SameX
2026年4月24日 11:34

上线三周,成就页的打开率掉到了 2%。我盯着这个数字看了好一会儿,意识到设计完全错了。

「雁过留痕」是我做的一个足迹记录 App,核心思路是把你走过的路变成可量化的探索面积(km²),用 25m 精度网格覆盖地图,慢慢把省份和城市染色。这个核心玩法我觉得还不错,但成就系统上线之后直接拖了后腿。

第一版成就系统到底错在哪

最早的版本只有几个维度:总距离、总录制天数、省份解锁数量。听起来挺完整,上线之后发现问题很具体——用户解锁了头三个徽章,然后成就页就再也不打开了。

原因其实事后想想很显然:目标太稀疏,中间段完全是空白期。你解锁了「初探者」,下一个目标要再走 500km 才能到「漫游者」,这中间几个月看不到任何进展反馈,等于告诉用户「别来了」。

游戏设计里有个基本原则:玩家需要随时都能看到「我离下一个里程碑还有多远」。我第一版完全忽略了这件事。

推倒之后怎么重建

重做的核心思路是把成就拆成多个 Track,每个 Track 内部是连续的多级徽章,保证任意时刻都有「快到了」的感觉。

enum BadgeTrack: String, CaseIterable, Identifiable {
    case all
    case exploration   // 面积、城市、省份
    case consistency   // 连续打卡、累计月数
    case china         // 省级/大区解锁
    case world         // 全球探索
    case pro           // Pro 会员专属

    func matches(_ definition: BadgeDefinition) -> Bool {
        self == .all || definition.badgeTrack == self
    }
}

这个分组做出来之后,成就页的平均停留时长从 8 秒涨到了 19 秒。说实话这个数字比我预期高,主要原因我猜是「中国赛道」——省份解锁这个玩法对国内用户有天然吸引力,很多人打开成就页就是去看自己还差哪几个省。

数据聚合这块踩的坑

成就判断需要的数据维度很多:总距离、连续天数、省份数量、面积……最开始每个徽章自己去查数据库,成就页一打开要跑几十次查询,加载卡顿肉眼可见。

后来抽了一个 BadgeMetrics 结构统一做一次聚合,所有徽章判断共用同一份数据:

struct BadgeMetrics {
    let totalDistanceKilometers: Double
    let recordedDays: Int
    let currentStreakDays: Int
    let longestStreakDays: Int
    let chinaProvinceCount: Int
    let chinaAreaKm2: Double
    let cityUnlockCount: Int
    let globalAreaKm2: Double

    static func build(
        stats: TraceStats,
        segments: [TraceSegment],
        geo: GeographicProfile,
        proMembershipActive: Bool,
        proMembershipActivatedAt: Date?
    ) -> BadgeMetrics { ... }
}

顺带提一个细节:segments 在 build 里有一步过滤,只保留 pointCount >= 8 的记录。这个阈值对应大约 10-15 秒的有效移动,过滤掉了打开 App 又马上锁屏的噪声。这个数值调了好几次,太小的话徽章进度会被一堆无效数据撑高,用户觉得「奇怪,我没走多少怎么进度涨这么快」,反而破坏信任感。

中国坐标系的坑,顺便说一下

省份解锁要判断「这个 GPS 点是否在某个省内」,但 GPS 原始数据是 WGS-84,国内地图用 GCJ-02,直接拿坐标去匹配行政区边界,边境附近会出现「明明走在省内却没解锁」的情况。

我在 GeographicProfile 里做了坐标系转换,省级和地市级边界数据全部内置,不走网络请求。好处是离线也能正常触发成就,坏处是包体增加了大概 3MB——这个取舍我觉得值,足迹类 App 很多场景就是在没网络的山里。

现在纠结的一个设计问题

下一步想加「状态徽章」:比如「连续 30 天记录」解锁之后不是永久持有,断了会变灰,需要重新激活。

但我现在真的没想清楚该不该做。

没压力就没粘性,这个逻辑说得通,健身 App 基本都用这套。但足迹记录和健身不一样——用户可能就是出去旅行才开,平时根本不用,强迫他们「每天打卡」会让 App 变成一个焦虑来源。我不想做那种让人觉得「没开就有罪恶感」的产品。

但如果完全没有时间压力,成就全部永久持有,用户解锁完一批之后可能又回到当初那个 2% 的困境。

这个矛盾我现在还没有好答案。如果你做过类似的游戏化设计,或者作为用户对「会过期的成就」有什么感受,真想听听。

我用 SpriteKit 给存钱罐装了个物理引擎

作者 SameX
2026年4月24日 10:41

调参数调到一半,我顺手往测试账号里又存了 200 块,纯粹是因为看硬币掉落太过瘾。那一刻我觉得这个 App 的核心方向对了。

存钱 App 不少,但我自己用了一圈,基本上三天就卸载了。问题不是功能不够,而是「存进去」这个动作没有任何正向反馈。往银行账户转 500 块,余额多了个数字,然后呢?没声音没动静,脑子里毫无反应,下次就很难再重复这个行为。做「聚沙攒钱」大概花了三个月,现在刚上线,核心想解决的就是这一件事:让存款这个动作本身变得有意思。

用 SpriteKit 做硬币物理动画

这是整个 App 里我花时间最多的部分。存款的时候,硬币从屏幕上方掉落,碰到罐子边缘会弹一下,堆在罐底。存的金额越大,掉的硬币越多。用 SpriteKit 的物理引擎实现,核心逻辑大概是这样:

func spawnCoins(count: Int, into scene: SKScene) {
    for _ in 0..<count {
        let coin = SKSpriteNode(imageNamed: "coin")
        coin.physicsBody = SKPhysicsBody(circleOfRadius: coin.size.width / 2)
        coin.physicsBody?.restitution = 0.4
        coin.physicsBody?.friction = 0.6
        let startX = CGFloat.random(in: scene.size.width * 0.3...scene.size.width * 0.7)
        coin.position = CGPoint(x: startX, y: scene.size.height + 20)
        scene.addChild(coin)
    }
}

restitution 控制弹性,friction 控制摩擦。这两个值调了挺久——最开始设 restitution = 0.8,硬币在罐里弹来弹去像乒乓球,完全不对;换成 0.1,又像石头直接沉底,没有金属感。来回试了大概二十组,0.4 + 0.6 是我自己觉得最接近「真实硬币掉进陶瓷罐」的感觉。就是在调这个参数的过程中,我忍不住顺手存了那笔 200 块。

双模式:短期愿望 vs 长期定投

产品结构上做了两种模式。

愿望模式:适合「我要攒钱买 AirPods Max」这种有明确目标的场景,设目标金额,每次存款推进度条,距离目标还差多少天一目了然。

聚沙模式:基于 DCA(定期定额)逻辑,设定每周或每月固定存入金额,内置复利计算器,输入年化收益率之后可以看到 N 年后的预估结果,适合想养成长期储蓄习惯的场景。

两种模式放在一起,设计阶段我自己也担心会让人觉得混乱。但在早期十几个测试用户里,有三四个两个模式都开着——一个用来存旅行基金,一个用来强迫自己每月定存。这个比例让我觉得放在一起是对的,两种心理状态确实可以并存。

成就徽章系统

参考了健身 App 的逻辑,把可见的里程碑作为习惯强化手段。徽章判断条件全部基于 StatsSummary 这个结构体,包括总存款金额、连续天数、存款时间段等等:

BadgeDefinition(id: "streak_7", name: "Week Streak",
    description: "Deposit 7 days in a row",
    category: "streak") {
    $0.currentStreak >= 7
},
BadgeDefinition(id: "night_owl", name: "Night Owl",
    description: "Deposit 10 times at night",
    category: "special") {
    $0.nightDeposits >= 10
}

「Night Owl」和「Early Bird」是我比较喜欢的两个,晚上存了 10 次和早上存了 10 次分别解锁。有测试用户看到「Night Owl」的时候说「这个 App 懂我」,这个反馈挺好的——徽章在记录的不只是金额,还有一个人存钱的时间节奏。

每日语录:18×18 组合生成

这个模块有点意思。我不想手写几百句鸡汤,所以用了组合逻辑——18 个「主语」乘以 18 个「谓语」,生成 324 种组合,足够一年内不重复。

比如「固定的存钱节奏」+「会让焦虑一点点淡下去」,「一杯奶茶的钱」+「能抵消很多小小的冲动消费」。有些组合挺通顺,有些拼出来确实略生硬,读起来像机器写的。生硬的那些我做了一个黑名单手动过滤,大概淘汰了 40 句,剩下的整体可读性还不错。说白了,这是个半自动流程,机器打草稿,人工做最后一道筛。

做错了的几个决定

订阅定价改了两次。最开始想做纯免费带广告,后来发现存钱 App 里放广告体验很糟,用户存钱存到一半弹出来一个游戏广告,心情直接崩了。改成一次性内购之后反而顺一些。

数据备份功能上线比预想晚了一个版本。有个测试用户换手机之后数据全没了,找我反馈,最后一条消息就是「我的数据没了」,然后就没再说话。我盯着那条消息看了挺久,没法回复什么。那之后备份功能直接插队到下个版本,别的需求全往后推。用户数据这件事,v1 就该做好,没有借口。

「聚沙模式」的 UI 一开始做得太复杂,复利计算器有七八个输入项,我自己用的时候都觉得烦,后来砍掉大半只留核心参数。试了三个方案,最后全删了重来。功能多不等于有用。

一个没解决的 SpriteKit 问题

目前有个穿模问题没有根治:硬币数量一多,相互重叠之后会出现轻微穿透。我现在的做法是限制单次最大生成数量,同时用 categoryBitMask 给硬币单独分一个碰撞分组,让它们只和罐壁、罐底以及彼此发生碰撞,不影响 UI 层的其他节点:

coin.physicsBody?.categoryBitMask = PhysicsCategory.coin
coin.physicsBody?.collisionBitMask = PhysicsCategory.coin | PhysicsCategory.jar
coin.physicsBody?.contactTestBitMask = PhysicsCategory.jar

这样能减少无关碰撞计算,但硬币堆多了之后还是会穿模,治标不治本。有做过 SpriteKit 堆叠物理的朋友吗?是怎么处理这个问题的?

7 个开源 iOS 应用,让你成为更好的开发者

作者 JarvanMo
2026年4月24日 10:24

多年来,我注意到开发者成长的一个规律。

教程很适合学习语法。课程有助于理解概念。但在某个阶段,最大的提升来自于阅读有经验的团队如何在真实代码库中解决真实问题。

不是示例项目,不是演示应用,而是真正上线的产品。

那种处理你根本想不到的边界情况的代码。那种经历了三年功能迭代仍然健壮的架构。那种只有亲眼看到它们在大规模下运作才能理解的决策。

我花了不少时间浏览开源 iOS 应用,以下是我认为真正值得深入研究的七个。每一个都能教会你不同的东西——关于架构、安全、设计模式,或者仅仅是良好的工程习惯。

以下是清单。

1. Firefox for iOS

仓库: mozilla-mobile/firefox-ios

许可证: MPL 2.0

这是 Mozilla 为 iOS 打造的完整浏览器,完全使用 Swift 编写。

这是一个庞大的代码库,而这正是它的价值所在。你很少有机会看到如此规模的项目如何在一个地方同时处理标签页管理、同步、遥测、内存压力和无障碍访问等问题。

最让我惊讶的是,尽管项目规模巨大,代码的可读性却相当高。Mozilla 积极标记 good first issue 工单,贡献流程文档也非常完善。

你可以学到:

  • 大规模 iOS 应用如何管理状态和内存
  • 复杂 UI 中无障碍访问的处理方式
  • 重大开源项目中的贡献流程是什么样的

如果你好奇一个生产级浏览器在底层是什么样子,这里是最好的起点。

2. Signal for iOS

仓库: signalapp/Signal-iOS

许可证: GPL-3.0

Signal 是一款注重隐私的即时通讯应用,数百万人信赖它进行安全通信。

从学习角度来看,Signal 代码库最有趣的地方在于它在每一层都极其认真地对待安全问题。端到端加密、安全本地存储、密钥管理——这些不是事后补充,而是嵌入到架构本身之中。

该应用还非常实际地混合使用了 UIKit 和 SwiftUI,这反映了当今许多生产应用的真实面貌——不是纯粹地使用其中一种,而是经过深思熟虑的混合方案。

你可以学到:

  • 安全导向的 iOS 工程模式
  • 推送通知和后台任务在真实通讯应用中如何运作
  • 团队如何在同一项目中管理 UIKit 和 SwiftUI 的共存

阅读 Signal 的代码会改变你对自己应用中数据处理的思考方式。

3. WordPress for iOS

仓库: wordpress-mobile/WordPress-iOS

许可证: GPL-2.0

这是 Automattic 官方的 WordPress 应用——最成熟的开源 iOS 项目之一。

该代码库涵盖了真正广泛的 iOS 挑战:Core Data、REST 和 GraphQL 网络请求、富文本编辑、离线同步、模块化架构。很难找到一个项目能同时涉及这么多领域。

WordPress 让我印象最深的是它的贡献体验。文档详尽,上手流程顺畅,项目周围有真正的导师文化。如果你想做出第一个有意义的开源贡献,这里是最好的起点之一。

你可以学到:

  • 生产级 Core Data 和离线优先架构
  • 如何在 iOS 上构建富文本编辑器
  • 一个成熟、维护良好的开源项目从内部看是什么样子的

4. NetNewsWire

仓库: Ranchero-Software/NetNewsWire

许可证: MIT

NetNewsWire 是一款免费的 RSS 阅读器,支持 iOS 和 macOS,由 Brent Simmons 开发。

如果你不熟悉这个名字,Brent 是 Apple 开发者社区最具影响力的元老之一。他几十年来一直在构建 Mac 和 iOS 应用,这在代码库的每一个角落都体现得淋漓尽致。

我喜欢 NetNewsWire 的地方在于它的 Swift 代码多么干净、多么地道。没有过度设计,没有不必要的抽象,只有结构良好的代码,恰好做它需要做的事。

它也是我见过的 iOS 和 macOS 之间跨平台代码共享的较好范例之一。项目规模足够小,你实际上可以通读整个代码库并理解所有部分是如何连接的。

你可以学到:

  • 地道的、干净的 Swift 在实践中是什么样子
  • 如何有效地在 iOS 和 macOS 之间共享代码
  • 经验丰富的开发者如何为长期可维护性来组织项目

如果你想从头到尾研究一个代码库,这是我推荐的仓库。

5. Wire for iOS

仓库: wireapp/wire-ios

许可证: GPL-3.0

Wire 是一款安全的即时通讯应用,支持语音通话、视频通话和群聊——全部默认加密。

对于 iOS 开发者来说,Wire 特别有趣的地方在于它的真实 WebRTC 集成。如果你好奇音频和视频通话在 iOS 代码层面到底是如何工作的,这是为数不多的能让你看到完整实现的开源项目之一。

该项目也是大规模模块化 Swift 架构的良好范例。它被拆分为边界清晰、定义明确的模块,这使得在如此规模的项目中导航比预期要容易。

你可以学到:

  • WebRTC 如何集成到原生 iOS 应用中
  • 音视频通话架构的实践
  • 如何用清晰的边界模块化大型 Swift 代码库

6. Element X for iOS

仓库: element-hq/element-x-ios

许可证: AGPL-3.0

这是下一代 Matrix 客户端,也是本列表中最现代的代码库之一。

Element X 完全使用 SwiftUI 构建,底层基于 matrix-rust-sdk。仅凭这个组合就值得研究——你能看到 SwiftUI 在生产规模下的使用,也能看到 Rust 和 Swift 如何通过 FFI 在真实应用中进行通信。

项目非常活跃,团队响应迅速,并且定期为新人标记 issue。如果你想找一个能反映 iOS 开发未来方向的项目——SwiftUI 优先,性能关键层用 Rust 编写——就是它了。

你可以学到:

  • SwiftUI 在真实生产应用中如何规模化
  • Rust 到 Swift 的 FFI 在实践中如何运作
  • SwiftUI 优先代码库中的现代架构模式

7. Kickstarter for iOS

仓库: kickstarter/ios-oss

许可证: Apache-2.0

Kickstarter 开源了他们的整个原生 iOS 应用,这是 iOS 社区中被引用最多的代码库之一。

它被广泛引用的原因在于其严谨性。函数响应式编程、MVVM 架构、依赖注入,以及真正有意义的测试覆盖率。每种模式在整个项目中都得到了一致的应用,这使它作为参考极其有用。

他们的 Pull Request 风格和代码审查文化也值得学习。从阅读他们的 PR 和提交信息中,你能学到和代码本身一样多的东西。

你可以学到:

  • 函数响应式编程在真实应用中的一致应用
  • 如何为可测试性构建依赖注入
  • 大规模下严谨的测试覆盖率到底是什么样子

如何真正用好这份清单

七个仓库确实很多。你不需要全部看完。

我的建议是:选择一个与你当前工作或好奇心相关的项目。克隆它,在 Xcode 中运行。然后挑一个功能——也许是登录流程,也许是同步层,也许是他们如何处理导航——从头到尾读一遍。

不要试图一次理解整个代码库。聚焦于一条代码路径,从 UI 层一直追踪到数据层。

你花一个下午阅读生产级代码所学到的东西,比跟着教程学一个月还要多。

如果你想做出自己的第一个开源贡献,这些项目中的大多数都会积极标记 good first issue 工单。这意味着有一个明确的入口在等着你。

最后的想法

阅读优秀的代码是一种会随时间悄然复利的好习惯。

你会开始注意到你从未想到过的模式。你会开始理解为什么某些架构决策会存在。你会培养出一种直觉——什么样的代码容易修改,什么样的代码对每一次改动都充满抗拒。

这些都不是来自某个突破性的瞬间。它们来自于持续地接触精心编写的代码,让这些模式重塑你对自己工作的思考方式。

希望这份清单能给你一个好的起点。

选一个。克隆它。开始阅读。

我做了一个把专注计时变成「声音护照」的 iOS App,聊聊数据可视化和成长系统的设计思路

作者 SameX
2026年4月24日 10:15

最近上线了一个叫「声境护照」的 iOS App,做的事情说起来很简单:番茄钟 + 环境音 + 数据可视化。但我想聊的不是功能本身,而是做这个 App 过程中一些有意思的设计决策——尤其是「把专注数据包装成旅行叙事」这条路到底值不值得走。

从一个具体的厌倦感出发

我用过很多专注类 App,Forest、潮汐、番茄ToDo,都挺好用。但用着用着有个感受:完成了专注,然后呢?数字加一,然后就没了。

说实话,这种「完成即消失」的感觉有点可惜。你花了 25 分钟认真写东西,这件事值得被记住。所以我想做一个让每次专注都留下「印记」的工具。

护照的比喻就是从这里来的——每次专注是一次起飞,声景是目的地,累计时长变成飞行里程,连续打卡天数是你的「航班记录」。

成长系统的数据结构设计

游戏化成长系统是这个 App 最核心的部分,我在 ExpeditionModels.swift 里把整个探险体系建模成章节 + 任务的结构:

struct ExpeditionChapterDefinition: Identifiable, Codable, Equatable {
    let id: String
    let sceneId: String
    let cityName: String       // 对应一个声景目的地
    let tagline: String
    let bonusBounces: Int
    let missions: [ExpeditionMissionDefinition]
}

enum ExpeditionMissionKind: String, Codable {
    case sessionCount    // 完成 N 次专注
    case focusMinutes    // 累计 N 分钟
    case deepFocusCount  // 深度专注 N 次
}

每个「城市章节」绑定一个声景 ID,完成章节里的任务才能解锁下一个城市。这样声音选择就不只是 UI 装饰,而是有推进感的目标。

ExpeditionMissionKind 只有三种,我故意控制得很少。试了几个方案,加过「连续打卡天数」「特定时段专注」等类型,最后都删了——任务类型越多,用户反而不知道该干什么。

会话战报的「下一步建议」逻辑

每次专注结束会弹出一张战报,这个战报除了展示当次数据,还会给出下一次专注的建议。这个逻辑在 SessionReportSheetViewModel 里:

func nextActionAdvice(taskCompleted: Bool) -> SessionNextActionAdvice {
    let remainingTodayPlan = max(0,
        store.weeklyPlanTodayTargetSegments - store.weeklyPlanTodayActualSegments
    )
    let streakHint: String
    if store.streakDays >= 5 {
        streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
    } else if store.streakDays >= 2 {
        streakHint = "再坚持 1-2 天可进入稳定习惯区。"
    } else {
        streakHint = "建议先连续 3 天完成每日最小闭环。"
    }
    // ...
}

这里有个取舍:建议文案是写死的字符串模板,不是 AI 生成的。我考虑过接 LLM,但一来成本不好控制,二来我发现这类「行为引导」场景其实不需要千变万化的文案,固定的几条反而更有仪式感,用户知道这是 App 在认真跟踪自己的状态。

连续天数的分层(1天 / 2-4天 / 5天以上)是我根据习惯养成的一般规律拍的,不是什么严格实验得出的结论。连续 3 天是个心理门槛,5 天以上用户大概率已经进入节奏了,策略应该从「建立习惯」切换到「维持节奏」。

分享卡片:让专注数据变成内容

这是我觉得最值得展开讲的部分。

专注类 App 的自然增长渠道几乎只有两条:AppStore 搜索,和用户分享。Forest 靠的是「种了一棵树」的视觉,潮汐靠的是精美的音景截图。我的切入点是「数据卡片」——把当次战报或周回顾渲染成一张可以直接发朋友圈的图片。

ShareCardFormatter 负责格式化卡片里的时间信息,战报卡片、成就徽章卡片、周回顾卡片用的日期格式各不相同(yyyy/MM/dd HH:mm vs yyyy/MM/dd),看起来细节,但如果格式乱掉整张卡片的质感就垮了。

卡片设计我做了三个版本,第一版太「仪表盘」,数字密密麻麻;第二版太「极简」,信息量不够,朋友看不出你做了什么;第三版找到了平衡——突出时长和等级称号,次要展示声景和任务名,底部放一行小字的里程数。

StatsService 和 GrowthService 的分层

统计相关的逻辑我拆成了两个 Service:

  • StatsService:纯数据聚合,负责按时间范围汇总 FocusLog,输出 StatsData
  • GrowthService:负责把 FocusLog 转换成 GrowthProfile,计算等级、经验值、称号

这两个 Service 都是无状态的纯函数风格,输入 logs 数组输出结果,在多个 ViewModel(StatsSheetViewModelProfileSheetViewModelWeekReviewSheetViewModel)里复用。

有一个小设计:当 focusLogs 为空时,会调用 StatsService.createDemoFocusLogs() 生成演示数据。新用户第一次打开统计页不会看到空白界面,而是看到一个「如果你用了两周会是什么样子」的预览。这个 onboarding 细节我觉得挺重要——空页面对新用户很劝退。

现在的状态和一些遗憾

App 刚上线 1.3 版本,下载量还很少,老实说基本还在 0 起步阶段。

有几个功能是做到一半放在 _disabled_features 目录里的——统计报告、周回顾、分享卡片这些模块代码都写完了,但 UI 打磨还不够,我没有在 1.3 开放。这种「功能写完了但藏起来」的状态有点难受,但比发出去然后体验很差要好。

声景库目前内容量不够丰富,「东京雨夜」「咖啡馆白噪音」这类场景音是有的,但城市章节太少,探险系统的推进感不强。这是接下来要重点补的。

还有一个我没想清楚的问题:护照 + 飞行里程这套叙事对喜欢旅行的用户很有共鸣,但对完全不在意这个比喻的用户来说可能显得有点奇怪。这个产品定位的边界到底在哪,我还在摸索。


如果你也在做类似的「工具 + 游戏化」方向的 iOS App,或者对专注类产品有什么看法,欢迎在评论区聊聊——我对「游戏化到底会不会让用户厌倦」这个问题挺好奇的,想听不同角度的判断。

鸿蒙呼吸动画踩了三个坑:GPU降级时机、设计Token校验、i18n漏key——具体怎么处理的

作者 SameX
2026年4月24日 08:28

在鸿蒙上做呼吸动画,我以为最难的是 ArkTS 语法,结果最麻烦的是——我根本不知道用户的设备跑到哪一档了。

呼吸动画是「呼吸视界」这个 App 的核心体验:吸气时圆圈缓慢扩张,屏气时保持,呼气时收缩。这个动画一旦卡顿,「跟着 App 呼吸」的节奏就断了,用户能感觉到「哪里不对」,但不会告诉你是帧率问题。

先说一下这个 App 是干什么的,方便后面的技术背景理解。

产品背景:一个给自己做的呼吸训练工具

「呼吸视界」(iOS App Store ID: 6758613852)做的是结构化呼吸训练引导——4-7-8 呼吸法、盒式呼吸、Wim Hof 法这些。网上这些方法的文字说明很多,但照着文字练,你得自己数秒、记顺序,练着练着就分心了。

我做这个的起因很功利:开会前容易紧张,想找个东西帮我两分钟之内把状态重置一下。找了一圈没找到合适的,就自己写了。

App 有三块核心功能:带动画节奏的引导式练习、本地持久化的训练记录、以及一个课程进度系统(不只是单次练习,而是完整的训练计划)。iOS 版目前评分 5 分,样本量不大,但有个用户说「可以跟随练习呼吸,保持稳定的心情」——说实话这个反馈比我预期的更朴实,我自己用下来觉得更直接的感受是:开会前真的有用,两分钟够了。

鸿蒙版最近发布,把移植过程里踩的几个坑整理一下。

坑一:呼吸动画的 GPU 降级,我不知道该在哪个阈值切

呼吸动画用 GPU 渲染时效果最好,过渡顺滑,缩放曲线自然。但鸿蒙设备碎片化比 iOS 严重得多,中低端机上 GPU 渲染直接掉帧,整个动画变得一顿一顿的。

所以我做了一套自适应降级:检测到性能不足时切到 Canvas fallback 模式,同时把当前渲染质量分成 highbalancedlow 三档。

问题来了:切换阈值怎么定?

我的判断方式是盯 frameMs(单帧渲染耗时)和连续低帧计数:

// 连续低帧超过阈值时触发降级
if (frameMs > 22 && consecutiveLowFpsCount >= 3) {
  // 22ms ≈ 45fps,低于此值且连续3帧 → 切 balanced
  adaptRenderer('degrade quality -> balanced');
  consecutiveLowFpsCount = 0;
}
if (frameMs > 33 && consecutiveLowFpsCount >= 3) {
  // 33ms ≈ 30fps,连续3帧 → 切 canvas fallback
  adaptRenderer('switch renderer -> canvas fallback');
}

这个阈值不是凭感觉拍的,是我把 hilog 日志抓出来跑脚本分析的结果。日志里会输出每帧的 fpsframeMstickMs 以及当前渲染质量档位,降级事件会打 BF_PERF_ADAPT 标签,比如 degrade quality -> balanced 或者 switch renderer -> canvas fallback

对独立开发者来说这套日志分析挺重要——没有 QA、没有用户主动反馈卡顿,只能靠工具自己发现问题。我在没有真机的情况下,靠日志回放重现了好几个卡顿场景。

目前 Canvas 模式下动画过渡还是不如 GPU 顺滑,这个还在打磨,算是没解决干净的问题。

坑二:设计 Token 漏用——一个脚本比 code review 更可靠

App 的调性是「平静克制」,UI 上我比较在意所有间距、圆角、阴影、动画时长要统一。如果哪个地方直接写了魔法数字,整体质感就散了。

鸿蒙版我把所有设计 token 收进一个 Style.ets,导出四个命名空间:SPACERADIUSSHADOWMOTION。问题是开发过程中很容易手滑——改某个组件时直接写 borderRadius(8) 而不是 RADIUS.card,这种事我自己也干过。

所以我写了一个 check_design_foundation.py,逻辑很简单:用 path.read_text() 读取关键文件内容,用字符串匹配检查是否包含预期的 token 引用。比如检查 AppBackdrop.ets 里有没有 export struct AppBackdrop,检查 SheetBackground.ets 里有没有调用 AppBackdrop(,检查根页面 RootPage.ets 里有没有 AppBackdrop({

不是正则匹配魔法数字(那个误报太多),而是检查「关键结构是否存在」——更像一个架构约束验证器。

真实案例:有一次我重构了背景组件 AppBackdrop,改了对外接口,但忘了更新 SheetBackground 里的调用方式,就是被这个脚本拦下来的。如果没有这个检查,这个问题可能得等到真机运行时才会发现。

我还把几个类似的脚本整合进一个 check_foundation_alignment.py,统一管理:设计 token 校验、按压反馈检查、页面过渡检查、i18n 对等检查、路由检查——提交前一起跑,哪个挂了去修哪个。独立开发没有 code review,这套东西算是自己给自己兜底。

坑三:i18n 漏 key,双语维护是个持续性的低级错误

App 支持中英双语,维护四个语言文件:strings_app_en.etsstrings_app_zh-Hans.ets 以及对应的 base 版本。每次加新功能往里填 key,英文填了忘了填中文,或者反过来,这种事经常发生。

check_i18n_keys.py 做的事很直白:把四个文件里的 key 全部提取出来做集合差运算,输出「哪些 key 在英文有但中文没有」以及反向的情况。

这个脚本帮我发现过好几次漏掉的 key,有时候漏的是边缘功能的文案,有时候是一个按钮标题——后者如果漏了,用户看到的就是 key 字符串本身,很难看。

课程进度系统:本地存储是主动选择,不是偷懒

ProgramProgressRecord 记录用户在某个训练计划里完成了哪些 session、当前在第几阶段。数据全部本地存储,没有云同步。

说实话云同步我也不想做。OAuth 接入、服务器费用、隐私合规、多端数据冲突处理……这一套对独立开发者来说投入产出比太低。用户的训练记录放本地就够了,鸿蒙的 Preferences 和 RelationalStore 用起来比我预期顺手,持久化这块没遇到太大麻烦。

自定义呼吸节奏的交互,我还没想明白

用户可以自由设置吸气、屏气、呼气各阶段的时长。这个功能的交互我试了三版:滑动条、数字步进器、转盘——感觉都差点意思。滑动条精度不够,步进器操作次数太多,转盘在小屏上很难操作。

这个目前还搁着,UI 做得比较简陋。如果你做过类似的时长输入控件——尤其是整数秒精度、范围大概 1-30 秒的场景——很想听听你用了什么方案。

❌
❌