🍣

SwiftUIでほんとにViewModelは不要なのか?

に公開
6

こんなタイトルにしてみたけど、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

Muukii - Kimura HiroshiMuukii - Kimura Hiroshi

問題は、「ViewModel使った方がいいよ」でもないし、「どこかしらで結局stateと一緒になっちゃいますよ」でもなく、renderを少なくしたいときに、どうやってrenderを少なくできる?って話

Muukii - Kimura HiroshiMuukii - Kimura Hiroshi
Muukii - Kimura HiroshiMuukii - Kimura Hiroshi
  private struct Cell: View, Equatable {
    
    nonisolated static func == (lhs: Cell, rhs: Cell) -> Bool {
      lhs.index == rhs.index && lhs.isSelected == rhs.isSelected
    }

こういうのを追加すると、レンダリングは選択したCellしか更新されなくなる

Muukii - Kimura HiroshiMuukii - Kimura Hiroshi

やはりそうで、

  onTap: { [selectedIndex] in
              print("previous selected index: \(String(describing: selectedIndex))")
              self.selectedIndex = index
            }

これはselectedIndexはnilのまま