Observable
#SwiftUI
基本
Swift 5.9, iOS 17 から利用できるようになった Swift Macros 及びそれによって適合する protocol 定義。
https://developer.apple.com/documentation/observation/observable()
https://developer.apple.com/documentation/observation/observable
@Observable マクロを使用すると、使用されたモデルのプロパティ定義を SwiftUI の View が監視できるようになり、プロパティ更新時に View を自動で更新できる。
定義
以下のように、マクロを適用するだけで良い。
code:swift
@Observable
class Book {
var title: String
var price: Int
init(title: String, price: Int) {
self.title = title
self.price = price
}
}
Discover Observation in SwiftUI - WWDC 2023
Stored Property
例えば、以下のようにタイトルを更新すると、Viewが自動で更新される。
code:swift
struct ContentView: View {
var book: Book
@State var editingTitle: String = ""
var body: some View {
VStack(spacing: 18) {
HStack {
Text("Title")
Spacer()
Text("\(book.title)")
}
HStack {
TextField(text: $editingTitle) {
Text("New Title")
}
Button {
book.title = editingTitle
} label: {
Text("Update title")
}
}
}
.padding()
}
}
試しに、body に let _ = Self._printChanges() を含めてからボタンを押すと、以下のように表示される。@dependencies として表現されるらしい。
code:text
ContentView: @dependencies changed.
Discover Observation in SwiftUI - WWDC 2023
Computed Property
computed property の場合も、同様に監視できる。以下のようにしてボタンを押した場合でも、_printChanges() の表示内容は変わらなかった。
code:swift
@Observable
class Book {
var title: String
var price: Int
var author: String
var displayTitle: String {
title + " / " + author
}
init(title: String, price: Int, author: String) {
self.title = title
self.price = price
self.author = author
}
}
struct ContentView: View {
var book: Book
@State var editingTitle: String = ""
var body: some View {
let _ = Self._printChanges()
VStack(spacing: 18) {
HStack {
Text("Title")
Spacer()
Text("\(book.displayTitle)")
}
HStack {
TextField(text: $editingTitle) {
Text("New Title")
}
Button {
book.title = editingTitle
} label: {
Text("Update title")
}
}
}
.padding()
}
}
Discover Observation in SwiftUI - WWDC 2023
プロパティベースの監視
ObservableObject との違いは、Observable はプロパティベースの追跡を行うので、関係ないプロパティが変更されても、View の body が再評価されないこと。ObservableObject は @Published プロパティが更新されたタイミングでそれを参照している View の body が強制的に再評価されてしまうので、パフォーマンスの問題があった。
例えば、以下のように本の値段を更新しても、本の値段は body 内で参照してないので、body は再評価されない。
code:swift
struct ContentView: View {
var book: Book
@State var editingPrice: Int = 0
let formatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter
}()
var body: some View {
let _ = Self._printChanges()
VStack(spacing: 18) {
HStack {
Text("Title")
Spacer()
Text("\(book.title)")
}
HStack {
TextField("New Price", value: $editingPrice, formatter: formatter)
Button {
book.price = editingPrice
} label: {
Text("Update price")
}
}
}
.padding()
}
}
一方、以下のように ObservableObject を利用した書き直すと、ボタンを押すたびに body が再評価される。
code:swift
import SwiftUI
class Book: ObservableObject {
@Published var title: String
@Published var price: Int
init(title: String, price: Int) {
self.title = title
self.price = price
}
}
struct ContentView: View {
@StateObject var book: Book
@State var editingPrice: Int = 0
let formatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter
}()
var body: some View {
VStack(spacing: 18) {
HStack {
Text("Title")
Spacer()
Text("\(book.title)")
}
HStack {
TextField("New Price", value: $editingPrice, formatter: formatter)
Button {
book.price = editingPrice
} label: {
Text("Update price")
}
}
}
.padding()
}
}
_printChanges() の出力は下記の通り。@StateObject が更新したとみなされていることがわかる。
code:text
ContentView: _book changed.
Discover Observation in SwiftUI - WWDC 2023
@State の利用
Observable は参照型だけど、@State を使うことができる。@State を使うモチベーションは @ObservedObject の代わりに @StateObject を利用した場合と同様で、View の Lifetime (SwiftUI) とモデルの生存期間を一致させるため。
例えば、以下の場合。CounterView側でカウントアップしても、ContentView側でカウントアップすると ContentView の body が再評価され、その時点で ConterView のインスタンスが作られるため、そのタイミングで Conter オブジェクトも初期化されてしまう。
code:swift
import SwiftUI
@Observable
class Counter {
var count: Int = 0
}
struct CounterView: View {
var counter = Counter()
var body: some View {
HStack {
Text("\(counter.count)")
Button {
counter.count += 1
} label: {
Text("Count up")
}
}
}
}
struct ContentView: View {
@State var count: Int = 0
var body: some View {
VStack(spacing: 16) {
HStack {
Text("\(count)")
Button {
count += 1
} label: {
Text("Count up")
}
}
CounterView()
}
.padding()
}
}
以下のように @State で初期化すれば、状態は破棄されずに残る。
code:swift
@State var counter = Counter()
が、状態が破棄されずとも、この書き方だと View の初期化の度に Counter の初期化は走ってしまう。実際には初期化されたインスタンスは即時解放されていることが、以下のようにコードを書き換えるとわかる。親 View でカウントアップする度に、Counter の init と deinit が呼ばれる。
code:swift
@Observable
class Counter {
var count: Int = 0
init() {
print("init")
}
deinit {
print("deinit")
}
}
これを避ける方法は公式ドキュメントで言及されていて、task 等の View の初回表示時にしか呼び出されないメソッドから初期化を実施すると良いと言われている。
Delaying the creation of the observable state object ensures that unnecessary allocations of the object doesn’t happen each time SwiftUI initializes the view. Using the task(priority:_:) modifier is also an effective way to defer any other kind of work required to create the initial state of the view, such as network calls or file access.
https://developer.apple.com/documentation/swiftui/state
code:swift
struct CounterView: View {
@State var counter: Counter?
var body: some View {
HStack {
Text("\(counter?.count ?? 0)")
Button {
counter?.count += 1
} label: {
Text("Count up")
}
}
.task {
counter = Counter()
}
}
}
ちなみに、この問題は @StateObject では発生しなかった。例えば以下のように書き換えても、init は一回しか出力されない。
code:swift
import SwiftUI
class Counter: ObservableObject {
@Published var count: Int = 0
init() {
print("init")
}
deinit {
print("deinit")
}
}
struct CounterView: View {
@StateObject var counter = Counter()
var body: some View {
HStack {
Text("\(counter.count)")
Button {
counter.count += 1
} label: {
Text("Count up")
}
}
}
}
View間でのデータの共有
通常の共有方法
Observable に適合したモデルは参照型であり、かつそのモデル自体が監視可能なので、単に値を受け渡すだけで複数の View 間で監視&更新が行える。
下記の例だと、EditView 側で変更した値が、自動的に ContentView 側にも反映される。
code:swift
@Observable
class Book: Identifiable {
let id: UUID = UUID()
var title: String
var price: Int
var author: String
var displayTitle: String {
title + " / " + author + " / " + "\(price) yen"
}
init(title: String, price: Int, author: String) {
self.title = title
self.price = price
self.author = author
}
}
struct ContentView: View {
var book: Book
var body: some View {
NavigationView {
VStack(spacing: 18) {
Text(book.displayTitle)
NavigationLink(destination: EditView(book: book)) { Text("Edit") }
}
.padding()
}
}
}
struct EditView: View {
var book: Book
@State var editingTitle: String = ""
var body: some View {
VStack {
HStack {
TextField("New Title", text: $editingTitle)
Button {
book.title = editingTitle
} label: {
Text("Done")
}
}
}
}
}
nil を共有したい場合
単に nullable な Observable オブジェクトを子 View に受け渡して、子 View 側で nil に更新しても、親 View は nil にならない。これをやりたい場合は、@State と @Binding を利用する。
code:swift
@Observable
class Book {
var title: String
init(title: String) {
self.title = title
}
}
struct ContentView: View {
@State var book: Book?
var body: some View {
VStack {
Text("\(book?.title ?? "<nil>")")
ClearView(book: $book)
}
.padding()
}
}
struct ClearView: View {
@Binding var book: Book?
var body: some View {
Button {
book = nil
} label: {
Text("Clear")
}
}
}
@Stateを利用せずにBindingしたい場合
Observable なオブジェクトはそれ自体が監視可能なので、通常はあえて Binding する必要がなく、親子間で値を共有したい場合は単に値を受け渡すだけで良い。ただし、API の中には Binding が必要なものも数多く存在する。例えば、テキストフィールドは入力するテキスト情報を Binding する必要がある。
code:swift
init(
_ titleKey: LocalizedStringKey,
text: Binding<String>
)
https://developer.apple.com/documentation/swiftui/textfield/init(_:text:)-4lffk
このような API を利用するために、Observable なオブジェクトの一部のプロパティを Binding することができる。この時に利用する property wrapper が @Bindable となる。@Bindable を付与して Observable なオブジェクトのプロパティを定義すると、@Binding を利用した時のように $ 経由で値を Binding することができるようになる。
code:swift
struct EditView: View {
@Bindable var book: Book
var body: some View {
TextField("Title", text: $book.title)
}
}