Skip to content

Swift Concurrency

军规

  • 只能在主线程中更新 UI

前置知识

hashable

  • 实现 hashable 协议 和 hash 函数
  • String 本身就实现 hashable 协议,因此才能
swift
let values: [String] = ["1", "2", "3"]

...

Foreach(values, \.self) { value in
    Text(value)
}

Background Thread

  • DispatchQueue.global.async()
  • DispatchQueue.main.async()
  • DispatchQueue.main.asyncAfter()

weak self/@escaping

  • Swift 的内存管理:ARC(Automatic Reference Counting)
  • weak self 防止循环引用导致内存泄漏
  • @escaping 用于函数的闭包参数,函数会异步回调闭包,由于闭包会在函数结束后才执行,这时候需要使用它来延长闭包的生命周期,告诉系统先不要释放闭包
Details
swift
import SwiftUI
import Combine

typealias getAppetizersCallback = (_ data :[String]) -> Void

class Manager {
    func getAppetizers(competed: @escaping getAppetizersCallback) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            let result = ["New Title", "Cool sala"]
            competed(result)
        }
    }
}

class ViewModel: ObservableObject {
    let manager = Manager()

    @Published var appetizers: [String] = []

    func getAppetizers() {
        manager.getAppetizers { [weak self] result in
            self?.appetizers = result
        }
    }
}

struct ContentView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
        VStack {
            ForEach(viewModel.appetizers, id: \.self) { appetizer in
                Text("\(appetizer)")
                    .fontWeight(.bold)
                    .font(.title)
                    .padding()
            }

            if viewModel.appetizers.count == 0 {
                Text("Empty appetizer")
            }
        }
        .padding()
        .onAppear {
            viewModel.getAppetizers()
        }
    }
}

请求 API 数据 - @escaping

Details
swift

import SwiftUI
import Combine

class Manager {
    let getAppetizersURL: String = "https://api.pingcx.cn/v3/appetizers"

    func getAppetizers(completed: @escaping (Data?) -> Void) {
        URLSession.shared.dataTask(with: URL(string: getAppetizersURL)!) { data, resp, err in
            guard err == nil ,
                let resp = resp as? HTTPURLResponse, resp.statusCode == 200,
                let data
            else {
                completed(nil)
                return
            }
            completed(data)
        }.resume()
    }
}

class ViewModel: ObservableObject {
    let manager = Manager()

    @Published var appetizers: [Appetizer] = []

    func getAppetizers() {
        manager.getAppetizers { data in
            guard let data else {
                print("fetch data failed")
                return
            }

            guard let res = try? JSONDecoder().decode(GetAppetizerResponse.self, from: data) else {
                print("parse data failed")
                return
            }

            DispatchQueue.main.async {
                self.appetizers = res.appetizers
            }
        }
    }
}

struct ContentView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
        NavigationView {
            ZStack {
                List {
                    ForEach(viewModel.appetizers) { appetizer in
                        VStack {
                            Text(appetizer.name)
                            Text(appetizer.description)
                        }
                    }
                }
                .onAppear {
                    viewModel.getAppetizers()
                }

                if viewModel.appetizers.count == 0 {
                    Text("Empty appetizer")
                }
            }
            .navigationTitle("Appetizers")
        }
    }
}

请求 API 数据 - Combine

请求 API 数据 - @escapinggetAppetizers() 替换为如下代码

Details
swift
...

@Published var appetizers: [Appetizer] = []

var cancellables = Set<AnyCancellable>()

...

func getAppetizers() {
    guard let url = URL(string: getAppetizersURL) else {
        return
    }

    URLSession.shared.dataTaskPublisher(for: url)
        .receive(on: DispatchQueue.main)
        .tryMap { (data, response) -> Data in
            guard
                let response = response as? HTTPURLResponse,
                response.statusCode == 200 else {
                throw URLError(.badServerResponse)
            }
            return data
        }
        .decode(type: GetAppetizerResponse.self, decoder: JSONDecoder())
        .replaceError(with: GetAppetizerResponse(appetizers: []))
        .sink(receiveValue: { [weak self] result in
            self?.appetizers = result.appetizers
        })
        .store(in: &cancellables)
}
Combine
  1. Combine 是什么

苹果官方的响应式编程框架,专门解决“数据变化如何驱动 UI”

  1. 核心概念:
  • Publisher(发布者):数据源
  • Subscriber(订阅者):监听数据变化的人
  • Operators(操作符):过滤器、转换器,比如 map、filter、debounce
  1. SwiftUI + Combine 的日常玩法
swift
class CounterViewModel: ObservableObject {
    @Published var value = 0
}
  • value 就是 Publisher
  • SwiftUI 的 View 订阅它
  • value 变了,View 自动重绘
  1. Combine 核心操作符:map、filter、debounce
swift
// 举例:搜索防抖
@Published var query = ""
var cancellable = $query
    .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
    .sink { text in
        print("发送网络请求: \(text)")
    }
  1. 总结
  • Combine = 数据流 + 响应式处理
  • SwiftUI + Combine = 数据变化自动刷新 UI
  • 常用套路:@Published → ObservableObject → SwiftUI View 订阅 → Operator 加工

Combine: Timer 和 onReceive

  • current time
  • countdown
countdown to date
swift
import SwiftUI
import Combine

struct ContentView: View {
    @State var timeRemaining = "--"

    let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
    let futureDate: Date = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()

    func updateTimeRemaining() {
        let remaining = Calendar.current.dateComponents([.hour, .minute, .second], from: Date(), to: futureDate)
        let min = remaining.minute ?? 0
        let sec = remaining.second ?? 0
        timeRemaining = "\(min):\(sec)"
    }

    var body: some View {
        Text(timeRemaining)
            .font(.system(size: 100, weight: .semibold, design: .rounded))
            .lineLimit(1)
            .minimumScaleFactor(0.1)
            .onReceive(timer) { _ in
                updateTimeRemaining()
            }
    }
}
动画:加载中
swift
import SwiftUI
import Combine

struct ContentView: View {

    let timer = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()

    @State var count = 0

    var body: some View {
        HStack(spacing: 15) {
            Circle()
                .offset(y: count == 1 ? -20: 0)

            Circle()
                .offset(y: count == 2 ? -20: 0)

            Circle()
                .offset(y: count == 3 ? -20: 0)
        }
        .frame(width: 150)
        .onReceive(timer) { _ in
            withAnimation(.easeInOut(duration: 0.45)) {
                count = count == 3 ? 0 : count + 1
            }
        }
    }
}
动画:轮播图
swift
struct ContentView: View {

    let timer = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()

    @State var count = 0

    var body: some View {
        TabView(selection: $count, content: {
            Rectangle()
                .foregroundColor(.red)
                .tag(1)

            Rectangle()
                .foregroundColor(.blue)
                .tag(2)

            Rectangle()
                .foregroundColor(.green)
                .tag(3)

            Rectangle()
                .foregroundColor(.orange)
                .tag(4)

            Rectangle()
                .foregroundColor(.pink)
                .tag(5)
        })
        .frame(height: 200)
        .tabViewStyle(PageTabViewStyle())
        .onReceive(timer) { _ in
            if count == 5 {
                withAnimation(nil) {
                    count = 1
                }
            } else {
                withAnimation(.default) {
                    count += 1
                }
            }
        }
    }
}

自定义 Publishers and Subscribers in Combine

参考代码

定义在 ViewModel 中的计时器(之前是定义在 View 里的)

1. 自定义 Publisher

计数器案例
swift
import SwiftUI
import Combine

class ViewModel: ObservableObject {
    @Published var count = 0

    var timer: AnyCancellable?

    init() {
        setupTimer()
    }

    func setupTimer() {
        timer = Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                guard let self = self else {
                    return
                }

                self.count += 1

                if self.count >= 10 {
                    self.timer?.cancel()
                }
            }
    }
}

struct ContentView: View {

    @StateObject private var viewModel = ViewModel()

    var body: some View {
        Text("\(viewModel.count)")
    }
}

2. 自定义 Subscriber

输入框 debounce 案例
swift
import SwiftUI
import Combine

class ViewModel: ObservableObject {
    var cancellables = Set<AnyCancellable>()

    @Published var textFieldText: String = ""
    @Published var textIsValid: Bool = false

    init() {
        addTextFieldSubscriber()
    }

    func addTextFieldSubscriber() {
        $textFieldText
            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
            .map { (text) -> Bool in
                text.count > 3
            }
//            .assign(to: \.textIsValid, on: self) // 推荐使用 sink, 而不是 assign 因为无法使用 weak self
            .sink(receiveValue: { [weak self] (isValid) in
                self?.textIsValid = isValid
            })
            .store(in: &cancellables)
    }
}

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        VStack {
            TextField("Type something here...", text: $viewModel.textFieldText)
                .padding(.leading)
                .frame(height: 55)
                .font(.headline)
                .background(Color(.gray))
                .cornerRadius(10)
                .overlay(
                    ZStack {
                        Image(systemName: "xmark")
                            .foregroundColor(.red)
                            .opacity(viewModel.textFieldText.count < 1 ? 0.0 : viewModel.textIsValid ? 0.0 : 1.0)

                        Image(systemName: "checkmark")
                            .foregroundColor(.green)
                            .opacity(viewModel.textIsValid ? 1.0 : 0.0)
                    }
                    .font(.title)
                    .padding(.trailing),
                    alignment: .trailing
                )

            Button {
                print("Pressed")
            } label: {
                Text("Submit".uppercased())
                    .font(.headline)
                    .foregroundColor(.white)
                    .frame(height: 55)
                    .frame(maxWidth: .infinity)
                    .background(Color(.blue))
                    .cornerRadius(10)
                    .opacity(viewModel.textIsValid ? 1.0 : 0.5)
            }
            .disabled(!viewModel.textIsValid)

        }
    }
}

do/try/catch/throws

Details
swift
import SwiftUI
import Combine

class Manager {
    var isActivate: Bool = true

    func getTitle() throws -> String {
        if !isActivate {
            throw URLError(.badURL)
        }

        return "New Title"
    }
}

class ViewModel: ObservableObject {
    let manager = Manager()

    @Published var title: String = ""

    func getTitle() {
        title = "Hello"

        do {
            let newTitle = try manager.getTitle()
            title = newTitle
        } catch {
            title = error.localizedDescription
        }
    }
}

struct ContentView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("\(viewModel.title)")
        }
        .padding()
        .onAppear {
            viewModel.getTitle()
        }
    }
}
  • getTitle() 的写法有很多种,尝试都实现一下
  • try? 忽略抛出的错误

Download images

视频

三种方式:

1. @escaping
swift
import SwiftUI
import Combine

typealias completedFn = (_ image: UIImage?, _ error: Error?) -> ()

class ImageManager {
    let url = URL(string: "https://picsum.photos/200")!

    func handleResponse(data: Data?, response: URLResponse?) -> UIImage? {
        guard
            let response = response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            return nil
        }

        guard
            let data = data,
            let image = UIImage(data: data) else {
            return nil
        }

        return image
    }

    func download(completed: @escaping completedFn) {
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            let image = self?.handleResponse(data: data, response: response)
            completed(image, error)
        }
        .resume()
    }
}

class ImageViewModel: ObservableObject {
    let imageManager = ImageManager()

    @Published var image: UIImage? = nil

    func fetchImage() {
        imageManager.download { [weak self] image, _ in
            DispatchQueue.main.async {
                self?.image = image
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var viewModel = ImageViewModel()

    var body: some View {
        ZStack {
            if let image = viewModel.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 250, height: 250)
            }
        }
        .onAppear {
            viewModel.fetchImage()
        }
    }
}
2. Combine
swift
import SwiftUI
import Combine

typealias completedFn = (_ image: UIImage?, _ error: Error?) -> ()

class ImageManager {
    let url = URL(string: "https://picsum.photos/200")!

    func handleResponse(data: Data?, response: URLResponse?) -> UIImage? {
        guard
            let response = response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            return nil
        }

        guard
            let data = data,
            let image = UIImage(data: data) else {
            return nil
        }

        return image
    }

    func download() -> AnyPublisher<UIImage?, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
            .map(handleResponse)
            // 把 publisher 的错误类型统一成 Error
            .mapError({ $0 })
            // 类型擦除
            .eraseToAnyPublisher()
    }
}

class ImageViewModel: ObservableObject {
    let imageManager = ImageManager()

    var cancellables = Set<AnyCancellable>()

    @Published var image: UIImage? = nil

    func fetchImage() {
        imageManager.download()
            .receive(on: DispatchQueue.main)
            .sink { _ in
            } receiveValue: { [weak self] image in
                self?.image = image
            }
            .store(in: &cancellables)
    }
}

struct ContentView: View {
    @StateObject private var viewModel = ImageViewModel()

    var body: some View {
        ZStack {
            if let image = viewModel.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 250, height: 250)
            }
        }
        .onAppear {
            viewModel.fetchImage()
        }
    }
}
3. Async/Await
swift
import SwiftUI
import Combine

typealias completedFn = (_ image: UIImage?, _ error: Error?) -> ()

class ImageManager {
    let url = URL(string: "https://picsum.photos/200")!

    func handleResponse(data: Data?, response: URLResponse?) -> UIImage? {
        guard
            let response = response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            return nil
        }

        guard
            let data = data,
            let image = UIImage(data: data) else {
            return nil
        }

        return image
    }

    func download() async throws -> UIImage? {
        do {
            let (data, response) = try await URLSession.shared.data(from: url, delegate: nil)
            return handleResponse(data: data, response: response)
        } catch {
            throw error
        }
    }
}

class ImageViewModel: ObservableObject {
    let imageManager = ImageManager()

    var cancellables = Set<AnyCancellable>()

    @Published var image: UIImage? = nil

    func fetchImage() async {
        let image = try? await imageManager.download()
        await MainActor.run {
            self.image = image
        }
    }
}

struct ContentView: View {
    @StateObject private var viewModel = ImageViewModel()

    var body: some View {
        ZStack {
            if let image = viewModel.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 250, height: 250)
            }
        }
        .onAppear {
            Task {
                await viewModel.fetchImage()
            }
        }
    }
}

async & await

如何实现延迟 2 秒后执行?

  1. 旧方式:DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
  2. 新方式:try? await Task.sleep(nanoseconds: 2_000_000_000)

主线程延迟执行,直接修改 UI 的状态

swift
func addTitle1() {
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        self.dataArray.append("Title1 : \(Thread.current)")
    }
}

后台线程延迟执行,必须通过 DispatchQueue.main.async 修改 UI 的状态

swift
func addTitle2() {
    DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
        let title = "Title2 : \(Thread.current)"
        DispatchQueue.main.async {
            self.dataArray.append(title)

            let title3 = "Title3 : \(Thread.current)"
            self.dataArray.append(title3)
        }
    }
}

新方式也是一样,await 之后 可能 会从主线程切换到后台线程,因此在需要修改 UI 的状态时,必须通过 await MainActor.run(body: { 来修改

swift
func addAuthor1() async {
    let author1 = "Author1 : \(Thread.current)"
    self.dataArray.append(author1)

    try? await Task.sleep(nanoseconds: 2_000_000_000)

    let author2 = "Author2 : \(Thread.current)"
    await MainActor.run(body: {
        self.dataArray.append(author2)

        let author3 = "Author3 : \(Thread.current)"
        self.dataArray.append(author3)
    })
}

Task 和 .task()

代码

  • Task, onAppearonDisappear
  • task() 自动取消任务
  • 优先级

async let 和 TaskGroup

代码

Details
swift
func fetchImagesWithTaskGroup() async throws -> [UIImage] {
    let urlStrings = [
        "https://picsum.photos/300",
        "https://picsum.photos/300",
        "https://picsum.photos/300",
        "https://picsum.photos/300",
        "https://picsum.photos/300",
    ]
    return try await withThrowingTaskGroup(of: UIImage?.self) { group in
        var images: [UIImage] = []
        images.reserveCapacity(urlStrings.count)

        for urlString in urlStrings {
            group.addTask {
                try? await self.fetchImage(urlString: urlString)
            }
        }

        for try await image in group {
            if let image = image {
                images.append(image)
            }
        }

        return images
    }
}

Continuations 转换头

withCheckedThrowingContinuation 的作用是:把传统的“回调闭包写法”包装成 async/await 可用的异步函数


  1. 把回调式网络请求转成 async/await
swift
func downloadImage(from url: URL) async throws -> UIImage {
    try await withCheckedThrowingContinuation { continuation in
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let data = data, let image = UIImage(data: data) {
                continuation.resume(returning: image)
            } else {
                continuation.resume(throwing: error ?? URLError(.badServerResponse))
            }
        }.resume()
    }
}
  1. 把 Firebase、CoreLocation 这种闭包 API 转换

  2. 把 GCD 异步任务转换成 async

swift
func doHeavyWork() async throws -> Int {
    try await withCheckedThrowingContinuation { continuation in
        DispatchQueue.global().async {
            // 模拟耗时任务
            sleep(2)
            if Bool.random() {
                continuation.resume(returning: 42)
            } else {
                continuation.resume(throwing: NSError(domain: "Failed", code: -1))
            }
        }
    }
}

actor

  • actor 和 class 的区别:线程安全。actor 是带自动加锁的 class,专为并发安全设计
struct、class 和 actor 的区别
 Links:
 https://blog.onewayfirst.com/ios/posts/2019-03-19-class-vs-struct/
 https://stackoverflow.com/questions/24217586/structure-vs-class-in-swift-language
 https://medium.com/@vinayakkini/swift-basics-struct-vs-class-31b44ade28ae
 https://stackoverflow.com/questions/24217586/structure-vs-class-in-swift-language/59219141#59219141
 https://stackoverflow.com/questions/27441456/swift-stack-and-heap-understanding
 https://stackoverflow.com/questions/24232799/why-choose-struct-over-class/24232845
 https://www.backblaze.com/blog/whats-the-diff-programs-processes-and-threads/
 https://medium.com/doyeona/automatic-reference-counting-in-swift-arc-weak-strong-unowned-925f802c1b99

 VALUE TYPES:
 - Struct, Enum, String, Int, etc.
 - Stored in the Stack
 - Faster
 - Thread safe!
 - When you assign or pass value type a new copy of data is created

 REFERENCE TYPES:
 - Class, Function, Actor
 - Stored in the Heap
 - Slower, but synchronized
 - NOT Thread safe (by default)
 - When you assign or pass reference type a new reference to original instance will be created (pointer)

 - - - - - - - - - - - - - -

 STACK:
 - Stores Value types
 - Variables allocated on the stack are stored directly to the memory, and access to this memory is very fast
 - Each thread has it's own stack!

 HEAP:
 - Stores Reference types
 - Shared across threads!

 - - - - - - - - - - - - - -

STRUCT:
 - Based on VALUES
 - Can be mutated
 - Stored in the Stack!

CLASS:
 - Based on REFERENCES (INSTANCES)
 - Stored in the Heap!
 - Inherit from other classes

ACTOR:
 - Same as Class, but thread safe!

 - - - - - - - - - - - - - -

Structs: Data Models, Views
Classes: ViewModels
Actors: Shared 'Manager' and 'Data Stores'

class 使用 DispatchQueue 来实现互斥

swift
class MyDataManager {
    static let instance = MyDataManager()
    private init() { }

    var data: [String] = []
    private let lock = DispatchQueue(label: "com.MyApp.MyDataManager")

    func getRandomData(completionHandler: @escaping (_ title: String?) -> ()) {
        lock.async {
            self.data.append(UUID().uuidString)
            print(Thread.current)
            completionHandler(self.data.randomElement())
        }
    }
}

actor 自带互斥锁

swift
actor MyActorDataManager {
    static let instance = MyActorDataManager()
    private init() { }

    var data: [String] = []

    nonisolated let myRandomText = "asdfasdfadfsfdsdfs"

    func getRandomData() -> String? {
        self.data.append(UUID().uuidString)
        print(Thread.current)
        return self.data.randomElement()
    }

    nonisolated func getSavedData() -> String {
        return "NEW DATA"
    }
}
  • nonisolated 修饰的函数或变量,调用方不需要使用 await

@globalActor@MainActor

特性@MainActor@globalActor
定义者Swift 内置你自定义
并发域主线程任意 actor
作用UI 安全全局逻辑隔离
典型用途SwiftUI ViewModel日志、数据库、配置中心
  • UI 层:用 @MainActor
  • 后台任务(网络、数据库、缓存):定义各自的 @globalActor

Sendable

没搞懂...

Sendable 用来确保你把某个对象从一个并发域(actor、task)传到另一个时,不会造成数据竞争(data race)或内存不安全

AsyncPublisher

没搞懂...

将 @Published 转为 async 的方式...

manage strong & weak references with Async Await

  • 弄明白什么时候用 strong 和 weak
  • onAppear()onDisappear()task()

MVVM with Async Await

代码

Details
swift
import SwiftUI

final class MyManagerClass {
    func getData() async throws -> String {
        "Some Data!"
    }
}

actor MyManagerActor {
    func getData() async throws -> String {
        "Some Data!"
    }
}

@MainActor
final class MVVMBootcampViewModel: ObservableObject {

    let managerClass = MyManagerClass()
    let managerActor = MyManagerActor()

    @Published private(set) var myData: String = "Starting text"
    private var tasks: [Task<Void, Never>] = []

    func cancelTasks() {
        tasks.forEach({ $0.cancel() })
        tasks = []
    }

    func onCallToActionButtonPressed() {
        let task = Task {
            do {
//                myData = try await managerClass.getData()
                myData = try await managerActor.getData()
            } catch {
                print(error)
            }
        }
        tasks.append(task)
    }
}

struct MVVMBootcamp: View {

    @StateObject private var viewModel = MVVMBootcampViewModel()

    var body: some View {
        VStack {
            Button(viewModel.myData) {
                viewModel.onCallToActionButtonPressed()
            }
        }
        .onDisappear {

        }
    }
}