🍁

Swift: Combineで書いていた値変更の購読をConcurrencyで書いてみた。

2022/12/02に公開2

1〜9のランダムな整数を1秒ごとに更新するようなサンプルを作りました。


こんな感じ

ViewはCombineで書いてもConcurrencyで書いても変わりませんでした。

View
import SwiftUI

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

    var body: some View {
        VStack {
            Text(viewModel.numberText)
                .font(.largeTitle)
                .padding(20)
            HStack {
                Button {
                    viewModel.startTimer()
                } label: {
                    Image(systemName: "play.circle.fill")
                        .imageScale(.medium)
                }
                Button {
                    viewModel.stopTimer()
                } label: {
                    Image(systemName: "stop.circle.fill")
                        .imageScale(.medium)
                }
            }
        }
        .padding()
    }
}

Combineで書いたModelとViewModel

Model
import Foundation
import Combine

class SampleModel {
    private var timerCancellable: AnyCancellable?
    private let randomNumberSubject = PassthroughSubject<Int, Never>()
    var randomNumberPublisher: AnyPublisher<Int, Never> {
        return randomNumberSubject.eraseToAnyPublisher()
    }

    init() {}

    deinit {
        timerCancellable?.cancel()
    }

    func startTimer() {
        timerCancellable = Timer.publish(every: 1.0, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                self?.randomNumberSubject.send(Int.random(in: 1 ..< 10))
            }
    }

    func stopTimer() {
        timerCancellable?.cancel()
    }
}
ViewModel
import SwiftUI
import Combine

class SampleViewModel: ObservableObject {
    @Published var numberText: String

    private let model = SampleModel()
    private var cancellable: AnyCancellable?

    init() {
        numberText = String(Int.random(in: 1 ..< 10))
        cancellable = model.randomNumberPublisher
            .sink { [weak self] value in
                self?.numberText = String(value)
            }
    }

    deinit {
        cancellable?.cancel()
    }

    func startTimer() {
        model.startTimer()
    }

    func stopTimer() {
        model.stopTimer()
    }
}

ModelのPublisherをViewModelで購読して値を更新しています。
AnyCancellableを利用するためにimport Combineが必要になっています。

特に難しくはないですね。関係性は以下の図のような感じ。

Concurrencyで書いたModelとViewModel

Model
import Foundation

class SampleModel {
    private var timer: Timer?
    private var randomNumberHandler: ((Int) -> Void)?
    var randomNumber: AsyncStream<Int> {
        AsyncStream { continuation in
            self.randomNumberHandler = { value in
                continuation.yield(value)
            }
        }
    }

    init() {}

    deinit {
        timer?.invalidate()
    }

    func startTimer() {
        timer?.invalidate()
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
            self?.randomNumberHandler?(Int.random(in: 0 ..< 10))
        }
    }

    func stopTimer() {
        timer?.invalidate()
        timer = nil
    }
}
ViewModel
import SwiftUI

class SampleViewModel: ObservableObject {
    @Published var numberText: String

    private let model = SampleModel()
    private var task: Task<Void, Never>?

    init() {
        numberText = String(Int.random(in: 1 ..< 10))
        // 無限ループでずっと値の更新を待つやつ
        task = Task {
            for await value in model.randomNumber {
                Task.detached { @MainActor [weak self] in
                    self?.numberText = String(value)
                }
            }
        }
    }

    deinit {
        task?.cancel()
    }

    func startTimer() {
        model.startTimer()
    }

    func stopTimer() {
        model.stopTimer()
    }
}

ModelのPublisherだったものがAsyncStreamになっていて、それをViewModelではTaskでスレッドを切り離しつつfor await in {}で受け取っています。なお、値の更新を待機するためにここは無限ループになっています。また、UIの更新にまつわる値の更新はメインスレッドで行う必要があるため、Task.detached { @MainActor in }でメインスレッドに戻しています。
Combineが不要なためimport Combineも不要になっています。(つまりSwift言語機能で非同期処理を実現できている。)

関係性は以下の図のような感じ。

Discussion

swifttyswiftty

モデルは一般的にビュー/ビューモデルより生存期間が長い場合が多いので、同じモデルに対して新たに SampleModel.randomNumber を参照すると randomNumberHandler が上書かれて最初の AsyncStream が何も起こらない for await ループになってしまいそうです。

randomNumber を初期化時に生成できるようにするか、 swift-async-algorithms の AsyncChannel 等を利用すると良さそうに思いました。