Combine + UIKit でタイマーを作ってみた
概要
久しぶりに iOS エンジニアらしい記事を書く気がします😎
最近色んな勉強会がオンラインで開催されていて、
地方に住む自分としてはすごくありがたく、Zoom 等を利用して参加させていただいています🙇♂️
そんな中 iOS13 から追加された Combine フレームワークについて、
そろそろ使ってみるのもいいんじゃない?と言った内容の発表が多くなってきた気がしたので、UIKit + Combine でタイマーを作ってみました。
SwiftUI との組み合わせも気になりましたが、 UIKit との相性が気になったので画面は UIKit で作成しています。
また、以前似たようなものを RxSwift で作ったことがあるので、タイマーの処理等はそちらをベースにしています。
リポジトリ
コードはこちらに上がっています。よければ確認してみてください🙇♂️
構成
アーキテクチャは MVVM です。
それぞれこんな感じの役割になっています。
■ Model
- タイマー本体のロジックを管理
- ViewModel から View のアクションを受け取り、タイマーの操作を行う
■ ViewModel
- Model から秒数を受け取り、View へフォーマットして流す
- View からユーザーのアクションを受け取り、Model へ通知する
■ View
- ViewModel から秒数を受け取り、画面に表示
- ViewModel にユーザーのアクションを通知
ViewModel の作りは Kickstarter の ViewModel の作り方を参考にしています。
また、Model にロジックを集約させて、ViewModel には View と Model の橋渡しをメインに行ってもらうようにしています。これにはこちらを参考にさせていただきました。
Model の処理
メインとなるタイマーの処理を細かくみてみます。
■ Subject
経過秒数を流す countSubject と、タイマーが開始されているか、停止されているかのフラグを流す isValidSubject の二つの Subject を用意しています。
let countSubject: CurrentValueSubject<Int, Never>
let isValidSubject: PassthroughSubject<Bool, Never>
■ タイマーの処理
基本的には RxSwift で作ったものと同じで Timer.publish() を利用して、1秒おきにcountSubject に 現在の値 + 1 の値を流すようにしていて、タイマーが開始されたら新しく Publisher を生成し、停止されたら Publisher を破棄するような動作になります。
isValidSubject
.flatMapLatest { isValid -> AnyPublisher<Date, Never> in
isValid
? Timer.publish(every: 1.0, on: .main, in: .default).autoconnect().eraseToAnyPublisher()
: Empty<Date, Never>(completeImmediately: true).eraseToAnyPublisher()
}.sink { _ in
countSubject.send(countSubject.value + 1)
}.store(in: &cancelables)
ただ Empty の使用方法がこれでいいのか、若干の不安があります😅
あと型を合わせるために使用した eraseToAnyPublisher() もこんなに多用すべきなのか微妙です...
■ Model の Protocol
ViewModel には Model の Protocol を公開するようにしていて、 countSubject を AnyPublisher に変換して公開しています。
他にもタイマー関連の処理を関数経由で公開しています。
protocol TimerModelProtocol {
var countPublisher: AnyPublisher<Int, Never> { get }
func pauseTimer()
func resetTimer()
func startTimer()
}
ViewModel の処理
■ Input、Output、ViewModelType の定義
Kickstarter の ViewModel の作り方を参考に Input と Output 、ViewModelType を定義して ViewModel に準拠させるようにしています。
protocol TimerViewModelInput {
/// 一時停止のボタンが押された時に呼ばれる.
func tappedPauseButton()
/// リセットのボタンが押された時に呼ばれる.
func tappedResetButton()
/// タイマー開始のボタンが押された時に呼ばれる.
func tappedStartButton()
}
protocol TimerViewModelOutput {
var currentSecondsPublisher: AnyPublisher<String?, Never> { get }
}
protocol TimerViewModelType {
var input: TimerViewModelInput { get }
var output: TimerViewModelOutput { get }
}
final class TimerViewModel: TimerViewModelType, TimerViewModelInput, TimerViewModelOutput {
var input: TimerViewModelInput { self }
var output: TimerViewModelOutput { self }
// ...
}
これで Input と Output が整理されて View から利用しやすくなりました。
■ Model の Publisher を map で変換
経過秒数のフォーマットのために Model から公開されている Publisher を map で変換しています。
self.currentSecondsPublisher = model.countPublisher
.map { String(format: "%02i:%02i", $0 / 60 % 60, $0 % 60) }
.eraseToAnyPublisher()
map させると型が Publisher.Map<...> となってネストされてしまったので、eraseToAnyPublisher() で型消去しています。
View の処理
■ バインディング
ViewModel を生成して Output として公開されている currentSecondsPublisher をラベルにバインディングさせています。
AnyPublisher<String?, Never> とエラーが流れないようになっていたので、assign を使うことでスマートに書くことができました。
let viewModel = TimerViewModel()
viewModel.currentSecondsPublisher
.assign(to: \.text, on: secondsLabel)
.store(in: &cancelables)
単体テスト
せっかくなので、Model の単体テストも作成してみました。
Combine の単体テストを作成するのは初めてだったので以下の記事を参考にさせていただきました🙇♂️
少し省略していますが、2秒後までに Subject へきちんと値が流れているかどうかを ViewModel へ公開している Publisher 経由で確認するテストは以下のようになりました。
let model = TimerModel()
let expectValues = [0, 1, 2]
let result = expectValue(of: model.countPublisher, equals: expectValues)
model.startTimer()
wait(for: [result.expectation], timeout: 2)
感想
UIKit + Combine の構成でタイマーアプリを作ってみましたが、
思った以上にストレスなく書くことができました。
何よりタイマーなら Timer.publish から Publisher が生成できたり、
通信系なら URLSession.shared.dataTaskPublisher から生成できたりするのが便利だなと感じました。
ただ、UIKit で使用する場合は UI とのバインディングが厳しかったり、そもそも欲しいオペレーターがなかったりするので、不便に感じるタイミングはあるかもしれません。
( 細かいですが @Published が Protocol で使えないのも若干不便でした。 )
それでも RxSwift に依存することなく同じような実装にできるのはすごいことですよね✨
その他参考にさせていただいた記事
- Updating UI with assign(to:on:) in Combine
- Combine Framework in Swift