被 Vibe 摧毁的版权壁垒,与开发者的新护城河 - 肘子的 Swift 周报 #131
Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用
Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用
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 开发者要想画一条像样的折线图,要么:
DGCharts 解决的核心痛点:
| 维度 | 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'
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是最终渲染视图。
| 类名 | 用途 |
|---|---|
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 // 关闭双击缩放
// 将数字索引转为日期字符串
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 完成绘制。
// 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
}
}
// 自定义 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
DGCharts 用一个 CGAffineTransform 维护数据坐标 → 屏幕坐标的映射:
屏幕坐标 = 数据坐标 × scale + offset + chart padding
每次缩放/平移,只更新 transform 矩阵然后 setNeedsDisplay,避免重新计算所有点,是渲染性能的核心保障。
// 每种图表有独立 Renderer,统一协议
public protocol DataRenderer: AnyObject {
func drawData(context: CGContext)
func drawValues(context: CGContext)
func drawExtras(context: CGContext)
func drawHighlighted(context: CGContext, indices: [Highlight])
}
遵循开闭原则,新增图表类型只需实现此协议,不修改任何现有代码。
触摸事件 → 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:动画结束后视图突然闪烁
animateX 和 animateY 同时调用,两个动画结束时各触发一次 setNeedsDisplay
animate(xAxisDuration:yAxisDuration:) 合并为一次调用问题 5:在 SwiftUI 中使用时手势冲突
chartView.dragEnabled = false // 在 ScrollView 内关闭拖拽
chartView.pinchZoomEnabled = false // 关闭缩放
问题 6:Swift Package Manager 拉取超时
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 |
github.com/yourname/ios-lib-demos
使用 LineChartView 实现一个实时动态折线图:每秒追加一个随机数据点,并保持最多显示最近 20 个点(超出则移除最旧的点),同时保证图表 X 轴自动滚动跟随最新数据。完成后在评论区贴出你的核心更新逻辑。
DGCharts 的 Transformer 用矩阵变换把数据坐标映射到屏幕坐标,这种设计和 CALayer 的 transform 有何本质区别?如果让你自己实现一套坐标系映射,你会选择哪种方案?为什么?
下一期我们将深入 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
iOS三方库精读 · 第 8 期
RxSwift 是 ReactiveX 的 Swift 实现,将异步操作和事件统一为可观察序列(Observable),通过操作符进行声明式组合变换,极大简化复杂异步逻辑。
| 属性 | 值 |
|---|---|
| GitHub Stars | 24.5k+ |
| 最新版本 | 6.7.0 |
| License | MIT |
| 支持平台 | iOS 9+ / macOS 10.10+ / watchOS / tvOS |
在没有 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 组件
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
// 完成
进阶层 最佳实践、性能优化、线程安全
// 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 (资源回收容器)
// 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() }
}
}
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)")
}
}
进阶层 值得借鉴的用法
// 每个操作符返回新的 Observable,支持无限链式
observable
.map { transform($0) } // 返回 Map<Source>
.filter { predicate($0) } // 返回 Filter<Map<Source>>
.subscribe { ... } // 返回 Disposable
// 当 self.deallocated 时自动取消网络请求
networkRequest()
.takeUntil(self.rx.deallocated)
.subscribe(onNext: { ... })
深入层 设计思想解析
// 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)
}
}
}
问题:在 ViewController 中订阅 Observable,页面释放后订阅仍在执行。
原因:未将 Disposable 加入 DisposeBag,或 DisposeBag 生命周期与 VC 不一致。
解决:
// ❌ 错误:未持有 Disposable
observable.subscribe { ... }
// ✅ 正确:加入 DisposeBag
private let disposeBag = DisposeBag()
observable.subscribe { ... }
.disposed(by: disposeBag)
问题:后台网络请求回调中更新 UI 导致崩溃。
原因:默认情况下 Observable 继承订阅者的线程上下文。
解决:
// ❌ 错误:可能在后台线程
networkRequest()
.subscribe(onNext: { label.text = $0 })
// ✅ 正确:显式切换到主线程
networkRequest()
.observe(on: MainScheduler.instance)
.subscribe(onNext: { label.text = $0 })
问题:快速输入搜索关键词,收到旧的请求结果。
原因:flatMap 会保留所有内部 Observable,flatMapLatest 会自动取消上一个。
解决:
// ❌ 错误:旧请求可能覆盖新结果
searchText.flatMap { searchAPI($0) }
// ✅ 正确:自动取消上一个请求
searchText.flatMapLatest { searchAPI($0) }
问题:PublishSubject 发送 .completed 后,后续订阅收不到事件。
原因:Subject 一旦 terminated,状态不可逆转。
解决:
// 方案 1:使用 Relay(不发送 completed)
let relay = PublishRelay<String>()
// 方案 2:重新创建 Subject
func resetSubject() {
subject = PublishSubject<String>()
}
问题:多个订阅者导致网络请求被执行多次。
原因:默认每个订阅者独立触发 Observable 执行。
解决:
// ❌ 错误:每个订阅触发一次请求
let request = api.fetchData()
request.subscribe { ... } // 请求 1
request.subscribe { ... } // 请求 2
// ✅ 正确:共享执行结果
let request = api.fetchData()
.share(replay: 1) // 缓存最近 1 个结果
request.subscribe { ... } // 请求 1
request.subscribe { ... } // 复用结果
问题:使用 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 | Combine |
|---|---|---|
| 开发商 | 社区 (ReactiveX) | Apple 官方 |
| 最低版本 | iOS 9+ | iOS 13+ |
| 操作符数量 | 100+ 极其丰富 | ~50 够用但较少 |
| UI 绑定 | RxCocoa 内建 | 需自行封装或用 SwiftUI |
| 调试支持 | RxSwift.Resources / debug() | print() / handleEvents() |
| 学习曲线 | 较陡(概念多) | 中等 |
| 包体积 | ~2MB | 系统内建,0 额外 |
| SwiftUI 集成 | 需桥接 | 原生支持 |
| 维护状态 | 活跃 | Apple 官方维护 |
选 RxSwift:
withLatestFrom 等 Combine 缺失的操作符选 Combine:
对于已有 RxSwift 项目:
RxCombine 库实现互相转换尝试用 RxSwift 实现一个「搜索建议」功能:输入框输入时防抖 300ms,发起网络请求获取建议列表,展示在 UITableView 中,并在评论区贴出你的关键代码片段。
如果让你从零实现 RxSwift 的 debounce 操作符,你会如何设计?需要考虑哪些边界情况?
你在使用 RxSwift 时踩过哪些坑?欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。
📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ Lottie] [✓ MarkdownUI] [✓ SDWebImage] [✓ SnapKit] [✓ ListDiff] [→ RxSwift] [○ Charts]
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 秒的加载动画,开发要干这些事:
CAKeyframeAnimation / CAAnimationGroup 手写每一条时间曲线这套流程不仅耗时(一个中等复杂动效需 1~3 天还原),还存在不可避免的还原偏差。
LottieView(animation: .named("xxx")) 一行接入,100% 还原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'
xxx.json 拖入 Xcode 工程(确保勾选 Target Membership)// 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)
}
}
import Lottie
let animationView = LottieAnimationView(name: "loading")
animationView.loopMode = .loop
animationView.contentMode = .scaleAspectFit
animationView.play()
view.addSubview(animationView)
进阶层 最佳实践:常用 API 与核心配置
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")
)
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") 中 ** 是通配符,匹配任意深度路径animationDidFinish 在 SwiftUI 中是 View 修饰符,回调在主线程执行进阶层 值得借鉴的用法
多个 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")
)
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))
}
)
}
}
深入层 设计思想解析
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)
类似 KVC,但专为 AE 图层树设计,支持 **(任意路径深度)和 *(单层通配):
"Button.Background.Color" → 精确匹配
"**.Color" → 所有名为 Color 的属性
"Button.*.Color" → Button 子级的任意图层的 Color
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 | Rive | SwiftUI 原生动画 | CAAnimation |
|---|---|---|---|---|
| 文件格式 | JSON (bodymovin) | .riv (专有) | 代码 | 代码 |
| 设计协作 | AE 直出,零交接 | Rive 编辑器 | 开发手写 | 开发手写 |
| 交互状态机 | ⚠️ 有限 | ✅ 内建 | ⚠️ 有限 | ❌ |
| 渲染性能 | ✅ GPU 加速 | ✅ 极佳 | ✅ | ✅ |
| 动态换色 | ✅ DynamicProperty | ✅ 输入驱动 | ✅ | ❌ |
| 包体积(库本身) | ~4 MB | ~2 MB | 0 | 0 |
| 社区素材库 | ✅ LottieFiles 海量 | ⚠️ 较少 | ❌ | ❌ |
| 维护状态 | 活跃 | 活跃 | Apple 官方 | Apple 官方 |
| 学习曲线 | 低 | 中 | 低~中 | 高 |
.animation() 即可HelloWorld/Features/iOSLibraries/LottieDetailView.swift
在你自己的项目(或 Demo 工程)中实现一个点赞按钮:
完成标准:能在真机或模拟器上稳定运行,按钮不会出现状态错乱。欢迎在评论区贴出实现思路或截图!
Lottie 的 DynamicProperty 机制允许在运行时"注入"新的值覆盖 JSON 中预设的属性。这种控制反转(IoC)的设计思路,在 iOS 开发中还有哪些类似的应用?你会如何把这种思路用在自己的业务组件设计上?
下一期选题投票正在进行!同时:你在使用 Lottie 时踩过哪些坑? 欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。
📅 本系列每周五晚更新
✅ 第1期:Alamofire · ✅ 第2期:Kingfisher · ✅ 第3期:MarkdownUI · ✅ 第4期:SnapKit · ➡️ 第5期:Lottie · ○ 第6期:待定
这里是后厂村阴影中最神秘的“全栈侦探事务所”。当你的 if-else 走到尽头,当你的 Bug 堆积如山,资深探长“老司机”就是你最后的救命稻草。本期案卷记录了一次关于“像素与文字”的离奇遭遇:实习生阿强因“人肉 OCR”识别截图密码失败,险些引发上线事故。面对这起“视力危机”,我们拒绝蛮力,祭出了 Apple 强大的 Vision 框架。这不仅是一篇关于如何用 Swift 实现 OCR(文字识别)的硬核教程,更是一场从构建“文字捕手”到破解“坐标迷宫”的技术探险。准备好了吗?泡好你的枸杞咖啡,跟随老司机的代码,一起揭开隐藏在图片像素背后的真相。
在一个雷雨交加的周五深夜,位于后厂村的“全栈侦探事务所”依然灯火通明。传说中,这里有一位代号为“老司机”的资深工程师,他不仅能用汇编语言写情书,还能在没有任何文档的遗留代码(Legacy Code)中自由穿梭。
就在刚刚,事务所的大门被撞开了。实习生阿强跌跌撞撞地跑进来,手里挥舞着一张模糊不清的截图,脸上写满了被产品经理折磨后的绝望。“老大!出大事了!这图片里藏着服务器的 Root 密码,但我手抄了三次都提示错误!现在上线倒计时只剩 30 分钟了!”
老司机缓缓放下手中早已凉透的黑咖啡,推了推鼻梁上那副防蓝光眼镜,嘴角勾起一抹神秘的微笑。“阿强,把你的‘人肉 OCR’停一停吧。在 Apple 的地盘上,我们有更优雅的武器——Vision 框架。”
在本次探案之旅中,您将学到如下内容:
他指尖在机械键盘上飞舞,屏幕上开始跳动起绿色的代码符文。“坐好,今晚带你见识一下,如何用机器学习的‘天眼’,让图片里的文字自己‘招供’。”
听好了,阿强。大多数人对 Apple Vision 框架的印象,还停留在扫个二维码或者条形码这种“小儿科”的阶段。这就好比你拿着一把激光剑去切西瓜——简直是暴殄天物!
实际上,Vision 就像是给你的 App 装上了一双“写轮眼”。它不仅能从图片中识别并定位文字(Text Detection),还能把图片里的特定区域剥离出来、在连续的视频帧里追踪物体、甚至检测你那僵硬的手势和坐姿!
我第一次跟 Vision 打交道的时候,是写了一个 Swift 命令行工具来移除图片背景 ✂️。那时候我就意识到,这玩意儿简直是修图师的噩梦,程序员的福音。但今天,我们要用它来做点更硬核的——文字识别。
要在茫茫像素中提取文字,我们得先组装一个名为 TextRecognizer 的“审讯室”。在这个环节,我们要用到 Vision 的核心组件:RecognizeTextRequest。
这就好比我们向系统提交一份“搜查令”,告诉它:“嘿,帮我把这张图里的字儿都给我找出来,而且要准(Accurate)!”
来看看这段代码,这可是我们的核心武器:
import Foundation
import SwiftUI
import Vision
struct TextRecognizer {
var recognizedText = ""
// 保存识别到的所有“线索”(观察结果)
var observations: [RecognizedTextObservation] = []
// 这个初始化器是异步的,因为查案需要时间,急不得
init(imageResource: ImageResource) async {
// 1. 创建搜查令:RecognizeTextRequest
var request = RecognizeTextRequest()
// 2. 将识别精度设置为 .accurate(我们要的是精准打击,不是瞎猜)
request.recognitionLevel = .accurate
// 3. 将 ImageResource 转换为 UIImage
let image = UIImage(resource: imageResource)
// 4. 重点来了!Vision 不吃 UIImage 这一套,它只认二进制数据 Data
// 所以我们必须把图片“粉碎”成 PNG 数据
if let imageData = image.pngData(),
// 执行搜查任务(perform)。这一步可能会失败,所以用了 try? 来“掩耳盗铃”
// 注意:这里是异步等待结果
let results = try? await request.perform(on: imageData) {
// 5. 将抓获的嫌疑人(观察结果)关进 observations 数组
observations = results
}
// 6. 审讯环节:遍历每一个观察结果
for observation in observations {
// 获取可能性最高的那个“候选词”(topCandidates(1))
// 就像指认现场,我们通常只信最像的那个
let candidate = observation.topCandidates(1)
if let observedText = candidate.first?.string {
// 把招供的文字拼接到结果字符串里
recognizedText += "\n\(observedText) "
}
}
}
}
这里有个坑你要注意,阿强。RecognizeTextRequest 是个挑剔的家伙,它不能直接处理 Swift 的 Image 或 UIImage 对象,它需要生肉——也就是 Image Data。
所以我们必须先把图片转成 Data 格式。另外,整个过程是 async(异步)的,毕竟机器学习这玩意儿虽然快,但也没快到能超越光速,我们得给 CPU 一点“思考”的时间。
接下来,我们把这个“文字捕手”集成到 SwiftUI 的视图里,让你亲眼看看效果:
import SwiftUI
struct TextRecognitionView: View {
let imageResource: ImageResource
// 状态变量,一旦侦探有了结果,界面就会刷新
@State private var textRecognizer: TextRecognizer?
var body: some View {
List {
// 展示嫌疑图片
Section {
Image(imageResource)
.resizable()
.aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.listRowBackground(Color.clear)
// 展示审讯结果(识别出的文字)
Section {
// 如果 textRecognizer 还没初始化好,就先显示空字符串
Text(textRecognizer?.recognizedText ?? "")
} header: {
Text("从图片中提取的证词")
}
}
.navigationTitle("文字侦探")
.task {
// 重点:在 .task 修饰符里调用异步初始化器
// 就像在后台偷偷干活,不阻塞主线程 UI 的渲染
textRecognizer = await TextRecognizer(imageResource: imageResource)
}
}
}
这时候,阿强凑过来看着模拟器屏幕,只见原本模糊的截图下方,整整齐齐地列出了识别出来的文字。“卧槽,神了!连那个像‘1’又像‘l’的字符都分清了!”
“别急着庆祝,阿强。”我敲了敲桌子,“光把字认出来还不够,我们要做到按图索骥。既然 Vision 已经告诉了我们文字在哪里,我们就得在图片上把它们圈出来,就像犯罪现场的粉笔线一样。”
这里涉及到一个让很多新手头秃的概念:坐标系转换。
Vision 返回的坐标是归一化的(Normalized),也就是说,它的 x 和 y 都在 0.0 到 1.0 之间。左下角是 (0,0),右上角是 (1,1)。但我们的屏幕图片是按像素画的,而且 UIKit/SwiftUI 的坐标原点通常在左上角。这就好比火星人给地球人指路,如果不好好翻译一下坐标,你画的框可能会飞到姥姥家去。
我们需要定义一个 Shape,专门用来画框:
import Foundation
import SwiftUI
import Vision
struct BoundsRect: Shape {
// 这里存的是 Vision 给我们的“火星坐标”(归一化矩形)
let normalizedRect: NormalizedRect
func path(in rect: CGRect) -> Path {
// 关键时刻!将归一化坐标转换为图片的实际像素坐标
// origin: .upperLeft 是为了适配 SwiftUI 的坐标系习惯
let imageCoordinatesRect = normalizedRect
.toImageCoordinates(rect.size, origin: .upperLeft)
return Path(imageCoordinatesRect)
}
}
🔍 技术扩展: toImageCoordinates 这个方法虽然原文没细说,但它大概率是一个扩展方法(Extension),用于把 0~1 的小数映射到图片的 width 和 height 上,并处理坐标原点的翻转。这一步至关重要,不做这一步,你的框框就会像没头苍蝇一样乱撞。
现在,我们把这个“现形符”贴到图片上:
struct TextRecognitionView: View {
// ... 前面的代码 ...
// 定义一个深红色的框,充满了悬疑感
let boundingColor = Color(red: 0.31, green: 0.11, blue: 0.11)
var body: some View {
List {
Section {
Image(imageResource)
.resizable()
.aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay {
// 如果侦探已经有了观察结果
if let observations = textRecognizer?.observations {
ForEach(observations, id: \.uuid) { observation in
// 遍历每一个观察点,画个圈圈诅咒...啊不,标记它
// observation.boundingBox 就是那个归一化的坐标
BoundsRect(normalizedRect: observation.boundingBox)
.stroke(boundingColor, lineWidth: 3) // 描边
}
}
}
}
// ... 后面的代码 ...
}
}
}
随着代码重新编译运行,屏幕上的截图发生了变化。每一个单词周围都被套上了一个暗红色的方框,就像是被狙击手锁定的目标。
“看到了吗?”我指着屏幕上被红框圈出的一串字符,“那根本不是 Root 密码。”
阿强瞪大了眼睛,盯着那行被 Vision 精准识别出的文字:WIFI_PASSWORD: 12345678。
“这……这就是隔壁会议室的 WiFi 密码?”阿强瘫软在椅子上,“我为了这个通宵了两天?”
我拍了拍他的肩膀,语重心长地说道:“虽然你是个笨蛋,但好在 Vision 框架足够聪明。记住,Vision 不仅仅能找字,它还能做更多事情——从视频里追踪隔壁老王的身影,到检测你是不是在偷偷抠脚(Body Pose Detection)。今天我们学的只是冰山一角,但也足够你在这个充满像素迷雾的开发世界里防身了。”
就这样,Vision 框架再次拯救了一个无知的灵魂(虽然并没有拯救他的加班费)。
希望宝子们喜欢这个故事,以及它背后的技术,但对于小伙伴们来说,利用 Apple 强大的 ML 能力去探索未知的旅程,才刚刚开始。
保持好奇,保持代码整洁,我们下个案子见。👋🙂 8-)
iOS三方库精读 · 第 4 期
SnapKit 是一个用于 iOS/macOS/tvOS 的 Swift Auto Layout DSL 库,它让繁琐的界面约束编写变得简洁优雅,是 UIKit 开发中最受欢迎的布局解决方案之一。
在 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
translatesAutoresizingMaskIntoConstraints = false
updateConstraints 和 remakeConstraints,轻松应对动态布局基础层 概念解释、环境配置、基础用法
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 | 作用 |
|---|---|
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 的架构设计非常精巧,主要包含以下几个核心组件:
关键协议 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 {}
深入层:设计思想解析
ConstraintMaker 是 Builder 模式的典型应用:
make/update/remake 不同构建策略SnapKit 大量使用协议扩展实现功能:
// 所有视图自动获得 snp 属性
extension UIView {
var snp: ConstraintDSL {
return ConstraintDSL(view: self)
}
}
这种设计让 SnapKit 可以无缝接入任何 UIView 子类,无需继承或修改原有类。
症状:控制台输出 "Unable to simultaneously satisfy constraints",界面布局错乱。
原因:SnapKit 自动设置 translatesAutoresizingMaskIntoConstraints = false,但如果视图在 Storyboard 或 Xib 中已有约束,会导致重复约束。
解决:确保代码创建的视图没有在其他地方添加约束,或使用 remakeConstraints 完全重制约束。
// 使用 remakeConstraints 清除旧约束
view.snp.remakeConstraints { make in
make.edges.equalToSuperview()
}
症状:调用 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)
}
症状: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
}
}
}
症状:使用 UIView.animate 更新 SnapKit 约束时出现闪烁或跳动。
原因:约束更新和布局刷新时机不正确。
解决:在动画块中先更新约束,然后调用 layoutIfNeeded()。
// ✅ 正确的动画方式
view.snp.updateConstraints { make in
make.width.equalTo(200)
}
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded()
}
症状:在 UIViewRepresentable 中使用 SnapKit 时约束不生效。
原因:SwiftUI 的生命周期和布局系统与 UIKit 不同。
解决:确保在 makeUIView 中创建约束,在 updateUIView 中使用 updateConstraints 或 remakeConstraints。
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 支持 | 需桥接 | 需桥接 | 需桥接 |
推荐使用:
不推荐使用:
Cartography 是另一个流行的 Swift 布局 DSL,使用运算符重载(==、>=、<=)实现约束。虽然 API 非常优雅,但该项目目前已归档不再维护,不建议在新项目中使用。
尝试用 SnapKit 实现一个自适应高度的评论区 Cell,要求:
完成后在评论区贴出你的 setupConstraints 方法代码。
如果你要自己实现一个类似的布局 DSL 库,你会如何设计 API 接口?是像 SnapKit 这样使用闭包和链式调用,还是像 Cartography 那样使用运算符重载?为什么?
你在使用 SnapKit 时踩过哪些坑?或者有什么高级用法想分享?欢迎在评论区留言,优质回答会收录进下一期《踩坑记录》。
下一期选题投票:
📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ MarkdownUI] [→ 本期 SnapKit] [○ 第5期]
对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截至目前,已有上亿用户受到影响。
Coruna 主要针对 iOS 13 至 iOS 17 的设备,在过去几个月间,苹果已为这些系统推送了多次安全更新。DarkSword 则主要针对 iOS 18.4 至 18.7 的设备。尽管这部分设备均具备升级至 iOS 26 的硬件条件,但由于种种原因,仍有不少 iOS 18 用户选择按兵不动。
在很长一段时间里,苹果用户对于系统更新的态度都相当积极,这也是苹果生态的一大特色。但这一趋势在去年出现了变化——Liquid Glass 带来的巨大视觉冲击,让苹果用户中第一次出现了相当比例主动拒绝升级到 iOS 26 的现象。与此同时,为遵守英国《网络安全法》(Online Safety Act)的要求,苹果在 iOS 26.4 中为英国用户引入了强制年龄验证机制,由于验证条件严苛,不少成年用户甚至被系统强行锁入‘儿童模式’,进一步推动了英国用户停留在 iOS 18 或 iOS 26.3 的风潮。而拒绝安装新版本,意味着这部分用户同时放弃了后续所有安全补丁,让设备进一步暴露在潜在风险之下。
面对这一局面,苹果承受了明显的舆论压力与品牌风险。特别是在 3 月下旬,DarkSword 的完整攻击代码被泄露到了 GitHub 上,让这一国家级黑客工具瞬间平民化,直接迫使苹果必须采取紧急行动。最终,苹果罕见地为 iOS 18 单独推出了安全补丁 iOS 18.7.7,将原本仅用于 iOS 26 的防护机制回移植到旧系统。至此,苹果完成了针对本次高危漏洞的全部官方安全响应。
无论是苹果还是生态中的开发者,大多希望用户能积极跟进系统更新——既能减少多版本适配的维护负担,也能让用户尽快享受到新 API 带来的便利。但现实是,始终有一部分用户出于性能、续航、使用习惯乃至隐私等方面的考量,有意将设备锁定在某个版本。
本次事件或许会带来两个方向上的变化:苹果在压力下调整了长期坚守的更新策略,为刻意留守旧系统的用户做出了妥协;而事件本身的广泛传播,也可能促使更多用户从安全角度重新审视“能不更新就不更新”的惯性,回到积极更新的轨道。这种双向的改变,或许正是这场风波意料之外的收获。
网络上并不缺少探讨 SwiftUI 动画机制的文章,但 Sagar Unagar的这篇仍然提供了一个颇具启发性的切入点。他没有从隐式或显式动画入手,而是围绕 Animatable 协议做了一次系统梳理:从 animatableData 的作用,到 AnimatablePair 如何承载多个插值参数,再到通过自定义 VectorArithmetic 让更复杂的数据结构参与动画。文章最值得注意的一点在于其核心视角:SwiftUI 实际上是在“动画数据”,而非直接对视图进行动画处理。
Xcode 能为 .xcstrings 文件自动生成类型安全的 Swift 符号,但这些符号仅在资源所在的 module 内可见——一旦将本地化资源抽离为独立的 Localization 包,其他 feature 包便无法享受编译期检查的优势。Khan Winter 的解决方案相当直接:通过一个 bash 脚本解析 .xcstrings 的 JSON 结构,生成 public extension LocalizedStringResource 扩展,使所有模块都能以 .l10n.helloWorld 的形式访问翻译键。
其中一个颇具参考价值的细节是 Debug 模式下的 @dynamicMemberLookup 设计——访问不存在的键时仅记录日志而不崩溃,而在 Release 构建中仍保留完整的编译期校验。相比基于 Swift 可执行文件的方案,这种实现更加轻量,复制脚本即可使用。
尽管 SwiftUI 一直在丰富基于状态驱动的导航 API,但管理全局导航一直是 SwiftUI 中的一个“痛点”。Wesley Matlock 以一个五 Tab 的音乐收藏应用为例,展示了如何通过 Coordinator 模式将导航决策从 View 中抽离:用一个 Route 枚举统一描述所有目的地,由单一的 Coordinator 对象持有导航状态并执行跳转,View 只需声明“去哪”而无需关心“怎么去”。文章没有回避 NavigationPath 不透明、路由携带模型对象导致的 Hashable 困境等实际问题。对于大多数中等规模的 SwiftUI 应用来说,这是一个务实且易于落地的导航治理方案。
Paul Hudson 和他的 Hacking with Swift 让很多开发者走上了 Swift 与 SwiftUI 的学习之路。在 AI 时代,Paul 不仅推出了面向苹果开发生态的各类专业 Skill,也开始尝试在与 AI 的协作中注入更具个人特质的编程风格。
在本文中,他分享了一份极具辨识度(且充满他标志性幽默)的 AGENTS.md 配置。这套规则不仅约束了 AI 的技术选型,还为 AI 注入了 Paul 的灵魂:强调先展示结果再解释原理、偏好清晰而非炫技、甚至包括在代码写得漂亮时适时地喊出一句 "Boom!"。与其说这是一份用于 AI 的“系统提示词”,不如说是在为 AI 定义一种编码哲学——某种程度上,这种方式正在将冷冰冰的“代码生成”推向带有人情味的“风格迁移”。
在刚过去的 Let's Vision 2026 中,王巍(Onevcat) 发表了关于在大型开发团队中应用 AI Agent 的演讲。整场分享讨论的重点,并不是某个具体工具有多强,而是当代码实现成本被迅速压低后,团队该如何重新组织开发流程,以及工程师的价值该如何重新定位。
作为 LINE 应用开发团队的一员,Onevcat 在过去几个月中的工作重心也已明显发生变化。用他自己的话说,他正在逐步从传统意义上的 iOS 工程师,转向探索如何将 AI 应用于服务大型产品研发团队的实践者。这种角色上的变化,也让这场分享比一般的工具介绍更有说服力。
演讲围绕三个关键问题展开:如何控制上下文污染,如何把个人经验沉淀为团队可复用的 memory 与 skill,以及如何让协作模式从“人指挥多个 Agent”逐步走向更自动化的闭环。里面有不少相当接地气的实践建议,例如将 AGENTS.md 控制在精简范围内、为 Agent 提供模块定位与架构速查脚本、鼓励 Claude Code、Codex、OpenCode 等多种 harness 并存,以及通过 webhook、cron、pipeline 和自动验收机制让 Agent 真正进入团队流程。
演讲稿仓库 中不仅包含完整的 Slidev 源码,也保留了不少演讲配套材料,包括原始资料收集和与 AI 协作的完整 trace,值得一并阅读。
我经常会在社交媒体上看到一些零基础的“开发者”通过 AI 构建了自己的产品或服务。尽管我使用 AI 的时间也不短,但我仍然比较困惑:这条路径真的像大家描述的那样有效吗?Zachary Zhang 分享了他完全借助 AI 工具,从零构建并上架一款纯原生 iOS 应用(SwiftUI + Cloudflare 后端)的实战全过程。这篇文章最让我印象深刻的,是他严谨的“工程化管线”:在让 AI 写代码前,必须先生成结构化的 PRD 和 HTML 格式的视觉参考;而在工具选择上,他在项目“从 0 到 1”的冷启动阶段,极力推荐 Claude Code 等终端工具,以便更好地统览全局,一次性构建出合理的多文件项目架构。
或许你和我一样,对于 100% 基于 AI 的开发方式仍存疑惑。但在代码生成越来越廉价的今天,开发者的核心壁垒,正在加速向“需求精准拆解”、“系统架构把控”以及“面向报错的全局调度能力”转移。
将多个视图组合封装成可复用组件,是 SwiftUI 开发中的常见需求,对团队内部开发者或第三方库作者来说更是如此。但当组件包 title、icon、image、action 等多个泛型 View 插槽后,初始化器的组合数量往往会迅速膨胀。Kyle Bashour 创建的 Slots 宏,正是为了解决这类多 slot 组件的样板代码问题。
开发者只需声明组件的 slot 属性,宏便会按组合自动生成所需的初始化器,无需手写大量 init 重载。对于需要支持文本便捷写法的 slot,还可以通过 @Slot(.text) 自动获得 LocalizedStringKey 和 String 版本的初始化方式。 Slots 很适合用于构建设计系统中的 Card、Row、Banner、Toolbar 这类既要支持简单调用、又要保留高度定制能力的组件。
尽管 Apple 官方文档的质量在逐年改善,但对于以声明式和视觉驱动为主的 SwiftUI 来说,官方文档中依然缺乏足够直观的代码与 UI 效果对照,尤其是同一组件在 iOS、macOS 和 visionOS 等多平台上的表现差异。很多时候,开发者为了实现某个特定的 UI 细节,往往会去求助于复杂的第三方库或手写冗长的自定义视图,却忽略了 SwiftUI 本身可能已经提供了绝佳的原生解决方案。Florian 建立的 Explore SwiftUI 站点,正是一个为了解决这一痛点而生的“视觉速查字典”。它摒弃了任何第三方封装,纯粹以展示 Apple 官方内置组件的原生能力为核心。所有的代码示例都被剥离了无关的业务逻辑,保持极简,配以高质量的视觉预览,开发者只需“复制、粘贴、运行”即可直接验证效果。
这是一本 Mohammad Azam 在不久前出版的新书。它不是一本教你如何使用 VStack 或编写动画的入门书,而是一本纯粹探讨 SwiftUI 应用架构、数据流和现代工程化实践的进阶读物。
书中提供了大量直击生产环境痛点的解决方案,例如:如何构建全局的 Sheets 和 Toasts、如何利用 NavigationPath 设计解耦的多 Tab 编程式路由、以及如何使用 Property Wrapper 编写优雅的表单验证。尤为重要的是,作者并不是要向你灌输某种死板的架构模式,而是旨在帮助你建立真正的声明式心智模型。
或许有人觉得,在 AI 辅助编程盛行的时代,这类探讨架构的书籍还重要吗?借用 Mohammad Azam 在书中的观点:AI 让代码生成变得廉价,但也正因如此,系统架构的设计(边界的划分和状态所有权的明确)变得比以往任何时候都更加重要。
如果本期周报对你有帮助,请:
🚀 拓展 Swift 视野
- 📮 邮件订阅 | weekly.fatbobman.com 获取独家技术洞察
- 👥 开发者社区 | Discord 实时交流开发经验
- 📚 原创教程 | fatbobman.com 学习 Swift/SwiftUI 最佳实践
对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截止目前,已有上亿用户受到影响。
iOS 进阶必修 · Swift 并发编程系列 第 2 期
面试官问你:"Swift 的 actor 是可重入的,你觉得这个设计合理吗?"
很多人第一反应是:可重入?那不是有 bug 风险吗?为什么不做成传统锁那样不可重入?
这篇文章就来彻底说清楚这件事。
可重入(Reentrant):actor 在 await 挂起时会释放自身的"访问权",其他任务可以趁机进入 actor 执行别的方法。
不可重入(Non-reentrant):actor 一旦被某任务占用,其他任务必须排队等待,直到当前任务彻底执行完(包括所有 await)。
用一句话概括差异:
可重入:
await是"暂时离开",锁被放开
不可重入:await是"原地等待",锁被一直握着
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 Logger {
func log(_ msg: String) async {
await writeToFile(msg) // 不可重入 → 自己等自己 → 死锁
}
func writeToFile(_ msg: String) async {
// 磁盘写入…
}
}
这意味着什么?actor 内部完全不能出现 await。但现实中 actor 管理的资源(网络、磁盘、数据库)几乎无一例外需要异步操作。
不可重入 + async/await 生态,在逻辑上根本无法自洽。
可重入把死锁风险消除了,但代价是:await 挂起期间,actor 的状态可能被其他任务修改。
这是最经典的重入陷阱,银行转账场景:
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,整个操作不是原子的。
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
}
}
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 检查通过后,立刻完成状态变更,然后才 await。await 之后不再依赖之前检查过的条件。
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 标志,让每种状态的含义更清晰,也更难被误用。
| 维度 | 不可重入(传统锁语义) | 可重入(Swift actor) |
|---|---|---|
| 跨 actor 调用 | ❌ 极易死锁 | ✅ 安全 |
| actor 内部 await | ❌ 自己等自己,死锁 | ✅ 正常工作 |
| 状态一致性 | await 前后一致 | ⚠️ 开发者自行保证 |
| 死锁风险 | ❌ 高,且难排查 | ✅ 无 |
| 正确性复杂度 | 低(锁语义直觉) | 中(需理解挂起语义) |
| 与 async/await 生态兼容性 | ❌ 根本无法自洽 | ✅ 天然融合 |
这是一道"两害取其轻"的工程决策题:
Apple 把不确定性更高、危害更大的风险消除了,把相对可控的复杂性留给开发者。
从语言设计角度看,这也与 Swift 的一贯哲学吻合:编译器负责能静态验证的安全,开发者负责剩下的语义正确性。
Swift 6 的严格并发检查(
-strict-concurrency=complete)正在把越来越多的重入问题提升为编译器警告,方向是对的。
优先用可重入,配合以下纪律:
await 之前必须完成所有关键状态变更,await 之后不再信任之前读取的条件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 期:待定
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 天然融合 |
核心优势:
无需配置,开箱即用
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() 等待时会阻塞线程这就是为什么同样是"异步",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 let、TaskGroup、actor全面落地。与 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
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 {} 修饰符(自动管理),UIKit 在 viewWillDisappear 中 cancel// UIKit
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
loadTask?.cancel()
}
问题 4:Actor 重入性导致余额多扣
问题 5:AsyncStream 中 timer / 监听器未释放,持续运行
continuation.onTermination
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 报链接错误
@available(iOS 15, *) 包裹| 方案 | 简介 | 学习曲线 | 线程安全 | 取消支持 | 适用场景 |
|---|---|---|---|---|---|
| Swift Concurrency | Swift 原生,语言级别支持 | 中 | 编译器保证(actor) | 结构化取消 | 新项目首选 |
| GCD + DispatchQueue | 苹果传统并发方案 | 低 | 手动加锁,容易出错 | 无原生支持 | 老项目维护 |
| Combine | 响应式框架,操作符丰富 | 高 | 需手动 receive(on:) | AnyCancellable | 复杂数据流转换 |
| PromiseKit | 基于 Promise 的链式回调 | 中 | 无特殊支持 | 有限支持 | OC/早期 Swift 项目 |
| RxSwift | 响应式编程全家桶 | 很高 | 需配置 scheduler | Disposable | 重度响应式架构 |
Continuation 包装,对调用方透明.values 属性转为 AsyncSequence 互通-strict-concurrency=complete),提前消灭隐患github.com/yourname/ios-swift-concurrency-demos
基于本文的 AsyncStream 示例,实现一个实时心跳检测器:
AsyncStream 每隔 1 秒 yield 一次当前时间戳continuation.finish() 结束流.task {} 消费流,将每次时间戳展示在列表中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 期:待定
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 | Alamofire |
|---|---|
| 需要手动构建 URLRequest | 链式 API,一行发起请求 |
| 响应解析需要大量样板代码 | 内建 Decodable 自动解析 |
| 上传/下载进度管理繁琐 | 原生支持进度回调 |
| 拦截器/重试需要自行实现 | 内建 RequestInterceptor
|
| 错误处理分散、不统一 | 统一的 AFError 体系 |
核心优势:
集成方式(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)
RequestInterceptor 的 adapt → retry 两个钩子将「请求构造」与「失败重试」完全分离,任何一个环节都可以独立替换,不影响其他逻辑。这是典型的责任链 + 开闭原则实践。
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 废弃警告
responseJSON 被标记为 deprecated,官方推荐 responseDecodable
Decodable Model,使用 .responseDecodable(of: MyModel.self)
问题 2:多个请求并发刷新 Token 导致死循环
isRefreshing flag + 队列缓存等待回调(见上方实战示例)问题 3:AF.request 在 Background Task 中失效
Session 使用前台 URLSession 配置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 到主线程更新 UIAF.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 项目首选 | 低 | 维护模式 |
github.com/yourname/ios-lib-demos
基于本文的 AuthInterceptor 示例,扩展实现以下功能:当 Token 刷新失败(服务端返回 400)时,自动跳转到登录页,并取消所有等待中的请求。在评论区贴出你的关键代码实现。
Alamofire 的 RequestInterceptor 将「修改请求」和「重试决策」放在同一个对象里——你认为这是合理的设计吗?如果让你重新设计这个接口,你会如何拆分职责?
下一期预计介绍 Kingfisher(图片加载库),如果你在使用 Kingfisher 时踩过坑,欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!
📅 本系列每周五晚更新 ➡️ 第1期:Alamofire · ○ 第2期:Kingfisher · ○ 第3期:待定 · ○ 第4期:待定
Harbeth是一个基于Metal的高性能图像处理库,为iOS和macOS开发者提供了一套简洁、高效的图像滤镜解决方案。它不仅支持传统的图像滤镜效果,还能处理HDR图像,让你的应用在图像处理方面如虎添翼。
| 用故障艺术美学建立动态RGB通道分离 | 实时检测边缘并添加霓虹灯发光效果 |
|---|---|
超过 200+ 内置滤镜,组织成直观的类别,涵盖从基本颜色调整到高级艺术效果的各种功能
// Swift Package Manager
.package(url: "https://github.com/yangKJ/Harbeth.git", from: "0.0.1")
// CocoaPods
pod 'Harbeth'
import Harbeth
// 加载图像
let image = UIImage(named: "example")!
// 创建滤镜
let filter = C7Brightness(brightness: 0.2)
// 应用滤镜
let result = try? HarbethIO(element: image, filter: filter).output() as? UIImage
// 显示结果
imageView.image = result
// 组合多个滤镜
let filters: [C7FilterProtocol] = [
C7Brightness(brightness: 0.1),
C7Contrast(contrast: 1.2),
C7Saturation(saturation: 1.3),
C7GaussianBlur(radius: 2.0)
]
// 应用滤镜链
let result = try? HarbethIO(element: image, filters: filters).output() as? UIImage
// 创建自定义滤镜
struct CustomFilter: C7FilterProtocol {
var modifier: ModifierEnum {
return .compute(kernel: "customKernel")
}
var factors: [Float] = [0.5, 0.5, 0.5]
}
// 应用自定义滤镜
let customFilter = CustomFilter()
let result = try? HarbethIO(element: image, filter: customFilter).output() as? UIImage
// 加载HDR图像
let hdrImage = UIImage(named: "hdr_example")!
// 应用HDR到SDR转换
let hdrFilter = HDRToSDR()
let result = try? HarbethIO(element: hdrImage, filter: hdrFilter).output() as? UIImage
// 实时处理相机捕获的图像
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
// 异步处理
HarbethIO(element: sampleBuffer, filter: C7Vibrance(vibrance: 0.5)).transmitOutput { result in
switch result {
case .success(let processedBuffer):
// 处理成功,显示结果
DispatchQueue.main.async {
self.previewLayer.enqueue(processedBuffer)
}
case .failure(let error):
print("处理失败: \(error)")
}
}
}
Harbeth在性能方面的表现令人印象深刻:
Harbeth适用于各种需要图像处理的场景:
Harbeth 完全支持 macOS 平台,为桌面应用提供强大的图像处理能力,打造原生、优化的用户体验:
探索 Harbeth 在 macOS 上的强大功能:
|
|
|
|
|
|
|
|
Harbeth是一个功能强大、性能优异的Metal图像处理库,它不仅提供了丰富的滤镜效果,还支持HDR图像处理,为iOS和macOS开发者提供了一套完整的图像处理解决方案。
无论是快速原型开发还是生产环境应用,Harbeth都能满足你的需求。它的高性能特性让图像处理不再成为应用的性能瓶颈,而简洁的API设计则让开发过程更加愉快。
如果你正在寻找一个强大而灵活的图像处理库,Harbeth绝对值得尝试!
让Harbeth为你的应用添加绚丽的图像处理能力,让每一张图片都成为艺术品! 🎨✨
现实情况是:SwiftUI 原生组件不够用。很多组件SwiftUI 自己没有直接提供,但 UIKit 里有。
那怎么办?苹果提供了一个"桥接协议":UIViewRepresentable
它是一个协议(Protocol) ,作用是:
把一个 UIKit 的
UIView,包装成 SwiftUI 能认识的View
你可以把它理解成一个翻译官,SwiftUI 和 UIKit 说的不是同一种语言,UIViewRepresentable 负责在中间翻译。
SwiftUI 世界 翻译官 UIKit 世界
───────────── ────────────────────── ──────────────────
some View ←→ UIViewRepresentable ←→ UIView(任意)
protocol UIViewRepresentable {
// 方法一:创建 UIKit 视图(只调用一次)
func makeUIView(context: Context) -> 某种UIView
// 方法二:更新 UIKit 视图(状态变化时调用)
func updateUIView(_ uiView: 某种UIView, context: Context)
}
就这两个,不多(有没有想到什么,OC的NSProxy 是不是也是实现两个方法,虽然八杆子打不着,但是突然想到了)。
makeUIView → 负责初始化,相当于 viewDidLoad,只跑一次updateUIView → 负责同步状态,SwiftUI 的数据变了,你要在这里手动更新 UIKit 视图struct BlurView: UIViewRepresentable {
let style: UIBlurEffect.Style // ← 从 SwiftUI 传进来的参数
// 第一步:创建真实的 UIKit 视图
func makeUIView(context: Context) -> some UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
// 这才是核心:UIKit 的毛玻璃视图
let blurEffect = UIBlurEffect(style: style)
let blurView = UIVisualEffectView(effect: blurEffect)
// 用 AutoLayout 让它撑满父视图
blurView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(blurView)
NSLayoutConstraint.activate([
blurView.heightAnchor.constraint(equalTo: view.heightAnchor),
blurView.widthAnchor.constraint(equalTo: view.widthAnchor)
])
return view // ← 把这个 UIKit 视图交给 SwiftUI 管理
}
// 第二步:状态更新时同步(这里暂时不需要做任何事)
func updateUIView(_ uiView: UIViewType, context: Context) {
// 如果 style 会动态变化,就在这里更新
}
}
重点理解:makeUIView 返回的那个 UIView,之后就由 SwiftUI 的布局系统接管了。你不需要手动设置 frame,SwiftUI 会帮你处理尺寸。
extension View {
func blurBackground(style: UIBlurEffect.Style) -> some View {
ZStack {
BlurView(style: style) // ← UIKit 毛玻璃,铺在底层
self // ← 原来的 SwiftUI 视图,叠在上层
}
//两个方法都行
//self.background(BlurView(style: style))
}
}
BlurView 在这里和任何 SwiftUI 原生 View 完全没有区别,可以直接放进 ZStack。这就是 UIViewRepresentable 的意义:让 UIKit 视图假装自己是 SwiftUI 视图。
.blurBackground(style: .systemMaterial)
↓
ZStack 叠加
┌────────────┐
│ BlurView │ ← UIViewRepresentable 在这里翻译
│ (UIKit) │ makeUIView() 被 SwiftUI 自动调用
└────────────┘
↑
self(原 SwiftUI 视图)叠在上面
| 场景 | 推荐方案 |
|---|---|
| 毛玻璃、特效 |
UIVisualEffectView → UIViewRepresentable
|
| 地图 |
MKMapView → 或直接用 SwiftUI 的 Map
|
| 网页 |
WKWebView → UIViewRepresentable
|
| 富文本编辑 |
UITextView → UIViewRepresentable
|
| 相机预览 |
AVCaptureVideoPreviewLayer → UIViewRepresentable
|
| SwiftUI 能搞定的 | 直接用 SwiftUI,别绕弯子 |
如果你要包装的不是 UIView,而是整个 UIViewController(比如系统的图片选择器、分享弹窗),用这个:
struct ImagePickerView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIImagePickerController {
return UIImagePickerController()
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
// 同上,同步状态用
}
}
逻辑完全一样,只是把 UIView 换成了 UIViewController。
UIViewRepresentable 本质上就是:
实现两个方法(创建、更新),让 SwiftUI 知道怎么驾驭一个 UIKit 视图
它解决的核心问题是:SwiftUI 和 UIKit 生命周期不同,这个协议负责在两套系统之间搭桥。
BlurView 是一个非常标准的使用案例——SwiftUI 没有,UIKit 有,包一下,用上。
本文同步自微信公众号 “Android技术圈”
Swift 终于正式杀进 Android 了。 不是社区 Demo,不是民间魔改,而是 Swift 官方第一次亲自发布 Android SDK。 这件事现在看起来像一条技术新闻,再往后看,很可能会变成移动开发格局变化的起点。
先别急着喊 “Kotlin 要完了”。 但也别把它轻飘飘理解成 “Swift 又做了个跨平台实验”。 因为这次最狠的地方在于:Swift 官方第一次给出了进入 Android 工程体系的真实路径。
比“Apple 要不要抢 Android 地盘”更值得看的,其实是官方这次到底把支持范围推进到了哪一步。
Swift 官方博客对这件事的表述,其实非常克制,但也非常明确。核心就三点:
Swift Java 和 Swift Java JNI Core,把 Swift 代码集成进现有 Kotlin / Java Android 应用这三句话加起来,真正说明的是:
Swift on Android,已经从社区探索,进入官方支持阶段。
这一步为什么重要?
因为过去你看到的很多 “Swift 跑 Android”,本质上都还是社区项目、实验性方案,或者少数团队自己打补丁维护的链路。
而这次不一样。 这次是 Swift 官方把 SDK、文档、安装方式、交叉编译路径和互操作方案一起摆上台面。
对开发者来说,差别非常大。
社区方案是“能折腾出来”。 官方方案才是“值得认真观察是否能进生产”。
这类新闻最容易出现两个极端判断。
第一种是:
“Kotlin 要凉了,Android 以后可以全用 Swift 写。”
第二种是:
“没意义,不就是把命令行程序编到 Android 上吗?”
这两种都不准确。
从 Swift 官方文档来看,今天已经明确成立的是:
这意味着什么?
意味着 Swift 现在已经不是“只能在 Apple 生态里玩”的语言了。 但它也还远远没到“Android 主流团队明天集体切语言”的程度。
它真正有价值的地方,不是在口号层。 而是在工程层。
它第一次让 Swift 有机会进入 Android 的真实项目结构里。
Swift 官方的 Android 入门文档其实很务实,没有画大饼。
要跑起来,你得准备 3 样东西:
然后通过 swift build --swift-sdk ... 做 Android 目标的交叉编译。
官方演示的第一步,也不是完整 App,而是先把一个 Swift 可执行程序编译到 Android 上运行。
很多人看到这里会下意识地说:
“那不还是离真正 Android App 很远?”
但官方文档后面紧接着补了一句特别关键的话:
Swift 模块可以构建为共享库,并被打进 Android 应用,再由 Java / Kotlin 代码调用。
这句话,才是整件事的核心。
因为这说明 Swift on Android 的第一落点,并不是 UI 层,也不是整项目重写。 而是更现实、更符合团队采纳路径的地方:
说白了,Swift 现在不是先来抢 Compose 的饭碗。 它更像是先从“共享模块语言”这个位置切进去。
对纯 Android 团队来说,这条新闻现在更像观察项。 但对已经重度使用 Swift 的团队,这个信号就完全不一样了。
过去很多团队都会面临一个老问题:
“iOS 侧已经有一套成熟的 Swift 代码资产了,Android 还要不要重写一遍?”
如果这些资产只是 UI,那没办法,平台差异太大。 但如果是业务规则、网络协议、加解密逻辑、通用数据处理,重写本身就是重复劳动。
Swift 官方 Android SDK 的出现,至少让这类团队开始有了一个新的选项:
把 Swift 资产往 Android 端延伸,而不是永远困在 Apple 生态内部。
这点其实很关键。
因为它改变的不是某个 API,而是开发者对 Swift 的心理预期。
过去大家默认 Swift 是: “写 iPhone、写 Mac、写 Apple 设备。”
现在这个认知边界开始松了。
Swift 正在从 Apple 平台语言,继续往真正的跨平台语言走。
短期答案很直接:不会。
Kotlin 在 Android 的位置,不是一两条技术新闻能撼动的。 它背后是 Android 官方支持、Jetpack 生态、Android Studio、社区实践、招聘市场和海量线上项目。
Swift 6.3 现在拿出来的是“首个官方 Android SDK”。 这离大规模生产落地,中间还隔着很多现实问题:
所以更合理的判断不是“Swift 替代 Kotlin”。
而是:
从更大的角度看,这次最受益的,也许不是 Android,而是 Swift 本身。
一门语言如果长期绑定单平台,它再强,外界对它的认知上限也很明确。
只有当它开始稳定支持更多平台,开发者才会真正把它当成一门独立的软件工程语言,而不是某家平台公司的附属工具。
Swift 6.3 这次最值得玩味的地方就在这里。
它不是一句空泛的“我们支持跨平台”。 而是给出了 SDK、文档、安装方式、构建路径、互操作方案。
这说明 Swift 官方现在回答的问题,已经不再只是:
“Swift 能不能把 Apple 生态服务好?”
而是:
Swift 能不能成为一门覆盖更多平台的软件工程语言?
Android,就是这个问题里最关键的一块拼图。
如果你是 Android 开发者,不需要焦虑,也没必要跟风重写任何项目。
你更应该做的是持续观察这几个点:
Swift Java 的接入体验到底顺不顺如果你是 iOS / Swift 开发者,这件事反而更值得持续盯住。
因为这可能是 Swift 在未来两三年里,最有战略意义的一次边界扩张。
它未必马上改变你的项目。 但它很可能会慢慢改变未来的技术选型。
Swift 6.3 的重点,不是简单一句“Swift 能写安卓了”。
更准确的说法应该是:
Swift 官方第一次正式发布 Android SDK,并给出了进入现有 Android 工程的可执行路径。
这一步还远远谈不上改写 Android 生态。 但它已经足够说明一件事:
平台边界,还在继续变薄。
你看好 Swift 在 Android 上的发展吗? 你觉得它会先成为共享逻辑工具,还是最终走到更完整的 Android 开发场景?
欢迎评论区聊聊。 如果你身边正好有做 iOS、Android、跨平台架构的朋友,也欢迎把这篇文章转给他们,一起讨论。
参考资料:
一年一度的 Let's Vision 大会在上海如期举行,今年的主题是:“Born to Create, Powered by AI”。除了与 Swift、空间计算相关的常规 Session,大会还邀请了许多开发者分享他们在工作中对 AI 的应用与理解。通过这些讲师对 AI 工作流的介绍,我也受益匪浅。原本只能容纳 300 人的 AI 主题会场,里三层外三层站满了热情高涨的观众。
然而,在众多优秀的 Session 中,一场由 YuChe Cheng 准备的、名为《Let's Create 1-liner Code in Swift》的演讲却将我的注意力引向了另一个会场。这究竟是一个怎样的话题?带着疑问我走了进去。作为一个 LeetCode 积分 2200+ 的开发者,YuChe Cheng 在演讲中展示了如何通过 Foundation 以及 Swift Algorithms 提供的大量高阶函数,将原本平淡无奇的 For-loop 代码,转换成更加优雅、美观、极具 Swift 风格的 Function Chaining(1-liner code),并在易读性与性能之间取得了很好的平衡。
看着幻灯片上的 Function Chaining 被一次又一次地优雅迭代,我有种茅塞顿开的畅快。整整 30 分钟的演讲,让我始终处于一种纯粹的兴奋之中——这种感觉,通常只在我绞尽脑汁最终攻克了一个难题,或是深刻理解了一个新概念后才会涌现。
尽管与主会场只有一墙之隔,但由于 AI 话题的绝对热度,本场演讲的听众明显偏少。与其说我为许多人错失了一场精彩演讲而感到遗憾,我真正担心的其实是:随着 AI 的进一步渗透,许多开发者原本在追求功能之外所赋予代码的那份“气质”,会不会就此消亡?
开发者不应该只关心编译后冷冰冰的二进制功能,代码本身也是个人风格的载体。它就像文章一样,在输出逻辑与结果之外,还承载着美学表达,体现着编写者的个人品味与巧思。
在今年的 Let's Vision 上,我感觉我们正站在一个时间的十字路口:我们是该一味追求 AI 带来的极致高效,还是在拥抱变化的同时,依然让属于开发者的那份骄傲与手艺,在 AI 时代得以保留?
从 Swift 6 开始,语言演进已经稳定在半年一个 minor 版本的节奏,上周 Swift 6.3 如期发布。与前几个版本相比,这一版本并未引入明显的重磅特性,更多是对既有体系的打磨:并发模型在诊断准确性方面有所改进,新增的 @c 特性(attribute)进一步强化了 C/C++ 互操作能力,同时编译优化的控制粒度也变得更加细致。
尽管如此,这一版本也释放出一个清晰的信号:Swift 正在从“以 Apple 平台为中心的应用开发语言”,逐步向“具备跨平台与系统级能力的通用语言”演进。Embedded Swift、Android 支持的持续推进,以及 SwiftPM 构建体系的统一,都在指向这一方向。对多数 iOS 开发者而言,短期体感或许有限,但从更长的时间维度来看,这更像是一次为未来铺路的基础性更新。
当 API 契约尚未稳定、前后端对字段的理解又经常漂移时,Swift 的强类型系统反而会放大数据与 JSON 之间转换时的边界问题。Roman Niekipielov 在本文中介绍了一个刻意做小的 JSONValue 类型,用来承接这类过渡阶段的 JSON 数据。相比 [String: Any],它保留了更明确的类型结构;相比直接编写 Codable 模型,又更适合应对频繁变化的契约。这个实现并不试图替代正式模型,而是将不确定性暂时限制在边界层。
市面上有大量开发者使用 Python、TypeScript 开发 AI Agent,但 Chris Karani 认为,Swift 的并发模型天然更适合 Agent 的隔离与调度,强类型系统和宏功能也带来了额外的安全保证。他用 6 篇文章、从多个角度实践了这一观点——从统一多个 LLM Provider 的 SDK Conduit,到基于 Apple Foundation Models 的 Agent 运行时 Colony,再到用 Metal 加速的上下文记忆管理。如果你正在考虑在 Apple 平台上构建 AI 功能,这个系列是目前少见的完整原生方案。
Danny Bolella 在纽约参加了苹果举办的 Liquid Glass 设计工作坊,与设计团队和 SwiftUI 工程师进行了为期三天的深入交流。本次活动传递出非常明确的信号:Liquid Glass 并非过渡性尝试,而是苹果未来数年的设计方向,且将在后续工具链中成为默认前提。与此同时,苹果反复强调“层级(Hierarchy)”的重要性——界面应围绕内容构建,控件只是服务于内容的辅助元素,应尽量退居边缘,让信息本身成为视觉与交互的中心。除此之外,Danny 还在本文中记录了其他一些 SwiftUI 工程师给出的建议和技巧。本文记录的内容可以帮助你更早理解这场设计演进的节奏与方向。
苹果对 App Store Connect 进行了近年来最大的一次更新,一口气引入了 100+ 官方指标、按来源划分的 cohort 分析、同行基准对比(转化率与单下载收益)以及可通过 API 导出的订阅数据。Jessica Chung 在本文中对这些关键变化进行了系统梳理。由于所有数据均来自苹果一手统计,这意味着开发者在 ASO 和增长决策中,将不再依赖第三方估算,而可以直接基于真实用户行为进行分析与优化。更重要的是,这次更新补齐了长期缺失的关键能力:你可以追踪不同关键词与渠道带来的用户质量,建立从曝光、下载到订阅与续费的完整转化链路,并通过同行基准明确自身所处位置。
本次更新对于开发者而言无疑是利好,但对于部分第三方 App Store 分析服务来说,也在一定程度上提高了竞争门槛,促使其提供更具附加值的能力。
在创建 SPM 时,某些依赖可能只被特定 API 使用,但一旦用户引入该包,即便不使用这些 API,也需要一并引入相关依赖。Package Traits 正是为了解决这一问题而引入的,它为 SPM 提供了一种声明可选特性的方式,使使用者能够按需启用功能,从而避免引入不必要的依赖。遗憾的是,在该功能推出后,一直只能在社区版本的 Swift 工具链中使用。随着 Xcode 26.4 的发布,Package Traits 终于获得了苹果官方支持,有望迎来更广泛的应用。Matt Massicotte 在本文中对该特性进行了介绍,并展示了其基本用法。
用户投诉响应慢,一定是应用性能问题吗?Rafał Dubiel 将关注点从“实际性能”转向“感知性能(Perceived Performance)”,讨论如何通过界面反馈与交互节奏,让用户感觉应用“更快”。例如通过 skeleton view、延迟加载,以及合理的动画与状态过渡来掩盖等待时间。作者指出,在许多场景下,用户体验的关键并不在于减少毫秒级的计算时间,而在于是否及时提供反馈。相比单纯优化性能指标,这种从用户感知出发的思路,往往更直接地影响用户对应用流畅度的判断。
iOS 26 为 SwiftUI 新增了 lineHeight(_:) modifier,用于控制文本相邻两行基线之间的距离。Natalia Panferova 在本文中对各种配置方式进行了详细对比:内置预设(.loose、.tight)、基于字号倍数的 .multiple(factor:)、固定增量的 .leading(increase:),以及绝对值控制的 .exact(points:)。此外,lineHeight(_:) 与已有的 lineSpacing(_:) 并不相同:前者控制基线间距,后者控制行底到下一行行顶的距离。
Natalia Panferova 曾是 Apple SwiftUI 核心团队成员,参与过多个关键 API 的设计与开发。本月她刚刚出版了新书 The SwiftUI Way,面向有一定 SwiftUI 经验的开发者,聚焦于生产环境中的模式选择、常见反模式识别,以及如何与框架“顺势而为”而非对抗。
Cove 是由 Emanuele Micheletti 开发的一款原生 macOS 数据库客户端,整个项目完全使用 Swift 6 构建,目前已经支持 PostgreSQL、MySQL、MariaDB、SQLite、MongoDB、Redis、ScyllaDB、Cassandra 和 Elasticsearch 等多种后端。它采用 SwiftUI 搭配 AppKit 原生控件实现,没有走 Electron 或 Web 技术栈,因此整体更轻量,也更符合 macOS 用户熟悉的交互体验。
相比“又一个数据库 GUI”,Cove 更值得关注的是它的实现思路。作者将所有数据库能力统一抽象为 DatabaseBackend 协议,UI 层不包含任何针对特定后端的分支逻辑。无论是 SQL 数据库、Redis 这类键值数据库,还是 MongoDB、Elasticsearch 这类非关系型后端,最终都会被整理为统一的表格模型交由界面渲染。项目目前仍处于 v0.1.0 的早期阶段,但已经具备查询、结构浏览、编辑、SSH 隧道和多标签等基础能力。即便你并不打算把它作为日常数据库工具,Cove 依然是一个很值得 Swift 开发者研究的桌面应用架构样本。
如果本期周报对你有帮助,请:
🚀 拓展 Swift 视野
- 📮 邮件订阅 | weekly.fatbobman.com 获取独家技术洞察
- 👥 开发者社区 | Discord 实时交流开发经验
- 📚 原创教程 | fatbobman.com 学习 Swift/SwiftUI 最佳实践
把一组样式或 UI 结构打包成可复用的东西,用 .modifier() 链式调用贴到任意 View 上。
UIKit 里你会封装一个函数来复用样式:
func styleToolButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 25)
button.setTitleColor(.white, for: .normal)
button.frame.size = CGSize(width: 30, height: 30)
}
ViewModifier 干的是同一件事,但它不只能改属性,还能在原有 View 外面包一层新的 View 结构,这是普通函数做不到的:
struct BadgeModifier: ViewModifier {
func body(content: Content) -> some View {
ZStack(alignment: .topTrailing) {
content // 原来的 View 原封不动
Text("99")
.background(Color.red)
.clipShape(Circle())
.offset(x: 10, y: -10)
}
}
}
Image(systemName: "bell").modifier(BadgeModifier())
Image(systemName: "message").modifier(BadgeModifier())
本质就是一个语法糖,功能上等价于自定义一个 View 然后把其他 View 塞进去,但它能融入 SwiftUI 的链式调用语法,用起来跟
.font().foregroundColor()一模一样。
SwiftUI 没有 UIKit 那样直接设置 layer.borderWidth 的属性,填充和描边需要两个图层叠加来实现。
UIKit 两行搞定:
view.layer.borderWidth = 4
view.layer.borderColor = UIColor.green.cgColor
SwiftUI 必须用 ZStack 叠两个 RoundedRectangle:
.background(
ZStack {
RoundedRectangle(cornerRadius: 20)
.stroke(model.color, style: StrokeStyle(lineWidth: 4))
RoundedRectangle(cornerRadius: 20)
.fill(gradientStyle)
}
)
stroke(描边)默认居中描边,线宽一半在内一半在外。fill 只填充内部区域,所以 fill 会覆盖 stroke 内侧的那一半。先画 stroke 再盖 fill,能让 stroke 外侧的一半露出来,边框视觉上更完整。反过来的话内侧边框线被盖住,边框显得细一半。
这块 UIKit 确实更直观,SwiftUI 的声明式思路在这个场景下反而绕了一圈
SwiftUI 没有 layer,只有 Shape + 绘制规则
| 操作 | 本质 |
|---|---|
| fill | 填充 Shape 内部 |
| stroke | 沿路径画边 |
.stroke(lineWidth: 4)
👉 描边在路径两侧(内 + 外)
.strokeBorder(lineWidth: 4)
👉 描边完全在内部
.fill(...)
.overlay(stroke)
👉 语义清晰:先填充,再叠加边框
本质是
(View) -> View,不是修改 View,而是生成新 View
顺序不是语法问题,而是 View 树结构
边框不是属性,而是绘制结果(Shape + stroke)
一年一度的 Let's Vision 大会在上海如期举行,今年的主题是:“Born to Create, Powered by AI”。除了与 Swift、空间计算相关的常规 Session,大会还邀请了许多开发者分享他们在工作中对 AI 的应用与理解。通过这些讲师对 AI 工作流的介绍,我也受益匪浅。原本只能容纳 300 人的 AI 主题会场,里三层外三层站满了热情高涨的观众。
它们是一套组合拳,缺一不可:
| 角色 | 是什么 |
|---|---|
ObservableObject |
一个协议,贴在 class 上,宣告"我是可被观察的数据源" |
@Published |
一个 Property Wrapper,贴在属性上,宣告"这个属性变化时要通知订阅者" |
@ObservedObject |
一个 Property Wrapper,贴在 View 的属性上,宣告"我订阅这个数据源,它变化我就刷新" |
@State 适合简单的值类型,但现实中你的数据模型往往是一个 class,有很多属性和方法,且需要被多个平级 View 共享。
// 一个用户信息模型,多个页面都要用
class UserModel {
var name: String = "Tom"
var age: Int = 18
var score: Int = 0
// ... 还有很多方法
}
把这个 class 塞进 @State 是行不通的——@State 是为值类型设计的,对 class 的引用地址变化不敏感,属性改了 UI 也不会刷新。
// 第一步:让你的 class 遵守 ObservableObject 协议
class UserModel: ObservableObject {
// 第二步:在需要触发 UI 刷新的属性上加 @Published
@Published var name: String = "Tom"
@Published var score: Int = 0
var internalCache: String = "" // 不加 @Published,改它不会刷新 UI
}
// 第三步:在 View 里用 @ObservedObject 订阅这个模型
struct ProfileView: View {
@ObservedObject var user: UserModel
var body: some View {
VStack {
Text(user.name)
Text("\(user.score)")
Button("加分") {
user.score += 1 // 改 @Published 属性 → 触发 UI 刷新
}
}
}
}
// 使用:顶层 View 用 @StateObject 持有并创建模型
struct ContentView: View {
@StateObject var user = UserModel()
var body: some View {
ProfileView(user: user)
}
}
@Published 本质上是:
@propertyWrapper
public struct Published<Value> {
// 每次 wrappedValue 被 set,就通过 objectWillChange 发出通知
public var wrappedValue: Value
// $score 拿到的是一个 Combine Publisher,可以接链式操作
public var projectedValue: Publisher
}
ObservableObject 协议本质上是:
public protocol ObservableObject: AnyObject {
// 编译器会自动合成这个,你的 @Published 属性改变时,它会发出信号
var objectWillChange: ObservableObjectPublisher { get }
}
@ObservedObject 本质上是:View 订阅了 user.objectWillChange,只要它 emit,SwiftUI 就重新计算这个 View 的 body。
整个流程:
user.score += 1
→ @Published 的 setter 触发
→ user.objectWillChange.send()
→ 订阅了它的 @ObservedObject 感知到
→ SwiftUI 重新渲染对应的 View
这是一个非常容易踩的坑:
| @ObservedObject | @StateObject | |
|---|---|---|
| 数据归属 | 不拥有,由外部传入 | 拥有,由这个 View 创建和持有 |
| 生命周期 | 跟随外部,不负责销毁 | 跟随 View,View 消失时销毁 |
| 典型场景 | 子 View 接收父 View 传来的模型 | 根 View 或顶层 View 创建模型 |
经验法则:谁创建,谁用 @StateObject;谁接收,谁用 @ObservedObject。
只有 class 能用:ObservableObject 是 AnyObject 的子协议,struct 和 enum 无法遵守,这套机制天生是为引用类型设计的。
@Published 要精准:不是所有属性都需要 @Published,只给真正需要驱动 UI 的属性加,滥加会导致不必要的 View 重渲染,影响性能。
objectWillChange 是"将要改变":SwiftUI 在属性改变之前就会收到通知,你通常不需要手动调用它,但在某些手动控制的场景可以用 objectWillChange.send() 主动触发刷新。