Zenn
🦔

SwiftUIの@StateとView Identityの関係

2025/03/23に公開
2

View IDの違いによる@Stateの挙動の違い

  1. それぞれIncremntボタンを3回タップするとそれぞれのcountが3に増加
  2. ToggleボタンをタップするとChildViewのinitが走る
  3. Aのcountは3のままで、B, Cはcountは0にinit(リセット)される
  4. A, B, CどれもisOnは更新されている
import SwiftUI

struct ParentView: View {
  @State var isOn = true
  
  var body: some View {
    VStack {
      Toggle("Toggle", isOn: $isOn)
      Section("A") {
        // A
        ChildView(count: 0, isOn: isOn)
      }
      Section("B") {
        // B
        if isOn {
          ChildView(count: 0, isOn: isOn)
        } else {
          ChildView(count: 0, isOn: isOn)
        }
      }
      Section("C") {
        // C
        ChildView(count: 0, isOn: isOn)
          .id(isOn)
      }
    }
  }
}

struct ChildView: View {
  @State var count: Int
  var isOn: Bool
  
  init(count: Int, isOn: Bool) {
    self.count = count
    self.isOn = isOn
  }
  
  var body: some View {
    VStack {
      Text("IsOn: \(isOn)")
      Text("Count: \(count)")
        .bold()
      Button("Increment") {
        count += 1
      }
      .buttonStyle(.borderedProminent)
    }
    .padding(30)
    .border(.red, width: 2)
  }
}

#Preview {
  ParentView()
}

A/B/CのView ID

  • AのView IDはStructural Identity
  • BのView IDはisOnを基準にifで分岐しているためisOnが関与したStructural Identity
    • SwiftUI._ConditionalContent
  • CのView IDは.id(isOn)で指定しているためExplicit Identity
    • SwiftUI.IDView
// A
ChildView(count: 0, isOn: isOn)
// B
if isOn {
  ChildView(count: 0, isOn: isOn)
} else {
  ChildView(count: 0, isOn: isOn)
}
// C
ChildView(count: 0, isOn: isOn)
  .id(isOn)

@Stateの仕様(考察)

@Stateはキャッシュ<Key, Value>のような動きをし、リセット(新規作成)するためにはKey: View IDの変更(別Key作成)が必要です。

var cache: Cache<ViewID, @State.Value> = [:]

A/B/Cでの@Stateの挙動の違い

  • A(ChildView)のView IDはStructural Identityのため、View IDに関係のないisOnが変更されても@Stateはリセットされません。
  • B(ChildView)はStructural IdentityにisOnが関与しているためView IDが変更され@Stateがリセットされます
  • C(ChildView)はExplicit IdentityであるisOnに変更があったため@Stateがリセットされます

A/B/Cのパフォーマンス

Xcode/Insruments/SwiftUIで描画時間/コストを確認するとAのパターンが1番抑えられています。
おそらくAはisOnを変更しただけ、B/CはViewそのものを再生成しているためです。

Toggleを100回切り替えた際の描画時間

パフォーマンス確認用コード(それぞれに名前付け)
struct ParentView: View {
  @State var isOn = true
  
  var body: some View {
    VStack {
      Toggle("Toggle", isOn: $isOn)
      Section("ChildViewA") {
        ChildViewA(isOn: isOn)
      }
      Section("ChildViewB") {
        ChildViewB(isOn: isOn)
      }
      Section("ChildViewC") {
        ChildViewC(isOn: isOn)
      }
    }
  }
}

struct ChildViewA: View {
  var isOn: Bool
  var body: some View {
    ChildView(count: 0, isOn: isOn)
  }
}

struct ChildViewB: View {
  var isOn: Bool
  var body: some View {
    if isOn {
      ChildView(count: 0, isOn: isOn)
    } else {
      ChildView(count: 0, isOn: isOn)
    }
  }
}

struct ChildViewC: View {
  var isOn: Bool
  var body: some View {
    ChildView(count: 0, isOn: isOn)
      .id(isOn)
  }
}


struct ChildView: View {
  @State var count: Int
  var isOn: Bool
  
  init(count: Int, isOn: Bool) {
    self.count = count
    self.isOn = isOn
  }
  
  var body: some View {
    VStack {
      Text("IsOn: \(isOn)")
      Text("Count: \(count)")
        .bold()
      Button("Increment") {
        count += 1
      }
      .buttonStyle(.borderedProminent)
    }
    .padding(30)
    .border(.red, width: 2)
  }
}

SwiftUI.List内での挙動

ParentViewのVStackListに変更して、Toggleを切り替えるとB, Cでcount: 0にリセットされた筈の@Stateが復元され、またcount: 3が表示されます。
おそらくListはデータの再利用機能があり、それぞれのCellがView IDに紐づいた@Stateを持ち、それをキャッシュ<Key, Value>から復元しています。
そのためB, Cのパターンを使う場合はこの挙動に気をつける必要があります。

Listに置き換えただけのParentView
struct ParentView: View {
  @State var isOn = true
  
  var body: some View {
    List {
      Toggle("Toggle", isOn: $isOn)
      Section("A") {
        // A
        ChildView(count: 0, isOn: isOn)
      }
      Section("B") {
        // B
        if isOn {
          ChildView(count: 0, isOn: isOn)
        } else {
          ChildView(count: 0, isOn: isOn)
        }
      }
      Section("C") {
        // C
        ChildView(count: 0, isOn: isOn)
          .id(isOn)
      }
    }
  }
}

SwiftUI.Listのバグ?

@Stateをキャッシュ<View ID, @StateのValue>という風に考えた際に、SwiftUI.Listが上記のように動作することは仕様通りと考えたのですが、以下の手順で操作するとステップ5でB/C, 4/5の@Stateが復元されるはずが0/0になってしまいます。これはバグのように思えます...

ステップ isOn A.count B.count C.count コメント
1 true 1 2 3 A/B/Cのcountを1/2/3にincrement
2 false 1 0 0 isOnをfalseに変更(B/Cが0にリセット)
3 false 1 4 5 B/Cのcountを4/5にincrement
4 true 1 2 3 isOnをtrueに戻す(ステップ1でincrementしたB/Cの2/3が復活
5 false 1 0 0 isOnをfalseに戻す(ステップ3でincrementしたはずのB/C, 4/5が消えている)

View Componentsのテスト観点

Viewの部品の動作確認の1つとしてSwiftUI.List内に置いても正しく動作するがあるとテスト観点として良さそうです。

参考資料

https://zenn.dev/zunda_pixel/articles/f94af3cbb8aeac

https://zenn.dev/kntk/articles/1f1b40da6fe181

https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-instruments-to-profile-your-swiftui-code-and-identify-slow-layouts

2

Discussion

ログインするとコメントできます