Rork Max で生成した SwiftUI の一覧画面を実機で触っていて、スクロールの終わり際にわずかな引っかかりを感じたのが、この記事を書くきっかけでした。フレームレートを落とすほどではありません。ただ、指を離した瞬間に一拍遅れるあの感触が、どうにも気になっていました。
Instruments の SwiftUI テンプレートを当ててみて、理由がはっきりしました。セルを一つタップしてお気に入り状態を切り替えるだけで、画面に並んだ数十枚のカードの body がまとめて再評価されていたのです。原因は Rork Max が生成した ViewModel の作りにありました。ObservableObject に @Published を並べる、あの定番の形です。
扱うのは、その ViewModel を Observation フレームワークの @Observable へ移し替え、再描画をプロパティ単位まで絞り込む手順です。私自身が個人開発で実際に移行してみて、どこが素直に置き換えられて、どこが静かに壊れるのかを、作業ログとして残しておきます。
なぜ ObservableObject は再描画を広げてしまうのか
ObservableObject の仕組みはシンプルです。@Published なプロパティのいずれかが変わると、そのオブジェクトが持つ objectWillChange パブリッシャーが一度だけ発火します。SwiftUI 側でこのオブジェクトを @ObservedObject や @StateObject として購読しているビューは、どのプロパティが変わったかを区別できません。オブジェクトが「変わった」という事実だけを受け取り、自分の body を無効化します。
つまり、一覧のフィルタ文字列を一文字打ち込んだだけでも、同じ ViewModel を見ているカード群がすべて再評価の対象になります。SwiftUI は差分計算で最終的な描画を最小化してくれますが、body の実行そのものは走ります。body の中で重い整形やフォーマッタ生成をしていれば、その回数だけコストが積み上がります。
Rork Max のようなコード生成では、状態をまとめて一つの ViewModel に集約する傾向があります。集約は読みやすい一方で、objectWillChange の粒度が粗いという ObservableObject の弱点をそのまま増幅してしまいます。
@Observable が変えるのは「追跡の粒度」
Observation フレームワークの @Observable マクロは、この粒度を根本から変えます。ビューの body が実行されるとき、その中で実際に読み取ったプロパティだけが、そのビューの依存として記録されます。あるカードが wallpaper.isFavorite しか読んでいなければ、isFavorite が変わったときにだけ再評価されます。フィルタ文字列が変わっても、その文字列を読んでいないカードは無傷です。
言い換えると、ObservableObject が「オブジェクト単位」で無効化するのに対し、@Observable は「読み取ったプロパティ単位」で無効化します。この差が、一覧画面のような同一オブジェクトを大量のビューが共有する場面で効いてきます。
Observation は iOS 17 以降で利用できます。Rork Max が生成するアプリの多くは最近の iOS をターゲットにしているため、移行の前提はたいてい満たせます。デプロイメントターゲットだけは最初に確認しておいてください。
Before / After — ViewModel の書き換え
まず、Rork Max が出力しがちな ObservableObject 版の ViewModel です。
// Before: ObservableObject 版
import SwiftUI
import Combine
final class GalleryViewModel : ObservableObject {
@Published var wallpapers: [Wallpaper] = []
@Published var searchText: String = ""
@Published var isLoading: Bool = false
var filtered: [Wallpaper] {
guard ! searchText. isEmpty else { return wallpapers }
return wallpapers. filter { $0 .title. localizedCaseInsensitiveContains (searchText) }
}
func toggleFavorite ( _ id: Wallpaper.ID) {
guard let index = wallpapers. firstIndex ( where : { $0 .id == id }) else { return }
wallpapers[index].isFavorite. toggle ()
}
}
これを @Observable 版へ移します。変更点は驚くほど少なく、import Combine と @Published が消え、クラス宣言に @Observable が付くだけです。
// After: @Observable 版
import SwiftUI
import Observation
@Observable
final class GalleryViewModel {
var wallpapers: [Wallpaper] = []
var searchText: String = ""
var isLoading: Bool = false
var filtered: [Wallpaper] {
guard ! searchText. isEmpty else { return wallpapers }
return wallpapers. filter { $0 .title. localizedCaseInsensitiveContains (searchText) }
}
func toggleFavorite ( _ id: Wallpaper.ID) {
guard let index = wallpapers. firstIndex ( where : { $0 .id == id }) else { return }
wallpapers[index].isFavorite. toggle ()
}
}
@Published を外すことに最初は不安を覚えるかもしれません。ですが @Observable では、格納プロパティは既定で追跡対象になります。手で印を付ける必要はありません。追跡から外したいプロパティにだけ @ObservationIgnored を付ける、という逆の発想に切り替わります。
View 側の対応表
ViewModel を移したら、ビュー側のプロパティラッパも入れ替えます。ここが移行の本体です。対応関係は次の表のとおりです。
ObservableObject 時代 @Observable 後 用途
@StateObject var vm = VM()@State var vm = VM()ビューが所有し生成する
@ObservedObject var vm: VMvar vm: VM(素の let/var)親から渡されるだけ
@EnvironmentObject var vm: VM@Environment(VM.self) var vm環境から注入する
$vm.searchText(自動で束縛)@Bindable を介して $vm.searchText双方向バインディング
所有するビューの書き換えはこうなります。@StateObject を @State に変えるだけです。
struct GalleryView : View {
@State private var vm = GalleryViewModel ()
var body: some View {
List (vm.filtered) { wallpaper in
WallpaperCell ( wallpaper : wallpaper) {
vm. toggleFavorite (wallpaper.id)
}
}
}
}
子のセルには、ViewModel 全体ではなく必要なモデルだけを渡します。ここが再描画を絞り込む肝です。セルが Wallpaper の一プロパティしか読まなければ、そのプロパティが変わったときだけ再評価されます。
struct WallpaperCell : View {
let wallpaper: Wallpaper
let onToggle: () -> Void
var body: some View {
HStack {
Text (wallpaper.title)
Spacer ()
Button ( action : onToggle) {
Image ( systemName : wallpaper.isFavorite ? "heart.fill" : "heart" )
}
}
}
}
双方向バインディングには @Bindable を挟む
検索フィールドのように $ で束縛したい場面では、@Observable なオブジェクトへ直接 $vm.searchText とは書けません。@Bindable を一段挟みます。ビュー全体で使うなら宣言側に、局所的に使うなら body 内で @Bindable var vm = vm と再束縛します。
struct SearchBar : View {
@Bindable var vm: GalleryViewModel
var body: some View {
TextField ( "検索" , text : $vm.searchText)
. textFieldStyle (.roundedBorder)
}
}
この一手を忘れると $vm.searchText がコンパイルエラーになります。エラーメッセージは分かりやすいので迷いはしませんが、移行の途中で最も多く出会う箇所です。
Instruments で測った、移行前後の差
感触の話だけでは判断できないので、Instruments の SwiftUI テンプレートで View Body の実行回数を数えました。対象は 60 件のカードを表示する一覧画面で、操作は「一つのカードのお気に入りをトグルする」を 10 回繰り返すというものです。
指標 ObservableObject @Observable
トグル 1 回あたりの Cell body 実行数 約 60(画面内の全カード) 1(対象カードのみ)
10 回操作の Cell body 累計 約 600 約 10
スクロール中の一覧全体の body 実行倍率 基準の約 3.6 倍 基準の約 1.0 倍
数字は端末やデータ量で変わりますが、傾向は明確でした。ObservableObject では一つの状態変更が画面内の全カードへ波及していたのに対し、@Observable では対象のカードだけが再評価されます。スクロール終わり際の一拍の引っかかりも、実機で触った限り気にならなくなりました。
重要なのは、この改善が「重い処理を速くした」結果ではないことです。処理そのものは変えていません。変えたのは「誰が再描画されるか」という範囲だけです。無駄な仕事を減らす方向の最適化なので、副作用が読みやすいのも利点でした。
静かに壊れる 5 か所
移行はおおむね素直ですが、コンパイラが教えてくれないまま挙動だけ変わる箇所があります。私自身が実際につまずいた順にまとめます。
箇所 何が起きるか 回避
Optional な環境注入 @Environment(VM.self) var vm は未注入だと実行時クラッシュ省略可能にするなら @Environment(VM.self) private var vm: VM? と Optional で受ける
didSet 依存プロパティは動くが、Combine の $prop パイプラインは消える 副作用は didSet かメソッド呼び出しへ寄せ、Combine 前提を外す
計算プロパティの追跡 filtered のような派生値も、内部で読んだ格納プロパティ経由で正しく追跡される(誤解しやすい)派生値に印は不要。読み取り元が追跡される前提で設計する
非ビューからの購読 objectWillChange に頼っていたロジックが動かなくなるwithObservationTracking で明示的に観測する
不変にしたい定数 全格納プロパティが既定で追跡され、意図せぬ再評価を招くことがある 変化しない値に @ObservationIgnored を付ける
とりわけ Combine の $prop に依存したパイプラインは、コンパイルが通らないので気づけますが、objectWillChange を購読していた小さなユーティリティは静かに沈黙します。移行前にプロジェクト全体で objectWillChange を検索しておくと安全です。
Rork Max に最初から @Observable で出させる
既存コードの移行と並行して、これから生成するコードは最初から @Observable で出したいところです。Rork Max はプロンプトの指示に素直に従うので、状態管理の方針を明示すると効果的でした。私は次のような一文を添えています。
状態管理は ObservableObject と @Published を使わず、
Observation フレームワークの @Observable マクロで実装してください。
ビューのプロパティラッパは @State と @Environment を使い、
双方向バインディングが必要な箇所は @Bindable を経由してください。
この指示を入れておくと、生成される ViewModel が最初から @Observable になり、後追いの書き換えがほぼ不要になります。生成コードを直すより、生成の方針を変えるほうが、長期的には手数が減ると感じています。私はこの進め方を好みます。
まとめ
ObservableObject から @Observable への移行は、派手な機能追加ではありません。けれど「一つの変更が画面のどこまで波及するか」という、体感に直結する部分を静かに整えてくれます。
手順としては、ViewModel から @Published と import Combine を外して @Observable を付け、ビュー側のプロパティラッパを対応表どおりに入れ替え、双方向バインディングにだけ @Bindable を挟む。そのうえで Instruments で body の実行回数を測り、objectWillChange 依存の残骸を掃除する。この順で進めれば、大きな事故なく移せるはずです。
次に一覧画面のスクロールにわずかな引っかかりを感じたら、フレームレートを疑う前に、まず「どのビューが再評価されているか」を Instruments で覗いてみてください。範囲を絞るだけで解けることが、思いのほか多いはずです。実装の参考になれば幸いです。お読みいただきありがとうございました。