NavigationSplitViewの"Simultaneous accesses to..."クラッシュの回避方法

NavigationSplitViewの"Simultaneous accesses to..."クラッシュの回避方法

iOS 16から登場したNavigationSplitViewとSwift 5.9から登場したmacroの@Observableの組み合わせでクラッシュが発生することがあります。
この記事ではその回避方法を説明します。

動作環境

  • Xcode 15
  • iOS 17

クラッシュが起こる原因

ListにはSelectionValueと呼ばれる選択された保持するパラメーターが定義されています。
このSelectionValueHashableを準拠したインスタンスを指定します。

@MainActor
struct List<SelectionValue, Content> where SelectionValue : Hashable, Content : View

そして、NavigationSplitViewListSelectionValue、そしてNavigationLinkを組み合わせるとエラーが発生する場合があります。

再現コードとして、文字列を表示複数リスト表示するViewを作りましょう。

let items = ["Item1", "Item2", "Item3", "Item4"]

@Observable
final class FolderViewModel {
    var selectedItem: String?
}

itemsは単純なStringの配列です。
そしてFolderViewModelObservableのオブジェクトでどのセルが選択されたかを表すselectedItemプロパティを持っています。

これらを使って、NavigationSplitViewでViewを作成します。

// クラッシュパターン
struct SplitView: View {
    @State var viewModel = FolderViewModel()

    var body: some View {
        NavigationSplitView {
            List(items, id: \.self, selection: $viewModel.selectedItem) { item in
                NavigationLink(value: item) {
                    Text(item)
                }
            }
            .navigationTitle("Sidebar")
        } detail: {
            if let selectedItem = viewModel.selectedItem  {
                Text(selectedItem)
                    .navigationTitle(selectedItem)
            } else {
                Text("Choose an item from the content")
            }
        }
    }
}

NavigationSplitViewの子ViewにListがあり、データとしてitemsを渡しています。
selectionのパラメーターに$viewModel.selectedItemを渡すことで選択アイテムをFolderViewModelでハンドリングしています。

NavigationLinkvalueイニシャライザーを使ってセルがタップしたら、Listselectionが更新されるようにします。

NavigationLink(value: item) {
    Text(item)
}

NavigationLinkvalueイニシャライザーは、NavigationSplitView内のListの子Viewに配置される場合、タップされるとvalueに渡した値をListselectionの値へ更新します。

Viewの見た目はこのようになります。

----------2023-10-05-1.21.19

このコードを実行し、セルをタップすると次のようなエラーを出力し、アプリがクラッシュしてしまいます。

Simultaneous accesses to 0x6000006e6410, but modification requires exclusive access.
Previous access (a modification) started at SwiftUI`OUTLINED_FUNCTION_11 + 3132 (0x105867044).

エラー発生箇所はmacroで生成されたwithMutationメソッドで発生していました。

iOS & iPadOS 17 Release Notes

Appleもこのバグは認識しているようで、iOS 17 & iPadOS 17のリリースノートにも記載がありました。

https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-17-release-notes#SwiftUI

On iOS, using an Observable object’s property as a selection value of a List inside NavigationSplitView may cause a “Simultaneous accesses to …” error when a list selection is made via tap gesture. (113978783) (FB12981860)
Workaround: There is no current workaround for Observable properties. Alternatives include factoring out the selection value into separate state stored outside the object, or using ObservableObject instead.
筆者訳:iOSではNavigationSplitViewの中でListのselection値としてObservableのプロパティを使い、タップジェスチャーでリストが選択されると「Simultaneous accesses to …」のエラーが発生する場合があります。
修正方法。Observableのプロパティについては修正方法が現状ありません。代替方法として、selection値をObservableオブジェクトの外部で保存される別の状態として扱うか、代わりに ObservableObject を使用します。

このエラーが発生した場合、Observableの利用はあきらめるしか現状なさそうです。
Observableではない別な方法で状態を管理するか、ObservableObjectのプロトコルでオブジェクトを管理するのが良いそうです。

AppStorageで回避する

クラッシュを回避するには、Observable以外で状態を管理する必要があります。
回避方法の一つとしてAppStorageを利用する方法を考えました。

Listのselect値を@Observableで管理することをやめて、AppStorageを利用します。

struct SplitViewWorkaround: View {
    // FolderViewModelをAppStorageに変更
    @AppStorage public var selectedItem: String?
    var body: some View {
        NavigationSplitView {
            List(items, id: \.self, selection: $selectedItem) { item in
                NavigationLink(value: item) {
                    Text(item)
                }
            }
            .navigationTitle("Sidebar")
        } detail: {
            if let selectedItem  {
                Text(selectedItem)
                    .navigationTitle(selectedItem)
            } else {
                Text("Choose an item from the content")
            }
        }
    }
}

#Preview {
    SplitViewWorkaround(selectedItem: AppStorage("selectedItem"))
}

先ほどクラッシュしたSplitViewとの差分は@Observableを使ったFolderViewModelをやめてAppStorageに差し替えただけです。

@State private var viewModel = FolderViewModel()

これでエラーが発生しなくなりました。

エラーが発生しないパターン

ただし、手元で確認したところ、NavigationLinkを使わない場合はクラッシュが発生しませんでした。
下記のコードはView階層がNavigationSplitView > ListとなっていてNavigationLinkがありません。

// OKパターン
struct SplitViewOK: View {
    @State private var viewModel = FolderViewModel()

    var body: some View {
        NavigationSplitView {
            List(items, id: \.self, selection: $viewModel.selectedItem) { item in
                Text(item)
            }
            .navigationTitle("Sidebar")
        } detail: {
            if let selectedItem = viewModel.selectedItem {
                Text(selectedItem)
                    .navigationTitle("selection")
            } else {
                Text("hello")
                    .navigationTitle("detail")
            }
        }
    }
}

このコードでもListselection値は更新され、詳細画面が表示されました。
NavigationLinkの内部にバグ原因があるのかもしれません。

まとめ

今回はiOS 17における、@ObservableNavigationSplitViewの組み合わせによるクラッシュとその回避方法を解説しました。
Listのselection値が@Observableのオブジェクトでクラッシュするのは意外でした。

アップデートで早く解決することを願います。

参考

宣伝

BOOTHより、同人版「Swift Concurrency入門」発売中です。
Swift Concurrencyを網羅的に学べ、さらに既存アプリへの適応方法も解説しています。
日本語で体系的に学べる解説本は他になかなかありません。
1章、2章が立ち読みできるおためし版もありますので、ぜひチェックしてください!



Swift Concurrency入門

https://personal-factory.booth.pm/items/3888109