普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月29日首页

我一点也不想要 OpenAI 手机|AI 器物志

作者 马扶摇
2026年4月29日 10:30

智能手机统治了过去十几年的数字生态,它是注意力的黑洞,是我们最私密的随身之物。但手机从设计之初就是为「人盯着它」而生的——它的全部逻辑,都止于屏幕。

AI 的需求却恰恰相反:它需要持续感知物理世界——见你所见,听你所闻,随时在场,而非等你解锁屏幕才醒来。

当 AI 真正成为一种基础能力,它迟早要从屏幕里破壳而出,寻找属于它自己的形状。这将是一个漫长的探索和演化过程。

「AI 器物志」栏目由此而来,爱范儿想和你一起持续观察:AI 如何改变硬件设计,如何重塑人机交互,以及更重要的——AI 将以怎样的形态进入我们的日常生活?

这是「AI 器物志」的第 10 篇文章。

全世界体量最大的人工智能厂商之一 OpenAI,要脱离虚拟领域,开始造手机了。

根据天风国际证券著名分析师郭明錤发布的调研报告:OpenAI 正与联发科和高通接洽生产手机处理器的事宜,拟由立讯精密协助设计和生产,预计 2028 年正式量产。

这款手机最大的不同在于:OpenAI 意图将它打造成一个为 AI Agent 模式特化的产品,郭明錤是这样介绍的:

用戶的目的不是使用一堆 App,而是透過手機執行任務並滿足各種需求,這從根本上推翻現在對手機的認知。

基于已经掌握的信息,郭明錤还设计了一张这种 AI Agent 手机可能的用户界面:

图|X @mingchikuo

在郭明錤的设想图中我们可以看到,在 AI Agent 的操作逻辑中,原本熟悉的「桌面 – App」模式会被一种「Agent 任务流」所取代——

不同功能的 App 图标会变成不同功能的 Agent、软件入口会变成更加具体的任务信息(比如从微信图标变成「发送一条朋友圈」按钮)、网格化排列的桌面 UI 也会直接变成任务信息的瀑布流。

就像 Niagara Launcher 一样|Android Authority

实话说,OpenAI 要造 AI 手机这件事本身并不稀奇。

它设想的很多场景我们其实在曾经的豆包手机上都体验得七七八八了,两者的本质都是希望给 AI 更高的自由度和自主权,以拓宽使用场景。

只不过相比「O 包手机」,反而是郭明錤提到的那个「纯粹基于 AI Agent 交互模式的手机 UI 」更加令我们感到不安。

在过去一段的 AI 产品中,无论是传统大模型,还是不同形状的 OpenClaw,我们都观察到了一种趋势:

现在 AI 的用户界面(UI)越来越向着以 Agent 为主导的方向发展,最激进的形态就是启动后只显示一个对话框。

图|Google

这种交互模式看上去人畜无害,但背后却暗含着一个汇聚了所有 AI 工具使用场景的「思维模式陷阱」——

AI 正在将人类异化成为机器。

正因如此,爱范儿在这里邀请你与我们共同进行一场小小的思想实验,来尝试理解 AI 将人异化的危险性。

Agent UI 最终会消灭 UI

OpenAI 手机所期望的那种纯粹的「Agent 使用模式」最表层的问题,是 Agent UI 对于「用户界面」中「用户」部分的忽视。

而这种忽视最明显的表征,就是上面所展示的那种任务瀑布流 UI,以及很多预制式 OpenClaw 客户端的裸露对话框。

更具体的说,任务瀑布流实际上是将交互界面从我们熟悉的「以应用为中心」(app-centric)转向了「以意图为中心」(intent-centric):

这些 AI 建议就是以意图为中心的

这就导致了一个问题:UI 全部由不停更新的任务信息构成,某个特定的界面没有一个固定的入口。

比如打开顺丰 app,映入眼帘的只有每秒不停更新的在途快递进度,却找不到「修改寄件地址」的按钮。

这在心理学上被称为「客体恒常性」的缺失,相当于「我知道这个开关是做什么用的」—— Agent UI 的界面只会让人感觉到在面对一堵随时变化的墙,没有一个可以熟悉的落脚点。

另一方面,纯粹目的性导向的 Agent UI 在实质上剥夺了用户的「主动探索」空间,将使用过程变成了一种纯粹的 AI 对于用户的预判。

最简单的例子就是:在淘宝 app 上买东西的时候,我们经常是边逛边选、偶尔刷到一些没见过的好货;而 Agent UI 直接帮你跳过了「淘」宝的过程,你只是那个控制支付宝付款的人肉 ATM 而已。

更退一步说,这相当于无论用户想要做什么,AI 手机始终在催促着用户进入「赶快完成任务」的生产模式——这种粗暴的接管,本质上是对于用户权利的消解。

归根结底,Agent UI 的逻辑,其实是将人给「零件化」。

它粗暴地假设用户是一个理性的、追求效率最大化的任务处理机,打开手机的唯一目的就是要解决任务列表里面的工作。

同时,它又忽略了人作为感性的、需要审美愉悦和情感缓冲的生命体的本质。

AI 最终会促成更多 AI

除了 Agent UI 本身对于「人」的基本需求的蔑视,我们同时也要理解:是什么样的环境促成了这种「唯效率论」的 UI 设计趋势的出现——

将人的一切使用行为,压缩成一条看不到尽头的任务列表,人与机器的唯一主动交互方式被简化进一个闪烁的对话框,背后的 AI 不可见、不可知、不可碰触。

这不是 AI 辅助生活,这是向克苏鲁献祭 token。

仅就上面提到的 Agent UI 来说,这种现象其实很像是一种从 GUI 到 CLI 的倒退:

我们花了几十年完善图形技术,又在一夜间回归到了最原始的用嘴发布命令。

这种由 Agent UI 所代表的「GUI 倒车」,深深根植于幕后的技术效率至上主义。

其中一个最简单的例子,就是游戏。

虽然如今本地计算仍然是主流,花钱买显卡就能享受到精美的画面,但我们完全可以想象这样的场景:

未来,云端计算成为主流之后,更有可能出现的情况是厂商根本不再出售图形算力,因为它们赚得没有文字/代码模型多。

甚至不只是手机和电脑的 GUI、以及游戏画面,我们消费的一切内容本身都会在这种对于效率的追逐中,退化成一种湮灭美学、纯粹的精神刺激工具。

图|网络

更讽刺的是,我们对于 AI 原本的愿景是「让 AI 处理琐事,让人类去作诗」。

但如今我们看到的却是 AI 工具泛化导致了效率主义极权的泛滥,人类对「美」的感知退化只是其中的附带伤害而已。

最让人无法接受的是,Agent UI、AI 手机、Agent 行为模式的设计背后,都存在着一种先入为主式的傲慢。

这些 AI 工具的生产者认为人类只关心「结果」,所以用生成式 AI、Agentic AI 将人从「过程」中去除,却忽略了人类的「存在感」本身就来源于我们对于行为过程的认知。

用更惨淡一点的说法就是:如果我不需要参与 AI 的一切决策,只是看个结果,那还需要我做什么?

最需要警惕人变成 AI

进行到这一步,很多人会有疑问:

这不就是个想象中的手机界面嘛,至于上升到存在主义哲学的高度吗?

这句话本身并没有问题,上面提到的所谓 Agent UI 的设想图,仅仅是郭明錤基于 OpenAI 手机的产品思路,给出的一种设想方案。

⚠ 图片使用 AI 技术生成|X @birdabo

但现代社会——尤其是 AI 加速过的现代社会——的危险性就在于:如果你自己不考虑存在主义哲学,就会有很多人乐于帮你考虑,然后再朝你收钱。

前面提到的 AI Agent 的确在非常多的层面上都可以帮助人们在工作中实现更高的效率,问题在于没有人一天 24 小时都在工作,但我们一天 24 小时都会带着手机。

这种纯粹基于待办任务流的 Agent UI、纯粹由执行效率驱动的 AI 产品,会借助「手机」这个不离身的媒介,从工作侵入到你的生活,让你的生活节奏、思维节奏去习惯 AI 的步调——

不太好理解?想想那些每天黑白颠倒抓紧时间抢 Token「谷电」时间的程序员们吧。

这还只是现阶段 AI 对人类作息的初步影响。长此以往下去,用户无论在工作时间还是非工作时间,都不再是一个具有主观意志的「人」——

当我们下班累瘫在沙发上,喊 AI 手机随便帮我们点个外卖当晚饭的时候,就相当于放弃了主动思考的权利,当这种放弃成为习惯,生活的每一个环节就都会成为一个别人的商机。

换句话说,AI 手机、Agent UI 现在看上去或许人畜无害,可一旦人类习惯跟随了 AI 的节奏和方式,就会暴露出巨大的危险性。

图|Futurama

德国哲学家马丁·海德格尔在《对技术的追问》中提到了一种观点,即现代技术有一种「促迫」(challenging-forth)的特性,指的是现代技术不仅制造机器,技术本身也拥有越来越强的主体性、会「强迫」自然界交出能量和资源。

同时,促迫也可以被看作一种人看待世界的方式,海德格尔认为:当人类用「促迫」的眼光看待自然时,人类最终也会把自己看作一种可以被技术索取的资源。

是不是很耳熟,没错,人力资源(Human Resources)就是这个意思。放到现在的 AI 环境里,则是简单的一句话:

去叫人力,给这个 Opus 4.7 账号配个员工。

糟糕的是,现在的 AI 技术,以及全世界的 AI FOMO(错失恐惧症),就是这种模式的完美复现。

Agent UI 那种瀑布流式待办任务界面不仅是帮你,同时也是在 PUA 你:

这种互相加速的现象(海德格尔称之为座架 Gestell),最终会导致人类调整自己的思维模式去适配机器的吞吐速率——

为了让 Agent 更好地工作,你会下意识地让自己的需求变得更明确、更单一、更具逻辑性,相当于人类主动将自己异化(alienation)成了机器。

这就是我们在现代技术中,最需要警惕的一点:人的机器化。

伴随着 AI 越来越侵入我们工作之外的生活,我们也在潜移默化地将自己降格为一种适配机器的存在。当人为了适配 AI 的高效而放弃了「临时起意」和「无目的漫游」时,你已经从逻辑上被机器同化了。

这种机器化的下一步,就是软性淘汰——

人不会像《黑客帝国》那样变成生物电池(暂时不会),只会被炼成一个 skill。

毕竟现实证明:生物机器在效率上永远无法媲美金属机器。

人在追求效率的同时,主动或被动地将自己异化成一种生物机器,最终的结局必然是被金属的或者硅基的机器淘汰。

人类的社会生产持续追求「0 摩擦」的完美效率时,本质上是在追求一个「去人化」的过程。

而无论 Agent UI、Agent AI 还是通用智能,都只是这个过程中的加速剂而已。

当我们在参与社会生产的过程中,主动切除了所有属于人的部分,好让自己在这个以 AI 为出发点、为工具、为最终目的的系统中跑得更顺滑。

直到切除剩下的那些部分,可以被另一段代码完美地替代。

图|Youtube @Moviefone

当然,在这个小小的思想实验之外,我们都知道郭明錤的预测只是一种预测,没人说 OpenAI 手机的交互方式真的就是一个纯粹的任务瀑布流。

但人作为感性动物,我们的思维模式就是在与客体世界的不断交互中形成的。

如果一个占据我们每天 24 个小时的工具正在走向纯粹机器性的交互,那我们距离被异化成机器后淘汰的结局,也的确不远了。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

昨天以前首页

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

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

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

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

2026年4月22日 19:11

在这里插入图片描述

凌晨三点,楼里只剩空调低鸣。林屿坐在工位前,盯着 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,其实已经不重要了。
重要的是,他终于从“会用组件”,走到了“会造秩序”。

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

从 OpenSwiftUI 到 DanceUI:换个方式 Dive SwiftUI -- 肘子的 Swift 周报 #132

作者 东坡肘子
2026年4月21日 08:05

issue132.webp

从 OpenSwiftUI 到 DanceUI:换个方式 Dive SwiftUI

从 2019 年问世算起,SwiftUI 已经快七年了。它早已脱去了最初几年的稚气,逐渐成为苹果生态开发者的基础能力之一。不过,SwiftUI 的闭源属性也意味着,它的很多运行机制始终不透明。开发者在使用时固然能感受到它的表达优势,但一旦遇到问题,往往很难进一步追踪原因。这种特性也让 SwiftUI 在 AI 辅助编程时代显得有些“吃亏”——相比那些长期暴露在社区讨论、源码和文档中的技术,大模型能参考的高质量材料终究有限。

也正因此,社区一直希望通过开源项目去复刻 SwiftUI:一方面,是希望让 SwiftUI 这套优秀的设计有机会运行在更多平台上;另一方面,也是希望借助复刻过程,对 SwiftUI 的内部机制获得更多理解。最近几年,这方面最受关注的项目无疑是 OpenSwiftUI。在社区持续推进下,它已经补齐了 SwiftUI 的一部分核心实现,并在苹果生态之外的平台上做出了一些实验性探索。虽然距离它的目标显然还有不短的路要走,但它依然是当下开发者理解 SwiftUI 内部机制的重要入口之一。

其实,除了社区之外,一些公司,甚至规模很大的公司,也在过去几年里做过对 SwiftUI 的深入研究和复刻。上周,字节跳动开源了他们的 SwiftUI 复刻项目 DanceUI

我第一次听说这个项目是在 2022 年。当时最让我感到意外的,不是“有人在复刻 SwiftUI”,而是“为什么是字节跳动在做这件事”。后来陆续和参与这个项目的开发者交流后,我大致理解了他们的动机:一方面,他们希望在将声明式开发引入庞大产品体系时获得更强的控制力;另一方面,也希望借由对 SwiftUI 这类优秀框架的研究,把运行时、依赖图和宿主整合等关键能力握在自己手里。和 OpenSwiftUI 相比,DanceUI 更不像一个社区式复刻项目,而更像一套从工程落地出发、反向拆解 SwiftUI 的样本。

更重要的是,过去几年中,DanceUI 已经在字节内部的一些产品模块中进入了生产环境。这意味着它显然不只是一个实验性的玩具,而是一套在性能和稳定性上都经受过一定检验的开发工具。对于 SwiftUI 开发者来说,它也因此提供了另一个理解 SwiftUI 的入口。

当然,这类项目并不适合被简单神化。它们不是 SwiftUI 本身,也不代表苹果官方实现。尤其像 OpenSwiftUI 这样带有强烈研究和兼容性导向的项目,本身就有明确边界;而像 DanceUI 这样的项目,则带着明显的大厂内部工程背景和落地取向。它们都不应该被当成“SwiftUI 真相”的唯一来源。

但这并不妨碍它们成为很好的学习材料。它们都不是 SwiftUI,却都能帮助我们更接近 SwiftUI。跟着开源项目去 dive SwiftUI,本质上不是在找一个“开源替代品”,而是在借这些项目训练自己理解 SwiftUI 的方式。

本期内容 | 前一期内容 | 全部周报列表

近期推荐

别让协议变成“怪物”:iOS 中的接口隔离实践 (Interface Segregation Principle In IOS: How To Prevent A Protocol From Becoming A Prison)

很多开发者可能都经历过类似的过程:项目早期一个精心设计的小协议,随着团队协作与业务演进,逐渐膨胀为难以维护的“怪物”。Pawel Kozielecki 通过一个逐步失控的 UserService 案例,具体展示了胖协议如何在团队协作中引入测试负担、隐性耦合,以及难以推进的重构成本。作者不仅给出了基于小协议组合与渐进迁移的现实方案,也点出了问题的根源:真正危险的,往往不是一次明显的设计失误,而是一连串“这次先加进去也没关系”的合理决定。

在 AI 辅助编程日益普及的背景下,这一问题反而更容易被放大。大模型倾向于依据文件名、协议名进行语义推断,一个模糊或过于宽泛的命名,往往会自然地吸引更多“不那么相关”的职责被不断叠加进去。清晰、准确且克制的命名,正在从代码风格问题,逐渐演变为影响系统边界的重要因素。


为 Text 实现删除线动画 (Animating Strikethroughs in SwiftUI)

为 SwiftUI Text 的删除线或下划线实现动画效果?不少人第一反应可能是基于 overlay + Shape 的方案。不过,这种方式很难正确适配 Dynamic Type 以及多行文本场景。Ashli Rankin 展示了一条更“系统化”的路径:基于 iOS 17 引入的 TextRenderer,直接访问 Text.Layout 的内部结构(行、glyph 等),并通过一个 progress 值在所有行之间累计绘制,从而实现连续、可动画的删除线效果。同时通过实现 Animatable,让 SwiftUI 在状态变化时自动完成插值过渡。

一个更有意思的细节在于:TextField 并不会走 Text 的渲染流程,因此 TextRenderer 无法直接应用。作者通过叠加一个透明的 Text(负责绘制动画)与真实的 TextField,并结合自定义 Layout 强制两者使用一致的换行宽度,最终解决了多行错位问题。


在 SwiftUI 预览中验证可访问性 (Checking accessibility with SwiftUI Previews)

SwiftUI Previews 通常用于检查界面布局,但同样可以在开发阶段快速验证部分可访问性(Accessibility)表现。Rob Whitake 梳理了几种常用途径:例如通过 Xcode Canvas 直接切换深浅色、方向、Dynamic Type 等进行快速检查,或借助 Preview Traits 定义特定的预览环境。文章还提到了一些仅用于 Preview 的私有环境变量(如增强对比度、减少动画、颜色反转等),通过带下划线的 keyPath 可以强制开启这些状态。不过需要注意,这类 API 必须限制在 #if DEBUG 中使用,以避免私有符号进入最终构建,带来审核风险。


一个 UIKit 项目的 SwiftUI 迁移实录

Yusuke Hosonuma 回顾了自己参与一个 UIKit + RxSwift + Coordinator 项目,并在一年多时间里逐步完成大部分界面 SwiftUI 化的经历。文章聚焦于真实项目中的工程取舍:在小团队、低沟通、几乎无文档的条件下,如何通过持续交付、渐进替换与尽量简单的设计,让项目保持可演进性。作者对不少常见做法都给出了很有现实感的反思,例如谨慎对待 protocol 抽象、EnvironmentObject、过早共通化,以及“顺手清理一切旧架构”的冲动。这并非单纯的技术实现总结,而是一篇充满真实感的团队实践复盘。


如何停止一个运行中的 SwiftUI 动画 Cancelling SwiftUI Animations: What Actually Works (And Why)

在 SwiftUI 中,停止一个已经运行的 repeatForever 动画并不像想象中那么简单。无论是使用 .none,还是通过 Transaction 禁用动画,都只能影响新的动画,而无法中断已经存在于渲染系统中的动画。Codelaby 给出了一个可行方案:通过自定义 CustomAnimation,让 animate 返回 nil(表示立即完成),并通过 shouldMerge 接管当前动画,从而实现终止动画的效果。

SwiftUI 会基于状态变化与动画函数自动进行插值计算。所谓“停止”,本质上是用一个新的状态变化去接管当前动画,而不是中断之前的动画。

工具

Swift Institute: 一个人的 Swift 基础设施重写

偶然看到的一个让我震惊的项目。Coen ten Thije Boonkkamp 在过去 9 个月里提交了约 9800 次 git commit,独自构建了一个分为 primitives、standards、foundations 三层、累计近 300 个包的 Swift 生态。目标只有一个——落地他去年提出的 Modern Swift Library Architecture 思想:依赖只能向下、集成发生在核心类型之外、"test what you own, trust what you import"。

一个人、一个构想,通过 AI 来进行尝试、验证。无论最后是否成功,但这是我想看到的 AI 意义。


swift-ast-lint:用 Swift 写 Swift 代码检查规则

Ryu 开发的 swift-ast-lint 不是另一个 SwiftLint,而是一套基于 SwiftSyntax 的自定义 lint 基础设施。它更适合需要编写 AST 级规则的团队,用来补足正则匹配在结构化检查上的局限。

项目支持脚手架生成、参数化规则、路径过滤以及 --fix 自动修复,比较适合处理架构约束、代码组织、模块边界等 regex 很难可靠覆盖的问题。它不太适合只想开箱即用的用户,但对于已经有明确工程规范、又希望把这些规范工具化的 Swift 团队来说,是一个值得关注的项目。

在 AI 辅助开发越来越普遍之后,真正有价值的可能不只是生成能力本身,还包括如何把团队规范和结构约束工具化。

活动

Swift Craft 2026

Swift Craft 是一个由社区驱动的 iOS / Apple 平台开发者大会,将于 5 月 18–20 日在英国 Folkestone 举行。目前议程已经公布,涵盖 Swift、SwiftUI 以及应用架构等多个方向。

相比大型会议,Swift Craft 更偏向小规模与深度交流,也更强调开发者之间的社区氛围。一个有趣的细节是本次会议的场地:位于海边悬崖上的 Leas Cliff Hall,会场三面落地窗直面英吉利海峡,这种环境本身就足以让会议体验变得与众不同。

主办方为本周报读者提供了折扣码 FBM26(£50 off Indie 票) 。如果你有参与线下开发者活动的计划,可以通过 Swift Craft tickets page 了解详情。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

从 OpenSwiftUI 到 DanceUI:换个方式 Dive SwiftUI - 肘子的 Swift 周报 #132

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

从 2019 年问世算起,SwiftUI 已经快七年了。它早已脱去了最初几年的稚气,逐渐成为苹果生态开发者的基础能力之一。不过,SwiftUI 的闭源属性也意味着,它的很多运行机制始终不透明。开发者在使用时固然能感受到它的表达优势,但一旦遇到问题,往往很难进一步追踪原因。这种特性也让 SwiftUI 在 AI 辅助编程时代显得有些“吃亏”——相比那些长期暴露在社区讨论、源码和文档中的技术,大模型能参考的高质量材料终究有限。

【SwiftyJSON】拯救你的 as? [String: Any]——链式 JSON 访问的正确姿势

作者 探索者dx
2026年4月20日 09:22

【SwiftyJSON】拯救你的 as? [String: Any]——链式 JSON 访问的正确姿势

iOS三方库精读 · 第 15 期


一、一句话介绍

SwiftyJSON 是一个用于 iOS/macOS 的 JSON 解析辅助库,它通过链式下标访问和安全类型转换,让原本需要大量 as? 强转和 guard let 解包的 JSON 解析代码,变成像访问字典一样直观的单行操作。

属性 信息
⭐ GitHub Stars 22k+
最新稳定版 5.0.2
License MIT
支持平台 iOS 13+ / macOS 11+
语言 Swift(纯 Swift,无 OC 接口)

二、为什么选择它

原生痛点

原生 JSONSerialization 解析复杂 JSON 的体验:

// ❌ 原生方式:每层都要 as? + guard,代码量爆炸
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
      let user = json["user"] as? [String: Any],
      let profile = user["profile"] as? [String: Any],
      let bio = profile["bio"] as? String,
      let score = profile["score"] as? Double else {
    return
}

5 层嵌套,1 个字段。如果某层返回 null,整个 guard 失败,无法优雅降级。

SwiftyJSON 方式:

// ✅ SwiftyJSON:一行,安全,不崩溃
let bio   = json["user"]["profile"]["bio"].stringValue   // 不存在则 ""
let score = json["user"]["profile"]["score"].doubleValue // 不存在则 0.0

核心优势:

  1. 链式下标:无论嵌套多深,中间路径不存在也不崩溃
  2. 类型转换属性.stringValue / .intValue / .boolValue 自动转换 + 默认值
  3. Optional 版本.string / .int / .bool 返回 Optional,可 if let
  4. 数组/字典直接遍历.arrayValue / .dictionaryValue
  5. null 安全.isNull.exists() 区分"不存在"和"存在但为 null"

三、核心功能速览

基础层(新手必读)

环境集成
// SPM
// URL: https://github.com/SwiftyJSON/SwiftyJSON.git
// from: "5.0.2"
# CocoaPods
pod 'SwiftyJSON', '~> 5.0'
创建 JSON 对象
import SwiftyJSON

// 从 Data 创建(最常用)
let json = JSON(data)

// 从字典/数组创建
let json2 = JSON(["name": "Alice", "age": 25])

// 从字符串创建
let json3 = JSON(parseJSON: "{\"key\": \"value\"}")
值访问:xValue vs x(Optional)
// .stringValue → String(不存在时返回 "")
// .string      → String?(不存在时返回 nil)
let name1 = json["user"]["name"].stringValue  // "Alice" 或 ""
let name2 = json["user"]["name"].string       // "Alice" 或 nil

// 其他类型同理
json["count"].intValue      // Int,默认 0
json["count"].int           // Int?
json["score"].doubleValue   // Double,默认 0.0
json["score"].double        // Double?
json["active"].boolValue    // Bool,默认 false
json["active"].bool         // Bool?

进阶层(最佳实践)

数组遍历
// arrayValue: 返回 [JSON],安全(不存在返回 [])
for item in json["user"]["repos"].arrayValue {
    let name  = item["name"].stringValue
    let stars = item["stars"].intValue
    print("\(name): ⭐ \(stars)")
}

// 快速 map
let repoNames = json["user"]["repos"].arrayValue
    .map { $0["name"].stringValue }
    .filter { !$0.isEmpty }
字典遍历
// dictionaryValue: 返回 [String: JSON]
for (key, value) in json["user"]["metadata"].dictionaryValue {
    print("\(key): \(value)")
}
Null 处理
let field = json["user"]["lastLogin"]

// 区分"不存在"和"存在但为 null"
print(field.exists())  // false → 路径不存在
print(field.isNull)    // true  → 路径不存在或值为 null

// 带默认值的安全访问
let last = json["user"]["lastLogin"].string ?? "从未登录"
整数索引访问数组
let firstTag = json["user"]["tags"][0].stringValue    // "swift"
let lastRepo  = json["user"]["repos"][2]["name"].stringValue  // "TodoApp"
SwiftyJSON 转回 Data / 字典
// 转回 Data(用于 Codable 混用)
let rawData = try? json["user"]["repos"].rawData()

// 转回 [String: Any]
let rawDict = json.dictionaryObject  // [String: Any]?
let rawArr  = json.arrayObject       // [Any]?
与 Codable 混用(最佳实践)
// 用 SwiftyJSON 做"柔性"部分,Codable 做"结构化"部分
let json = JSON(data)

// 1. 取出子 JSON(SwiftyJSON 处理不确定的动态结构)
let extraInfo = json["response"]["extra"]  // 动态字段,结构不定

// 2. 将确定结构的部分转为 Codable
if let reposData = try? json["user"]["repos"].rawData() {
    let repos = try? JSONDecoder().decode([Repo].self, from: reposData)
}

深入层(源码视角)

JSON 的枚举本质

SwiftyJSON 的核心是一个 JSON 结构体,内部用枚举表示类型:

public struct JSON {
    // 内部存储联合类型
    fileprivate var rawArray: [Any] = []
    fileprivate var rawDictionary: [String: Any] = [:]
    fileprivate var rawString: String = ""
    fileprivate var rawNumber: NSNumber = 0
    fileprivate var rawNull: NSNull = NSNull()
    fileprivate var rawBool: Bool = false

    public internal(set) var type: Type = .null
}

subscript 访问时,如果类型不匹配或 key 不存在,返回一个 JSON.null 单例而非崩溃。这是链式访问安全性的核心保障。

性能注意

每次 subscript 访问都会创建新的 JSON 实例(值类型复制),深层链式访问在循环中可能造成性能开销。热路径代码建议:

// ❌ 在循环中重复深层访问
for _ in 0..<10000 {
    let _ = json["a"]["b"]["c"]["d"].stringValue
}

// ✅ 缓存中间节点
let profile = json["a"]["b"]  // 只创建一次
for _ in 0..<10000 {
    let _ = profile["c"]["d"].stringValue
}

四、实战演示

场景:解析 GitHub API 响应

// 解析 https://api.github.com/search/repositories?q=swift 的响应
func parseSearchResult(data: Data) -> [String] {
    let json = JSON(data)

    // 总数
    let total = json["total_count"].intValue
    print("找到 \(total) 个仓库")

    // 取前 5 个仓库名
    return json["items"].arrayValue.prefix(5).map { repo in
        let name  = repo["full_name"].stringValue
        let stars = repo["stargazers_count"].intValue
        let lang  = repo["language"].string ?? "Unknown"
        return "\(name)\(stars) [\(lang)]"
    }
}

五、源码亮点

进阶层:链式安全的实现

// SwiftyJSON 的 subscript 关键实现
public subscript(key: String) -> JSON {
    get {
        if type == .dictionary {
            if let value = rawDictionary[key] {
                return JSON(value)
            }
        }
        return JSON.null  // ← 不崩溃,返回 null JSON
    }
}

JSON.null 是一个静态单例,所有对它的 subscript 访问都继续返回自身,形成"null 传播链",这就是为什么 json["a"]["b"]["c"]["d"] 即便 "a" 不存在也不会 crash。

深入层:与 Codable 的本质区别

维度 SwiftyJSON Codable
解析时机 运行时,按需访问 解码时一次性反序列化
类型错误 运行时,返回默认值 编译时 / 解码时抛错
内存占用 保留完整 JSON 树 只保留 struct/class 数据
适用场景 探索、动态结构 固定 API 模型

六、踩坑记录

问题 1:.string 返回 nil.stringValue 返回空字符串

  • 原因:JSON 中该字段是 null 或类型是 Number,.string 只在类型是 String 时返回非 nil
  • 解决:根据场景选择:string ?? "默认值".stringValue;如果需要 Number → String 转换:
    let val = json["count"].string ?? json["count"].numberValue.stringValue
    

问题 2:修改 SwiftyJSON 的值没有生效

  • 原因JSON 是值类型(struct),赋值后修改的是副本
  • 解决
    var json = JSON(data)
    json["user"]["name"] = "New Name"  // ✅ 使用 subscript setter
    

问题 3:Swift Package Manager 找不到模块

  • 原因:SwiftyJSON 的 SPM 包名是 SwiftyJSON,但有时大小写不一致
  • 解决:确保 import SwiftyJSON(大驼峰),检查 SPM 依赖是否成功解析

问题 4:OC 项目无法使用 SwiftyJSON

  • 原因:SwiftyJSON 是纯 Swift,OC 不能直接 import
  • 解决:OC 项目用 NSJSONSerialization + YYModel / MJExtension,或在 Swift 桥接层封装

问题 5:解析性能在大量数据时较差

  • 原因:SwiftyJSON 在内部创建大量临时 JSON 实例
  • 解决:大量数据(10w+ 条)时改用 Codable,小量动态数据 SwiftyJSON 够用

七、延伸思考

JSON 解析方案全景对比

方案 类型安全 动态 JSON OC 支持 性能 推荐场景
SwiftyJSON 运行时 ✅ 最好 中等 探索/动态结构
Codable 编译时 ⚠️ 需 AnyCodable 固定 API 模型
YYModel (OC) 运行时 OC 项目
ObjectMapper 运行时 中等 Swift,已有项目
NSJSONSerialization 简单/OC 场景

推荐原则

新项目 Swift:优先 Codable,复杂动态 JSON 用 SwiftyJSON 辅助。 老项目 OCNSJSONSerialization + YYModel / MJExtension。 混合项目:在 Swift 层用 Codable 建模,可选 SwiftyJSON 处理边界情况。


八、参考资源


九、本期互动

小作业

用 SwiftyJSON 解析一个真实 API(如 GitHub / 豆瓣 / OpenWeather),要求:处理嵌套 3 层以上的 JSON,包含数组遍历和 null 字段处理,最终展示在 UITableView 中。评论区分享你选的 API 和最复杂的解析路径。

思考题

SwiftyJSON 用"null 传播"(路径不存在时返回 JSON.null 而非 crash)来保证安全性,而 Swift Codable 用 Optionalthrows 来保证类型安全。这两种设计哲学各有什么权衡?在什么情况下"静默返回默认值"比"抛出错误"更合适?

读者征集

下一期我们将深入 R.swift(编译时安全的资源访问)。你在项目中遇到过资源文件名拼写错误导致运行时崩溃吗?你目前是如何管理图片/字体/颜色等资源的?欢迎评论区分享你的资源管理方案。


📅 本系列每周五晚更新 ✅ 第11期:DGCharts · ✅ 第12期:Hero · ✅ 第13期:Realm · ✅ 第14期:Moya · ➡️ 第15期:SwiftyJSON · ○ 第16期:R.swift

【Moya】为什么你的 Alamofire 代码需要再封装一层?

作者 探索者dx
2026年4月20日 09:21

【Moya】为什么你的 Alamofire 代码需要再封装一层?

iOS三方库精读 · 第 14 期


一、一句话介绍

Moya 是一个建立在 Alamofire 之上的网络抽象层库,它用 TargetType 协议将所有 API 接口声明为 Swift 枚举 case,让网络请求从"散落在各处的字符串 URL"变成"编译器可检查的类型化接口",同时内置单元测试 Stubbing 和 Plugin 拦截机制。

属性 信息
⭐ GitHub Stars 15k+
最新稳定版 15.0.3
License MIT
支持平台 iOS 13+
语言 Swift(纯 Swift,无 OC 接口)
依赖 Alamofire 5.x

二、为什么选择它

原生痛点

直接使用 Alamofire 或 URLSession 时,常见的问题:

// ❌ 硬编码 URL,散落在各处
AF.request("https://api.example.com/users/\(userId)/profile",
           method: .get,
           parameters: ["include": "avatar"],
           headers: ["Authorization": "Bearer \(token)"])
  • URL 字符串:运行时才发现拼写错误
  • 参数类型parameters: [String: Any],传错类型编译器不报错
  • 认证 Token:每个请求都要手动加 headers
  • Mock 测试:需要替换整个 URLSession,实现复杂
  • 接口文档:散落在业务代码里,难以统一维护

Moya 的解决方案:

  1. TargetType 协议:将每个 API 的 URL、方法、参数、headers 集中声明
  2. 类型安全:枚举 case 关联值确保参数类型正确,编译时报错
  3. Plugin 系统:一处注入 Token,所有请求自动携带
  4. 内置 StubbingsampleData + StubbingProvider,无需 mock URLSession

三、核心功能速览

基础层(新手必读)

环境集成
// SPM
// URL: https://github.com/Moya/Moya.git
// from: "15.0.3"
// Products: Moya(基础)/ RxMoya(RxSwift)/ CombineMoya(Combine)
# CocoaPods
pod 'Moya', '~> 15.0'
pod 'Moya/RxSwift'    # 可选
pod 'Moya/Combine'    # 可选
TargetType 完整示例
import Moya

enum UserAPI {
    case login(email: String, password: String)
    case profile(userId: Int)
    case updateAvatar(data: Data)
    case logout
}

extension UserAPI: TargetType {
    var baseURL: URL { URL(string: "https://api.example.com/v2")! }

    var path: String {
        switch self {
        case .login:              return "/auth/login"
        case .profile(let id):   return "/users/\(id)"
        case .updateAvatar:      return "/users/avatar"
        case .logout:            return "/auth/logout"
        }
    }

    var method: Moya.Method {
        switch self {
        case .login, .updateAvatar: return .post
        case .logout:               return .delete
        case .profile:              return .get
        }
    }

    var task: Task {
        switch self {
        case .login(let email, let pw):
            return .requestParameters(
                parameters: ["email": email, "password": pw],
                encoding: JSONEncoding.default
            )
        case .updateAvatar(let data):
            let formData = MultipartFormData(provider: .data(data),
                                              name: "file",
                                              fileName: "avatar.jpg",
                                              mimeType: "image/jpeg")
            return .uploadMultipart([formData])
        default:
            return .requestPlain
        }
    }

    var headers: [String: String]? { ["Content-Type": "application/json"] }

    // 单元测试用的 Stub 数据
    var sampleData: Data {
        switch self {
        case .login:
            return """{"token":"test_token","userId":1}""".data(using: .utf8)!
        default:
            return Data()
        }
    }
}
发起请求
let provider = MoyaProvider<UserAPI>()

// 回调方式
provider.request(.login(email: "test@example.com", password: "123456")) { result in
    switch result {
    case .success(let response):
        let json = try? response.mapJSON()
        print(json ?? "")
    case .failure(let error):
        print(error)
    }
}

// 直接 map 到 Codable 模型
provider.request(.profile(userId: 42)) { result in
    if case .success(let response) = result {
        let user = try? response.map(User.self)
    }
}

进阶层(最佳实践)

Plugin 系统:拦截所有请求
// 统一注入认证 Token
struct TokenPlugin: PluginType {
    func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
        var req = request
        if let token = AuthManager.shared.token {
            req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        return req
    }

    // 401 自动触发 token 刷新
    func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
        if case .success(let response) = result, response.statusCode == 401 {
            AuthManager.shared.refreshToken()
        }
    }
}

// 统计 API 耗时
struct MetricsPlugin: PluginType {
    func willSend(_ request: RequestType, target: TargetType) {
        Analytics.trackStart(api: target.path)
    }
    func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
        Analytics.trackEnd(api: target.path)
    }
}

// 组合多个 Plugin
let provider = MoyaProvider<UserAPI>(plugins: [
    TokenPlugin(),
    MetricsPlugin(),
    NetworkLoggerPlugin()  // Moya 内置日志插件
])
单元测试:Stubbing
// 无需真实网络,立即返回 sampleData
let testProvider = MoyaProvider<UserAPI>(stubClosure: MoyaProvider.immediatelyStub)

// 延迟返回(模拟网络延迟)
let testProvider2 = MoyaProvider<UserAPI>(
    stubClosure: MoyaProvider.delayedStub(0.5)  // 延迟 0.5s
)

// 测试代码
func testLogin() {
    testProvider.request(.login(email: "test@example.com", password: "123")) { result in
        switch result {
        case .success(let response):
            XCTAssertEqual(response.statusCode, 200)
            let model = try? response.map(LoginResponse.self)
            XCTAssertNotNil(model?.token)
        case .failure:
            XCTFail()
        }
    }
}
Combine 集成
import Combine

class UserViewModel: ObservableObject {
    @Published var user: User?
    @Published var error: String?

    private var cancellables = Set<AnyCancellable>()
    private let provider = MoyaProvider<UserAPI>()

    func loadProfile(userId: Int) {
        provider.requestPublisher(.profile(userId: userId))
            .tryMap { try $0.map(User.self) }
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    if case .failure(let error) = completion {
                        self?.error = error.localizedDescription
                    }
                },
                receiveValue: { [weak self] user in
                    self?.user = user
                }
            )
            .store(in: &cancellables)
    }
}
RxSwift 集成
import RxSwift

provider.rx.request(.searchRepos(query: "swift"))
    .map(RepoSearchResult.self)
    .observe(on: MainScheduler.instance)
    .subscribe(
        onSuccess: { result in print(result.items) },
        onFailure: { error in print(error) }
    )
    .disposed(by: disposeBag)

深入层(源码视角)

MoyaProvider 的请求流程
provider.request(.login(...))
    ↓
MoyaProvider.requestNormal
    ↓
调用所有 Plugin.prepare(修改 URLRequest)
    ↓
调用 Plugin.willSend(发送前通知)
    ↓
Alamofire.request(真正发网络请求)
    ↓
收到响应
    ↓
调用 Plugin.didReceive(响应后通知)
    ↓
回调 completion handler

Moya 本质是 Alamofire 的装饰器(Decorator),所有的实际网络操作都委托给 Alamofire,Moya 只负责协议声明、Plugin 拦截、Stub 切换。

Task 枚举的设计

Task 枚举涵盖了所有常见请求类型:

public enum Task {
    case requestPlain                              // 无 body
    case requestData(_ data: Data)                 // 原始 Data
    case requestParameters(parameters:encoding:)   // URL 参数 or JSON body
    case uploadMultipart(_ data: [MultipartFormData]) // 文件上传
    case downloadDestination(_ destination: DownloadDestination) // 文件下载
    case uploadCompositeMultipart(_, urlParameters:)  // 混合上传
    // ...
}

这种穷举枚举设计确保了所有请求形式都有类型安全的表达方式。


四、实战演示

场景:统一网络层封装(生产级模板)

// 1. 定义 API Target
enum NewsAPI {
    case topHeadlines(country: String, page: Int)
    case article(id: String)
}

extension NewsAPI: TargetType {
    var baseURL: URL { URL(string: "https://newsapi.org/v2")! }
    var path: String {
        switch self {
        case .topHeadlines: return "/top-headlines"
        case .article(let id): return "/articles/\(id)"
        }
    }
    var method: Moya.Method { .get }
    var task: Task {
        switch self {
        case .topHeadlines(let country, let page):
            return .requestParameters(
                parameters: ["country": country, "page": page, "pageSize": 20],
                encoding: URLEncoding.queryString
            )
        case .article: return .requestPlain
        }
    }
    var headers: [String: String]? { nil }
    var sampleData: Data { Data() }
}

// 2. Service 层封装(屏蔽 Moya 细节)
final class NewsService {
    private let provider = MoyaProvider<NewsAPI>(plugins: [
        TokenPlugin(),
        NetworkLoggerPlugin()
    ])

    func fetchHeadlines(country: String, page: Int) async throws -> [Article] {
        return try await withCheckedThrowingContinuation { cont in
            provider.request(.topHeadlines(country: country, page: page)) { result in
                switch result {
                case .success(let response):
                    do {
                        let articles = try response.map([Article].self, atKeyPath: "articles")
                        cont.resume(returning: articles)
                    } catch {
                        cont.resume(throwing: error)
                    }
                case .failure(let error):
                    cont.resume(throwing: error)
                }
            }
        }
    }
}

// 3. 业务层调用
let service = NewsService()
Task {
    let articles = try await service.fetchHeadlines(country: "cn", page: 1)
}

五、源码亮点

进阶层

TargetType 作为抽象屏障

Moya 的 MoyaProvider<Target: TargetType> 是泛型类型,每种 API 有独立的 Provider 实例。这意味着:

  • 不同 API 服务(UserAPI / ProductAPI / OrderAPI)完全隔离
  • 每个 Provider 可以配置不同的 Plugin(如不同的 Token 策略)
  • 测试时替换 Provider 无需修改任何业务代码

深入层:网络层的 SOLID 原则

Moya 的设计完美体现了 SOLID 原则:

原则 体现
Single Responsibility TargetType 只描述接口声明,Provider 只负责执行
Open/Closed 新增 API 只需新增枚举 case,不修改 Provider
Liskov Substitution StubbingProvider 可无缝替换真实 Provider
Interface Segregation Plugin 协议的每个方法都是可选实现
Dependency Inversion 业务代码依赖 TargetType 协议,而非具体 URL 字符串

六、踩坑记录

问题 1:sampleData 返回空 Data 导致测试解析失败

  • 原因:使用 StubbingProvider 但忘记实现 sampleData
  • 解决:为每个需要测试的 case 提供合法 JSON 的 sampleData

问题 2:Plugin.prepare 中修改 headers 无效

  • 原因URLRequest 是值类型,必须先 copy 再修改
  • 解决
    func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
        var req = request  // ← copy 一份
        req.setValue("Bearer xxx", forHTTPHeaderField: "Authorization")
        return req         // ← 返回修改后的副本
    }
    

问题 3:Moya 请求不在主线程回调

  • 原因:默认 callbackQueue.main,但某些版本或配置下可能改变
  • 解决:UI 更新前显式 DispatchQueue.main.async { ... } 或使用 .receive(on: MainScheduler.instance)

问题 4:多个 API 服务需要不同 baseURL

  • 原因:Moya 一个 TargetType 对应一个 baseURL
  • 解决:拆分为多个 enum(UserAPI, ProductAPI),各自独立 Provider

问题 5:文件上传进度无法监听

  • 原因:回调方式无进度回调
  • 解决
    provider.request(.uploadAvatar(data: imageData)) { result in ... }
    // 上面不支持进度,改用:
    provider.requestWithProgress(.uploadAvatar(data: imageData)) { progress in
        print("上传进度:", progress.progress)
    }
    

七、延伸思考

同类方案对比

方案 类型安全 测试友好 学习成本 OC 支持 推荐场景
URLSession ❌ 字符串 需 mock 超简单场景
Alamofire ⚠️ 需封装 需封装 中型 App
Moya ✅ 枚举 ✅ 内置 中大型 Swift App
Apollo(GraphQL) ✅ 代码生成 ⚠️ GraphQL API

推荐使用场景

  • ✅ API 接口较多(20+)的中大型 App
  • ✅ 团队协作,需要统一的 API 文档化
  • ✅ 需要完整的单元测试覆盖网络层
  • ✅ 已经在使用 Alamofire 想升级架构

不推荐场景

  • ❌ API 极少(3 个以内)→ 直接 Alamofire 更简单
  • ❌ OC 项目 → Moya 纯 Swift,考虑 AFNetworking
  • ❌ 追求最小依赖体积 → URLSession 直接封装

八、参考资源


九、本期互动

小作业

用 Moya 封装一个天气 API 服务层:定义 WeatherAPI enum,包含"当前天气"和"5天预报"两个 case,实现 TargetType,并编写一个 TokenPlugin 注入 API Key,最后用 StubbingProvider 为两个接口各写一个单元测试。评论区分享你的 TargetType 实现。

思考题

Moya 的 TargetType 强制将一个服务的所有 API 放在同一个 enum 里。当 API 接口很多时(50+),这个大 enum 会变得难以维护。你会如何设计拆分方案?能否在不改变使用方代码的前提下实现"API 分模块管理"?

读者征集

下一期我们将深入 SwiftyJSON(JSON 解析利器)。你在处理复杂嵌套 JSON 时用过哪些方案(SwiftyJSON / Codable / ObjectMapper / 手动解析)?在 OC 项目中你是如何处理 JSON 的?欢迎评论区分享。


📅 本系列每周五晚更新 ✅ 第11期:DGCharts · ✅ 第12期:Hero · ✅ 第13期:Realm · ➡️ 第14期:Moya · ○ 第15期:SwiftyJSON

MVVM 本质解构 + RxSwift 与 Combine 深度对决与选型指南

作者 择势
2026年4月19日 13:47

作为 iOS 开发演进的核心架构,MVVM彻底解决了原生 MVC 的 Massive View Controller 顽疾;而响应式编程是 MVVM 落地的灵魂 —— 脱离响应式的 MVVM 只是伪架构。本文从资深开发工程化视角,深度拆解 MVVM 的底层设计逻辑,全方位对比 RxSwift 与 Combine 两大 iOS 响应式框架,结合实战、踩坑与选型策略,为中大型 iOS 项目的架构设计提供专业参考。

一、深刻理解 MVVM:不止是分层,是 iOS UI 开发的范式升级

绝大多数 iOS 开发者对 MVVM 的理解停留在「View-ViewModel-Model」三层结构,这是表层认知。从资深开发和工程化角度,MVVM 的核心是UI 与业务逻辑的彻底解耦数据驱动 UI的编程范式升级。

1.1 原生 MVC 的致命困境

iOS 官方推荐的 MVC 架构,在实际工程中会快速腐化:

  • ViewController 身兼数职:UI 渲染、用户交互、网络请求、数据解析、业务逻辑、状态管理;
  • 千行 VC 是常态,不可测试、难复用、难维护
  • View 与 Model 强耦合,UI 修改会牵连业务逻辑,业务逻辑变动会破坏 UI 渲染。

这是 iOS 原生开发的历史痛点,也是 MVVM 诞生的核心原因。

1.2 MVVM 的核心本质(资深开发必掌握)

MVVM 的设计目标不是「分层」,而是让 UI 层彻底被动化,让业务逻辑彻底纯净化

核心角色职责(严格边界)

表格

角色 核心职责 禁忌
View(ViewController/UIView) 仅负责:转发用户交互事件、响应数据渲染 UI 不写任何业务逻辑、不直接操作 Model、不持有网络 / 数据库对象
ViewModel 核心中间层:持有 Model、处理业务逻辑(校验 / 网络 / 数据转换)、暴露可观察数据流 不导入 UIKit、不持有任何 UI 对象、完全脱离 iOS 平台,可独立单元测试
Model 纯数据结构(实体类 / 结构体) 不包含任何业务逻辑、不与 UI/ViewModel 耦合

MVVM 的灵魂:双向绑定

View 与 ViewModel 之间不直接调用方法,而是通过可观察数据流实现自动绑定:

  1. ViewModel 数据变化 → 自动驱动 View 更新 UI;
  2. View 用户交互(点击 / 输入)→ 自动触发 ViewModel 业务逻辑。

这是 MVVM 的核心价值,也是原生 iOS 无法高效实现的能力 ——KVO/Notification/Delegate 代码冗余、易泄漏、难以维护,必须依赖响应式编程框架落地。

1.3 MVVM 黄金法则(工程化落地准则)

  1. View 只做「UI 转发 + 渲染」,无任何业务逻辑;
  2. ViewModel 无 UIKit 依赖,100% 可单元测试;
  3. 所有通信通过响应式数据流,禁止反向引用;
  4. 单一职责:复杂 ViewModel 拆分 UseCase/Service,拒绝臃肿。

二、响应式编程:MVVM 的唯一高效落地方案

MVVM 的核心是「绑定」,而响应式编程(RP) 是实现绑定的最优解:

  • 一切异步事件(UI 点击、网络请求、数据变化、定时器)抽象为可观察的数据流
  • 声明式语法处理数据流,实现自动化绑定;
  • 彻底告别代理、通知、闭包嵌套的异步噩梦。

iOS 生态中,只有两个选择:

  1. RxSwift:跨平台响应式标准 ReactiveX 的 iOS 实现,成熟稳定;
  2. Combine:苹果原生官方响应式框架,iOS13 + 内置,未来主流。

三、RxSwift 深度解析:成熟的响应式事实标准

3.1 核心定位

RxSwift 是ReactiveX的 iOS 移植版本(跨平台响应式规范,Java/RxJS 通用),是 iOS 响应式编程的「事实标准」,历经多年迭代,生态极致完善。

3.2 核心抽象

  • Observable:数据流生产者(发送数据 / 错误 / 完成);
  • Observer:数据流消费者;
  • Disposable:资源回收器(避免内存泄漏);
  • Operator:操作符(map/filter/flatMap/zip),数据流处理核心;
  • Scheduler:线程调度器(主线程 / 后台线程切换)。

3.3 iOS 生态矩阵

  • RxCocoa:UIKit 全扩展(UIButton.rx.tap/UITextField.rx.text);
  • RxDataSources:UITableView/CollectionView 极简数据绑定;
  • RxAlamofire:网络请求响应式封装;
  • 几乎所有主流第三方库都提供 Rx 扩展。

3.4 优劣势

优势

  • 全版本兼容:iOS8+,覆盖所有存量项目;
  • 生态天花板:社区成熟,无实现不了的场景;
  • 操作符丰富:复杂数据流开箱即用;
  • 文档 / 社区完善,问题秒解。

劣势

  • 学习成本极高:冷 / 热 Observable、背压等概念抽象;
  • 第三方依赖:增加包体积;
  • 非官方维护,未来迭代放缓。

四、Combine 深度解析:苹果原生的响应式未来

4.1 核心定位

苹果在 iOS13 推出的原生响应式框架,深度集成 SwiftUI、UIKit、Swift Concurrency(async/await),是苹果生态的未来标准

4.2 核心抽象(与 RxSwift 无缝映射)

表格

RxSwift Combine 功能一致
Observable Publisher 数据流生产者
Observer Subscriber 数据流消费者
Disposable Cancellable 资源销毁
BehaviorSubject CurrentValueSubject 带缓存值
PublishSubject PassthroughSubject 无缓存值

4.3 原生杀手锏

  • @Published:属性包装器,一行代码生成可观察数据流,ViewModel 绑定极简;
  • 原生集成 GCD/Operation,线程调度零成本;
  • 无缝衔接 Swift Concurrency,现代 Swift 编程体验拉满。

4.4 优劣势

优势

  • 官方原生:无第三方依赖,系统级优化;
  • 轻量无体积:内置系统,无需引入库;
  • 语法极简:贴合 Swift 语法,学习成本低;
  • 未来兼容:随 Swift/SwiftUI 迭代,长期维护。

劣势

  • 版本硬限制:iOS13 以下完全不支持
  • 生态贫瘠:第三方库远少于 RxSwift;
  • 操作符精简:复杂场景需自定义。

五、RxSwift vs Combine:全方位深度对比(资深开发核心参考)

5.1 基础能力对比

表格

维度 RxSwift Combine
兼容性 iOS8+,全平台覆盖 iOS13+,低版本无支持
依赖方式 第三方库(CocoaPods/SPM) 系统内置,无依赖
语法风格 标准 ReactiveX 链式调用 Swift 原生语法,极简简洁
核心简化 无属性包装器,需手动创建 Subject @Published 一行实现绑定
生态完善度 极致完善(UI / 网络 / 列表全覆盖) 原生生态完善,第三方薄弱
背压支持 需额外处理 原生内置支持
错误处理 灵活,无强类型约束 强类型泛型约束,更安全
测试工具 RxTest/RxBlocking,功能强大 原生 XCTest,简洁轻量化
学习成本 高(ReactiveX 抽象概念) 低(Swift 原生,易上手)

5.2 性能与内存

  • Combine:系统级优化,内存占用更低,线程调度更高效;
  • RxSwift:社区优化多年,性能稳定,资源回收严格可控;
  • 内存管理:两者均需手动管理订阅(DisposeBag/Set),否则泄漏。

5.3 工程化适配

  • 存量旧项目 → RxSwift(兼容低版本);
  • 全新 SwiftUI 项目 → Combine(原生最佳搭配);
  • 团队新手 → Combine(学习成本低);
  • 复杂数据流 / 列表 → RxSwift(生态完善)。

六、实战对比:MVVM + 登录页面(两种实现)

用最经典的登录场景,直观感受两种方案的编码差异。

核心需求

  • 账号 / 密码输入 → 实时校验按钮是否可点击;
  • 点击登录 → 触发网络请求 → 响应结果;
  • 严格遵循 MVVM:ViewModel 无 UIKit,View 仅绑定。

方案 1:MVVM + RxSwift

swift

// ViewModel (无UIKit依赖)
import RxSwift
import RxCocoa

class LoginViewModel {
    // 输入:账号、密码
    let account = BehaviorSubject<String>(value: "")
    let password = BehaviorSubject<String>(value: "")
    // 输出:登录按钮可点击、登录结果
    let isLoginEnabled = Observable<Bool>
    let loginResult = PublishSubject<Bool>()
    
    private let disposeBag = DisposeBag()
    
    init() {
        // 数据流绑定:实时校验输入
        isLoginEnabled = Observable.combineLatest(account, password)
            .map { account, pwd in
                return account.count >= 6 && pwd.count >= 6
            }
        
        // 业务逻辑:登录方法
        func login() {
            // 模拟网络请求
            Observable.just(true)
                .delay(.seconds(1), scheduler: ConcurrentDispatchQueueScheduler(qos: .default))
                .observe(on: MainScheduler.instance)
                .subscribe(onNext: { [weak self] result in
                    self?.loginResult.onNext(result)
                })
                .disposed(by: disposeBag)
        }
    }
}

// View (ViewController)
import UIKit
import RxSwift
import RxCocoa

class LoginVC: UIViewController {
    @IBOutlet weak var accountTF: UITextField!
    @IBOutlet weak var passwordTF: UITextField!
    @IBOutlet weak var loginBtn: UIButton!
    private let vm = LoginViewModel()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        // 1. UI输入 → ViewModel
        accountTF.rx.text.orEmpty.bind(to: vm.account).disposed(by: disposeBag)
        passwordTF.rx.text.orEmpty.bind(to: vm.password).disposed(by: disposeBag)
        
        // 2. ViewModel状态 → UI渲染
        vm.isLoginEnabled.bind(to: loginBtn.rx.isEnabled).disposed(by: disposeBag)
        
        // 3. UI交互 → ViewModel逻辑
        loginBtn.rx.tap.subscribe(onNext: { [weak self] in
            self?.vm.login()
        }).disposed(by: disposeBag)
        
        // 4. 业务结果 → UI响应
        vm.loginResult.subscribe(onNext: { success in
            print("登录结果:(success)")
        }).disposed(by: disposeBag)
    }
}

方案 2:MVVM + Combine

swift

// ViewModel (无UIKit依赖)
import Combine

class LoginViewModel {
    // 输入:@Published 极简声明
    @Published var account = ""
    @Published var password = ""
    // 输出
    @Published var isLoginEnabled = false
    let loginResult = PassthroughSubject<Bool, Never>()
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // 实时校验
        $account.combineLatest($password)
            .map { account, pwd in
                account.count >= 6 && pwd.count >= 6
            }
            .assign(to: &$isLoginEnabled)
    }
    
    func login() {
        // 模拟网络请求 + 异步
        Future<Bool, Never> { promise in
            DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
                promise(.success(true))
            }
        }
        .receive(on: DispatchQueue.main)
        .sink { [weak self] success in
            self?.loginResult.send(success)
        }
        .store(in: &cancellables)
    }
}

// View (ViewController)
import UIKit
import Combine

class LoginVC: UIViewController {
    @IBOutlet weak var accountTF: UITextField!
    @IBOutlet weak var passwordTF: UITextField!
    @IBOutlet weak var loginBtn: UIButton!
    private let vm = LoginViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        // UI输入 → ViewModel
        accountTF.publisher(for: .editingChanged)
            .map { $0.text ?? "" }
            .assign(to: &vm.$account)
        
        passwordTF.publisher(for: .editingChanged)
            .map { $0.text ?? "" }
            .assign(to: &vm.$password)
        
        // ViewModel → UI
        vm.$isLoginEnabled
            .assign(to: .isEnabled, on: loginBtn)
            .store(in: &cancellables)
        
        // 点击事件
        loginBtn.publisher(for: .touchUpInside)
            .sink { [weak self] in
                self?.vm.login()
            }
            .store(in: &cancellables)
        
        // 登录结果
        vm.loginResult
            .sink { success in
                print("登录结果:(success)")
            }
            .store(in: &cancellables)
    }
}

七、资深开发选型决策树

无需盲目追新,工程化落地是第一准则:

  1. 项目最低支持 < iOS13 → 唯一选择:RxSwift
  2. 全新项目 ≥iOS13 / SwiftUI 项目 → 首选:Combine
  3. 存量项目逐步升级 → 混合方案:旧页面保留 RxSwift,新页面用 Combine;
  4. 团队无响应式基础 → 优先:Combine(学习成本低,原生规范);
  5. 重度复杂数据流(电商 / 金融) → 优先:RxSwift(生态完善);
  6. 长期维护、追求苹果原生标准 → 必选:Combine

八、工程化避坑指南(资深实战经验)

8.1 MVVM 通用误区

  1. ❌ ViewModel 持有 UIKit 对象 → 破坏可测试性,严格禁止;
  2. ❌ ViewModel 过度臃肿 → 拆分 UseCase/Service,单一职责;
  3. ❌ 为了绑定而绑定 → 简单 UI 用原生,复杂数据流用响应式。

8.2 RxSwift 避坑

  • 内存泄漏:必须DisposeBag管理订阅;
  • 冷 / 热 Observable 误用:网络请求用Single,事件用PublishSubject
  • UI 更新必须切MainScheduler

8.3 Combine 避坑

  • 订阅销毁:必须Set<AnyCancellable>存储,否则订阅立即失效;
  • iOS13 存在 APIbug,建议最低支持 iOS14;
  • 缺少操作符时,用async/await补充。

九、总结

  1. MVVM 的核心:不是三层结构,而是数据驱动 UI+UI 与业务彻底解耦,响应式编程是其唯一高效落地方式;
  2. RxSwift:成熟稳定、生态完善、全版本兼容,是存量项目的最优解
  3. Combine:苹果原生、轻量简洁、未来主流,是新项目的标准答案
  4. 资深 iOS 开发的核心能力:不迷信框架,根据项目场景选型,落地可维护、可测试的工程化架构

iOS 开发已进入SwiftUI+Combine+async/await的原生现代化时代,MVVM 作为核心架构,将长期主导中大型项目的设计。


关键点回顾

  1. MVVM 核心:解耦 + 数据驱动,无响应式则无落地价值;
  2. RxSwift:存量项目、低版本兼容、生态为王;
  3. Combine:新项目、原生未来、简洁轻量;
  4. 选型看系统版本+项目阶段+团队成本,不盲目追新。

被 Vibe 摧毁的版权壁垒,与开发者的新护城河 -- 肘子的 Swift 周报 #131

作者 东坡肘子
2026年4月14日 07:56

issue131.webp

被 Vibe 摧毁的版权壁垒,与开发者的新护城河

Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用(当然,也有分析者指出,这一安排同样有助于防止模型蒸馏,并锁定企业级客户)。但即便这头“猛兽”被暂时按住,当前主流 AI 模型的代码能力,已经足以让复制一款产品变得轻而易举。

上周,Reddit 上一位开发者宣称,自己花了一年时间“逆向 SwiftUI API”,打造了一个全新的 Swift Web 框架。帖子行文流畅、术语考究,一度吸引了不少关注。但 Paul Hudson 很快现身评论区打假:所谓“独立研发”,实际上只是将他的 MIT 开源项目 Ignite 做了简单的字符串替换,甚至连原作者带有个人风格的代码注释都原封不动保留,随后将整个仓库压缩为一次提交以抹除历史,并违规改为具有传染性的 GPL 协议。社区中不少开发者都怀疑,这套“逆向 SwiftUI”的叙事本身也是由 AI 生成。更耐人寻味的是,该作者本就是 Ignite 的主要贡献者之一——当 Vibe Coding 将“重新打包一个项目”的成本降至极低时,“我参与过”本身也可能成为一种模糊责任边界的话术。

几乎在同一时间,macOS 上精致的 AI 工作状态监控应用 Vibe Island 在发布后不久便遭遇了像素级仿制。尽管仿制者打着“开源替代品”的旗号公开了代码,这依然对原作者的商业销售与创作热情造成了明显冲击。然而,即便作者希望采取法律手段,也将面临一个新的时代难题:在确权与维权过程中,他可能需要证明其作品具备足够的人类独创性,并说明 AI 生成内容的参与程度,否则将面临更高的不确定性。

事实上,代码的法律壁垒正从“确权端”开始松动。上个月,中国版权保护中心正式启用新版《计算机软件著作权登记申请表》及相关审查新规,明确要求经办人实名承诺“未使用 AI 开发编写代码、撰写文档或生成登记材料”,并在审查中重点评估人类智力投入是否达到著作权法所要求的“独创性”门槛;缺乏实质人类参与的内容,将难以获得确权。违规者还可能被纳入失信名单,并与个人征信挂钩。

这一趋势也与欧美近期的判例方向趋于一致:如果一段代码主要由 AI 根据提示词快速“改写或重组”生成,其获得著作权保护的可能性将显著降低。

我们必须承认一个残酷的事实:“我有一个绝妙的 Idea,并把它 Vibe 成代码”,已经不足以构成商业壁垒。这种名为 Vibe Coding 的新范式,不仅改变了开发流程、显著提升了效率,也从三个方向同时动摇了软件版权体系的基础逻辑:确权门槛提高、侵权举证变难、功能复刻被默许。

令人遗憾的是,即便这些克隆项目饱受争议,它们依然在 GitHub 上收获了不菲的 Star。这说明,在获取成本极低的前提下,仅靠道德呼吁,已经很难阻止人们对“免费平替”的追逐。

或许,正如我们在 第 120 期周报中讨论 Skip 的开源举措 时所提到的那样——在代码实现成本趋近于零、应用随时可能被 AI 一键克隆的当下,闭门造车“卖工具”将变得愈发困难。与用户建立真实连接,将“出品方的信誉与社区的信任”转化为不可复制的品牌资产,或许才是未来开发者真正的核心竞争力与护城河。

本期内容 | 前一期内容 | 全部周报列表

近期推荐

Swift Blog Carnival: Tiny Languages

Swift 社区正在发起第一届 Blog Carnival,四月的主题是 Tiny LanguagesChristian Tietze 邀请开发者围绕这一主题撰写博客:自定义 DSL、Result Builder、脚本解析器、路由规则……任何与“微型语言”相关的思考都可以作为切入点,截止日期为 5 月 1 日。

目前已有三篇投稿:

  • Matt Massicotte 回顾了他 从 Rake 到 Make,再到各类 Swift 任务运行器的探索历程,坦言至今仍未找到理想替代品
  • Chris Liscio 分享了 Capo 应用内置 DSL 的设计:用于描述键盘与 MIDI 绑定,基于 Point-Free 的 swift-parsing 库构建
  • Nicolas Zinovieff 则展示了一个 符号数学 DSL 的实验:通过协议与运算符重载,让 (1 + 2 * "X") * (3 - "Y")成为合法的 Swift 表达式,并在提供具体值时惰性求值,核心实现不超过 300 行

在 macOS 菜单中清晰显示当前选中状态 (Indicating Selection in macOS Menus Using SwiftUI)

SwiftUI 提供了不少用于表达选择的组件,例如 PickerToggleMenu,但如何清晰地引导用户进行选择,并准确标识当前选中项,并没有想象中那样理所当然。Gabriel Theodoropoulos 从最基础的 Button 出发,一步步演进到 PickerToggle,系统梳理了几种常见方案及其各自的局限。文章的价值不在于给出“唯一正确解”,而在于提醒开发者:SwiftUI 提供的标准组件,并不会自动带来最佳的用户呈现效果。很多时候,你仍需在“系统一致性”与“实现自由度”之间做出权衡。


构建 List 的替代组件 (Building List replacement in SwiftUI)

如何在 ListScrollView + LazyStack 之间做出选择,一直是困扰不少 SwiftUI 开发者的问题。在本文中,Majid Jabrayilov 在重构其 CardioBot 应用时,选择基于 SwiftUI 的 Container View API(iOS 17+)构建了三个可复用的 UI 组件:ScrollingSurface、DividedCard、SectionedSurface,以替代 List。这些组件在使用方式上与 List + Section 高度相似,但彻底摆脱了 listRowBackgroundlistItemTint 等仅在 List 中生效的限制。

List 并非只是“有默认样式的 LazyVStack”——两者在底层架构、滚动控制、与导航容器的协同、以及大数据集下的性能表现上均有本质差异。如何在两者之间做出综合判断,可以参考这篇旧文。


AppIntents meet MCP

当大家还在将 AppIntents 视为 Siri 与快捷指令的“配套工具”时,Florian Schweizer 给出了一个更值得关注的方向:将 AppIntents 直接暴露为 MCP(Model Context Protocol)工具,从而让 LLM 能够调用你的应用能力。Florian 基于 SwiftMCP,通过宏构建 MCP Server,并将 AppIntents 无缝映射为 MCP Tools,使 AI Agent 能够直接调用应用中的 Intent,实现跨应用自动化。

去年便有传闻,苹果正在为其生态系统引入 MCP 支持。或许再过两个月,在 WWDC 26 上答案便会揭晓。


创建交互式小组件 (Ride the Lightning Air: Building Interactive WidgetKit Widgets)

很多开发者都会被 WidgetKit 的文档误导,错将 AppIntentTimelineProvider 视为实现交互按钮的关键——实则不然。它是为“用户可配置 Widget”(如长按后编辑设置项)准备的,与交互行为并无直接关系。而真正用于实现交互(按钮点击触发行为)的,依然是最基础的 TimelineProviderWesley Matlock 通过一个虚构航空公司的登机状态 Widget,完整演示了正确路径:使用 TimelineProvider + Button(intent:) + AppGroup 共享存储来构建交互式 Widget。

整个数据流形成一个清晰的闭环:用户操作 → Intent 执行 → 状态写入 → Widget 刷新 → UI 更新。


文件存储与 iCloud:从本地到云端的完整认知

在 iOS / macOS 开发和使用中,文件存储往往被当作“基础能力”,但它实际上直接决定了数据的生命周期与系统行为。

Working with files and directories in iOS 一文中,Natascha Fadeeva 系统梳理了 App Sandbox 的结构,以及 DocumentsLibraryCaches 等目录的职责划分,帮助开发者建立“什么数据该放在哪里”的基本认知,并说明如何避免无关文件被 iCloud 备份。

Howard Oakley 撰写的 Understanding and Testing iCloud 则从系统层面揭示了这些数据的“后续命运”。iCloud 并非单一服务,而是由 CloudKit、iCloud Drive、系统更新等多个子系统组成,不同数据类型对应不同的同步与备份路径。

文件的存储位置,并不仅仅是组织问题,更是在定义它是否会被备份、同步,以及在设备之间如何流转。

因此,iCloud 问题往往不是“是否开启同步”这么简单,而是涉及客户端、网络、缓存以及服务端限流等多个环节。

工具

Bad Dock: 让你的 Dock 图标动起来

这是一个“离谱但严肃”的 macOS 实验项目。Eric Martz 利用公开的 NSDockTile / NSDockTilePlugin API,绕过 Big Sur 之后 Dock 图标的 squircle 限制,将视频流直接渲染进 Dock 图标。实现思路并不复杂,但非常完整:使用 AVAssetReader 解码视频、主动降帧至约 12fps、通过 ring buffer 控制内存占用,最终将一个“整活想法”打磨成了结构清晰的技术验证。

这类项目的价值不在功能本身,而在于展示:系统 API 的边界,往往比文档写得更远。

补充说明:该项目实现的是运行时动态 Dock 图标(应用运行时持续绘制);应用退出后,仅能通过 NSDockTilePlugin 保留静态自定义图标。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

被 Vibe 摧毁的版权壁垒,与开发者的新护城河 - 肘子的 Swift 周报 #131

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

Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用

【DGCharts】iOS 图表渲染事实标准——8 种图表类型、高度可定制,3 行代码画出一条折线

作者 探索者dx
2026年4月13日 10:25

【DGCharts】iOS 图表渲染事实标准——8 种图表类型、高度可定制,3 行代码画出一条折线

iOS三方库精读 · 第 11 期


一、一句话介绍

DGCharts(原名 Charts)是一个用于 iOS/macOS/tvOS 的图表渲染库,它是 Android 端 MPAndroidChart 的 Swift 移植版,让在 UIKit 与 SwiftUI 中绘制专业级折线图、柱状图、饼图等 8 种图表类型变得像配置数据一样简单。

属性 信息
⭐ GitHub Stars 27.5k+
最新稳定版 5.1.0
License Apache 2.0
支持平台 iOS 13+ / macOS 10.14+ / tvOS 13+
语言 Swift(纯 Swift,无 OC 接口)

二、为什么选择它

原生痛点

苹果官方没有提供专用图表框架(Swift Charts 直到 iOS 16 才随 SwiftUI 一起发布),在此之前,iOS 开发者要想画一条像样的折线图,要么:

  • 用 CoreGraphics 手撸贝塞尔曲线 → 代码量大,动画难实现
  • 用 CALayer + CAAnimation → 正确实现坐标系变换极其麻烦
  • 引入 WebView 渲染 ECharts → 性能差、交互延迟

DGCharts 解决的核心痛点:

  1. 零 CoreGraphics 基础可上手:数据 → DataSet → Chart,三行完成
  2. 内置 8 种图表类型:折线、柱状、饼、雷达、散点、K 线、气泡、组合图
  3. 原生手势支持:缩放、拖拽、高亮点击,全部开箱即用
  4. 可定制到像素级别:颜色、字体、轴线、动画均可覆盖
  5. iOS 13+,Swift Charts 的前辈:大量存量 App 仍在使用,理解它有助于迁移评估

与 Swift Charts 的核心区别(一眼对比)

维度 DGCharts Swift Charts(苹果官方)
平台要求 iOS 13+ iOS 16+
UI 框架 UIKit + SwiftUI SwiftUI only
图表类型 8 种(含 K 线/雷达) 折/柱/面积/点(4 种)
动画控制 精细帧级控制 声明式,较难定制
手势交互 内置缩放/平移 需自己实现
维护状态 社区维护,活跃 Apple 官方,稳定更新

三、核心功能速览

基础层(新手必读)

环境集成

SPM(推荐):

// Package.swift 或 Xcode Add Package Dependency
// URL: https://github.com/danielgindi/Charts.git
// from: "5.1.0"

CocoaPods:

pod 'DGCharts', '~> 5.1.0'
最简用法:3 行画折线图
import DGCharts

let chartView = LineChartView(frame: view.bounds)

// 准备数据
let entries = (0..<10).map { ChartDataEntry(x: Double($0), y: Double.random(in: 10...100)) }
let dataSet = LineChartDataSet(entries: entries, label: "销售额")
chartView.data = LineChartData(dataSet: dataSet)

view.addSubview(chartView)

基础层 概念:ChartDataEntry 是最小数据单元(x, y),XxxChartDataSet 是同类数据的集合(样式配置在这里),XxxChartData 是图表的数据容器,XxxChartView 是最终渲染视图。


进阶层(最佳实践)

8 种图表类型一览
类名 用途
LineChartView 折线图(支持曲线插值)
BarChartView 柱状图(支持堆叠/分组)
PieChartView 饼图 / 甜甜圈图
RadarChartView 雷达图(蜘蛛网图)
ScatterChartView 散点图
CandleStickChartView K 线图(OHLC)
BubbleChartView 气泡图
CombinedChartView 组合图(折+柱叠加)
常用样式定制
// 折线数据集样式
let dataSet = LineChartDataSet(entries: entries, label: "趋势")
dataSet.colors = [.systemBlue]          // 线条颜色
dataSet.lineWidth = 2.0                  // 线宽
dataSet.circleColors = [.systemBlue]     // 数据点颜色
dataSet.circleRadius = 4.0               // 数据点半径
dataSet.drawFilledEnabled = true         // 开启渐变填充
dataSet.fillColor = .systemBlue
dataSet.fillAlpha = 0.3
dataSet.mode = .cubicBezier              // 贝塞尔平滑曲线

// 图表视图样式
lineChartView.rightAxis.enabled = false  // 隐藏右轴
lineChartView.xAxis.labelPosition = .bottom
lineChartView.animate(xAxisDuration: 1.0, easingOption: .easeInOutQuart)
交互设置
chartView.scaleXEnabled = true           // 允许 X 轴缩放
chartView.scaleYEnabled = false          // 禁止 Y 轴缩放
chartView.dragEnabled = true             // 允许拖拽
chartView.pinchZoomEnabled = true        // 双指缩放
chartView.doubleTapToZoomEnabled = false // 关闭双击缩放
X 轴自定义 Formatter
// 将数字索引转为日期字符串
class DateAxisValueFormatter: AxisValueFormatter {
    private let dates = ["1月", "2月", "3月", "4月", "5月", "6月"]
    func stringForValue(_ value: Double, axis: AxisBase?) -> String {
        let index = Int(value)
        return index < dates.count ? dates[index] : ""
    }
}
chartView.xAxis.valueFormatter = DateAxisValueFormatter()

深入层(源码视角)

DGCharts 的核心渲染架构遵循 Renderer 模式

  • ChartView → 入口,持有 ChartData 和多个 DataRenderer
  • DataRenderer(如 LineChartRenderer)→ 负责具体图形绘制,使用 CGContext
  • ChartViewBase → 提供手势识别、坐标变换 Transformer
  • Transformer → 负责将数据坐标系映射到屏幕坐标系(核心矩阵变换)

每次 data 被 set,触发 notifyDataSetChanged()calcMinMax()setNeedsDisplay(),最终回调 draw(_ rect:) 由各 Renderer 完成绘制。


四、实战演示

场景:股票日 K 线 + 成交量组合图

// Swift UIKit,iOS 17+
import UIKit
import DGCharts

class StockChartVC: UIViewController {

    private lazy var combinedChart: CombinedChartView = {
        let chart = CombinedChartView()
        chart.legend.enabled = true
        chart.rightAxis.enabled = false
        chart.xAxis.labelPosition = .bottom
        chart.animate(xAxisDuration: 0.8)
        return chart
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        view.addSubview(combinedChart)
        combinedChart.frame = CGRect(x: 16, y: 100,
                                      width: view.bounds.width - 32,
                                      height: 320)
        setupChart()
    }

    private func setupChart() {
        // ---- K 线数据 ----
        let candleEntries: [CandleChartDataEntry] = [
            CandleChartDataEntry(x: 0, shadowH: 185, shadowL: 162, open: 165, close: 178),
            CandleChartDataEntry(x: 1, shadowH: 190, shadowL: 170, open: 178, close: 188),
            CandleChartDataEntry(x: 2, shadowH: 195, shadowL: 175, open: 188, close: 177),
            CandleChartDataEntry(x: 3, shadowH: 182, shadowL: 165, open: 177, close: 169),
            CandleChartDataEntry(x: 4, shadowH: 175, shadowL: 160, open: 169, close: 172),
        ]
        let candleSet = CandleChartDataSet(entries: candleEntries, label: "K 线")
        candleSet.shadowColor = .darkGray
        candleSet.shadowWidth = 1.5
        candleSet.decreasingColor = .systemRed     // 阴线颜色
        candleSet.decreasingFilled = true
        candleSet.increasingColor = .systemGreen   // 阳线颜色
        candleSet.increasingFilled = true
        candleSet.neutralColor = .systemGray

        // ---- 成交量柱状图 ----
        let barEntries = [
            BarChartDataEntry(x: 0, y: 3200),
            BarChartDataEntry(x: 1, y: 5400),
            BarChartDataEntry(x: 2, y: 4100),
            BarChartDataEntry(x: 3, y: 6800),
            BarChartDataEntry(x: 4, y: 3900),
        ]
        let barSet = BarChartDataSet(entries: barEntries, label: "成交量(手)")
        barSet.colors = [.systemBlue.withAlphaComponent(0.5)]
        barSet.axisDependency = .right  // 绑定右轴(虽然禁用了,仅用于数据缩放)

        // ---- 组合 ----
        let combined = CombinedChartData()
        combined.candleData = CandleChartData(dataSet: candleSet)
        combined.barData = BarChartData(dataSet: barSet)

        // X 轴 Formatter
        combinedChart.xAxis.valueFormatter = IndexAxisValueFormatter(
            values: ["Mon", "Tue", "Wed", "Thu", "Fri"]
        )
        combinedChart.xAxis.granularity = 1
        combinedChart.data = combined
    }
}

五、源码亮点

进阶层:值得借鉴的用法

MarkerView——点击数据点显示气泡
// 自定义 Marker,继承 MarkerView
class BalloonMarker: MarkerView {
    private var label: String = ""

    override func refreshContent(entry: ChartDataEntry, highlight: Highlight) {
        label = String(format: "%.1f", entry.y)
        super.refreshContent(entry: entry, highlight: highlight)
    }

    override func draw(context: CGContext, point: CGPoint) {
        // 用 CoreGraphics 绘制气泡背景 + 文字
        let attrs: [NSAttributedString.Key: Any] = [
            .font: UIFont.systemFont(ofSize: 12),
            .foregroundColor: UIColor.white
        ]
        let attrStr = NSAttributedString(string: label, attributes: attrs)
        let size = attrStr.size()
        let rect = CGRect(x: point.x - size.width / 2 - 8,
                          y: point.y - size.height - 12,
                          width: size.width + 16, height: size.height + 8)
        context.setFillColor(UIColor.systemBlue.cgColor)
        UIBezierPath(roundedRect: rect, cornerRadius: 6).fill()
        attrStr.draw(in: rect.insetBy(dx: 8, dy: 4))
    }
}

// 绑定
chartView.marker = BalloonMarker()
chartView.drawMarkers = true

深入层:设计思想解析

1. Transformer 矩阵变换

DGCharts 用一个 CGAffineTransform 维护数据坐标 → 屏幕坐标的映射:

屏幕坐标 = 数据坐标 × scale + offset + chart padding

每次缩放/平移,只更新 transform 矩阵然后 setNeedsDisplay,避免重新计算所有点,是渲染性能的核心保障。

2. Protocol-Oriented Renderer
// 每种图表有独立 Renderer,统一协议
public protocol DataRenderer: AnyObject {
    func drawData(context: CGContext)
    func drawValues(context: CGContext)
    func drawExtras(context: CGContext)
    func drawHighlighted(context: CGContext, indices: [Highlight])
}

遵循开闭原则,新增图表类型只需实现此协议,不修改任何现有代码。

3. ChartHighlighter — 触摸高亮的寻值逻辑

触摸事件 → getHighlight(x:y:) → 二分查找最近 x → 在该 x 的所有 DataSet 中找最近 y → 返回 Highlight(x:y:dataSetIndex:) → 触发 highlightValue。整套流程在主线程同步完成,保证交互响应 < 16ms。


六、踩坑记录

问题 1:数据更新后图表没有刷新

  • 原因:修改了 entries 数组但没有通知图表
  • 解决
    chartView.data?.notifyDataChanged()
    chartView.notifyDataSetChanged()
    

问题 2:X 轴标签显示 0, 1, 2 而不是自定义文字

  • 原因:没有设置 xAxis.valueFormatter 或者 granularity 不正确
  • 解决
    xAxis.valueFormatter = IndexAxisValueFormatter(values: yourLabels)
    xAxis.granularity = 1.0   // 必须设置,否则可能跳步
    

问题 3:饼图中间的 "洞" 大小无法控制

  • 原因holeRadiusPercent 默认 0.5(即 50%)
  • 解决
    pieChart.holeRadiusPercent = 0.3  // 调整洞的比例,0 = 实心饼图
    

问题 4:动画结束后视图突然闪烁

  • 原因animateXanimateY 同时调用,两个动画结束时各触发一次 setNeedsDisplay
  • 解决:使用 animate(xAxisDuration:yAxisDuration:) 合并为一次调用

问题 5:在 SwiftUI 中使用时手势冲突

  • 原因:DGCharts 的 UIPanGestureRecognizer 与 SwiftUI ScrollView 的手势抢夺
  • 解决
    chartView.dragEnabled = false       // 在 ScrollView 内关闭拖拽
    chartView.pinchZoomEnabled = false  // 关闭缩放
    

问题 6:Swift Package Manager 拉取超时

  • 原因:Charts 仓库体积较大(含所有 Demo)
  • 解决:在 package.json 中指定 .upToNextMinor(from: "5.1.0"),避免每次重新解析

七、延伸思考

同类库对比

维度 DGCharts Swift Charts (Apple) AAChartKit Charts.js (WebView)
平台要求 iOS 13+ iOS 16+ iOS 11+ iOS (via WKWebView)
图表类型数 8 4 10+ 20+
性能 原生,高 原生,高 原生,良好 WebView,差
K 线图
SwiftUI 支持 UIViewRepresentable 原生 UIViewRepresentable WKWebViewRepresentable
维护状态 社区活跃 Apple 官方 活跃 Web 项目
学习曲线 中等 低(SwiftUI 声明式) 需了解 JS

推荐使用场景

  • ✅ iOS 13~15 的存量 App,无法使用 Swift Charts
  • ✅ 需要 K 线图、雷达图等特殊类型
  • ✅ 需要精细控制动画和交互手势
  • ✅ 同时支持 iOS 和 macOS 的多平台 App

不推荐场景

  • ❌ 全新 App,最低支持 iOS 16+,且只用 SwiftUI → 直接用 Swift Charts
  • ❌ 只需要一个简单静态饼图/柱图 → 手动 CoreGraphics 更轻量
  • ❌ 需要超复杂的动态可视化(如力导向图)→ 考虑 WebView + D3.js

八、参考资源


九、本期互动

小作业

使用 LineChartView 实现一个实时动态折线图:每秒追加一个随机数据点,并保持最多显示最近 20 个点(超出则移除最旧的点),同时保证图表 X 轴自动滚动跟随最新数据。完成后在评论区贴出你的核心更新逻辑。

思考题

DGCharts 的 Transformer 用矩阵变换把数据坐标映射到屏幕坐标,这种设计和 CALayertransform 有何本质区别?如果让你自己实现一套坐标系映射,你会选择哪种方案?为什么?

读者征集

下一期我们将深入 Hero(转场动画库)。你在项目中用过哪些自定义转场方案(Hero / UIViewControllerTransitioningDelegate / NavigationController Push 动画)?欢迎评论区分享你的实践经验,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新 ✅ 第1期:Alamofire · ✅ 第2期:SDWebImage · ✅ 第3期:Kingfisher · ✅ 第4期:SnapKit · ✅ 第5期:ListDiff · ✅ 第6期:RxSwift · ✅ 第7期:Lottie · ✅ 第8期:MarkdownUI · ✅ 第9期:AFNetworking · ➡️ 第11期:DGCharts · ○ 第12期:Hero

【Lottie】让设计稿上的动效直接"活"在 App 里

作者 探索者dx
2026年4月8日 20:21

【Lottie】让设计稿上的动效直接"活"在 App 里

iOS三方库精读 · 第 5 期


一、一句话介绍

Lottie 是由 Airbnb 开源的跨平台动画库,它让 Adobe After Effects 导出的 JSON 动效文件在 iOS / Android / Web 上以矢量方式实时渲染,彻底消灭"设计交付 → 开发还原"之间的信息损耗。

属性 详情
⭐ Stars 25k+(GitHub)
最新稳定版 4.5.x
License Apache 2.0
支持平台 iOS 14+ / macOS 11+ / tvOS 14+ / visionOS 1+
SwiftUI 原生支持 ✅(4.0 起)

二、为什么选择它

原始痛点

在没有 Lottie 之前,设计师在 After Effects 中做好一个 3 秒的加载动画,开发要干这些事:

  1. 看着动效视频,逐帧拆解关键帧参数
  2. CAKeyframeAnimation / CAAnimationGroup 手写每一条时间曲线
  3. 颜色稍有偏差,回去对着设计稿截图像素级校准
  4. 动效改版?从头重写

这套流程不仅耗时(一个中等复杂动效需 1~3 天还原),还存在不可避免的还原偏差

核心优势

  • 零还原成本:AE 安装 bodymovin 插件,导出 JSON,开发侧 LottieView(animation: .named("xxx")) 一行接入,100% 还原
  • 矢量渲染,无损缩放:JSON 中存储的是贝塞尔曲线参数,任意分辨率下锐利清晰,不像 GIF 有马赛克
  • 极小体积:同等视觉效果的动效,Lottie JSON 通常比 GIF 小 80%~90%
  • 运行时动态换色:通过 DynamicProperty API,在不修改 JSON 文件的前提下替换任意图层的颜色、图片、文字,支持深色模式适配
  • 精细帧控制:可播放任意帧区间、设置播放速度、绑定手势进度,实现交互式动画

三、核心功能速览

基础层 新手必读:环境配置与基础播放

集成方式

Swift Package Manager(推荐)

project.yml(XcodeGen)中添加:

packages:
  lottie-ios:
    url: https://github.com/airbnb/lottie-ios.git
    minorVersion: 4.5.0

dependencies:
  - package: lottie-ios
    product: Lottie

或在 Xcode → File → Add Package Dependencies 搜索:

https://github.com/airbnb/lottie-ios

CocoaPods

pod 'lottie-ios', '~> 4.5'

准备动画 JSON

  1. 在 After Effects 中安装 bodymovin 插件
  2. 渲染导出 → 选择 JSON 格式
  3. xxx.json 拖入 Xcode 工程(确保勾选 Target Membership)
  4. 或从 LottieFiles.com 下载社区免费素材

SwiftUI 基础用法

// Swift 5.9+ / iOS 17+
import SwiftUI
import Lottie

struct ContentView: View {
    var body: some View {
        LottieView(animation: .named("loading"))  // 对应 loading.json
            .playing(.fromProgress(0, toProgress: 1, loopMode: .loop))
            .resizable()
            .scaledToFit()
            .frame(height: 200)
    }
}

UIKit 基础用法

import Lottie

let animationView = LottieAnimationView(name: "loading")
animationView.loopMode = .loop
animationView.contentMode = .scaleAspectFit
animationView.play()

view.addSubview(animationView)

进阶层 最佳实践:常用 API 与核心配置

LottieView 常用修饰符(SwiftUI)

LottieView(animation: .named("confetti"))
    // 播放控制
    .playing(.fromProgress(0, toProgress: 1, loopMode: .playOnce))
    .animationSpeed(1.5)                    // 1.5 倍速
    // 完成回调
    .animationDidFinish { completed in
        print("播放完成: \(completed)")
    }
    // 动态换色
    .valueProvider(
        ColorValueProvider(LottieColor(r: 1, g: 0.8, b: 0, a: 1)),
        for: AnimationKeypath(keypath: "**.Color")
    )

LottieAnimationView 常用 API(UIKit)

let av = LottieAnimationView(name: "success")

// 播放控制
av.play()                           // 从当前进度播放到末尾
av.pause()                          // 暂停
av.stop()                           // 停止并重置到开头

// 帧区间播放
av.play(fromFrame: 0, toFrame: 60, loopMode: .playOnce) { finished in
    // finished = true 表示正常播放完毕,false 表示被中断
}

// 手动控制进度(绑定手势)
av.currentProgress = 0.5           // 跳到 50% 位置

// 速度与循环
av.animationSpeed = 2.0
av.loopMode = .loop                 // .playOnce / .loop / .autoReverse / .repeat(3)

播放状态枚举速查

// SwiftUI LottieView.play() 参数
.playing()                                              // 无限循环
.playing(.fromProgress(0, toProgress: 1, loopMode: .loop))
.playing(.paused)                                       // 暂停
.playing(.paused(at: .progress(0.5)))                   // 暂停在 50%
.playing(.paused(at: .frame(30)))                       // 暂停在第 30 帧

深入层 源码视角:渲染架构与关键模块

渲染器选择

Lottie 4.x 提供三种渲染器,可在初始化时指定:

// 默认:Core Animation 渲染器,GPU 友好,支持大部分 AE 特性
LottieConfiguration.shared.renderingEngine = .automatic

// 强制 Core Animation(推荐生产环境)
LottieConfiguration.shared.renderingEngine = .coreAnimation

// 主线程渲染器(兼容性最佳,性能较差,4.x 已逐步弃用)
LottieConfiguration.shared.renderingEngine = .mainThread

关键模块职责

模块 职责
LottieAnimation JSON 解析,将 bodymovin 数据映射为内存模型
AnimationLayer CALayer 树构建器,将动画模型转为 Core Animation 结构
AnimationContext 时间线管理,处理帧率、时间缩放、循环逻辑
ValueProvider 动态属性注入点,DynamicProperty 系统的核心抽象

四、实战演示

场景:电商 App 加载页 + 下单成功动效,含动态换色适配品牌主色

import SwiftUI
import Lottie

// MARK: - 加载页动效(带品牌色动态换色)

struct BrandLoadingView: View {
    @Environment(\.colorScheme) var colorScheme

    /// 品牌主色(浅色/深色模式自适应)
    var brandColor: LottieColor {
        colorScheme == .dark
            ? LottieColor(r: 1.0, g: 0.85, b: 0.0, a: 1.0)   // 深色:亮黄
            : LottieColor(r: 0.9, g: 0.6, b: 0.0, a: 1.0)     // 浅色:金黄
    }

    var body: some View {
        ZStack {
            Color(.systemBackground).ignoresSafeArea()

            LottieView(animation: .named("loading_ring"))
                .playing(.fromProgress(0, toProgress: 1, loopMode: .loop))
                .animationSpeed(0.8)
                // 替换 JSON 中所有名为 "Primary Color" 图层的颜色
                .valueProvider(
                    ColorValueProvider(brandColor),
                    for: AnimationKeypath(keypath: "**.Primary Color.Color")
                )
                .resizable()
                .scaledToFit()
                .frame(width: 120, height: 120)

            Text("加载中...")
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .offset(y: 80)
        }
    }
}

// MARK: - 下单成功动效(单次播放 + 完成回调)

struct OrderSuccessView: View {
    @State private var showContent = false
    @Binding var isPresented: Bool

    var body: some View {
        VStack(spacing: 20) {
            LottieView(animation: .named("success_checkmark"))
                .playing(.fromProgress(0, toProgress: 1, loopMode: .playOnce))
                .animationDidFinish { _ in
                    // 动效播放完毕后展示订单详情
                    withAnimation(.easeIn(duration: 0.3)) { showContent = true }
                }
                .resizable()
                .scaledToFit()
                .frame(height: 160)

            if showContent {
                VStack(spacing: 8) {
                    Text("下单成功!")
                        .font(.title2).bold()
                    Text("预计 3~5 个工作日送达")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    Button("查看订单") { isPresented = false }
                        .buttonStyle(.borderedProminent)
                }
                .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
        .padding(32)
    }
}

要点说明:

  • AnimationKeypath(keypath: "**.Primary Color.Color")** 是通配符,匹配任意深度路径
  • 图层名称需与 AE 中一致,设计师导出前应统一命名规范
  • animationDidFinish 在 SwiftUI 中是 View 修饰符,回调在主线程执行

五、源码亮点

进阶层 值得借鉴的用法

链式 ValueProvider 叠加

多个 valueProvider 可链式叠加,分别控制不同图层:

LottieView(animation: .named("badge"))
    .playing()
    .valueProvider(
        ColorValueProvider(LottieColor(r: 1, g: 0.3, b: 0.3, a: 1)),
        for: AnimationKeypath(keypath: "Background.Color")
    )
    .valueProvider(
        ColorValueProvider(LottieColor(r: 1, g: 1, b: 1, a: 1)),
        for: AnimationKeypath(keypath: "Icon.**.Color")
    )
    .valueProvider(
        TextValueProvider("99+"),
        for: AnimationKeypath(keypath: "Badge.Text")
    )

手势驱动进度(类似 pull-to-refresh)

struct GestureDrivenAnimation: View {
    @GestureState private var dragOffset: CGFloat = 0
    @State private var progress: Double = 0

    var body: some View {
        LottieView(animation: .named("pull_refresh"))
            .playing(.paused(at: .progress(progress)))
            .resizable().scaledToFit().frame(height: 80)
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in state = value.translation.height }
                    .onChange(of: dragOffset) { _, new in
                        progress = Double(min(max(new / 120, 0), 1))
                    }
            )
    }
}

深入层 设计思想解析

1. Protocol-Oriented ValueProvider

ValueProvider 是一个协议,内部通过 AnyValueProvider 做类型擦除,使得颜色、数值、文字、图片等完全不同的类型可以共享一套注入 API:

// 库内抽象(简化版)
public protocol AnyValueProvider {
    var valueType: Any.Type { get }
    func hasUpdate(frame: AnimationFrameTime) -> Bool
    func value(frame: AnimationFrameTime) -> Any
}

// 使用侧无感知具体类型
animationView.setValueProvider(colorProvider, keypath: keypath)
animationView.setValueProvider(textProvider, keypath: keypath)

2. Keypath 通配符系统

类似 KVC,但专为 AE 图层树设计,支持 **(任意路径深度)和 *(单层通配):

"Button.Background.Color"      → 精确匹配
"**.Color"                     → 所有名为 Color 的属性
"Button.*.Color"               → Button 子级的任意图层的 Color

3. Core Animation 渲染器的零主线程原则

Lottie 4.x 的 Core Animation 渲染器将所有动画帧的计算预烘焙为 CAAnimation 关键帧,提交给 Render Server 后完全在主线程之外运行,即使主线程卡顿也不会影响动效流畅度——这正是它相比 mainThread 渲染器的核心优势。


六、踩坑记录

问题 1:JSON 加载返回 nil,动效不显示

原因:JSON 文件未加入 Target Membership,或文件名拼写错误(大小写敏感)。

解决:选中 JSON 文件 → Xcode 右侧 File Inspector → 勾选对应 Target;或用 URL 方式加载并捕获错误:

let animation = LottieAnimation.named("loading")  // 返回 Optional
// 或
if let url = Bundle.main.url(forResource: "loading", withExtension: "json") {
    let animation = try? LottieAnimation.loadedFrom(url: url)
}

问题 2:DynamicProperty 换色不生效

原因:Keypath 中的图层名称与 AE 中不一致(导出时 bodymovin 会对图层名做 URL 编码);或使用了 Core Animation 渲染器但该属性不支持动态修改。

解决:启用调试日志查看所有可用 Keypath:

// 打印动画内所有可被覆盖的属性路径
if let animation = LottieAnimation.named("badge") {
    let paths = animation.keypaths(for: .init(keypath: "**"))
    paths.forEach { print($0) }
}

问题 3:Swift 6 / Sendable 编译报错

原因:Lottie 4.4 以前部分类型未标注 @MainActor,在 Swift 6 严格并发检查下会报 Sendable 违规。

解决:升级到 Lottie 4.5+(已系统性修复 Swift 6 合规问题);或临时在模块级别关闭严格检查:

// 临时方案(不推荐长期保留)
import Lottie
nonisolated(unsafe) let sharedAnimation = LottieAnimation.named("loading")

问题 4:在 List / ScrollView 中大量 LottieView 导致卡顿

原因:每个 LottieView 初始化时都会同步解析 JSON 并构建 Layer 树,Cell 复用时重复创建开销大。

解决:预加载并缓存 LottieAnimation 对象,复用时只更新播放状态:

// 在 ViewModel / 缓存层提前加载
final class AnimationCache {
    static let shared = AnimationCache()
    private var cache: [String: LottieAnimation] = [:]

    func animation(named name: String) -> LottieAnimation? {
        if let cached = cache[name] { return cached }
        let anim = LottieAnimation.named(name)
        cache[name] = anim
        return anim
    }
}

// 使用
LottieView(animation: AnimationCache.shared.animation(named: "like_button"))
    .playing()

问题 5:autoReverse 循环模式在 SwiftUI 中反向播放后卡住

原因.autoReverse 在部分版本的 LottieView 中有已知 Bug,正向→反向后停在第 0 帧不再循环。

解决:用 .loop 替代,并在 animationDidFinish 中手动反转进度,或升级到最新 Lottie 版本。


问题 6:从网络 URL 加载动效时闪烁

原因:网络请求完成前 LottieView 已渲染了空状态,数据到来后重新布局导致闪烁。

解决:使用 LottieView 的 URL 加载重载 + showPlaceholder 搭配:

LottieView {
    try await LottieAnimation.loadedFrom(
        url: URL(string: "https://example.com/fireworks.json")!
    )
}
.playing()
.background { ProgressView() }  // 加载中占位

七、延伸思考

Lottie vs 主流动画方案横向对比

维度 Lottie Rive SwiftUI 原生动画 CAAnimation
文件格式 JSON (bodymovin) .riv (专有) 代码 代码
设计协作 AE 直出,零交接 Rive 编辑器 开发手写 开发手写
交互状态机 ⚠️ 有限 ✅ 内建 ⚠️ 有限
渲染性能 ✅ GPU 加速 ✅ 极佳
动态换色 ✅ DynamicProperty ✅ 输入驱动
包体积(库本身) ~4 MB ~2 MB 0 0
社区素材库 ✅ LottieFiles 海量 ⚠️ 较少
维护状态 活跃 活跃 Apple 官方 Apple 官方
学习曲线 低~中

推荐使用 Lottie 的场景

  • Splash Screen / 启动动画
  • Loading / 空状态 / 错误状态插画动效
  • 点赞、收藏、成功等一次性触发的微交互动效
  • 设计团队已有 AE 工作流,动效资产丰富
  • 需要在 LottieFiles 快速取用社区素材

不推荐使用 Lottie 的场景

  • 动效极简(仅 opacity / scale / translate)→ SwiftUI .animation() 即可
  • 需要复杂交互状态机(手势联动多个状态跳转)→ 考虑 Rive
  • 需要 3D 变换效果 → SceneKitRealityKit
  • 超大 JSON(> 2MB)在列表中大量实例化 → 需谨慎评估性能

八、参考资源


九、本期互动


小作业

在你自己的项目(或 Demo 工程)中实现一个点赞按钮

  1. 点击前显示灰色心形(未点赞状态,可用 SF Symbol 或 Lottie JSON)
  2. 点击时播放 Lottie 爱心爆炸动效(建议从 LottieFiles 搜索 "like" 下载)
  3. 播放完成后停留在点赞完成帧
  4. 再次点击恢复未点赞状态

完成标准:能在真机或模拟器上稳定运行,按钮不会出现状态错乱。欢迎在评论区贴出实现思路或截图!


思考题

Lottie 的 DynamicProperty 机制允许在运行时"注入"新的值覆盖 JSON 中预设的属性。这种控制反转(IoC)的设计思路,在 iOS 开发中还有哪些类似的应用?你会如何把这种思路用在自己的业务组件设计上?


读者征集

下一期选题投票正在进行!同时:你在使用 Lottie 时踩过哪些坑? 欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新

✅ 第1期:Alamofire · ✅ 第2期:Kingfisher · ✅ 第3期:MarkdownUI · ✅ 第4期:SnapKit · ➡️ 第5期:Lottie · ○ 第6期:待定

赛博探案集:用 Vision 框架在像素迷宫中“揪”出文字真凶

2026年4月7日 09:59

在这里插入图片描述

这里是后厂村阴影中最神秘的“全栈侦探事务所”。当你的 if-else 走到尽头,当你的 Bug 堆积如山,资深探长“老司机”就是你最后的救命稻草。本期案卷记录了一次关于“像素与文字”的离奇遭遇:实习生阿强因“人肉 OCR”识别截图密码失败,险些引发上线事故。面对这起“视力危机”,我们拒绝蛮力,祭出了 Apple 强大的 Vision 框架。这不仅是一篇关于如何用 Swift 实现 OCR(文字识别)的硬核教程,更是一场从构建“文字捕手”到破解“坐标迷宫”的技术探险。准备好了吗?泡好你的枸杞咖啡,跟随老司机的代码,一起揭开隐藏在图片像素背后的真相。

🕵️‍♂️ 引子

在一个雷雨交加的周五深夜,位于后厂村的“全栈侦探事务所”依然灯火通明。传说中,这里有一位代号为“老司机”的资深工程师,他不仅能用汇编语言写情书,还能在没有任何文档的遗留代码(Legacy Code)中自由穿梭。

就在刚刚,事务所的大门被撞开了。实习生阿强跌跌撞撞地跑进来,手里挥舞着一张模糊不清的截图,脸上写满了被产品经理折磨后的绝望。“老大!出大事了!这图片里藏着服务器的 Root 密码,但我手抄了三次都提示错误!现在上线倒计时只剩 30 分钟了!”

在这里插入图片描述

老司机缓缓放下手中早已凉透的黑咖啡,推了推鼻梁上那副防蓝光眼镜,嘴角勾起一抹神秘的微笑。“阿强,把你的‘人肉 OCR’停一停吧。在 Apple 的地盘上,我们有更优雅的武器——Vision 框架。”

在本次探案之旅中,您将学到如下内容:

  • 🕵️‍♂️ 引子
  • 🤖 第一章:不仅是扫码工具人的 Vision
  • 🛠️ 第二章:打造“文字捕手” (The Text Recognizer)
  • ⚠️ 老司机的技术批注:
  • 🎯 第三章:给真相画个圈 (Highlighting Found Text)
  • 🤝 终章:真相大白

他指尖在机械键盘上飞舞,屏幕上开始跳动起绿色的代码符文。“坐好,今晚带你见识一下,如何用机器学习的‘天眼’,让图片里的文字自己‘招供’。”

在这里插入图片描述


🤖 第一章:不仅是扫码工具人的 Vision

听好了,阿强。大多数人对 Apple Vision 框架的印象,还停留在扫个二维码或者条形码这种“小儿科”的阶段。这就好比你拿着一把激光剑去切西瓜——简直是暴殄天物!

实际上,Vision 就像是给你的 App 装上了一双“写轮眼”。它不仅能从图片中识别并定位文字(Text Detection),还能把图片里的特定区域剥离出来、在连续的视频帧里追踪物体、甚至检测你那僵硬的手势和坐姿!

在这里插入图片描述

我第一次跟 Vision 打交道的时候,是写了一个 Swift 命令行工具来移除图片背景 ✂️。那时候我就意识到,这玩意儿简直是修图师的噩梦,程序员的福音。但今天,我们要用它来做点更硬核的——文字识别

在这里插入图片描述


🛠️ 第二章:打造“文字捕手” (The Text Recognizer)

要在茫茫像素中提取文字,我们得先组装一个名为 TextRecognizer 的“审讯室”。在这个环节,我们要用到 Vision 的核心组件:RecognizeTextRequest

这就好比我们向系统提交一份“搜查令”,告诉它:“嘿,帮我把这张图里的字儿都给我找出来,而且要准(Accurate)!”

在这里插入图片描述

来看看这段代码,这可是我们的核心武器:

import Foundation
import SwiftUI
import Vision
 
struct TextRecognizer {
    var recognizedText = ""
    // 保存识别到的所有“线索”(观察结果)
    var observations: [RecognizedTextObservation] = []
 
    // 这个初始化器是异步的,因为查案需要时间,急不得
    init(imageResource: ImageResource) async {
        // 1. 创建搜查令:RecognizeTextRequest
        var request = RecognizeTextRequest()
        // 2. 将识别精度设置为 .accurate(我们要的是精准打击,不是瞎猜)
        request.recognitionLevel = .accurate
        
        // 3. 将 ImageResource 转换为 UIImage
        let image = UIImage(resource: imageResource)
        
        // 4. 重点来了!Vision 不吃 UIImage 这一套,它只认二进制数据 Data
        // 所以我们必须把图片“粉碎”成 PNG 数据
        if let imageData = image.pngData(),
           // 执行搜查任务(perform)。这一步可能会失败,所以用了 try? 来“掩耳盗铃”
           // 注意:这里是异步等待结果
           let results = try? await request.perform(on: imageData) {
            
            // 5. 将抓获的嫌疑人(观察结果)关进 observations 数组
            observations = results
        }
 
        // 6. 审讯环节:遍历每一个观察结果
        for observation in observations {
            // 获取可能性最高的那个“候选词”(topCandidates(1))
            // 就像指认现场,我们通常只信最像的那个
            let candidate = observation.topCandidates(1)
            if let observedText = candidate.first?.string {
                // 把招供的文字拼接到结果字符串里
                recognizedText += "\n\(observedText) "
            }
        }
    }
}

在这里插入图片描述

⚠️ 老司机的技术批注:

这里有个坑你要注意,阿强。RecognizeTextRequest 是个挑剔的家伙,它不能直接处理 Swift 的 ImageUIImage 对象,它需要生肉——也就是 Image Data

在这里插入图片描述

所以我们必须先把图片转成 Data 格式。另外,整个过程是 async(异步)的,毕竟机器学习这玩意儿虽然快,但也没快到能超越光速,我们得给 CPU 一点“思考”的时间。

在这里插入图片描述

接下来,我们把这个“文字捕手”集成到 SwiftUI 的视图里,让你亲眼看看效果:

import SwiftUI
 
struct TextRecognitionView: View {
    let imageResource: ImageResource
    // 状态变量,一旦侦探有了结果,界面就会刷新
    @State private var textRecognizer: TextRecognizer?
 
    var body: some View {
        List {
            // 展示嫌疑图片
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
            }
            .listRowBackground(Color.clear)
 
            // 展示审讯结果(识别出的文字)
            Section {
                // 如果 textRecognizer 还没初始化好,就先显示空字符串
                Text(textRecognizer?.recognizedText ?? "")
            } header: {
                Text("从图片中提取的证词")
            }
        }
        .navigationTitle("文字侦探")
        .task {
            // 重点:在 .task 修饰符里调用异步初始化器
            // 就像在后台偷偷干活,不阻塞主线程 UI 的渲染
            textRecognizer = await TextRecognizer(imageResource: imageResource)
        }
    }
}

这时候,阿强凑过来看着模拟器屏幕,只见原本模糊的截图下方,整整齐齐地列出了识别出来的文字。“卧槽,神了!连那个像‘1’又像‘l’的字符都分清了!”

在这里插入图片描述


🎯 第三章:给真相画个圈 (Highlighting Found Text)

“别急着庆祝,阿强。”我敲了敲桌子,“光把字认出来还不够,我们要做到按图索骥。既然 Vision 已经告诉了我们文字在哪里,我们就得在图片上把它们圈出来,就像犯罪现场的粉笔线一样。”

在这里插入图片描述

这里涉及到一个让很多新手头秃的概念:坐标系转换

Vision 返回的坐标是归一化的(Normalized),也就是说,它的 x 和 y 都在 0.0 到 1.0 之间。左下角是 (0,0),右上角是 (1,1)。但我们的屏幕图片是按像素画的,而且 UIKit/SwiftUI 的坐标原点通常在左上角。这就好比火星人给地球人指路,如果不好好翻译一下坐标,你画的框可能会飞到姥姥家去。

我们需要定义一个 Shape,专门用来画框:

import Foundation
import SwiftUI
import Vision
 
struct BoundsRect: Shape {
    // 这里存的是 Vision 给我们的“火星坐标”(归一化矩形)
    let normalizedRect: NormalizedRect
 
    func path(in rect: CGRect) -> Path {
        // 关键时刻!将归一化坐标转换为图片的实际像素坐标
        // origin: .upperLeft 是为了适配 SwiftUI 的坐标系习惯
        let imageCoordinatesRect = normalizedRect
            .toImageCoordinates(rect.size, origin: .upperLeft)
        return Path(imageCoordinatesRect)
    }
}

在这里插入图片描述

🔍 技术扩展: toImageCoordinates 这个方法虽然原文没细说,但它大概率是一个扩展方法(Extension),用于把 0~1 的小数映射到图片的 widthheight 上,并处理坐标原点的翻转。这一步至关重要,不做这一步,你的框框就会像没头苍蝇一样乱撞。

在这里插入图片描述


在这里插入图片描述

现在,我们把这个“现形符”贴到图片上:

struct TextRecognitionView: View {
    // ... 前面的代码 ...
    
    // 定义一个深红色的框,充满了悬疑感
    let boundingColor = Color(red: 0.31, green: 0.11, blue: 0.11)
 
    var body: some View {
        List {
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
                    .overlay {
                        // 如果侦探已经有了观察结果
                        if let observations = textRecognizer?.observations {
                            ForEach(observations, id: \.uuid) { observation in
                                // 遍历每一个观察点,画个圈圈诅咒...啊不,标记它
                                // observation.boundingBox 就是那个归一化的坐标
                                BoundsRect(normalizedRect: observation.boundingBox)
                                    .stroke(boundingColor, lineWidth: 3) // 描边
                            }
                        }
                    }
            }
            // ... 后面的代码 ...
        }
    }
}

在这里插入图片描述

随着代码重新编译运行,屏幕上的截图发生了变化。每一个单词周围都被套上了一个暗红色的方框,就像是被狙击手锁定的目标。

在这里插入图片描述


在这里插入图片描述

🤝 终章:真相大白

“看到了吗?”我指着屏幕上被红框圈出的一串字符,“那根本不是 Root 密码。”

阿强瞪大了眼睛,盯着那行被 Vision 精准识别出的文字:WIFI_PASSWORD: 12345678

“这……这就是隔壁会议室的 WiFi 密码?”阿强瘫软在椅子上,“我为了这个通宵了两天?”

在这里插入图片描述

我拍了拍他的肩膀,语重心长地说道:“虽然你是个笨蛋,但好在 Vision 框架足够聪明。记住,Vision 不仅仅能找字,它还能做更多事情——从视频里追踪隔壁老王的身影,到检测你是不是在偷偷抠脚(Body Pose Detection)。今天我们学的只是冰山一角,但也足够你在这个充满像素迷雾的开发世界里防身了。”

就这样,Vision 框架再次拯救了一个无知的灵魂(虽然并没有拯救他的加班费)。

在这里插入图片描述

希望宝子们喜欢这个故事,以及它背后的技术,但对于小伙伴们来说,利用 Apple 强大的 ML 能力去探索未知的旅程,才刚刚开始。

在这里插入图片描述

保持好奇,保持代码整洁,我们下个案子见。👋🙂 8-)

【SnapKit】优雅的 Swift Auto Layout DSL 库

作者 探索者dx
2026年4月7日 09:00

【SnapKit】优雅的 Swift Auto Layout DSL 库

iOS三方库精读 · 第 4 期


一、一句话介绍

SnapKit 是一个用于 iOS/macOS/tvOS 的 Swift Auto Layout DSL 库,它让繁琐的界面约束编写变得简洁优雅,是 UIKit 开发中最受欢迎的布局解决方案之一。

  • Stars: 19k+ ⭐
  • 最新版本: 5.7.0
  • License: MIT
  • 支持平台: iOS 12.0+ / macOS 10.13+ / tvOS 12.0+

二、为什么选择它

原生 NSLayoutConstraint 的痛点

在 UIKit 中,使用原生 API 创建约束通常是这样的:

// 原生方式 - 需要 4 行代码创建一个约束
let constraint = NSLayoutConstraint(
    item: view,
    attribute: .leading,
    relatedBy: .equal,
    toItem: superview,
    attribute: .leading,
    multiplier: 1.0,
    constant: 16
)
constraint.isActive = true
view.translatesAutoresizingMaskIntoConstraints = false

SnapKit 的核心优势

  1. 链式 DSL 语法:一行代码表达一个约束意图,代码可读性大幅提升
  2. 类型安全:编译期检查约束目标,避免运行时因字符串 API 导致的崩溃
  3. 自动管理:自动设置 translatesAutoresizingMaskIntoConstraints = false
  4. 动态更新:支持 updateConstraintsremakeConstraints,轻松应对动态布局
  5. 优先级支持:链式设置约束优先级,优雅处理约束冲突

三、核心功能速览

基础层 概念解释、环境配置、基础用法

环境要求与集成

SPM 集成:

// Package.swift
dependencies: [
    .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.0")
]

CocoaPods 集成:

pod 'SnapKit', '~> 5.7.0'

最简单的使用示例

import SnapKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let box = UIView()
        box.backgroundColor = .systemBlue
        view.addSubview(box)
        
        // 使用 SnapKit 创建约束
        box.snp.makeConstraints { make in
            make.center.equalToSuperview()
            make.width.height.equalTo(100)
        }
    }
}

进阶层 最佳实践、性能优化、线程安全

常用 API 一览

API 作用
makeConstraints 创建并激活约束
updateConstraints 更新已有约束(保持其他不变)
remakeConstraints 移除旧约束,创建新约束
removeConstraints 移除所有约束
prepareConstraints 预创建约束(不激活),用于条件判断

常见用法组合

// 1. 边距控制
view.snp.makeConstraints { make in
    make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16))
}

// 2. 相对布局
view1.snp.makeConstraints { make in
    make.top.left.equalToSuperview().offset(16)
    make.right.equalTo(view2.snp.left).offset(-8)
    make.height.equalTo(44)
}

// 3. 倍数与偏移
imageView.snp.makeConstraints { make in
    make.width.equalToSuperview().multipliedBy(0.5).offset(-16)
    make.height.equalTo(imageView.snp.width).multipliedBy(9.0/16.0)
}

// 4. 优先级设置
label.snp.makeConstraints { make in
    make.left.right.equalToSuperview().inset(16)
    make.top.equalToSuperview().offset(20)
    make.height.greaterThanOrEqualTo(20).priority(.required)
    make.height.lessThanOrEqualTo(100).priority(.high)
}

深入层 源码解析、设计思想、扩展定制

核心模块介绍

SnapKit 的架构设计非常精巧,主要包含以下几个核心组件:

  1. ConstraintMaker:DSL 的入口,提供链式调用接口
  2. ConstraintItem:封装约束的目标视图和属性
  3. Constraint:内部表示单个约束的数据结构
  4. ConstraintAttributes:约束属性的枚举封装

关键协议 ConstraintRelatableTarget 允许约束目标可以是:

  • 另一个视图 (UIView)
  • 数值 (CGFloat, Int)
  • 另一个约束项 (ConstraintItem)

这种设计使得 SnapKit 的 API 非常灵活,可以写出如 make.width.equalTo(100)make.width.equalTo(otherView) 这样自然的代码。


四、实战演示

下面是一个完整的登录界面布局示例,展示了 SnapKit 在实际业务场景中的应用:

import UIKit
import SnapKit

class LoginViewController: UIViewController {
    
    private let logoImageView = UIImageView()
    private let usernameTextField = UITextField()
    private let passwordTextField = UITextField()
    private let loginButton = UIButton(type: .system)
    private let forgotPasswordButton = UIButton(type: .system)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        setupConstraints()
    }
    
    private func setupViews() {
        view.backgroundColor = .systemBackground
        
        // Logo
        logoImageView.image = UIImage(systemName: "person.circle.fill")
        logoImageView.tintColor = .systemBlue
        logoImageView.contentMode = .scaleAspectFit
        view.addSubview(logoImageView)
        
        // Username
        usernameTextField.placeholder = "用户名"
        usernameTextField.borderStyle = .roundedRect
        usernameTextField.autocapitalizationType = .none
        view.addSubview(usernameTextField)
        
        // Password
        passwordTextField.placeholder = "密码"
        passwordTextField.borderStyle = .roundedRect
        passwordTextField.isSecureTextEntry = true
        view.addSubview(passwordTextField)
        
        // Login Button
        loginButton.setTitle("登录", for: .normal)
        loginButton.backgroundColor = .systemBlue
        loginButton.setTitleColor(.white, for: .normal)
        loginButton.layer.cornerRadius = 8
        view.addSubview(loginButton)
        
        // Forgot Password
        forgotPasswordButton.setTitle("忘记密码?", for: .normal)
        view.addSubview(forgotPasswordButton)
    }
    
    private func setupConstraints() {
        logoImageView.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide).offset(40)
            make.centerX.equalToSuperview()
            make.width.height.equalTo(80)
        }
        
        usernameTextField.snp.makeConstraints { make in
            make.top.equalTo(logoImageView.snp.bottom).offset(40)
            make.left.right.equalToSuperview().inset(32)
            make.height.equalTo(44)
        }
        
        passwordTextField.snp.makeConstraints { make in
            make.top.equalTo(usernameTextField.snp.bottom).offset(16)
            make.left.right.height.equalTo(usernameTextField)
        }
        
        loginButton.snp.makeConstraints { make in
            make.top.equalTo(passwordTextField.snp.bottom).offset(24)
            make.left.right.equalTo(usernameTextField)
            make.height.equalTo(48)
        }
        
        forgotPasswordButton.snp.makeConstraints { make in
            make.top.equalTo(loginButton.snp.bottom).offset(16)
            make.centerX.equalToSuperview()
        }
    }
}

关键要点:

  • 使用 view.safeAreaLayoutGuide 适配刘海屏
  • 通过 equalTo 复用约束,保持代码 DRY
  • 合理的间距和尺寸,确保界面美观

五、源码亮点

进阶层:值得借鉴的用法

链式调用的实现技巧

SnapKit 通过 @discardableResult 和返回 self 实现链式调用:

// 简化示意
struct ConstraintMaker {
    @discardableResult
    func equalTo(_ other: ConstraintRelatableTarget) -> ConstraintMaker {
        // 设置约束关系
        return self
    }
    
    @discardableResult
    func offset(_ amount: CGFloat) -> ConstraintMaker {
        // 设置偏移量
        return self
    }
}

类型安全的约束目标

使用协议和泛型确保编译期类型检查:

protocol ConstraintRelatableTarget {}
extension UIView: ConstraintRelatableTarget {}
extension CGFloat: ConstraintRelatableTarget {}
extension Int: ConstraintRelatableTarget {}

深入层:设计思想解析

Builder 模式的应用

ConstraintMaker 是 Builder 模式的典型应用:

  1. 分离构建与表示:DSL 描述意图,内部 Builder 构建实际约束
  2. 精细控制构建过程:支持 make/update/remake 不同构建策略
  3. 延迟执行:约束在闭包执行完毕后才真正创建和激活

Protocol-Oriented Programming

SnapKit 大量使用协议扩展实现功能:

// 所有视图自动获得 snp 属性
extension UIView {
    var snp: ConstraintDSL {
        return ConstraintDSL(view: self)
    }
}

这种设计让 SnapKit 可以无缝接入任何 UIView 子类,无需继承或修改原有类。


六、踩坑记录

问题 1:约束冲突导致界面异常

症状:控制台输出 "Unable to simultaneously satisfy constraints",界面布局错乱。

原因:SnapKit 自动设置 translatesAutoresizingMaskIntoConstraints = false,但如果视图在 Storyboard 或 Xib 中已有约束,会导致重复约束。

解决:确保代码创建的视图没有在其他地方添加约束,或使用 remakeConstraints 完全重制约束。

// 使用 remakeConstraints 清除旧约束
view.snp.remakeConstraints { make in
    make.edges.equalToSuperview()
}

问题 2:updateConstraints 找不到要更新的约束

症状:调用 updateConstraints 时约束没有变化,或控制台报错。

原因updateConstraints 只能更新已存在的约束。如果约束类型不同(如从 equalTo 改为 lessThanOrEqualTo),需要先用 remakeConstraints

解决:检查约束类型是否一致,不一致时使用 remakeConstraints

// ❌ 错误:尝试将 equalTo 更新为 lessThanOrEqualTo
view.snp.makeConstraints { make in
    make.width.equalTo(100)
}
view.snp.updateConstraints { make in
    make.width.lessThanOrEqualTo(200) // 不会生效
}

// ✅ 正确:使用 remakeConstraints
view.snp.remakeConstraints { make in
    make.width.lessThanOrEqualTo(200)
}

问题 3:在 UITableViewCell 中布局问题

症状:Cell 高度计算不正确,或复用时布局错乱。

原因:Cell 的 contentView 是实际容器,约束应该添加到 contentView 而非 Cell 本身。

解决:始终将子视图添加到 contentView,约束也相对于 contentView

class MyCell: UITableViewCell {
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        
        let label = UILabel()
        contentView.addSubview(label) // 注意是 contentView
        
        label.snp.makeConstraints { make in
            make.edges.equalTo(contentView).inset(16) // 相对于 contentView
        }
    }
}

问题 4:动画更新约束时闪烁

症状:使用 UIView.animate 更新 SnapKit 约束时出现闪烁或跳动。

原因:约束更新和布局刷新时机不正确。

解决:在动画块中先更新约束,然后调用 layoutIfNeeded()

// ✅ 正确的动画方式
view.snp.updateConstraints { make in
    make.width.equalTo(200)
}

UIView.animate(withDuration: 0.3) {
    self.view.layoutIfNeeded()
}

问题 5:与 SwiftUI 混用时的注意事项

症状:在 UIViewRepresentable 中使用 SnapKit 时约束不生效。

原因:SwiftUI 的生命周期和布局系统与 UIKit 不同。

解决:确保在 makeUIView 中创建约束,在 updateUIView 中使用 updateConstraintsremakeConstraints

struct MyView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        let subview = UIView()
        view.addSubview(subview)
        
        subview.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        // 更新约束
    }
}

七、延伸思考

与同类库的横向对比

维度 SnapKit PureLayout Cartography
语言 Swift Objective-C/Swift Swift
Stars 19k+ 7k+ 3k+
维护状态 ✅ 活跃 ⚠️ 维护较少 ⚠️ 已归档
API 风格 链式 DSL 方法调用 运算符重载
学习曲线
SwiftUI 支持 需桥接 需桥接 需桥接

推荐使用场景

推荐使用:

  • 纯 Swift UIKit 项目
  • 需要频繁动态更新布局的场景
  • 复杂界面,约束关系较多的页面
  • 团队已熟悉 Masonry(OC 版 SnapKit)

不推荐使用:

  • 纯 SwiftUI 项目(直接使用 SwiftUI 布局)
  • 零依赖要求的 SDK/框架开发
  • 极其简单的固定布局(原生代码量差异不大)

关于 Cartography 的说明

Cartography 是另一个流行的 Swift 布局 DSL,使用运算符重载(==>=<=)实现约束。虽然 API 非常优雅,但该项目目前已归档不再维护,不建议在新项目中使用。


八、参考资源


本期互动

小作业

尝试用 SnapKit 实现一个自适应高度的评论区 Cell,要求:

  1. 头像在左侧,固定 40x40
  2. 用户名在头像右侧,单行显示
  3. 评论内容在用户名下方,多行自适应高度
  4. 整体边距 16pt

完成后在评论区贴出你的 setupConstraints 方法代码。

思考题

如果你要自己实现一个类似的布局 DSL 库,你会如何设计 API 接口?是像 SnapKit 这样使用闭包和链式调用,还是像 Cartography 那样使用运算符重载?为什么?

读者征集

你在使用 SnapKit 时踩过哪些坑?或者有什么高级用法想分享?欢迎在评论区留言,优质回答会收录进下一期《踩坑记录》。

下一期选题投票:

  • A. RxSwift - 响应式编程库
  • B. Realm - 移动端数据库
  • C. Lottie - 动画渲染库

📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ MarkdownUI] [→ 本期 SnapKit] [○ 第5期]

苹果的罕见妥协:当高危漏洞遇上“拒升”潮 -- 肘子的 Swift 周报 #130

作者 东坡肘子
2026年4月7日 07:53

issue130.webp

苹果的罕见妥协:当高危漏洞遇上“拒升”潮

对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截至目前,已有上亿用户受到影响。

Coruna 主要针对 iOS 13 至 iOS 17 的设备,在过去几个月间,苹果已为这些系统推送了多次安全更新。DarkSword 则主要针对 iOS 18.4 至 18.7 的设备。尽管这部分设备均具备升级至 iOS 26 的硬件条件,但由于种种原因,仍有不少 iOS 18 用户选择按兵不动。

在很长一段时间里,苹果用户对于系统更新的态度都相当积极,这也是苹果生态的一大特色。但这一趋势在去年出现了变化——Liquid Glass 带来的巨大视觉冲击,让苹果用户中第一次出现了相当比例主动拒绝升级到 iOS 26 的现象。与此同时,为遵守英国《网络安全法》(Online Safety Act)的要求,苹果在 iOS 26.4 中为英国用户引入了强制年龄验证机制,由于验证条件严苛,不少成年用户甚至被系统强行锁入‘儿童模式’,进一步推动了英国用户停留在 iOS 18 或 iOS 26.3 的风潮。而拒绝安装新版本,意味着这部分用户同时放弃了后续所有安全补丁,让设备进一步暴露在潜在风险之下。

面对这一局面,苹果承受了明显的舆论压力与品牌风险。特别是在 3 月下旬,DarkSword 的完整攻击代码被泄露到了 GitHub 上,让这一国家级黑客工具瞬间平民化,直接迫使苹果必须采取紧急行动。最终,苹果罕见地为 iOS 18 单独推出了安全补丁 iOS 18.7.7,将原本仅用于 iOS 26 的防护机制回移植到旧系统。至此,苹果完成了针对本次高危漏洞的全部官方安全响应。

无论是苹果还是生态中的开发者,大多希望用户能积极跟进系统更新——既能减少多版本适配的维护负担,也能让用户尽快享受到新 API 带来的便利。但现实是,始终有一部分用户出于性能、续航、使用习惯乃至隐私等方面的考量,有意将设备锁定在某个版本。

本次事件或许会带来两个方向上的变化:苹果在压力下调整了长期坚守的更新策略,为刻意留守旧系统的用户做出了妥协;而事件本身的广泛传播,也可能促使更多用户从安全角度重新审视“能不更新就不更新”的惯性,回到积极更新的轨道。这种双向的改变,或许正是这场风波意料之外的收获。

本期内容 | 前一期内容 | 全部周报列表

近期推荐

通过 Animatable 深入 SwiftUI 动画 (Animatable in SwiftUI Explained - Complete Guide with Examples & Deep Dive)

网络上并不缺少探讨 SwiftUI 动画机制的文章,但 Sagar Unagar的这篇仍然提供了一个颇具启发性的切入点。他没有从隐式或显式动画入手,而是围绕 Animatable 协议做了一次系统梳理:从 animatableData 的作用,到 AnimatablePair 如何承载多个插值参数,再到通过自定义 VectorArithmetic 让更复杂的数据结构参与动画。文章最值得注意的一点在于其核心视角:SwiftUI 实际上是在“动画数据”,而非直接对视图进行动画处理。


在 Swift Package 中共享本地化资源 (Localization in Swift Packages)

Xcode 能为 .xcstrings 文件自动生成类型安全的 Swift 符号,但这些符号仅在资源所在的 module 内可见——一旦将本地化资源抽离为独立的 Localization 包,其他 feature 包便无法享受编译期检查的优势。Khan Winter 的解决方案相当直接:通过一个 bash 脚本解析 .xcstrings 的 JSON 结构,生成 public extension LocalizedStringResource 扩展,使所有模块都能以 .l10n.helloWorld 的形式访问翻译键。

其中一个颇具参考价值的细节是 Debug 模式下的 @dynamicMemberLookup 设计——访问不存在的键时仅记录日志而不崩溃,而在 Release 构建中仍保留完整的编译期校验。相比基于 Swift 可执行文件的方案,这种实现更加轻量,复制脚本即可使用。


Coordinator 全局导航模式 (SwiftUI Coordinator Pattern: Navigation Without NavigationLink)

尽管 SwiftUI 一直在丰富基于状态驱动的导航 API,但管理全局导航一直是 SwiftUI 中的一个“痛点”。Wesley Matlock 以一个五 Tab 的音乐收藏应用为例,展示了如何通过 Coordinator 模式将导航决策从 View 中抽离:用一个 Route 枚举统一描述所有目的地,由单一的 Coordinator 对象持有导航状态并执行跳转,View 只需声明“去哪”而无需关心“怎么去”。文章没有回避 NavigationPath 不透明、路由携带模型对象导致的 Hashable 困境等实际问题。对于大多数中等规模的 SwiftUI 应用来说,这是一个务实且易于落地的导航治理方案。


把 Hacking with Swift 的编程风格写进 AI (Teach your AI to write Swift the Hacking with Swift way)

Paul Hudson 和他的 Hacking with Swift 让很多开发者走上了 Swift 与 SwiftUI 的学习之路。在 AI 时代,Paul 不仅推出了面向苹果开发生态的各类专业 Skill,也开始尝试在与 AI 的协作中注入更具个人特质的编程风格。

在本文中,他分享了一份极具辨识度(且充满他标志性幽默)的 AGENTS.md 配置。这套规则不仅约束了 AI 的技术选型,还为 AI 注入了 Paul 的灵魂:强调先展示结果再解释原理、偏好清晰而非炫技、甚至包括在代码写得漂亮时适时地喊出一句 "Boom!"。与其说这是一份用于 AI 的“系统提示词”,不如说是在为 AI 定义一种编码哲学——某种程度上,这种方式正在将冷冰冰的“代码生成”推向带有人情味的“风格迁移”。


AI Agent 的道与术

在刚过去的 Let's Vision 2026 中,王巍(Onevcat) 发表了关于在大型开发团队中应用 AI Agent 的演讲。整场分享讨论的重点,并不是某个具体工具有多强,而是当代码实现成本被迅速压低后,团队该如何重新组织开发流程,以及工程师的价值该如何重新定位。

作为 LINE 应用开发团队的一员,Onevcat 在过去几个月中的工作重心也已明显发生变化。用他自己的话说,他正在逐步从传统意义上的 iOS 工程师,转向探索如何将 AI 应用于服务大型产品研发团队的实践者。这种角色上的变化,也让这场分享比一般的工具介绍更有说服力。

演讲围绕三个关键问题展开:如何控制上下文污染,如何把个人经验沉淀为团队可复用的 memory 与 skill,以及如何让协作模式从“人指挥多个 Agent”逐步走向更自动化的闭环。里面有不少相当接地气的实践建议,例如将 AGENTS.md 控制在精简范围内、为 Agent 提供模块定位与架构速查脚本、鼓励 Claude Code、Codex、OpenCode 等多种 harness 并存,以及通过 webhook、cron、pipeline 和自动验收机制让 Agent 真正进入团队流程。

演讲稿仓库 中不仅包含完整的 Slidev 源码,也保留了不少演讲配套材料,包括原始资料收集和与 AI 协作的完整 trace,值得一并阅读。


从零开始:用 AI 开发一个 iOS 原生 APP 完整指南

我经常会在社交媒体上看到一些零基础的“开发者”通过 AI 构建了自己的产品或服务。尽管我使用 AI 的时间也不短,但我仍然比较困惑:这条路径真的像大家描述的那样有效吗?Zachary Zhang 分享了他完全借助 AI 工具,从零构建并上架一款纯原生 iOS 应用(SwiftUI + Cloudflare 后端)的实战全过程。这篇文章最让我印象深刻的,是他严谨的“工程化管线”:在让 AI 写代码前,必须先生成结构化的 PRD 和 HTML 格式的视觉参考;而在工具选择上,他在项目“从 0 到 1”的冷启动阶段,极力推荐 Claude Code 等终端工具,以便更好地统览全局,一次性构建出合理的多文件项目架构。

或许你和我一样,对于 100% 基于 AI 的开发方式仍存疑惑。但在代码生成越来越廉价的今天,开发者的核心壁垒,正在加速向“需求精准拆解”、“系统架构把控”以及“面向报错的全局调度能力”转移。

工具

Slots:提高自定义 SwiftUI 组件设计效率的宏

将多个视图组合封装成可复用组件,是 SwiftUI 开发中的常见需求,对团队内部开发者或第三方库作者来说更是如此。但当组件包 title、icon、image、action 等多个泛型 View 插槽后,初始化器的组合数量往往会迅速膨胀。Kyle Bashour 创建的 Slots 宏,正是为了解决这类多 slot 组件的样板代码问题。

开发者只需声明组件的 slot 属性,宏便会按组合自动生成所需的初始化器,无需手写大量 init 重载。对于需要支持文本便捷写法的 slot,还可以通过 @Slot(.text) 自动获得 LocalizedStringKeyString 版本的初始化方式。 Slots 很适合用于构建设计系统中的 Card、Row、Banner、Toolbar 这类既要支持简单调用、又要保留高度定制能力的组件。


Explore SwiftUI:纯原生组件与修饰符的视觉速查图库

尽管 Apple 官方文档的质量在逐年改善,但对于以声明式和视觉驱动为主的 SwiftUI 来说,官方文档中依然缺乏足够直观的代码与 UI 效果对照,尤其是同一组件在 iOS、macOS 和 visionOS 等多平台上的表现差异。很多时候,开发者为了实现某个特定的 UI 细节,往往会去求助于复杂的第三方库或手写冗长的自定义视图,却忽略了 SwiftUI 本身可能已经提供了绝佳的原生解决方案。Florian 建立的 Explore SwiftUI 站点,正是一个为了解决这一痛点而生的“视觉速查字典”。它摒弃了任何第三方封装,纯粹以展示 Apple 官方内置组件的原生能力为核心。所有的代码示例都被剥离了无关的业务逻辑,保持极简,配以高质量的视觉预览,开发者只需“复制、粘贴、运行”即可直接验证效果。

书籍

SwiftUI Architecture: Patterns and Practices for Building Scalable Applications

这是一本 Mohammad Azam 在不久前出版的新书。它不是一本教你如何使用 VStack 或编写动画的入门书,而是一本纯粹探讨 SwiftUI 应用架构、数据流和现代工程化实践的进阶读物。

书中提供了大量直击生产环境痛点的解决方案,例如:如何构建全局的 Sheets 和 Toasts、如何利用 NavigationPath 设计解耦的多 Tab 编程式路由、以及如何使用 Property Wrapper 编写优雅的表单验证。尤为重要的是,作者并不是要向你灌输某种死板的架构模式,而是旨在帮助你建立真正的声明式心智模型。

或许有人觉得,在 AI 辅助编程盛行的时代,这类探讨架构的书籍还重要吗?借用 Mohammad Azam 在书中的观点:AI 让代码生成变得廉价,但也正因如此,系统架构的设计(边界的划分和状态所有权的明确)变得比以往任何时候都更加重要。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

苹果的罕见妥协:当高危漏洞遇上“拒升”潮 - 肘子的 Swift 周报 #130

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

对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截止目前,已有上亿用户受到影响。

Swift Actor 为什么选择可重入设计?——一道让人深思的并发题

作者 探索者dx
2026年4月4日 19:34

Swift Actor 为什么选择可重入设计?——一道让人深思的并发题

iOS 进阶必修 · Swift 并发编程系列 第 2 期


面试官问你:"Swift 的 actor 是可重入的,你觉得这个设计合理吗?"

很多人第一反应是:可重入?那不是有 bug 风险吗?为什么不做成传统锁那样不可重入?

这篇文章就来彻底说清楚这件事。


先把概念说明白

可重入(Reentrant):actor 在 await 挂起时会释放自身的"访问权",其他任务可以趁机进入 actor 执行别的方法。

不可重入(Non-reentrant):actor 一旦被某任务占用,其他任务必须排队等待,直到当前任务彻底执行完(包括所有 await)。

用一句话概括差异:

可重入:await 是"暂时离开",锁被放开
不可重入:await 是"原地等待",锁被一直握着


如果 Actor 是不可重入的,会发生什么?

死锁:跨 actor 调用的必然结局

actor ServiceA {
    let b: ServiceB
    func doWork() async {
        await b.help()   // A 持锁,等待 B
    }
}

actor ServiceB {
    let a: ServiceA
    func help() async {
        await a.check()  // B 持锁,等待 A ← 死锁!
    }
}

两个 actor 互相持锁等待对方,经典死锁。

在真实业务里这种结构比比皆是——网络层调用缓存层,缓存层调用配置层,配置层又依赖某个共享状态…只要存在环形调用,就必然死锁。

而且这种死锁极难排查:没有崩溃日志,没有报错,App 就静静地卡在那里。

Actor 内部 async 调用,自己等自己

actor Logger {
    func log(_ msg: String) async {
        await writeToFile(msg)   // 不可重入 → 自己等自己 → 死锁
    }

    func writeToFile(_ msg: String) async {
        // 磁盘写入…
    }
}

这意味着什么?actor 内部完全不能出现 await。但现实中 actor 管理的资源(网络、磁盘、数据库)几乎无一例外需要异步操作。

不可重入 + async/await 生态,在逻辑上根本无法自洽。


那可重入会带来哪些坑?

可重入把死锁风险消除了,但代价是:await 挂起期间,actor 的状态可能被其他任务修改

坑 1:状态假设在 await 前后失效

这是最经典的重入陷阱,银行转账场景:

actor BankAccount {
    var balance: Double = 1000

    func withdraw(_ amount: Double) async throws {
        // ① 检查余额:1000 >= 800,通过
        guard balance >= amount else { throw InsufficientFundsError() }

        // ② await 挂起,actor 释放访问权
        //    另一个 withdraw(800) 趁机进来,也通过了 guard
        //    它先执行,balance 变成 200
        await logTransaction(amount)

        // ③ 回来继续执行:800 > 200,但已经没有再次检查!
        balance -= amount   // balance = 200 - 800 = -600,超支!
    }
}

// 并发:两个任务同时取 800
Task { try await account.withdraw(800) }
Task { try await account.withdraw(800) }
// 最终 balance = -600,资损!

问题的根源:guard 检查到 balance -= amount 之间夹着一个 await,整个操作不是原子的。

坑 2:不变量(Invariant)在 await 期间被破坏

actor DataPipeline {
    var isProcessing = false
    var buffer: [Data] = []

    func process() async {
        guard !isProcessing else { return }
        isProcessing = true   // 设置标志

        // await 挂起,另一个 process() 调用进来
        // 它看到 isProcessing = true,直接 return
        // 看起来没问题…但如果两个调用"同时"通过 guard 呢?
        // → 取决于调度时序,存在 TOCTOU(检查-使用时差)窗口
        await doHeavyWork()

        isProcessing = false
    }
}

正确应对可重入的三个模式

模式一:await 之前完成所有关键状态变更

actor BankAccount {
    var balance: Double = 1000

    // ✅ 正确写法
    func withdraw(_ amount: Double) async throws {
        guard balance >= amount else { throw InsufficientFundsError() }
        balance -= amount          // ← 先改状态(无 await,绝对原子)
        await logTransaction(amount)  // 再异步处理(状态已一致)
    }
}

规则guard 检查通过后,立刻完成状态变更,然后才 awaitawait 之后不再依赖之前检查过的条件。


模式二:原子卫兵——同步方法作为临界区

actor SafeQueue {
    private var items: [WorkItem] = []
    private var isRunning = false

    // 同步方法:无 await,绝对原子
    private func takeNext() -> WorkItem? {
        guard let item = items.first else { return nil }
        items.removeFirst()  // 取出即删除,不会被重入影响
        return item
    }

    func drainAll() async {
        guard !isRunning else { return }
        isRunning = true
        while let item = takeNext() {
            await item.execute()   // await 时 item 已从队列移除,安全
        }
        isRunning = false
    }
}

思路:把"检查 + 修改"合并进一个不含 await 的同步方法,让它成为原子操作。


模式三:状态机保护并发入口

actor TaskScheduler {
    private enum Phase { case idle, running, draining }
    private var phase: Phase = .idle

    func schedule(_ task: Task<Void, Never>) async {
        guard phase == .idle else { return }
        phase = .running           // ← await 之前切状态,拿到"令牌"
        await task.value           // 其他调用看到 .running,直接 return
        phase = .idle
    }
}

用状态机枚举而非 Bool 标志,让每种状态的含义更清晰,也更难被误用。


设计对比:可重入 vs 不可重入

维度 不可重入(传统锁语义) 可重入(Swift actor)
跨 actor 调用 ❌ 极易死锁 ✅ 安全
actor 内部 await ❌ 自己等自己,死锁 ✅ 正常工作
状态一致性 await 前后一致 ⚠️ 开发者自行保证
死锁风险 ❌ 高,且难排查 ✅ 无
正确性复杂度 低(锁语义直觉) 中(需理解挂起语义)
与 async/await 生态兼容性 ❌ 根本无法自洽 ✅ 天然融合

Apple 为什么必须选可重入

这是一道"两害取其轻"的工程决策题:

  • 死锁:不可预测,运行时无日志,难以复现,线上问题几乎无法定位
  • 重入陷阱:有规律可循(await 前完成状态变更),编码期可发现,有明确的防御模式

Apple 把不确定性更高、危害更大的风险消除了,把相对可控的复杂性留给开发者。

从语言设计角度看,这也与 Swift 的一贯哲学吻合:编译器负责能静态验证的安全,开发者负责剩下的语义正确性

Swift 6 的严格并发检查(-strict-concurrency=complete)正在把越来越多的重入问题提升为编译器警告,方向是对的。


实际项目中的选择建议

优先用可重入,配合以下纪律:

  1. 黄金法则await 之前必须完成所有关键状态变更,await 之后不再信任之前读取的条件
  2. 原子临界区:把"检查 + 修改"封装进无 await 的同步方法
  3. 状态机优先:用枚举状态机而非 Bool 标志管理并发入口
  4. 最小化 await 范围:需要保护的临界操作不要夹带 await
// 完整示例:安全的资源管理 actor
actor ResourceManager {
    private enum State { case idle, acquired, releasing }
    private var state: State = .idle
    private var resource: Resource?

    // ✅ 获取资源:先拿到"凭证"再 await
    func acquire() async throws -> Resource {
        guard state == .idle else { throw ResourceError.busy }
        state = .acquired            // 改状态在 await 之前
        let res = try await fetchResource()
        resource = res
        return res
    }

    // ✅ 释放资源:先清理状态再 await
    func release() async {
        guard state == .acquired else { return }
        let res = resource
        resource = nil               // 先清空
        state = .releasing
        await cleanupResource(res)
        state = .idle
    }
}

总结

问题 答案
可重入设计合理吗? 合理,是工程必要性决定的,不是妥协
不可重入的最大问题? 跨 actor 死锁 + 内部 async 调用死锁,且难排查
可重入最大的坑? await 前后状态假设失效,经典场景是 guard 通过后 await,回来状态已变
实际项目怎么用? 拥抱可重入,用"await 前完成状态变更"作为硬性编码纪律

可重入的坑有规律可循,死锁没有。选可重入,然后学会驾驭它。


延伸思考

Kotlin 协程的 Mutex 提供了不可重入的互斥锁,但它是手动使用的工具,而不是语言默认行为——与 Swift actor 的定位完全不同。Java 的 synchronized 则是可重入的(同一线程可以重复进入),与 Swift actor 的可重入语义有些类似,但实现机制不同。

Swift actor 的可重入设计,本质上是结构化并发思想的延伸:任务在 await 时让出资源,让其他任务有机会推进,整个系统的吞吐量更高,而不是让一个任务独占 actor 直到它的所有 await 全部完成。


如果你在项目里遇到过 actor 重入导致的 bug,欢迎评论区分享——是什么场景、如何发现、怎么修复的?优质案例会收录进下一期。


📅 本系列持续更新 ✅ 第 1 期:Swift Concurrency 基础精讲 · ➡️ 第 2 期:Actor 可重入设计深析(本期)· ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定

【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲

作者 探索者dx
2026年4月4日 19:17

【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲

iOS 进阶必修 · Swift 并发编程系列 第 1 期


一、一句话介绍

Swift Concurrency 是 Apple 在 Swift 5.5(iOS 15+)正式引入的原生并发框架,它让异步代码的编写、错误处理、线程安全变得声明式、结构化、且编译器可静态验证。

属性 信息
引入版本 Swift 5.5 / Xcode 13
运行时最低要求 iOS 13+(back-deploy)/ iOS 15+ 全功能
核心特性 async/await · Task · Actor · AsyncStream
与 Combine 关系 互补共存,AsyncSequence 可与 Combine 互转
官方文档 Swift Concurrency

二、为什么选择它

原生异步方案的痛点

在 Swift Concurrency 出现之前,iOS 异步编程长期面临这些问题:

旧方案 Swift Concurrency
回调嵌套(Callback Hell),可读性极差 async/await 线性写法,与同步代码几乎一致
DispatchQueue + 锁保护共享状态,极易出错 actor 编译器静态保证线程安全
DispatchGroup 聚合多个并行任务,样板代码多 async let / withTaskGroup 声明式并行
任务取消需要自行维护 flag,容易遗漏 结构化取消,父取消子自动跟随
线程切换 DispatchQueue.main.async {} 到处散落 @MainActor 注解,编译器强制保证主线程
Combine 学习曲线陡,操作符多 AsyncStream 原生支持,与 for await 天然融合

核心优势:

  • 可读性:async/await 让异步代码读起来像同步,减少 80% 认知负担
  • 安全性:actor 让数据竞争成为编译错误而非运行时崩溃
  • 结构化:父子任务形成树形结构,取消/错误自动传播
  • 可组合:AsyncSequence 统一了事件流、定时器、网络流的消费模型
  • 零依赖:语言内置,无需引入任何第三方库

三、核心功能速览

基础层(新手必读)

无需配置,开箱即用

Swift Concurrency 是语言特性,直接在 Xcode 13+ 的任意 Swift 文件中使用:

// Swift 5.5+ · iOS 13+ (back-deploy) / iOS 15+ (全功能)
import Foundation  // 仅需标准库

async/await:异步函数的声明与调用

// ✅ 声明异步函数:加 async 关键字
func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

// ✅ 调用:必须在 async 上下文中,用 await 挂起
Task {
    do {
        let user = try await fetchUser(id: 1)
        print(user.name)
    } catch {
        print("加载失败:\(error)")
    }
}

await挂起点而非阻塞点:挂起时线程被释放,恢复后可能在不同线程继续执行。这是 Swift Concurrency 高效的根本原因。


深入理解:await 挂起 vs 传统回调的线程行为

这是理解 Swift Concurrency 为何高效的关键,也是很多人初学时最容易混淆的地方。

传统 GCD 回调的线程行为

// 传统方式:调用线程不阻塞,但"上下文"从此丢失
func fetchData(completion: @escaping (Data) -> Void) {
    URLSession.shared.dataTask(with: url) { data, _, _ in
        // 回调所在线程:URLSession 内部线程(不确定,通常是子线程)
        completion(data!)
    }.resume()
}

// 调用方
fetchData { data in
    // ⚠️ 线程已改变,需要手动切回主线程
    DispatchQueue.main.async {
        self.label.text = "done"   // 上下文全靠开发者自己管理
    }
}
// 调用方线程立即继续往下跑(不等待,也不挂起)
print("这行代码立即执行,不等 fetchData 完成")

async/await 的线程行为

// async/await:await 是挂起点,调用线程被释放回线程池
func fetchData() async -> Data { ... }

func loadPage() async {
    print("当前线程:\(Thread.current)")  // 线程 A
    
    let data = await fetchData()          // ← 挂起点:线程 A 被释放,可去执行其他任务
    
    // 恢复后:可能是不同线程,但 Actor 上下文(如 @MainActor)被自动还原
    print("恢复线程:\(Thread.current)")  // 可能是线程 B,但上下文依然正确
    updateUI(data)                        // 如果在 @MainActor 中,编译器保证这里一定在主线程
}

两者最本质的区别:线程是否被"占用"

维度 传统 GCD 回调 async/await
调用方线程 继续运行(不等待,不挂起) 挂起,线程释放回线程池
等待期间 调用线程去干别的事(但无连接) 线程被其他任务复用
回调/恢复线程 由 GCD 队列决定,不确定 由调度器决定,保留 Actor 上下文
代码连续性 回调嵌套,逻辑分散 线性代码,逻辑连续
线程安全 手动管理,容易出错 编译器 + Actor 静态保证

⚠️ 注意:传统回调的调用方线程确实不阻塞,这点和 await 一样。但两者的关键区别在于:传统回调是"断开连接"继续跑,而 await 是"挂起等待"并能恢复连续执行上下文

为什么 async/await 不会导致线程爆炸

传统 GCD 的一个隐患:当你用 semaphore.wait()DispatchGroup.wait() 真正"等"结果时,线程被阻塞(占着资源啥也不干)。系统发现线程不够用时会持续创建新线程,最终导致线程爆炸。

// ❌ 危险:阻塞线程(传统方式的隐患)
let sema = DispatchSemaphore(value: 0)
fetchData { data in sema.signal() }
sema.wait()  // 线程在此阻塞,占着系统资源却无法被复用
             // 并发请求多时,可能导致线程数量爆炸

Swift Concurrency 的协作式线程池解决了这个问题:

// ✅ 协作式挂起:线程释放回池子,完全不阻塞
let data = await fetchData()
// 线程池始终维持在约等于 CPU 核数的小规模,高效复用

Swift Concurrency 的线程池设计

传统 GCD 线程池(可能爆炸)          Swift Concurrency 协作式线程池
┌──────────────────────────┐         ┌──────────────────────────┐
│ 线程1(等待网络,阻塞)     │         │ 线程1(执行 Task A)        │
│ 线程2(等待数据库,阻塞)   │         │ 线程2(执行 Task B)        │
│ 线程3(等待文件,阻塞)     │  vs     │ 线程3(执行 Task C)        │
│ 线程4(新建中...)         │         │ ← 线程数 ≈ CPU 核数         │
│ 线程N(继续新建...)  💥   │         │ Task 挂起时释放线程,不占用  │
└──────────────────────────┘         └──────────────────────────┘

一句话总结:

  • 传统回调:调用线程不等待,但回调上下文断开,线程安全靠自己保证,用 wait() 等待时会阻塞线程
  • async/await:挂起点释放线程,调度器恢复时还原上下文,Actor 保证线程安全,系统始终保持小规模线程池

这就是为什么同样是"异步",Swift Concurrency 在高并发场景下比传统 GCD 回调效率更高、更安全。


SwiftUI 中使用 .task 修饰符(推荐)

struct UserView: View {
    @State private var user: User?

    var body: some View {
        Text(user?.name ?? "加载中...")
            .task {
                // 视图消失时任务自动取消,无需手动管理
                user = try? await fetchUser(id: 1)
            }
    }
}

进阶层(最佳实践)

async let:并行执行多个任务

// ❌ 顺序执行:总耗时 = 500ms + 300ms + 200ms = 1000ms
let user    = try await fetchUser(id: 1)
let orders  = try await fetchOrders(uid: 1)
let profile = try await fetchProfile(uid: 1)

// ✅ async let 并行:总耗时 = max(500ms, 300ms, 200ms) = 500ms
async let user    = fetchUser(id: 1)
async let orders  = fetchOrders(uid: 1)
async let profile = fetchProfile(uid: 1)
let (u, o, p) = try await (user, orders, profile)
// 三行代码实现并行,耗时减半

withTaskGroup:动态数量的并行任务

// 并行下载数量不固定的图片列表
func downloadImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask { try await fetchImage(from: url) }
        }
        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Task:非结构化任务与取消

// 创建任务(继承当前 actor 上下文)
let task = Task(priority: .userInitiated) {
    for i in 1...100 {
        try Task.checkCancellation()   // 取消时自动 throw CancellationError
        await processItem(i)
    }
}

// 取消(协作式,不会强制停止)
task.cancel()

// Task.detached:不继承 actor 上下文,完全独立
Task.detached(priority: .background) {
    let result = await heavyComputation()
    await MainActor.run { updateUI(result) }
}

Continuation:桥接旧式回调 API

// 将旧式 completion block API 包装为 async 函数
func requestLocation() async throws -> CLLocation {
    try await withCheckedThrowingContinuation { continuation in
        locationManager.requestLocation { location, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let location {
                continuation.resume(returning: location)
            }
        }
    }
}
// ⚠️ resume 只能调用一次,多次调用会 crash

深入层(源码视角)

核心模块职责划分

特性 职责 适用场景
async/await 异步函数声明与挂起 任何异步 IO 操作
async let 静态数量并行任务 首页多接口聚合
Task 非结构化任务单元 按钮触发的独立操作
withTaskGroup 动态数量结构化并发 批量下载/处理
actor 数据竞争保护 共享状态管理
@MainActor 主线程强制约束 UI 更新
Sendable 跨边界类型安全 actor 参数/返回值
AsyncStream 自定义异步序列 事件流/实时数据

四、实战演示

场景:AI 流式问答 + 打字机渲染

这是目前最热门的应用场景之一,完整演示了 AsyncStream + Task + @MainActor 的协同工作。

// Swift 5.5+

// MARK: - 1. 流式 AI 服务层(可替换为真实 SSE 接口)

enum AIStreamService {
    /// Mock:逐字符推送,实际项目替换为 URLSession.bytes 读取 SSE
    static func stream(prompt: String) -> AsyncStream<String> {
        let response = "Swift Concurrency 让并发编程如行云流水," +
            "async/await 消除回调地狱,Actor 守护数据安全," +
            "AsyncStream 带来流式体验。🚀"

        return AsyncStream { continuation in
            Task {
                for char in response {
                    guard !Task.isCancelled else {
                        continuation.finish()
                        return
                    }
                    continuation.yield(String(char))
                    try? await Task.sleep(nanoseconds: 60_000_000) // 60ms/字
                }
                continuation.finish()
            }
        }
    }

    /// 接入真实 SSE 接口(生产参考)
    static func streamFromSSE(url: URL) -> AsyncStream<String> {
        AsyncStream { continuation in
            Task {
                let (bytes, _) = try await URLSession.shared.bytes(from: url)
                for try await line in bytes.lines {
                    guard line.hasPrefix("data: "),
                          let data = line.dropFirst(6).data(using: .utf8),
                          let json = try? JSONDecoder().decode(TokenResponse.self, from: data)
                    else { continue }
                    continuation.yield(json.token)
                }
                continuation.finish()
            }
        }
    }
}

// MARK: - 2. SwiftUI 打字机视图

struct TypewriterView: View {
    @State private var prompt = "Swift 并发编程"
    @State private var output = ""
    @State private var isStreaming = false
    @State private var streamTask: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            TextField("输入问题…", text: $prompt)
                .textFieldStyle(.roundedBorder)

            // 打字机光标效果
            Text(output + (isStreaming ? "▌" : ""))
                .font(.body)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
                .background(Color(.secondarySystemBackground))
                .cornerRadius(10)
                .animation(.none, value: output)

            HStack(spacing: 12) {
                Button(isStreaming ? "生成中…" : "开始生成") {
                    startStream()
                }
                .buttonStyle(.borderedProminent)
                .disabled(isStreaming)

                Button("停止") {
                    streamTask?.cancel()
                    isStreaming = false
                }
                .buttonStyle(.bordered)
                .tint(.red)
                .disabled(!isStreaming)
            }
        }
        .padding()
        .onDisappear { streamTask?.cancel() } // ✅ 离开页面时取消
    }

    private func startStream() {
        streamTask?.cancel()
        output = ""
        isStreaming = true
        streamTask = Task {
            for await token in AIStreamService.stream(prompt: prompt) {
                output += token  // SwiftUI 自动感知变化实时渲染
            }
            isStreaming = false
        }
    }
}

// MARK: - 3. UIKit 打字机控制器(@MainActor 保证 UI 安全)

@MainActor
class TypewriterViewController: UIViewController {
    private let textView = UITextView()
    private var streamTask: Task<Void, Never>?

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        streamTask?.cancel()  // ✅ 离开页面时取消,防止内存泄漏
    }

    @objc func startStream() {
        streamTask?.cancel()
        textView.text = ""
        streamTask = Task {
            for await token in AIStreamService.stream(prompt: "UIKit") {
                guard !Task.isCancelled else { break }
                textView.text += token
                // 自动滚到底部
                let range = NSRange(location: textView.text.count - 1, length: 1)
                textView.scrollRangeToVisible(range)
            }
        }
    }
}

这个示例完整演示了:AsyncStream 的创建与消费、Task 的取消管理、@MainActor 的 UI 安全保证、SwiftUI 和 UIKit 的两套接入方式。


五、源码亮点

进阶层:值得借鉴的设计

Actor 并发计数器(告别 DispatchQueue + 锁)

// ❌ 传统写法:容易因忘记加锁而出现数据竞争
class Counter {
    var value = 0
    let queue = DispatchQueue(label: "counter.queue")
    func increment() { queue.sync { value += 1 } }
}

// ✅ actor:编译器静态保证,忘加 await 直接报错
actor SafeCounter {
    private(set) var value = 0
    func increment() { value += 1 }
}

// 并发使用:1000 个任务同时递增,结果一定是 1000
let counter = SafeCounter()
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<1000 {
        group.addTask { await counter.increment() }
    }
}
print(await counter.value)  // 1000,绝无数据竞争

AsyncStream 资源安全回收

// 定时器流:onTermination 防止 timer 泄漏
func timerStream(interval: Double) -> AsyncStream<Int> {
    AsyncStream { continuation in
        var tick = 0
        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
            tick += 1
            continuation.yield(tick)
        }
        // ✅ 流取消/结束时自动调用,清理外部资源
        continuation.onTermination = { _ in
            timer.invalidate()
        }
    }
}

深入层:设计思想解析

结构化并发的思想来源

结构化并发的核心理念来自结构化编程的类比:就像 if/for/while 让控制流有明确的进入和退出点,结构化并发让并发任务的生命周期也有明确的边界

// 传统 GCD:任务生命周期不受控
func fetchData() {
    DispatchQueue.global().async {
        // 这个任务完全脱离 fetchData 的控制
        // fetchData 返回后,任务仍在跑
    }
}

// 结构化并发:任务生命周期受作用域约束
func fetchData() async {
    async let result = networkCall()   // 任务在这里创建
    let data = await result            // 函数返回前,任务必须完成
}                                      // ← 离开作用域,所有子任务保证已结束

三大核心约束

约束 含义
父子关系 子任务归属于父任务,父任务取消时子任务自动取消
生命周期包含 父任务不能在子任务完成之前结束
错误传播 子任务的错误必须传递给父任务处理

非结构化 vs 结构化对比

// ❌ 非结构化(Task.detached)—— 孤儿任务,生命周期不受控
Task.detached {
    await riskyOperation()  // 即使调用方已取消,这里仍然在跑
}

// ✅ 结构化(async let / TaskGroup)—— 任务有明确的父子关系
await withTaskGroup(of: String.self) { group in
    group.addTask { await fetch("A") }
    group.addTask { await fetch("B") }
    // 离开 withTaskGroup 之前,所有子任务保证结束
}

这套思想由 Nathaniel J. Smith 的 Notes on structured concurrency 奠基,Swift 从 5.5 开始通过 async letTaskGroupactor 全面落地。与 Kotlin 协程的 StructuredConcurrency 一脉相承,但 Swift 通过编译器强制实施,更难写错。


结构化并发:任务树模型

Swift Concurrency 引入了"结构化并发"概念——任务形成父子树形结构:

父任务(Task)
 ├── 子任务 A(async let)
 ├── 子任务 B(async let)
 └── TaskGroup
      ├── 子任务 C(addTask)
      └── 子任务 D(addTask)

关键特性:

  • 父取消 → 子自动取消:无需手动遍历
  • 子抛出错误 → 父捕获:错误自动冒泡
  • 父作用域结束 → 等待所有子完成:无任务泄漏

这与 Kotlin 协程的 StructuredConcurrency 思想一脉相承,但 Swift 通过编译器强制实施,更难写错。


Actor 的可重入设计

Actor 内部通过隐式串行队列保证数据安全,但它是可重入的:

actor BankAccount {
    var balance: Double = 1000

    // ⚠️ 重入陷阱:await 挂起期间,其他任务可进入 actor 修改 balance
    func withdrawUnsafe(amount: Double) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        await logTransaction(amount)  // 挂起!balance 可能被别的 withdraw 修改
        balance -= amount             // 此时 balance 可能已不足!
    }

    // ✅ 正确:先修改状态再 await
    func withdrawSafe(amount: Double) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        balance -= amount          // 先扣,在 await 之前完成关键状态变更
        await logTransaction(amount)
    }
}

规则:actor 中,await 之前必须完成所有关键状态变更。


六、踩坑记录

问题 1:Continuation.resume 调用了多次导致 crash

  • 原因:某些旧 SDK 的 completion block 可能被调用多次(如进度回调)
  • 解决:用 bool flag 保护,确保 resume 只执行一次
func safeContinuation<T>(_ block: (@escaping (T) -> Void) -> Void) async -> T {
    await withCheckedContinuation { continuation in
        var resumed = false
        block { value in
            guard !resumed else { return }
            resumed = true
            continuation.resume(returning: value)
        }
    }
}

问题 2:Task.detached 中直接更新 UI 导致崩溃

  • 原因Task.detached 不继承当前 actor 上下文,不在主线程
  • 解决:显式切回主线程
// ❌ 危险
Task.detached { self.label.text = "done" }

// ✅ 正确
Task.detached {
    let result = await process()
    await MainActor.run { self.label.text = result }
}

问题 3:视图消失后 Task 仍在运行,导致内存泄漏

  • 原因:Task 生命周期独立于视图,视图销毁后任务仍持有 self
  • 解决:SwiftUI 用 .task {} 修饰符(自动管理),UIKit 在 viewWillDisappear 中 cancel
// UIKit
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    loadTask?.cancel()
}

问题 4:Actor 重入性导致余额多扣

  • 原因:await 挂起期间其他任务进入 actor 修改共享状态
  • 解决:遵守"先修改状态,再 await"原则(见第五章深入层)

问题 5:AsyncStream 中 timer / 监听器未释放,持续运行

  • 原因:忘记实现 continuation.onTermination
  • 解决:每个 AsyncStream 必须实现 onTermination,清理外部资源
continuation.onTermination = { reason in
    timer.invalidate()
    notificationCenter.removeObserver(observer)
}

问题 6:withTaskGroup 中子任务抛出错误没有被感知

  • 原因:使用了 withTaskGroup(不抛出版),错误被吞掉
  • 解决:需要错误传播时,使用 withThrowingTaskGroup
// ✅ 任意子任务失败,整个 group 取消并抛出错误
try await withThrowingTaskGroup(of: Data.self) { group in
    for url in urls { group.addTask { try await fetch(url) } }
    for try await data in group { process(data) }
}

问题 7:在 iOS 13 / 14 上使用 actor 报链接错误

  • 原因:actor 运行时需要 iOS 15+ 的系统库支持;Xcode back-deploy 支持 async/await 但不完全支持 actor
  • 解决:确认最低 Deployment Target,或对 actor 用 @available(iOS 15, *) 包裹

七、延伸思考

与同类方案横向对比

方案 简介 学习曲线 线程安全 取消支持 适用场景
Swift Concurrency Swift 原生,语言级别支持 编译器保证(actor) 结构化取消 新项目首选
GCD + DispatchQueue 苹果传统并发方案 手动加锁,容易出错 无原生支持 老项目维护
Combine 响应式框架,操作符丰富 需手动 receive(on:) AnyCancellable 复杂数据流转换
PromiseKit 基于 Promise 的链式回调 无特殊支持 有限支持 OC/早期 Swift 项目
RxSwift 响应式编程全家桶 很高 需配置 scheduler Disposable 重度响应式架构

推荐使用场景

  • ✅ iOS 13+ 新项目,全面拥抱 Swift Concurrency
  • ✅ 需要并行聚合多个接口的页面(async let / TaskGroup)
  • ✅ 共享状态管理,替代 DispatchQueue + 锁(actor)
  • ✅ 实时数据流、WebSocket、AI 流式响应(AsyncStream)
  • ✅ 需要优雅取消的长时任务(下载、文件处理)

不推荐场景

  • ❌ 项目最低支持 iOS 12 及以下,部分特性无法使用
  • ❌ 已有大量 Combine 代码,短期内迁移成本过高
  • ❌ 需要复杂响应式操作符链(merge、combineLatest 等),Combine 更合适

迁移策略建议

  1. 新功能优先用 async/await,不强制改旧代码
  2. 旧接口Continuation 包装,对调用方透明
  3. Combine Pipeline 可通过 .values 属性转为 AsyncSequence 互通
  4. Swift 6 开启严格并发检查(-strict-concurrency=complete),提前消灭隐患

八、参考资源


九、本期互动

小作业

基于本文的 AsyncStream 示例,实现一个实时心跳检测器

  1. AsyncStream 每隔 1 秒 yield 一次当前时间戳
  2. 连续 5 次 yield 后,主动调用 continuation.finish() 结束流
  3. 在 SwiftUI 中用 .task {} 消费流,将每次时间戳展示在列表中
  4. 点击「停止」按钮时,通过 task.cancel() 终止流,并验证 onTermination 被调用

完成后在评论区贴出你的 AsyncStream 创建代码和 onTermination 实现,说明你是如何验证资源正确释放的。


思考题

Swift Concurrency 的 actor 选择了可重入设计——即 await 挂起时允许其他任务进入 actor 执行。你认为这个设计决策是合理的吗?

如果设计成不可重入(像传统锁一样),会带来哪些问题?可重入又会引发哪些坑?在你的实际项目中,你更希望哪种行为?


读者征集

下一期预计深入讲解 Swift Concurrency 进阶:自定义 AsyncSequence、结构化并发原理、Swift 6 严格并发检查实战

如果你在项目中迁移到 Swift Concurrency 时踩过坑(特别是 actor 重入、Sendable 编译报错、Task 泄漏等),欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!


📅 本系列持续更新 ➡️ 第 1 期:Swift Concurrency 基础精讲(本期)· ○ 第 2 期:Actor 可重入设计深析 · ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定

SwiftUI 如何使用 UIKit 组件

2026年3月31日 10:06

先理解问题是什么

现实情况是:SwiftUI 原生组件不够用。很多组件SwiftUI 自己没有直接提供,但 UIKit 里有。

那怎么办?苹果提供了一个"桥接协议":UIViewRepresentable


UIViewRepresentable 是什么

它是一个协议(Protocol) ,作用是:

把一个 UIKit 的 UIView包装成 SwiftUI 能认识的 View

你可以把它理解成一个翻译官,SwiftUI 和 UIKit 说的不是同一种语言,UIViewRepresentable 负责在中间翻译。

SwiftUI 世界          翻译官                    UIKit 世界
─────────────    ──────────────────────    ──────────────────
  some View  ←→  UIViewRepresentable  ←→   UIView(任意)

它要求你实现两个方法

protocol UIViewRepresentable {
    // 方法一:创建 UIKit 视图(只调用一次)
    func makeUIView(context: Context) -> 某种UIView
    
    // 方法二:更新 UIKit 视图(状态变化时调用)
    func updateUIView(_ uiView: 某种UIView, context: Context)
}

就这两个,不多(有没有想到什么,OC的NSProxy 是不是也是实现两个方法,虽然八杆子打不着,但是突然想到了)。

  • makeUIView → 负责初始化,相当于 viewDidLoad,只跑一次
  • updateUIView → 负责同步状态,SwiftUI 的数据变了,你要在这里手动更新 UIKit 视图

我写了一个BlurView,早期SwiftUI background不支持毛玻璃效果

struct BlurView: UIViewRepresentable {
    let style: UIBlurEffect.Style   // ← 从 SwiftUI 传进来的参数
    
    // 第一步:创建真实的 UIKit 视图
    func makeUIView(context: Context) -> some UIView {
        let view = UIView(frame: .zero)
        view.backgroundColor = .clear
        
        // 这才是核心:UIKit 的毛玻璃视图
        let blurEffect = UIBlurEffect(style: style)
        let blurView = UIVisualEffectView(effect: blurEffect)
        
        // 用 AutoLayout 让它撑满父视图
        blurView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(blurView)
        NSLayoutConstraint.activate([
            blurView.heightAnchor.constraint(equalTo: view.heightAnchor),
            blurView.widthAnchor.constraint(equalTo: view.widthAnchor)
        ])
        
        return view  // ← 把这个 UIKit 视图交给 SwiftUI 管理
    }
    
    // 第二步:状态更新时同步(这里暂时不需要做任何事)
    func updateUIView(_ uiView: UIViewType, context: Context) {
        // 如果 style 会动态变化,就在这里更新
    }
}

重点理解makeUIView 返回的那个 UIView,之后就由 SwiftUI 的布局系统接管了。你不需要手动设置 frame,SwiftUI 会帮你处理尺寸。


然后 View Extension 做了什么

extension View {
    func blurBackground(style: UIBlurEffect.Style) -> some View {
        ZStack {
            BlurView(style: style)  // ← UIKit 毛玻璃,铺在底层
            self                    // ← 原来的 SwiftUI 视图,叠在上层
        }
        //两个方法都行
        //self.background(BlurView(style: style))
    }
}

BlurView 在这里和任何 SwiftUI 原生 View 完全没有区别,可以直接放进 ZStack。这就是 UIViewRepresentable 的意义:让 UIKit 视图假装自己是 SwiftUI 视图


整体调用链是这样的

.blurBackground(style: .systemMaterial)
        ↓
    ZStack 叠加
   ┌────────────┐
   │  BlurView  │ ← UIViewRepresentable 在这里翻译
   │   (UIKit)  │   makeUIView() 被 SwiftUI 自动调用
   └────────────┘
        ↑
   self(原 SwiftUI 视图)叠在上面

什么时候用 UIViewRepresentable(有些SwfitUI 现在自己已经有了)

场景 推荐方案
毛玻璃、特效 UIVisualEffectViewUIViewRepresentable
地图 MKMapView → 或直接用 SwiftUI 的 Map
网页 WKWebViewUIViewRepresentable
富文本编辑 UITextViewUIViewRepresentable
相机预览 AVCaptureVideoPreviewLayerUIViewRepresentable
SwiftUI 能搞定的 直接用 SwiftUI,别绕弯子

还有一个兄弟协议:UIViewControllerRepresentable

如果你要包装的不是 UIView,而是整个 UIViewController(比如系统的图片选择器、分享弹窗),用这个:

struct ImagePickerView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIImagePickerController {
        return UIImagePickerController()
    }
    
    func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
        // 同上,同步状态用
    }
}

逻辑完全一样,只是把 UIView 换成了 UIViewController


总结

UIViewRepresentable 本质上就是:

实现两个方法(创建更新),让 SwiftUI 知道怎么驾驭一个 UIKit 视图

它解决的核心问题是:SwiftUI 和 UIKit 生命周期不同,这个协议负责在两套系统之间搭桥

BlurView 是一个非常标准的使用案例——SwiftUI 没有,UIKit 有,包一下,用上。

❌
❌