阅读视图

发现新文章,点击刷新页面。

我对《缺氧》的游戏理解

最近一个月,我一直在玩《缺氧》(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 中并没有直接提供一个可以完全隔离两个空间的气闸门,而是设计成开门会有一小段时间漏气或漏水,这留下了很多的操作空间。


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

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

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

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

上线一周,下载量是零。

说实话这很正常,冷启动就是这样。但我还是想把「健康手账」这个项目的一些设计思路写出来,因为做的过程中有几个决策点我觉得挺有意思,适合和做 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,欢迎在评论区聊聊你在数据录入和用户习惯培养上的做法,我挺好奇不同产品的解法有什么差异的。

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

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

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

一、存储方案的选择:从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个坑之后

上线三周,成就页的打开率掉到了 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 给存钱罐装了个物理引擎

调参数调到一半,我顺手往测试账号里又存了 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 应用,让你成为更好的开发者

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

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

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

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

我花了不少时间浏览开源 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,聊聊数据可视化和成长系统的设计思路

最近上线了一个叫「声境护照」的 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——具体怎么处理的

在鸿蒙上做呼吸动画,我以为最难的是 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 秒的场景——很想听听你用了什么方案。

一个 iOS 埋点 SDK 从 0 到 1,再到真实项目接入打磨

我最近把一套已经在线上跑通的项目内埋点代码,抽成了一套可复用的 iOS SDK。原本我以为难点只是“把代码搬出去”,后来才发现,真正难的是哪些能力该留在 SDK、埋点上报发送请求到底要不要sdk来接管、日志怎么做才方便进行测试验证、文档和版本号怎么跟上。这篇文章分享的是这套 SDK 被真实接入反馈一步步打磨出来的过程。


最开始我以为,做这套埋点 SDK,就是把项目里那套已经跑通的代码抽出来就可以了,后来发现没那么简单。

真正麻烦的地方,是代码抽出去之后才出现的:

  1. 哪些能力应该放进SDK
  2. 哪些逻辑必须留在业务项目
  3. SDK 要不要负责埋点上报发送请求
  4. 日志到底是给开发看,还是给测试和产品验收使用
  5. 文档和版本号没跟上时,同事会不会直接集成失败

因为我后来确定了一件事:把埋点代码抽成 SDK,难点从来不是“把代码搬出去”,而是让它在接入、调试、验证、埋点上报这些环节里都真的可用。

一、为什么我会做这个 SDK

起因其实很简单,我手上有一个已经在线上跑通的 iOS APP 项目,里面已经有一套比较完整的埋点能力:

  1. 自动采集公共事件属性
  2. 自动补一组固定用户属性
  3. 统一时间格式
  4. 固定首次安装时间和安装时区
  5. 构建事件请求
  6. 构建用户属性请求
  7. 埋点上报发送请求
  8. 失败后自动重试
  9. 打调试日志

于是,领导就给我提出一个需求:让我封装一个埋点sdk,把固定用户属性和公共事件属性都封装在sdk中,让sdk内部自动获取这些属性值,iOS同事在其他项目中进行埋点上报的时候,就不需要再单独写一套固定用户属性和公共事件属性上报的代码了,直接使用sdk的能力就可以了。

所以,这次的目标很明确,是把这些已经被项目验证过的能力,封装成一个其他 App 项目也能对接的 SDK。

二、原来那套代码为什么不适合直接复用

我最开始手上拥有的,是一套项目里我写好的埋点管理代码。

这种埋点管理类在业务项目里很常见,一开始也确实好用,因为它把所有事都接住了:

  1. 事件名
  2. 公共属性
  3. 用户属性
  4. 时间格式
  5. 请求参数构建
  6. 请求发送
  7. 失败重试
  8. 日志它

以上8点它全部都管。截一小段原来的调用入口,就能看出这种写法的特点:

func track(_ eventName: SC_MQ09EventName,
           properties: [String: Any] = [:],
           timestamp: Date? = nil) {
    let resolvedTimestamp = timestamp ?? Date()
    var payload = buildEventPayload(
        eventName: eventName,
        properties: properties,
        timestamp: resolvedTimestamp
    )

    guard JSONSerialization.isValidJSONObject(payload) else {
        SuperCoderNetLog("[MQ09] invalid payload for \(eventName.rawValue): \(payload)")
        return
    }

    do {
        let data = try JSONSerialization.data(withJSONObject: payload, options: [])
        routeEventPayload(
            payload: payload,
            payloadData: data,
            allowRetryStore: true,
            eventName: eventName.rawValue
        )
    } catch {
        SuperCoderNetLog("[MQ09] encode failed: \(error.localizedDescription)")
    }
}

这段代码本身没有错,问题在于,它已经同时在做几件事:决定事件时间、构建请求参数、校验 JSON、准备失败重试需要的数据、再把埋点上报请求发送出去。

在一个项目里,这样写能很快推进,但一旦你想复用到别的项目,就会发现它太像“项目现场代码”,而不是一层可以被其他 App 直接依赖的通用能力。

这种写法在“单项目快速推进”阶段没问题,但一旦你想跨项目复用,它马上就会暴露两个大问题。

第一,职责太杂。

它既有通用能力,又有业务语义。比如某些页面事件、某些业务字段、某些页面触发时机,这些本来只属于当前项目,但也被混进了同一层埋点管理代码。

第二,边界不清。

你很难回答一个问题:

到底哪些是“埋点 SDK 应该负责的”,哪些只是“当前这个业务项目碰巧这么写了”。

这也是我后来感受最强的一点:

项目里能跑通,不代表它已经具备跨项目复用条件。

三、我怎么划 SDK 和业务项目的边界

真正开始封装 SDK 之后,我先做的不是写代码,而是先把边界想清楚。

我想清楚了以下3点:

1. 必须放进 SDK 的,是稳定的基础能力

比如这些:

  1. 公共事件属性采集
  2. 固定用户属性采集
  3. 时间格式统一
  4. 安装时间与安装时区
  5. 事件请求参数构建
  6. 用户属性请求参数构建
  7. 可选的埋点上报发送能力
  8. 失败重试
  9. 日志输出

这些东西不依赖某个具体页面,也不属于某个特定业务,很适合收进 SDK。

2. 必须留在业务项目里的,是具体业务逻辑

比如:

  1. 某个页面的事件名
  2. 某个业务字段怎么算
  3. 哪个时机触发埋点
  4. 哪组字段是这个业务独有的

这部分如果硬塞进 SDK,SDK 很快就会变成“这个项目专用库”,复用价值就没了。

3. 埋点上报发送能力必须做成可选

我同事跟我说,一般SDK能够发送请求上报埋点,他们都会选择直接用SDK上报,但我在做的过程中,还是觉得做成可选吧,因为不一定所有项目都愿意把网络请求交给 SDK。

有的项目想要的是:

  1. SDK 帮我构建参数
  2. 我自己发请求

有的项目则希望:

  1. SDK 帮我构建参数
  2. SDK 直接把请求也发了

所以我最后没有把发送写死,而是保留了两条路:

  1. 标准 SDK 接法:直接 track / setUserProperties
  2. 直接发送完整请求参数:项目先把所有参数组合好,再交给 SDK 发

标准接法的入口最后被压得很薄:

public func track(
    eventName: String,
    properties: [String: Any] = [:],
    timestamp: Date? = nil,
    eventType: String = "track"
) {
    let rawParams = ZZHAnalyticsJSONSanitizer.dictionary(properties)
    let payload = makeEventPayload(
        eventName: eventName,
        properties: rawParams,
        timestamp: timestamp,
        eventType: eventType
    )
    sendPayloadIfPossible(
        payload,
        endpointType: .event,
        startLogContext: .event(eventName: eventName, params: rawParams)
    )
}

业务方只需要告诉 SDK:我要发哪个事件,带哪些业务参数,至于公共字段怎么补、时间怎么格式化、请求怎么发、日志怎么打,都留在 SDK 里面处理。

这个决定看起来只是 API 设计,实际上后来直接影响了后面埋点日志输出功能的业务逻辑。

我也没有直接把旧的上报方式整条推翻,这轮更稳的做法其实是:

  1. 先让 SDK 在原来已经在跑的那条上报路径旁边,并行对照一段时间
  2. 先看 SDK 组合出来的参数,和项目里原来那套上报逻辑是不是一致
  3. 确认没问题以后,再正式切到只走 SDK 这一条上报路径

这件事现在回头看也特别值得记下来。

因为复杂项目里,真正危险的不是代码抽得慢,而是你一上来就直接替换项目里正在使用的那条上报路径,这样一旦 SDK 和旧逻辑有没对齐的地方,往往要到真实验收时才会暴露出来。

四、真正让这个 SDK 变难的,不是封装,而是真实项目接入后的反馈

如果这套 SDK 只停留在“我本地能跑”,其实没什么特别值得写的。

真正让它有工程价值的,是iOS同事在他的项目中接入时遇到的SDK出现的各种问题。

1. distinct_idaccount_id 应该怎么传

一开始最容易掉进去的坑,是想把这两个用户标识字段设计得很“完整”,但后来对着真实项目接进去时,我才发现这不只是字段怎么传的问题。

distinct_id 这条比较清楚:

  1. distinct_id == device_id
  2. 这个值必须由接入方自己提供
  3. SDK 不再内部默认生成

真正麻烦的是 account_id

一开始我把它理解成“有就带,没有也不影响上报”,代码里也确实是这么处理的。

但真实接入时,很快就暴露出一个更具体的问题:

很多 App 都是先初始化 SDK,后面才从 Adjust SDK 异步拿到 adid

也就是说,问题不只是“account_id 要不要传”,而是:

SDK 初始化完成的时候,account_id 很可能还拿不到。

后来产品把规则也确认得更明确了:

  1. distinct_id 必须有
  2. account_id 的值就是外部拿到的 adjustid
  3. account_id 不是初始化时必须就有,但外部拿到 adjustid 后要立刻传给 SDK
  4. 传进去以后,后续标准事件上报和用户属性上报都要自动传参 account_id
  5. account_id 还要作为用户属性,再主动补报一次 user_setOnce

一开始我以为这是字段传值规则的问题,后来接入时才发现,真正麻烦的是 SDK 已经初始化好了,adjustid 却还没回来。

所以后面真正的修改方式,不是继续讨论 account_id 到底算“可选”还是“不可选”,而是把 SDK 补成初始化完成后也能继续更新 account_id

最后 SDK 对外多补充了一个明确方法:

Adjust.adid { adid in
    guard let adid, adid.isEmpty == false else { return }
    ZZHAnalyticsSDK.shared.setAccountID(adid)
}

也就是说,这件事最后真正定下来的,不是一句“account_id 可选”,而是:

  1. distinct_id 一开始就由项目传进来
  2. account_id 等 Adjust 返回后,再立刻传给 SDK
  3. 标准上报路径后续自动传参 account_id
  4. SDK 主动补一次 user_setOnce 用户属性 account_id

这比我一开始那种“先把规则写简单一点再说”的理解,要更接近真实项目接入。

2. 用户属性更新方式为什么要改成枚举

一开始用户属性更新方式可以传字符串,这在设计上很灵活,但真实接入时很容易变成:

  1. 表面看起来统一
  2. 实际每个项目都可能传不同字符串
  3. 最后 SDK 很难保证大家传的是同一套规则

所以后来我只保留了两种明确的写法:

  1. user_set
  2. user_setOnce

这个改动看起来不大,但它背后的意思是:

SDK 不是只负责“让你能传”,还要尽量避免接入方传错、传乱。

3. 失败重试为什么不能只停在当前进程内

一开始 SDK 只有自动重试 2 次。

这对临时网络失败来说够用,但接入方很快会问一个问题:

如果这次重试两次都失败,下次 App 重启以后怎么办?

这个问题一出来,我就知道,这不再只是“重试几次”的问题,而是“第一次生成的请求内容要不要保存下来”的问题。

因为一旦你要支持 App 重启后继续重发,就意味着:

  1. 这次请求的 bodyData 不能丢
  2. 不能下次再重新组合一遍参数
  3. 否则字段和时间可能会和第一次不一致

所以后来这一块的核心原则就变成:

重试永远基于第一次生成的请求内容,而不是重新构建请求参数。

这也是我觉得很值得写出来的一条工程经验。

很多人会默认“失败了再组合一次参数”,但埋点这种东西,真这么做,最后发出去的内容就可能和第一次不一样了。

而且这件事后面我越看越觉得不能偷懒,因为变化的根本不只是接口的参数 time

如果你让 SDK 失败后重新组一次参数,可能一起变掉的还有:

  1. 当时的网络状态
  2. 当时的权限状态
  3. 当时的安装相关字段
  4. 那次请求真正想表达的时间点

所以后来我对这条原则的理解就更明确了:

埋点请求一旦生成,就应该尽量把它当成那一刻的快照。

4. ta_app_install 事件上报的时间后来为什么还要单独修改

这也是产品验收时发现的一个问题。

一开始我会默认觉得:

  1. 普通事件上报接口传参 time 用当前时间
  2. 这是很自然的做法

这对绝大多数事件都没问题。

ta_app_install 不一样。

因为产品验收时看的不只是传参的 time,还会一起看:

  1. #install_time
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time

这几个时间字段本质上都应该指向同一个安装时间点。

当前项目之所以一直没出问题,是因为业务层本来就主动把安装时间传给了这条事件。

但 SDK 标准接法如果只是:

ZZHAnalyticsSDK.shared.track(eventName: "ta_app_install")

旧逻辑会直接把发起这次上报时的当前时间,写进这个事件的 time 字段。

这样一来,这个事件里的 time,和安装相关字段就不是同一个时间来源了。

这个问题特别能说明一件事:

把代码封装成 SDK,只是第一步,等产品真的开始逐个字段检查时,你才会知道还有哪些地方没处理对。

后来的修法也很克制:

  1. 普通事件还是继续用当前时间
  2. 只有 ta_app_install 且外部没传 timestamp 时,才默认改用 SDK 保存的安装时间

这件事让我后面更确定,SDK 真正难的不是“第一版怎么设计”,而是:

当产品拿着真实字段来验的时候,你能不能只改那一个真正有问题的地方,而不是顺手把整套时间逻辑都推倒。

五、埋点日志系统是怎么一步一步优化和完善的

如果说前面这些问题还在解决“SDK 能不能用”,那埋点日志系统解决的是另一件事:

SDK 能不能帮助测试和产品快速确认埋点参数有没有传对。

1. 最开始的日志,其实只对 SDK 开发者有用

最开始 SDK 里的日志更像网络请求调试日志:

  1. 打印发送过程
  2. 打印状态码
  3. 打印成功失败

但这类日志有个问题:

做 SDK 的人能看懂,测试拿去检查埋点参数就很难受。

因为测试真正关心的不是网络请求内部过程,而是:

  1. 这次到底发到哪个 URL
  2. 请求头是什么
  3. 请求参数是什么
  4. 服务端响应了什么
  5. 到底成功还是失败

所以后来日志被拆成了两类:

  1. 发起日志
  2. 结果日志

代码里也尽量保持这个拆分方式:

public func send(snapshot: ZZHAnalyticsRequestSnapshot,
                 completion: @escaping (Bool) -> Void) {
    var request = URLRequest(url: snapshot.url)
    request.httpMethod = "POST"
    request.httpBody = snapshot.bodyData

    #if DEBUG
    ZZHAnalyticsDebugStartLog(Self.startLog(for: snapshot), snapshot: snapshot)
    #endif

    URLSession.shared.dataTask(with: request) { data, response, error in
        if let error {
            #if DEBUG
            ZZHAnalyticsDebugLog(Self.failureLog(for: snapshot, error: error))
            #endif
            completion(false)
            return
        }

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            #if DEBUG
            ZZHAnalyticsDebugLog(
                Self.responseLog(for: snapshot, response: response, data: data, success: false)
            )
            #endif
            completion(false)
            return
        }

        #if DEBUG
        ZZHAnalyticsDebugLog(
            Self.responseLog(for: snapshot, response: httpResponse, data: data, success: true)
        )
        #endif
        completion(true)
    }.resume()
}

这段代码的重点,不是“加了几行日志”,而是把日志按使用场景拆开:请求发出去之前,先打印一条发起日志,让人能看到这次准备发什么;请求回来之后,再打印一条结果日志,让人能看到这次到底有没有成功。

2. 发起日志解决“准备发什么”的问题

发起日志最终打印的是:

  1. 时间
  2. 事件名或者用户属性更新方式
  3. URL
  4. Headers
  5. Params

它解决的问题很明确:

它能够让开发者、产品、测试清晰地知道埋点是否有正确发起上报。

3. 结果日志解决“最终发得对不对”的问题

结果日志则继续保留:

  1. URL
  2. Headers
  3. Params
  4. StatusCode
  5. Response
  6. Success

它解决的是另一层问题:

埋点上报请求最终到底成功没有,服务端返回了什么。

4. 为什么后来还要加埋点日志系统的代理方法

做到这里,我以为已经完成任务了。

后来同事那边又提了一个很真实的诉求:

他们项目里本来就有一个悬浮日志窗口,测试和产品会直接在 App 里看埋点日志,如果 SDK 内部只把日志打到 Xcode 控制台,对他们来说是远远不够的。

这时我才意识到,日志不只是“打印出来”,还得“送出去”。

于是后来又补了一层日志代理:

  1. SDK 在 Xcode 打什么
  2. 代理方法就原样返回什么
  3. 接入方拿到以后,直接塞进自己的日志窗口

最终对外暴露的协议是这样的:

public protocol ZZHAnalyticsLogDelegate: AnyObject {
    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveEventStartLog message: String,
        eventName: String,
        params: [String: Any]
    )

    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveUserPropertyStartLog message: String,
        updateType: String,
        params: [String: Any]
    )

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveEventResultLog message: String)

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveUserPropertyResultLog message: String)
}

这个代理方法不复杂,但它把两件事拆清楚了:一边是 SDK 原样日志 message,另一边是业务自己可能想再打印一条简洁日志用的 eventName / updateType / params

这一层能力看起来很小,但它把日志从“开发调试工具”变成了“测试和产品也能直接用的快速确认埋点参数有没有传对的工具”。

做到这一步,我自己的总结是:

很多 SDK 日志的问题,是日志只对 SDK 开发者有用,对测试和产品没有用。

六、同一个 params,在不同接法下代表的内容不一样

这是我在真实项目接入时遇到的一个具体问题。

一开始我把发起日志代理设计成:

  1. message:完整日志原文
  2. params:给接入方自己打印一条简洁发起日志

看起来很合理,但接入后我发现,同样叫 params,在不同接法下代表的内容并不一样。

1. 标准 SDK 接法

如果你走的是:

track(eventName:properties:)
setUserProperties(...)

params 很好理解,就是业务方最开始传进来的参数。

2. 直接发送完整请求参数

如果你走的是:

sendEventPayloadSnapshot(payload)
sendUserPropertyPayloadSnapshot(payload)

那 SDK 拿到的已经是完整的请求参数了。

这时 SDK 内部根本没法再判断:

  1. 哪些是页面最开始传进来的业务参数
  2. 哪些是 SDK 自动获取的公共、固定字段

所以这时发起日志里的 params,默认只能是请求参数里现成的 properties

而当前我这个项目,真实主路径其实更接近这一种。

也就是说,当前 SuperCoder / MQ09 不是一开始就完全走标准 track / setUserProperties,而是更多时候先在项目里把完整请求参数组装好,再交给 SDK 发送上报请求。

这也是为什么我后面会专门把这一点写进接入文档里。因为如果不把“当前项目接法”和“标准 SDK 接法”拆开讲,同事看到日志里的 params,会很容易误以为 SDK 自己把业务参数改掉了。

这个区别后来我专门写进了使用文档中,以免iOS同事理解有误。

3. 固定用户属性自动补发又是一个特例

后面又出现了第三种情况。

SDK 会自动补发一组固定用户属性,比如:

  1. country
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time
  5. install_ts_time_timezone

这组属性不是业务方手动传的,但测试又希望在发起日志里直接看到它们。

所以最后我又单独给它做了一个特例:

  1. 普通发起日志:params 继续代表外部原始入参
  2. 固定用户属性自动补发:params 特例代表 SDK 这次自动补发的固定字段

这件事说明了一个问题:

同一个参数名,看起来一样,但在不同使用方式下,代表的内容可能不一样。

这也是 SDK 设计里很容易忽略、但真实接入时很容易暴露的问题。

七、文档、tag、Pod 接入,为什么也是 SDK 工程的一部分

如果只看代码,这套 SDK 其实已经“能用了”。

但我觉得,真正决定它能不能被其他同事真正的在项目中使用,不只是代码。

还有以下这些东西:

  1. 使用文档写得是不是够直白
  2. 示例代码是不是和真实接法一致
  3. 版本号和 tag 有没有同步
  4. 其他同事复制文档接入时,会不会直接编译报错

我这轮就真实踩到了几个这样的坑:

  1. 使用文档写了新能力,但 tag 还是旧版本,同事一装就报错
  2. 使用文档里某些词太偏内部表达,比如“快照模式”,接入同事不好理解
  3. 日志代理示例里,类型写法稍微不直白,同事就可能写成错误的双层类型名

这些问题不容小觑,是 SDK 工程化本身的一部分。

因为对接入方来说,他们真正关心的是:

  1. 我怎么接
  2. 我怎么调
  3. 我怎么验
  4. 我装下来的这个版本,到底是不是文档里写的那个版本

所以,SDK 不是“代码抽出来”就结束了,它还要能被别人低成本接上。

而且到后面我还发现,低成本接上这件事,其实也分两层。

第一层是:

  1. README 写清楚
  2. tag 发对
  3. Pod 依赖能装上

第二层是:

  1. 同事能不能只写一个包名
  2. pod install 的时候会不会还要处理私有仓库认证

这轮我其实只把第一层基本收住了。

也就是说,SDK 代码和接入文档已经比一开始成熟很多了,但分发体验还没有完全走到最理想的形态。现在依然更接近“私有 git + tag”的方式,而不是那种更标准、更省心的私有 Specs 仓接法。

这也让我后面更确定一件事:

SDK 的工程化,不只是代码和 README,还包括分发基础设施到底有没有跟上。

八、这轮工作最后让我确定的几条原则

最后,总结 6 个我这次做 SDK 后真正踩出来的经验。

1. 项目里能跑通的代码,不一定适合直接做成 SDK

很多时候,项目里的代码只是刚好满足当前业务,想让其他项目也能用,还需要重新拆清楚:哪些放进 SDK,哪些留在项目里。

2. 发送能力最好可选,不要默认 SDK 接管一切

不是所有项目都希望 SDK 直接发请求。让 SDK 同时支持“构建参数”和“直接发送”,接入成本会小很多。

3. 重试一定要基于第一次生成的请求内容

埋点怕的不是失败,而是失败后重新组参数,导致最后发出去的内容和第一次不一样。只要涉及重试,就尽量保存第一次生成的请求内容。

4. 日志要方便测试和产品检查参数,而不只是方便 SDK 开发者调试

对 SDK 开发者好用,不代表对测试好用。日志里能不能一眼看到 URL、参数、响应和成功、失败,才决定这套日志有没有价值。

5. 文档、版本号和 Pod 接入方式,也要一起维护

文档不准、tag 不同步、示例代码不对,都会让接入方直接踩坑。SDK 想让别人顺利接入,就不能只管代码。

6. SDK 是靠真实接入反馈一点点打磨出来的

我这轮最大的感受就是这个,很多一开始觉得“设计得挺好”的地方,最后都是在真实项目接入、同事反馈和测试检查参数时,才暴露出问题。

一个埋点 SDK 真正的完成度,不取决于它能不能发请求,而取决于它能不能被其他项目低成本接入、被测试高效验参、被版本稳定发布。

这可能也是我这轮工作里,最值得留下来的那部分。

老司机 iOS 周报 #369 | 2026-04-27

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

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

文章

🌟 🐢 Under the hood: Android 17’s lock-free MessageQueue

@Crazy:Android MessageQueue 是从 Android 的核心框架,从 API1 就已经存在了,这次 Android 针对它进行了重构,是非常大的优化。

原始 MessageQueue 存在的问题: Android 的 MessageQueue 在过去的二十多年里靠着一把 monitor 同步锁保护,虽然没有大的问题,但是在多核多优先级场景下,这把锁会引发多线程争用同一把锁,并且进一步引发高优先级 UI 线程被低优先级后台线程间接拖慢。

新 MessageQueue 设计核心 DeliQueue: 新的 DeliQueue 采用 lock-free 数据结构的设计方式来解决上面的问题,简单用一句话来描述就是 “可以无锁写入的多生产者线程与独占排序和结构整理能力的单消费者 Looper 线程。” 核心方式就是利用原子操作来替代锁,下面我们把 lock-free 拆解一下,不涉及很多的源码。

  1. 插入序号: 利用 mNextInsertSeqValue/mNextFrontInsertSeqValue 两个 volatile 变量来进行插入排序, 主要判断在 enqueueMessageUnchecked 方法的第一行,就是判断 when 是否为 0 来保证不管来自多少个生产者线程的任何两个消息都有一个全序:先比 when,再比 insertSeq。最小堆就靠这个 key 排序。
long seq = when != 0 ? ((long) sNextInsertSeq.getAndAdd(this, 1L) + 1L) : ((long) sNextFrontInsertSeq.getAndAdd(this, -1L) - 1L);
  1. 唤醒判断: 利用 mWaitState 这个 volatile 变量实现 64 位状态机,然后加上 CAS 版本号操作实现整体唤醒判断.
if (WaitState.isCounter(waitState)) {
    // 情况 A:looper 已醒
} else if (msg.when >= WaitState.getTSMillis(waitState)) {
    // 情况 B:新消息不比当前 deadline 更早,我们不需要唤醒
} else if (msg.isAsynchronous()) {
    // 情况 C:新消息更早,且是 async(绕过消息屏障),需要唤醒
} else {
    // 情况 D:我们需要看消息屏障状态,决定是否需要唤醒
    if (blockedByBarrier) {
        newWaitState = WaitState.incrementDeadline(waitState);
        checkBarrier = barrier;
        needWake = false;
    } else {
        newWaitState = WaitState.initCounter();
        checkBarrier = null;
        needWake = true;
    }
}
  1. native 指针保护: 利用 mMptrRefCountValue 这个 volatile 变量实现对 native epoll 的句柄控制。native epoll 句柄,必须有人持有时不能 free,无人持有时要立即 free,Google 仅用一个 long 就这两件事。
private static final long MPTR_TEARDOWN_MASK = 1L << 63;

// 生产者增引用(incrementMptrRefs)
while (true) {
    final long oldVal = mMptrRefCountValue;
    if ((oldVal & MPTR_TEARDOWN_MASK) != 0) {
        return false;  // 已 teardown,拒绝新引用
    }
    if (sMptrRefCount.compareAndSet(this, oldVal, oldVal + 1L)) {
        return true;
    }
}

// 生产者减引用(decrementMptrRefs)
long oldVal = (long) sMptrRefCount.getAndAdd(this, -1L);
if (oldVal - 1 == MPTR_TEARDOWN_MASK) {
    LockSupport.unpark(mLooperThread);  // 我是最后一个活引用,且 looper 在等
}

// 与 wake 的协作
private void concurrentWake() {
    if (incrementMptrRefs()) {
        try { nativeWake(mPtr); }
        finally { decrementMptrRefs(); }
    }
}
  1. 单消息存在性: Tombstone CAS(墓碑 CAS),用一次 CAS 翻一个"已删除"标志位,而不是用锁去操作数据结构本身,保证消息的逻辑消失(真正能让消息消失的只有 looper 线程操作 min-heap),同时保证不会触发多线程修改同一个列表的情况。
// 代码位置 MessageStack,所有线程都可以调用
public int moveMatchingToFreelist(Message.MessageCompare compare, Handler h, int what, Object object, Runnable r, long when) {
    Message current = (Message) sTop.getAcquire(this);
    Message prev = null;
    Message firstRemoved = null;
    int numRemoved = 0;

    while (current != null) {
        if (messageMatches(current, compare, h, what, object, r, when)
                && current.markRemoved()) {
            if (firstRemoved == null) {
                firstRemoved = current;
            }
            current.clearReferenceFields();
            // nextFree links each to-be-removed message to the one processed before.
            current.nextFree = prev;
            prev = current;
            numRemoved++;
        }
        current = current.next;
    }

    if (firstRemoved != null) {
        Message freelist;
        do {
            freelist = mFreelistHeadValue;
            firstRemoved.nextFree = freelist;
        // prev points to the last to-be-removed message that was processed.
        } while (!sFreelistHead.compareAndSet(this, freelist, prev));
    }

        return numRemoved;
}

// looper 准备派发的消息已被并发删除
if (found != null && !peek) {
    if (!found.markRemoved()) {
        continue;  // 别人已经把它标记为删除,重新找下一条
    }
    mStack.remove(found);
}

// looper 线程
MessageStack.poplooper 线程):
if (!m.markRemoved()) {
    return null;  // 别人已经标记了,我让出
}
  1. 生产者/消费者职责切分: 任何线程都可以进行 pushMessage、markRemoved、freelist 和 nativeWake 等操作,但是只有 looper 线程可以进行 min-heap、nativePollOnce 等操作,将整体职责全部分开,昂贵的结构维护工作集中到单线程中完成。

最后我们总结一下新的设计的整体流程: 拿插入序号(原子 getAndAdd,1 条 CPU 指令) -> 写消息字段(不用同步控制) -> mStack.pushMessage(Treiber stack CAS push。失败重试,平均 1–2 次 CAS) -> 唤醒决策循环(读 mWaitState,CAS 写新状态) -> 可能调 concurrentWake。完成整体 pushMessage 操作,全程没有 synchronized 关键字,最坏情况也只有几次 CAS retry,最快路径 0 次内核调用,大大减轻了系统负担。

整篇文章其实不止写了 lock-free 数据结构的设计,其余还有很多,比如 Treiber stack、比如如何利用双链表机制是让 Looper 在线程内高效地把某个节点从 stack 链中摘掉。还有 Google 如何利用 Perfetto 和 PerfettoSQL 进行大量的 trace 分析,确认问题以及修复问题后的验证。可以说这篇文章中的每一部分都可以拿出来单独写一篇比较好的操作指南针,也可以看出 Google 在针对 MessageQueue 的修改上是有多么的慎重,以及在这种多线程上的恐怖控制力,可以说这是一篇值得所有人反复阅读的文章。

🐕 SQLite: Vacuuming the WALs

@ChengzhiHuang: sqlite 是常见的端用存储,一般也都会辅以开启 Write-Ahead Logging(WAL) 模式提升性能。对于一些低存储用户,我们还会辅以开启 incremental_vacuum 定期整理 .-wal 文件进一步减少磁盘占用(注:直接使用 vacuum 是不被推荐的,但是如果数据库本身已经存在,则必须先执行一次完整的 vacuum 才能开启 incremental_vacuum,因此最好是新建的时候默认打开)。本文对 incremental_vacuum 进行了进一步的细分,研究了配置不同的阈值(每次清理的页数)下,整体数据库的表现。大家可以参考自己数据库的实际情况选择不同阈值分批 incremental_vacuum 。

同时提醒大家记得在 incremental_vacuum 完成后再手动进行 checkpoint 才能有效减少磁盘占用,不然只是缩小了 free pages 的数量。

🐕 A Small SwiftUI Warning and a Long Journey to Understand It

@阿权:本文作者在开启 Swift InferSendableFromCaptures(SE-0418)特性后,遇到 SwiftUI 导航修饰器传递视图构造器函数引用的 Actor 隔离警告的问题。根本原因是:警告只是 Swift 5 迁移模式下的一个产物,升级到 Swift 6 后并不是问题。怎么理解呢?

  1. Swift 5 迁移模式的限制:当我们启用一个“未来特性”标志(如 InferSendableFromCaptures)来提前测试 Swift 6 的行为时,它可能会暴露一些问题,但由于底层的检查模型仍是旧版的,所以会产生一些在最终模型中并不存在的“过渡性警告”。
  2. 理解编译模式的差异:在 Swift 6 模式下,编译器能全盘理解上下文并得出正确结论,所以代码直接通过。而在 Swift 5 模式下,它只看到了部分信息,从而发出了多余的警告。

如何去一步步找到问题的根因也是文章的重点,通过作者的探索也能给到我们一些开发实践的建议:

  1. 优先升级语言版本:如果项目条件允许,尽早将 Swift 语言版本升级到 Swift 6。这能让你获得最准确、最一致的并发检查体验。
  2. 深入理解并发模型:花时间去理解 Swift 并发模型的核心概念,如 Actor 隔离和上下文继承。这能让你在处理更复杂的并发问题时游刃有余。
  3. 审慎看待过渡期警告:当你使用 -enable-upcoming-feature 等标志在旧语言模式下测试新特性时,要意识到看到的警告可能带有“过渡性”特征,需要结合最终的语言模型来理解其真正含义。
  4. 不满足于“能用”:在开发中,遇到一个修复方案时,多追问一句“为什么”,“为什么这样能解决问题”。这能帮助你真正理解问题本质,避免被表象解释所误导。

🐕 Lazy Properties in Swift - Why They Don't Always Work in SwiftUI

@Barney:这篇文章系统梳理了 Swift 里 lazy 属性的行为边界,重点不是语法本身,而是它在 SwiftUI 里的常见误用。作者先回顾了 lazy 适合解决的几类问题:延迟昂贵初始化、缓存只需计算一次的结果,以及依赖 self 的初始化;随后指出一个很容易踩的坑:SwiftUI 的 View 是值类型且会频繁重建,而 lazy 首次访问时需要发生写入,这使它既不适合作为稳定缓存,也无法直接放进 body 所依赖的视图属性里。文章给出的实践建议也很明确:在 SwiftUI 中优先用 @State@StateObject 或对象持有者管理生命周期,把 lazy 留给 class、service、formatter 或计算代价较高的缓存对象。对经常在 SwiftUI 中做性能优化的同学很有参考价值。

🐎 A Reusable Spotlight Onboarding Component in SwiftUI

@DylanYang:作者基于 SwiftUI 的 PreferenceKey 与锚点系统,实现了一款可复用的引导组件,无需依赖 UIKit 即可完成视图高亮、圆角镂空遮罩、自适应提示卡片展示与多步骤平滑动画切换。该组件适配导航栈、滚动视图、安全区、弹窗等各类场景,通过 tutorialSpotlight modifier 和 tutorialSpotlightSource modifier 即可快速接入,还支持自定义高亮内边距、圆角、背景点击关闭等配置,能便捷搭建完整的界面引导流程。感兴趣的开发同学可以阅读下具体的实现过程。

🐎 SwiftPM: 2x faster resolves, 3x smaller disk footprint

@david-clang:SwiftPM 长期受限于 Git 全量克隆导致的解析缓慢与磁盘占用过大。受此影响的 Ordo One 公司提交了优化提案 (PR #9870),通过引入源码归档下载路径实现大幅优化。该方案能保持 Public API 不变,且无需开发者修改 Package.swift 。其核心流程如下:

  1. 执行 git ls-remote --tags —— 发现可用版本(无新增 API,与现有机制一致)。
  2. 从 CDN 获取 Package.swift —— 检查工具版本(Tools Version)的兼容性。
  3. 从 GitHub CDN 直接下载 ZIP 压缩包 —— 提取源码,完全绕过 Git 克隆过程。

降级机制 :

  • 子模块 (Submodules):降级为浅克隆。
  • 流程异常 (如下载失败):无缝回退至旧版全量克隆机制(git clone --mirror)。

基准测试与性能收益:

  • 测试方法:选取包含 swift-composable-architecture (TCA)、SwiftLint 等不同规模(9 至 67 个依赖项)的知名开源项目。分别对比新旧方案在冷解析(清空 .build 与全局缓存,模拟 CI 环境)和热解析(保留全局共享缓存,模拟本地开发)下的耗时与磁盘增量。
  • 解析提速:冷解析场景下速度最高提升约 2.1 倍;热解析场景下速度最高提升达 3.8 倍
  • 空间优化:因彻底免除本地 Git 历史数据的存储,.build/ 目录的磁盘占用平均锐减 3 倍(例如,某重度依赖项目的体积由 1.8GB 缩减至约 600MB)。

内推

重新开始更新「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)

在 Github 中通过创建 issue 来唤醒 claude 工作

前置条件

  • 你是目标 repo 的 admin
  • 已有 Anthropic API Key(或 AWS Bedrock 凭证)
    • 申请 Anthropic API Key 可以使用 claude setup-token 命令,得到一个 sk 开头的 key

方式一:安装官方 Claude App(最快)

  1. 打开 https://github.com/apps/claude,点击 Install
  2. 选择你要授权的 repo(建议只勾选需要的 repo,不要 All repositories)
  3. 确认安装

安装完成后跳到下面的「配置 Secrets 和 Workflow」章节。


方式二:创建自定义 GitHub App(完全掌控权限)

适用场景:组织策略不允许装第三方 App、需要更严格的权限控制、使用 AWS Bedrock / Vertex AI。

A)快速创建(推荐)

  1. https://github.com/anthropics/claude-code-action/blob/main/docs/create-app.html 右键「另存为」下载 create-app.html
  2. 用浏览器打开这个文件
    • 个人账号:点击「Create App for Personal Account」
    • 组织账号:输入组织名称,点击「Create App for Organization」
  3. GitHub 会展示 App 配置预览 → 确认名称 → 点击 Create GitHub App
  4. 创建后自动跳转到 App 设置页,往下滚到 Private keys → 点 Generate a private key → 下载 .pem 文件(妥善保管)
  5. 跳到下面的「安装 App 到 Repo」步骤

B)手动创建

  1. 打开 https://github.com/settings/apps(个人)或组织的 Settings → Developer settings → GitHub Apps
  2. New GitHub App,配置以下权限:
权限类别 权限项 级别
Repository permissions Contents Read & Write
Repository permissions Issues Read & Write
Repository permissions Pull requests Read & Write
Account permissions
  1. 「Where can this GitHub App be installed?」选 Only on this account
  2. Create GitHub App
  3. 创建完成后,滚到 Private keysGenerate a private key → 下载 .pem 文件

安装 App 到 Repo

  1. 进入你刚创建的 App 设置页
  2. 左侧菜单点 Install App
  3. 选择要安装的 repo,确认

配置 Secrets 和 Workflow

第一步:添加 Repository Secrets

进入 repo → SettingsSecrets and variablesActionsNew repository secret

Secret 名称
ANTHROPIC_API_KEY 你的 Anthropic API Key(sk-ant- 开头)
CLAUDE_CODE_OAUTH_TOKEN(可选,替代上一条) claude setup-token 生成的 OAuth Token
APP_ID(自定义 App 才需要) App 设置页里的 App ID
APP_PRIVATE_KEY(自定义 App 才需要) .pem 文件的完整内容

⚠️ 绝对不要把 API Key 写在代码里,只通过 Secrets 引用。
ANTHROPIC_API_KEYCLAUDE_CODE_OAUTH_TOKEN 二选一即可,下面示例以 API Key 为主,OAuth 用法是把 anthropic_api_key: 换成 claude_code_oauth_token:

第二步:创建 Workflow 文件

在 repo 中创建 .github/workflows/claude.yml

如果用官方 Claude App:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
name: Claude Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]

# 仓库级权限:按需最小化——只读场景可把三个 write 都改成 read
permissions:
contents: write
pull-requests: write
issues: write
# 用 CLAUDE_CODE_OAUTH_TOKEN 时必加,OAuth 流程要用 OIDC token 去换;
# 用 ANTHROPIC_API_KEY 时可省。
id-token: write
# 让 Claude 能读 CI run 日志("我 PR 的 CI 挂了帮我看看")
actions: read

jobs:
claude-response:
# 双重门槛:
# 1. actor 必须是仓库主人本人——防外部用户触发
# 2. 触发载体里必须出现 @claude——没有则直接 skip,连 runner 都不起,省 Action 额度
# 注意:include_comments_by_actor 只过滤"评论",不一定覆盖 issue body /
# PR description / review body,所以第 1 条的 actor 校验是必须的纵深防御。
if: |
github.actor == '你的用户名' && (
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude')
|| contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
steps:
# claude-code-action 需要 .git 目录才能创建分支去提 PR
- uses: actions/checkout@v4
with:
fetch-depth: 1

- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# 第三道门:评论场景也只接受白名单
include_comments_by_actor: "你的用户名"
# 工具权限:纵深防御——Bash 不再裸开,按命令前缀逐条白名单。
# 只读场景可把所有 Bash(...) 和 Edit/Write 删掉。
claude_args: |
--max-turns 30
--allowedTools "WebFetch,WebSearch,Edit,Write,Read,Glob,Grep,TodoWrite,Bash(git:*),Bash(gh:*),Bash(npm:*),Bash(pnpm:*),Bash(yarn:*),Bash(npx:*),Bash(node:*),Bash(curl:*),Bash(jq:*),Bash(rg:*),Bash(fd:*)"

如果用自定义 GitHub App:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
name: Claude with Custom App
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]

permissions:
contents: write
pull-requests: write
issues: write
id-token: write # 用 OAuth Token 时必加
actions: read # 让 Claude 能看 CI run 日志

jobs:
claude-response:
if: |
github.actor == '你的用户名' && (
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude')
|| contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}

- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.app-token.outputs.token }}
include_comments_by_actor: "你的用户名"
claude_args: |
--max-turns 30
--allowedTools "WebFetch,WebSearch,Edit,Write,Read,Glob,Grep,TodoWrite,Bash(git:*),Bash(gh:*),Bash(npm:*),Bash(pnpm:*),Bash(yarn:*),Bash(npx:*),Bash(node:*),Bash(curl:*),Bash(jq:*),Bash(rg:*),Bash(fd:*)"

Public Repo 安全清单

  • ifactor 白名单github.actor == '你的用户名'),不要只用”排除 bot”的黑名单
  • if 再叠一层 contains(<事件正文>, '@claude') 判断——没有触发词直接 skip,省 Action 额度也避免被误触
  • include_comments_by_actor 同步设置用户白名单(注意:它只过滤评论,不一定覆盖 issue body / PR description,所以上面两条 if 校验是必须的纵深防御)
  • 顶层 permissions: 块按需最小化授权——只读场景 contents/pull-requests/issues 都给 read 即可;要提 PR 才给 write
  • claude_argsBash 不要裸开——用 Bash(git:*),Bash(gh:*),... 这种命令前缀白名单收紧
  • allowed_bots 保持默认空值(不要设 *
  • show_full_output 保持默认 false
  • API Key / OAuth Token 只通过 ${{ secrets.XXX }} 引用,不硬编码
  • 定期轮换 API Key
  • 意识到一旦 actor 校验被绕过,攻击者拿到的就是 workflow permissions 里授予的全部能力——所以前两条最关键

验证

配置完成后,在 issue 里评论 @claude 你好,如果一切正常,Claude 会在几秒内回复。

参考文档:https://github.com/anthropics/claude-code-action

我做了个专注 App,把连续打卡阈值从 3/7/14 改成 2/5 之后留存明显好了

起点

我在 App Store 搜「专注计时」,前十名的截图几乎一模一样:一个大圆圈倒计时,白底或深色背景,偶尔配个绿色进度环。点进去功能也差不多,计时、响铃、记录时长,结束。

作为开发者我看这些截图的第一反应是:这赛道已经死了吗?还是说用户根本不在意差异化?

我选择赌后者不成立。声境护照的核心假设是:计时工具留不住人,不是因为功能不够,而是因为「完成一次专注」这件事本身没有叙事——没有积累感,没有值得回头看的东西。所以我把每次专注包装成一段「声音旅行」:选声景、积里程、攒护照印章,结束后拿一张可以发朋友圈的战报卡片。

下面聊几个做这个 App 时真正踩过坑的技术决定。


探险系统:把定义层和状态层拆开

探险章节的数据模型是我返工次数最多的部分。

早期我把任务进度直接存在 definition 结构里,targetValueprogresscompletedAt 全塞一起。有次用户完成任务后触发回写逻辑,targetValue 被意外覆盖成了 0——因为写进度的地方用了同一个赋值路径——任务直接从列表里消失了。用户以为是 bug,其实是数据结构没设计好。

后来拆成了「定义层」和「状态层」两套结构:

// 定义层:静态配置,只读,不随用户行为变化
struct ExpeditionMissionDefinition: Identifiable, Codable {
    let id: String
    let title: String
    let kind: ExpeditionMissionKind  // sessionCount / focusMinutes / deepFocusCount
    let targetValue: Int
    let rewardMiles: Int
}

// 状态层:只存进度和完成时间戳
struct ExpeditionMissionState: Identifiable, Codable {
    let id: String
    var progress: Int
    var completedAt: Date?   // nil = 未完成
    var completed: Bool { completedAt != nil }
}

两层通过 id 关联,定义层只从远端下发,本地不写。这样之后就算服务端更新了任务内容,也不会碰用户本地的进度状态。deepFocusCount 的判定逻辑是单次时长超过 20 分钟且中途没有中断,这个阈值调了四五次才定下来,最开始设的是 15 分钟,太容易达到,用户没有「深度」的感觉。


连续天数阈值:从 3/7/14 改成 2/5

会话结束后 App 会给一条下一步建议,根据当天计划完成情况和连续打卡天数动态生成:

let streakHint: String
if store.streakDays >= 5 {
    streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
} else if store.streakDays >= 2 {
    streakHint = "再坚持 1-2 天可进入稳定习惯区。"
} else {
    streakHint = "建议先连续 3 天完成每天的最低目标。"
}

早期版本阈值是 3/7/14,对应「习惯养成」里常见的节点说法。结果我发现第一个门槛「再坚持 4 天」对新用户压力很大——刚用第一天,看到这句话心理上已经开始算成本了。

改成 2/5 之后,第一个提示变成「再坚持 1-2 天」,心理距离近了很多。我没有大样本数据来证明这个改动有多少提升,但从我自己用和几个测试用户的反馈来看,看到「1-2 天」比看到「4 天」更容易当天再开一次计时。

说白了就是:第一个里程碑要近到「今晚就能拿到」。


统计页的 Demo 模式

新用户第一次打开,专注记录为空,统计页一片白——这是工具类 App 最难看的冷启动体验。

处理方式是:focusLogs 为空时用 StatsService.createDemoFocusLogs() 生成假数据填充,同时打 isDemo: true 标记,UI 层显示「这是示例数据」的提示。几乎所有 ViewModel 都是这一行:

private var logs: [FocusLog] {
    store.focusLogs.isEmpty
        ? StatsService.createDemoFocusLogs()
        : store.focusLogs
}

这个方案有个明显缺陷:demo 数据是静态写死的,不会根据时区或当前时间调整,周一打开看到的「本周统计」热力图和周五打开是一样的。这是我已知的技术债,下个版本会改成基于当前时间动态生成。但在真实数据出现之前,给用户看一个「满血状态」的统计页,比空白页的跳失率要低——这是我在几个类似工具上观察到的规律,所以先凑合用着。


分享卡片:为什么最终选了 SwiftUI 截图方案

会话结束后可以导出一张数据卡片:时长、效率指数、声景名、里程和等级称号。这个功能我试了三个方案。

Core Graphics 手绘:可控性最高,但每次改卡片样式要同时维护 UI 代码和绘制代码两套,改了一个忘了另一个,有次导出的卡片和 App 里显示的样式差了半个版本,挺尴尬的。

WKWebView 渲染 HTML:样式灵活,服务端可以随时更新模板,但首次渲染有明显延迟,用户点「生成卡片」之后要等将近一秒才出图,这个等待感在分享场景里特别割裂。

最后选了 SwiftUI 视图截图:UI 和卡片共用同一套组件,改一处两边同步,维护成本低。代价是在部分低端设备上截图后文字抗锯齿发虚,看起来不如原生渲染清晰。我接受这个取舍——大多数用户用的是近三年的机型,发虚的问题不常见。


卡在一个问题上,想听听大家的看法

App 现在刚上线,还在冷启动阶段。我目前卡在一个判断上:「护照 + 里程」这套叙事,对重度效率用户来说会不会显得幼稚?

我身边用这类工具的人大概分两种:一种要的是纯粹的效率,恨不得界面越简单越好;另一种喜欢打卡晒图,仪式感对他们来说本身就是动力。声境护照明显是为第二种人做的,但我不确定第一种人会不会因为「太花哨」直接关掉。

你们做工具类 App 的时候,怎么处理这两类用户的需求冲突?或者说,你们自己用专注工具,更看重哪一面?

别被系统绑架:SwiftUI List 替换背后的底层逻辑

在这里插入图片描述

凌晨三点,楼里只剩空调低鸣。林屿坐在工位前,盯着 SwiftUI 里的 List,像盯着一个多年的老朋友。这个老朋友不坏,甚至称得上可靠。可今天,他忽然觉得不对劲了。页面能跑,交互也顺,但那层说不清的“高级感”,总像隔着一层雾,伸手能碰到,握住却没有。问题出在哪?他顺着代码往下摸,摸到最后,才发现真正的悬念从来不在样式,而在工具选错了场子。

🧭 在 SwiftUI 中构建 List 的替代方案

每当你打算在 SwiftUI 里做一个可滚动页面时,第一反应往往是用 List。这很正常。List 名声在外,又是系统组件,出手就带着几分正统气息。

在这里插入图片描述

但话说回来,它并不总是最合适的选择

List 最擅长的,是展示那种整齐划一的统一数据。比如邮箱列表、待办事项、联系人,这类内容结构规整,节奏统一,List 处理起来得心应手。

可如果不是这种场景,画风就变了。
对于其他更灵活、更讲究布局和视觉层次的页面,ScrollView 搭配 lazy stack,几乎总是更好的方案。

在这里插入图片描述

这篇文章要讲的,就是如何在 SwiftUI 中构建一个自定义的可滚动容器,让我们对 look and feel 拥有真正精细、可控的掌握力。


⚙️ 先把底牌摊开:ScrollView 这几年,已经不是昨日黄花

先说一句实在话。

过去几年里,SwiftUIScrollView + lazy stacks 的性能做了相当大的改进。它早已不是那个“能用,但心里发毛”的角色。今天的它,已经足够稳,足够快,也足够灵活。

在这里插入图片描述

所以,如果你展示的不是那种几十万条统一数据,比如邮箱、待办清单这种超大规模列表,那么:

ScrollView is a way to go.

这句话轻描淡写,实际上意味深长。

它的意思不是 “List 不行”,而是:
如果你的页面不依赖大规模统一数据的复用机制,那你就没必要把自己绑死在 List 上。

工具有长处,也有边界。看不见边界,迟早吃亏。


🫀 CardioBot 的现状:已经不错,但还不够狠

这是林屿自己独立开发的 CardioBot app。

在这里插入图片描述

上面有 4 张截图:前两张是当前版本,后两张是林屿想达到的效果。

现在这款 app 使用的是标准 List。而且说句公道话,它现在的界面观感并不差,作者自己也很喜欢它目前的 look and feel

但人一旦开始较真,就回不了头。

在这里插入图片描述

林屿决定重新审视自己的 UI。目标并不激进,不是要把界面改得面目全非,而是要做到两件事:

  • 保留 iPhone 用户熟悉、直观、可识别的感觉
  • 让 UI 再精致一些,再讲究一些,再“骚”一点,但绝不轻浮

这类优化最难。它不是“重做”,而是“进一寸”。可往往,真正拉开差距的,就是这一寸。


🧱 为什么这里的 List 已经不再对味了

CardioBot 展示的是不同类型的健康指标。问题在于,这些内容不是统一数据集,而是一组风格不同、职责不同的内容块。

林屿用了多种 card 类型,比如:

  • HeroCard
  • TintedCard
  • RegularCard

看到这里,症结就露出来了。

如果数据并不统一,那么使用 List 去做 cell recycling,其实就没多大意义。List 的一身本事,主要是为海量、统一、标准化的数据而生。可这里是一桌散席,不是整齐列队。

在这里插入图片描述

林屿当然也试过继续依赖 List
他可以通过一些 list-specific view modifiers 做出接近目标的样子,比如:

  • listRowBackground
  • listItemTint
  • listRowInsets

它们在 List 内部确实很好使,像一把趁手的短刀。

可惜,刀再锋利,也有出鞘范围。
这些 list-specific view modifiers 一旦离开 List view,立刻失灵。也就是说,这些能力是 List 私有的,不可外借。

在这里插入图片描述

结果就是:
你想在 List 之外维持相同风格,就必须额外补样式。补来补去,补成了拆东墙补西墙,最后不是代码发虚,就是视觉跑偏。

这就不是“能不能做”的问题了,而是“做得值不值”。


🪄 真正的转机:Container View APIs

幸运的是,SwiftUI 后来引入了 Container View APIs

这套 API 看起来安安静静,实际上杀伤力很大。它允许我们把 SwiftUI 视图先拆解,改点东西,再重新组合回来。

这意味着什么?

在这里插入图片描述

意味着你不再只是“使用容器”,而是可以“制造容器”。
你可以借助 Container View APIs 构建可复用的容器视图,像 ListForm,甚至任何高度自定义的东西。

说穿了,这是一种权限的变化。
以前你在用系统给的积木。
现在你很Happy的开始自己烧砖。


📦 第一块积木:ScrollingSurface

由于林屿的 app 中每个页面都采用 ScrollView 加 lazy stack,所以他提炼出了一个统一类型:ScrollingSurface

public struct ScrollingSurface<Content: View>: View {
    public enum Direction {
        case vertical(HorizontalAlignment)
        case horizontal(VerticalAlignment)
    }

    let direction: Direction
    let spacing: CGFloat?
    let content: Content

    public init(
        _ direction: Direction = .vertical(.leading),
        spacing: CGFloat? = nil,
        @ViewBuilder content: () -> Content
    ) {
        self.spacing = spacing
        self.direction = direction
        self.content = content()
    }

    public var body: some View {
        switch direction {
        case .horizontal(let alignment):
            ScrollView(.horizontal) {
                LazyHStack(alignment: alignment, spacing: spacing) {
                    content
                }
                .scrollTargetLayout() // 告诉滚动系统:这里是目标布局区域
                .padding()
            }
        case .vertical(let alignment):
            ScrollView(.vertical) {
                LazyVStack(alignment: alignment, spacing: spacing) {
                    content
                }
                .scrollTargetLayout() // 垂直方向同理
                .padding()
            }
        }
    }
}

他的意思很直接:
ScrollingSurface 本质上就是对 ScrollViewLazyVStack / LazyHStack 的一个简单包装。根据方向不同,切换成垂直或水平滚动容器。

在这里插入图片描述

但别小看这个“简单”。

为什么它值得单独抽出来?

因为它做了三件很重要的事:

  • 统一了页面根结构
  • 统一了滚动方向的表达方式
  • 统一了 spacing 和 padding 的布局语义

林屿会把 ScrollingSurface 作为 app 每个页面的 root view。
这不是偷懒,这是定规矩。

在这里插入图片描述

规矩一旦立住,后面的样式和结构才能不乱套。


🃏 第二块核心积木:DividedCard

接下来,UI 里的关键原语出现了:DividedCard

它最重要的地方,在于使用了 Group(subviews:),这是 SwiftUI Container View API 的一部分。

public struct DividedCard<Content: View>: View {
    let content: Content

    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    public var body: some View {
        Group(subviews: content) { subviews in
            if !subviews.isEmpty {
                VStack(alignment: .leading) {
                    ForEach(subviews) { subview in
                        subview

                        if subviews.last?.id != subview.id {
                            Divider()
                                .padding(.vertical, 8) // 在每个子视图之间插入分隔线
                        }
                    }
                }
                .padding()
                .frame(maxWidth: .infinity, alignment: .topLeading)
                .background(
                    .regularMaterial,
                    in: RoundedRectangle(cornerRadius: 32) // 给整体包上圆角卡片背景
                )
            }
        }
    }
}

Group(subviews:) 到底妙在哪?

这招很关键。
它允许我们把通过 @ViewBuilder 传进来的视图拆成一个个子视图

在这里插入图片描述

换句话说,你不再只能把一整坨内容当黑盒来用,而是能看到里面每个子项,并逐个处理它们。

林屿在 DividedCard 里干的事情很漂亮:

  1. 先把内容拆开
  2. 遍历所有 subviews
  3. 在每个子视图后面加上 Divider,但最后一个不加
  4. 最后把整个结构包进一个带圆角的材质背景里

结果就是:
一组原本只是“连续排列的内容”,立刻拥有了卡片感、分组感和边界感。

这一手为什么重要?

因为很多产品界面都存在这样的结构:

  • 一张卡片里放多个入口
  • 每个入口既独立,又需要视觉连续
  • 中间要有分隔,但不能显得生硬

以前你可能要在每个地方重复写 Divider、padding、background、cornerRadius`,写多了就腻,改起来更烦。

在这里插入图片描述

现在不同了。
DividedCard 把这套规则提炼成了一个可复用 primitive

这就是架构的味道:
不是“这页看着对”,而是“以后都能对”。


🧩 第三块积木:SectionedSurface

另一个很有意思的 UI primitive,是 SectionedSurface

public struct SectionedSurface<Content: View>: View {
    let content: Content

    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    public var body: some View {
        ForEach(sections: content) { section in
            if !section.content.isEmpty {
                section.header.padding(.top) // 给 section 的 header 增加顶部间距
                section.content
                section.footer
            }
        }
    }
}

它使用了 ForEach(sections:),这个能力可以从传入的视图中提取所有的 Section,然后做统一处理。

林屿这里做了两件事:

  • 过滤掉没有内容的 section
  • 给 section header 增加一些顶部间距

这看着朴素,实际上很实用。

在这里插入图片描述

因为在真实业务里,section 常常是动态的。
某块有数据,就该显示;没数据,就该消失。
如果每个页面都自己处理一遍这些逻辑,迟早会写成一锅粥。

SectionedSurface 把这类规则直接吸收到了容器层。
页面只负责描述内容,容器负责决定组织方式。

这就叫分寸。
代码里有分寸,界面就不会失态。


➡️ 离开 List 后,NavigationLink 的箭头去哪了?

很多人一旦不用 List,很快就会发现少了点什么。
没错,就是 NavigationLink 右侧那个熟悉的小箭头,也就是 chevron

List 中,它会自动出现在 trailing edge,系统帮你安排得明明白白。可一旦离开 List,这个默认样式就没了。

在这里插入图片描述

林屿的办法很干脆:写一个自定义 ButtonStyle

public struct NavigationButtonStyle: ButtonStyle {
    public func makeBody(configuration: Configuration) -> some View {
        HStack {
            configuration.label
                .opacity(configuration.isPressed ? 0.7 : 1) // 按下时微微变淡,增加反馈感
            Spacer()
            Image(systemName: "chevron.right")
                .foregroundStyle(.tertiary) // 补回 List 风格的右侧箭头
        }
        .contentShape(.rect) // 扩大点击区域,让整行都可点
    }
}

extension ButtonStyle where Self == NavigationButtonStyle {
    public static var navigation: Self { .init() }
}

这一招的好处在于,它不是临时补救,而是顺势把“导航型按钮”的风格单独抽了出来。

在这里插入图片描述

以后只要写:

.buttonStyle(.navigation)

整页涉及导航的按钮,就能统一表现。

这才像回事。
高手不是把洞补上,高手是顺手把墙也砌直。


🏗️ 实战拼装:SummaryView

下面这段代码,展示了前面这些新原语在 app 中的实际用法。

public struct SummaryView: View {
    let summary: SummaryStore
    
    public var body: some View {
        ScrollingSurface {
            SectionedSurface {
                coachSection
                activitySection
                recoverySection
                vitalsSection
                heartRateSection
                alcoholicBeveragesSection
            }
        }
        .buttonStyle(.navigation) // 统一套用导航按钮样式
    }
    
    @ViewBuilder private var activitySection: some View {
        Section {
            if !summary.metrics.workouts.isEmpty {
                DividedCard {
                    ForEach(summary.metrics.workouts, id: \.workout.uuid) { snapshot in
                        NavigationLink {
                            WorkoutDetailsView(snapshot: snapshot)
                        } label: {
                            WorkoutView(snapshot: snapshot)
                        }
                    }
                }
            }
        } header: {
            SectionHeader(
                .horizontal,
                title: Text("activitySection"),
                systemImage: "figure.run"
            )
            .tint(.orange)
        }
    }
}

这一段真正漂亮的地方在哪?

表面上看,它的使用方式和 List API 非常像:

  • Section
  • NavigationLink
  • 有 header
  • 有内容分组

但底层已经换了天地。

在这里插入图片描述

林屿通过:

  • ScrollingSurface
  • DividedCard
  • SectionedSurface
  • NavigationButtonStyle

重新拼出了类似 List 的使用体验,同时拿回了对 look and feel 的精准控制。

更妙的是,如果某个页面压根不需要 sections,只要把 SectionedSurface 去掉即可,其余 primitive 仍然能继续复用。

这就说明它们不是页面特供,而是真正的可复用 building blocks

在这里插入图片描述

到了这一步,已经不是“替代 List”那么简单了。
这是在搭自己的界面语言。


真相大白:弃用 List 非叛逆,懂了取舍是清醒

最后,林屿把话说得很准。

SwiftUI 里替换 List,并不是要放弃一个强大的组件。它真正要表达的是:

不是背叛 List,而是为场景选择正确的工具。

如果你面对的是大型、统一的数据集,List 依旧是极好的选择,毫无问题。

在这里插入图片描述

但当你的 UI 需要更细致的结构、更独特的样式、更符合产品自身 design language 的表达时,现代 SwiftUI 已经给了我们足够的自由。

借助 ScrollView、lazy stacks 和 Container View APIs,我们不只是可以重建 List 的能力,某些时候甚至能够超越它。

ScrollingSurfaceDividedCardSectionedSurface 这样的自定义 primitive,证明了一件事:

真正成熟的 SwiftUI 代码,不只是把视图摆出来,而是把可复用的规则提炼出来。

性能、清晰度、设计语言,三者并行不悖。
这才是正路。

在这里插入图片描述


🌒 尾声:他最终没有推翻 List,只是看透了它

天快亮的时候,林屿合上电脑,办公室的灯光仍旧冷,心里却亮了。

他没有把 List 当成敌人。
也没有为了“自定义”而自定义。

在这里插入图片描述

他只是终于明白:
组件从来不是信仰,它只是工具。

该用 List 的时候,别拧巴;
该用 ScrollView 和自定义容器的时候,也别手软。

很多人写 UI,写到最后,写成了对系统组件的依赖。
可真正厉害的人,写到最后,会慢慢长出自己的容器、自己的规则、自己的语言。

在这里插入图片描述

那一刻,所谓 List replacement,其实已经不重要了。
重要的是,他终于从“会用组件”,走到了“会造秩序”。

而这,才是这篇文章最狠的一刀。

网络层架构演进:从回调地狱到声明式数据流

引言:网络请求的"阿喀琉斯之踵"

在移动应用开发中,网络层如同人体的循环系统,负责所有数据的吞吐与交换。一个常见的起点是直接使用URLSession或Alamofire发起请求,并在闭包回调中处理响应。然而,随着业务复杂度攀升,这种模式迅速演变为"回调地狱"——深层嵌套的回调、分散各处的错误处理、难以维护的重复代码。更严峻的是,它催生了视图控制器与网络逻辑的紧密耦合,使得单元测试举步维艰,状态管理混乱不堪。本文旨在剖析网络层设计的核心痛点,并探索一条通往清晰、健壮、可测试的声明式数据流架构之路。

一、痛点浮现:传统回调模式的困局

让我们从一个典型的用户列表请求开始,它需要处理加载状态、分页、错误展示和最终的数据渲染。传统实现方式将网络请求、状态管理、错误处理和UI更新全部混杂在视图控制器中:

// 传统方式:嵌套回调与分散的状态管理
class UserListViewController: UIViewController {
    var users: [User] = []
    var currentPage = 1
    var isLoading = false

    func loadUsers() {
        guard !isLoading else { return }
        isLoading = true
        showLoadingIndicator()
        
        // 直接发起网络请求,处理回调
        let url = URL(string: "https://api.example.com/users?page=\(currentPage)")!
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            DispatchQueue.main.async {
                // 状态管理、错误处理、数据解析全部混在一起
                self?.isLoading = false
                self?.hideLoadingIndicator()
                
                if let error = error {
                    self?.showErrorAlert(message: error.localizedDescription)
                    return
                }
                
                // 更多嵌套处理...
            }
        }.resume()
    }
}

这种模式暴露了多个架构问题:状态管理脆弱、错误处理重复、业务逻辑耦合、可测试性差。当应用需要添加请求重试、缓存、日志等功能时,每个网络请求都需要重复修改,维护成本急剧上升。

更深层次的问题在于,这种紧耦合的设计违反了单一职责原则。视图控制器本应专注于UI呈现和用户交互,却被赋予了过多与网络相关的职责。这种架构上的缺陷会导致代码的"技术债"快速积累,随着功能增加,代码的可读性和可维护性急剧下降。

二、架构演进:构建分层清晰的基础网络层

解决上述问题的第一步是分离关注点。我们应构建一个独立的基础网络层,其核心职责是接收请求配置,发起网络调用,并返回标准化响应。下图展示了分层网络架构中各层的职责与数据流向:

image.png

通过引入Combine框架的Publisher,我们将异步回调转换为声明式的数据流。基础网络层现在只负责最纯粹的HTTP通信,为上层构建提供了稳定的基石:

// 基础网络服务协议
protocol NetworkServiceProtocol {
    func perform(_ request: NetworkRequest) -> AnyPublisher<NetworkResponse, NetworkError>
}

这种分层设计的核心优势在于每一层都有明确的职责边界。基础网络层专注于HTTP协议的实现,中间件层处理横切关注点,API客户端层负责业务逻辑与网络协议的转换,业务服务层则封装具体的业务领域逻辑。这种清晰的边界使得每一层都可以独立演化、独立测试,大大提升了系统的可维护性。

三、核心进阶:中间件机制与统一错误处理

一个健壮的网络层需要处理横切关注点,例如自动添加认证令牌、统一日志记录、响应缓存、网络状态监测等。中间件模式是解决此问题的优雅方案。

中间件是一个在请求发出前和收到响应后能够介入处理的管道组件。下图展示了中间件在请求/响应流程中的位置和作用:

image.png 通过串联多个中间件,我们可以形成灵活的处理管道。例如,认证中间件自动为需要认证的请求添加Token,错误处理中间件检查401状态码并触发Token刷新流程。这种设计使得横切逻辑模块化且可插拔,极大提升了代码的可维护性和可测试性。

统一错误处理是另一个关键。我们应定义业务相关的错误类型,并在网络层与业务层之间建立清晰的错误转换层:

enum APIError: Error, LocalizedError {
    case networkUnreachable
    case requestTimeout
    case serverError(message: String)
    case clientError(code: Int, message: String)
    case unauthorized
    // ... 其他错误类型
    
    var errorDescription: String? {
        // 提供用户友好的错误信息
        switch self {
        case .networkUnreachable: return "网络似乎断开了,请检查连接"
        case .requestTimeout: return "请求超时,请稍后重试"
        case .serverError(let message): return "服务器开小差了: \(message)"
        case .clientError(_, let message): return message
        case .unauthorized: return "登录已过期,请重新登录"
        default: return "发生未知错误"
        }
    }
}

这种统一的错误处理机制确保了整个应用对错误有一致的处理方式,无论是网络层错误、业务逻辑错误还是数据解析错误,都能通过统一的接口暴露给上层,使得错误处理逻辑可以集中管理,而不是分散在各个视图控制器中。

四、与业务层融合:声明式数据流的最佳实践

最终,网络层需要优雅地服务于业务层和表现层。在MVVM或类似架构中,ViewModel应通过声明式数据流驱动UI。这种模式带来了根本性转变:UI成为状态的被动反映。

class UserListViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let userService: UserServiceProtocol
    
    func loadUsers() {
        isLoading = true
        errorMessage = nil
        
        userService.fetchUsers(page: 1)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    self?.errorMessage = error.localizedDescription
                }
            }, receiveValue: { [weak self] newUsers in
                self?.users = newUsers
            })
            .store(in: &cancellables)
    }
}

在视图控制器中,我们只需观察ViewModel的状态变化:

private func bindViewModel() {
    viewModel.$users
        .receive(on: DispatchQueue.main)
        .sink { [weak self] _ in
            self?.tableView.reloadData()
        }
        .store(in: &cancellables)
    
    viewModel.$isLoading
        .receive(on: DispatchQueue.main)
        .sink { [weak self] isLoading in
            isLoading ? self?.showLoading() : self?.hideLoading()
        }
        .store(in: &cancellables)
}

这种声明式绑定彻底解耦了网络逻辑与视图控制器,使代码更易于测试和维护。网络请求的状态(加载中、成功、失败)通过ViewModel@Published属性单向流动到UI,实现了清晰的数据流管理。

下图展示了声明式数据流在MVVM架构中的完整工作流程,从用户交互到网络请求,再到UI更新的完整闭环:

image.png 这种架构的最大优势在于其可预测性。由于数据流是单向的,我们可以清晰地追踪状态变化的来源和去向。当出现问题时,调试也变得相对简单——我们只需要关注状态是如何变化的,而不是在复杂的回调嵌套中寻找问题。

五、总结:构建面向未来的数据通道

网络层的演进,是从"如何发起请求"到"如何管理数据流"的思维跃迁。通过分层设计,我们分离了HTTP通信、横切逻辑和业务转换;通过中间件模式,我们实现了关注点分离与功能可插拔;通过声明式数据流,我们创建了可预测、可测试的状态驱动UI。

这种架构演进不仅仅是技术实现的变化,更是开发思维的转变。它要求我们从"命令式"的思维方式转向"声明式"的思维方式,从关注"如何做"转向关注"是什么"。这种转变带来的好处是深远的:代码更加清晰、测试更加容易、维护成本大幅降低。

一个优秀的网络层不仅是技术的实现,更是架构思想的体现。它像一条精心设计的高速公路,确保数据安全、高效、可靠地抵达目的地,同时为未来的扩展——如离线缓存、实时同步、性能监控——预留了接口。当网络层稳固如磐石,开发者便能更专注于创造业务价值,而非深陷于回调的泥潭。

iOS 26 libass字幕渲染问题兼容解决实践

背景

在 iOS 26 上,视频播放器使用的 libass 字幕渲染器遭遇了严重的兼容性问题。当字幕指定的字体在系统中找不到时,libass 的 CoreText 后端会尝试 fallback 到系统字体路径:

/System/Library/PrivateFrameworks/FontServices.framework/CorePrivate/PingFangUI.ttc

然而,这个路径在 iOS 26 的沙盒机制下被系统拦截,导致 fallback 失败,内嵌 ASS/SSA 字幕中的中文字符完全无法渲染。用户看到的只是一片空白或乱码。


问题分析

1. 问题表现

字幕类型 问题描述
内嵌 ASS/SSA 中文字符完全不显示,字体 fallback 失败
外挂 ASS/SSA 某些字体无法渲染,fallback 到被拦截的路径
SRT(内嵌提取后) 带有 <font face="xxx"> 标签的 SRT,freetype 尝试加载指定字体失败

2. 根本原因

iOS 26 引入了一个沙盒安全限制,阻止了 libass 对系统字体的访问。libass 的字体回退机制无法获取 PingFang 字体,导致整个字幕渲染失败。


解决方案

方案概述

视频播放
    ↓
禁用内嵌字幕(避免 libass 走系统字体路径)
    ↓
提取内嵌字幕流为 SRT(通过 FFmpeg)
    ↓
SRT 无字体定义,通过 freetype 渲染器 + 指定中文字体显示
    ↓
同时对外挂 ASS 字幕做字体名替换(指向 CoreText 已注册的字体)

核心实现

1. 内嵌字幕提取

通过 FFmpeg 提取视频中的字幕流,转换为无字体定义的 SRT 格式:

// 使用 FFmpeg 提取字幕流
FFmpegWrapperAPI *ffAPI = [[FFmpegWrapperAPI alloc] init];
ffAPI.inputPath = videoPath;

// -map 0:s:N 选择特定字幕流
NSString *command = [NSString stringWithFormat:@"-map 0:s:%d", trackIndex];

[ffAPI runFFmpegAPI:videoPath
         outputPath:srtOutputPath
             prefix:nil
            command:command
              async:YES];

2. 字体名替换(正则方案)

ASS 字幕中的字体名出现在两处:

  • [V4+ Styles] 定义行:Style: Name,Fontname,Fontsize,...
  • [Events] Dialogue 行内覆盖标签:{\fn字体名}
// 替换 Dialogue 行内的 {\fn任意字体名} 覆盖标签
NSRegularExpression *fnTagRegex = [NSRegularExpression
    regularExpressionWithPattern:@"\\{\\\\fn[^}\\\\]+"
    options:NSRegularExpressionCaseInsensitive
    error:&regexError];
modifiedText = [fnTagRegex stringByReplacingMatchesInString:modifiedText
                                                     options:0
                                                       range:NSMakeRange(0, modifiedText.length)
                                                withTemplate:[NSString stringWithFormat:@"{\\fn%@", kTargetFontName]];

// 替换 Style 定义行的 Fontname 字段
NSRegularExpression *styleLineRegex = [NSRegularExpression
    regularExpressionWithPattern:@"^(Style\\s*:\\s*[^,]+,)([^,]+)(,.*)$"
    options:0
    error:&regexError];
// ...

SRT 字幕中的字体名出现在 HTML 标签中:

// 替换 <font face="任意内容">
NSRegularExpression *fontFaceRegex = [NSRegularExpression
    regularExpressionWithPattern:@"<font\\s+face\\s*=\\s*([\"'])[^\"']*\\1"
    options:NSRegularExpressionCaseInsensitive
    error:&regexError];

🔴 踩坑实录

坑一:VLC 索引与 FFmpeg 索引的映射错误

问题描述:用户选择中文内嵌字幕,但实际显示的是英文字幕。

根因分析

  • VLC 的 videoSubTitlesIndexes 数组索引 0 是 "Disable"
  • 内嵌字幕从索引 1 开始:索引 1 → 第一条字幕,索引 2 → 第二条字幕
  • FFmpeg 的字幕流索引从 0 开始:第一条字幕流是 0,第二条是 1

错误的映射:

用户选择 VLC 索引 1(第一条字幕)→ 错误地映射为 FFmpeg 索引 1 → 提取了第二条字幕

代码修复

// 修复前(错误)
int ffmpegTrackIndex = trackIndex + 1;

// 修复后(正确)
int ffmpegTrackIndex = (int)subtitleIndex - 1;

// VLC 索引 1 → FFmpeg 索引 0
// VLC 索引 2 → FFmpeg 索引 1

日志验证

[updateSubtitleUrl] iOS 26 拦截内嵌字幕: VLC subtitleIndex=1,
→ FFmpeg trackIndex=0  // 修复后正确映射到第一条字幕流

坑二:SRT 字幕的 <font> 标签问题

问题描述:内嵌字幕提取为 SRT 后,部分 SRT 仍无法显示中文。

日志分析

[ExtractSub] SRT 前 200 字:
1
00:00:00,000 --> 00:00:03,018
<font face="方正准圆简体" size="21"><b>...

根因分析:虽然 FFmpeg 提取时没有字体定义,但某些视频的字幕流本身已包含 <font face="xxx"> 标签。这些标签导致 VLC 的 freetype 渲染器尝试加载指定字体,同样失败并 fallback 到被拦截的路径。

修复:在加载 SRT 前,批量替换所有 <font face="xxx"> 标签:

NSString *srtText = [NSString stringWithContentsOfFile:srtUrl.path encoding:NSUTF8StringEncoding error:nil];
NSString *replaced = [self replaceSrtFontNamesInText:srtText];
[replaced writeToFile:srtUrl.path atomically:YES encoding:NSUTF8StringEncoding error:nil];

坑三:字符串匹配无法覆盖所有字体

问题描述:硬编码的字体名列表无法覆盖所有可能出现的字体名。

原方案

NSArray *fontNamesToReplace = @[
    @"微软雅黑", @"微软雅黑", @"SimHei", @"SimSun",
    @"黑体", @"宋体", @"楷体", // ...
];

问题:总有漏网之鱼,如 方正准圆简体Noto Sans CJK SC 等。

改进方案:正则 + 字符串匹配兜底

正则覆盖任意字体名,字符串匹配处理边缘情况:

// 正则:替换所有 {\fn任意字体名} → {\fnSource Han Sans CN}
// 兜底:字符串匹配常见字体名
modifiedText = [self fallbackReplaceFontNamesInText:modifiedText];

完整架构图

┌─────────────────────────────────────────────────────────────────┐
                    iOS 26 字幕兼容架构                            
├─────────────────────────────────────────────────────────────────┤
                                                                 
  ┌─────────────┐     ┌─────────────┐     ┌─────────────┐        
   handleiOS26       updateSubtitleUrl  convertSubtitle    
   SubtitleOn          (内嵌拦截)          (外挂处理)         
     Playing                                               
  └──────┬──────┘     └──────┬────────┘  └──────┬────────┘       
                                                              
                                                              
  ┌──────────────────────────────────────────────────────────┐   
                @available(iOS 26.0, *) 守卫                    
  └──────────────────────────────────────────────────────────┘   
                                                              
                                                              
  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐         
   禁用内嵌字幕       FFmpeg 提取       字体名替换             
   (libass)         SRT              (正则+兜底)            
  └──────────────┘   └──────┬───────┘   └──────────────┘         
                                                                
                                                                
                    ┌──────────────┐                             
                     freetype 渲染                              
                     + 指定中文字体                              
                    └──────────────┘                             
                                                                 
└─────────────────────────────────────────────────────────────────┘

参考资料

用 AI 降低 iOS 客户端 UI 自动化测试难度

为什么 iOS UI 自动化仍然难

在真实业务里,UI 自动化往往卡在几类问题上:

  • 门槛高:需要熟悉 XCTest、页面抽象、CI 集成,非客户端同学很难独立推进。
  • 维护贵:界面一改,选择器、坐标、等待逻辑跟着失效,修复成本像还技术债。
  • 反馈慢:过度依赖截图或视觉比对时,脚本和排障都变慢,协作也不顺畅。

近期探索方向是:用系统无障碍(Accessibility)能力看见界面,用 命令行工具 驱动模拟器;把写脚本交给 AI,把测什么、对不对交给人(其实交给AI应该也可以)。

这样可以把自动化从少数工程师专属(如测试开发岗位)拉回到测试与交付都能参与的节奏里。

核心思路:无障碍树 + AXe

iOS 为视障用户暴露的无障碍信息,会在系统侧形成一棵无障碍树:控件文案、(若开发配置)唯一标识、大致几何信息都可以被读取。类比前端:界面是渲染结果,无障碍树更接近可被程序消费的语义结构。

在命令行驱动模拟器这一层,目前AXe 在易用性、能力完整度和可脚本化程度上综合表现最好,因此方案明确为 无障碍树 + AXe

  • 用 AXe 读取无障碍树、点击、输入、手势、截图等,再配合 Shell 或步骤文件做编排。
  • 脚本层负责稳定可重复的执行;
  • 人 + AI 负责把业务语言翻译成脚本,并在失效时快速迭代。

AI 具体降低了哪些难度

除下文分条说明外,这里想单独强调一点:编写与排障时,describe-ui 拉起的无障碍树仍是最快、成本最低的定位与断言手段;但在结构复杂的原生页面,或 WebView / H5 等无障碍信息不完整、控件不可见的场景里,完全可以把 AXe 截屏交给 AI 分析——既可用于结果校验(布局是否异常、关键视觉元素是否出现),也可在 AI 协助下从画面反推点击坐标 / 热区,再固化为 touch、像素级辅助脚本等步骤。相比过去「只能死磕无障碍树或完全依赖人工看图写坐标」的传统做法,可选路径更多:树优先、截图与 AI 作补充;人工判断与模型辅助看图可以组合使用,而不必二选一。

从写脚本到描述流程:协作时序

传统模式下,测试同学往往要先补编程与框架知识;AI 辅助时,自然语言 + 页面结构文本即可闭环迭代:

sequenceDiagram
    participant QA as 测试/业务
    participant AI as AI 助手
    participant SIM as 模拟器 + CLI
    QA->>AI: 用自然语言描述端到端流程与验收点
    AI->>SIM: 按需读取无障碍树(describe-ui 或等价能力)
    SIM-->>AI: 返回页面结构文本
    AI-->>QA: 交付可执行脚本(.steps / Shell 等)
    QA->>SIM: 本地执行脚本
    SIM-->>QA: 某步失败或状态不符
    QA->>AI: 反馈失败步骤 + 当前页面结构文本
    AI->>SIM: 必要时再次拉取树或调整定位策略
    AI-->>QA: 修改后的脚本
    Note over QA,SIM: 人负责测什么、怎样算对;AI负责怎么点、怎么判、怎么改

降低的难度:不必从零掌握语法与定位细节,把翻译为可执行步骤外包给模型。

排障成本:默认走文本通道而非截图通道

同一类问题(例如点不到、断言失败),用文本无障碍树通常比反复传图更省、更稳:

flowchart LR
    subgraph fail["脚本失败 / 状态异常"]
        A["失败步骤 + 上下文"]
    end

    subgraph pathText["推荐:文本路径"]
        T1["拉取无障碍树输出"]
        T2["grep / 条件分支 / 贴给 AI 分析"]
        T3["改 label / id / 等待 / 分支逻辑"]
    end

    subgraph pathImg["必要时:视觉路径"]
        I1["截图"]
        I2["人工或 AI 看布局 / H5 等"]
        I3["改坐标或视觉辅助逻辑"]
    end

    A --> T1
    T1 --> T2
    T2 --> T3
    A -.->|"仅当树不够用"| I1
    I1 --> I2
    I2 --> I3

降低的难度:排障从猜界面加大量截图对话变成结构化文本 diff,更适合日常高频使用。

成本结构:AI 管「写脚本、修脚本」,不管「跑脚本」

把 token 与人力集中在编写与改版修复,执行阶段不依赖模型:

flowchart TB
    subgraph once["一次性 / 低频"]
        W1["新流程:描述需求"]
        W2["AI 生成首版脚本"]
        W3["人确认可重复跑通"]
    end

    subgraph daily["高频:回归执行"]
        R1["CI 或本地直接跑脚本"]
        R2["零模型调用"]
    end

    subgraph rare["偶发:UI 改版"]
        U1["脚本失效"]
        U2["贴新无障碍树 + 失败信息"]
        U3["AI 小步修补"]
    end

    W1 --> W2
    W2 --> W3
    W3 --> R1
    R1 --> R2
    R1 --> U1
    U1 --> U2
    U2 --> U3
    U3 --> R1

降低的难度:把自动化从持续烧对话/烧图变成可沉淀的脚本资产,更容易在团队里推广。

经验法则:默认仍以 describe-ui 无障碍树为主;遇到复杂原生页、Web 页树信息不足时,再用 AXe 截图 + AI 做结果校验或反推坐标,与「只靠树或只靠人眼」相比,路径更灵活。

工程落地:三种编写方式怎么选

按复杂度递进,避免一上来就做大而全框架:

  1. 交互模式:在终端逐条执行看树、点击、再验,适合探索页面与验证定位。
  2. 批量步骤文件(如 .steps):适合线性、无分支的流程,结构简单、可读性强。
  3. Shell 脚本:需要条件判断、重试、关闭弹窗、拼装环境变量时再用;可与公共函数库复用高频动作。选型建议:能线性顺序完成的用步骤文件;一旦出现如果出现某文案则、最多重试 N 次就上升到 Shell。不确定时,把业务口述给 AI,让它帮你选载体即可。

工程内案例:跨页面资源链路冒烟

该小节展示目前已经在工程中应用的案例。

辅助 QA 验证某类资源是否生效——从打开 App,进入资源相关页面并选用资源,再进入另一处资源应用页面触发使用,最终以 截图呈现结果,形成可重复结论(中途可配合 describe-ui 做关键状态断言)。

flowchart TD
    A[启动并进入 App] --> B[进入资源入口页]
    B --> C[选用目标资源]
    C --> D[进入资源应用页]
    D --> E[触发资源使用]
    E --> F[无障碍树断言关键状态]
    F --> G[截图固化结果]

落地要点:关键路径优先 accessibilityIdentifier 或稳定 label;WebView 区域用 touch 或坐标兜底;异步生效处加重试或等待;截图偏重最终留档与对非研发可读的佐证,日常仍以无障碍树文本断言为主。

不足之处

  • 仅支持模拟器(AXe) :当前 AXe 面向模拟器;若要在真机上跑同类 UI 自动化,通常需转向 XCUITest,或评估各厂商付费真机云 / 设备农场等方案,并在证书、并发、脚本形态与成本之间做权衡。
  • WebView / H5:内部细粒度控件往往不出现在无障碍树里,常见做法是坐标触摸或截图后做像素/区域启发式分析,这类脚本更依赖评审与设备基准。
  • 多语言包:按文案定位会在语言切换后失效;更稳的是推动客户端为关键控件补齐 accessibilityIdentifier
  • 坐标定位:不同机型逻辑分辨率不同,应作为最后手段,或结合比例计算。
  • 音视频与强动画:更适合接口层、状态层或人工探索性测试,不宜对 UI 脚本抱有过高期望。

小结

  • 无障碍树 + AXe把看见界面变成可脚本化、可 diff 的文本问题。
  • AI 把脚本编写与失效修复从专业技能降维成自然语言协作。
  • 文本优先、控制模型介入频率把成本压到可持续的水平。若你也在做 iOS 交付质量与回归效率,可先让模拟器上的端到端跑通,再逐步资产化用例,而不是先搭一座无人维护的测试金字塔。

Swift 核心协议揭秘:从 Sequence 到 Collection,你离标准库设计者只差这一步

swift是面向协议编程,果然名不虚传

swift中的Iterator初步认识

IteratorProtocol 协议

public protocol IteratorProtocol<Element> {
    associatedtype Element
    mutating func next() -> Element?
}

这样所有遵守了IteratorProtocol协议的类型,都是可以使用next方法的,这已经很完美了。但是!迭代器只能消费一次, 这里举一个不恰当的例子:

let numbers = [102030]
// 从序列要一个迭代器(IteratorProtocol)
var it = numbers.makeIterator()
// 一步一步消费
print(it.next() as Any)  // Optional(10)
print(it.next() as Any)  // Optional(20)
print(it.next() as Any)  // Optional(30)
print(it.next() as Any)  // nil —— 已经到头了
// 同一个 it 再 next,永远是 nil(状态已经走到结束)
print(it.next() as Any)  // nil
print(it.next() as Any)  // nil

想再从头遍历一遍,不能指望复活这个it,只能再向序列要一个新的迭代器:

var it2 = numbers.makeIterator()
print(it2.next() as Any)  // Optional(10) —— 又从第一个开始

但是这里的makeIterator是sequence协议要求提供的东西,之所以说这个例子不恰当,是因为我似乎在用已经解决的问题去回答问题,这里不应该把sequence牵涉进来。

那么,接下来的例子将非常合适。

struct CountFromToIteratorProtocol {
    var current: Int
    let end: Int
    init(fromIntthroughInt) {
        current = from; end = through
    }
    mutating func next() -> Int? {
        guard current <= end else { return nil }
        defer { current += 1 }
        return current
    }
}
var it = CountFromTo(from: 3, through: 5)
while let x = it.next() { print(x) }   // 耗尽
print(it.next()) //nil,因为之前已经耗尽了
// 不能复活it,只能再来一个新的迭代器实例
var it2 = CountFromTo(from: 3, through: 5)
print(it2.next() as Any)   // 又从 3 开始
var it = CountFromTo(from: 3, through: 5)

现在假设我们是swift标准库团队开发人员,要实现Array,我们需要提供给开发者类似以下这些功能

  • for x in arr
  • arr.map { }、arr.filter { }的功能
  • 和别的“能挨个读一遍某个东西”的方法用同一套API

下标 arr[i]可以实现“挨个读一遍”的功能,但是正如我们提到的for x in arr / arr.map这种功能,它们只想对每个元素做某事,不需要关心下标。

for x in arr {
    print(x)
}
//可以通过这种方式实现
var __iterator = arr.获取iterator()
while let x = __iterator.next() {
    print(x)
}

map大致如下

func mapSimple<T>(_ transform: (Element) -> T) -> [T] {
        var result: [T= []
        var it = 获取iterator()
        while let x = it.next() {
            result.append(transform(x))
        }
        return result
    }

可以看出不论实现哪个功能都需要array有一个获取iterator的方法,给这个方法起名叫做makeIterator,也就是说array既要有next方法,又要有makeiterator的方法,我们把这两个方法都放入一个起名为sequence的protocol中,这就是sequence的由来了。Sequence 是 Swift 中最轻量的遍历协议。一个类型只要遵守 Sequence,就能用 for-in 遍历。实现了 Sequence的结构体或类 必须关联一个遵守 IteratorProtocol 的类型,

  • Sequence工厂:生产迭代器

  • IteratorProtocol产品:实际遍历逻辑

所以不能说实现了Sequence就是是实现了IteratorProtocol.

仅仅实现Sequence协议,你的类型就能享受所有Sequence的默认extension方法:mapfilterreducecontains(Element: Equatable)reversed。

//Sequence 协议:
  protocol Sequence<Element> {
      associatedtype Element where Self.Element == Self.Iterator.Element
      associatedtype Iterator: IteratorProtocol
      func makeIterator() -> Iterator
  }

Sequence 够用了吗?

Sequence 只保证:能 makeIterator(),按顺序 next() 一个个拿。

适合:for-inmapfilter 等扫一遍的事。

但日常还会遇到:

  • 第 3 个元素是谁?(随机访问某一位)

  • 有多少个?(count)

  • 第一个、最后一个下标怎么表示?

只靠 Iterator:只能往后走,不能跳到中间,也不一定有常数时间的长度概念(有些序列是无限的、或算长度很贵)。

所以要在 Sequence 上再叠一层:能按下标(或索引)访问、有明确首尾——这就是 Collection 的由来。

Collection 在解决什么?

在能遍历之上,再约定像容器一样用下标访问的能力。典型能力包括(概念上):

  • startIndex / endIndex

  • 能用 collection[index] 读元素(subscript

  • 索引可以 index(after:) 往后走(不一定只是 Int + 1,字符串的 Index 就复杂)

  • 往往还能提供 count(有的集合是 O(n) 算出来)

public protocol CollectionSequence {
    associatedtype IndexComparable
    var startIndex: Index { get }
    var endIndex: Index { get }
    subscript(positionIndex) -> Element { get }
    func index(after iIndex) -> Index
}

`Collection` 协议**继承自** `Sequence` 协议,因此任何遵守 `Collection` 的类型**自动满足** `Sequence` 的所有要求。

Array 是最典型的 Collection:下标是 Int,从 0count-1

Sequence  ←── 更基础:只保证能遍历
   ↑
Collection ←── 继承 Sequence,并加:索引 + 下标访问 + …

image.png

在 Swift 里遵守 Collection 只能说明它是可按索引访问的一段序列,不一定是自己拥有一块独立存储的容器。例如Range,遵守RandomAccessCollection属于 Collection 一族

let r = 0..<10
print(r.count)           // 10
print(r[r.startIndex]) // 0

这里并没有一个数组在内存里存 0,1,2,...,9Range 只是用起点、终点描述区间,按需算出元素。它更像区间视图,不是传统意义上的数组那种容器。

本文使用 文章同步助手 同步

使用Edge和ADB进行Android Webview远程调试的完整教程

前言

朋友小X在一家小公司从事安卓开发工作。

有一天老板想做一个功能。用户能通过前端网页,调用起原生安卓应用支持的功能,如人脸识别等。

前端开发主要使用Javascript进行开发,安卓应用使用Kotlin进行开发。

Javascript是动态语言,Kotlin是静态语言。

动态语言的优缺点

  • 不用考虑声明变量类型,代码编写可以比较随意。
  • 没有语法检查工具时,只有运行后才知道,代码有无语法问题。

静态语言的优缺点

  • 要考虑声明变量类型。
  • 在使用IDE时,可提前发现语法错误。

而小X的公司,没有为前端开发人员提供语法检查工具,只能靠前端开发自查自改。

负责前端网页的妹子是一位新手,写代码经常出现各种语法问题,且不知道如何在Android Webview上进行有效调试。

为了调试,前端妹子只会在有可能出问题的代码,添加alert函数,通过提示框的信息,来进行调试。

使用这种方式进行开发,开发效率非常低下,进度远远落后于计划。

但是老板催着功能赶紧上线,为了尽快上线,小X只能陪着加班,帮助前端妹子排查问题。

陪着妹子加班两天后,小X忍受不了天天要陪加班的状态。

他通过搜索,找到了工具和方法,可让前端在Android Webview高效调试代码。并把方法教给了前端妹子。

前端妹子知道如何用调试工具后,自己可以进行调试,大大提高开发效率,不用小X天天陪着加班了。

工具介绍

刚刚提到的工具,就是Edge + ADB。

ADB是什么?

ADB 的全称是Android Debug Bridge, 是一种功能多样的命令行工具。

ADB 命令可用于执行各种设备操作(例如安装和调试应用),并提供对 Unix shell(可用来在设备上运行各种命令)的访问权限。

现在主流浏览器,包括微软的Edge,还有Android应用自带的Webview,使用的是谷歌出品的浏览器内核。

几年前,遇到类似的问题,只能通过用Chrome浏览器+ADB进行调试。

现在Edge浏览器成了主流,也有和Chrome一样功能,而且不用像Chrome,需下载额外的浏览器插件。

因此,我们的工具选择Edge + ADB。

对于更复杂的调试场景,开发者可以考虑使用专业工具如WebDebugx,它是一款跨平台移动端网页调试工具,提供类似Chrome DevTools的完整调试体验,支持iOS和Android设备远程调试网页和WebView内容,包括网络监控、性能分析和JavaScript控制台集成等功能。

安装及使用

这里以大多数公司常见的Windows系统为例,介绍如何安装及使用。

安装前准备

  • 开发电脑
  • 安卓手机(已启用开发人员模式)
  • ADB工具安装包
  • 质量较好的数据线

使用前准备

  1. 安装Edge浏览器 比较新的电脑,只要预装了正版Windows,Edge是随机附带的)
  2. 安装ADB(具体步骤可看参考资料一)
  • 2.1 下载ADB安装包
  • 2.2 解压缩到目标磁盘路径
  • 2.3 设置全局变量
  • 2.4 在命令行工具,输入以下命令

ADB version

  • 2.4.1 能正常显示ADB版本号,则工具准备完毕
  • 2.4.2 不能正常显示,需要根据命令行工具的提示,和下面的【注意要点】,进行排查

使用

  1. 将手机和电脑,通过数据线连接起来。
  2. 电脑识别出手机后,将手机USB调试模式,设为打开状态
  3. 打开Edge浏览器
  4. 浏览器地址栏输入

edge://inspect

  1. 手机上打开要调试的应用,并进入到要调试的网页
  2. 点击要调试链接对应的“inspect”按钮

其他步骤与在电脑上调试网页步骤类似

注意要点

有些人在做前期准备时,会遇到各种问题,解决方法汇总如下

  • 硬件设置

    • Android设备应直接连到开发用电脑
    • Android设备和开发电脑都处于亮屏状态
    • 确保USB电缆能正常使用,在开发电脑看到Android设备上的文件
  • 软件设置

    • 开发电脑的系统是Windows,尝试为Android设备安装驱动程序
    • 某些Android设备需要特别设置
  • Android设备未显示“允许USB调试”对话框

    • 将Android 设备和开发电脑的显示设置,改为永不休眠状态
    • Android 的 USB 模式设置为 PTP
    • Android 设备上的“开发人员选项”屏幕中选择“撤销 USB 调试授权”,将其重置为全新状态。
❌