はじめに
refreshable(action:)
はSwiftUIのモディファイアであり、 List
や ScrollView
などのビューに付けるだけでPull-to-Refreshを実現できます。
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello, world!")
}
.refreshable {
try? await Task.sleep(for: .seconds(2))
}
}
}
上記のコードでは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つ表示されるという、シンプルなアプリです。
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())
}
}
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("ローディング中です...")
}
}
}
}
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()
に仕込んでいます。
これらのコードをコピペして動かすと、期待通りの動作にならないことがわかります。
refreshRandomNumbers()
の処理を待たずにインジケータが消えてしまいます。
SwiftUIやSwift Concurrencyに詳しい方なら、どこが悪いかすぐにわかるかもしれません。
読み進める前に考えてくださると嬉しいです。
非同期処理が投げっぱなしになっている
処理の実行を待たない一番の理由は、非同期処理が投げっぱなしになっていることです。
refreshable()
へ渡すアクションは非同期のクロージャなので、その中で直接非同期メソッドを呼ばないと待ってくれません。
send(_:)
は同期メソッドなので、新しく非同期メソッドを用意して対応します。
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())
}
}
// 変更なし
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
}
}
非同期処理を待つことで、処理が完了するまでインジケータが表示されるようになりました。
同期処理に対応できていない
今回のケースでは投げっぱなしを修正するのみで問題なく動作しました。
しかしスリープのような非同期処理でなく、同期処理の場合はどうでしょうか。
// 変更なし
// 変更なし
// ...
// 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
}
}
メインスレッドで実行され、その間は画面が固まってしまいます。
同期処理を別アクターに逃がす
同期処理を nonisolated
を付けた非同期メソッドに切り出すことで別アクターにて実行され、画面が固まらなくなります。
// 変更なし
// 変更なし
// ...
// 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()
には nonisolated
と async
の両方が必要です。
どちらか片方でも欠けるとメインスレッドで実行されます。
コメント で頂いた内容は、その通りだと思います。
プレゼンテーションロジックの範疇を越えていることもあり、ViewModelから処理を切り出したほうがメインアクターの外で実行されていることがわかりやすくなります。
ViewModelの処理はすべてメインアクターで実行される、という前提があるほうが読みやすいと思います。
ViewModelのクラス全体に @MainActor
を付けるのでなく、必要なプロパティやメソッドのみに付ける解決策もありますが、上記の理由に加え、手間が掛かるため私は採用していません。
不必要にnonisolatedを付ける
こちらのコードは悪い例なので、試したら元に戻してください。
不必要に nonisolated
を付けると、コードが冗長になり、さらに意図しない動作になることがあります。
// 変更なし
// 変更なし
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
を付けて実行を待つ必要があります。
// 変更なし
// 変更なし
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()
と直接は関係ありません。
まず、わかりやすいように重い処理を同期から非同期へ戻します。
// ...
// 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
で実行すべきです。
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())
}
}
// 変更なし
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
を以下のように変更すると発生します。
// 変更なし
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)")
+ }
+ }
}
}
// 変更なし
refreshable()
内で isLoading
を更新しているため、ビューの構造が変わっています。
それによりタスクがキャンセルされ、データも更新されません。
ブレークポイントを貼ると、タスクがキャンセルされていることがわかりやすいです。
そのためビューの構造が変わりやすい設計を避けるのが望ましいです。
ビューの構造を変えたい場合、データの更新後に行う必要があります。
非同期処理を投げっぱなしにしているとタスクがキャンセルされないため、あとからこの問題に気づく場合もあります。
ビューを Equatable
に準拠させることで、ビューの構造を同一とみなす手法もあるようですが、私は試したことがありません。
まとめ
refreshable()
の使用時は以下の観点で動作確認しましょう。
- 処理の完了までインジケータが表示され続けているか
- 非同期処理が投げっぱなしになっていないか
- 処理中に画面が固まらないか
- 処理がメインアクターで実行されていないか
- 処理がキャンセルされてデータが更新されないことがないか
- 処理中にビューの構造が変わっていないか
おわりに
refreshable()
の動作を通して、SwiftUIにおけるビューの再描画やSwift Concurrencyについて学ぶことができました。
このあたりの理解を深めることで、非同期周りで問題が発生したときに追求しやすくなります