【Swift】Combineを利用した非同期処理の書き方

Combineの仕組みをなんとなく理解したけど業務で実装すると手が止まります。MVVMアーキテクチャでCombineを利用した非同期処理の書き方について解説します。

initで非同期処理を実行してSubjectに値を流す

ViewModelのinit内でAPIを叩いてSubjectに流すやり方です。

Swift
class ViewModel {
    
    private var cancellables = Set<AnyCancellable>()
    
    // 値を保持するSubject
    let stringSubject = CurrentValueSubject<String, Never>("")
    
    // 値を保持しないSubjcet
    let intSubject = PassthroughSubject<Int, Never>()
    
    init() {
        let resultString = fetchStringValue()
        stringSubject.send(resultString)
        
        let resultInt = fetchIntValue()
        intSubject.send(resultInt)
    }
}

CurrentValueSubject

最新の値を保持することができ、購読した時点で値が流れます。UIの表示処理などでから配列を保持しておく必要がある場合などに向いていると思います。

PassthroughSubject

値を保持しないPublisherで購読前に送られた値は受け取ることができないです。状態保持には向いておらずボタンタップ時などのイベント通知や非同期処理のトリガーとして利用できます。

initでAPIを叩く問題があるとすれば、購読する前に値を流してしまわないことに注意する必要があります。ViewController側でViewModelインスタンスを生成する場合は購読前に非同期処理が実行されてしまうため利用することができません。

初回サブスクライブ時に非同期処理を実行して値を流す

Deferredを利用するとサブスクライブされるまで処理を遅延することができます。

class ViewModel {

    var fetchStringFuture: AnyPublisher<String, Never> {
        Deferred {
            Future<String, Never> { promise in
            // 非同期処理
                promise(.success("非同期処理が完了しました"))
            }
        }
        .eraseToAnyPublisher()
    } 
    
    func binding(reloadButtonTrigger: AnyPublisher<Void, Never>) -> AnyPublisher<String, Never> {
        reloadButtonTrigger
            .flatMap {
                fetchStringFuture
            }
    }
}

class ViewController: UIViewController {

    private var cancellables: Set<AnyCancellable> = []
    
    private let reloadButtonTrigger = PassthroughSubject<Void, Never>()
    
    private let viewModel = ViewModel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        viewModel.binding(reloadButtonTrigger: reloadButtonTrigger.eraseToAnyPublisher())
            .sink {
                print($0)
            }
            .store(in: &cancellables)
        
        reloadButtonTrigger.send()
    }
} 

Futureは値を1度だけ流して完了するPublisherです。生成された瞬間にクロージャ内の処理が走るのですがDeferredを利用することで遅延させています。

ここで一番重要なのがflatMapです。flatMapは外側のイベントが来たら内側のPublisherを作ってそれを購読することができるオペレーターです。そのためsend()されるたびにDeferredが評価されてFutureの処理が走ります。

ライフサイクルのタイミングで非同期処理を実行して値を流す

ViewModelのInputとしてライフサイクルメソッドを通知するという方法で非同期処理を実行することが可能となります。

ViewModel

class ViewModel {
        
    // 値を保持するSubject
    let stringSubject = CurrentValueSubject<String, Never>("")
    
    // 値を保持しないSubjcet
    let intSubject = PassthroughSubject<Int, Never>()
    
    init() {}
    
    func binding(_ input: Input, cancellables: inout Set<AnyCancellable>) -> ViewModelOutput {
        let stringValuePublisher =  input.viewDidLoadTrigger
            .map { _ in self.fetchStringValue() }
            .eraseToAnyPublisher()
        
        let intValuePublisher =  input.viewWillAppearTrigger
            .map { _ in self.fetchIntValue() }
            .eraseToAnyPublisher()
        
        return ViewModelOutput(
            showString: stringValuePublisher,
            showInt:intValuePublisher
        )
    }
    
    private func fetchStringValue() -> String {
        "読み込み完了"
    }
    
    private func fetchIntValue() -> Int {
        1234
    }
}

extension ViewModel {
    struct Input {
        var viewDidLoadTrigger: AnyPublisher<Void, Never>
        var viewWillAppearTrigger: AnyPublisher<Void, Never>
    }
}

struct ViewModelOutput {
    var showString: AnyPublisher<String, Never>
    var showInt: AnyPublisher<Int, Never>
}

VC

class ViewController: UIViewController {
    
    private var cancellables: Set<AnyCancellable> = []
    
    private let viewModel = ViewModel()
    
    private let viewDidLoadTrigger = PassthroughSubject<Void, Never>()
    private let viewWillAppearTrigger = PassthroughSubject<Void, Never>()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let output = viewModel.binding(.init(viewDidLoadTrigger: viewDidLoadTrigger.eraseToAnyPublisher(),
                                             viewWillAppearTrigger: viewWillAppearTrigger.eraseToAnyPublisher()),
                          cancellables: &cancellables)
        
        output.showString
            .sink {
                print($0)
            }
            .store(in: &cancellables)
        
        output.showInt
            .sink {
                print($0)
            }
            .store(in: &cancellables)
        
        viewDidLoadTrigger.send()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        viewWillAppearTrigger.send()
    }
}

// 読み込み完了
// 1234

特定のライフサイクルのタイミングで実行したい場合はライフサイクル名のPassthroughSubjectをsendしてあげることで明確になります。

※バインディングの仕方はプロトコルを利用したり好きなやり方で大丈夫です。

値をまとめるオペレータの使い方

Merge

複数のPublisherを同じストリームにまとめるPublisherです。どちらかのPublisherが新しい値を流すとMergeも値を流します。Mergeする条件としてOutputとFailureの型が同じである必要があります。flatMapなどを使っているとFailureの型が違うのでeraseToAnyPublihserを使ってもまとめることができません。

    func binding(_ input: Input, cancellables: inout Set<AnyCancellable>) -> ViewModelOutput {
        let stringValuePublisher = Publishers.Merge(
            input.viewDidLoadTrigger,
            input.reloadButtonTrigger
        )
            .map { _ in self.fetchStringValue() }
            .eraseToAnyPublisher()
        
        return ViewModelOutput(
            showString: stringValuePublisher,
        )
    }

Zip

各Publisherが1つずつ値を発行するまで待ってから値を流します。一度値を流すと再度値が両方揃うまでは値が流れないです。

    func binding(_ input: Input, cancellables: inout Set<AnyCancellable>) -> ViewModelOutput {        
        let intValuePublisher = Publishers.Zip(
            input.viewWillAppearTrigger,
            input.reloadButtonTrigger
        )
        .map { _ in self.fetchIntValue() }
        .eraseToAnyPublisher()
        
        return ViewModelOutput(
            showInt:intValuePublisher
        )
    }

さいごに

Combineを利用すると非同期処理の結果をいい感じにバインディングすることができます。複雑になってくるとZipやMergeが大きくなり流れが追いにくくなりそうです。

やはりSwift Concurrency × SwiftUI(Publishedによるバインディング)が楽です!