MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Swift响应式编程框架Combine入门

2022-08-226.7k 阅读

Combine框架概述

什么是响应式编程

在传统编程范式中,我们通常以命令式的方式编写代码,即按照顺序描述要执行的操作。比如,获取数据、处理数据然后展示数据。而响应式编程(Reactive Programming)是一种基于异步数据流和变化传播的编程范式。在响应式编程中,数据以流(Stream)的形式存在,当数据流中的数据发生变化时,相关的操作会自动作出响应。这就像是在一个实时更新的系统中,各个部分会自动根据数据的变化而调整。例如,在一个实时股票价格显示应用中,当股票价格数据发生变化时,界面上的价格显示会自动更新,而不需要手动去检查和更新。

Combine框架的地位

Combine 是苹果在 iOS 13、macOS Catalina、watchOS 6 和 tvOS 13 中引入的一个响应式编程框架。它为 Swift 开发者提供了一种统一的方式来处理异步事件流,如用户输入、网络请求、传感器数据等。Combine 基于函数式编程理念,使用可观察对象(Observable)、订阅者(Subscriber)和操作符(Operator)等概念,使得处理复杂的异步逻辑变得更加简洁和可读。与其他流行的响应式编程框架,如 RxSwift 相比,Combine 是苹果官方推出的框架,与 Swift 语言和苹果的生态系统结合得更加紧密,具有更好的原生支持和性能优化。

Combine基础概念

可观察对象(Observable)

可观察对象是 Combine 框架中的核心概念之一,它代表了一个可以发出一系列事件的实体。这些事件可以是值的更新、错误的发生或者完成信号。在 Combine 中,可观察对象通常是一个符合 Publisher 协议的类型。例如,Timer 可以作为一个可观察对象,它会按照设定的时间间隔发出事件。

下面是一个简单的 Timer 作为可观察对象的代码示例:

import Combine

let timerPublisher = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
let cancellable = timerPublisher.sink { value in
    print("Timer fired: \(value)")
}

在这个例子中,Timer.publish(every: 1, on: .main, in: .common) 创建了一个 Publisher,它会每秒在主线程上发出一个事件。.autoconnect() 方法使得这个 Publisher 开始实际发出事件。sink 方法创建了一个订阅者,当 Publisher 发出事件时,闭包中的代码会被执行,打印出事件的值。

订阅者(Subscriber)

订阅者是接收可观察对象发出的事件的实体。在 Combine 中,订阅者通常是一个符合 Subscriber 协议的类型。不过,在实际使用中,我们更多地使用 sinkassign(to:on:) 等方法来创建临时的订阅者。

sink 方法为例,它可以方便地创建一个订阅者,并且可以处理 Publisher 发出的三种类型的事件:值事件、完成事件和错误事件。例如:

let subject = PassthroughSubject<Int, Never>()
let cancellable = subject.sink(
    receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("Publisher finished")
        case .failure(let error):
            print("Publisher failed with error: \(error)")
        }
    },
    receiveValue: { value in
        print("Received value: \(value)")
    }
)
subject.send(1)
subject.send(2)
subject.send(completion: .finished)

在这个代码中,PassthroughSubject 是一个 Publishersink 创建了一个订阅者。当 subject 发送值事件(send(1)send(2))时,receiveValue 闭包会被执行。当 subject 发送完成事件(send(completion: .finished))时,receiveCompletion 闭包会被执行。

操作符(Operator)

操作符是对可观察对象发出的事件进行处理和转换的函数。它们可以对事件流进行过滤、映射、合并等操作。Combine 框架提供了丰富的操作符,使得我们可以灵活地处理复杂的事件逻辑。

例如,map 操作符可以将 Publisher 发出的每个值进行转换:

let numbersPublisher = [1, 2, 3].publisher
let squaredPublisher = numbersPublisher.map { $0 * $0 }
let cancellable = squaredPublisher.sink { value in
    print("Squared value: \(value)")
}

在这个例子中,numbersPublisher 是一个发出整数的 Publishermap 操作符将每个发出的整数平方,squaredPublisher 就是转换后的 Publisher。当订阅 squaredPublisher 时,会打印出平方后的值。

再比如,filter 操作符可以过滤掉不符合条件的事件:

let numbers = [1, 2, 3, 4, 5].publisher
let evenNumbersPublisher = numbers.filter { $0 % 2 == 0 }
let cancellable = evenNumbersPublisher.sink { value in
    print("Even number: \(value)")
}

这里,filter 操作符只让偶数通过,所以订阅 evenNumbersPublisher 只会收到偶数事件。

Combine框架的常用类型

PassthroughSubject

PassthroughSubject 是一种特殊的 Publisher,它可以手动发送事件。这在我们需要在代码的不同部分灵活控制事件的发送时非常有用。例如,在一个视图模型中,当某个条件满足时,我们可能想要发送一个事件通知视图进行更新。

let subject = PassthroughSubject<String, Error>()
let cancellable = subject.sink { completion in
    if case .failure(let error) = completion {
        print("Error: \(error)")
    }
} receiveValue: { value in
    print("Received: \(value)")
}
subject.send("Hello, Combine!")
subject.send(completion: .finished)

在上述代码中,PassthroughSubject 被创建用来发送字符串类型的值和 Error 类型的错误。通过 send 方法手动发送值和完成事件,订阅者会相应地处理这些事件。

CurrentValueSubject

CurrentValueSubject 也是一种 Publisher,它与 PassthroughSubject 的不同之处在于它会记住最后发送的值。当有新的订阅者订阅时,它会立即将最后发送的值发送给新订阅者。这在一些需要实时获取最新状态的场景中非常实用,比如实时显示用户的登录状态。

let subject = CurrentValueSubject<Bool, Never>(false)
let cancellable1 = subject.sink { isLoggedIn in
    print("Subscriber 1: Logged in? \(isLoggedIn)")
}
subject.send(true)
let cancellable2 = subject.sink { isLoggedIn in
    print("Subscriber 2: Logged in? \(isLoggedIn)")
}

在这个例子中,CurrentValueSubject 初始值为 falsecancellable1 订阅时,会收到初始值 false。当发送 true 后,cancellable1 会收到新值。而 cancellable2 订阅时,会立即收到 true,因为 CurrentValueSubject 记住了最后发送的值。

Future

Future 是一个 Publisher,它只会发出一个值或者一个错误,然后完成。它通常用于表示异步操作的结果,比如网络请求。一旦异步操作完成,Future 会发送相应的值或者错误。

func fetchData() -> Future<String, Error> {
    Future { promise in
        // 模拟异步操作,这里使用延迟来模拟
        DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
            if arc4random_uniform(2) == 0 {
                promise(.success("Data fetched successfully"))
            } else {
                promise(.failure(NSError(domain: "com.example.error", code: 1, userInfo: nil)))
            }
        }
    }
}

let cancellable = fetchData().sink(
    receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("Error: \(error)")
        }
    },
    receiveValue: { value in
        print("Received: \(value)")
    }
)

在上述代码中,fetchData 函数返回一个 Future。在 Future 的闭包中,模拟了一个异步操作(延迟 2 秒)。操作完成后,根据随机数决定是发送成功的值还是错误。订阅者通过 sink 来处理结果。

Combine中的数据绑定

使用assign(to:on:)进行数据绑定

在 Combine 中,assign(to:on:) 方法提供了一种方便的数据绑定方式,特别是在将 Publisher 的值绑定到视图属性上时非常有用。它可以将 Publisher 发出的值直接赋值给指定对象的属性。

假设我们有一个简单的视图模型和视图:

class ViewModel {
    var count = CurrentValueSubject<Int, Never>(0)
}

class MyView: UIView {
    @IBOutlet weak var label: UILabel!
    var viewModel: ViewModel!
    var cancellable: AnyCancellable?

    func setup() {
        cancellable = viewModel.count.assign(to: \.text, on: label)
    }
}

在这个例子中,ViewModel 中的 count 是一个 CurrentValueSubject。在 MyViewsetup 方法中,通过 assign(to: \.text, on: label)count 发出的值绑定到 labeltext 属性上。当 count 的值发生变化时,labeltext 会自动更新。

双向数据绑定的实现思路

双向数据绑定在很多应用场景中非常重要,它允许数据在视图和视图模型之间双向流动。虽然 Combine 本身没有直接提供双向数据绑定的方法,但我们可以通过一些技巧来实现。

一种常见的方法是使用 PassthroughSubject 来监听视图的变化,然后在视图模型中处理这些变化并更新相关的 Publisher。例如,对于一个文本输入框和对应的视图模型属性:

class TextViewModel {
    var text = CurrentValueSubject<String, Never>("")
}

class TextViewController: UIViewController {
    @IBOutlet weak var textField: UITextField!
    var viewModel: TextViewModel!
    var cancellable1: AnyCancellable?
    var cancellable2: AnyCancellable?

    override func viewDidLoad() {
        super.viewDidLoad()

        cancellable1 = viewModel.text.assign(to: \.text, on: textField)

        let textSubject = PassthroughSubject<String, Never>()
        textField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)

        cancellable2 = textSubject
          .debounce(for: 0.3, scheduler: RunLoop.main)
          .assign(to: \.text, on: viewModel)
    }

    @objc func textFieldDidChange(_ textField: UITextField) {
        guard let text = textField.text else { return }
        textSubject.send(text)
    }
}

在这个代码中,cancellable1viewModel.text 的值绑定到 textFieldtext 属性上。textSubject 用于监听 textField 的变化,通过 debounce 操作符防止频繁发送事件,然后将变化的值绑定回 viewModel.text。这样就实现了双向数据绑定。

Combine处理复杂异步逻辑

合并多个Publisher

在实际应用中,我们经常需要合并多个 Publisher 的事件流。Combine 提供了多种方法来实现这一点,比如 mergecombineLatestzip

merge 操作符可以将多个 Publisher 合并成一个,新的 Publisher 会按照顺序发出所有源 Publisher 的事件。例如:

let publisher1 = [1, 2, 3].publisher
let publisher2 = [4, 5, 6].publisher
let mergedPublisher = Publishers.Merge(publisher1, publisher2)
let cancellable = mergedPublisher.sink { value in
    print("Merged value: \(value)")
}

在这个例子中,mergedPublisher 会依次发出 1, 2, 3, 4, 5, 6

combineLatest 操作符会在任意一个 Publisher 发出新值时,将所有 Publisher 的最新值组合成一个元组发出。例如:

let publisherA = CurrentValueSubject<Int, Never>(1)
let publisherB = CurrentValueSubject<String, Never>("a")
let combinedPublisher = Publishers.CombineLatest(publisherA, publisherB)
let cancellable = combinedPublisher.sink { value in
    print("Combined values: (\(value.0), \(value.1))")
}
publisherA.send(2)
publisherB.send("b")

这里,combinedPublisher 会在 publisherApublisherB 有新值时,发出组合后的元组 (1, "a"), (2, "a"), (2, "b")

zip 操作符会将多个 Publisher 发出的值一一对应地组合成元组发出,当其中一个 Publisher 完成时,新的 Publisher 也会完成。例如:

let numbersPublisher = [1, 2, 3].publisher
let lettersPublisher = ["a", "b", "c"].publisher
let zippedPublisher = Publishers.Zip(numbersPublisher, lettersPublisher)
let cancellable = zippedPublisher.sink { value in
    print("Zipped values: (\(value.0), \(value.1))")
}

zippedPublisher 会发出 (1, "a"), (2, "b"), (3, "c")

处理异步序列

有时候,我们需要处理异步序列,即一系列异步操作。Combine 提供了 flatMapflatMapLatest 等操作符来处理这种情况。

flatMap 操作符可以将 Publisher 发出的值转换为另一个 Publisher,然后将这些新的 Publisher 的事件合并到一个事件流中。例如,假设我们有一个 Publisher 发出用户 ID,然后我们需要根据用户 ID 去获取用户信息:

func getUserInfo(for id: Int) -> Future<String, Error> {
    Future { promise in
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            if id == 1 {
                promise(.success("User 1 info"))
            } else {
                promise(.failure(NSError(domain: "com.example.error", code: 1, userInfo: nil)))
            }
        }
    }
}

let userIdsPublisher = [1, 2].publisher
let userInfoPublisher = userIdsPublisher.flatMap { userId in
    getUserInfo(for: userId)
}
let cancellable = userInfoPublisher.sink(
    receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("Error: \(error)")
        }
    },
    receiveValue: { value in
        print("User info: \(value)")
    }
)

在这个例子中,flatMap 将每个用户 ID 转换为获取用户信息的 Future,然后将这些 Future 的结果合并成一个事件流。

flatMapLatestflatMap 类似,但它只会关注最新的 Publisher。当有新的 Publisher 产生时,它会取消之前的 Publisher。这在处理用户频繁操作,只关心最新结果的场景中非常有用。例如,在一个搜索框中,用户频繁输入搜索关键词,我们只关心最后一次输入的搜索结果:

func search(for query: String) -> Future<String, Error> {
    Future { promise in
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            if query == "apple" {
                promise(.success("Search results for apple"))
            } else {
                promise(.failure(NSError(domain: "com.example.error", code: 1, userInfo: nil)))
            }
        }
    }
}

let searchSubject = PassthroughSubject<String, Never>()
let searchResultsPublisher = searchSubject.flatMapLatest { query in
    search(for: query)
}
let cancellable = searchResultsPublisher.sink(
    receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("Error: \(error)")
        }
    },
    receiveValue: { value in
        print("Search results: \(value)")
    }
)
searchSubject.send("banana")
searchSubject.send("apple")

在这个例子中,当 searchSubject 发送 "banana" 后,flatMapLatest 开始处理获取 "banana" 的搜索结果。但当发送 "apple" 时,之前获取 "banana" 结果的操作会被取消,只处理获取 "apple" 的搜索结果。

Combine在实际项目中的应用场景

网络请求处理

在现代应用开发中,网络请求是非常常见的操作。Combine 可以很好地处理网络请求的异步性和事件流。例如,我们可以使用 URLSession 结合 Combine 来进行网络请求。

func fetchData(from url: URL) -> Future<Data, Error> {
    Future { promise in
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                promise(.failure(error))
            } else if let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode), let data = data {
                promise(.success(data))
            } else {
                promise(.failure(NSError(domain: "com.example.networkerror", code: 1, userInfo: nil)))
            }
        }
        task.resume()
    }
}

let url = URL(string: "https://example.com/api/data")!
let cancellable = fetchData(from: url)
  .decode(type: [String: Any].self, decoder: JSONDecoder())
  .sink(
        receiveCompletion: { completion in
            if case .failure(let error) = completion {
                print("Error: \(error)")
            }
        },
        receiveValue: { value in
            print("Received data: \(value)")
        }
    )

在这个例子中,fetchData 函数返回一个 Future,用于发起网络请求。decode 操作符将返回的 Data 解码为指定的类型(这里是 [String: Any])。订阅者通过 sink 来处理请求的结果。

传感器数据处理

对于使用传感器数据的应用,如计步器、陀螺仪等,Combine 可以方便地处理传感器数据的实时更新。例如,假设我们要处理设备的加速度传感器数据:

import CoreMotion

class AccelerometerViewModel {
    let motionManager = CMMotionManager()
    let accelerometerData = PassthroughSubject<CMAccelerometerData, Error>()

    init() {
        if motionManager.isAccelerometerAvailable {
            motionManager.accelerometerUpdateInterval = 0.1
            motionManager.startAccelerometerUpdates(to: .main) { data, error in
                if let error = error {
                    self.accelerometerData.send(completion: .failure(error))
                } else if let data = data {
                    self.accelerometerData.send(data)
                }
            }
        } else {
            accelerometerData.send(completion: .failure(NSError(domain: "com.example.sensorerror", code: 1, userInfo: nil)))
        }
    }
}

let viewModel = AccelerometerViewModel()
let cancellable = viewModel.accelerometerData.sink(
    receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("Error: \(error)")
        }
    },
    receiveValue: { value in
        print("Accelerometer data: \(value.acceleration)")
    }
)

在这个代码中,AccelerometerViewModel 使用 CoreMotion 框架获取加速度传感器数据,并通过 PassthroughSubject 发送数据。订阅者可以实时接收并处理这些数据。

用户界面交互处理

在处理用户界面交互时,Combine 可以使代码更加简洁和可读。例如,处理按钮点击事件、文本输入变化等。

class ButtonViewModel {
    let buttonTapped = PassthroughSubject<Void, Never>()
}

class ButtonViewController: UIViewController {
    @IBOutlet weak var button: UIButton!
    var viewModel: ButtonViewModel!
    var cancellable: AnyCancellable?

    override func viewDidLoad() {
        super.viewDidLoad()

        cancellable = viewModel.buttonTapped.sink {
            print("Button tapped!")
        }

        button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
    }

    @objc func buttonTapped(_ sender: UIButton) {
        viewModel.buttonTapped.send()
    }
}

在这个例子中,ButtonViewModel 中的 buttonTapped 是一个 PassthroughSubject,用于发送按钮点击事件。在 ButtonViewController 中,通过 addTarget 监听按钮点击,然后发送事件给 buttonTapped,订阅者可以处理这个点击事件。

Combine的性能优化与注意事项

避免内存泄漏

在使用 Combine 时,内存泄漏是一个需要注意的问题。如果订阅者没有正确取消订阅,可能会导致对象无法释放,从而造成内存泄漏。例如,在视图控制器中订阅了一个 Publisher,但在视图控制器销毁时没有取消订阅,就会出现这种情况。

为了避免内存泄漏,我们可以使用 AnyCancellable 类型,并在适当的时候调用 cancel 方法取消订阅。例如:

class MyViewController: UIViewController {
    var cancellable: AnyCancellable?

    override func viewDidLoad() {
        super.viewDidLoad()
        let publisher = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
        cancellable = publisher.sink { value in
            print("Timer fired: \(value)")
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        cancellable?.cancel()
    }
}

在这个例子中,viewWillDisappear 方法中调用 cancellable?.cancel() 取消订阅,确保在视图控制器消失时不会有未释放的引用。

合理使用操作符

虽然 Combine 提供了丰富的操作符,但不合理地使用操作符可能会导致性能问题。例如,过度使用 debouncethrottle 等操作符可能会增加不必要的计算开销。

在使用操作符时,要根据实际需求进行选择。如果只是需要简单地过滤或映射数据,使用基本的 filtermap 操作符即可。如果需要处理频繁的事件,如文本输入变化,要合理设置 debounce 的时间间隔,既保证用户体验,又不影响性能。

另外,对于复杂的操作符组合,要注意其执行顺序和逻辑。例如,在使用 flatMapcombineLatest 等操作符时,要清楚它们对事件流的影响,避免出现不符合预期的结果。

错误处理

在 Combine 中,正确处理错误非常重要。Publisher 可以发送错误事件,订阅者需要有相应的机制来处理这些错误。如果不处理错误,可能会导致程序崩溃或出现未定义行为。

在使用 sink 方法时,可以通过 receiveCompletion 闭包来处理错误:

let publisher = Future<String, Error> { promise in
    promise(.failure(NSError(domain: "com.example.error", code: 1, userInfo: nil)))
}
let cancellable = publisher.sink(
    receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("Error: \(error)")
        }
    },
    receiveValue: { value in
        print("Received: \(value)")
    }
)

此外,还可以使用 tryMaptryFlatMap 等操作符来处理可能抛出错误的转换操作,并通过 catch 操作符来捕获和处理错误:

let publisher = [1, 2, 3].publisher
let transformedPublisher = publisher.tryMap { value -> Int in
    if value == 2 {
        throw NSError(domain: "com.example.error", code: 1, userInfo: nil)
    }
    return value * 2
}
let handledPublisher = transformedPublisher.catch { error in
    Just(0)
}
let cancellable = handledPublisher.sink { value in
    print("Handled value: \(value)")
}

在这个例子中,tryMap 操作符可能会抛出错误,catch 操作符捕获错误并返回一个默认值 0,这样订阅者就不会因为错误而终止。