@State 魅影:一个多窗口模式下 SwiftUI 应用的 Bug 分析

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

在本篇文章中,我们将探讨一个影响多窗口模式下 SwiftUI 应用的 Bug,并提出有效的临时解决策略。我们不仅会详细描述这一问题的表现,还将分享从发现到诊断,最终解决问题的全过程。通过这一探索,旨在为遇到类似挑战的开发者提供一个指引,以帮助他们更好应对其他的 SwiftUI 开发难题。

多窗口模式下的 @Observable 宏:存在问题吗?

几天前,在 Reddit 上有网友向我提出了一个有关 Observable 宏的 问题

image-20240404092012388

revblaze 尝试将一段很简单的基于 Combine 构建的代码转换成 Observation 模式,但在完成后出现了奇怪的情况。

运行基于 Combine 的代码,每个新创建的窗口的表现都很正常,每个浏览器都可以正常使用:

Swift
struct ContentView: View {
  @StateObject private var webViewManager = WebViewManager()

  var body: some View {
    WebView(manager: webViewManager)
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .onAppear {
        webViewManager.load("https://www.example.com")
      }
  }
}

class WebViewManager: ObservableObject {
  @Published var urlString: String? = nil
  let webView: WKWebView = .init()

  func load(_ urlString: String) {
    self.urlString = urlString
    guard let url = URL(string: urlString) else { return }
    let request = URLRequest(url: url)
    webView.load(request)
  }
}

struct WebView: NSViewRepresentable {
  @ObservedObject var manager: WebViewManager
  func makeNSView(context _: Context) -> WKWebView {
    return manager.webView
  }

  func updateNSView(_: WKWebView, context _: Context) {}
}

但是,当将上面这段简单的代码转换成 Observation 模式后,问题就出现了。所有的窗口都共用同一个 WebViewManager 实例( 为了能将问题表现的更加的清晰,我在 WebViewManager 中声明了一个 UUID ,并在视图中将其显示了出来):

Swift
import Observation
import SwiftUI
import WebKit

struct ContentView: View {
  @State private var webViewManager = WebViewManager()

  var body: some View {
    Text("ID:\(webViewManager.id)")
    WebView(manager: webViewManager)
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .onAppear {
        webViewManager.load("https://www.example.com")
      }
  }
}

@Observable
class WebViewManager {
  var urlString: String?
  let webView: WKWebView = .init()
  let id = UUID()

  func load(_ urlString: String) {
    self.urlString = urlString
    guard let url = URL(string: urlString) else { return }
    let request = URLRequest(url: url)
    webView.load(request)
  }
}

struct WebView: NSViewRepresentable {
  var manager: WebViewManager

  func makeNSView(context _: Context) -> WKWebView {
    return manager.webView
  }

  func updateNSView(_: WKWebView, context _: Context) {}
}

在最初看到这个提问后我也感觉十分的奇怪,根据我对 Observation 框架的理解,不应该出现这种情况。但是当我看到在 ContentView 中是通过 @State 来声明这个可观察对象的时候,让我想起了一年前碰到的类似的问题。

@State 在多窗口模式下引发的动态值一致性问题

去年,在北京举行的 SwiftUI 技术沙龙 中,我展示了一个名为 Movie Hunter 的项目,用以演示 SwiftUI 的跨平台开发能力。项目中利用了我自己开发的 SwiftUI Overlay Container,这是一个可定制的覆盖视图管理库。为了区分每个覆盖容器,我采用 UUID().uuidString 作为每个窗口的覆盖容器的唯一标识符。

Swift
@State var containerName = UUID().uuidString

VStack {
  ...
}
.overlayContainer(containerName, containerConfiguration: ContainerConfiguration.share)
.environment(\.containerName, containerName)

我原本预期,SwiftUI 在为每个窗口构建根视图时会为每个容器生成一个全新的 UUID,从而使得每个窗口可以拥有不同名称的独立容器。

然而,实际表现出乎意料。一旦启用了多窗口功能(无论是 macOS 还是 iPadOS),所有窗口展示的视图竟然完全同步的,进行了相同的操作。这表明每个窗口中的 containerName 实际上是相同的。

为了更清晰地演示这一问题,我编写了以下代码:

Swift
@main
struct StateIssueInMultiWindowsApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

struct ContentView: View {
  @State var sameIdByState = UUID()
  @GestureState var sameIdByGestureState = UUID()
  @SceneStorage("id") var sameIdBySceneStore = UUID().uuidString
  @State var number = Int.random(in: 0...10000)
  @State var date = Date.now.timeIntervalSince1970
  var body: some View {
    VStack {
      Text("State \(sameIdByState)")
      Text("GestureState \(sameIdByGestureState)")
      Text("SceneStorage \(sameIdBySceneStore)")
      Text("Dumber \(number)")
      Text("Date \(date)")
    }
    .padding()
  }
}

通过上面的视频可以看到,在根视图中,通过 @State@SceneStorage@GestureState 等声明的动态状态( UUID、随机数、当前日期 ),每个窗口中都是一样的。

一系列测试揭示了以下规律:

  • 在根视图中,通过 @State 或类似机制(如 @SceneStorage@GestureState)创建的动态数据,在每个新窗口中都完全相同。
  • 新窗口中通过 @State 声明的动态数据的初始值与第一个窗口中的值相同。即便在某个窗口中修改了这些数据,也不会影响到其他窗口,说明每个窗口的状态实际上还是独立的,只是它们的初始值被复制了。
  • 这一问题仅出现在根视图中。
  • 使用 StateObject 声明的可观察对象并不会遇到此问题。

考虑到这个问题的出现条件相当特殊,我在解决问题之后竟忘记了向苹果反馈。但随着开发者转向新的 Observation 框架并采用 @State 来声明可观察对象,遇到此类问题的可能性将显著增加。

通过修改测试代码,我们增加一个基于 Observation 框架且通过 @State 声明的可观察对象:

Swift
@Observable
class ObservableID {
  var id = UUID()
  init(){
    print("Observable init")
  }
}

// Add in ContentView
@State var object = ObservableID()

Text("ObservableID \(object.id)")

结果显示,所有窗口都在使用同一个可观察对象实例,对于需要为每个窗口提供独立状态容器的应用来说,这种情况是不可接受的。

临时解决策略

尽管我们无法完全搞清楚问题具体出现在哪个层面( @State 优化机制、窗口根视图的构造机制 ),但毕竟我们已经掌握出现问题的规律,因此就可以采用一些简单的方式来进行规避。

封装视图以隔离根视图

通过创建一个新的封装视图来包裹原有的根视图(ContentView),我们可以有效地绕过这一问题。

Swift
@main
struct StateIssueInMultiWindowsApp: App {
  var body: some Scene {
    WindowGroup {
      RootView()
    }
  }
}

struct RootView: View {
  var body: some View {
    ContentView()
  }
}

利用 onAppear 或 task 重置状态

利用 onAppear 或者 task 为状态重新赋值,同样是一种有效的策略。这样做可以确保根视图在加载时会刷新状态值,从而避免状态共享问题。

Swift
.onAppear{
  sameIdByState = UUID()
  ...
}

采用上述任一方法,都能够成功规避多窗口环境下状态一致性的问题,当然也同样能够解决本文开篇中 revblaze 所遇到的情况。

此外,若场景的根视图每次被创建时能够接收到来自外部的不同参数值,或者采用了如 WindowGroup(for: Item.self) 等场景构造方法,这样有时能够避开此问题,但并不能保证绝对有效。我已向苹果提交了反馈(FB13707448),期待它们能够在不久的将来提供修复。

对 @State 的未来展望

@State 属性包装器在 SwiftUI 中占据着举足轻重的地位,自 SwiftUI 的初期以来就为视图和局部状态的绑定提供了支持。它的主要特点包括:

  • 为 SwiftUI 视图体系高度优化,通过 isRead 属性判断被视图的实际使用情况,确保了状态与视图间的精准关联。
  • 当开发者利用 @StatewrappedValue 构建自定义的 Binding 时,在极少数情况下可能遇到问题(因状态变化与视图更新不同步导致的应用崩溃),但 @StateProjectedValue 提供的 Binding 直接因直接操作底层数据( 绕过 wrappedValue),因此更为安全和高效。
  • 在尚未开启严格并发检查的情况下,@State 具备线程安全的特征,可以在不同线程中修改值类型的状态而不产生问题。

然而,随着 Observation 框架的引入,@State 的使用场景和角色发生了显著转变。现在,它不仅仅是用于管理值类型的状态,而且还承担了更广泛的生命周期管理职责。此外,随着并发编程模式的普及和严格并发检查的实施,@State 未像 StateObject 那样显式地标注为 @MainActor,这一问题也逐渐显露出来。

鉴于本文讨论的新问题,以及 @State 在现代 SwiftUI 应用中的重要性和使用中遇到的挑战,我们有理由期待在不久后的 WWDC 2024 中见到 @State 的进一步发展和优化。

总结

本文除了揭示了在多窗口模式下 SwiftUI 应用中 @State 使用所遭遇的 Bug 外,更主要的目的是鼓励开发者在面对问题时需从更多的角度来进行思考和分析,而非仅停留在表象。深入理解问题的本质是解决问题的关键,它能引导我们找到正确的解决思路和方法。

后记

在本文发表之前,我收到了另一位网友的求助。他在使用 tvOS 17 时遇到了一个令人困惑的问题。

image-20240405203729630

Swift
struct ContentView: View {
  var body: some View {
    HStack {
      Text("Left")

      Form {
        Section(header: Text("rate")) {
          Button("0.5") {}
          Button("1.0") {}
          Button("1.5") {}
        }
      }
    }
  }
}

这段代码在 tvOS 16 上表现正常,但升级到 tvOS 17 后,当 Button 获得焦点时,其左侧的高亮显示不完整(被截断)。

image-20240405201157359

通过几次测试,我总结出以下规律:

  • 在 tvOS 16 上一切正常,而在 tvOS 17 上则表现异常。
  • Form 更换为 List,问题仍然存在。
  • Form 更换为 VStack,问题得以解决。
  • Form 更换为 ScrollView + VStack,问题再次出现。

这表明,该问题可能与 tvOS 17 对 ScrollView 进行的某项功能增强有关。

在之前的文章 深入了解 SwiftUI 5 中 ScrollView 的新功能 中,我介绍了苹果在 WWDC 2023 中为 ScrollView 带来的所有新特性。其中的 scrollClipDisabled 功能旨在控制是否对滚动内容进行裁剪,以适应滚动容器的边界。

根据当前现象进行分析,这个问题很可能与该修饰符有关。因此,我对代码进行了如下调整:

Swift
struct ContentView: View {
  var body: some View {
    HStack {
      Text("Left")

      Form {
        Section(header: Text("rate")) {
          Button("0.5") {}
          Button("1.0") {}
          Button("1.5") {}
        }
      }
      .scrollClipDisabled()  // disalbe clip
    }
  }
}

image-20240405202541019

随后,问题得以解决。

从开始测试问题代码到问题解决,整个过程用了不到十分钟的时间,这也验证了本文想要传达的核心信息:当遇到问题时,深入探究其本质,往往能够使解决问题变得更为简单。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam