普通视图

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

稳定 > 新功能 -- 肘子的 Swift 周报 #138

作者 东坡肘子
2026年6月2日 07:56

issue138.webp

稳定 > 新功能

无论是 SwiftUI 还是 SwiftData,这些苹果寄予厚望的基础框架,在推出时都描绘了充满光明的未来,但实际走势似乎都和最初的设定不太一样。当我越深入了解这些框架,就越对它们精妙的架构设计所折服,同时也对那些不尽如人意的实现感到无语。眼看着这些设计的光环逐渐褪去,心中不免唏嘘。

传闻苹果在今年即将发布的全新操作系统(包括 iOS 27 和 macOS 27)中,将采取类似于当年 Mac OS X Snow Leopard 时代的调整策略——将重心放在系统稳定性、性能优化、清理老旧代码和修复 Bug 上,而不是引入颠覆性的视觉设计或繁多的底层新功能。如果真能如此,那实在是令人欣慰。回看两年多前的 第六期周报,当时也传出过苹果要集中修复彼时存在的缺陷并提高软件性能,但至少从过去两年的实际使用体验来看,这个目标似乎未能达成。

距离 WWDC 26 已经不足十天了。相较于更多、更炫的新功能,我今年更期待苹果能给开发者和消费者带来一次脚踏实地、更加稳定的体验。

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

原创

用自定义 Layout 化解 SwiftUI List 的行高与间距跳变

在 SwiftUI 中,为视图状态变化添加动画往往只需要很少的代码,但 List 并不总能给出符合预期的过渡效果。尤其当 row 内部的内容高度发生变化时,例如副标题从无到有、文本行数因数据更新而改变,系统默认的布局过程很容易让行高直接硬切,进而带来闪烁、裁剪或 spacing 突变。本文从这一常见问题出发,逐步拆解 List 动态行高动画失效的原因,并通过自定义 LayoutAnimatableLayoutValueKey 与状态解耦,构建出一套完全基于 SwiftUI 原生能力的解决方案。

这并不是一篇以提供开箱即用组件为主要目标的文章。相比最终代码,我更希望借这个问题梳理一条 SwiftUI 布局问题的探查路径:先理解框架为什么“不按预期工作”,再顺着它的机制重新组织状态、测量与布局,让动画真正发生在正确的层级上。

近期推荐

无状态 actor (Stateless Actors)

Actor 通常被理解为“用来保护可变状态”的工具,因此一个没有存储属性、看似无状态的 actor 很容易让人疑惑:它到底有没有存在的必要?文章以 NetworkClient、自定义 global actor、custom executor 与文件系统访问等场景为例,说明无状态 actor 并非一定不合理,但也可能带来串行化、协议适配和类型系统传播等额外成本;关键在于它究竟是在解决什么问题。

这篇文章最值得关注的点,是 Matt Massicotte 提出的“actor 第一原则”:像使用任何同步原语一样,在使用 actor 之前,应该能清楚说明它为什么必要。无状态 actor 并不一定是错误,但它很可能意味着我们正在用 actor 解决一个并不需要 actor 的问题;只有当它确实承担了隔离、串行化、executor 适配或外部状态保护等明确职责时,才是合理的设计。


为 SwiftData 构建自定义存储格式 (Building a Custom Data Store in SwiftData)

很多开发者容易将 SwiftData 理解为苹果官方对 SQLite 的高层封装,但这其实低估了它作为对象图管理与持久化协调框架的潜力。无论是 SwiftData 还是 Core Data,更重要的能力都不只是替我们读写数据库,而是让模型对象、查询、上下文管理与底层存储之间保持清晰分工。只要底层存储能够按照框架要求完成数据交换,数据来源就不一定非得是 SQLite。

Mohammad Azam 通过实现一个基于 JSON 文件的 custom data store,展示了 SwiftData 与底层存储之间真正交换的并不是 @Model 标记的实时对象,而是经过转换后的 snapshot。理解这一点之后,DataStoreConfigurationDataStoreDefaultSnapshotPersistentIdentifier 以及 fetch / save 的职责边界都会变得清晰起来:SwiftData 负责模型对象、观察、变更追踪和 SwiftUI 集成,而自定义 store 只负责读取和写入快照。

#127 期周报中,我曾推荐过一个更工程化的自定义 DataStore 项目 DataStoreKit。它并不是简单地把 SwiftData 后端换成 JSON 或文件存储,而是尝试基于 custom DataStore 重新实现一套 SwiftData-aware 的 SQLite 存储层,并在 predicate 翻译、继承、缓存、历史追踪以及后台预取等方面做了大量扩展。想在此方向进一步研究的开发者,可以将它作为进阶参考。


给 Swift 并发任务命名 (Task Names in Swift Concurrency)

GCD 有 queue label,Swift Concurrency 的 task 却长期缺少类似的诊断标识。随着 SE-0469 在 Swift 6.2 中实现,Task、Task.detached、task group 的 addTask 等 API 开始支持 name 参数,开发者可以为并发任务添加简短、可读的名称,从而在 LLDB、Instruments 和日志中更容易定位具体执行单元。

Artem Novichkov 梳理了 task name 的用法与限制,并提醒开发者:task name 应只作为诊断信息,而不是程序逻辑的一部分。它适合辅助调试、性能分析和日志排查,但不应承载业务状态。


UniqueBox, Ref, and MutableRef in Swift 6.4

Swift 6.4 延续了近几年围绕 ownership、borrow、noncopyable type、lifetime dependency、SpanMutableSpan 等方向的演进。Artem Mirzabekian 通过 UniqueBoxRefMutableRef 这三个类型,介绍了 Swift 在存储位置、所有权与访问生命周期上的新表达能力。它们的意义并不是鼓励普通业务代码立刻改用这些底层构件,而是展示 Swift 正在把过去依赖 class box、UnsafePointer 或编译器内部推理的关系,逐步提升为可以写进 API 形状、并由类型系统检查的语言模型。

其中,UniqueBox 用来表达“一个值位于堆上,并由单一所有者持有”;RefMutableRef 则分别对应某段生命周期内的共享读取与独占修改。换句话说,这些类型补上的不是某个具体业务场景的便利 API,而是一套更精确描述“值在哪里、归谁所有、谁能访问”的底层词汇。


关于 CloudKit CKAsset 当前文件大小限制的澄清 (Clarification on Current CloudKit CKAsset File Size Limits)

关于 CKAsset 的文件大小上限,社区里长期存在一个容易混淆的说法:有些文档中提到过 50 MB 限制,但那更多对应 CloudKit Web Services,而不是 Apple 平台上通过原生 CloudKit framework 上传的 CKAsset。这次 Apple Frameworks Engineer 在开发者论坛中给出了一个非常明确的回答:单个 CKAsset 最高支持 50 GB,前提是用户仍有足够的 iCloud 存储空间,因此应用需要正确处理 CKError.quotaExceeded

之所以在周报中介绍这个问答,并不只是因为它澄清了一个数字,而是因为它划清了一个重要边界:CKAsset 支持大文件,并不等于大文件同步就是简单可靠的工程问题。后续讨论也提到,大文件传输往往需要考虑应用挂起、终止后的后台执行能力,可以结合 long-lived CloudKit operations,或通过 BackgroundTasks 中的 BGProcessingTask 来安排上传任务。也就是说,50 GB 回答的是“CloudKit 是否允许”,而后台传输、失败恢复、配额处理和用户体验,仍然需要应用自己认真设计。


为 Agent 提供有效的调试信息 (Don’t allow the agent reading whole output of Xcodebuild)

随着 AI agent 越来越多地参与 Swift 项目的编译、测试和问题定位,一个很现实的问题开始变得突出:xcodebuild 的输出实在太长了。如果直接把完整日志交给 agent,不仅会消耗大量 token,也容易让真正有用的信息被淹没在噪声中。Lee young-jun 在本文中介绍的 Xcsift 正是为这个场景设计的工具:它不像 xcpretty 那样主要面向人类阅读,而是把 xcodebuild / SwiftPM 的输出整理成更适合 LLM 消费的结构化结果,例如编译错误、警告、测试失败、覆盖率和构建耗时等。

AI 辅助开发工作流中一个容易被忽略的细节是:我们不应该让 agent “读完所有内容再自己判断重点”,而应该尽量在工具层先完成信息筛选与结构化。Xcsift 是一个基于现有 xcodebuild 输出的实用方案,而 Tuist 团队此前在 Teaching AI to Read Xcode Builds 中则进一步讨论了构建日志之外的结构化 build data。两者放在一起看,正好体现了 agentic coding 在构建诊断上的两个层次:先减少噪声,再提升语义。


The MiniSwift Story

我在之前的周报中曾推荐过 MiniSwift,当时最吸引人的部分,是 Ugur Toprakdeviren 在不依赖 LLVM、Clang 或 Apple 官方工具链的情况下,用 C 从零实现了 Swift 编译器前端与 WASM 后端。而在这篇文章中,作者补全了项目背后的来龙去脉:它最初并不是为了“炫技式地写一个编译器”,而是源于一个很具体的问题——能不能摆脱 WebView 或 Xcode Preview 的不稳定,用 canvas 得到更稳定的 SwiftUI 预览。

这次值得再次推荐,是因为 MiniSwift 已经明显从早期 compiler prototype 向它最初的目标迈进了一大步。现在它不只是把 Swift 代码编译到 WASM,而是进一步通过自定义 UIIR、canvas renderer 和 diff engine,把 SwiftUI 代码直接渲染成浏览器中的可交互预览;从官网展示来看,@State、基础布局、按钮、文本和部分修饰器已经可以在网页中形成类似 SwiftUI Preview 的即时反馈。也就是说,之前提到的“不会崩溃的 SwiftUI 浏览器预览”,正在从设想变成一个可以直接体验的工具。

工具

MistKit:让服务端 Swift 访问 CloudKit

Leo G Dion 开发的 MistKit 目标很明确:把 Apple 的 CloudKit Web Services REST API 封装成现代 Swift 接口,让服务端 Swift、命令行工具,以及 Linux、Windows 等没有原生 CloudKit framework 的环境,也能访问同一套 CloudKit 容器。它并不是要替代 Apple 平台上的 CloudKit framework,而是补足那些无法直接使用原生框架的场景。

项目基于 swift-openapi-generator 构建底层客户端,上层提供 async/await、类型安全的 CloudKit 操作、结构化错误、record 查询与增删改、record / zone changes、asset upload 和用户身份相关能力。认证方面覆盖 API Token、Web Auth Token 与 Server-to-Server;其中 Server-to-Server 主要用于 public database,涉及 private 或 shared database 的用户上下文操作则需要 Web Auth Token。这使它适合后台任务、CLI、内容目录、公共数据库管理,以及 Web 与 Apple 设备之间的数据桥接。

CloudKit 常被视为“客户端服务”,但 MistKit 让服务端参与 CloudKit 生态变得更自然。例如,用定时任务维护 public database 中的软件版本目录、RSS 聚合内容、应用素材包;或在用户授权后,让后端处理 private database 中的数据。对已经依赖 CloudKit 的应用来说,它提供了一条在 Apple 平台之外扩展数据处理能力的路径。

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

【图像处理】vImage/Accelerate——SIMD 让 CPU 也能飞

作者 FIRE_Lee
2026年6月1日 16:07

GPU 是并行之王,但它不是唯一的选择。 CPU 的 SIMD 单元在正确的场景下,可以让代码快 8–16 倍——而且不需要离开 Swift。


一、SIMD 原理:单指令多数据

传统 CPU 标量运算一次处理一个数:

标量加法(每次 1 个字节):
  ADD r1, r2    → 结果:1 个字节

SIMD(Single Instruction Multiple Data)一次处理多个数:

SIMD 加法(ARM NEON,每次 16 个字节):
  VADD v1.16b, v2.16b, v3.16b    → 结果:16 个字节(16 个像素的单通道值)

Apple Silicon(ARM 架构)的 NEON 单元:

寄存器宽度 支持类型 一次处理量 典型用途
128 位 uint8×16 16 个字节 像素通道运算
128 位 float32×4 4 个浮点数 滤波器权重计算
128 位 int16×8 8 个 16 位整数 卷积中间值

图像处理中的意义:一张 4000×3000 RGBA 图有 4800 万字节。SIMD 每次处理 16 字节,理论上比标量快 16 倍;考虑内存带宽和流水线效率,实测约快 8–12 倍。

Apple 的 Accelerate 框架把这些 SIMD 指令封装成高层 API,开发者无需写汇编。


二、vImage_Buffer:核心数据结构

vImage 的所有操作围绕一个结构体展开:

// vImage_Buffer 的 Swift 定义(来自 Accelerate 框架)
struct vImage_Buffer {
    var data: UnsafeMutableRawPointer?  // 指向像素数据的指针
    var height: vImagePixelCount        // 图像高度(行数)
    var width: vImagePixelCount         // 图像宽度(列数)
    var rowBytes: Int                   // 每行的字节数(可能比 width*4 大,用于内存对齐)
}

rowBytes 不等于 width × 4 的原因

为了满足 SIMD 的内存对齐要求(通常 64 字节对齐),每行末尾可能有 padding 字节。vImage 会自动处理这些 padding。

从 MLBitmap 构建 vImage_Buffer

extension MLBitmap {

    func withVImageBuffer<T>(_ body: (inout vImage_Buffer) throws -> T) rethrows -> T {
        // MLBitmap.pixels 是连续的 [UInt8],直接用指针包装,零拷贝
        return try pixels.withUnsafeMutableBytes { ptr in
            var buffer = vImage_Buffer(
                data: ptr.baseAddress!,
                height: vImagePixelCount(height),
                width: vImagePixelCount(width),
                rowBytes: width * 4          // MLBitmap 保证紧凑布局
            )
            return try body(&buffer)
        }
    }
}

三、vImageBoxConvolve_ARGB8888:Box Filter 的 SIMD 实现

Box Filter(均值模糊)是最简单的平滑操作:把每个像素替换为邻域内所有像素的均值。

3×3 Box Filter 核:
  ┌ 1/9  1/9  1/9 ┐
  │ 1/9  1/9  1/9 │
  └ 1/9  1/9  1/9

vImage 的实现利用积分图(Summed-Area Table),使得 Box Filter 的复杂度与核的大小无关:

朴素实现(Swift 嵌套循环):
  每个像素 → 访问 k×k 邻域 → O(N × k²)
  5×5 核:每像素 25 次加法
  21×21 核:每像素 441 次加法

积分图实现(vImage 内部):
  每个像素 → 4 次查表 → O(N × 1),与 k 无关
  5×5 核:每像素 4 次操作
  21×21 核:每像素 4 次操作(相同!)

调用方式:

func applyBoxBlur(to bitmap: inout MLBitmap, kernelSize: UInt32) {
    // kernelSize 必须是奇数
    let k = kernelSize % 2 == 0 ? kernelSize + 1 : kernelSize

    bitmap.withVImageBuffer { src in
        // 需要一块同等大小的临时缓冲区作为输出
        var tempPixels = [UInt8](repeating: 0, count: bitmap.pixels.count)
        tempPixels.withUnsafeMutableBytes { dstPtr in
            var dst = vImage_Buffer(
                data: dstPtr.baseAddress!,
                height: vImagePixelCount(bitmap.height),
                width: vImagePixelCount(bitmap.width),
                rowBytes: bitmap.width * 4
            )
            // ARGB8888:4 通道,每通道 8 位;边缘用 kvImageEdgeExtend 镜像填充
            vImageBoxConvolve_ARGB8888(&src, &dst, nil, 0, 0, k, k,
                                       nil, vImage_Flags(kvImageEdgeExtend))
        }
        bitmap.pixels = tempPixels
    }
}

四、3× Box Filter 近似高斯——中心极限定理的实际应用

对同一图像连续做 3 次 Box Filter,等效于卷积了一个近似高斯核:

数学原理(中心极限定理)

均匀分布的 n 次卷积 → 趋近于正态(高斯)分布

Box Filter = 均匀分布卷积
Box × Box × Box = 3 次均匀分布卷积 → 接近高斯

近似误差 < 3%(与相同 σ 的真高斯核相比)

计算复杂度对比

方法 复杂度 radius=10(21px核) radius=20(41px核)
朴素高斯(嵌套循环) O(N × k²) 441N 次操作 1681N 次操作
可分离高斯(两次一维) O(N × 2k) 42N 次操作 82N 次操作
3× Box Filter(积分图) O(N × 3) 3N 次操作(与 k 无关!) 3N 次操作(与 k 无关!)

对于大半径模糊,3× Box Filter 是理论上最优的实现。


五、Ping-Pong 缓冲区:避免读写竞争

vImage 操作要求 src 和 dst 指向不同的内存区域(不支持原地修改)。连续做 3 次 Box Filter 时,需要两块缓冲区交替使用:

func applyTripleBoxBlur(bitmap: MLBitmap, kernelSize: UInt32) -> MLBitmap {
    let byteCount = bitmap.pixels.count
    let k = kernelSize

    // 准备两块缓冲区,A 存原始数据,B 是临时
    var bufA = bitmap.pixels                              // Ping
    var bufB = [UInt8](repeating: 0, count: byteCount)  // Pong

    let h = vImagePixelCount(bitmap.height)
    let w = vImagePixelCount(bitmap.width)
    let rb = bitmap.width * 4

    // 循环 3 次,每次 A → B,然后交换
    for _ in 0..<3 {
        bufA.withUnsafeMutableBytes { aBuf in
            bufB.withUnsafeMutableBytes { bBuf in
                var src = vImage_Buffer(data: aBuf.baseAddress!, height: h, width: w, rowBytes: rb)
                var dst = vImage_Buffer(data: bBuf.baseAddress!, height: h, width: w, rowBytes: rb)
                vImageBoxConvolve_ARGB8888(&src, &dst, nil, 0, 0, k, k,
                                           nil, vImage_Flags(kvImageEdgeExtend))
            }
        }
        swap(&bufA, &bufB)   // Ping-Pong:交换指针,避免内存拷贝
    }

    var result = bitmap
    result.pixels = bufA     // 3 次后,最终结果在 bufA
    return result
}

swap 的作用:Swift 的 swap 只交换两个变量持有的引用(O(1) 操作),不复制底层字节数组。每次迭代结束后,上一次的 dst 变成下一次的 src。


六、Lanczos 重采样 vs 双线性插值

图像缩放时,需要在原始像素之间做插值。vImage 提供多种质量等级:

双线性插值(Bilinear)

在频域看,双线性插值等效于一个三角形频率响应核:

频率响应(双线性):
  ▲
  │ ████
  │     ███
  │        ██
  │──────────── → 频率
  0            Nyquist

特点:平滑,但会引入模糊(高频衰减)
     对锯齿的抑制差(边缘有混叠)
     计算量小:每像素 4 个原始像素参与

Lanczos 重采样

Lanczos 核是对 sinc 函数加窗的结果:

Lanczos-3 核(a=3):
  L(x) = sinc(x) × sinc(x/3)    其中 sinc(x) = sin(πx)/(πx)

频率响应(Lanczos-3):
  ▲
  │ ███████████
  │            ▌
  │            ▌(截止更陡峭)
  │──────────── → 频率
  0            Nyquist

特点:高频保留更好,缩小后细节更锐利
     轻微振铃(ringing),边缘有轻微光晕
     计算量大:每像素 36 个原始像素参与(3×3 正负权重)
特性 双线性 Lanczos-3
图像质量 中等,偏软 高质量,锐利
计算量 低(4 个采样点) 高(36 个采样点)
振铃效果 轻微
适合场景 实时预览、小尺寸 最终导出、缩图

vImage 中调用 Lanczos:

func lanczosScale(bitmap: MLBitmap, targetWidth: Int, targetHeight: Int) -> MLBitmap {
    var result = MLBitmap(width: targetWidth, height: targetHeight)
    bitmap.withVImageBuffer { src in
        result.withVImageBuffer { dst in
            // kvImageHighQualityResampling 启用 Lanczos 核
            vImageScale_ARGB8888(&src, &dst, nil, vImage_Flags(kvImageHighQualityResampling))
        }
    }
    return result
}

七、vImageMin / vImageMax:形态学操作的 SIMD 实现

腐蚀(Erosion) = 取邻域最小值:暗区域膨胀,亮区域收缩
膨胀(Dilation) = 取邻域最大值:亮区域膨胀,暗区域收缩

// 膨胀(Dilation):每个像素取 k×k 邻域最大值
func applyDilation(bitmap: inout MLBitmap, kernelSize: UInt32) {
    var tempPixels = [UInt8](repeating: 0, count: bitmap.pixels.count)
    bitmap.withVImageBuffer { src in
        tempPixels.withUnsafeMutableBytes { dstPtr in
            var dst = vImage_Buffer(
                data: dstPtr.baseAddress!,
                height: vImagePixelCount(bitmap.height),
                width: vImagePixelCount(bitmap.width),
                rowBytes: bitmap.width * 4
            )
            vImageMax_ARGB8888(&src, &dst, nil, 0, 0,
                               kernelSize, kernelSize,
                               vImage_Flags(kvImageEdgeExtend))
        }
    }
    bitmap.pixels = tempPixels
}

// 腐蚀(Erosion):每个像素取 k×k 邻域最小值
func applyErosion(bitmap: inout MLBitmap, kernelSize: UInt32) {
    // 结构同上,替换为 vImageMin_ARGB8888
    vImageMin_ARGB8888(&src, &dst, nil, 0, 0, kernelSize, kernelSize,
                       vImage_Flags(kvImageEdgeExtend))
}

形态学组合操作

  • 开运算(开 = 先腐蚀后膨胀):去除小噪点,保持主体形状
  • 闭运算(闭 = 先膨胀后腐蚀):填补小孔洞,平滑轮廓

八、完整性能对比

以 4000×3000 RGBA 图像为基准,iPhone 14 实测:

操作 Phase 1 Swift 循环 vImage/Accelerate Core Image(GPU) 加速比(vs Phase 1)
高斯模糊(radius=10) ~380ms ~28ms ~4ms vImage 快 13.6×
高斯模糊(radius=20) ~1200ms ~28ms ~5ms vImage 快 42.8×(与 radius 无关!)
双线性缩放(50%) ~95ms ~8ms ~2ms vImage 快 11.9×
Lanczos 缩放(50%) ~35ms ~6ms GPU 快 5.8×
腐蚀/膨胀(5×5) ~220ms ~15ms ~3ms vImage 快 14.7×
Box Filter(radius=15) ~340ms ~12ms ~4ms vImage 快 28.3×

选择策略

数据量 < 500KB(小图、缩略图):
  → vImage(启动开销低,无 GPU 上下文切换)

数据量 > 2MB(大图)且操作 < 5ms GPU 阈值:
  → Core Image

需要确定性输出(测试 / CI 环境):
  → vImage(无随机性,跨平台结果一致)

多滤镜链(3 个以上):
  → Core Image(惰性合并 Pass 优势显著)

九、小结

概念 核心内容
SIMD 一条指令处理 16 字节,ARM NEON 实现,是 vImage 的底层
vImage_Buffer 像素数据的包装结构,含 rowBytes 对齐字段
Box Filter 积分图实现,O(N) 复杂度,与核大小无关
3× Box ≈ 高斯 中心极限定理,误差 < 3%,大半径时远快于真高斯
Ping-Pong 缓冲 src/dst 交替,swap 零拷贝,避免读写竞争
Lanczos vs 双线性 Lanczos 质量高但计算量大,双线性适合实时预览
vImageMin/Max 腐蚀/膨胀的 SIMD 实现,形态学操作基础
选择策略 小图/确定性 → vImage;大图多滤镜 → Core Image

♥️喜欢我的内容,欢迎大家点赞、转发、关注。

♥️本人专注于技术+投资+认知三位一体的内容分享。

昨天以前iOS

稳定 > 新功能 - 肘子的 Swift 周报 #138

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

传闻苹果在今年即将发布的全新操作系统(包括 iOS 27 和 macOS 27)中,将采取类似于当年 Mac OS X Snow Leopard 时代的调整策略——将重心放在系统稳定性、性能优化、清理老旧代码和修复 Bug 上,而不是引入颠覆性的视觉设计或繁多的底层新功能。如果真能如此,那实在是令人欣慰。

iOS 横竖屏实践(UIKit)

作者 iOS日常
2026年5月28日 18:02

present 场景

关注点

present 页面时,重点是这两个属性:

override var supportedInterfaceOrientations: UIInterfaceOrientationMask
override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation
  • supportedInterfaceOrientations:页面允许方向
  • preferredInterfaceOrientationForPresentation:模态展示时首选方向

必须满足的规则

preferredInterfaceOrientationForPresentation 必须包含在 supportedInterfaceOrientations 中。

错误示例:

override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    .landscape
}

override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
    .portrait
}

iOS 16 及以下会崩溃:

preferredInterfaceOrientationForPresentation 'portrait' must match a supported interface orientation: 'landscapeLeft, landscapeRight'!

iOS 17 为警告,不崩溃,但仍属于非法配置。

与应用级方向的关系

present 页面还必须与应用级可用方向有交集。
若应用只允许竖屏,而被 present 页面只支持横屏,会出现:

Supported orientations has no common orientation with the application...

推荐写法

override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    .landscape
}

override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
    .landscapeRight
}

如果需要在多个页面混用横竖屏,可在 AppDelegate 放开应用级方向:

func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
    .all
}

push 场景

关注点

push 页面只用看:

override var supportedInterfaceOrientations: UIInterfaceOrientationMask

preferredInterfaceOrientationForPresentation 不是 push 的控制项。

与应用级方向的关系

如果应用级只允许竖屏,即使页面声明横屏,也不会崩溃,但横屏不会生效。

iOS 版本行为

iOS 15 及以下

从竖屏页 push 到横屏页,不会自动旋转,需要手动触发:

let value: UIInterfaceOrientation = orientation.contains(.landscapeRight) ? .landscapeRight : .portrait
UIDevice.current.setValue(value.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()

iOS 16 及以上

会自动旋转。
如需显式请求方向更新,可用 iOS 16 新接口:

guard let windowScene = self.view.window?.windowScene else {
    return
}
let preferences = UIWindowScene.GeometryPreferences.iOS(interfaceOrientations: orientation)
windowScene.requestGeometryUpdate(preferences) { _ in }
self.setNeedsUpdateOfSupportedInterfaceOrientations()

wift Part 5 oc -> swift

作者 看谷秀
2026年5月28日 12:45

1. 记号/标记

作用: 打个标记, 方便寻找


// MARK:  类似于OC中的 #pragma mark (红旗任务)

// TODO:  用于标记未完成的任务

// FIXME:  用于标记待修复的问题

#warning("警告!!!")

截屏2026-05-25 12.00.13.png

2 条件编译


// 操作系统:macOS\iOS\tvOS\watchOS\Linux\Android\Windows\FreeBSD
#if os(macOS) || os(iOS)
// CPU架构:i386\x86_64\arm\arm64
#elseif arch(x86_64) || arch(arm64)
// swift版本
#elseif swift(<5) && swift(>=3)
// 模拟器
#elseif targetEnvironment(simulator)
// 可以导入某模块
#elseif canImport(Foundation)
#else
#endif

查看当前模式 product -> scheme -> edit scheme -> (run) Build Configiuration -> Debug / Release

// 代码区分 debug模式 或 release模式

#if DEBUG 
  print("debug!!!!") 
#else 
  print("release!!!!") 
#endif
//统版本检测 (available 可获得的)

if #available(iOS 11.0, tvOS 11.0, *){
// ios 11.0 以上, tvOS 11.0 以上
}

3 程序入口配置 根控制器配置

// 1 通过 AppDelegate 配置根控制器

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow? // 新建

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        
        // 新建
        window = UIWindow(frame: UIScreen.main.bounds)
            window?.rootViewController = ViewController()
            window?.makeKeyAndVisible()
        return true
    }
}
// 2 通过 SceneDelegate 配置根控制器

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?  //新建
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        // 替换成你的控制器
        let yourVC = TabBarViewController()
        window?.rootViewController = yourVC
        window?.makeKeyAndVisible()
    }
}

4 Swift 调用 OC

swift 通过桥接文件 调用oc文件 {targetName}-Bridging-Header.h

  • 桥接文件并暴露oc文件, swift通过桥接文件访问oc文件

  • 桥接文件会在第一次创建oc文件同时创建

// 例如 
learn-swift1-Bridging-Header.h  //桥接文件

learn-swift1 // 项目名称

// 默认 swift项目首次新建oc文件, 会自动创建一个桥接文件 

// 支持自行创建桥接文件

截屏2026-05-25 12.39.32.png

// 1 创建oc文件

#import "OCViewController.h"

@implementation OCViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.title = @"oc文件";
}
@end
// 2 桥接文件中暴露oc文件 

// 测试oc控制器
#import "OCViewController.h"
// 3 swift 调用oc文件

import UIKit  
class TestViewController: UIViewController { 
    override func viewDidLoad() {
        super.viewDidLoad() 
    }
    
    // 使用 oc 文件
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {   
        self.navigationController?.pushViewController(OCViewController(), animated: true)
    }
}

5 OC 调用 swift

oc 通过桥接文件 调用 swift 文件 {targetName}-Swift.h

  • 桥接文件是个隐藏文件, 非醒目在项目内
  • 桥接文件自动创建(bulid setting查看)

ps: 所以 oc 调用swift 仅需要引入头文件即可

截屏2026-05-25 12.58.11.png

// 文件名 learn-swift1
#import "learn-swift1-Swift.h" // ❌ 按道理是这个  但是找不到

#import "learn_swift1-Swift.h" // 这个能找到  

// 所以 带横线的会转成 "-" -> "_"

5.1 @objcMembers & @objc

objcMembers: 全类都开放给 oc 文件使用

@objc: 某属性, 方法暴露给 oc 文件使用

// swift 文件 暴露 属性方法 给oc

@objcMembers class TestViewController: UIViewController { 

    @objc var price: Double
    var band: String
    
    init(price: Double, band: String) {
        self.price = price
        self.band = band
        super.init(nibName: nil, bundle: nil)  // ViewController 的初始化必须确保父类也完成初始化
    } 
    
    required init?(coder: NSCoder) { 
        fatalError("init(coder:) has not been implemented")
    }
    
    @objc func run() {
        print(price, band, "run")
    }
    
    static func run() {
        print("Car run")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad() 
    }
}


//  oc 调用 swift

#import "OCViewController.h"
#import "learn_swift1-Swift.h" // 引入头文件

@implementation OCViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.title = @"oc文件";
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

    TestViewController *con = [[TestViewController alloc] initWithPrice:10 band:@"band"];
    
    [con run]; //  print(price, band, "run")
    
    TestViewController.run; //  print("Car run")
    
    [self.navigationController pushViewController:con animated:true];
}

5.1 @objc (别名)

@objc (TVC) 代表将类 "TestViewController" 起别名为 "TVC" 并开放给oc

// 起别名

@objc (TVC)
@objcMembers class TestViewController: UIViewController { 
    @objc var price: Double
    @objc func run() {
        print(price, band, "run")
    }
    
    static func run() {
        print("Car run")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()    
    }
}

//  注意 @objc (TVC) 代表开发 TVC 给oc
// @objcMembers 必须有, 否则找不到 TVC 内的方法

6 选择器 Selector

swift也有perform 方法 , 由于是oc方法 需要添加 @objcMembers

// oc 的 perform

- (void) jump {
    NSLog(@"OCViewController--挑起来了");
}

[self performSelector:@selector(jump)];
// swift 的 perform

 @objc func test1(v1: Int) {
     print("test1")
 } 
 
self.perform(#selector(test1(v1:)))

7 String 与 NSString关系 及 as 转换

// String 转 NSString   方便操作字符串切割
    let str = "123456789"
    let str1 = str as NSString  // 转oc字符串
    let str3 = str1.substring(with: NSRange(location: 3, length: 2)) // 45
// NSString 转 String
    let str5 : NSString = "0987654321"
    var str6 = str5 as String
    str6.append("-111") // 0987654321-111
// 对比  NSString VS String  截取字符串

// OC截取
let str2 = str1.substring(from: 4) // 56789

// Swift截取
let str4 = str[str.index(str.startIndex, offsetBy: 4) ... str.index(before: str.endIndex)] // 56789

桥接转换表
String 和 NSString 相互转换, 本质是通过桥接进行的

双向箭头: 代表可以互相转换

截屏2026-05-25 14.46.21.png

8 是否继承 NSObject 内存分析

class Person {
    var age = 10
    var weight: Int = 0
}

// 内存情况

// Person对象占用32位

// 最前8个字节存放isa指针(指向元类型)
// 然后8个字节引用计数相关
// 在后8个字节 age 
// 最后8个字节 weight*
class Person: NSObject {
    var age = 10
    var weight: Int = 0
}

// 内存情况

// Person对象占用32位

// 最前8个字节存放isa指针(指向元类型)
// 然后8个字节 age 
// 最后8个字节 weight 
// 最后8个字节内存对齐

结论:

  1. 纯 Swift 类(不继承 NSObject)完全可以正常工作,并且有自己的内存管理机制(通过单独的引用计数区域)。
  2. 这证明了:在 Swift 中,NSObject 不是必须的基类。纯 Swift 类更轻量、更独立,不依赖 Objective-C 运行时。
  3. 继承 NSObject 的类后, 引用计数相关压缩进了前8个字节

9 可选协议

  1. 仅仅能被类遵守的协议
protocol Runnable1: AnyObject {}
protocol Runnable2: class {}  // 这个过期了 AnyObject代替
@objc protocol Runnable3 {} // 除了需要被类swift类遵守, 还将暴露给外部oc类调用

// 协议
protocol Runnable {
    func run()
}

class TestViewController: UIViewController, Runnable {
// ❌ 不实现协议方法 func run() 报错
}
  1. 遵守协议, 不用写实现了
// 协议
protocol Runnable {
    func run()
}

// 扩展给默认实现(空)
extension Runnable {
    func run() {
        print("什么都不做")
    }
}

class TestViewController: UIViewController, Runnable {
    
    override func viewDidLoad() {
        super.viewDidLoad()  
        TestViewController().run()   
    }
}

//  TestViewController 遵守协议, 但是不需要写实现了

加 @objc dynamic 的核心作用:让方法调用走 Objective-C 的消息转发机制(objc_msgSend),而不是 Swift 的静态或虚表调用。

10 dynamic 关键字

class Dog: NSObject {
    @objc dynamic func bark() {
        print("汪汪")
    }
}

let original = #selector(Dog.bark)

// Dog.bark 走oc的消息机制

11 Swift 代码使用 OC 的 KVC / KVO

class TestViewController: UIViewController { 

    @objc dynamic var age: Int = 0
    var observation : NSKeyValueObservation? 
    
    override func viewDidLoad() {
        super.viewDidLoad() 
        
        // 添加观察值 监听age  
        observation = observe(\Self.age, options: [.old,.new]) {
            (person, change) in
            print("---",change.oldValue as Any,change.newValue as Any)
        }
        
        self.age = 30 //--- Optional(0) Optional(30)
        self.age = 12 //--- Optional(30) Optional(12)
    }
}
     
// 注意 \Self.age 类型的属性这么写, 类型.age
// 原方法

observe(_ keyPath: KeyPath<_KeyValueCodingAndObserving, Value>,

options: NSKeyValueObservingOptions, 

changeHandler:(_KeyValueCodingAndobserving, NSKeyValueObservedChange<Value>) -> Void) -> NSKeyValueObservation

// 参数 1 
_ keyPath: KeyPath<_KeyValueCodingAndObserving, Value>

// `_KeyValueCodingAndObserving` 就是“支持 KVC/KVO 的任何类型”(比如 `NSObject` 的子类)。

传一个 类型的值

// swift 属性监听

class TestViewController: UIViewController {
    
    var name : String? {
        willSet {  // 属性即将改变时进行监听
              print("willSet--name:", name ?? "")
              print("willSet--newValue:", newValue ?? "")
         }
         didSet {  // 属性已经改变时进行监听
              print("didSet--name:", name ?? "")
              print("didSet--oldValue:", oldValue ?? "")
         }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad() 
        
        self.name = "why"
        self.name = "yz"    
    }
}


// willSet--name: 

// willSet--newValue: why

// didSet--name: why

// didSet--oldValue: 


// willSet--name: why

// willSet--newValue: yz

// didSet--name: yz

12 关联对象(Associated Object)

// 关联对象

class Person { }

extension Person {
    // 定义全局变量, 只要为了用地址值, Void占一个内存
    private static var AGE_KEY: Void?
    var age: Int {
        get {
            (objc_getAssociatedObject(self, &Self.AGE_KEY) as? Int) ?? 0
        }
        set {
            objc_setAssociatedObject(self, &Self.AGE_KEY, // 关联地址值
                                     newValue, // 传进来的变量
                                     .OBJC_ASSOCIATION_ASSIGN)  // 存储策略
        }
    }
}
var p = Person()
print(p.age) // 0

p.age = 10
print(p.age) // 10

13 多线程

13.1 异步线程

class TestViewController: UIViewController {
    // 创建 DispatchGroup 实例!!!
    let group = DispatchGroup() 
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // 异步并发队列
        let queue1 = DispatchQueue(label: "com.Miss.queue", qos: .default, attributes: .concurrent)
               queue1.async(group: group) { 
                    Thread.sleep(forTimeInterval: 3)
                    print("翻翻书")   
                }
                        
        let queue2 = DispatchQueue(label: "com.Miss.queue", qos: .default, attributes: .concurrent)
                queue2.async(group: group) {
                    print("画画画")
                    Thread.sleep(forTimeInterval: 1)
                }
                        
        let queue3 = DispatchQueue(label: "com.Miss.queue", qos: .default, attributes: .concurrent)
                queue3.async(group: group) {
                    print("看看景")
                    Thread.sleep(forTimeInterval: 2)
                }
                        
        group.notify(queue: DispatchQueue.main) {
                    print("收拾下")
        }        
    }
}

// 画画画
// 看看景
// 翻翻书
// 收拾下

13.2 延迟线程

let item  = DispatchWorkItem {
        print("来来来\(Thread.current)")
}

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3, execute: item) // 主线程

DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 3) {
     print("来来来\(Thread.current)") // 子线程
}


// 来来来 <NSThread: 0x1159192c0>{number = 10, name = (null)}
// 来来来 <_NSMainThread: 0x1046ca130>{number = 1, name = main}

13.3 一次性任务

线程安全, 整个程序只初始化一次

fileprivate let initTask2: Void = { // 静态全局变量
   print("initTask2---------")
}()

class TestViewController: UIViewController {
    static let initTask1: Void = { // 静态局部变量
        print("initTask1---------")
}()

    override func viewDidLoad() {
        super.viewDidLoad()

        test()
        test()
        test()
        print("华丽的分割线")
        
    }
    func test() {
        
        let _ = Self.initTask1 // 初始化一次
        let _ = initTask2 // 初始化一次
    }
}

// initTask1---------
// initTask2---------
// 华丽的分割线

// 注意 Void = () 
// 立即执行闭包 { ... }() 返回值为 () 所以用Void声明

13.4 信号量

// 1 保证同一时间只有 1 个线程读写共享数据(字典、数组、变量),防止多线程崩溃。

// 2 控制线程最大并发数

class Cache {
  private static var data = [String: Any]()
  private static var lock = DispatchSemaphore(value: 2) // 最多两条线程可以访问

  static func set(_ key: String, _ value: Any) {
  lock.wait()
  defer { lock.signal() } // defer: 方法退出前执行
  data[key] = value
  }
}

Swift Optional几个名词备忘

作者 songgeb
2026年5月25日 14:02

Optional 的几种使用方式

1. 普通 Optional(Optional Type)

var name: String?
  • 官方名称:Optional
  • 推荐默认使用

2. 强制解包(Force Unwrapping)

let name: String? = "Tom"
print(name!)
  • 官方名称:Force Unwrapping
  • 使用 !
  • nil → crash

❌ 不推荐滥用

3. 可选绑定(Optional Binding)

if let name = name {
    print(name)
}

或:

guard let name = name else { return }
  • 官方名称:Optional Binding
  • 安全解包
  • ⭐ 最推荐方式

4. 可选链(Optional Chaining)

person?.address?.street
  • 官方名称:Optional Chaining
  • 任意一层 nil → 整体为 nil
  • 安全访问链式调用

5. Nil 合并运算符(Nil-Coalescing Operator)

let name = input ?? "Default"
  • 官方名称:Nil-Coalescing Operator
  • 提供默认值

T! 是什么?

var name: String!

👉 官方名称:

Implicitly Unwrapped Optional

本质

Optional<String>

👉 本质仍然是 Optional,但带自动解包行为

行为

var name: String! = "Tom"
print(name)   // 不需要 !

但如果name实际为 nil时,则会触发解包失败crash:

var name: String! = nil
print(name)   // 💥 crash

使用场景

1. Objective-C 互操作(Objective-C Bridging)

@property NSString *name;

→ Swift:

var name: String!

2. 延迟初始化(Delayed Initialization)

典型:IBOutlet

@IBOutlet weak var label: UILabel!

风险

  • 看起来像非 Optional
  • 实际是 Optional
  • nil 时直接 crash

👉 本质:危险语法糖

总结对照表

写法 英文名称 安全性 建议
String? Optional 默认使用
name! Force Unwrapping 尽量避免
if let / guard let Optional Binding ⭐推荐
?. Optional Chaining 常用
?? Nil-Coalescing Operator 常用
String! Implicitly Unwrapped Optional ⚠️ 限定场景

用自定义 Layout 化解 SwiftUI List 的行高与间距跳变

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

动画的声明式表达是 SwiftUI 的核心优势之一。但在某些场景里,结果并不总像我们期待的那样平滑。一个典型例子是:当 `List` 行内的内容高度发生动态变化——副标题从空变为非空、文本因更新而导致行数变化——系统自带的布局引擎往往无法给出连续的过渡动画。本文从这个现象出发,逐层拆解原因,给出一种完全基于 SwiftUI 原生能力的解决方案;也借这条路径回看 SwiftUI 在布局机制层面的几个关键约束。

从社区路标到生态基石:Dave Verwer 的新篇章 - 肘子的 Swift 周报 #137

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

Dave Verwer 在 iOS Dev Weekly 第 751 期宣布,这份已经持续近 15 年的周报将交由新的团队继续运营,而他自己接下来会全职投入 Swift Package Index。我的博客在早期获得关注,也曾得益于 iOS Dev Weekly 的推荐;而我在周报中坚持撰写每期周评,同样在很大程度上受到 Dave Verwer 的启发。对于很多 Apple 平台开发者来说,iOS Dev Weekly 早已不只是一份链接合集。它既是社区路标,也是长期陪伴。

消失的 WWDC 愿望单 -- 肘子的 Swift 周报 #136

作者 东坡肘子
2026年5月19日 07:54

issue136.webp

消失的 WWDC 愿望单

距离 WWDC 2026 只剩下 20 天了。每年到这个时候,我都会看到不少开发者分享自己的 WWDC 愿望单,写下预测与期许。但今年,至少到我汇总本期周报时,这类内容相较去年同期明显少了许多。究竟是开发者对 WWDC 的期待变淡了,还是更多人开始秉持“降低预期才能获得更多惊喜”的心理?

也许问题并不是开发者没有期待,而是旧有的愿望单形式已经不太够用了。过去,我们期待的是某个 API、某个框架、某项功能;而现在,当软件开发迅速向 AI Agent 时代靠拢时,很多期待本身也变得更难被清晰描述。

一年前,恐怕很少有开发者会预料到软件开发会如此迅速地进入 AI Agent 时代。即便我们期待 Xcode 提供更好的 AI 支持,在 Xcode 26.3 推出前,也未必会想到苹果会在 IDE 中提供与 Agent 如此紧密的集成。应用或 API 已不再只是面向消费者或开发者的接口,它们也可能成为 AI Agent 理解、调用和编排的对象。AI 不只是开发工具,也会作为新的参与者,深度进入软件服务的构建和使用过程。

我想,这也是不少开发者面对 WWDC 2026 时既期待又茫然的地方。我们希望看到更新的功能、更稳定的框架、更清晰的平台方向;同时也在思考,在这样的开发体系中,如何继续保持自己作为开发者的独特性与必要性,并与 AI 一起构建更好的服务。

WWDC 2026 究竟会带来多少变化,让我们拭目以待。


BTW:上周我非常有幸入选了苹果官方最新公布的 Apple Developer Community Spotlight。作为一名内容创作者,能够得到这样的认可,对我来说既是鼓励,也是鞭策:继续认真写下去,继续把有价值的内容带给大家。感谢每一位长期阅读、反馈和支持我的朋友。

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

近期推荐

从 URLSession 到电磁波:iOS 网络请求的底层原理 (URLSession to Electrons: How Networking works on iOS)

很多 iOS 开发者都知道:URLSessionDataTask 需要调用 resume() 才会真正开始请求。但在这之后究竟发生了什么?Jacob Bartlett 用一篇长文,带读者一路跟随一个普通的网络请求,从 URLSession、CFNetwork、TCP/IP、Wi-Fi,一直深入到无线电、天线与电磁波。Jacob 不仅串联起 HTTP、DNS、TCP、QUIC、IPv6 等开发者熟悉却未必真正理解的概念,也结合 iOS 内部实现介绍了 Network.framework、XNU 内核 TCP 栈、Wi-Fi 帧结构以及蜂窝网络的调度机制。

一个简单的 resume(),背后涉及的代码与协议演进跨度,可能超过十几甚至几十年。不得不感慨,现代软件世界建立在层层抽象之上,而绝大多数时候,我们其实只是幸运地生活在这些抽象足够稳定的年代。


当 AI 和 Xcode 打架时:我写了个工具来拉架

Xcode 的构建系统从未真正为“并行开发”设计过。多个任务同时构建时,DerivedData、ModuleCache、SwiftPM 缓存乃至 Simulator 都可能互相踩踏,而这一问题在 AI Agent 并行开发场景下被进一步放大。Maples7 在本文中介绍了他的解决方案:VibeChard。它基于 Git Worktree,为每个 AI Agent 创建独立的构建沙箱,并进一步隔离 DerivedData、ModuleCache、SwiftPM 缓存以及模拟器环境。最有意思的是,它并不要求开发者修改构建命令,而是通过 PATH shim 透明接管 xcodebuild,让包括 Tuist、Fastlane 在内的整条工具链都自动运行在隔离环境中。与其说这是一个单纯的辅助工具,不如说它揭示了 AI 编程时代一个更底层的问题:当代码生成速度大幅提升后,传统开发工具链的环境隔离能力也必须随之升级。


Swift 真的被搞得乱七八糟了吗?写了几年之后说点实话

Swift 变得越来越复杂,这是一个不争的事实。但这是否意味着 Swift 已经变成了一门糟糕的语言?迷途酱 从语言演进的现实约束出发,重新审视 Swift 这些年的“膨胀”。作者认为,许多被吐槽的复杂度,其实来自 Swift 同时承担应用层、系统级、DSL 宿主与服务端语言等多重目标,而并发安全与所有权模型本身,也属于计算机科学层面的“硬复杂度”。

文章既讨论了 Sendableactor isolationborrowing~Copyable 等近年来快速增长的新概念,也坦率指出 Swift 在并发关键字、泛型语法以及 SwiftUI “魔法感”上的设计问题。尤其是“Swift 其实是一个语言的多个层级”这一观点,相当值得思考:绝大多数开发者日常写 App 时,其实并不需要承担全部复杂度,但 WWDC 与官方文档却经常把这些内容同时呈现在所有人面前。


Xcode Cloud 进阶:Shell 脚本自动化实战 (Writing shell scripts for Xcode Cloud)

Xcode Cloud 的上手体验已经足够简单,但在真实项目中,许多自动化需求仍然需要借助 shell scripts 完成。Amy Delves 以“在归档完成后自动创建 GitHub Release”为例,展示了如何结合 Xcode Cloud 提供的环境变量判断当前构建是否来自 main 分支的归档流程,如何读取 archive 中的版本号与构建号生成 tag,并通过 GitHub API 创建 release。虽然示例本身并不复杂,但它很好地说明了 Xcode Cloud 并不只是一个“点几下就能跑测试”的服务:借助环境变量、脚本钩子与外部 API,它同样可以承担更完整的发布自动化工作流。


我们为什么离开了很棒的 CloudKit (Why CloudKit is amazing and why we're leaving it)

这是一篇相当少见、坦诚的 CloudKit 迁移复盘。César Pinto Castillo 并没有简单批评 CloudKit,恰恰相反,他首先承认:对于小团队来说,CloudKit 几乎提供了一套“不可思议”的能力组合——免费同步、自动身份认证、端到端加密、无服务器运维,以及跨 Apple 平台的共享能力。

但随着产品逐渐发展,CloudKit 的另一面也开始显现:缺乏服务端可观测性、Schema 发布依赖手动操作、不同 Apple 平台间长期存在的同步边缘问题、对 iCloud 账户的强依赖,以及最关键的——无法真正走向 Web 与跨平台生态。最终,César 所在的团队将数据迁移到了基于 Supabase/Postgres 的同步架构。

CloudKit 是苹果生态最重要的护城河之一。但在应用越来越复杂、数据规模越来越大、用户对同步实时性的要求越来越高的今天,它的能力边界也开始显现。在追求“无感同步”的同时,我想苹果也确实需要重新认真审视这个多年未发生明显演进的基础设施了。


用 Swift 手写 LLM 训练内核 (Training an LLM in Swift, Part 1: Taking matrix multiplication from Gflop/s to Tflop/s)

在 Swift 中训练 LLM 听起来多少有些“匪夷所思”,但 Matt Gallagher 做了一次非常硬核的性能探索:不依赖现成机器学习框架,而是从手写矩阵乘法开始,一步步将基础 Swift 实现从 2.8 Gflop/s 优化到 1.1 Tflop/s。文章通过一个完整的优化过程,展示了高性能 Swift 的现实面貌:Swift 并不是不能快,但当你逐渐逼近硬件能力时,也将不可避免地进入 UnsafeBufferPointer、SIMD、并发切片、内存布局与 GPU tile 优化的世界。

这篇文章的价值并不在于鼓励开发者手写机器学习内核。恰恰相反,作者反复强调,生产环境应该优先使用 Accelerate、BNNS、Core ML、MPSGraph 等成熟框架。

当 Swift 被优化到接近 C 的程度时,它还能否保持原本的可读性与优雅?这篇文章给出了一个非常具体,也很诚实的答案。


Swift 适合写 App,但不适合训练 ML 模型 (Swift Is Great for Apps, Not for Training ML Models)

上一篇文章还在展示如何将 Swift 手写矩阵乘法一路优化到 Tflop/s,这篇文章则从另一个角度泼了盆冷水:Mohammad Azam 认为,Swift 与 Core ML 非常适合“部署”机器学习模型,但并不适合承担现代机器学习训练流程本身。Mohammad 指出,真正耗费时间的往往不是模型训练,而是数据清洗、特征工程、归一化、Pipeline 组合与实验迭代,而这些恰恰是 Python 生态最成熟、最顺手的领域。

Swift 并非不能触碰机器学习底层,但当问题从“性能”转向“数据科学工作流”后,语言与生态的重心差异便会迅速显现。这也凸显了 Swift 当前的一个困境:它具备进入多个领域的语言能力,但在应用开发之外,配套生态仍不足以支撑同等顺畅的开发体验。

工具

Swift MarkdownEngine

MarkdownEngine 是 Nodes 团队从自家 macOS Markdown 应用中抽离并开源的原生编辑器引擎。它不是 HTML 渲染器,也不是 WebView 包装,而是基于 TextKit 2 与 AppKit 构建,并桥接到 SwiftUI 的 source-style Markdown 编辑器:文本仍保持纯 Markdown,但在编辑时提供类似 Obsidian Live Preview / iA Writer 的实时样式。项目支持 wiki link、图片嵌入、代码块高亮、LaTeX、任务列表、Writing Tools,以及针对代码、公式和链接的拼写检查抑制。

TextKit 2 文档稀薄、行为细节多,而这个项目把一套已在 Nodes.app 中使用的编辑器能力开源出来,对正在开发写作、笔记或知识管理类 macOS 应用的开发者很有参考价值。


Harness:让 AI 像真实用户一样测试你的 App

Harness 是由 Alan Wizemann 开发的一款原生 macOS 开发者工具,可以驱动 iOS Simulator、macOS App 和 Web App。你用自然语言写下目标,选择一个 persona,Harness 会让 LLM agent 基于截图观察界面、执行点击和输入,并生成可回放的运行路径、成功或失败结论,以及按类型记录的 UX friction。相比单纯让 AI “操作应用”,它更像是把 AI agent、截图、事件日志、凭证脱敏、运行回放和摩擦分类整合成了一套面向开发阶段的用户测试工作台。

目前项目仍处于 alpha 阶段,Web 端依赖 WebKit,iOS/macOS 的 Set-of-Mark 定位能力还在规划中,因此更适合用于探索产品体验中的模糊点和死角,而不是替代确定性的回归测试。不过从 Swift 6、SwiftUI、SwiftData、actor 化的执行流程、JSONL run log,以及跨 Anthropic/OpenAI/Gemini 的模型抽象来看,它已经不是一个简单 demo,而是一个很值得观察的 AI-native developer tool 样本。

传统 UI 测试更擅长验证开发者预设好的路径,而 Harness 试图回答另一个问题:一个带着具体目标和身份设定的真实用户,会不会在你的界面中顺利完成任务。

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

消失的 WWDC 愿望单 - 肘子的 Swift 周报 #136

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

距离 WWDC 2026 只剩下 20 天了。每年到这个时候,我都会看到不少开发者分享自己的 WWDC 愿望单,写下预测与期许。但今年,至少到我汇总本期周报时,这类内容相较去年同期明显少了许多。究竟是开发者对 WWDC 的期待变淡了,还是更多人开始秉持“降低预期才能获得更多惊喜”的心理?

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

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

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

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

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

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

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

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

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

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

2026年4月24日 14:50

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

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

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

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

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

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

image.png

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

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

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

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

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

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

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

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

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

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

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

image.png

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

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

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

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

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

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

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

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

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

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

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

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

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

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

别被系统绑架: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,其实已经不重要了。
重要的是,他终于从“会用组件”,走到了“会造秩序”。

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

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

2026年4月21日 15:16

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

swift中的Iterator初步认识

IteratorProtocol 协议

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

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

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

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

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

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

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

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

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

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

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

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

map大致如下

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

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

  • Sequence工厂:生产迭代器

  • IteratorProtocol产品:实际遍历逻辑

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

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

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

Sequence 够用了吗?

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

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

但日常还会遇到:

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

  • 有多少个?(count)

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

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

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

Collection 在解决什么?

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

  • startIndex / endIndex

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

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

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

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

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

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

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

image.png

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

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

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

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

从 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 视野

Swift 方法派发机制深度解析 —— 兼与 Objective-C `objc_msgSend` 对比

作者 visual_zhang
2026年4月20日 16:44

基于 Swift 5.10(含部分 Swift 6 行为)与现代 Objective-C(ARC + LLVM clang)。 文中代码片段为最小化示例,仅作为论点的佐证。


核心要点

派发方式 调用开销 触发条件 可被 Hook 典型场景
Static Dispatch(直接派发) 最低,可内联 struct/enum 方法、final、全局函数、@inlinable 值类型、性能敏感路径
V-Table Dispatch(虚表派发) 一次间接跳转 class 的非 final 方法(无 @objc 普通 Swift 类继承
Witness Table Dispatch 一次表查 + 一次间接跳转 通过协议变量调用协议方法 面向协议编程
Message Dispatch(OC objc_msgSend SEL→IMP 查表(带缓存) @objc dynamic、继承自 NSObject 且未优化 是(Swizzle/KVO) OC 互操作、AOP

一句话总结:Swift 默认追求"能静态就静态",只在继承、协议、互操作三条路径上才退化为表派发或消息派发;OC 则把所有方法调用统一成 objc_msgSend 的动态消息查找,灵活但每次调用都要付出查表代价


1. 为什么要谈"派发"

方法派发(method dispatch)就是编译器/运行时如何把"调用某方法"翻译成"跳转到某段机器指令"

派发方式直接决定三件事:

  • 性能:是否能内联、是否要查表、是否能命中分支预测。
  • 可扩展性:能不能在运行时替换实现(Swizzle、KVO、Mock)。
  • 二进制兼容:库的方法表布局变化是否会破坏调用方。

OC 与 Swift 在这三个维度上的设计哲学截然相反,下面分别拆解。


2. Objective-C:一切皆消息

2.1 objc_msgSend 的本质

OC 中 [obj doSomething:arg] 在编译期不会被解析为某个具体函数地址,而是被翻译成:

((void (*)(id, SEL, id))objc_msgSend)(obj, @selector(doSomething:), arg);

objc_msgSend 是一段手写汇编,做的事情大致是:

1. 取 obj->isa 拿到 Class
2. 在 Class 的 method cache 里按 SEL hash 查 IMP
3. 命中 → 直接 jmp IMP(尾调用,不入栈)
4. 未命中 → __class_lookupMethodAndLoadCache3 走 method list / 父类链
5. 查到 → 写回 cache
6. 仍找不到 → 进入 forwarding(resolveInstanceMethod / forwardingTargetForSelector / forwardInvocation)

⚠️ 实战提示objc_msgSend 的 cache 是 per-class 的开放寻址哈希表,命中率通常 > 95%。这意味着"OC 方法慢"的直觉并不准确——在热路径上一次调用通常只多花几个时钟周期,远低于一次 cache miss。真正的成本不在派发本身,而在无法内联导致优化器失去全局视野。

2.2 消息派发带来的能力

消息派发让以下能力成为零成本默认值:

  • Method Swizzling:替换 Class 的 method list 即可全局劫持。
  • KVO:runtime 动态生成 NSKVONotifying_XXX 子类并替换 isa
  • 响应链 / Target-ActionUIApplication sendAction:to:from:forEvent: 完全建立在 SEL 之上。
  • 消息转发forwardInvocation: 让一个对象"假装"实现某协议,是 OC 多代理与 RPC 框架的基石。

代价是:编译期不知道 IMP 是什么,全程无法内联,也无法做跨方法优化


3. Swift:四种派发方式共存

Swift 没有"统一派发"的设计,而是根据声明上下文挑选最便宜的合法方式。理解 Swift 性能模型的关键,就是搞清"什么场景用哪种"。

3.1 Static Dispatch(直接派发)

调用直接编译成 call <symbol>,可被内联、可被常量折叠。触发条件:

  • structenum 的所有方法(值类型不存在继承)
  • class 中标了 final 的方法、或 final class 的全部方法
  • private 方法(编译器能证明无覆写)
  • 全局函数、static 函数
  • @inlinable / @_transparent 修饰的方法
struct Counter {
    var value = 0
    mutating func tick() { value += 1 }
}

var c = Counter()
c.tick()

c.tick()-O 下会被完全内联,最终汇编里看不到调用指令,只剩一条 add

3.2 V-Table Dispatch(虚表派发)

Swift 的 class 与 C++ 类似,每个类有一张 V-Table(在 metadata 末尾),按声明顺序存放方法指针。调用时:

1. 从 obj 的 metadata 偏移取 V-Table
2. 按方法在表中的固定 index 取 IMP
3. call IMP

只有一次表查 + 一次间接跳转,比 objc_msgSend 少了 SEL hash 与 cache 命中判断,但因为是间接调用,仍然无法跨方法内联

class Animal {
    func speak() { print("...") }
}
final class Dog: Animal {
    override func speak() { print("woof") }
}

Animalspeak 通过 V-Table 派发;Dog 因为 final,其调用点会被去虚化(devirtualize)回 static dispatch。

3.3 Witness Table Dispatch(协议见证表)

通过协议变量调用协议方法时,Swift 用一张 Protocol Witness Table(PWT):每个"类型 × 协议"对生成一张表,表里按协议方法的固定 index 存放该类型的具体实现。

protocol Drawable {
    func draw()
}
struct Circle: Drawable { func draw() { /* ... */ } }

func render(_ d: Drawable) {
    d.draw()
}

render 拿到的 d 是一个 existential container(值 + 类型 metadata + PWT 指针)。d.draw() 实际是:

1. 从 existential container 取 PWT
2. 按 draw 在协议中的 index 取 witness
3. call witness(witness 内部再 call 真正的实现)

⚠️ 实战坑some Drawable(opaque return type)和 Drawable(existential)的派发完全不同。前者编译期就确定了具体类型,可以走 static dispatch + 单态化(specialization),后者必须走 PWT。把热路径里的 func make() -> Drawable 改成 func make() -> some Drawable,在 SwiftUI / 集合操作里能拿到数量级的性能提升。

3.4 Message Dispatch(走 objc_msgSend

Swift 在以下两种情况会退化到 OC 的消息派发

  • 显式标注 @objc dynamic
  • 类继承自 NSObject,且方法满足 @objc 暴露规则,没有被去虚化优化
class MyVC: UIViewController {
    @objc dynamic func reload() { /* ... */ }
}

只有 @objc dynamic 的方法是保证objc_msgSend 的,因此也只有它能被 KVO / Method Swizzling 劫持。仅 @objc 不带 dynamic 的方法,编译器仍可能选择 V-Table 派发。


4. 派发规则速查表

把"声明位置 × 修饰符"组合起来,就是一张完整的决策表:

声明上下文 默认派发 final @objc @objc dynamic
struct / enum 方法 Static 不允许 不允许
class 直接定义的方法 V-Table Static V-Table(兼可 OC 调) Message
class extension 中的方法 Static Static V-Table Message
protocol 要求的方法 Witness Message(要求 @objc protocol Message
protocol extension 默认实现 Static 不允许 不允许
NSObject 子类的方法 V-Table Static V-Table Message

几条容易踩的经验法则:

  • extension 中定义的方法即使被子类重写也不会触发动态派发,重写会被静默忽略。要支持覆写就把方法定义放回类主体。
  • 协议 extension 的"默认实现"是 static 的,不会走 PWT。如果某个类型实现了同名方法,但调用方持有的是协议变量,仍可能调到 default 实现(这是经典面试题)。
  • @objcdynamic:前者只是给 OC runtime 暴露符号,后者才强制走消息派发。

5. 性能:到底差多少

简化的相对开销(命中 cache、无优化干扰的情况下):

派发方式 相对开销 备注
Inlined static ~1× 实质上没有调用
Direct call (static) ~1× 一条 call
V-Table ~1.5–2× 一次 load + 间接 call
Witness Table ~2× 与 V-Table 量级相同
objc_msgSend(cache 命中) ~3–5× 多了 SEL hash 与 cache 比对
objc_msgSend(cache miss) 数十× 走 method list 查找

真实业务里这些差异通常被淹没,例如 UI 主线程一次 reloadData 的耗时可能是 10ms 量级,几百次 objc_msgSend 完全可以忽略。性能差异真正显著的场景是:

  • 集合的内层热循环map / filter / 自定义 reduce)
  • 每帧调用的渲染回调CADisplayLink、SwiftUI 的 body 求值)
  • 大量小对象的属性 getter/setter(特别是泛型容器)

6. 选型与最佳实践

6.1 写 Swift 类型时

  • 默认优先 struct,需要引用语义或 OC 互操作再用 class
  • class 不需要继承时直接 final class,让编译器去虚化。
  • 协议返回值能用 some P 就别用 P,能用 any P 就别忘加 any 让代码意图清晰。
  • 性能敏感的 ABI 稳定库导出 API 时配合 @inlinable + @usableFromInline

6.2 需要动态能力时

  • 要被 KVO 监听 → @objc dynamic var ...
  • 要被 Swizzle / Aspect → @objc dynamic func ...
  • 要在 OC 代码里调用 → @objc(不必加 dynamic
  • 要做 Mock / Stub → 优先用协议依赖注入,而不是 Swizzle

6.3 OC 仍不可替代的场景

公平起见,OC 的消息派发模型在以下场景仍有 Swift 难以替代的优势:

维度 OC 占优的原因
编译速度 没有泛型单态化、类型推断爆炸,增量编译显著快于 Swift
运行时反射 class_copyMethodList / class_copyIvarList 等一整套 runtime API
二进制体积 没有 witness table / metadata 膨胀,纯 OC 类的 metadata 极小
AOP / Hook 生态 Aspect、Stinger、KVO 都建立在 objc_msgSend 之上,Swift 难以等价替代
C / C++ 互操作 与 C 二进制接口零成本互通

工程实践中,底层基础库(埋点、网络、监控)继续用 OC,业务层迁 Swift,往往是兼顾性能与生产力的最稳妥分工。


7. 一个综合案例

下面这段代码同时触发了四种派发,是理解 Swift 派发模型的一个好对照:

@objc protocol Refreshable { func refresh() }

class Base: NSObject, Refreshable {
    func refresh() { print("base") }
}
final class Leaf: Base {
    override func refresh() { print("leaf") }
}

let a: Refreshable = Leaf()
let b: Base       = Leaf()
let c: Leaf       = Leaf()
a.refresh()
b.refresh()
c.refresh()
  • a.refresh()Refreshable@objc protocol,走 objc_msgSend
  • b.refresh()Base 继承 NSObject,编译器保守起见走 V-Table(若 Base 也是 final,可去虚化)。
  • c.refresh()Leaffinal,编译器去虚化为 Static,可被内联。

把同一个方法在三种持有方式下分别调用,得到三种不同的派发路径——这是 OC 永远不会出现的现象,也是 Swift 性能调优最容易被忽略的细节。

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

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

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

❌
❌