非同期処理の隠蔽と状態管理
アプリの設計が語られるときなど、非同期処理の状態管理がしっかり考慮されずにサンプルコードが書かれ解説されていることが多い気がするので、こうした方が良いんじゃないかというのを自分なりにまとめておきます。
一言で書くと、「非同期処理は隠蔽して、その状態を値で表現しよう」です。
なお、この記事での「非同期処理」は「メインスレッドから呼び出されて、別のスレッドで処理が実行されたり待ちが発生して、その完了や結果を遅延してメインスレッドで受け取るまでの一連の流れ」と捉えてもらえると良いと思います。
Viewが非同期処理の状態管理をしている例
例えば、通信処理のような非同期処理を想定して、非同期処理を開始して結果が返ってくるまではインジケータを表示したりUI操作をできなくしたいとします。以下のようなUIの流れです。
1.起動直後。非同期処理を開始するボタンだけがある | 2.ボタンをタップすると非同期処理を行う。UIはインジケータのみ表示し操作できない | 3.処理が終わると結果を表示し、再度ボタンをタップできるようになる |
---|---|---|
こういった場合に、UIから非同期関数を直接呼ぶようなコードをよく見かける気がします。できるだけ単純に書くと以下のような感じです。
import SwiftUI
// 非同期処理の結果
enum Result {
case success
case failure
}
// ダミーの非同期処理。1秒後にランダムに結果を返す
func processAsync() async -> Result {
do {
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
return Bool.random() ? .success : .failure
} catch {
return .failure
}
}
class ViewModel: ObservableObject {
// 非同期処理を実行する関数。UIから呼ばれる
func process() async -> Bool {
let result = await processAsync()
// UIではBoolで結果を扱いたいので変換して返す
switch result {
case .success:
return true
case .failure:
return false
}
}
}
struct ContentView: View {
@StateObject var viewModel: ViewModel = .init()
@State var isProcessing = false
@State var isSuccess: Bool?
var body: some View {
if isProcessing {
// 非同期処理実行中ならインジケータを表示
ProgressView()
.progressViewStyle(.circular)
} else {
// 非同期処理実行中でなければボタンを表示
Button("Process") {
isProcessing = true
Task {
// Viewが非同期関数を呼ぶ
isSuccess = await viewModel.process()
isProcessing = false
}
}
.padding()
// 実行結果があればテキストを表示
if let result = isSuccess {
Text(verbatim: "isSuccess : \(result)")
}
}
}
}
ViewModelで非同期処理をラップして呼び出して、その結果をUIで扱うためにBoolに変換して返しています。ですが、型を変換しているだけでViewが非同期関数を呼び出している状態です。
Viewが非同期関数を直接呼び出すと「非同期処理が実行されているか」「非同期処理の結果は何か」をViewが管理しないといけなくなります。それの何が良くないかというと、例えばViewModelでViewと関係なく非同期処理が呼ばれた場合に、いくらViewがViewModelを見ても処理中なのかどうかはわかりません。
非同期処理がどこから呼ばれたとしても、あるいは、Viewが生成される前に非同期処理が開始されていたとしても、間違いなく非同期処理中の状態をViewに反映できるべきでしょう。他にも、Viewから呼ばれた非同期処理の実行状態をView以外のところから知ることもできません。
非同期処理をViewから分離する
上記の問題を踏まえて、ViewからViewModelに処理を移動して整理してみます。
import SwiftUI
// ResultとprocessAsyncは前のコードと共通
enum Result { ... }
func processAsync() async -> Result { ... }
@MainActor
class ViewModel: ObservableObject {
// UI表示に必要な値をViewModel側で定義
@Published private(set) var isProcessing = false
@Published private(set) var isSuccess: Bool?
func process() {
// 非同期処理が実行中であれば何もせず終了
guard !isProcessing else { return }
// 非同期処理が実行中であることをセット
isProcessing = true
Task {
let result = await processAsync()
switch result {
case .success:
self.isSuccess = true
case .failure:
self.isSuccess = false
}
// 非同期処理が実行中でないことをセット
isProcessing = false
}
}
}
struct ContentView: View {
@StateObject var viewModel: ViewModel = .init()
var body: some View {
if viewModel.isProcessing {
ProgressView()
.progressViewStyle(.circular)
} else {
Button("Process") {
viewModel.process()
}
.padding()
if let result = viewModel.isSuccess {
Text(verbatim: "isSuccess : \(result)")
}
}
}
}
このようにViewModel側にisProcessing
などのプロパティを持たせることで、ViewModelをどのタイミングで見ても非同期処理の状態が取得できます。さらにprocessAsync
をテスト用に差し替えられるようにすれば、ViewModelのコードを確実にテストすることもできるようになりそうです。
あと、この記事の本題とはずれるかもしれませんが、非同期処理中の場合にprocess()
関数の頭でスキップするコードを追加しました。これで、 https://zenn.dev/yasos/articles/61e215c6bc7b08 で説明したような、意図しないタイミングでイベントが呼ばれることがあっても、重複して実行される心配はありません。こういった対処も、View側で表面的に行うのではなく、より実際の処理に近いところで行われていると安全なコードに近づくと思います。
ビジネスロジックを分離する
今回の例のようなシンプルなものであればここまでの整理でも良いかもしれませんが、もっとアプリが複雑になる場合を考えると、ViewModelで非同期処理を呼び出しているのはプレゼンテーションロジックの役割を超えていて良くないのではないかと考えます。
そこでさらに、ViewModelのコードをビジネスロジックとプレゼンテーションロジックに切り分けて整理してみます。
import SwiftUI
import Combine
// ResultとprocessAsyncは前のコードと共通
enum Result { ... }
func processAsync() async -> Result { ... }
@MainActor
class Processor {
// 非同期処理の実行状態
enum State {
case processing
case waiting(Result?)
}
let state = CurrentValueSubject<State, Never>(.waiting(nil))
func process() {
// 非同期処理が実行中であれば何もせず終了
guard !state.value.isProcessing else { return }
// 非同期処理が実行中であることをセット
state.value = .processing
Task {
let result = await processAsync()
// 非同期処理の結果をセット(=実行中でない)
state.value = .waiting(result)
}
}
}
extension Processor.State {
var isProcessing: Bool {
switch self {
case .processing:
return true
case .waiting:
return false
}
}
var isSuccess: Bool? {
switch self {
case .waiting(.success):
return true
case .waiting(.failure):
return false
case .waiting(nil), .processing:
return nil
}
}
}
@MainActor
class ViewModel: ObservableObject {
private let processor: Processor = .init()
@Published private(set) var isProcessing = false
@Published private(set) var isSuccess: Bool?
init() {
self.processor.state
.map(\.isProcessing)
.assign(to: &$isProcessing)
self.processor.state
.map(\.isSuccess)
.assign(to: &$isSuccess)
}
func process() {
self.processor.process()
}
}
// ContentViewは前のコードと共通
struct ContentView: View { ... }
ビジネスロジックにあたる部分を別のクラスProcessor
に分けました。ViewModelは、Processorの状態を単純にUIで必要な値に変換するだけのコードになっています。
ここまで整理が進むと、ProcessorではアプリとしてUIで必要なことは考慮しつつもUIとは関係なく非同期処理の状態を管理する実装ができています。非同期処理が実行中であるかとかその結果は、いつでもstate
プロパティから取得できます。
このように、Viewが非同期関数を直接呼ぶことなく、Model側で非同期処理をその状態を表す値に変換し、単純にメインスレッドでバインドできるようにしておくのが良いのではないでしょうか。
なお、今回の記事は「非同期処理を呼び出すView」と「非同期処理を行うModel」と言う関係で説明しましたが、呼び出す側が別のModelである場合も同じです。非同期関数がインターフェースに現れているだけで、その非同期処理が始まっているのか終わっているのかわからないオブジェクトは、他の非同期関数が呼び出す処理の一部となる部品に過ぎないと考えられます。
例外
ここまで非同期関数はUIに公開せずに直接呼ばない方が良いと言うことを主張してきたのですが、例外もあります。
SwiftUI側でasync関数の呼び出しを期待する場合
例えばSwiftUIのrefreshable
では引数に渡すクロージャはasyncになっていて、クロージャ内の処理が全て終わったらインジケータ表示を隠すと言う挙動になっています。こういった場合はしょうがないので、今回の例だとprocess
関数をasyncにしてrefreshable
に対応しつつ、非同期処理の実行状態は別のプロパティで取得できるようになっていると良さそうです。
Discussion