🍣
SwiftUIでほんとにViewModelは不要なのか?
こんなタイトルにしてみたけど、ViewModelって名前で呼ぶと議論が巻き起こってしまうだけで、
ViewModelはSwiftUI的にはexternal-stateという存在と言えます。
この記事はどっちが良いって話をするものではないです。
View用のModelが必要って話をしたいわけでもないです。
大抵の場合は@Stateで解決できるのだが、特定の状況下においてexternal-stateを使うことが有効だと判断されるケースもあります。
ということもあるのだが、ScrollView+LazyStack系のCellの更新で面白いことがあったのでここにまとめておきます。
結果としては、external-stateを使った方がcellの更新が必要な範囲に留まりそう。という感じです。
観察された挙動の違い
SwiftUIでリストを実装する際、@State
と@Observable
を使った場合で再レンダリングの挙動が大きく異なることが観察されます。
検証コード
パターン1: @Stateを使用
private struct BookListRerenderingInternal: View {
@State private var selectedIndex: Int? = nil
let items = Array(0..<20)
var body: some View {
VStack {
Button("Select Random Item") {
selectedIndex = Int.random(in: 0..<items.count)
}
.padding()
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items, id: \.self) { index in
Cell(
index: index,
isSelected: selectedIndex == index,
onTap: {
_ = selectedIndex // ただ参照するだけ
}
)
}
}
.padding()
}
}
}
private struct Cell: View {
let index: Int
let isSelected: Bool
let onTap: () -> Void
var body: some View {
let _ = print("Render \(index)")
HStack {
Text("Cell \(index)")
.foregroundColor(isSelected ? .white : .primary)
Spacer()
}
.padding()
.background(isSelected ? Color.blue : Color.gray.opacity(0.2))
.cornerRadius(8)
}
}
}
パターン2: @Observableを使用
@Observable
private final class BookListViewModel {
var selectedIndex: Int? = nil
let items = Array(0..<20)
func selectRandom() {
selectedIndex = Int.random(in: 0..<items.count)
}
}
private struct BookListRerenderingWithViewModel: View {
@State private var viewModel = BookListViewModel()
var body: some View {
VStack {
Button("Select Random Item") {
viewModel.selectRandom()
}
.padding()
ScrollView {
LazyVStack(spacing: 8) {
ForEach(viewModel.items, id: \.self) { index in
Cell(
index: index,
isSelected: viewModel.selectedIndex == index,
onTap: {
viewModel.selectedIndex = index
}
)
}
}
.padding()
}
}
}
private struct Cell: View {
let index: Int
let isSelected: Bool
let onTap: () -> Void
var body: some View {
let _ = print("Render \(index)")
HStack {
Text("Cell \(index)")
.foregroundColor(isSelected ? .white : .primary)
Spacer()
}
.padding()
.background(isSelected ? Color.blue : Color.gray.opacity(0.2))
.cornerRadius(8)
.onTapGesture(perform: onTap)
}
}
}
観察結果
各セルのbody
内に配置したprint("Render \(index)")
の出力を確認すると:
@Stateパターンの挙動
- "Select Random Item"ボタンを押すと、**表示対象(lazyなので)の全てのセルが再レンダリング(正確にはbodyがcall)**される
-
onTap
クロージャー内でselectedIndex
を参照しているだけでこの現象が発生することが面白い。- 一体どうやってこれが判定されているのか。closureだから内部のgetterでは判断できないはずだが... reference counter?
@Observableパターンの挙動
- "Select Random Item"ボタンを押すと、選択状態が変更されたセルのみ再レンダリングされる
- 前回選択されていたセルと新しく選択されたセルの最大2つのみ出力される
- より効率的な更新が行われている
まとめ
依存のキャプチャ方法はSwiftUI内部でいろんなことやってるのだなと思いました。もっとそれを知りたい
Discussion
問題は、「ViewModel使った方がいいよ」でもないし、「どこかしらで結局stateと一緒になっちゃいますよ」でもなく、renderを少なくしたいときに、どうやってrenderを少なくできる?って話
これが関連しそう
こういうのを追加すると、レンダリングは選択したCellしか更新されなくなる
良いけど、closure周りでトラップはないのか?
やはりそうで、
これはselectedIndexはnilのまま
っていうことなんだなー