普通视图

发现新文章,点击刷新页面。
昨天以前掘金 iOS

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 ⚠️ 限定场景

消失的 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 视野

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

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 性能调优最容易被忽略的细节。

【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. 选型看系统版本+项目阶段+团队成本,不盲目追新。

iOS RunLoop 原理深度解析与Swift高级用法

作者 择势
2026年4月18日 21:59

RunLoop是iOS开发的底层核心,贯穿应用全生命周期,支撑UI响应、定时器、网络回调、线程保活等所有异步操作,更是解决卡顿、死锁、内存泄漏的关键。本文以Swift视角,系统精简RunLoop的核心原理、组件机制、工作流程及高级实战,摒弃冗余,直击本质,助力开发者快速吃透底层逻辑并落地实践。

一、核心认知:RunLoop 的本质与关键误区

1.1 纠正常见误区

❌ 错误认知:RunLoop是用户态空转轮询(do-while死循环),持续占用CPU; ✅ 正确结论:RunLoop是苹果基于Mach内核封装的线程级事件调度管理器,核心靠内核阻塞调用实现“无事件休眠、有事件唤醒”,99%时间线程休眠,CPU占用接近0。 补充对比(精简版):普通死循环CPU占用接近100%,线程sleep无法响应即时事件,而RunLoop可在休眠时被即时事件唤醒,兼顾资源释放与响应速度。

1.2 核心定义与价值

本质:单线程事件调度中枢,核心职责3点:

  1. 统一接管线程所有事件(UI、定时器、网络回调等);
  2. 无事件时通过mach_msg阻塞休眠,释放CPU;
  3. 事件触发时唤醒线程,按优先级调度处理,通过Mode隔离事件避免干扰。

1.3 线程与RunLoop的绑定关系

  • 一一对应:一个线程对应一个RunLoop,生命周期完全绑定;
  • 懒加载:线程默认无RunLoop,调用RunLoop.current/CFRunLoopGetCurrent()时自动创建;
  • 主线程:系统自动创建并启动,贯穿APP生命周期;
  • 子线程:需手动管理(启动/停止),无事件源则启动后立即退出。

1.4 Swift常用的两套API体系

框架 API类型 线程安全 Swift用法 核心场景
Foundation RunLoop ❌ 非线程安全 RunLoop.current/main 上层业务开发(便捷)
Core Foundation CFRunLoopRef ✅ 线程安全 CFRunLoopGetCurrent() 底层开发(卡顿监控、线程保活)

二、底层拆解:RunLoop 核心组件(精简版)

核心结构:1个RunLoop + N个Mode + 3类组件(Source、Timer、Observer),核心规则:一次RunLoop仅运行在一个Mode下,切换Mode需退出并重新进入。

2.1 Mode:事件隔离容器(核心)

作用:隔离不同类型事件,避免干扰(如滑动与定时器不冲突),Swift常用Mode:

  • RunLoop.Mode.default:默认模式,APP空闲时运行(普通UI、默认定时器);
  • RunLoop.Mode.tracking:界面跟踪模式,滑动ScrollView/TableView时自动切换;
  • RunLoop.Mode.common:通用模式集合,事件可在多个Mode生效(推荐用于滑动时需触发的定时器)。

2.2 Source:事件输入源(唤醒RunLoop)

分两类,核心区别的是“是否具备内核唤醒能力”,补充Swift核心处理逻辑:

  • Source0:用户态事件(UI点击、手势、performSelector:onThread:),无内核唤醒能力,需手动调用CFRunLoopSourceSignal标记待处理,再调用CFRunLoopWakeUp唤醒RunLoop;
  • Source1:内核态事件(屏幕触摸、网络回调、跨线程Mach Port消息),基于Mach Port通信,内核检测到事件后自动唤醒RunLoop,无需手动操作。

补充:屏幕触摸完整流程(精简):手指触摸 → 内核包装为Mach消息 → Source1接收 → 唤醒RunLoop → 分发到Source0 → 处理手势/UI响应。

  • Source0:用户态事件(UI点击、手势),无内核唤醒能力,需手动标记待处理并唤醒RunLoop;
  • Source1:内核态事件(屏幕触摸、网络回调),基于Mach Port,可自动唤醒RunLoop。

2.3 Timer:定时触发源

依赖Mode机制,仅在绑定的Mode下触发,Swift实战选型(精简):

  • Timer:精度低(受RunLoop阻塞影响),适合普通定时(倒计时、轮播);
  • CADisplayLink:与屏幕刷新率同步(60fps),适合自定义动画;
  • GCD定时器:内核级精度最高,不依赖RunLoop,适合高精度场景(秒杀倒计时)。

2.4 Observer:状态监控者

监控RunLoop生命周期状态(entry/afterWaiting等),核心用于卡顿检测、性能监控,Swift中通过CFRunLoopObserver实现。

三、深度剖析:RunLoop 工作机制

核心流程:事件处理 → 阻塞休眠 → 唤醒处理 → 循环往复,核心依赖mach_msg函数实现阻塞与唤醒,结合CFRunLoop源码核心逻辑(精简伪代码):

// 核心循环逻辑(精简版)
void __CFRunLoopRun() {
    // 1. 通知进入RunLoop
    __CFRunLoopDoObservers(entry);
    while (1) {
        // 2. 处理Timer和Source0
        __CFRunLoopDoTimers();
        __CFRunLoopDoSources0();
        // 3. 检查Source1,有则处理,无则休眠
        if (!__CFRunLoopServiceMachPort()) {
            __CFRunLoopDoObservers(beforeWaiting);
            mach_msg(...);// 阻塞休眠
            __CFRunLoopDoObservers(afterWaiting);
        }
        // 4. 处理唤醒事件(Timer/Source1等)
        __CFRunLoopHandleMsg();
        // 5. 满足条件则退出
        if (shouldExit) break;
    }
    __CFRunLoopDoObservers(exit);
}

流程拆解:

  1. 进入RunLoop,通知Observer(entry状态);
  2. 处理当前Mode下到期的Timer、待处理的Source0;
  3. 检查Source1,有则直接处理,无则调用mach_msg阻塞休眠(释放CPU);
  4. 被事件(Source1/Timer/手动唤醒)唤醒,处理对应事件;
  5. 满足退出条件则终止,否则重复循环。

关键:mach_msg是内核级阻塞调用,无事件时线程挂起,有事件时内核自动唤醒,这是RunLoop与死循环的本质区别。

四、Swift 实操:基础用法

4.1 获取RunLoop实例

// 当前线程RunLoop(懒加载)
let currentRunloop = RunLoop.current
// 主线程RunLoop(系统自动创建)
let mainRunloop = RunLoop.main
// 线程安全的CFRunLoop
let cfRunloop = CFRunLoopGetCurrent()

4.2 子线程RunLoop启动(重点)

// 子线程保活示例
DispatchQueue.global().async {
    let runloop = RunLoop.current
    // 必须添加事件源(否则启动后立即退出)
    runloop.add(NSMachPort(), forMode: .default)
    // 无限运行(需手动停止)
    runloop.run()
}

// 停止RunLoop(需在对应线程调用)
DispatchQueue.global().async {
    CFRunLoopStop(CFRunLoopGetCurrent())
}

4.3 Timer避坑用法(推荐)

// 手动添加到common模式,滑动时仍触发
let timer = Timer(timeInterval: 1, repeats: true) { _ in
    print("定时执行,滑动不暂停")
}
RunLoop.current.add(timer, forMode: .common)
timer.fire() // 立即触发一次

五、高级实战:RunLoop 核心落地场景

5.1 主线程卡顿检测(核心应用)

原理:监控beforeSources和afterWaiting状态,计算耗时超过阈值(300ms)判定为卡顿,捕获堆栈用于排查。

import UIKit
import QuartzCore

class卡顿Monitor {
    static let shared = 卡顿Monitor()
    private let threshold: TimeInterval = 0.3 // 300ms阈值(可调整)
    private var startTimestamp: CFTimeInterval = 0
    private let lock = NSLock() // 保证线程安全
    private var observer: CFRunLoopObserver?
    
    private init() {} // 单例,禁止外部初始化
    
    func startMonitoring() {
        guard observer == nil else { return }
        let mainRunloop = CFRunLoopGetMain() // 监控主线程RunLoop
        // 上下文传递,将self绑定到Observer回调中
        let context = CFRunLoopObserverContext(
            version: 0,
            info: Unmanaged.passUnretained(self).toOpaque(),
            retain: nil,
            release: nil,
            copyDescription: nil
        )
        // 监控beforeSources(即将处理事件)和afterWaiting(唤醒后)状态
        observer = CFRunLoopObserverCreate(
            nil,
            CFRunLoopActivity.beforeSources.rawValue | CFRunLoopActivity.afterWaiting.rawValue,
            true, // 重复监控
            0, // 优先级(0最低)
            { _, activity, info in
                // 从上下文取出self
                guard let info = info else { return }
                let monitor = Unmanaged<卡顿Monitor>.fromOpaque(info).takeUnretainedValue()
                monitor.handleRunLoopActivity(activity)
            },
            &context
        )
        // 添加到common模式,确保滑动时也能监控
        if let observer = observer {
            CFRunLoopAddObserver(mainRunloop, observer, CFRunLoopMode.commonModes)
        }
    }
    
    func stopMonitoring() {
        guard let observer = observer else { return }
        CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, CFRunLoopMode.commonModes)
        self.observer = nil // 释放,避免内存泄漏
    }
    
    private func handleRunLoopActivity(_ activity: CFRunLoopActivity) {
        lock.lock()
        defer { lock.unlock() } // 确保锁一定会释放
        
        switch activity {
        case .beforeSources:
            // 记录事件处理开始时间戳
            startTimestamp = CACurrentMediaTime()
        case .afterWaiting:
            // 计算事件处理耗时
            let elapsed = CACurrentMediaTime() - startTimestamp
            if elapsed > threshold {
                print("⚠️ 主线程卡顿警告,耗时:(String(format: "%.2f", elapsed*1000))ms")
                let stack = getCurrentStack()
                print("卡顿堆栈信息:\n(stack)")
                // 实际开发中可在此处添加日志上报(友盟、Bugly等)
            }
        default:
            break
        }
    }
    
    // 优化版堆栈捕获:过滤系统堆栈,保留业务堆栈,更易排查
    private func getCurrentStack() -> String {
        var callStack = Thread.callStackSymbols
        // 过滤前2条(当前函数、Observer回调)和后3条(系统底层函数)
        callStack.removeFirst(2)
        if callStack.count > 8 {
            callStack = Array(callStack.prefix(8))
        }
        // 格式化堆栈,添加序号,更易阅读
        return callStack.enumerated().map { "($0.offset + 1). ($0.element)" }.joined(separator: "\n")
    }
}

// 使用方式(AppDelegate或SceneDelegate中)
// func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
//     卡顿Monitor.shared.startMonitoring()
//     return true
// }

5.2 其他高频场景

  • 线程保活:通过子线程RunLoop+Source/Port,实现后台任务长期运行(如后台下载);
  • 延迟执行:利用RunLoop.perform(afterDelay:),比GCD更轻量且可取消;
  • 避免滑动卡顿:将耗时任务(如复杂计算)移出主线程,或切换到合适Mode。

5.3 常见坑点总结

  • 坑点1:Timer滑动失效 → 解决方案:将Timer添加到common模式,而非default模式;
  • 坑点2:子线程RunLoop启动后立即退出 → 解决方案:必须添加事件源(Source/Port/Timer),否则无事件可处理会直接退出;
  • 坑点3:手动停止RunLoop无效 → 解决方案:停止RunLoop必须在对应线程调用,不可跨线程停止;
  • 坑点4:Observer内存泄漏 → 解决方案:停止监控时,必须移除Observer并置为nil,避免循环引用;
  • 坑点5:混淆RunLoop与GCD定时器 → 解决方案:高精度定时用GCD定时器,普通定时用RunLoop的Timer(更轻量)。

六、核心总结

  1. RunLoop核心:线程的事件调度中枢,靠mach_msg实现“休眠-唤醒”,不占用多余CPU;

  2. 组件核心:Mode隔离事件,Source提供事件,Timer定时,Observer监控;

  3. Swift选型:上层用RunLoop(便捷),底层用CFRunLoopRef(线程安全);

  4. 实战价值:解决卡顿、线程保活、定时器失效等底层问题,是iOS高级开发必备技能;补充:吃透RunLoop,能快速定位APP性能瓶颈,避免因底层认知不足导致的隐蔽bug。

iOS 线程常驻(RunLoop 保活)实战:原理、优劣、避坑与双语言实现

作者 择势
2026年4月18日 13:37

作为 iOS 资深开发,线程常驻是底层线程开发的高阶技能,核心用于高频轻量任务、音视频数据流、长连接等极致性能场景。它的本质是通过 RunLoop 保活子线程,让线程执行完任务后不销毁,一直等待新任务。

本文将从核心原理、优劣分析、生产级高级写法、避免方案四个维度深度拆解,并提供 Objective-C + Swift 双语言完整示例。


一、核心原理:线程常驻的底层逻辑

1. 默认线程生命周期

iOS 普通子线程(NSThread/pthread)执行流程:创建线程 → 执行任务 → 任务完成 → 线程自动销毁缺点:频繁创建 / 销毁线程会产生巨大性能开销。

2. 线程常驻核心机制

RunLoop 保活:给子线程绑定一个无限循环的 RunLoop,添加空输入源防止 RunLoop 立即退出,让线程进入休眠状态(不消耗 CPU),实现永久存活。

  • 关键 API:CFRunLoopAddSource(添加保活源)、CFRunLoopRun(启动循环)、CFRunLoopStop(停止循环)
  • 核心:RunLoop 不退出 → 线程不销毁

3. 适用边界

仅用于高频、轻量、低延迟任务(日志上报、埋点、音视频编解码、长连接心跳);普通业务绝对禁止使用。


二、线程常驻的 优势 VS 劣势(资深视角)

✅ 核心优势

  1. 极致性能:避免线程频繁创建 / 销毁(线程是操作系统重量级资源,创建耗时≈100ms)
  2. 低延迟响应:任务直达常驻线程,无线程创建耗时
  3. 资源可控:专用线程处理特定任务,不与业务线程竞争
  4. 长连接保活:网络长连接、音视频流必须用常驻线程保证链路不中断

❌ 致命劣势

  1. 内存泄漏风险:忘记停止 RunLoop → 线程永久驻留内存,无法释放
  2. 系统资源浪费:常驻线程会占用系统线程池配额,过多会导致 APP 卡顿
  3. 维护成本极高:手动管理 RunLoop、线程安全、生命周期,极易出现死锁 / 野指针
  4. 违背系统设计:GCD/NSOperation 已自动实现线程复用,手动常驻是兜底方案

三、线程常驻 高级写法(生产级封装)

基础版仅用于理解原理,工程中必须用高级封装版:单例复用、线程安全任务队列、优雅退出、无内存泄漏。线程常驻仅支持 NSThread(pthread),GCD 无法手动实现常驻(系统自动管理线程)。

方案 1:Objective-C 高级常驻线程

objectivec

#import <Foundation/Foundation.h>

@interface ResidentThread : NSObject
/// 单例全局常驻线程
+ (instancetype)sharedThread;
/// 异步执行任务
- (void)executeTask:(dispatch_block_t)task;
/// 优雅退出线程(必须调用,防止内存泄漏)
- (void)stopThread;
@end

// ====================== 实现 ======================
#import "ResidentThread.h"

@interface ResidentThread ()
@property (nonatomic, strong) NSThread *residentThread; // 常驻线程
@property (nonatomic, assign) BOOL isStopped;            // 退出标记
@property (nonatomic, strong) NSLock *lock;               // 线程安全锁
@property (nonatomic, strong) NSMutableArray *taskArray; // 任务队列
@end

@implementation ResidentThread

+ (instancetype)sharedThread {
    static ResidentThread *instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[self alloc] init];
    });
    return instance;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        _isStopped = NO;
        _lock = [[NSLock alloc] init];
        _taskArray = [NSMutableArray array];
        // 创建常驻线程
        __weak typeof(self) weakSelf = self;
        self.residentThread = [[NSThread alloc] initWithTarget:weakSelf selector:@selector(runLoopAction) object:nil];
        self.residentThread.name = @"com.app.resident.thread";
        [self.residentThread start];
    }
    return self;
}

/// RunLoop 保活核心方法
- (void)runLoopAction {
    @autoreleasepool {
        // 1. 添加空输入源,防止RunLoop立即退出
        CFRunLoopSourceContext context = {0};
        CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
        CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
        CFRelease(source);
        
        // 2. 启动RunLoop循环(休眠状态,不消耗CPU)
        while (!self.isStopped) {
            // 执行队列中的任务
            [self.lock lock];
            if (self.taskArray.count > 0) {
                dispatch_block_t task = self.taskArray.firstObject;
                [self.taskArray removeObjectAtIndex:0];
                task();
            }
            [self.lock unlock];
            
            // RunLoop 运行1秒,循环检测
            CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, NO);
        }
        
        // 3. 停止RunLoop,线程销毁
        CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
        NSLog(@"常驻线程已销毁");
    }
}

/// 异步添加任务
- (void)executeTask:(dispatch_block_t)task {
    if (!task || self.isStopped) return;
    [self.lock lock];
    [self.taskArray addObject:task];
    [self.lock unlock];
}

/// 优雅退出
- (void)stopThread {
    if (self.isStopped) return;
    self.isStopped = YES;
    // 停止RunLoop
    CFRunLoopStop(CFRunLoopGetCurrent());
    self.residentThread = nil;
}

@end

方案 2:Swift 高级常驻线程

swift

import Foundation

final class ResidentThread {
    // 单例
    static let shared = ResidentThread()
    private init() {
        self.setupThread()
    }
    
    // MARK: - 私有属性
    private var thread: Thread!
    private var isStopped = false
    private let lock = NSLock()
    private var taskArray = [() -> Void]()
    
    // MARK: - 初始化常驻线程
    private func setupThread() {
        thread = Thread(target: self, selector: #selector(runLoopAction), object: nil)
        thread.name = "com.app.resident.thread.swift"
        thread.start()
    }
    
    // MARK: - RunLoop 保活核心
    @objc private func runLoopAction() {
        autoreleasepool {
            // 1. 添加空源,防止RunLoop退出
            let context = CFRunLoopSourceContext()
            let source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, context)
            CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .defaultMode)
            
            // 2. 循环执行任务
            while !isStopped {
                lock.lock()
                if !taskArray.isEmpty {
                    let task = taskArray.removeFirst()
                    task()
                }
                lock.unlock()
                
                // RunLoop 休眠1秒,低功耗
                CFRunLoopRunInMode(.defaultMode, 1.0, false)
            }
            
            // 3. 清理资源
            CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .defaultMode)
            print("Swift 常驻线程已销毁")
        }
    }
    
    // MARK: - 公开API
    /// 执行任务
    func execute(task: @escaping () -> Void) {
        guard !isStopped else { return }
        lock.lock()
        taskArray.append(task)
        lock.unlock()
    }
    
    /// 优雅退出
    func stop() {
        guard !isStopped else { return }
        isStopped = true
        CFRunLoopStop(CFRunLoopGetCurrent())
    }
}

双语言使用示例

objectivec

// OC 使用
- (void)testResidentThread {
    // 执行任务
    [[ResidentThread sharedThread] executeTask:^{
        NSLog(@"OC 常驻线程执行任务:%@", [NSThread currentThread]);
    }];
    
    // 页面销毁/模块销毁时,必须调用退出!
    // [[ResidentThread sharedThread] stopThread];
}

swift

// Swift 使用
func testResidentThread() {
    // 执行任务
    ResidentThread.shared.execute {
        print("Swift 常驻线程执行任务:Thread.current)")
    }
    
    // 必须在合适时机退出
    // ResidentThread.shared.stop()
}

四、如何避免线程常驻?(最优工程实践)

99% 的业务场景,完全不需要手动实现线程常驻!苹果的 GCD / NSOperation 已经内置了线程池复用机制,系统自动管理线程生命周期,比手动常驻更安全、更高效。

替代方案 1:GCD 串行队列(系统自动复用线程)

GCD 会复用空闲线程,不会频繁创建 / 销毁,完美替代手动常驻线程。

objectivec

// OC:GCD 复用线程(推荐)
dispatch_queue_t serialQueue = dispatch_queue_create("com.app.gcd.serial", DISPATCH_QUEUE_SERIAL);
- (void)gcdTask {
    dispatch_async(serialQueue, ^{
        NSLog(@"GCD 复用线程:%@", [NSThread currentThread]);
    });
}

swift

// Swift:GCD 复用线程
private let serialQueue = DispatchQueue(label: "com.app.gcd.serial.swift")
func gcdTask() {
    serialQueue.async {
        print("GCD 复用线程:Thread.current)")
    }
}

替代方案 2:NSOperationQueue(可控并发)

swift

// Swift 操作队列
private let operationQueue = OperationQueue()
init() {
    operationQueue.maxConcurrentOperationCount = 1 // 串行复用
}
func operationTask() {
    let op = BlockOperation {
        print("NSOperation 复用线程")
    }
    operationQueue.addOperation(op)
}

避免线程常驻的核心原则

  1. 普通业务 → 用 GCD:系统自动线程复用,零维护成本
  2. 复杂任务 → 用 NSOperation:支持依赖 / 取消,自动管理线程
  3. 绝对禁止:无理由创建手动常驻线程
  4. 必须用常驻:仅音视频、长连接、低延迟心跳等极致场景

五、关键避坑指南

  1. 必须优雅退出:页面 / 模块销毁时,一定要调用 stopThread 停止 RunLoop,否则内存泄漏
  2. 禁止多开:整个 APP 最多创建 1~2 个 常驻线程,过多会耗尽系统线程资源
  3. 线程安全:任务队列必须加锁,防止多线程读写崩溃
  4. 禁止 UI 操作:常驻线程是子线程,绝对不能更新 UI
  5. 低功耗设计:RunLoop 使用 RunInMode 定时休眠,不要无限循环消耗 CPU

总结

  1. 核心原理:线程常驻 = RunLoop 保活,是底层性能优化方案
  2. 高级写法:生产级必须封装单例 + 线程安全队列 + 优雅退出
  3. 优劣:性能极致但风险极高,仅用于特殊场景
  4. 最优解优先用 GCD/NSOperation,系统自动线程复用,避免手动常驻
  5. 生命周期:常驻线程必须手动退出,否则永久泄漏

【AFNetworking】OC 时代网络请求事实标准,Alamofire 的前身

作者 探索者dx
2026年4月14日 00:39

【AFNetworking】OC 时代网络请求事实标准,Alamofire 的前身

iOS三方库精读 · 第 9 期


一、一句话介绍

AFNetworking 是 iOS / macOS 平台上最早也是最成功的 Objective-C HTTP 网络库,它让网络请求从繁琐的 NSURLConnection / NSURLSession API 变得简洁优雅,成为 OC 时代的事实标准。

属性
GitHub Stars 33k+
最新版本 4.0.0
License MIT
支持平台 iOS 9+ / macOS 10.10+ / watchOS / tvOS
维护状态 维护模式(Maintenance Mode)

二、为什么选择它

原生痛点(2011 年的 iOS 世界)

在没有 AFNetworking 之前,iOS 开发者不得不面对:

  • NSURLConnection 样板代码:每次请求都要实现 delegate 回调,代码分散难以维护
  • JSON 解析手动处理:iOS 5 之前没有 NSJSONSerialization,需要第三方库
  • 图片加载无缓存:网络图片每次都要重新下载,没有内存/磁盘缓存
  • 网络状态监听复杂:Reachability API 繁琐,代码量大
  • HTTPS 配置困难:自签名证书、SSL Pinning 需要深入了解安全 API

AFNetworking 核心优势

  1. 一行代码发起请求:GET / POST / PUT / DELETE 统一 API,无需 delegate
  2. 自动序列化:JSON / XML / Plist / Image 响应自动解析
  3. UIImageView CategorysetImageWithURL: 一行搞定网络图片加载
  4. Reachability 内建:实时监听网络状态,断网自动提示
  5. SSL Pinning 支持:证书校验一行配置,安全合规
  6. Block 回调:告别分散的 delegate,代码逻辑更清晰

原生 API vs AFNetworking

场景 原生 NSURLSession AFNetworking
GET 请求 10+ 行代码 + delegate [manager GET:success:failure:]
JSON 解析 NSJSONSerialization 手动调用 自动解析为 NSDictionary / NSArray
图片加载 需自行实现缓存 setImageWithURL: 一行
网络监听 Reachability 复杂 API setReachabilityStatusChangeBlock:
文件上传 构造 multipart 请求繁琐 POST:constructingBodyWithBlock:

三、核心功能速览

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

环境配置

CocoaPods(OC 项目首选)

pod 'AFNetworking', '~> 4.0'

Swift Package Manager

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

Swift 项目桥接

// {ProjectName}-Bridging-Header.h
#import <AFNetworking/AFNetworking.h>
#import "UIImageView+AFNetworking.h"

基础 GET 请求

// AFNetworking 4.x (OC)
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];

[manager GET:@"https://jsonplaceholder.typicode.com/users"
  parameters:nil
    progress:nil
     success:^(NSURLSessionDataTask *task, id responseObject) {
         NSLog(@"成功: %@", responseObject);
     }
     failure:^(NSURLSessionDataTask *task, NSError *error) {
         NSLog(@"失败: %@", error.localizedDescription);
     }];

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

POST 请求带参数

NSDictionary *params = @{
    @"name": @"张三",
    @"email": @"zhangsan@example.com",
    @"age": @28
};

[manager POST:@"https://api.example.com/users"
   parameters:params
     progress:nil
      success:^(NSURLSessionDataTask *task, id responseObject) {
          NSLog(@"创建成功: %@", responseObject);
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          NSLog(@"创建失败: %@", error);
      }];

文件上传

NSData *imageData = UIImageJPEGRepresentation(image, 0.8);

[manager POST:@"https://api.example.com/upload"
   parameters:nil
    constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
        [formData appendPartWithFileData:imageData
                                    name:@"file"
                                fileName:@"photo.jpg"
                               mimeType:@"image/jpeg"];
    }
     progress:^(NSProgress *progress) {
         NSLog(@"上传进度: %.0f%%", progress.fractionCompleted * 100);
     }
      success:^(NSURLSessionDataTask *task, id responseObject) {
          NSLog(@"上传成功");
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          NSLog(@"上传失败: %@", error);
      }];

图片加载(UIImageView Category)

#import "UIImageView+AFNetworking.h"

// 基础用法
[imageView setImageWithURL:[NSURL URLWithString:@"https://example.com/avatar.jpg"]];

// 带占位图
[imageView setImageWithURL:[NSURL URLWithString:@"https://example.com/avatar.jpg"]
         placeholderImage:[UIImage imageNamed:@"default_avatar"]];

// 取消加载(cell 复用场景)
[imageView cancelImageDownloadTask];

网络状态监听

[[AFNetworkReachabilityManager sharedManager] setReachabilityStatusChangeBlock:
    ^(AFNetworkReachabilityStatus status) {
        switch (status) {
            case AFNetworkReachabilityStatusNotReachable:
                NSLog(@"无网络连接");
                break;
            case AFNetworkReachabilityStatusReachableViaWiFi:
                NSLog(@"WiFi 连接");
                break;
            case AFNetworkReachabilityStatusReachableViaWWAN:
                NSLog(@"蜂窝网络");
                break;
            default:
                break;
        }
    }];

[[AFNetworkReachabilityManager sharedManager] startMonitoring];

HTTPS 证书校验

manager.securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate];
manager.securityPolicy.allowInvalidCertificates = YES;  // 允许自签名
manager.securityPolicy.validatesDomainName = YES;

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

核心类关系

AFURLSessionManager (核心)
    ├── AFHTTPSessionManager (HTTP 封装)
    │       ├── requestSerializer (请求序列化)
    │       └── responseSerializer (响应序列化)
    ├── AFSecurityPolicy (安全策略)
    └── AFNetworkReachabilityManager (网络监听)

Category 扩展:
    UIImageView+AFNetworking (图片加载)
    UIProgressView+AFNetworking (进度条)
    AFNetworkActivityIndicatorManager (状态栏网络指示器)

Session Manager 核心设计

// AFHTTPSessionManager 继承 AFURLSessionManager
// 职责分离:网络层、请求构建、响应解析各自独立

@interface AFHTTPSessionManager : AFURLSessionManager
@property (nonatomic, strong) AFHTTPRequestSerializer <AFURLRequestSerialization> *requestSerializer;
@property (nonatomic, strong) AFHTTPResponseSerializer <AFURLResponseSerialization> *responseSerializer;
@end

// 序列化协议设计
@protocol AFURLRequestSerialization <NSObject>
- (NSURLRequest *)requestBySerializingRequest:(NSURLRequest *)request
                                withParameters:(id)parameters
                                         error:(NSError * __autoreleasing *)error;
@end

四、实战演示

场景:完整的网络请求封装

// APIClient.h - 统一网络请求封装
#import <AFNetworking/AFNetworking.h>

@interface APIClient : AFHTTPSessionManager
+ (instancetype)sharedClient;

// 业务方法
- (void)fetchUsers:(void(^)(NSArray *users, NSError *error))completion;
- (void)createUser:(NSDictionary *)params
        completion:(void(^)(NSDictionary *user, NSError *error))completion;
- (void)uploadAvatar:(UIImage *)image
           completion:(void(^)(NSString *url, NSError *error))completion;
@end

// APIClient.m
@implementation APIClient

+ (instancetype)sharedClient {
    static APIClient *instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        NSURL *baseURL = [NSURL URLWithString:@"https://api.example.com/"];
        instance = [[self alloc] initWithBaseURL:baseURL];
        
        // 配置请求/响应序列化
        instance.requestSerializer = [AFJSONRequestSerializer serializer];
        instance.responseSerializer = [AFJSONResponseSerializer serializer];
        instance.requestSerializer.timeoutInterval = 30;
        
        // 添加通用请求头
        [instance.requestSerializer setValue:@"application/json"
                             forHTTPHeaderField:@"Content-Type"];
        
        // HTTPS 配置
        instance.securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeNone];
    });
    return instance;
}

- (void)fetchUsers:(void(^)(NSArray *users, NSError *error))completion {
    [self GET:@"users"
      parameters:nil
        progress:nil
         success:^(NSURLSessionDataTask *task, id responseObject) {
             completion(responseObject, nil);
         }
         failure:^(NSURLSessionDataTask *task, NSError *error) {
             completion(nil, error);
         }];
}

- (void)createUser:(NSDictionary *)params
        completion:(void(^)(NSDictionary *user, NSError *error))completion {
    [self POST:@"users"
   parameters:params
     progress:nil
      success:^(NSURLSessionDataTask *task, id responseObject) {
          completion(responseObject, nil);
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          completion(nil, error);
      }];
}

- (void)uploadAvatar:(UIImage *)image
           completion:(void(^)(NSString *url, NSError *error))completion {
    NSData *data = UIImageJPEGRepresentation(image, 0.8);
    
    [self POST:@"upload"
   parameters:nil
    constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
        [formData appendPartWithFileData:data
                                    name:@"avatar"
                                fileName:@"avatar.jpg"
                               mimeType:@"image/jpeg"];
    }
     progress:nil
      success:^(NSURLSessionDataTask *task, id responseObject) {
          NSString *url = responseObject[@"url"];
          completion(url, nil);
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          completion(nil, error);
      }];
}

@end

五、源码亮点

进阶层 值得借鉴的用法

序列化器组合模式

// 请求序列化器可插拔
manager.requestSerializer = [AFJSONRequestSerializer serializer];   // JSON
manager.requestSerializer = [AFPropertyListRequestSerializer serializer];  // Plist

// 响应序列化器可插拔
manager.responseSerializer = [AFJSONResponseSerializer serializer];  // JSON
manager.responseSerializer = [AFXMLParserResponseSerializer serializer];  // XML
manager.responseSerializer = [AFImageResponseSerializer serializer];  // Image

Block 回调替代 Delegate

// 传统 delegate 分散在多个方法
// AFNetworking 用 block 聚合逻辑
[manager GET:url
  parameters:params
    progress:^(NSProgress *progress) {
        // 进度回调
    }
     success:^(NSURLSessionDataTask *task, id responseObject) {
        // 成功回调
    }
     failure:^(NSURLSessionDataTask *task, NSError *error) {
        // 失败回调
    }];

深入层 设计思想解析

NSURLSession 封装架构

// AFURLSessionManager 核心方法
- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request
                            completionHandler:(void (^)(NSURLResponse *, id, NSError *))handler {
    // 1. 创建 task
    NSURLSessionDataTask *task = [self.session dataTaskWithRequest:request];
    
    // 2. 存储 task delegate(用于回调)
    self.mutableTaskDelegatesKeyedByTaskIdentifier[@(task.taskIdentifier)] = delegate;
    
    // 3. 返回 task
    return task;
}

// task delegate 处理各种回调
- (void)URLSession:(NSURLSession *)session
          dataTask:(NSURLSessionDataTask *)dataTask
    didReceiveData:(NSData *)data {
    // 累积数据,最终一次性回调
    [mutableData appendData:data];
}

UIImageView Category 的缓存设计

// UIImageView+AFNetworking 内部使用 AFImageDownloader
// 自动实现内存缓存 + 磁盘缓存

@interface AFImageDownloader : NSObject
@property (nonatomic, strong) AFImageCache *imageCache;  // 内存缓存
@property (nonatomic, strong) NSURLCache *urlCache;       // 磁盘缓存
@end

// 缓存策略
- (void)downloadImageForURLRequest:(NSURLRequest *)request
                        completion:(void (^)(UIImage *, NSError *))completion {
    // 1. 先查内存缓存
    // 2. 再查磁盘缓存
    // 3. 最后发起网络请求
}

六、踩坑记录

问题 1:iOS 9+ ATS 限制

问题:iOS 9 默认要求 HTTPS,HTTP 请求被阻止。

原因:App Transport Security (ATS) 默认禁止明文 HTTP。

解决

<!-- Info.plist 添加例外 -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

问题 2:Response Serializer 编码问题

问题:返回中文乱码或解析失败。

原因:服务器返回非 UTF-8 编码,AFJSONResponseSerializer 默认 UTF-8。

解决

AFHTTPResponseSerializer *serializer = [AFHTTPResponseSerializer serializer];
serializer.stringEncoding = NSUTF8StringEncoding;  // 或其他编码
manager.responseSerializer = serializer;

问题 3:Cell 复用图片错乱

问题:UITableView 快速滚动时,图片显示错误。

原因:Cell 复用时未取消之前的下载任务。

解决

- (void)prepareForReuse {
    [super prepareForReuse];
    [self.imageView cancelImageDownloadTask];  // 取消之前的下载
    self.imageView.image = nil;
}

问题 4:后台任务未处理

问题:App 进入后台后,下载任务中断。

原因:NSURLSession 默认不支持后台。

解决

// 使用后台 session configuration
NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"com.example.background"];
AFHTTPSessionManager *manager = [[AFHTTPSessionManager alloc] initWithSessionConfiguration:config];

问题 5:Swift 混编桥接头文件缺失

问题:Swift 项目中使用 AFNetworking 报找不到模块。

原因:未配置 Objective-C Bridging Header。

解决

  1. 创建 {ProjectName}-Bridging-Header.h 文件
  2. 添加 #import <AFNetworking/AFNetworking.h>
  3. Build Settings → Swift Compiler - General → Objective-C Bridging Header 设置路径

七、延伸思考

AFNetworking vs Alamofire 横向对比

维度 AFNetworking Alamofire
语言 Objective-C Swift
首发年份 2011 2014
GitHub Stars 33k+ 41k+
最低版本 iOS 9.0 iOS 12.0
包体积 ~500KB ~300KB
维护状态 维护模式 活跃开发
Combine 集成 ❌ 不支持 ✅ DataRequest.publisher
async/await ❌ 不支持 ✅ async(...)
SwiftUI 支持 ❌ 不支持 需自行封装
学习曲线 中等 低(链式 API)
OC 兼容性 原生 需桥接

API 对照迁移表

AFNetworking (OC) Alamofire (Swift)
[manager GET:success:failure:] AF.request(url).response()
[manager POST:parameters:success:failure:] AF.request(url, method: .post, parameters: params)
[manager downloadTaskWithRequest:progress:destination:completion:] AF.download(url).downloadProgress()
[manager uploadTaskWithRequest:from:progress:completion:] AF.upload(multipartFormData:)
AFNetworkReachabilityManager NWPathMonitor (系统 API)
AFSecurityPolicy ServerTrustManager + PinnedCertificatesTrustEvaluator
UIImageView+AFNetworking AlamofireImage / Kingfisher

选型建议

选 AFNetworking:

  • 维护遗留 OC 项目,迁移成本高
  • 项目需要支持 iOS 9 ~ iOS 11
  • 团队 OC 技术栈成熟,Swift 经验少

选 Alamofire:

  • 新项目,Swift 为主要语言
  • 需要 Combine / async-await 集成
  • 想要更活跃的社区和持续更新
  • 最低支持 iOS 12+

渐进式迁移策略

  1. 共存阶段:OC 模块继续用 AFNetworking,新 Swift 模块用 Alamofire
  2. 抽象层:统一封装 NetworkService 协议,底层可替换
  3. 按模块迁移:优先迁移独立的业务模块,而非逐个请求修改
  4. 测试覆盖:迁移前后确保集成测试通过

八、参考资源

官方资源

相关文章

系列 Demo 仓库


本期互动

小作业

查看你现有的 OC 项目中 AFNetworking 的使用方式,尝试将一个简单的 GET 请求改写为 Alamofire 版本,对比代码量和可读性变化。

思考题

AFNetworking 从 1.x 到 4.x 经历了 NSURLConnection → NSURLSession 的底层迁移,如果你是核心开发者,你会如何设计 API 以保持向后兼容?

读者征集

你在从 AFNetworking 迁移到 Alamofire 的过程中遇到过哪些坑?欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ Lottie] [✓ MarkdownUI] [✓ SDWebImage] [✓ SnapKit] [✓ ListDiff] [✓ RxSwift] [→ AFNetworking] [○ Charts]

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

【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

【RxSwift】Swift 版 ReactiveX,响应式编程优雅处理异步事件流

作者 探索者dx
2026年4月10日 23:20

【RxSwift】Swift 版 ReactiveX,响应式编程优雅处理异步事件流

iOS三方库精读 · 第 8 期


一、一句话介绍

RxSwift 是 ReactiveX 的 Swift 实现,将异步操作和事件统一为可观察序列(Observable),通过操作符进行声明式组合变换,极大简化复杂异步逻辑。

属性
GitHub Stars 24.5k+
最新版本 6.7.0
License MIT
支持平台 iOS 9+ / macOS 10.10+ / watchOS / tvOS

二、为什么选择它

原生痛点

在没有 RxSwift 之前,处理异步事件流往往面临:

  • 回调地狱:嵌套的网络请求、多层 callback 难以维护
  • 状态同步:UI 与数据模型的双向绑定需要大量 KVO / Notification / Delegate 样板代码
  • 线程切换:GCD 和 OperationQueue 手动管理容易出错
  • 错误处理:分散在各处的 try-catch,遗漏处理导致崩溃
  • 事件取消:Timer、Notification 观察者忘记移除,造成内存泄漏

RxSwift 核心优势

  1. 统一异步模型:网络请求、通知、KVO、Timer、手势统统归为 Observable,一套 API 走天下
  2. 声明式组合:通过 map/filter/flatMap 等操作符链式调用,代码如流水般清晰
  3. 自动资源管理:DisposeBag 机制确保订阅随生命周期自动释放
  4. 丰富的操作符:100+ 操作符覆盖变换、过滤、组合、错误处理、调度等场景
  5. RxCocoa 扩展:UIKit 控件开箱即用的双向绑定(UITextField、UIButton、UITableView 等)

原生 API vs RxSwift

场景 原生方式 RxSwift 方式
网络请求 URLSession + closure 嵌套 Observable + flatMap 链式
输入框实时搜索 addTarget + Timer 去抖 rx.text + debounce
表单验证 多个 KVO / Delegate combineLatest 一行搞定
Timer 管理 Timer.scheduledTimer + invalidate Observable.interval + disposed(by:)
通知监听 NotificationCenter + removeObserver NotificationCenter.rx.notification

三、核心功能速览

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

环境配置

Swift Package Manager(推荐)

// Package.swift
dependencies: [
    .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.7.0")
]

CocoaPods

pod 'RxSwift', '~> 6.7'
pod 'RxCocoa', '~> 6.7'  # UIKit 扩展
pod 'RxRelay', '~> 6.7'  # Relay 组件

核心概念:Observable 三部曲

import RxSwift

let disposeBag = DisposeBag()

// 1. 创建 Observable
let observable = Observable<String>.create { observer in
    observer.onNext("Hello")
    observer.onNext("RxSwift")
    observer.onCompleted()
    return Disposables.create()
}

// 2. 订阅
observable
    .subscribe(
        onNext: { print("收到: \($0)") },
        onCompleted: { print("完成") }
    )
    // 3. 管理 disposal
    .disposed(by: disposeBag)

// 输出:
// 收到: Hello
// 收到: RxSwift
// 完成

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

Subject 与 Relay

// PublishSubject: 只收到订阅后的事件
let publish = PublishSubject<String>()
publish.onNext("A")        // 不会收到
publish.subscribe { print($0) }  // 订阅
publish.onNext("B")        // ✅ 收到

// BehaviorSubject: 保留最新一个值,新订阅者立即收到
let behavior = BehaviorSubject(value: "初始值")
behavior.onNext("新值")
behavior.subscribe { print($0) }  // ✅ 立即收到 "新值"

// BehaviorRelay: UI 安全,永不触发 error/completed
let relay = BehaviorRelay(value: "初始值")
relay.accept("更新值")     // 用 accept 替代 onNext
relay.asObservable().subscribe { print($0) }

常用操作符

// 过滤
Observable.of(1, 2, 3, 4, 5)
    .filter { $0 % 2 == 0 }     // [2, 4]

// 变换
Observable.of(1, 2, 3)
    .map { $0 * 2 }             // [2, 4, 6]

// 去重
Observable.of("a", "b", "a", "c")
    .distinctUntilChanged()     // ["a", "b", "a", "c"] - 相邻去重

// 扁平化(网络请求链式)
searchText
    .flatMapLatest { query in
        return networkAPI.search(query)  // 自动取消上一个请求
    }

// 组合
Observable.combineLatest(
    usernameValid,
    passwordValid
) { $0 && $1 }                   // 表单验证

线程调度

Observable<String>.create { observer in
    // 后台执行耗时任务
    Thread.sleep(forTimeInterval: 1)
    observer.onNext("计算结果")
    observer.onCompleted()
    return Disposables.create()
}
.subscribe(on: ConcurrentDispatchQueueScheduler(qos: .background))  // 订阅线程
.observe(on: MainScheduler.instance)                               // 观察线程
.subscribe(onNext: { value in
    // UI 更新在主线程
    label.text = value
})
.disposed(by: disposeBag)

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

核心协议关系

ObservableType (协议)
    ↓
Observable (class)
    ↓ 创建
AnyObserver (观察者抽象)
    ↓ 订阅
Disposable (取消订阅)
    ↓ 持有
DisposeBag (资源回收容器)

Observer 模式实现

// Observable.subscribe 核心流程
func subscribe(_ observer: Observer) -> Disposable {
    // 1. 包装 observer
    let disposable = Disposables.create()
    
    // 2. 调用核心生产逻辑
    let sink = AnonymousSink(observer: observer, dispose: disposable)
    
    // 3. 返回 disposable 用于取消
    return disposable
}

// dispose 时移除订阅
final class DisposeBag {
    var disposables: [Disposable] = []
    deinit {
        disposables.forEach { $0.dispose() }
    }
}

四、实战演示

场景:实时搜索 + 表单验证 + TableView 绑定

import UIKit
import RxSwift
import RxCocoa

final class LoginViewController: UIViewController {
    private let disposeBag = DisposeBag()
    
    // UI
    private let emailField = UITextField()
    private let passwordField = UITextField()
    private let loginButton = UIButton(type: .system)
    private let tableView = UITableView()
    
    // 数据源
    private let suggestions = BehaviorRelay<[String]>(value: [])
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupBindings()
    }
    
    private func setupBindings() {
        // 1. 实时搜索(防抖 500ms + 去重)
        emailField.rx.text.orEmpty
            .debounce(.milliseconds(500), scheduler: MainScheduler.instance)
            .distinctUntilChanged()
            .filter { !$0.isEmpty }
            .flatMapLatest { [weak self] query -> Observable<[String]> in
                // 模拟搜索建议 API
                return self?.searchSuggestions(query) ?? .just([])
            }
            .bind(to: suggestions)
            .disposed(by: disposeBag)
        
        // 2. 表单验证(多输入组合)
        let emailValid = emailField.rx.text.orEmpty
            .map { $0.contains("@") && $0.contains(".") }
        
        let passwordValid = passwordField.rx.text.orEmpty
            .map { $0.count >= 6 }
        
        Observable.combineLatest(emailValid, passwordValid) { $0 && $1 }
            .bind(to: loginButton.rx.isEnabled)
            .disposed(by: disposeBag)
        
        // 3. TableView 数据绑定
        suggestions
            .bind(to: tableView.rx.items(cellIdentifier: "Cell")) { _, text, cell in
                cell.textLabel?.text = text
            }
            .disposed(by: disposeBag)
        
        // 4. 点击事件
        loginButton.rx.tap
            .withLatestFrom(Observable.combineLatest(
                emailField.rx.text.orEmpty,
                passwordField.rx.text.orEmpty
            ))
            .subscribe(onNext: { [weak self] email, password in
                self?.performLogin(email: email, password: password)
            })
            .disposed(by: disposeBag)
    }
    
    private func searchSuggestions(_ query: String) -> Observable<[String]> {
        // 模拟 API 请求
        return Observable.just(["\(query)@gmail.com", "\(query)@icloud.com"])
            .delay(.milliseconds(300), scheduler: MainScheduler.instance)
    }
    
    private func performLogin(email: String, password: String) {
        print("登录: \(email), 密码: \(password)")
    }
}

五、源码亮点

进阶层 值得借鉴的用法

操作符链式调用的 Builder 模式

// 每个操作符返回新的 Observable,支持无限链式
observable
    .map { transform($0) }       // 返回 Map<Source>
    .filter { predicate($0) }    // 返回 Filter<Map<Source>>
    .subscribe { ... }           // 返回 Disposable

takeUntil 实现自动取消

// 当 self.deallocated 时自动取消网络请求
networkRequest()
    .takeUntil(self.rx.deallocated)
    .subscribe(onNext: { ... })

深入层 设计思想解析

Producer-Consumer 模式

// Observable 是 Producer,产生事件
class Producer<Element>: Observable<Element> {
    func run(_ observer: Observer, cancel: Cancel) -> Sink {
        // 子类实现具体生产逻辑
    }
}

// Sink 是 Consumer,消费事件并管理生命周期
class Sink<Observer: ObserverType>: Disposable {
    let observer: Observer
    var disposed = false
    
    func dispose() {
        disposed = true
    }
}

操作符的实现模式

map 为例:

final class MapSink<Source, Result>: Sink<Result>, ObserverType {
    typealias Element = Source
    
    private let transform: (Source) -> Result
    
    func on(_ event: Event<Source>) {
        switch event {
        case .next(let element):
            let result = transform(element)  // 变换
            forwardOn(.next(result))         // 传递给下游
        case .error(let error):
            forwardOn(.error(error))
        case .completed:
            forwardOn(.completed)
        }
    }
}

六、踩坑记录

问题 1:订阅未释放导致内存泄漏

问题:在 ViewController 中订阅 Observable,页面释放后订阅仍在执行。

原因:未将 Disposable 加入 DisposeBag,或 DisposeBag 生命周期与 VC 不一致。

解决

// ❌ 错误:未持有 Disposable
observable.subscribe { ... }

// ✅ 正确:加入 DisposeBag
private let disposeBag = DisposeBag()
observable.subscribe { ... }
    .disposed(by: disposeBag)

问题 2:UI 更新不在主线程

问题:后台网络请求回调中更新 UI 导致崩溃。

原因:默认情况下 Observable 继承订阅者的线程上下文。

解决

// ❌ 错误:可能在后台线程
networkRequest()
    .subscribe(onNext: { label.text = $0 })

// ✅ 正确:显式切换到主线程
networkRequest()
    .observe(on: MainScheduler.instance)
    .subscribe(onNext: { label.text = $0 })

问题 3:flatMap 与 flatMapLatest 混淆

问题:快速输入搜索关键词,收到旧的请求结果。

原因flatMap 会保留所有内部 Observable,flatMapLatest 会自动取消上一个。

解决

// ❌ 错误:旧请求可能覆盖新结果
searchText.flatMap { searchAPI($0) }

// ✅ 正确:自动取消上一个请求
searchText.flatMapLatest { searchAPI($0) }

问题 4:Subject 发送 completed 后无法复用

问题:PublishSubject 发送 .completed 后,后续订阅收不到事件。

原因:Subject 一旦 terminated,状态不可逆转。

解决

// 方案 1:使用 Relay(不发送 completed)
let relay = PublishRelay<String>()

// 方案 2:重新创建 Subject
func resetSubject() {
    subject = PublishSubject<String>()
}

问题 5:share(replay:) 重复执行副作用

问题:多个订阅者导致网络请求被执行多次。

原因:默认每个订阅者独立触发 Observable 执行。

解决

// ❌ 错误:每个订阅触发一次请求
let request = api.fetchData()
request.subscribe { ... }  // 请求 1
request.subscribe { ... }  // 请求 2

// ✅ 正确:共享执行结果
let request = api.fetchData()
    .share(replay: 1)       // 缓存最近 1 个结果
request.subscribe { ... }   // 请求 1
request.subscribe { ... }   // 复用结果

问题 6:withUnretained 造成循环引用

问题:使用 withUnretained(self) 仍出现循环引用。

原因:闭包内额外强引用了 self。

解决

// ❌ 错误:闭包内强引用
observable
    .withUnretained(self)
    .subscribe { self, value in
        self.items.append(value)  // 强引用
    }

// ✅ 正确:使用 weak 或确保无循环
observable
    .subscribe(onNext: { [weak self] value in
        self?.items.append(value)
    })

七、延伸思考

RxSwift vs Combine 横向对比

维度 RxSwift Combine
开发商 社区 (ReactiveX) Apple 官方
最低版本 iOS 9+ iOS 13+
操作符数量 100+ 极其丰富 ~50 够用但较少
UI 绑定 RxCocoa 内建 需自行封装或用 SwiftUI
调试支持 RxSwift.Resources / debug() print() / handleEvents()
学习曲线 较陡(概念多) 中等
包体积 ~2MB 系统内建,0 额外
SwiftUI 集成 需桥接 原生支持
维护状态 活跃 Apple 官方维护

选型建议

选 RxSwift:

  • 项目需要兼容 iOS 13 以下
  • 团队有 RxJava / RxJS 经验,希望统一范式
  • 需要大量 UIKit 双向绑定(RxCocoa 非常成熟)
  • 需要 withLatestFrom 等 Combine 缺失的操作符

选 Combine:

  • 纯 SwiftUI 项目,Combine 原生集成最流畅
  • 不想引入第三方依赖,减少包体积
  • 新项目最低版本 ≥ iOS 13

迁移建议

对于已有 RxSwift 项目:

  • 短期内无需迁移,RxSwift 维护状态良好
  • 新增 SwiftUI 页面可用 Combine,与 RxSwift 共存
  • 使用 RxCombine 库实现互相转换

八、参考资源

官方资源

推荐文章

系列 Demo 仓库


本期互动

小作业

尝试用 RxSwift 实现一个「搜索建议」功能:输入框输入时防抖 300ms,发起网络请求获取建议列表,展示在 UITableView 中,并在评论区贴出你的关键代码片段。

思考题

如果让你从零实现 RxSwift 的 debounce 操作符,你会如何设计?需要考虑哪些边界情况?

读者征集

你在使用 RxSwift 时踩过哪些坑?欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ Lottie] [✓ MarkdownUI] [✓ SDWebImage] [✓ SnapKit] [✓ ListDiff] [→ RxSwift] [○ Charts]

【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期:待定

❌
❌