24
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

refreshableから学ぶSwift Concurrency

Last updated at Posted at 2024-03-30

はじめに

refreshable(action:) はSwiftUIのモディファイアであり、 ListScrollView などのビューに付けるだけでPull-to-Refreshを実現できます。

ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        ScrollView {
            Text("Hello, world!")
        }
        .refreshable {
            try? await Task.sleep(for: .seconds(2))
        }
    }
}

Simulator Screen Recording - iPhone 15 Pro Max - 2024-03-30 at 12.09.08.gif

上記のコードでは2秒スリープしているため、約2秒間インジケータ(くるくる)が表示されています。

このように簡単なコードではうまく動作します。
しかし refreshable() は、SwiftUIにおけるビューの再描画やSwift Concurrencyについて理解していないと正しく動作しないことがあります。

本記事では refreshable() が正しく動作しないコードを修正していき、それを通してビューの再描画やSwift Concurrencyについての理解を深めます。

環境

  • OS: macOS Sonoma 14.2.1
  • Xcode: 15.3
  • Swift: 5.10

悪い例を修正する

悪い例を紹介し、それを修正していきます。

コードを試しやすいような記事の構成にしているので、ぜひみなさんも実際に実行しながら読み進めてみてください。

サンプルコード

製品コードに近いアーキテクチャで refreshable() が正しく動かないコードの例を紹介します。

全体的なアーキテクチャはAndroidの Guide to app architecture を参考にし、 send(_:) とActionだけ TCA を参考にしています。

Pull-to-Refreshすると画面中央に「ローディング中です...」のテキストが表示され、更新が完了するとランダムな数字が10つ表示されるという、シンプルなアプリです。

FooScreen.swift
import SwiftUI

struct FooScreen: View {
    @StateObject private var viewModel: FooViewModel

    var body: some View {
        FooView(
            randomNumbers: viewModel.uiState.randomNumbers,
            isLoading: viewModel.uiState.isLoading
        )
        .refreshable {
            viewModel.send(.refreshable)
        }
    }

    @MainActor
    init() {
        self._viewModel = StateObject(wrappedValue: FooViewModel())
    }
}
FooView.swift
import SwiftUI

struct FooView: View {
    let randomNumbers: [Int]
    let isLoading: Bool

    var body: some View {
        List(randomNumbers, id: \.self) { randomNumber in
            Text("\(randomNumber)")
        }
        .overlay {
            if isLoading {
                Text("ローディング中です...")
            }
        }
    }
}
FooViewModel.swift
import Combine

// MARK: UI state

struct FooUiState {
    var randomNumbers: [Int] = []
    var isLoading = false
}

// MARK: - Action

enum FooAction {
    case refreshable
}

// MARK: - View model

@MainActor
final class FooViewModel: ObservableObject {
    @Published private(set) var uiState: FooUiState

    init() {
        self.uiState = FooUiState()

        Task {
            await refreshRandomNumbers()
        }
    }

    func send(_ action: FooAction) {
        switch action {
        case .refreshable:
            Task {
                await refreshRandomNumbers()
            }
        }
    }
}

// MARK: - Privates

private extension FooViewModel {
    func refreshRandomNumbers() async {
        uiState.isLoading = true
        #if DEBUG
        try? await Task.sleep(for: .seconds(2))
        #endif
        var randomNumbers: [Int] = []
        for _ in 0..<10 {
            randomNumbers.append(.random(in: 0..<10))
        }
        uiState.randomNumbers = randomNumbers
        uiState.isLoading = false
    }
}

ViewModelのクラス全体に @MainActor を付けているのが特徴です。
ビューモデルはプレゼンテーションロジックを担当するので、基本的にはメインアクターで実行されるべき、という考えです。

重い処理として、デバッグ時のみ2秒のスリープを refreshRandomNumbers() に仕込んでいます。

これらのコードをコピペして動かすと、期待通りの動作にならないことがわかります。

Simulator Screen Recording - iPhone 15 Pro Max - 2024-03-30 at 21.17.03.gif

refreshRandomNumbers() の処理を待たずにインジケータが消えてしまいます。

SwiftUIやSwift Concurrencyに詳しい方なら、どこが悪いかすぐにわかるかもしれません。
読み進める前に考えてくださると嬉しいです。

非同期処理が投げっぱなしになっている

処理の実行を待たない一番の理由は、非同期処理が投げっぱなしになっていることです。
refreshable() へ渡すアクションは非同期のクロージャなので、その中で直接非同期メソッドを呼ばないと待ってくれません。

send(_:) は同期メソッドなので、新しく非同期メソッドを用意して対応します。

FooScreen.swift
import SwiftUI

struct FooScreen: View {
    @StateObject private var viewModel: FooViewModel

    var body: some View {
        FooView(
            randomNumbers: viewModel.uiState.randomNumbers,
            isLoading: viewModel.uiState.isLoading
        )
        .refreshable {
-             viewModel.send(.refreshable)
+             await viewModel.sendAsync(.refreshable)
        }
    }

    @MainActor
    init() {
        self._viewModel = StateObject(wrappedValue: FooViewModel())
    }
}
FooView.swift
// 変更なし
FooViewModel.swift
import Combine

// MARK: UI state

struct FooUiState {
    var randomNumbers: [Int] = []
    var isLoading = false
}

- // MARK: - Action
+ // MARK: - Actions

enum FooAction {
-     case refreshable
}

+ enum FooAsyncAction {
+     case refreshable
+ }

// MARK: - View model

@MainActor
final class FooViewModel: ObservableObject {
    @Published private(set) var uiState: FooUiState

    init() {
        self.uiState = FooUiState()

        Task {
            await refreshRandomNumbers()
        }
    }

    func send(_ action: FooAction) {
-         switch action {
-         case .refreshable:
-             Task {
-                 await refreshRandomNumbers()
-             }
-         }
    }

+     func sendAsync(_ asyncAction: FooAsyncAction) async {
+         switch asyncAction {
+         case .refreshable:
+             await refreshRandomNumbers()
+         }
+     }
+ }

// MARK: - Privates

private extension FooViewModel {
    func refreshRandomNumbers() async {
        uiState.isLoading = true
        #if DEBUG
        try? await Task.sleep(for: .seconds(2))
        #endif
        var randomNumbers: [Int] = []
        for _ in 0..<10 {
            randomNumbers.append(.random(in: 0..<10))
        }
        uiState.randomNumbers = randomNumbers
        uiState.isLoading = false
    }
}

非同期処理を待つことで、処理が完了するまでインジケータが表示されるようになりました。

Simulator Screen Recording - iPhone 15 Pro Max - 2024-03-30 at 23.20.07.gif

同期処理に対応できていない

今回のケースでは投げっぱなしを修正するのみで問題なく動作しました。
しかしスリープのような非同期処理でなく、同期処理の場合はどうでしょうか。

FooScreen.swift
// 変更なし
FooView.swift
// 変更なし
FooViewModel.swift
// ...

// MARK: - Privates

private extension FooViewModel {
    func refreshRandomNumbers() async {
        uiState.isLoading = true
        #if DEBUG
-         try? await Task.sleep(for: .seconds(2))
+         for i in 0..<100_000 {
+             print(i)
+         }
        #endif
        var randomNumbers: [Int] = []
        for _ in 0..<10 {
            randomNumbers.append(.random(in: 0..<10))
        }
        uiState.randomNumbers = randomNumbers
        uiState.isLoading = false
    }
}

Simulator Screen Recording - iPhone 15 Pro Max - 2024-03-30 at 23.59.42.gif

メインスレッドで実行され、その間は画面が固まってしまいます。

:o: 同期処理を別アクターに逃がす

同期処理を nonisolated を付けた非同期メソッドに切り出すことで別アクターにて実行され、画面が固まらなくなります。

FooScreen.swift
// 変更なし
FooView.swift
// 変更なし
FooViewModel.swift
// ...

// MARK: - Privates

private extension FooViewModel {
    func refreshRandomNumbers() async {
        uiState.isLoading = true
-         #if DEBUG
-         for i in 0..<100_000 {
-             print(i)
-         }
-         #endif
-         var randomNumbers: [Int] = []
-         for _ in 0..<10 {
-             randomNumbers.append(.random(in: 0..<10))
-         }
-         uiState.randomNumbers = randomNumbers
+         uiState.randomNumbers = await randomNumbers()
        uiState.isLoading = false
    }
+ 
+     nonisolated
+     func randomNumbers() async -> [Int] {
+         #if DEBUG
+         for i in 0..<100_000 {
+             print(i)
+         }
+         #endif
+         var randomNumbers: [Int] = []
+         for _ in 0..<10 {
+             randomNumbers.append(.random(in: 0..<10))
+         }
+         return randomNumbers
+     }
}

randomNumbers() には nonisolatedasync の両方が必要です。
どちらか片方でも欠けるとメインスレッドで実行されます。

コメント で頂いた内容は、その通りだと思います。
プレゼンテーションロジックの範疇を越えていることもあり、ViewModelから処理を切り出したほうがメインアクターの外で実行されていることがわかりやすくなります。
ViewModelの処理はすべてメインアクターで実行される、という前提があるほうが読みやすいと思います。

ViewModelのクラス全体に @MainActor を付けるのでなく、必要なプロパティやメソッドのみに付ける解決策もありますが、上記の理由に加え、手間が掛かるため私は採用していません。

:small_red_triangle: 不必要にnonisolatedを付ける

こちらのコードは悪い例なので、試したら元に戻してください。

不必要に nonisolated を付けると、コードが冗長になり、さらに意図しない動作になることがあります。

FooScreen.swift
// 変更なし
FooView.swift
// 変更なし
FooViewModel.swift
import Combine

// MARK: UI state

struct FooUiState {
    var randomNumbers: [Int] = []
    var isLoading = false
}

// MARK: - Actions

enum FooAction {
}

enum FooAsyncAction {
    case refreshable
}

// MARK: - View model

@MainActor
final class FooViewModel: ObservableObject {
    @Published private(set) var uiState: FooUiState

    init() {
        self.uiState = FooUiState()

        Task {
            await refreshRandomNumbers()
        }
    }

    func send(_ action: FooAction) {
    }

+     nonisolated
    func sendAsync(_ asyncAction: FooAsyncAction) async {
        switch asyncAction {
        case .refreshable:
            await refreshRandomNumbers()
        }
    }
}

// MARK: - Privates

private extension FooViewModel {
+     nonisolated
    func refreshRandomNumbers() async {
-         uiState.isLoading = true
-         uiState.randomNumbers = await randomNumbers()
-         uiState.isLoading = false
+         Task { @MainActor in
+             uiState.isLoading = true
+         }
+         let randomNumbers = await randomNumbers()
+         Task { @MainActor in
+             uiState.randomNumbers = randomNumbers
+             uiState.isLoading = false
+         }
    }

    nonisolated
    func randomNumbers() async -> [Int] {
        #if DEBUG
        for i in 0..<100_000 {
            print(i)
        }
        #endif
        var randomNumbers: [Int] = []
        for _ in 0..<10 {
            randomNumbers.append(.random(in: 0..<10))
        }
        return randomNumbers
    }
}

uiState をメインアクターで更新するために、冗長なコードになっています。
さらにタスクの実行順序が保証されないため、 Task.value を付けて実行を待つ必要があります。

FooScreen.swift
// 変更なし
FooView.swift
// 変更なし
FooViewModel.swift
import Combine

// MARK: UI state

struct FooUiState {
    var randomNumbers: [Int] = []
    var isLoading = false
}

// MARK: - Actions

enum FooAction {
}

enum FooAsyncAction {
    case refreshable
}

// MARK: - View model

@MainActor
final class FooViewModel: ObservableObject {
    @Published private(set) var uiState: FooUiState

    init() {
        self.uiState = FooUiState()

        Task {
            await refreshRandomNumbers()
        }
    }

    func send(_ action: FooAction) {
    }

    nonisolated
    func sendAsync(_ asyncAction: FooAsyncAction) async {
        switch asyncAction {
        case .refreshable:
            await refreshRandomNumbers()
        }
    }
}

// MARK: - Privates

private extension FooViewModel {
    nonisolated
    func refreshRandomNumbers() async {
        uiState.isLoading = true
        uiState.randomNumbers = await randomNumbers()
        uiState.isLoading = false
-         Task { @MainActor in
+         await Task { @MainActor in
            uiState.isLoading = true
        }
+         .value
        let randomNumbers = await randomNumbers()
-         Task { @MainActor in
+         await Task { @MainActor in
            uiState.randomNumbers = randomNumbers
            uiState.isLoading = false
        }
+         .value
    }

    nonisolated
    func randomNumbers() async -> [Int] {
        #if DEBUG
        for i in 0..<100_000 {
            print(i)
        }
        #endif
        var randomNumbers: [Int] = []
        for _ in 0..<10 {
            randomNumbers.append(.random(in: 0..<10))
        }
        return randomNumbers
    }
}

これで逐次的に実行され、先ほどと同様の処理になったはずです。
冗長なので不必要に nonisolated を付けるのは避けるべきです。

データの初期化はViewModelのinitでなくtaskで行う

refreshable() と直接は関係ありません。

まず、わかりやすいように重い処理を同期から非同期へ戻します。

FooViewModel.swift
// ...

// MARK: - Privates

private extension FooViewModel {
    func refreshRandomNumbers() async {
        uiState.isLoading = true
        #if DEBUG
-         for i in 0..<100_000 {
-             print(i)
-         }
+         try? await Task.sleep(for: .seconds(2))
        #endif
        var randomNumbers: [Int] = []
        for _ in 0..<10 {
            randomNumbers.append(.random(in: 0..<10))
        }
        uiState.randomNumbers = randomNumbers
        uiState.isLoading = false
    }
}

データの初期化をViewModelの init() で行っていますが、投げっぱなしになっているのでタスクをキャンセルできません。
task で実行すべきです。

FooScreen.swift
import SwiftUI

struct FooScreen: View {
    @StateObject private var viewModel: FooViewModel

    var body: some View {
        FooView(
            randomNumbers: viewModel.uiState.randomNumbers,
            isLoading: viewModel.uiState.isLoading
        )
+         .task {
+             await viewModel.sendAsync(.task)
+         }
        .refreshable {
            await viewModel.sendAsync(.refreshable)
        }
    }

    @MainActor
    init() {
        self._viewModel = StateObject(wrappedValue: FooViewModel())
    }
}
FooView.swift
// 変更なし
FooViewModel.swift
import Combine

// MARK: UI state

struct FooUiState {
    var randomNumbers: [Int] = []
    var isLoading = false
}

// MARK: - Actions

enum FooAction {
}

enum FooAsyncAction {
+     case task
    case refreshable
}

// MARK: - View model

@MainActor
final class FooViewModel: ObservableObject {
    @Published private(set) var uiState: FooUiState

    init() {
        self.uiState = FooUiState()
- 
-         Task {
-             await refreshRandomNumbers()
-         }
    }

    func send(_ action: FooAction) {
    }

    func sendAsync(_ asyncAction: FooAsyncAction) async {
        switch asyncAction {
+         case .task:
+             await refreshRandomNumbers()
+ 
        case .refreshable:
            await refreshRandomNumbers()
        }
    }
}

// MARK: - Privates

private extension FooViewModel {
    func refreshRandomNumbers() async {
        uiState.isLoading = true
-         uiState.randomNumbers = await randomNumbers()
+         do {
+             uiState.randomNumbers = try await randomNumbers()
+         } catch is CancellationError {
+             print("task cancel")
+         } catch {
+             print("error")
+         }
        uiState.isLoading = false
    }

    nonisolated
-     func randomNumbers() async -> [Int] {
+     func randomNumbers() async throws -> [Int] {
        #if DEBUG
-         try? await Task.sleep(for: .seconds(2))
+         try await Task.sleep(for: .seconds(2))
        #endif
        var randomNumbers: [Int] = []
        for _ in 0..<10 {
            randomNumbers.append(.random(in: 0..<10))
        }
        return randomNumbers
    }
}

タスクがキャンセルされたら後続の処理をスキップしたいため、 try? await Task.sleep()try?try にし、スロー関数としています。
ケースバイケースですが、今回はエラーハンドリングを呼び出し元で行っています。

タスクのキャンセルについての詳細は、以下の記事をご参照ください。

refreshable時にビューの構造を変えない

こちらのコードは悪い例なので、試したら元に戻してください。

今回の例では発生しませんが、refreshable時にビューの構造を変えると、 refreshable() のモディファイアごと再描画されてタスクがキャンセルされます。

例えば FooView を以下のように変更すると発生します。

FooScreen.swift
// 変更なし
FooView.swift
import SwiftUI

struct FooView: View {
    let randomNumbers: [Int]
    let isLoading: Bool
    
    var body: some View {
-         List(randomNumbers, id: \.self) { randomNumber in
-             Text("\(randomNumber)")
-         }
-         .overlay {
-             if isLoading {
-                 Text("ローディング中です...")
-             }
-         }
+         if isLoading {
+             List(randomNumbers, id: \.self) { randomNumber in
+                 Text("\(randomNumber)")
+             }
+             .overlay {
+                 Text("ローディング中です...")
+             }
+         } else {
+             List(randomNumbers, id: \.self) { randomNumber in
+                 Text("\(randomNumber)")
+             }
+         }
    }
}
FooViewModel.swift
// 変更なし

refreshable() 内で isLoading を更新しているため、ビューの構造が変わっています。
それによりタスクがキャンセルされ、データも更新されません。

Simulator Screen Recording - iPhone 15 Pro Max - 2024-03-31 at 03.13.28.gif

ブレークポイントを貼ると、タスクがキャンセルされていることがわかりやすいです。

image.png

そのためビューの構造が変わりやすい設計を避けるのが望ましいです。
ビューの構造を変えたい場合、データの更新後に行う必要があります。

非同期処理を投げっぱなしにしているとタスクがキャンセルされないため、あとからこの問題に気づく場合もあります。

ビューを Equatable に準拠させることで、ビューの構造を同一とみなす手法もあるようですが、私は試したことがありません。

まとめ

refreshable() の使用時は以下の観点で動作確認しましょう。

  • 処理の完了までインジケータが表示され続けているか
    • 非同期処理が投げっぱなしになっていないか
  • 処理中に画面が固まらないか
    • 処理がメインアクターで実行されていないか
  • 処理がキャンセルされてデータが更新されないことがないか
    • 処理中にビューの構造が変わっていないか

おわりに

refreshable() の動作を通して、SwiftUIにおけるビューの再描画やSwift Concurrencyについて学ぶことができました。
このあたりの理解を深めることで、非同期周りで問題が発生したときに追求しやすくなります :relaxed:

参考リンク

24
17
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?