【SwiftUI】謎の[Index out of range]クラッシュで苦戦した話

謎の[Index out of range]クラッシュで苦戦した話

アプリ開発中に以下のような配列外クラッシュで数時間格闘したのでその解決方法などを残します。

Index out of range: file Swift/ContiguousArrayBuffer.swift, line 444

よくある配列外参照のエラーかと思いましたが、ちょっと事情が違いました。

原因箇所はすぐに絞れたけれど、何故か配列外参照になる理由がわかりませんでした。

実際に遭遇したエラーを以下のようなプログラムで再現してみました。

struct User: Identifiable {
    let id = UUID()
    var name: String
}

class ViewModel: ObservableObject {
    @Published var users: [User] = [
        User(name: "Thomas"),
        User(name: "James"),
        User(name: "Percy"),
        User(name: "Gordon"),
        User(name: "Emily")
    ]
}

struct ContentView: View {
    
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        VStack {
            List(viewModel.users.indices) { index in
                UserView(name: $viewModel.users[index].name)
            }
            Spacer().frame(height: 32)
            Button("Delete Last") {
                viewModel.users.removeLast()
            }
            Spacer()
        }
    }
}

struct UserView: View {
    
    @Binding var name: String
    
    var body: some View {
        Text("My name is \(name)")
    }
}

Userデータ型の @Publish 配列を List で インデックス指定(indices)で UserView に順に渡しています。

そして、ボタンを押下すると最後尾の要素が削除されるようになっています。

問題はこの部分↓

UserView(name: $viewModel.users[index].name)

配列の最後尾を削除しても View が再構築されてループは4回になるから何ら問題ないように思えるのですが、冒頭のエラーメッセージでクラッシュしてしまいます。

とりあえず、if分で配列のcountのチェックを入れてみることにしました。

if index < viewModel.users.count {
    UserView(name: $viewModel.users[index].name)
}

このサンプルでは、一応これでクラッシュはしなくなるのですが、筆者が実際に遭遇した状況は少し事情が異なり、この if を挟んでも解決しませんでした。

配列の要素数が WebAPIレスポンスの結果で変化するというものだったのですが、 どうも View の再構築に対して Binding していることが悪さしているようでした

参考にした記事はこちらです。

結局、作りを見直してみて、そもそも Binding させる必要もなかったので、普通にコピーデータを渡すようにしてみたところクラッシュしなくなりました

今一つ腑に落ちないところがあるので、何かわかったら追記します。

※追記 2021/03/24

その後、本当の解決方法がわかりましたので更新します。

これまで User は構造体としていましたが、これを ObservableObject に準拠した class することでこの問題を回避することができます。

修正版のプログラムは下記のようになります。

class User: ObservableObject, Identifiable {
    let id = UUID()
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

class ViewModel: ObservableObject {
    @Published var users: [User] = [
        User(name: "Thomas"),
        User(name: "James"),
        User(name: "Percy"),
        User(name: "Gordon"),
        User(name: "Emily")
    ]
}

struct ContentView: View {
    
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        VStack {
            List(viewModel.users) { user in
                UserView(user: user)
            }
            Spacer().frame(height: 32)
            Button("Delete Last") {
                if viewModel.users.count > 2 {
                    viewModel.users.remove(at: 2)
                }
            }
            Spacer()
        }
    }
}

struct UserView: View {
    
    @ObservedObject var user: User
    
    var body: some View {
        Text("My name is \(user.name)")
    }
}

配列の途中(2番目の要素)を削除してもクラッシュしないはずです。

また、@Binding としていたところも @ObservedObject にしていることで User のデータに変化があった場合にも View を更新することができます。

以上