Kodu Kırmak: Gizemli @State Enjeksiyon Mekanizması

Bu makale, tekrarlanabilir bir "gizemli kod" parçası aracılığıyla Durum enjeksiyon optimizasyon mekanizmasını, modal görünümlerin (Sayfa, FullScreenCover) üretim zamanlamasını ve farklı bağlamlar (karşılıklı bağımsız görünüm ağaçları) arasındaki veri koordinasyonunu keşfedecektir.
Bu makalenin kodu burada bulunabilir .
Sorun
Son zamanlarda, Momo6 kullanıcısı sohbet odasında şu soruyu sordu :

Aşağıdaki kodda, Text("n = \(n)")
ContentView'deki kod yorumlanırsa, fullScreenCover görünümünde görüntülenen n, düğmeye basıldıktan sonra 1 olarak kalır (n, 2 olarak ayarlanır). Bu kod satırı yorumlanmazsa, fullScreenCover'da (beklendiği gibi) "n = 2" görüntülenecektir. Bu neden oluyor?
struct ContentView: View {
@State private var n = 1
@State private var show = false
var body: some View {
VStack {
// If the following line of Text code is commented out,
// the Text in full-screen will still display n = 1 after pressing the Button (n = 2)
// Text("n = \(n)") // Uncomment this line to display "n = 2" in the sheet
Button("Set n = 2") {
n = 2
show = true
}
}
.fullScreenCover(isPresented: $show) {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n) // This will always print as 2, regardless of whether the above Text is commented out or not
}
}
}
}
}

Burada, birkaç dakika ara vermeniz ve sorunu çözüp çözemeyeceğinize bakmanız önerilir.
Problem Bileşimi
Garip görünse de, Metin'in eklenmesi veya yokluğu gerçekten de Sayfa görünümlerinde görüntülenen içeriği etkiler. Bu fenomenin nedeni, State tarafından enjekte edilen optimizasyon mekanizması, Sayfa (FullScreenCover) görünümlerinin yaşam döngüsü ve yeni bağlamların oluşturulmasıdır.
Devlet Tarafından Enjekte Edilen Optimizasyon Mekanizması
SwiftUI'de, referans türleri için, geliştiriciler bunları @StateObject, @ObservedObject veya @EnvironmentObject aracılığıyla görünümlere enjekte edebilir. Bu şekilde enjekte edilen bağımlılıklar için, örneğin objectWillChange.send() yöntemi çağrıldığı sürece, örneğin özelliklerinin görünümün gövdesinde kullanılıp kullanılmadığına bakılmaksızın ilişkili görünümler yenilenmeye (gövde değerlerini yeniden hesaplamaya) zorlanır. .
Bunun tersine, değer türleri için, SwiftUI'deki @State ana enjeksiyon yöntemi yüksek oranda optimize edilmiş bir mekanizma uygular (EnvironmentValue optimizasyon sağlamaz ve referans tipi enjeksiyonla aynı şekilde davranır). Bu, görünümü tanımlayan yapı içinde @State ile işaretlenmiş bir değişken bildirsek bile, özellik gövdede kullanılmıyorsa (ViewBuilder tarafından desteklenen sözdizimini kullanarak), özellik değişse bile görünümün yenilenmeyeceği anlamına gelir.
struct StateTest: View {
@State var n = 10
var body: some View {
VStack {
let _ = print("update")
Text("Hello")
Button("n = n + 1") {
n += 1
print(n)
}
}
}
}

Yüklenen görünümün Durum kaynak verilerini gözlemleyerek, Durumun herhangi bir görünümle ilişkilendirildikten sonra doğru hale gelen özel bir _wasRead özelliği içerdiğini görebiliriz.

Mevcut "sorun" kodumuza dönersek:
struct ContentView: View {
@State private var n = 1
@State private var show = false
var body: some View {
VStack {
// Text("n = \(n)") // Comment out this line, and the n value displayed in the sheet will be 1 (not the expected 2)
Button("Set n = 2") {
n = 2
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n)
}
.buttonStyle(.bordered)
}
}
}
}
Sayfanın Yaşam Döngüsü (FullScreenCover) Görünümü
Belki birisi, sayfanın kodunda Metin'in n'ye bir referans içerdiğini sorabilir. Bu referans, n ile ContentView görünümü arasında bir ilişki kuracak mı?
.sheet
Çoğu Görünüm Uzantısı ve ViewModifier'dan farklı olarak, görünüm aracılığıyla veya .fullScreenCover
görünümde bildirilen kalıcı görünüm içeriğinin kapatılması, yalnızca kalıcı görünüm görüntülendiğinde çağrılır ve çözümlenir (kapanıştaki Görünümü değerlendirin).
Görünüm değiştiricileri aracılığıyla bildirilen diğer kod blokları, ana görünüm gövdesi değerlendirildiğinde belirli işlemleri gerçekleştirecektir:
- bindirme, arka plan vb. gövde değerlendirildiğinde çağrılır ve çözümlenir (çünkü bunların ana görünümle birlikte görüntülenmesi gerekir).
- alert, contextMenu vb. ayrıca gövde değerlendirmesi sırasında çağrılacak (bir örnek oluşturmak olarak anlaşılabilir), ancak yalnızca görüntülenmesi gerektiğinde değerlendirilecektir.
Yukarıdaki ifadeyi göstermek için, Sheet'teki kodu, gözlemimiz için View protokolüne uyan bir yapıya sardık.
struct AnalyticsView: View {
@State private var n = 1
@State private var show = false
var body: some View {
let _ = print("Parent View update") // main view body evaluation
VStack {
// Text("n = \(n)") // After commenting out this line, n in sheet displays as 1 (not the expected 2)
Button("Set n = 2") {
n = 2
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
SheetInitMonitorView(show: $show, n: n)
}
}
}
struct AnalyticsViewPreview: PreviewProvider {
static var previews: some View {
AnalyticsView()
}
}
struct SheetInitMonitorView: View {
@Binding var show: Bool
let n: Int
init(show: Binding<Bool>, n: Int) {
self._show = show
self.n = n
print("sheet view init") // Create an instance (indicating that the closure of sheet is called)
}
var body: some View {
let _ = print("sheet view update") // sheet view evaluation
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n)
}
.buttonStyle(.bordered)
}
}
}
Orijinal koda geri dönersek:
.fullScreenCover(isPresented: $show) {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n) // Regardless of whether the above Text is commented out, this will always print as 2.
}
}
}
Metin, ContextView'e dahil edilmediğinde, n'nin _wasRead değeri, Sayfa görüntülendikten sonra true olarak değişecektir (Sayfa görünümü görüntülendikten sonra, ilişkilendirme oluşturulur). Düğmeye aşağıdaki kodu ekleyerek görüntüleyebilirsiniz:
Button("Set n = 2") {
n = 2
show = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1){ // Delay is to ensure that the view in Sheet has been created
dump(_n)
}
}
SwiftUI bir Sayfa görünümü oluşturduğunda ve görüntülediğinde, mevcut görünüm ağacında bir dal oluşturmaz, ancak yeni bir bağımsız görünüm ağacı oluşturur. Bu, Sheet'teki görünümlerin ve orijinal görünümlerin farklı bağlamlarda olduğu anlamına gelir.
SwiftUI'nin ilk sürümlerinde, geliştiriciler, farklı bağlamlarda ayrı bağımsız görünüm ağaçlarına yerleştirildiklerinde, Sayfa görünümü ağacı için açıkça ortam bağımlılıkları enjekte etmek zorunda kaldılar. Daha sonraki sürümler, geliştiriciler için bu enjeksiyonu otomatik olarak yapmıştır.
Bu, görünüm ağacını yeni bir bağlamda yeniden oluşturmanın daha pahalı olduğu ve orijinal görünüm ağacında bir dal oluşturmaktan daha fazla iş gerektirdiği anlamına gelir.
Verimliliği optimize etmek için SwiftUI genellikle birkaç işlemi birleştirir. Yeni bağlamdaki görünümler için ilişkilendirme işlemi, görünüm değerlendirme işleminden önce tamamlanmış olsa bile, "n"deki değişiklik ve ilişkilendirme işlemi bir Oluşturma Döngüsünde yoğunlaştığı için, bu, yeni ilişkilendirilmiş görünümleri ilişkilendirmeden sonra yenilemeye zorlamaz. (değer ilişkilendirmeden sonra değişmedi).
Fenomen Analizi
Yukarıda tanıtılan içeriğe dayanarak, bu makalenin kodundaki garip fenomenin kapsamlı bir analizini tamamlayacağız:
ContextView Metin içermediğinde (ContextView n ile ilişkili değildir)
- Program çalıştığında, SwiftUI, ContextView'ün gövdesini değerlendirir ve işler.
- Kapanışı
.fullScreenCover
şu anda çağrılmaz, ancak görünümde n'nin geçerli değerini yakalar (n = 1). - Butona tıkladıktan sonra n'nin içeriği değişse de ContextView'ün gövdesi yeniden değerlendirilmez.
- Gösteri true olarak değiştiğinden, SwiftUI bir Sayfa görünümü oluşturmak için kapanışı çağırmaya başlar
.fullScreenCover
.
- Birleştirme işlemleri nedeniyle Sayfa görünümü n ile ilişkilendirildikten sonra güncellenmeyecektir.
- Sayfadaki Metin n = 1'i görüntüler.
- Sheet'te Close butonuna tıklandıktan sonra Button'ın kapatma işlemi gerçekleştirilir ve tekrar n'nin o anki değeri elde edilir (n=2) ve 2 değeri yazdırılır.
- Program çalıştığında, SwiftUI, ContextView'ün gövdesini değerlendirir ve işler.
- Kapanışı
.fullScreenCover
henüz çağrılmadı, ancak n'nin mevcut değerini (n = 1) yakalar. - Butona tıklandıktan sonra n değeri değiştiği için ContextView yeniden değerlendirilir (yani DSL kodu yeniden ayrıştırılır).
- Yeniden değerlendirme sırasında, kapanışı
.fullScreenCover
n'nin yeni değerini (n = 2) yakalar. - Sayfa görünümü oluşturulur ve işlenir.
- Kapanışı
.fullScreenCover
zaten yeni değeri yakaladığından, Sayfa Metni n = 2 görüntüler.
Çözüm
"İstisnanın" nedenini anladıktan sonra, benzer garip fenomenlerin tekrar olmasını önlemek ve çözmek artık zor değil.
1. Çözüm: DSL ile İlişkilendirin ve Yenilemeye Zorlayın
Orijinal kodda, Metni ContextView olarak eklemek ve bununla n arasında bir ilişki oluşturmak kabul edilebilir bir çözümdür.
Ek olarak, ek görüntüleme içeriği eklemeden de bir ilişkilendirme oluşturabiliriz:
Button("Set n = 2") {
n = 2
show = true
}
.buttonStyle(.bordered)
// .id(n)
.onChange(of:n){_ in } // id or onChange can create associations without adding display content
Seçenek 2, yenilemeyi zorlamak için @StateObject kullanın
State'i farklı bağlamlar arasında ilişkilendirirken oluşabilecek sıralama hatalarını Source tipi bir referans oluşturarak önleyebiliriz. Aslında, @StateObject kullanmak, görünümü vm.n değişikliğinden sonra yeniden hesaplamaya zorlamakla eşdeğerdir.
struct Solution2: View {
@StateObject var vm = VM()
@State private var show = false
var body: some View {
VStack {
Button("Set n = 2") {
vm.n = 2
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
VStack {
Text("n = \(vm.n)")
Button("Close") {
show = false
print("n in fullScreenCover is", vm.n)
}
.buttonStyle(.bordered)
}
}
}
}
class VM: ObservableObject {
@Published var n = 1
}
Binding türünü, belirli bir değerin get ve set yöntemleri için bir sarmalayıcı olarak görebiliriz. Sheet view değerlendirildiğinde Binding'in get metodu ile n'nin en son değerini alacaktır.
Binding'deki get yöntemi, Sheet'e yeniden enjeksiyona gerek kalmadan ContextView'deki n'nin orijinal adresine karşılık gelir, böylece değerlendirme aşamasında en son değer elde edilebilir.
struct Solution3: View {
@State private var n = 1
@State private var show = false
var body: some View {
VStack {
Button("Set n = 2") {
n = 2
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
SheetView(show: $show, n: $n)
}
}
}
struct SheetView:View {
@Binding var show:Bool
@Binding var n:Int
var body: some View {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n)
}
.buttonStyle(.bordered)
}
}
}
n değerinin değiştirilmesini geciktirerek (yani yalnızca Sayfa görünümünde verileri değerlendirip ilişkilendirdikten sonra değiştirerek), Sayfa görünümünü yeniden değerlendirmeye zorlayabilir.
struct Solution4: View {
@State private var n = 1
@State private var show = false
var body: some View {
VStack {
Button("Set n = 2") {
// A slight delay can achieve the effect
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01 ){
n = 2
}
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n)
}
.buttonStyle(.bordered)
}
}
}
}
SwiftUI, sürüm 4.0'a geliştirilmiş olsa da, hala bazı beklenmeyen davranışlar var. Bu "garip olaylarla" karşılaştığımızda, onlar hakkında daha fazla araştırma yapabilirsek, gelecekte benzer sorunlardan kaçınmakla kalmaz, aynı zamanda analiz sürecinde SwiftUI'nin çeşitli çalışma mekanizmalarını da derinlemesine anlarız.

Umarım bu makale size yardımcı olabilir. Benimle Twitter , Discord kanalı veya blogumun mesaj panosu aracılığıyla da iletişim kurabilirsiniz .