Окружение и стили SwiftUI являются двумя столпами официального фреймворка (декларативной структуры) от Apple. Не смотря на это, при первом запуске SwiftUI их совместное использование приводило к гарантированному падению приложения.
В частности, сбой происходил, когда мы использовали @EnvironmentObject внутри нашего определения стилей: когда безопасно использовать их вместе? Давайте выясним.
Заметка
Для нетерпеливых результаты в конце статьи.
Пример
Познакомьтесь с FSStyle, стилем кнопки, ожидающим объект среды (FSEnvironmentObject):
class FSEnvironmentObject: ObservableObject {
@Published var title = "tap me"
}
struct FSStyle: ButtonStyle {
@EnvironmentObject var object: FSEnvironmentObject
func makeBody(configuration: Configuration) -> some View {
Button(object.title) { }
}
}
Исходя из определения, мы ожидаем, что все будет работать, пока мы вводим объект окружения в какой-то момент до того, как применить стиль. Например:
struct ContentView: View {
@StateObject var object = FSEnvironmentObject()
var body: some View {
Button("tap me") {
}
.buttonStyle(FSStyle())
.environmentObject(object)
}
}
..и все же, если мы запускали этот код в любой версии iOS до 14.5, он гарантированно давал сбой в 100% случаев с Fatal error: No ObservableObject of type FSEnvironmentObject found..
Сбой происходит, как только объект окружения используется внутри метода makeBody (configuration: ), и определение объекта, и исполнение makeBody (configuration: ) не имеют значения.
Есть два основных способа обойти ошибку: или привести стиль в соответствие с DynamicProperty (большое спасибо Линь Цин Мо за подсказку!), или вернуть View в методе makeBody (configuration: ) и заставить этот View читать объект окружения.
Теперь, когда мы увидели, в чем проблема, давайте узнаем, в каких версиях iOS и стилях встречается эта ошибка.
Тестовая установка
Мы хотим выяснить, в каких версиях iOS безопасно использовать все возможные стили (не только стили кнопок). Мы можем создать небольшое тестовое приложение и запустить его во всех версиях iOS, поддерживающих SwiftUI, и получить результат.
Продолжая на примере с ButtonStyle, вот полное приложение:
import UIKit
import SwiftUI
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { }
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var object: FSEnvironmentObject = FSEnvironmentObject()
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let contentView = ContentView().environmentObject(object)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
}
struct ContentView: View {
var body: some View {
Button("tap me") {
}
.buttonStyle(FSStyle())
}
}
struct FSStyle: ButtonStyle {
@EnvironmentObject var object: FSEnvironmentObject
func makeBody(configuration: Configuration) -> some View {
Button(object.title) { }
}
}
class FSEnvironmentObject: ObservableObject {
@Published var title = "tap me"
}
Приложение состоит из одного экрана, на котором находится наш тестовый компонент/стиль.
Несколько замечаний:
- мы используем жизненный цикл UIKit, потому что мы хотим запускать тесты также и на iOS 13
- мы не используем @StateObject для объекта окружения, потому что данная обёртка свойств только для iOS 14+
- единственная разница между тестированием ButtonStyle и другими стилями заключается в определении тела FSStyle и ContentView, все остальное остается прежним
Настройка CI/CD
Мы будем тестировать двенадцать версий iOS, от iOS 13.0 до iOS 14.5, и все восемь стилей, поддерживающих настройку.
Тестировать каждую комбинацию вручную было бы довольно сложно, вместо этого мы можем позволить провайдеру CI/CD сделать всю тяжелую работу за нас. Подойдет любая установка CI/CD, вот как различные версии Xcode/iOS были распределены для этого исследования:
- macOS 10.14
-
- iOS 13.0, Xcode 11.0
- iOS 13.1, Xcode 11.1
- iOS 13.2, Xcode 11.2
- macOS 10.15
-
- iOS 13.3, Xcode 11.3.1
- iOS 13.4, Xcode 11.4.1
- iOS 13.5, Xcode 11.5
- iOS 13.6, Xcode 11.6
- iOS 13.7, Xcode 11.7
- iOS 14.0, Xcode 12.0.1
- iOS 14.1, Xcode 12.1
- iOS 14.2, Xcode 12.2
- iOS 14.3, Xcode 12.3
- macOS 11.4:
-
- iOS 14.4, Xcode 12.4
- iOS 14.5, Xcode 12.5
Результаты
Поскольку в тестовом приложении есть только один экран, на котором сразу отображается тестируемый компонент, все, что нужно для прохождения теста, - это запустить приложение и не вылететь тотчас же. Вот результат:
Style 👇🏻 / iOS 👉🏻
13.0
13.1
13.2
13.3
13.4
13.5
13.6
13.7
14.0
14.1
14.2
14.3
14.4
14.5
ButtonStyle
💥
💥
💥
💥
💥
💥
💥
💥
💥
💥
💥
💥
💥
✅
GroupBoxStyle*
✅
✅
✅
✅
✅
✅
LabelStyle*
✅
✅
✅
✅
✅
✅
MenuStyle*
✅
✅
✅
✅
✅
✅
PrimitiveButtonStyle
💥
💥
💥
💥
💥
💥
💥
💥
✅
✅
✅
✅
✅
✅
ProgressViewStyle*
✅
✅
✅
✅
✅
✅
TextFieldStyle
💥
💥
💥
💥
💥
💥
💥
💥
✅
✅
✅
✅
✅
✅
ToggleStyle
💥
💥
💥
💥
💥
💥
💥
💥
✅
✅
✅
✅
✅
✅
💥 = вылет, ✅ = пройдено. * Стиль доступен с iOS 14.
Резюмируем:
- все стили, кроме ButtonStyle, поддерживают @EnvironmentObject с iOS 14.0
- начиная с iOS 14.5, все стили, включая ButtonStyle, поддерживают @EnvironmentObject
Выводы
Причина, по которой комбинация styles + @EnvironmentObject не поддерживалась с самого начала, вероятно, останется внутри команды SwiftUI, однако это могло быть намеренно:
Глядя на то, как применяются стандартные стили SwiftUI, помимо нескольких параметров, передаваемых через Configuration, бОльшая часть изменяемых компонентов приходит из EnvironmentValues, например @Environment (\.IsEnabled), @Environment (\.font) и @Environment (\. controlProminence).
В отличие от @EnvironmentObject, EnvironmentValues поддерживается (без вылета!) с iOS 13.0, поэтому я рекомендую их при добавлении динамики в наши пользовательские стили.
Невзирая на то, было ли это ошибкой или намеренно, как поставщик SDK, нам важно устранить неоднозначность и прояснить такие сценарии для разработчиков.
Насколько мне известно, это нигде не было задокументировано и не рассматривалось ни в одном примечании к выпуску: если разработчики это допустили, это находится во фреймворке.
Еще одно место, где это могло быть проблемой - это модификаторы View, однако и EnvironmentValues, и @EnvironmentObject поддерживаются (без 💥) из iOS 13.0.