🍁
Swift: Combineで書いていた値変更の購読をConcurrencyで書いてみた。
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
treastrainさんの指摘を反映
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)
}
continuation.onTermination = { _ in
timer?.invalidate()
}
}
}
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
モデルは一般的にビュー/ビューモデルより生存期間が長い場合が多いので、同じモデルに対して新たに
SampleModel.randomNumber
を参照するとrandomNumberHandler
が上書かれて最初の AsyncStream が何も起こらないfor await
ループになってしまいそうです。randomNumber
を初期化時に生成できるようにするか、 swift-async-algorithms の AsyncChannel 等を利用すると良さそうに思いました。Combine に頼らず、Swift 側だけで完結できるのはとても魅力的に感じられる記事でした!
一点、
SampleModel.randomNumber
をキャンセルしたときにSampleModel.timer
を invalidate できるよう、AsyncStream.Continuation.onTermination
を実装するか、AsyncStream
のinit(unfolding:onCancel:)
のonCancel
の実装を行うとよりよいサンプルになるかと感じました。