[译文]理解SwiftUI的属性包装器

575 阅读7分钟

原文:

Understanding Property Wrappers in SwiftUI

New property wrappers in SwiftUI

SwiftUI提供了 @State, @Binding, @ObservedObject, @EnvironmentObject, @Environment属性包装器,所以让我们来尝试了解下他们之间的不同之处, 以及我们必须在何时以及为什么使用哪一个。

WWDC20 为 SwiftUI 带来了许多新功能,本文也会介绍 @StateObject@AppStorage@SceneStorage@ScaledMetric 属性包装器对 SwiftUI 数据流的补充。

Property Wrappers

SE-0258 提案中描述了的属性包装器(Property Wrappers)功能。这里的主要目标是用逻辑包装属性,这些逻辑可以提取到分离的结构中,以便在代码库中重用它。

@State

@State 是用来描述View的状态的属性包装器. SwiftUI会将其存储在View结构之外的特殊的内部存储器.只有相关的View才能访问它.只要 @State属性的值发生变化,SwiftUI就会重构View以反映state的变化.下面举个简单的例子:

struct ProductsView: View {
  let products: [Product]
  
  @State private var showFavorited: Bool = false
  
  var body: some View {
    List {
      Button(
        action: { self.showFavorited.toggle()},
        label: { Text("Change filter")}
      )
      ForEach(products) { product in
          if !self.showFavorited || product.isFavorited {
            Text(product.title)
          }
      }
    }
  }
}

在上面的示例中,我们有一个带有按钮和产品列表的简单屏幕。只要我们按下按钮,它就会改变 state 属性的值,并且 SwiftUI 会重新创建 View。

@Binding

@Binding为值类型提供类似引用的访问。有时我们需要让视图的状态对它的子视图可访问。但我们不能简单地传递该值,因为它是一个值类型,Swift会传递该值的副本。这就是我们可以使用 @Binding 的地方。

struct FilterView: View {
  @Binding var showFavorited: Bool
  var body: some View {
    Toggle(isOn: $showFavorited) {
      Text("Change filter")
    }
  }
}
​
struct ProductsView: View {
  let products: [Product]
  
  @State private var showFavorited: Bool = false
  
  var body: some View {
    List {
      FilterView(showFavorited: $showFavorited)
      
      ForEach(products) { product in
          if !self.showFavorited || product.isFavorited {
              Text(product.title) 
          }
      }
    }
  }
}

我们使用 @Binding来标记FilterView中的showFavorited属性。我们还使用 传递绑定引用,因为如果没有** 传递绑定引用,因为如果没有 ** ,Swift将传递值的副本,而不是传递可绑定引用。FilterView可以读写ProductsViewshowFavorited属性的值。一旦FilterView改变了showFavorited属性的值,SwiftUI将重新创建ProductsView和作为它子视图的FilterView

@Binding 为值类型提供类似访问的引用。这就是为什么它应该只与值类型一起使用。如果绑定的值不是值语义,则任何视图的更新行为不指定使用 Binding 的结果。

@ObservedObject

我们应该使用 @ObservedObject来处理SwiftUI之外的数据,比如你的业务逻辑.我们可以在多个独立视图之间共享它,这些视图可以订阅并观察该对象上的更改,一旦更改出现,SwiftUI就会重新构建绑定到该对象上个的所有Views.让我们看一个例子.

import Combine
​
final class PodcastPlayer: ObservableObject {
  @Published private(set) var isPlaying: Bool = false
  
  func play() {
    isPlaying = true
  }
  
  func pause() {
    isPlaying = false
  }
}

这里我们有一个在我们App界面之间共享的PodcastPlayer类, 当App在播放播客时,每一个界面都展示一个浮动的暂停按钮.SwiftUI@Published属性包装器的帮助下追踪ObservableObject上的变化,一旦标记为@Published的属性变化,SwiftUI会重新构建所有绑定到PodcatPlayer对象的所有视图.这里我们用 @ObservedObject属性包装器来将EpisodesView绑定到PodcatPlayer类.

struct EpisodesView: View {
  @ObservedObject var player: PodcastPlayer
  let episodes: [Episode]
  
  var body: some View {
    List {
      Button(
        action: {
          if self.player.isPlaying {
            self.player.pause()
          } else {
            self.player.play()
          }
        }, label: {
          Text(player.isPlaying ? "Pause" : "Play")
        }
      )
    }
  }
}

请记住,我们可以在多个视图之间共享 ObservableObject,这就是为什么它必须是引用类型/类。

@EnvironmentObject

我们可以将 ObservableObject 隐式注入到 View 层次结构的 Environment 中,而不是通过 View 的 init 方法传递 ObservableObject。通过这样做,我们为当前环境的所有子视图访问此 ObservableObject 创造了机会。

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
  
  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    let window = UIWindow(frame: UIScreen.main.bounds)
    let episodes = [
      Episode(id: 1, title: "First episode"),
      Episode(id: 2, title: "Second episode")
    ]
    
    let player = PodcastPlayer()
    window.rootViewController = UIHostingController(rootView: EpisodesView(episodes: episodes).enviromentObject(player)
    )
    self.window = window
    window.makeKeyAndVisible()
  }
}
​
struct EpisodesView: View {
  @EnvironmentObject var player: PodcastPlayer
  let episodes: [Episode]
  
  var body: some View {
    List {
      Button(
        action: {
          if self.player.isPlaying {
            self.player.pause()
          } else {
            self.player.play()
          }
        }, label: {
          Text(player.isPlaying ? "Pause" : "Play")
        }
      )
      ForEach(episodes) { episode in
        Text(episode.title)
      }
    }
  }
}

如您所见,我们可以通过 View 的 environmentObject 修饰符传递 PodcastPlayer 对象。通过这样做,我们可以通过使用 @EnvironmentObject 属性包装器定义 PodcastPlayer 来轻松访问它。 @EnvironmentObject 使用动态成员查找功能在 Environment 中查找 PodcastPlayer 类实例,这就是为什么您不需要通过 EpisodesViewinit 方法传递它的原因。它像魔术一样工作。

@Enviroment

正如我们在上一章中所讨论的,我们可以将自定义对象传递到 SwiftUI 中 View 层次结构的 Environment 中。但是SwiftUI已经有一个系统范围的设置来填充 Environment,我们可以通过 @Environment 属性包装器轻松地访问它们。

struct CalendarView: View {
  @Enviroment(.calendar) var calendar: Calendar
  @Enviroment(.locale) var locale: Locale
  @Enviroment(.colorScheme) var colorScheme: ColorScheme
  
  var body: some View {
    return Text(locale.identifier)
  }
}

通过 @Enviroment属性包装器来标记我们的属性,我们可以访问和订阅系统范围设置的变化,只要系统的 LocaleCalendarColorScheme 发生变化,SwiftUI 就会重新创建我们的 CalendarView

@StateObject

如您所知,SwiftUI 为我们提供了 @ObservedObject 属性包装器,它允许我们观察位于 SwiftUI 框架之外的数据模型(data model)的变化。例如,它可能是您从 Web 服务或本地数据库中获取的数据。 @ObservedObject 的主要关注点是生命周期。您必须将其存储在 SwiftUI 之外的某个位置,以便在视图更新期间保存它,例如,在 SceneDelegateAppDelegate 中。在其他情况下,您可能会在某些情况下丢失 @ObservedObject 支持的数据.

这是全新的 StateObject 属性包装器,它填补了 SwiftUI 数据流管理中最重要的空白。SwiftUI 仅为您声明的每个容器实例创建一个 StateObject 实例,并将其保存在内部框架内存中,以便在视图更新期间保它. StateObject的工作原理跟State包装属性很相似,但它不是值类型,而是设计用于引用类型。

struct CalendarContainerView: View {
  @StateObject var viewModel = ViewModel()
  
  var body: some View {
    CalendarView(viewModel.dates)
      .onAppear(perform: viewModel.fetch)
  }
}

@AppStorage

AppStorage是存储由 UserDefaults 支持的少量键值数据的完美方式。AppStorage 是动态属性 (DynamicProperty),这意味着只要 UserDefaults 中特定键的值更新,SwiftUI 就会更新您的视图。AppStorage 非常适合存储应用设置。让我们看看如何使用它。

enum Settings {
  static let notifications = "notifications"
  static let sleepGoal = "sleepGoal"
}
​
struct SettingsView: View {
  @AppStorage(Settings.notifications) var notificatios: Bool = false
  @AppStorage(Settings.sleepGoal) var sleepGoal: Double = 8.0
  
  var body: some View {
    Form {
      Section {
        Toggle("Notifications", isOn: $notifications)
      }
      
      Section {
        Stepper(value: $sleepGoal, in: 6...12) {
          Text("Sleep goal is (sleepGoal, specifier: "%.f") hr")
        }
      }
    }
  }
}

在上面的示例中,我们使用 AppStorage 属性包装器和表单视图构建了一个设置界面。现在我们可以使用 AppStorage 在应用程序的任何位置访问我们的设置,并且一旦我们更改了值,SwiftUI 就会更新视图。

struct ContentView: View {
  @AppStorage(Settings.sleepGoal) var sleepGoal = 8
  @StateObject var store = SleepStore()
  
  var body: some View {
    WeeklySleepChart(store.sleeps, goal: sleepGoal)
    	.onAppear(perform: store.fetch)
  }
}

@SceneStorage

今年我们在没有 UIKit 的情况下获得了在 SwiftUI 中控制场景的所有能力。因此,我们有一个新的 SceneStorage 属性包装器,它允许我们为我们的场景实现正确的状态恢复。SceneStorage 的工作方式与 AppStorage 类似,但它使用的是按场景存储而不是 UserDefaults。这意味着每个场景都有自己的私有存储,其他场景无法访问。系统完全负责管理每个场景的存储,如果没有 SceneStorage 属性包装器,您将无法访问数据。

struct ContentView: View {
  @SceneStorage("selectedTab") var selection = 0
  
  var body: some View {
    TabView(selection: $selection) {
      Text("Tab 1").tag(0)
      Text("Tab 2").tag(1)
    }
  }
}

我建议使用 SceneStorage 在阅读器应用程序中存储特定于场景的数据,例如选项卡选择或选定的书籍索引。如今,iOS 在后台杀死应用方面非常激进,而状态恢复是提供出色用户体验的关键解决方案。

@ScaledMetric

ScaledMetric 允许我们缩放相对于动态类型大小类别的任何二进制浮点值。例如,根据 Dynamic Type 大小类别更改应用程序中的间距非常容易。我们来看一个小例子。

struct ContentView: View {
    @ScaledMetric(relativeTo: .body) var spacing: CGFloat = 8

    var body: some View {
        VStack(spacing: spacing) {
            ForEach(0...10, id: .self) { number in
                Text(String(number))
            }
        }
    }
}

一旦用户更改了 Dynamic Type 设置,SwiftUI 就会缩放间距值并更新视图。

总结

相信了解了SwiftUI 中的这些属性包装器。我相信有足够的数据流属性包装器可以涵盖我们在实现应用程序时需要的任何逻辑。