普通视图

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

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

【SnapKit】优雅的 Swift Auto Layout DSL 库

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

【SnapKit】优雅的 Swift Auto Layout DSL 库

iOS三方库精读 · 第 4 期


一、一句话介绍

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

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

二、为什么选择它

原生 NSLayoutConstraint 的痛点

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

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

SnapKit 的核心优势

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

三、核心功能速览

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

环境要求与集成

SPM 集成:

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

CocoaPods 集成:

pod 'SnapKit', '~> 5.7.0'

最简单的使用示例

import SnapKit

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

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

常用 API 一览

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

常见用法组合

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

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

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

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

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

核心模块介绍

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

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

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

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

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


四、实战演示

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

import UIKit
import SnapKit

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

关键要点:

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

五、源码亮点

进阶层:值得借鉴的用法

链式调用的实现技巧

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

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

类型安全的约束目标

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

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

深入层:设计思想解析

Builder 模式的应用

ConstraintMaker 是 Builder 模式的典型应用:

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

Protocol-Oriented Programming

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

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

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


六、踩坑记录

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

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

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

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

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

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

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

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

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

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

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

问题 3:在 UITableViewCell 中布局问题

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

七、延伸思考

与同类库的横向对比

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

推荐使用场景

推荐使用:

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

不推荐使用:

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

关于 Cartography 的说明

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


八、参考资源


本期互动

小作业

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

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

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

思考题

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

读者征集

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

下一期选题投票:

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

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

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

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

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

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


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

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

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


先把概念说明白

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

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

用一句话概括差异:

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


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

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

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

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

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

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

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

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

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

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

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

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


那可重入会带来哪些坑?

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

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

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

actor BankAccount {
    var balance: Double = 1000

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

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

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

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

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

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

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

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

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

        isProcessing = false
    }
}

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

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

actor BankAccount {
    var balance: Double = 1000

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

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


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

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

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

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

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


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

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

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

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


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

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

Apple 为什么必须选可重入

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

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

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

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

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


实际项目中的选择建议

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

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

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

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

总结

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

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


延伸思考

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

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


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


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

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

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

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

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


一、一句话介绍

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

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

二、为什么选择它

原生异步方案的痛点

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

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

核心优势:

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

三、核心功能速览

基础层(新手必读)

无需配置,开箱即用

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

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

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

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

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

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


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

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

传统 GCD 回调的线程行为

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

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

async/await 的线程行为

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

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

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

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

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

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

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

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

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

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

Swift Concurrency 的线程池设计

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

一句话总结:

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

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


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

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

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

进阶层(最佳实践)

async let:并行执行多个任务

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

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

withTaskGroup:动态数量的并行任务

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

Task:非结构化任务与取消

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

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

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

Continuation:桥接旧式回调 API

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

深入层(源码视角)

核心模块职责划分

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

四、实战演示

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

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

// Swift 5.5+

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


五、源码亮点

进阶层:值得借鉴的设计

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

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

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

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

AsyncStream 资源安全回收

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

深入层:设计思想解析

结构化并发的思想来源

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

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

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

三大核心约束

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

非结构化 vs 结构化对比

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

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

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


结构化并发:任务树模型

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

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

关键特性:

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

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


Actor 的可重入设计

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

actor BankAccount {
    var balance: Double = 1000

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

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

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


六、踩坑记录

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

七、延伸思考

与同类方案横向对比

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

推荐使用场景

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

不推荐场景

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

迁移策略建议

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

八、参考资源


九、本期互动

小作业

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

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

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


思考题

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

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


读者征集

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

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


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

【Alamofire】优雅的 Swift 网络库——告别繁琐的 URLSession

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

【Alamofire】优雅的 Swift 网络库——告别繁琐的 URLSession

iOS三方库精读 · 第 1 期


一、一句话介绍

Alamofire 是一个用于 iOS / macOS / watchOS / tvOS 的 Swift HTTP 网络库,它让发起网络请求、处理响应、上传/下载文件变得声明式、可组合、且极易阅读。

属性 信息
⭐ GitHub Stars ~41k
最新版本 5.x(当前 5.9+)
License MIT
支持平台 iOS 10+ / macOS 10.12+ / tvOS 10+ / watchOS 3+
Swift 最低版本 Swift 5.7+

二、为什么选择它

原生 URLSession 的痛点

苹果的 URLSession 功能完整,但在工程实践中会遇到这些问题:

原生 URLSession Alamofire
需要手动构建 URLRequest 链式 API,一行发起请求
响应解析需要大量样板代码 内建 Decodable 自动解析
上传/下载进度管理繁琐 原生支持进度回调
拦截器/重试需要自行实现 内建 RequestInterceptor
错误处理分散、不统一 统一的 AFError 体系

核心优势:

  • 声明式链式调用,代码意图一目了然
  • 内建 JSON/Decodable 解析,减少胶水代码
  • RequestInterceptor:拦截、重试、Token 刷新统一处理
  • EventMonitor:全链路可观测,调试/日志非常方便
  • async/await 原生支持(5.5 起)

三、核心功能速览

基础层(新手必读)

集成方式(SPM 推荐)

Package.swift 或 Xcode 的 Package Dependencies 中添加:

https://github.com/Alamofire/Alamofire.git

最简单的 GET 请求

// Swift 5.7+
import Alamofire

AF.request("https://httpbin.org/get").responseJSON { response in
    print(response.value ?? "No data")
}

使用 async/await(推荐)

let response = await AF.request("https://httpbin.org/get")
    .serializingDecodable(MyModel.self)
    .response

switch response.result {
case .success(let model): print(model)
case .failure(let error): print(error)
}

进阶层(最佳实践)

带参数的 POST 请求

let parameters: [String: Any] = ["username": "swift", "password": "123456"]

AF.request(
    "https://httpbin.org/post",
    method: .post,
    parameters: parameters,
    encoding: JSONEncoding.default,
    headers: ["Authorization": "Bearer your_token"]
)
.validate(statusCode: 200..<300)   // 自动校验状态码
.responseDecodable(of: LoginResponse.self) { response in
    // 直接拿到强类型 Model
}

文件上传(带进度)

AF.upload(
    multipartFormData: { form in
        form.append(fileData, withName: "file", fileName: "photo.jpg", mimeType: "image/jpeg")
    },
    to: "https://example.com/upload"
)
.uploadProgress { progress in
    print("上传进度:\(progress.fractionCompleted)")
}
.responseDecodable(of: UploadResult.self) { response in
    print(response.value)
}

文件下载

let destination = DownloadRequest.suggestedDownloadDestination()
AF.download("https://example.com/file.zip", to: destination)
    .downloadProgress { progress in
        print("下载进度:\(Int(progress.fractionCompleted * 100))%")
    }
    .responseURL { response in
        print("保存路径:\(response.fileURL)")
    }

深入层(源码视角)

Alamofire 5 的核心模块职责:

模块 职责
Session URLSession 的封装,全局入口(AF 是默认单例)
Request 体系 DataRequest / UploadRequest / DownloadRequest 三条请求链路
RequestInterceptor adapt 修改请求 + retry 重试逻辑分离
ResponseSerializer Data 转换为目标类型,可自定义扩展
EventMonitor 全链路事件监听,用于日志/埋点

四、实战演示

场景:带 Token 自动刷新的 API 客户端

这是工程中最常见的场景——Token 过期后自动刷新并重试原始请求。

// Swift 5.7+

// 1. 定义拦截器
final class AuthInterceptor: RequestInterceptor {
    private var accessToken: String = KeychainHelper.accessToken
    private var isRefreshing = false
    private var requestsToRetry: [RetryCompletion] = []

    // adapt:每次请求前注入 Token
    func adapt(_ urlRequest: URLRequest,
               for session: Session,
               completion: @escaping (Result<URLRequest, Error>) -> Void) {
        var request = urlRequest
        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
        completion(.success(request))
    }

    // retry:401 时触发刷新
    func retry(_ request: Request,
               for session: Session,
               dueTo error: Error,
               completion: @escaping RetryCompletion) {
        guard let response = request.task?.response as? HTTPURLResponse,
              response.statusCode == 401 else {
            completion(.doNotRetry)
            return
        }
        requestsToRetry.append(completion)
        guard !isRefreshing else { return }
        refreshToken { [weak self] success in
            self?.requestsToRetry.forEach { $0(success ? .retry : .doNotRetry) }
            self?.requestsToRetry.removeAll()
        }
    }

    private func refreshToken(completion: @escaping (Bool) -> Void) {
        isRefreshing = true
        AF.request("https://api.example.com/refresh",
                   method: .post,
                   parameters: ["refreshToken": KeychainHelper.refreshToken])
            .responseDecodable(of: TokenResponse.self) { [weak self] response in
                self?.isRefreshing = false
                if let token = response.value?.accessToken {
                    self?.accessToken = token
                    KeychainHelper.accessToken = token
                    completion(true)
                } else {
                    completion(false)
                }
            }
    }
}

// 2. 创建自定义 Session(全局单例,推荐)
enum APIClient {
    static let session = Session(interceptor: AuthInterceptor())

    static func fetchUserProfile() async throws -> UserProfile {
        try await session.request("https://api.example.com/profile")
            .validate()
            .serializingDecodable(UserProfile.self)
            .value  // throws on error
    }
}

// 3. 调用
Task {
    do {
        let profile = try await APIClient.fetchUserProfile()
        print("用户:\(profile.name)")
    } catch {
        print("请求失败:\(error)")
    }
}

这个示例涵盖了:Token 注入、自动刷新、队列等待、async/await 调用——工程级最常见的模式。


五、源码亮点

进阶层:值得借鉴的用法

链式调用设计

Alamofire 所有方法都返回 Self(请求对象本身),使得可以无限链式组合:

AF.request(url)
    .validate()                        // 校验
    .responseDecodable(of: T.self)     // 解析
    .uploadProgress { }               // 进度
// 每一步都是独立关注点,互不干扰

自定义 ResponseSerializer

// 扩展支持自定义格式(如 protobuf)
struct ProtobufSerializer<T: Message>: ResponseSerializer {
    func serialize(request: URLRequest?, response: HTTPURLResponse?,
                   data: Data?, error: Error?) throws -> T {
        guard let data = data else { throw AFError.responseSerializationFailed(...) }
        return try T(serializedData: data)
    }
}

深入层:设计思想解析

责任链模式(Chain of Responsibility)

RequestInterceptoradaptretry 两个钩子将「请求构造」与「失败重试」完全分离,任何一个环节都可以独立替换,不影响其他逻辑。这是典型的责任链 + 开闭原则实践。

EventMonitor:观察者模式的正确姿势

// 实现一个打印所有请求的 Logger
final class NetworkLogger: EventMonitor {
    func requestDidFinish(_ request: Request) {
        print("✅ \(request.request?.url?.absoluteString ?? "")")
    }
    func request<Value>(_ request: DataRequest,
                        didParseResponse response: DataResponse<Value, AFError>) {
        print("📦 StatusCode: \(response.response?.statusCode ?? 0)")
    }
}

// 注入 Session
let session = Session(eventMonitors: [NetworkLogger()])

不侵入业务代码,零耦合实现全链路可观测——比 print 打散在各处优雅得多。


六、踩坑记录

问题 1:responseJSON 废弃警告

  • 原因:5.5+ 起 responseJSON 被标记为 deprecated,官方推荐 responseDecodable
  • 解决:定义 Decodable Model,使用 .responseDecodable(of: MyModel.self)

问题 2:多个请求并发刷新 Token 导致死循环

  • 原因:多个请求同时 401,每个都触发了刷新逻辑
  • 解决:拦截器中用 isRefreshing flag + 队列缓存等待回调(见上方实战示例)

问题 3:AF.request 在 Background Task 中失效

  • 原因:默认 Session 使用前台 URLSession 配置
  • 解决:自建 Session 并传入 URLSessionConfiguration.background(withIdentifier:)
let config = URLSessionConfiguration.background(withIdentifier: "com.app.bg")
let bgSession = Session(configuration: config)

问题 4:上传大文件内存暴涨

  • 原因:使用 Data 形式上传会将整个文件加载进内存
  • 解决:使用 fileURL 形式上传,Alamofire 会以流式方式读取
AF.upload(fileURL, to: "https://example.com/upload")

问题 5:.validate() 没有按预期触发

  • 原因:没有加 .validate(),Alamofire 默认不对 4xx/5xx 报错
  • 解决:养成习惯,链式调用中永远加 .validate(statusCode: 200..<300)

问题 6:响应在主线程,但 UI 更新闪烁

  • 原因responseDecodable 默认回调在主队列,但复杂解析会短暂阻塞
  • 解决:用 queue: 参数将解析切到后台,主动 dispatch 到主线程更新 UI
AF.request(url).responseDecodable(of: T.self, queue: .global(qos: .userInitiated)) { response in
    DispatchQueue.main.async { /* 更新 UI */ }
}

七、延伸思考

同类库横向对比

语言 特点 学习曲线 维护状态
Alamofire Swift 功能全面,生态最成熟 活跃
Moya Swift 基于 Alamofire,API 抽象层 中高 活跃
URLSession + async/await Swift 零依赖,苹果原生 低(但样板多) 官方
AFNetworking Objective-C OC 项目首选 维护模式

推荐使用场景

  • ✅ 中大型 Swift 项目,需要统一网络层
  • ✅ 需要 Token 刷新、请求重试等复杂拦截逻辑
  • ✅ 需要完善的上传/下载进度管理
  • ✅ 团队协作,希望网络层有统一规范

不推荐场景

  • ❌ 简单脚本或极小型项目(引入成本 > 收益)
  • ❌ 纯 SwiftUI + async/await 项目,原生 URLSession 已经足够
  • ❌ 对包体积极度敏感的场景

八、参考资源


九、本期互动

小作业

基于本文的 AuthInterceptor 示例,扩展实现以下功能:当 Token 刷新失败(服务端返回 400)时,自动跳转到登录页,并取消所有等待中的请求。在评论区贴出你的关键代码实现。

思考题

Alamofire 的 RequestInterceptor 将「修改请求」和「重试决策」放在同一个对象里——你认为这是合理的设计吗?如果让你重新设计这个接口,你会如何拆分职责?

读者征集

下一期预计介绍 Kingfisher(图片加载库),如果你在使用 Kingfisher 时踩过坑,欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!


📅 本系列每周五晚更新 ➡️ 第1期:Alamofire · ○ 第2期:Kingfisher · ○ 第3期:待定 · ○ 第4期:待定

❌
❌