#ios #swift #enums #swiftui
#iOS #swift #перечисления #swiftui
Вопрос:
Я столкнулся с интересной проблемой, связанной с тем, что публикация SwiftUI периодически не синхронизирована с представлением, которое я, похоже, не могу объяснить или решить. Проблема возникает при использовании связанного перечисления с несколькими регистрами.
Моя проблема лучше всего объясняется минимальным примером кода. Для этого предположим, что мы моделируем математический вопрос с ответом. Ответом может быть либо число, либо дробь. Оба значения могут быть целыми или двойными.
Для последнего мы определяем связанное перечисление следующим образом:
enum Number {
case int(Int)
case double(Double)
var description: String {
switch self {
case let .int(i): return i.description
case let .double(d): return d.description
}
}
}
Ответ моделируется как структура с вложенным связанным перечислением следующим образом:
struct Answer {
var answer: AnswerType
enum AnswerType {
case number(Number)
case fraction(Number, Number)
}
var description: String {
switch answer {
case let .number(number): return number.description
case let .fraction(numerator, denominator): return numerator.description " / " denominator.description
}
}
}
Вопрос также моделируется как структура. В этом примере он содержит только свойство answer и некоторые (изменяющие) функции, которые я позже буду использовать, чтобы показать проблему. В качестве ответов используются только дроби, поскольку только в этом случае возникает проблема.
struct Question {
var answer: Answer
mutating func randomFraction() {
answer = Answer(answer: .fraction(.int(Int.random(in: 1...99)), .int(Int.random(in: 1...99))))
}
mutating func randomNumerator() {
answer = Answer(answer: .fraction(.int(Int.random(in: 1...99)), .int(1)))
}
mutating func randomDenominator() {
answer = Answer(answer: .fraction(.int(1), .int(Int.random(in: 1...99))))
}
mutating func emptyMutatingFunction() {
}
func emptyFunction() {
}
}
Модель представления — это класс, публикующий модель вопроса, и наследуется от ObservableObject:
class ExampleViewModel: ObservableObject {
@Published var question: Question
init() {
question = Question(answer: Answer(answer: .fraction(.int(0), .int(0))))
}
func randomFraction() {
question.randomFraction()
}
func randomNumerator() {
question.randomNumerator()
}
func randomDenominator() {
question.randomDenominator()
}
func emptyMutatingFunction() {
question.emptyMutatingFunction()
}
func emptyFunction() {
question.emptyFunction()
}
}
Finally, to demonstrate the issue with the view getting intermittently out of sync with the ObservedObject, the view is setup to show the answer to the question in three different ways. Method A: Via a property in the subview, Method B: Via a binding property in the subview, Method C: Directly in the main view. I also added a few buttons to communicate with the view model. This results in the following:
struct ExampleView: View {
@ObservedObject var viewModel: ExampleViewModel
var body: some View {
VStack(spacing: 20) {
HStack {
Text("A:")
SubView1(question: viewModel.question)
}
HStack {
Text("B:")
SubView2(question: $viewModel.question)
}
HStack {
Text("C:")
Text(viewModel.question.answer.description)
}
Button(action: viewModel.randomFraction) {
Text("Random Fraction")
}
Button(action: viewModel.randomNumerator) {
Text("Random Numerator")
}
Button(action: viewModel.randomDenominator) {
Text("Random Denominator")
}
Button(action: viewModel.emptyMutatingFunction) {
Text("Empty Mutating Function")
}
Button(action: viewModel.emptyFunction) {
Text("Empty Function")
}
}
}
}
struct SubView1: View {
let question: Question
var body: some View {
Text(question.answer.description)
}
}
struct SubView2: View {
@Binding var question: Question
var body: some View {
Text(question.answer.description)
}
}
Finally, for completeness, the view model and view are initialised in the SceneDelegate scene function as follows:
let viewModel = ExampleViewModel()
let contentView = ExampleView(viewModel: viewModel)
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
To demonstrate the problem I have made a small gif:
Demonstration of the problem in the Xcode Simulator (The problem also occurs on a real device)
As can be seen when a random fraction is generated, all three methods A, B and C are updated to show the same fractions simultaneously. However, as soon as the numerator of a fraction remains the same between updates (i.e., tapping on the ‘random denominator’ button), the view using method A is not updated, whereas the question model did update and published the changes to methods B and C. This problem occurs intermittently, meaning that when another fraction with equal numerator is generated (i.e., another press on the ‘random denominator’ button), the next update cycle or publishing results in all three methods showing the same fraction again. When the numerator is changed (i.e., tapping on the ‘random numerator’ button), regardless of the denominator value, the problem does not arise. The same holds for not using ‘fractions’ at all. Also with the problem occurring and the ’empty mutating function’ button tapped, method A updates its view to reflect the change in the question struct. When the non-mutating function is tapped this does not occur.
During debugging I found that once I remove the ‘double(Double)’ case in the Number enum, the problem does not arise and all methods A, B and C always show the same fraction. Similarly, if I make the fractions of the double type and remove the ‘case int(Int)’, it also resolves the problem. Hence, it seems that the problem is related to the Number enum containing multiple associated cases such that SwiftUI somehow cannot tell whether the question model did update when using a direct property in the subview (Method A). Note that if this property is defined as a ‘var’ instead of a ‘let’ the problem still occurs.
Clearly, the problem does not occur when using a binding property in the subview (Method B). However, I do not think that this is a proper ‘fix’, since I don’t require a binding. I just want to display the answer value in the view, not mutate it directly. The problem also does not occur when using Method C, but ultimately, I want to prevent massive views and separate view concerns into subviews.
Could this potentially be a bug in SwiftUI or what am I missing in my implementation or understanding of SwiftUI’s update cycle or associated enums and SwiftUI’s publishing?
Thank you all for your help and insights.
Обновление: благодаря комментарию «Нового разработчика» я создал еще один минимальный пример, который воспроизводит проблему. Это приводит к следующему:
enum Number {
case int(Int)
case double(Double)
var description: String {
switch self {
case let .int(i): return i.description
case let .double(d): return d.description
}
}
}
enum AnswerType {
case number(Number)
case fraction(Number, Number)
var description: String {
switch self {
case let .number(number): return number.description
case let .fraction(numerator, denominator): return numerator.description " / " denominator.description
}
}
}
struct ExampleView: View {
@State var answerType: AnswerType = .fraction(.int(0), .int(0))
var body: some View {
VStack(spacing: 20) {
Text(answerType.description)
Button(action: {
self.answerType = .fraction(.int(Int.random(in: 1...99)), .int(Int.random(in: 1...99)))
}) {
Text("Random Fraction")
}
Button(action: {
self.answerType = .fraction(.int(1), .int(Int.random(in: 1...99)))
}) {
Text("Random Denominator")
}
}
}
}
Здесь, когда нажата кнопка «случайная дробь», случайная дробь отображается правильно. Однако, когда нажата кнопка «случайный знаменатель» (т. Е. Числитель остается неизменным между обновлениями), проблема возникает циклически между каждым нажатием кнопки. При первом нажатии кнопки отображается дробь 1 / XX. При нажатии второй кнопки проблема возникает только с показанным номером 1, фактически, тип ответа сам по себе изменился на регистр «число». При третьем нажатии снова отображается дробь 1 / XX и так далее.
Проблема «решается» при удалении любого из регистров в числовом перечислении или в перечислении типа ответа. Таким образом, похоже, что это ошибка SwiftUI, при которой использование двух (или более) перечислений, связанных с несколькими регистрами, не сразу приведет к правильному обновлению представления. Что вы думаете?
Комментарии:
1. На самом деле я думаю, что это ошибка… SwiftUI совершает какое-то волшебство, пытаясь сравнить и посмотреть, изменилось ли представление. Как он узнает
SubView1
, изменился ли он и нуждается ли в повторном рендеринге? Это делает некоторое отражение (и я думаю, что я где-то читал, даже сравнения с необработанной памятью), и это может быть сбито с толку при использовании enum со связанными значениями. Но если это ошибка, она не связанаObservableObject
с. Вы можете повторить это с помощью just@State var answer: AnswerType
inExampleView
.2. Спасибо @NewDev, я обновил свой вопрос, включив второй минимальный пример воспроизведения ошибок. Проблема, по-видимому, связана с использованием более одного перечисления, связанного с несколькими регистрами. Действительно, потенциально ошибка SwiftUI.
3. да, я тоже это заметил. Это
1
(вместо1/XX
)AnswerType
переключается наnumber
fromfraction
без видимой причины — поэтому я подумал, что это ошибка
Ответ №1:
После дополнительной отладки я нашел решение, которое не требует привязки @, как в первом примере, а также работает со вторым примером.
«Решение» состоит в том, чтобы поменять местами регистры в перечислении типа ответа таким образом, чтобы регистр с несколькими связанными значениями находился сверху. В примере это означает, что регистр «дробь» должен быть поверх регистра «число».
Я все еще считаю, что это ошибка SwiftUI. Это правда или я пропустил что-то очевидное?
Пример рабочего кода (без ошибки) выглядит следующим образом:
enum Number {
case int(Int)
case double(Double)
var description: String {
switch self {
case let .int(i): return i.description
case let .double(d): return d.description
}
}
}
enum AnswerType {
case fraction(Number, Number) // Placed on top mitigates the bug
case number(Number)
var description: String {
switch self {
case let .number(number): return number.description
case let .fraction(numerator, denominator): return numerator.description " / " denominator.description
}
}
}
struct ExampleView: View {
@State var answerType: AnswerType = .fraction(.int(0), .int(0))
var body: some View {
VStack(spacing: 20) {
Text(answerType.description)
Button(action: {
self.answerType = .fraction(.int(Int.random(in: 1...99)), .int(Int.random(in: 1...99)))
}) {
Text("Random Fraction")
}
Button(action: {
self.answerType = .fraction(.int(1), .int(Int.random(in: 1...99)))
}) {
Text("Random Denominator")
}
}
}
}