当带有 Picker 的 SwiftUI 视图消失时应用程序崩溃(自 iOS 16 起)

Vam*_*ama 10 ios swift swiftui swiftui-picker

我们有一个具有一些“聊天”功能的应用程序,可以提出问题,用户可以使用一些预定义的选项进行回答:对于每个问题都会呈现一个新视图。其中一个选项是带有选择器的视图,从 iOS 16 开始,当带有选择器的视图消失时,此选择器会导致应用程序崩溃,并出现以下错误:Thread 1: Fatal error: Index out of range定位于class AppDelegate: UIResponder, UIApplicationDelegate {。在日志中我可以看到这个错误:Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range

为了解决这个问题,我将代码重构到最低限度,甚至没有使用选择器,但仍然导致错误发生。当我从此视图中删除选择器时,它会再次工作。

查看发生错误的地方

struct PickerQuestion: View {
    
    @EnvironmentObject() var questionVM: QuestionVM

    let question: Question
    
    var colors = ["A", "B", "C", "D"]
    @State private var selected = "A"

    var body: some View {
        VStack {
            // When removing the Picker from this view the error does not occur anymore
            Picker("Please choose a value", selection: $selected) {
                ForEach(colors, id: \.self) {
                    Text($0)
                }
            }.pickerStyle(.wheel) // with .menu style the crash does not occur

            Text("You selected: \(selected)")

            Button("Submit", action: {
                // In this function I provide an answer that is always valid so I do not
                // have to use the Picker it's value
                questionVM.answerQuestion(...)

                // In this function I submit the answer to the backend.
                // The backend will provide a new question which can be again a Picker
                // question or another type of question: in both cases the app crashes
                // when this view disappears. (the result of the backend is provided to
                // the view with `DispatchQueue.main.async {}`)
                questionVM.submitAnswerForQuestionWith(questionId: question.id)
            })
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

使用上面视图的父视图(注意:即使删除了所有与动画相关的行,崩溃仍然发生):

struct QuestionContainerView: View {
    
    @EnvironmentObject() var questionVM: QuestionVM
    
    @State var questionVisible = true
    @State var questionId = ""
    
    @State var animate: Bool = false
    
    var body: some View {
        VStack {
            HeaderView(...)
            Spacer()
            if questionVM.currentQuestion != nil {
                ZStack(alignment: .bottom) {
                    if questionVisible {
                        getViewForQuestion(question: questionVM.currentQuestion!)
                            .transition(.asymmetric(
                                insertion: .move(edge: self.questionVM.scrollDirection == .Next ? .trailing : .leading),
                                removal: .opacity
                            ))
                            .zIndex(0)
                            .onAppear {
                                self.animate.toggle()
                            }
                            .environmentObject(questionVM)
                    } else {
                        EmptyView()
                    }
                }
            }
        }
        .onAppear {
            self.questionVM.getQuestion()
        }
        .onReceive(self.questionVM.$currentQuestion) { q in
            if let question = q, question.id != self.questionId {
                self.questionVisible = false
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                    withAnimation {
                        self.questionVisible = true
                        self.questionId = question.id
                    }
                }
            }
        }
    }
    
    func getViewForQuestion(question: Question) -> AnyView {
        switch question.questionType {
        case .Picker:
            return AnyView(TestPickerQuestion(question: question))
        case .Other:
            ...
        case ...
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

该应用程序最初是为 iOS 13 制作的,但仍然得到维护:在每个新版本的 iOS 中,该应用程序都可以按预期运行,直到现在在 iOS 16 上。

最小可重现代码:(放入TestView您的ContentView

struct MinimalQuestion {
    var id: String = randomString(length: 10)
    var text: String
    var type: QuestionType
    var answer: String? = nil
    
    enum QuestionType: String {
        case Picker = "PICKER"
        case Info = "INFO"
        case Boolean = "BOOLEAN"
    }
    
    // /sf/answers/1879199731/
    private static func randomString(length: Int) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String((0..<length).map{ _ in letters.randomElement()! })
    }
}

class QuestionViewModel: ObservableObject {
    
    @Published var questions: [MinimalQuestion] = []
    
    @Published var current: MinimalQuestion? = nil//MinimalQuestion(text: "Picker Question", type: .Picker)
    
    @Published var scrollDirection: ScrollDirection = .Next
    
    func getQuestion() {
        DispatchQueue.global(qos: .userInitiated).async {
            DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 0.1...0.2)) {
                var question: MinimalQuestion
                switch Int.random(in: 0...2) {
                case 1:
                    question = MinimalQuestion(text: "Info", type: .Info)
                case 2:
                    question = MinimalQuestion(text: "Boolean question", type: .Boolean)
                default:
                    question = MinimalQuestion(text: "Picker Question", type: .Picker)
                }
                self.questions.append(question)
                self.current = question
            }
        }
    }
    
    func answerQuestion(question: MinimalQuestion, answer: String) {
        if let index = self.questions.firstIndex(where: { $0.id == question.id }) {
            self.questions[index].answer = answer
            self.current = self.questions[index]
        }
    }
    
    func submitQuestion(questionId: MinimalQuestion) {
        DispatchQueue.global(qos: .userInitiated).async {
            DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 0.1...0.2)) {
                self.getQuestion()
            }
        }
    }
    
    func restart() {
        self.questions = []
        self.current = nil
        self.getQuestion()
    }
}

struct TestView: View {
    
    @StateObject var questionVM: QuestionViewModel = QuestionViewModel()

    @State var questionVisible = true
    @State var questionId = ""
    
    @State var animate: Bool = false
    
    var body: some View {
        return VStack {
            Text("Questionaire")
            Spacer()
            if questionVM.current != nil {
                ZStack(alignment: .bottom) {
                    if questionVisible {
                        getViewForQuestion(question: questionVM.current!).environmentObject(questionVM)
                            .frame(maxWidth: .infinity)
                            .transition(.asymmetric(
                                insertion: .move(edge: self.questionVM.scrollDirection == .Next ? .trailing : .leading),
                                removal: .opacity
                            ))
                            .zIndex(0)
                            .onAppear {
                                self.animate.toggle()
                            }
                    } else {
                        EmptyView()
                    }
                }.frame(maxWidth: .infinity)
            }
            Spacer()
        }
        .frame(maxWidth: .infinity)
        .onAppear {
            self.questionVM.getQuestion()
        }
        .onReceive(self.questionVM.$current) { q in
            print("NEW QUESTION OF TYPE \(q?.type)")
            if let question = q, question.id != self.questionId {
                self.questionVisible = false
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                    withAnimation {
                        self.questionVisible = true
                        self.questionId = question.id
                    }
                }
            }
        }
    }
    
    func getViewForQuestion(question: MinimalQuestion) -> AnyView {
        switch question.type {
        case .Info:
            return AnyView(InfoQView(question: question))
        case .Picker:
            return AnyView(PickerQView(question: question))
        case .Boolean:
            return AnyView(BoolQView(question: question))
        }
    }
}

struct PickerQView: View {
    
    @EnvironmentObject() var questionVM: QuestionViewModel
    
    var colors = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]
    @State private var selected: String? = nil

    let question: MinimalQuestion
    
    var body: some View {
        VStack {
            // When removing the Picker from this view the error does not occur anymore
            Picker("Please choose a value", selection: $selected) {
                ForEach(colors, id: \.self) {
                    Text("\($0)")
                }
            }.pickerStyle(.wheel)

            Text("You selected: \(selected ?? "")")

            Button("Submit", action: {
                questionVM.submitQuestion(questionId: question)
            })
        }.onChange(of: selected) { value in
            if let safeValue = value {
                questionVM.answerQuestion(question: question, answer: String(safeValue))
            }
        }
    }
}

struct InfoQView: View {
    
    @EnvironmentObject() var questionVM: QuestionViewModel
    
    let question: MinimalQuestion
    
    var body: some View {
        VStack {
            Text(question.text)
            Button("OK", action: {
                questionVM.answerQuestion(question: question, answer: "OK")
                questionVM.submitQuestion(questionId: question)
            })
        }
    }
}

struct BoolQView: View {
    
    @EnvironmentObject() var questionVM: QuestionViewModel
    
    let question: MinimalQuestion
    
    @State var isToggled = false
    
    var body: some View {
        VStack {
            Toggle(question.text, isOn: self.$isToggled)
            Button("OK", action: {
                questionVM.answerQuestion(question: question, answer: "\(isToggled)")
                questionVM.submitQuestion(questionId: question)
            })
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

小智 4

此崩溃在 iOS 16.2 上仍然存在。它似乎只发生在你ForEach在你的Picker视图中使用时。Text因此,当您在内容中手动提供每个选择器选项的视图Picker而不是使用ForEach创建选择器选项时,崩溃就会消失Text

当然,在许多情况下,对选择器选项进行硬编码并不是一个可行的解决方法。

ForEach但您也可以通过将生成选择器选项的循环移动到另一个视图来解决该问题。为了实现这一点,定义一个辅助视图:

struct PickerContent<Data>: View where Data : RandomAccessCollection, Data.Element : Hashable {
    let pickerValues: Data
    
    var body: some View {
        ForEach(pickerValues, id: \.self) {
            let text = "\($0)"
            Text(text)
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

PickerContent然后在您的中使用Picker而不是ForEach, 例如(根据您的示例):

Picker("Please choose a value", selection: $selected) {
    PickerContent(pickerValues: colors)
}.pickerStyle(.wheel)
Run Code Online (Sandbox Code Playgroud)