[SwiftUI] ViewのIdentityと再描画を意識しよう
SwiftUIはViewをどのように管理しているのでしょうか?その裏側には、Identityという仕組みがあります。 SwiftUIはIdentityによってViewを管理し、また、再描画についてもこのIdentityが関わっています。
この記事は、Identityを理解することでSwiftUIの再描画について意識できるようにし、どのようにコードを書けばSwiftUIの描画システム的にパフォーマンスの良いアプリが作れるのかを実践していきます。
「View Identityの概念・挙動はもう完璧に知ってるよ」という方は、「(考察)SwiftUIの描画ロジック」から見ていただければと思います。
View Identity
WWDC2021 Demystify SwiftUIで解説があります。
Identity is how SwiftUI recognizes elements as the same or distinct across multiple updates of your app.
(翻訳)Identityは、SwiftUIがアプリの複数の更新にわたって同じまたは異なる要素を認識する方法です。
つまり、Viewの要素が同一かどうかを識別するものです。
View Identityには2種類あります。Explicit IdentityとStructural Identityです。
Explicit Identity
明示的な値によって管理されるIdentityです。
Explicit Identityとして指定している値が変化すると、Viewが別の要素として認識されます。
文字通り、明示的に指定された場合のみ Explicit Identityを持つと考えられます。(コンポーネントが内部的に行っている場合はあるかもしれません)
.id
@State var id = 1
var body: some View {
VStack {
Text("Hello World")
.id(id)
Button {
id += 1
} label: {
Text("change id")
}
}
}
ボタンを押すとid
が変化するため、Text
が別のIdentityに変化します。
Text("Hello World").id(1)
とText("Hello World").id(2)
が別の要素としてSwiftUIに認識されるというのが重要なポイントです。
ForEach(_:id:content:)
@State var messages = ["A", "B"]
var body: some View {
VStack {
ForEach(messages, id: \.self) { message in
Text(message)
}
}
}
ForEachは以下のように展開されていると考えられます。
var body: some View {
VStack {
Text(messages[0])
.id(messages[0])
Text(messages[1])
.id(messages[1])
}
}
Structural Identity
View構造と型によって管理されるIdentityです。Explicit Identityは明示的に利用する一方で、Structural IdentityはView構造と型によって管理されるため、全てのViewに存在するIdentityだと言えます。
Structural Identityを一言で言うと、Viewをn回書いたらn個別々のIdentityを持つ=n個実態が作られるです。より具体的に言うと、(body
等の)Viewの型によって管理され、型情報上で別のViewと表現されているViewは別のIdentityを持ちます。
まず、このコードで2回書いたHogeViewがそれぞれ別のIdentityを持ち、別々の実態として動作することは直感的に理解できるかと思います。
var body: some View {
VStack {
HogeView()
HogeView()
}
}
type(of: self.body)
を出力してbody
の型情報を見てみましょう。
var body: some View {
VStack {
HogeView()
HogeView()
}
.onAppear {
print(type(of: self.body))
}
}
VStack<TupleView<(HogeView, HogeView)>>
(本来は.onAppear
で出力しているのでModifiedContent<..., _AppearanceActionModifier>
が出力されていますが、出力を加工しています)
TupleView<(HogeView, HogeView)>
のようにHogeView
が2回出現しています。
これは、2つのHogeView
がそれぞれ別のIdentityを持つことを意味します。
Structural Identityはこれが全てです。
例えレイアウト上で表示される位置が同じでも、見た目が同じでも、型が同じでも、引数等の値が同じでも、ViewBuilder上で2回HogeView()
と書いたら、それぞれは別々のIdentityを持ち、別々の要素として認識され、実態も二つ持ちます。
Viewをn回書いたらn個別々のIdentityを持つ が全てで新しい法則は出てきませんが、ifやswitchについてもみていきましょう。
if
Viewをn回書いたらn個別々のIdentityを持つに基づき、AとBのHogeViewは別のIdentityとして管理されます。
var body: some View {
if condition {
// A
HogeView()
} else {
// B
HogeView()
}
}
body
の型
_ConditionalContent<HogeView, HogeView>
HogeView
それぞれが別のIdentityを持つことがわかります。
例えレイアウト的に、同じ位置に同じ見た目のViewだったとしても、ifを使っている以上、body
の型情報には影響しないため、別のIdentityとして管理されます。
また、computed property
やfunction
を利用して呼び出しを共通化しても、生成されるbodyは同じなので、別のIdentityとして管理されるのに変わりありません。(これは、SwiftUIに関係なくcomputed property
やfunction
の挙動として理解できると思います。)
var body: some View {
if condition {
content
} else {
content
}
}
var content: some View {
HogeView()
}
body
の型
_ConditionalContent<HogeView, HogeView>
_ConditionalContent
という型がでてきました。これは条件分岐を表現する型で、_ConditionalContent<Trueの時, Falseの時>
という表現になります。
switch
Viewをn回書いたらn個別々のIdentityを持つに基づき、swtichの各分岐も別のIdentityとして管理されます。
enum Kind {
case a
case b
case c
}
@State private var kind: Kind = .a
var body: some View {
switch kind {
case .a:
HogeView()
case .b:
HogeView()
case .c:
HogeView()
}
}
body
の型
_ConditionalContent<_ConditionalContent<HogeView, HogeView>, HogeView>>
switchも_ConditionalContent
で表現されています。
ViewBuilderの出力から見ると、ifとswitchに本質的な違いはなさそうです。
ここが大事なんですが、switchでenumのcaseをまとめて記述すると、Identityを同一にできます。(これもViewをn回書いたらn個別々のIdentityを持つに基づいています。enumの.a, .bでHogeView
を共通化できているので理解できる挙動かと思います。)
var body: some View {
switch kind {
case .a, .b:
// enumがa<->bで変化しても、Identityに変化なし
HogeView()
case .c:
HogeView()
}
}
body
の型
_ConditionalContent<HogeView, HogeView>
_ConditionalContent
の分岐が一つ減り、enumの.a, .b間ではView Identityが共通なことが理解できると思います。
また、全てのcaseをまとめて書くと_ConditionalContent
すら消えるのも面白い点です。
var body: some View {
switch kind {
case .a, .b, .c:
HogeView()
}
}
body
の型
HogeView
View Identityはどんな影響を与えるか
Identityがどんな物かを理解したところで、「じゃあIdentityってどんな影響を与えるの?」という話です
状態(State, StateObject)はIdentityごとに管理される
以下の状態を持つCounterViewを考えましょう。
struct CounterView: View {
@State var count = 0
var body: some View {
Button {
count += 1
} label: {
Text(count.description)
.font(.title)
}
}
}
Explicit Identity
@State var id = 1
var body: some View {
VStack {
CounterView()
.id(id)
Button {
id += 1
} label: {
Text("change id")
}
}
}
"change id"のボタンを押すと、カウンタがリセットされます。
これは、CounterView
のExplicit Identityを変更しており、Identityごとに管理されている状態(@State
)が削除されるためです。
また、ViewのライフサイクルもIdentityによって管理されているため、Explicit Identityの変更によってonAppear
, onDisappear
も呼ばれます。
(Identityの変更によってViewが別の要素に変化したと考えると、理解できる挙動かなと思います)
@State var id = 1
var body: some View {
VStack {
CounterView()
.onAppear {
print("appear")
}
.onDisappear {
print("disappear")
}
.id(id)
Button {
id += 1
} label: {
Text("change id")
}
}
}
なお、.id()
の外側に書くと呼ばれません。Identityが変化しているのは.id()
の内側のViewのみということがわかります。
@State var id = 1
var body: some View {
VStack {
CounterView()
.id(id)
.onAppear {
print("appear")
}
.onDisappear {
print("disappear")
}
Button {
id += 1
} label: {
Text("change id")
}
}
}
Structural Identity
@State var condition = true
var body: some View {
VStack {
if condition {
CounterView()
} else {
CounterView()
}
Button {
condition.toggle()
} label: {
Text("toggle")
}
}
}
"toggle"
のボタンを押すと分岐の一方のViewが見えなくなり、他方のViewが見えることで状態がリセットされたように見えます。分岐間で状態は共有されません。なぜならIdentityが異なるからです。
Identityが変わると該当Viewが「完全に再描画」される
Identityが違うViewは別の要素として扱われるため、例え見た目が完全に同じでも、「完全に再描画」 されます。
「完全に再描画」というのは、「コンポーネントを新しく0から生成し、画面に表示する」 という意味です。UIKitのレイヤーの話をすると、「該当UIKitのコンポーネントのインスタンスを新しくinit
してaddSubView
する」 処理に相当します。
「完全に再描画」と「描画のアップデート」は違う
SwiftUIには 「描画のアップデート」 も存在します。一般的にSwiftUIで「再描画」と言われたらこちらをイメージすると思います。これはViewで用いている@State
の値が更新された時に起きる挙動で、 「コンポーネントの必要なパラメーターを変更して表示をアップデートする」 という意味です。UIKitのレイヤーの話をすると、 「該当UIKitのコンポーネントの必要なパラメーターを変更する」 処理に相当します。
例えば、以下のようなViewでcount
を増やした際にボタンのテキストが0->1->2と増えていくと思います。この際は「描画のアップデート」が行われています。
@State var count = 0
var body: some View {
Button {
count += 1
} label: {
Text(count.description)
}
}
「完全に再描画」と「描画のアップデート」は違うと言える根拠
以下のような描画が重いViewを用意します。
これはScrollViewで1万個のTextを表示するViewです。各Textで表示する数字に足す変数adding
を親Viewから渡すことができます。
struct HeavyView: View {
let adding: Int
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(0..<1_000_0, id: \.self) { index in
Text((index + adding).description)
}
}
}
}
}
Identityを変更する例
Explicit Identity
とStructural Identity
の例を両方用意しましたが挙動はほぼ同じです。
ボタンを押すとHeavyView
に渡すadding
を増やし、HeavyViewの表示を更新します。Explicit Identity
では.id(adding)
とすることで、Structural Identity
ではifを用いることでIdentityを変更します。
struct HeavyViewCheckExplicitIdentityChange: View {
@State var adding = 0
var body: some View {
VStack {
HeavyView(adding: adding)
.id(adding)
Button {
adding += 1
} label: {
Text("adding")
}
}
}
}
struct HeavyViewCheckStructuralIdentityChange: View {
@State var adding = 0
@State var condition = true
var body: some View {
VStack {
if condition {
HeavyView(adding: adding)
} else {
HeavyView(adding: adding)
}
Button {
adding += 1
condition.toggle()
} label: {
Text("adding")
}
}
}
}
値を更新する例
HeavyView
に純粋にadding
を渡すだけです。
struct HeavyViewCheck: View {
@State var adding = 0
var body: some View {
VStack {
HeavyView(adding: adding)
Button {
adding += 1
} label: {
Text("adding")
}
}
}
}
Xcode>Open Developer Tool>Instruments>Time Profiler を用いてそれぞれのパフォーマンスを測定します。
Identityを変更 | 値を更新 |
---|---|
ボタンを押した際のHangに着目すると
- Identityを変更: 1.43s
- 値を更新: 538.27ms≒0.54s
と、これらの再描画に明らかな違いがあることがわかります。
実際の画面でも、Hangの長さの違いがわかると思います。
Identityを変更 | 値を更新 |
---|---|
以上より、
- Identityを変更->「完全に再描画」: コンポーネントを0から生成し直し、画面に表示する
- 値を更新->「描画のアップデート」: コンポーネントの必要なパラメーターを更新し、画面表示をアップデートする
という違いがあると考えられます。
「完全に再描画」だとScrollView
とText
1万個のインスタンスを再生成しているのに対し、「描画のアップデート」だと既に表示されているText
の文字列パラメーターを変更し、表示をアップデートするだけなので、描画コストが比較的低く済んでいると推測できます。
今回は差を証明するために極端な例を採用しましたが、実際の開発でユーザーが知覚できる違い (60FPS=16ms以上, 120FPS=8ms以上) かどうかは場合によると思います。ただ、差があることは事実なので、可能な限りIdentityの変更は避け、「描画のアップデート」で済むように書くのが良いと思います。
存在しなくなったIdentityに紐づく状態・Viewは即削除される
A, Bの二つのIdentityがあったとします。
A->Bと変化した時点でAの状態やViewは削除されるので、A->B->Aと後から戻ってきても状態は残っていませんし、Viewの描画時にキャッシュなどは効きません。つまり毎回「完全に再描画」されます。
Explicit Identityの例
@State var id = 1
var body: some View {
VStack {
CounterView()
.id(id)
Text("id: \(id)")
HStack {
Button {
id -= 1
} label: {
Text("-")
.font(.title)
}
Button {
id += 1
} label: {
Text("+")
.font(.title)
}
}
}
}
Structural Identityの例
@State var condition = true
var body: some View {
VStack {
if condition {
CounterView()
.foregroundColor(.red)
} else {
CounterView()
.foregroundColor(.yellow)
}
Button {
condition.toggle()
} label: {
Text("toggle")
}
}
}
アニメーション中はIdentityが延命される(っぽい)
アニメーションをつけると、そのアニメーション中にA->B->Aと戻ってこれば状態は維持できます。アニメーション終了後だとダメです。
これはSwiftUIがアニメーションにIdentityを利用しているのが影響していると思います。
@State var condition = true
var body: some View {
VStack {
if condition {
CounterView()
.foregroundColor(.red)
} else {
CounterView()
.foregroundColor(.yellow)
}
Button {
withAnimation(.linear(duration: 1.0)) {
condition.toggle()
}
} label: {
Text("toggle")
}
}
}
Listは最新1個のIdentityをキャッシュする(っぽい)
これは意味がわからないです。ListはSwiftUIのコンポーネントの中でも特に謎の挙動をするので、「Listは内部でゴチャゴチャやっている」と覚えておくとバグを踏んだ時に助けになるかもしれません
@State var id = 0
var body: some View {
List {
CounterView()
.id(id)
Text("id: \(id)")
HStack {
Text("-")
.font(.title)
.onTapGesture {
id -= 1
}
Text("+")
.font(.title)
.onTapGesture {
id += 1
}
}
}
}
ちなみにList(datas) { data in }
やList { ForEach(datas) { data in } }
ではこの現象は起きません。List { }
とList(datas) { data in }
は内部でやっていることが違うようです。
(考察)Identity = Structural & Explicit なのでは?
今まで、「Explicit Identityの場合」「Structural Identityの場合」と、排他的であるかのような構造で挙動を確認してきましたが、これらは排他的ではないと考えています。さらに、Structural Identityの方が上位だと考えています。
つまり、Identityの同値チェックは以下のようなロジックで行われているのではないかと考えています。
// 擬似コード
extension View.Identity {
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.structural == rhs.structural && lhs?.explicit == rhs?.explicit
}
}
根拠1: IDView
.id()
の型はIDView
という非公開の型になっています。
var body: some View {
Text("Hello World")
.id(1)
}
bodyの型
IDView<Text, Int>
swiftinterface(./Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator/prebuilt-modules/16.4/SwiftUI.swiftmodule/arm64e-apple-ios.swiftinterface
)
でIDViewを検索すると、以下がヒットしました。
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@usableFromInline
@frozen internal struct IDView<Content, ID> : SwiftUI.View where Content : SwiftUI.View, ID : Swift.Hashable {
@usableFromInline
internal var content: Content
// idを持っている
@usableFromInline
internal var id: ID
@inlinable internal init(_ content: Content, id: ID) {
self.content = content
self.id = id
}
@usableFromInline
@_Concurrency.MainActor(unsafe) internal var body: Swift.Never {
get
}
@usableFromInline
internal typealias Body = Swift.Never
}
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension SwiftUI.View {
// .id()の実装
@inlinable public func id<ID>(_ id: ID) -> some SwiftUI.View where ID : Swift.Hashable {
return IDView(self, id: id)
}
}
IDView
がvar id: ID
を持っていることがわかりますし、.id()
を呼ぶとIDView(self, id: id)
が返されることがわかります。
一般的なmodifierはModifiedContent
となる一方で、わざわざIDView
を作っているのはこのIDView
がExplicit Identityを管理しているのではないでしょうか。Explicit Identityを保持しているIDView
が型で表現されているということは、Structural Identityの概念の上に存在しているということになります。
以上から、Explicit Identityは同一のStructural Identityの中でのidなのではないでしょうか。
根拠2: Structural Identityを跨いでExplicit Identityを適応できない
if分岐のそれぞれにCounterView
を定義し、同じExplicit Identityを持たせても、状態は共有されません。(ifの切り替え時に.id(1)
が一瞬存在しなくなることで、状態が消えている可能性を排除するため、アニメーションによってIdentityの生存期間を延長していますが、それでもダメです。)
これも、Explicit Identityは同一のStructural Identityの中でのidと考えられるのではないでしょうか。
@State var condition = true
var body: some View {
VStack {
if condition {
CounterView()
.id(1)
} else {
CounterView()
.id(1)
}
Button {
withAnimation(.linear(duration: 1.0)) {
condition.toggle()
}
} label: {
Text("toggle")
}
}
}
反例: ForEach
ForEachを用いると、上記の根拠の反例となる挙動を作れます。
まず、以下のような二つのViewを用意します。
-
ForEachCheckA
:[1, 2]
の配列をForEach
で展開するView -
ForEachCheckB
:[1, 2]
の配列をForEach
を使わずに記述し、.id()
を用いてExplicit Identityを付与したView
また、ボタンを押すと[1,2]
が[2,1]
に反転します。
struct ForEachCheckA: View {
@State var numbers = [1, 2]
var body: some View {
VStack {
ForEach(numbers, id: \.self) { number in
CounterView()
}
Button {
withAnimation {
numbers.reverse()
}
} label: {
Text("reverse")
}
}
}
}
struct ForEachCheckB: View {
@State var numbers = [1, 2]
var body: some View {
VStack {
CounterView()
.id(numbers[0])
CounterView()
.id(numbers[1])
Button {
withAnimation {
numbers.reverse()
}
} label: {
Text("reverse")
}
}
}
}
ForEachCheckA | ForEachCheckB |
---|---|
ForEach
で記述すると、状態を保持したままカウンターの位置が入れ替わる(!!)一方でForEach
なしで記述すると、状態が消えます。
(冒頭で触れた、ForEach
と、ForEach
を使わず.id()
を付与するViewが同等ではない、という話もこの挙動からわかります。)
ForEach
は内部に複数のViewを持っていると考えられるので、Viewをn回書いたらn個別々のIdentityを持つに基づいて考えれば、それらのViewは別々のIdentityを持つはずです。つまり、この例ではStructural Identityを跨いでExplicit Identityを適応できていることになります。
一方で「ForEach
が例外なんだ」とご都合主義な解釈をすれば、
ForEach
内では特別にStructural Identityが一つの扱いになっているのではないか? (もしくは、Explicit Identityの影響を特別に強くしている)という考え方もできると思います。なお、List
でも同様の挙動が起きます。
これは完全にただの考察ですので、他に面白い根拠や反例があればぜひ教えてください。
(考察)SwiftUIの描画ロジック
「完全に再描画」と「描画のアップデート」の2種類があるという話をしました。
これをもう少し掘り下げて、SwiftUIの描画ロジックについて考察したいと思います。
「完全に再描画」をするかどうかの判断に使われるのがIdentityでしたが
同一のIdentityのViewが見つかった後、「描画のアップデート」をするかどうかの判断にはEquatable
が使われます。Viewのプロパティが全てEquatable
の場合なら、Equatable
に明示的に準拠していなくても同様の挙動します。
A view type that compares itself against its previous value and prevents its child updating if its new value is the same as its old value.
(翻訳)自分自身と以前の値を比較し、新しい値が古いと同じ場合、子要素の更新を防止するViewの型です。
SwiftUIのViewの描画ロジックは以下のようになっていると考えています(想像です)。
-
Identityが同値か?:
- Identityが変化していない場合はViewの同値判定へ移ります。
- 変化している場合は子Viewも含めViewを完全に再描画します。
-
Viewが同値か?: Equatableによる判定をします。
- 同値の場合は描画のアップデートなしとして判定を終了します。この場合はプロパティを見るだけで
body
は呼び出されません。 - 同値ではない(または
Equatable
ではなく比較ができない)場合はViewのbody
を呼び出して次のステップに進みます。
- 同値の場合は描画のアップデートなしとして判定を終了します。この場合はプロパティを見るだけで
- 描画実態を持つか?: 描画実態を持つView(後述)の場合は自身の描画のアップデートを行います。
-
子Viewの評価へ再帰:
- bodyに子Viewが存在する場合は全ての子Viewに対して1〜4のViewの評価を実行します(再帰)。
- 存在しない場合は(再帰を)終了します。
ここで大事なポイントは Equatable
の評価は再帰的にViewごとに行われるため、親のbodyが呼び出されたからといって、子のbodyが呼び出されているとは限らない という点です。(一方でIdentityの変化は子を含め完全に再描画します)
描画実態を持つ・持たないView
「描画実態を持つView」 というのは、(私が作った言葉ですが)以下の2種類のViewのことを指しています。
-
Text
,Image
,Color
,VStack
などのSwiftUIから提供されているView全般 -
UIView(Controller)Representable
によって作られたView
これらのViewは実際の描画を管理するViewになっており、これらに渡すパラメーターが変更されたら実際に描画に影響します。
「描画実態を持たないView」 というのは、以下のようなViewのことを指しています。
以下のChildView
は描画実態を持ちません、描画実態を持っているのはChildView
の子ViewであるText
であって、ChildView
ではないです。(と定義します)
struct ChildView: View {
let message: String
var body: some View {
Text(message)
}
}
body
が呼ばれたこと自体は描画に影響しない
描画実態を持たないViewのこういう定義(見方)をすると何が嬉しいのかというと
描画実態を持たないViewのbody
が呼ばれたこと自体は描画に影響しない という見方ができます。
以下の例を見てみましょう。ChildView
は受け取ったlet message: String
をText
で表示するだけのViewです。Equatable
での判定をfalse
にするために、適当な値flag: Bool
を定義しています。
struct ChildView: View {
let flag: Bool
let message: String
var body: some View {
let _ = Self._printChanges()
Text(message)
}
}
struct ParentView: View {
@State var condition = true
var body: some View {
VStack {
ChildView(flag: condition, message: "Hello")
Button {
condition.toggle()
} label: {
Text("toggle")
}
}
}
}
ParentView
でmessage
は定数値にし、flag
だけを変えます。この時、ChildView
のbody
が呼ばれlet _ = Self._printChanges()
は出力されますが、これ自体は描画がアップデートされていることを示すわけではありません。
なぜなら、body
が呼び出されているのは描画実態を持たないChildView
であって、描画実態を持つText
ではありません。
また、実際に、Text
はEquatable
に準拠しており、message
が変化していないため、 Equatable
の判定によってTextのbody
は呼び出されておらず、Text
の描画もアップデートされていません。 結果、全体としても描画はアップデートされていないということになります。
このように、描画実態を持たないView(自作Viewの大半)のbody
が呼びされても描画には影響しません。描画に影響するのは、描画実態を持つViewのbody
が呼び出された時です。
重要なのは、「Viewのbody
が呼ばれる」≠「描画がアップデートされる」=「描画実態を持つViewのbody
が呼ばれる」 という点です。
ただ、body
の呼び出しを全く気にしなくて良いというわけではありません。 描画に直接影響しなくとも、body
はメインスレッドで呼ばれるため、メインスレッドのCPU時間を消費し、描画エンジンが利用できるCPU時間が減ることで結果的に描画にも影響します。
実践: View Identityを意識してレイアウトを組もう
以上の知識を踏まえて、どのようにレイアウトを組むべきか見ていきます。
Explicit Identityにはuniqueかつstableなidを使おう
ForEach
などに用いるidは unique(重複しない) かつ stable(変化しない) 物にしましょう。
メッセージを一覧表示する画面を考えます。
NotGood: uniqueじゃない
@State var message = ""
@State var messages = ["Hello", "World", "Good"]
var body: some View {
List {
ForEach(messages, id: \.self) { message in
Text(message)
}
TextField("new message", text: $message)
Button {
withAnimation {
messages.insert(message, at: 0)
}
message = ""
} label: {
Text("add")
}
}
}
messageはユーザーが任意に入力できるので、uniqueではないです。
同じ文字列を入力した際以下のようなwarningが出力される上、アニメーションも不安定になります。
ForEach<Array<String>, String, Text>: the ID Hello occurs multiple times within the collection, this will give undefined results!
Not Good: stableじゃない
var body: some View {
List {
ForEach(messages.indices, id: \.self) { index in
Text(messages[index])
.listRowRandomColor()
}
TextField("new message", text: $message)
Button {
withAnimation {
messages.insert(message, at: 0)
}
message = ""
} label: {
Text("add")
}
}
}
messageはuniqueじゃないので、indexを使うことにしました。これはuniqueですが、stableではありません。メッセージを上部に追加すると、indexがズレるので、メッセージに対してidが変化しています。
(メッセージをローカルで追加するケースだけでなく、APIから新規メッセージを取得する場合も同様の挙動が発生します。)
index(id) message
1 Hello
2 World
3 Good
↓ Byeを追加
1 Bye
2 Hello // idが変化
3 World // idが変化
4 Good // idが変化
これだと、追加したメッセージの部分だけ描画をアップデートをすれば良いのに、メッセージに対してidが変化するため、すべての行で描画のアップデートが必要になります。
すべての行で描画のアップデートが行われている様子
以上より、表示する内容がuniqueではない場合は、UUID
などのuniqueなIDを別途用意して、idとして利用しましょう。
Good
struct Message: Identifiable {
var id: UUID
var text: String
init(id: UUID = UUID(), text: String) {
self.id = id
self.text = text
}
}
struct ForEachDiffGood: View {
@State var message = ""
@State var messages: [Message] = [Message(text: "Hello"), Message(text: "World"), Message(text: "Good")]
var body: some View {
List {
ForEach(messages) { message in
Text(message.text)
}
TextField("new message", text: $message)
Button {
withAnimation {
messages.insert(Message(text: message), at: 0)
}
message = ""
} label: {
Text("add")
}
}
}
}
新しいメッセージのみが描画のアップデートをされている様子
実際の開発ではAPIやDBなどを用いるケースの方が多く、そこにidが存在するケースが多いと思いますので、実際に強く意識することは少ないかもしれません。
カスタマイズはifではなくmodifier内の三項演算子で行おう
Viewの見た目をカスタマイズする際は極力ifを使わず、modifier内の三項演算子で行いましょう。
そもそも、Structural Identityの挙動からわかる通り、ifという物は「二つの異なるViewを出し分ける」物であって、「一つのViewの見た目を変える」物ではないです。
Not Good
var body: some View {
if condition {
CounterView()
.foregroundColor(.blue)
.fontWeight(.bold)
} else {
CounterView()
.foregroundColor(.red)
}
}
bodyの型
_ConditionalContent<ModifiedContent<ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>, _EnvironmentKeyTransformModifier<Array<AnyFontModifier>>>, ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>>
Good
var body: some View {
CounterView()
.foregroundColor(condition ? .blue : .red)
.fontWeight(condition ? .bold : .regular)
}
bodyの型
ModifiedContent<ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>, _EnvironmentKeyTransformModifier<Array<AnyFontModifier>>>
modifiler内の三項演算子はViewBuilder
が生成する型に影響を与えないので_ConditionalContent
がなくなっているのがわかります。
後者の方が良い理由は二つあります。
- Identityが変わると状態が消えてしまうから
- Viewの完全な再描画が行われてしまうから
です。今回の例では内部に状態(@State
)を持つCounterView
の例ですが、状態を持たない場合も後者の影響があるので極力modifier内の三項演算子で行った方が良いです。
if { self } しているmodifierは危険
これについては以前書いた記事がありますのでそちらも参照
先ほどの例で「いやーwwwそんなifでカスタマイズしないよw」と思った方も、これによって気づかない内に同様のことを行なっている可能性があります。
extension View {
@ViewBuilder
func `if`<Content: View>(
_ condition: Bool,
@ViewBuilder transform: (Self) -> Content
) -> some View {
if condition {
transform(self)
} else {
self
}
}
@ViewBuilder
func `if`<TrueContent: View, FalseContent: View>(
_ condition: Bool,
@ViewBuilder _ then: (Self) -> TrueContent,
@ViewBuilder `else`: (Self) -> FalseContent
) -> some View {
if condition {
then(self)
} else {
`else`(self)
}
}
}
このexntesionは以下のように条件に応じてmodifierを切り替えることができる物です。
var body: some View {
CounterView()
.if(condition) { view in
view
.foregroundColor(.blue)
.fontWeight(.bold)
} else: { view in
view
.foregroundColor(.red)
}
}
一見、Viewをn回書いたらn個別々のIdentityを持つ に基づくと、CounterView
は1回しか書いてないのでIdentityは一つにように見えます。
ここで、bodyの型を出力してみましょう。
_ConditionalContent<ModifiedContent<ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>, _EnvironmentKeyTransformModifier<Array<AnyFontModifier>>>, ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>>
見てわかるとおり、_ConditionalContent
が出現しており、ifでCounterViewを2回記述した時と同じ型になっていることがわかります。
それもそのはずで、.if
のextensionで以下のようにself
をif分岐でラップしているためです。
if condition {
then(self)
} else {
`else`(self)
}
先ほどの例ではself
がCounterView
に相当するため、
CounterView()
.if(condition) { view in
} else: { view in
}
が
if condition {
CounterView()
} else {
CounterView()
}
に展開されていると言うことです。
また、以下のようなextensionを利用時にifを利用した場合も同様です。
func extend<Content: View>(@ViewBuilder transform: (Self) -> Content) -> some View {
transform(self)
}
CounterView()
.extend {
if condition {
$0.foregroundColor(.blue)
.fontWeight(.bold)
} else {
$0.foregroundColor(.red)
}
}
Structural Identityの本質は**ViewBuilder
の中でViewを複数回記述すること** です。
それがmodifierの中やクロージャーの中にあっても、結果は同じです。
Structural Identityが内部で変化するようなmodifierは、Structural Identityが変化していることを利用側から隠蔽する危険なmodifierであると言えます。
可能な限り使わない方が良いと言えます。
三項演算子が使えないケース
modifierの中には引数が存在しない等で三項演算子が使えない物もあります。
var body: some View {
// 特定のケースではonDragを無効化したいが`isActive:`がない
// onDrag { if {} }では、UIでドラッグ自体はできてしまう
Text("aaa")
.onDrag { data in
...
}
}
こういったケースになって初めて ifを使うことを検討しましょう。
もちろん、状態の初期化と完全な再描画は起きていますので、それらを妥協できる場合に限ります。
(これはSwiftUIが未熟でよくない点ですね)
var body: some View {
// isActive: がない
if condition {
Text("aaa")
.onDrag { data in
...
}
} else {
Text("aaa")
}
}
また、このケースになって初めて modifier内でifを使うextensionを記述する選択をしても良いかなと思います。
危険なmodifierであることに変わりないので注意は必要ですが、onDrag
の無効化はifを使うことでしか実現できないので、このケースでは便利なmodifierであると言えます。
@ViewBuilder
func onDrag(isActive: Bool, data: @escaping () -> NSItemProvider) -> some View {
if isActive {
self.onDrag(data)
} else {
self
}
}
if, switchの分岐は可能な限り統合しよう
「異なるViewを出し分ける」際にはif,switchを使う必要がありますが、その際にも注意すべき点があります。
可能な限り分岐を統合して、Structural Identity
を統合しましょう。
Not Good
enum UIState {
case idle
case loading
case success
case failure
}
@State var uiState: UIState = .idle
var body: some View {
switch uiState {
case .idle:
ProgressView()
case .loading:
ProgressView()
case .success:
Text("success")
case .failure:
ErrorStateView()
}
}
_ConditionalContent<_ConditionalContent<ProgressView<EmptyView, EmptyView>, ProgressView<EmptyView, EmptyView>>, _ConditionalContent<Text, ErrorStateView>>
Good
var body: some View {
switch uiState {
case .idle, .loading:
ProgressView()
case .success:
Text("success")
case .failure:
ErrorStateView()
}
}
_ConditionalContent<_ConditionalContent<ProgressView<EmptyView, EmptyView>, Text>, ErrorStateView>
_ConditionalContent
が一つ減り、.idle
と.loading
のIdentityが統合できていることがわかります。
例: 状態管理に用いるenum
↓のようなenumを画面の状態管理に用いるケースがあると思います。
enum UIState<V, E: Error> {
case idle
case loading
case success(V)
case failure(E)
}
「再読み込み時ににも前回の結果を表示する」という仕様になったとしましょう。
この時、2通りの実装方法が考えられると思います。
A: loading(V?)
enum UIState<V, E: Error> {
case idle
case loading(V?)
case success(V)
case failure(E)
}
B: reLoading(V)
enum UIState<V, E: Error> {
case idle
case initialLoading
case reLoading(V)
case success(V)
case failure(E)
}
A: loading(V?)
@State private var uiState: UIState<String, any Error> = .idle
var body: some View {
VStack {
switch dataState {
case .idle, .loading(nil):
EmptyView()
case .success(let text),
.loading(let text?):
Text(text)
case .failure:
ErrorStateView()
}
}
.overlay {
if uiState.isLoading {
ProgressView()
}
}
}
再読み込み時に「完全な再描画」を発生させないために、.loading
と.success
を同一caseに記述してStructural Identityを統合する必要があります。 しかし、.loading
と.success
のAssociated Valueの型が異なるため .loading(nil)
と.loading(let text?)
に分けて記述する必要があります。
以下のような書き方は再読み込みをするたびに該当Viewを「完全に再描画」しており、パフォーマンス的にも悪いし、状態があった場合は状態が消えるので良くない実装といえます。
var body: some View {
switch dataState {
case .idle:
EmptyView()
case .loading(let text):
if let value {
Text(text)
}
case .success(let text):
Text(text)
case .failure:
ErrorStateView()
}
}
B: reLoading(V)
var body: some View {
VStack {
switch dataState {
case .idle, .initialLoading:
EmptyView()
case .success(let text),
.reLoading(let text):
Text(text)
case .failure:
ErrorStateView()
}
}
.overlay {
if uiState.isLoading {
ProgressView()
}
}
}
.reLoading(V)
の場合も同様です。こちらは.initialLoading
と.reLoading
にそもそも分かれており、.reLoading(V)
とsuccess(V)
とAssociated Valueの型が同じなので同一caseに通常通り記述できます。
@swiftty さんのコメントにより修正 .loading(nil)
.loading(text?)
と記述できるのを知らず、A: loading(V?) をアンチパターンとして記載しておりましたが、こちらを使えばAでも良さそうなので、修正しました。
分岐の位置を工夫して「完全に再描画」の影響範囲を減らそう
「異なるViewを出し分ける」際には他にも注意すべき点があります。
それは分岐位置を工夫して、分岐の影響範囲を小さくすることです。
Not Good
@State var isLoading = false
@State var datas: [String] = ...
var body: some View {
if isLoading {
ProgressView()
} else {
List(datas, id: \.self) { data in
Text(data)
}
}
}
Good
var body: some View {
List {
if isLoading {
ProgressView()
} else {
ForEach(datas, id: \.self) { data in
Text(data)
}
}
}
}
もしくは
var body: some View {
List {
ForEach(datas, id: \.self) { data in
Text(data)
}
}
.overlay {
if isLoading {
ProgressView()
}
}
}
Not Goodの例だと、Listが分岐に含まれてしまっているため、ロードする度にList
を「完全に再描画」しています。(存在しなくなったIdentityに紐づくViewや状態はキャッシュされないので、毎回Listのインスタンスを再生成しています。)
UIKitのレイヤーで言うと、ロードする度にUICollectionView
をremoveFromSuperView
してdeinit
し、その後新しいインスタンスをinit
してaddSubView
しています。
Goodの例は、List内部もしくはListに被せたViewで分岐が起きているため、Listの「完全に再描画」は起きません。UIKitのレイヤーで言うと、UICollectionView
の中のViewを操作しているだけで、Not Goodに比べコストが低いと言えます。
頻繁に起こる分岐の中に状態を持たないようにしよう
頻繁に起こる分岐の中に状態をもつViewを配置せざるを得ない場合は、@Binding
等を用いて状態を分岐の外に出しましょう。
@State var condition = true
@State var count = 1
// 条件によってVStackとHStackを切り替えたい例
var body: some View {
if condition {
VStack {
Text("counter")
CounterView(count: $count)
}
} else {
HStack {
Text("counter")
CounterView(count: $count)
}
}
}
一方で、頻繁に起きない分岐や、分岐した際に状態が消えるのが意味的におかしくないケースなら分岐内に状態をもっても良いと思います。
// ログイン画面とログイン後の画面を切り替える例
var body: some View {
if isLogin {
MainScreen()
} else {
LoginScreen()
}
}
分岐を内部的に行うViewにも気をつけよう
分岐(Viewの出しわけ)を内部的に行うViewがいくつかあります。
これらはクロージャーで複数のViewを受け取り、条件に応じて出し分けるため、ほぼifやswitchと同等の挙動をします。
TabView
はその中の一つですが、TabViewの用途的にもそれぞれのViewが別のIdentityとして扱われるのは明確ですし、またTabViewは非表示状態のViewもキャッシュするので、状態が消える等問題になることは少ないかと思います。
var body: some View {
TabView {
HomeScreen()
PostScreen()
}
}
ViewThatFits
iOS16+から使える、レスポンシブレイアウトのようなレイアウトを組めるViewThatFitsというViewがあります。
以前書いたこちらの記事も参照
ViewThatFitsは、クロージャーで受け取ったViewのうち、サイズに収まる最初のViewを表示します。
以下の例では、Aがサイズに収まるならA、そうでないならBを表示します。
var body: some View {
ViewThatFits(in: .horizontal) {
// A
HStack {
Text("counter")
CounterView()
}
.frame(minWidth: 380)
// B
VStack {
Text("counter")
CounterView()
}
}
}
この時、画面回転やiPadのSlide Overなどを利用すると、画面サイズが変わるため、表示されるViewが切り替わる可能性があります。この時、ViewThatFits
の中に状態を持っていると、ifの時と同様に状態が消えてしまいます。
この場合も@Binding
などを利用して状態をViewThatFits
の外に出すと良いでしょう。
@State var count = 0
var body: some View {
ViewThatFits(in: .horizontal) {
HStack {
Text("counter")
CounterView(count: $count)
}
.frame(minWidth: 380)
VStack {
Text("counter")
CounterView(count: $count)
}
}
}
このように、分岐を内部的に行うViewを利用する際も、ifやswitchを使う際と同等の注意が必要です。
分岐を内部的に行うViewが特殊な例のように取り上げてしまいましたが、実はこれもViewをn回書いたらn個別々のIdentityを持つに基づいており、本質的には特段新しいことではありません。クロージャーに二つViewを書いているので、Identityが二つできて状態が共有されないのは当たり前かなと思います。
デバッグでView Identityの変更を検知する方法
Self._printChanges()
の@identity changed
Self._printChanges()
は公式から提供されているプライベートAPIです。
変更を検知したいViewのbodyにlet _ = Self._printChanges()
と記述します。
struct CounterView: View {
@State var count = 0
var body: some View {
let _ = Self._printChanges()
Button {
count += 1
} label: {
Text(count.description)
.font(.title)
}
}
}
struct PrintChangeCheck: View {
@State var condition = true
@State var message = ""
var body: some View {
VStack {
if condition {
CounterView(message: message)
} else {
CounterView(message: message)
}
Button {
condition.toggle()
} label: {
Text("toggle")
}
TextField("message", text: $message)
}
}
}
// 初回表示時
CounterView: @self, @identity, _count changed.
// message変更時
CounterView: @self changed.
// CounterViewのcount変更時
CounterView: _count changed.
// condition変更時
CounterView: @self, @identity, _count changed.
-
@self changed
: initが呼ばれた時(値が変更された時) -
_count changed
:@State
が変更された時 -
@identity changed
Identityが変更された時
という挙動になっていると推測できます。
この@identity changed
でIdentityの変更を検知できます。
が、Self._printChanges()
には問題があります
@State
が存在しないと@identity changed
が表示されない
struct TextView: View {
let text: String
var body: some View {
let _ = Self._printChanges()
Text(text)
}
}
struct PrintChangeCheck2: View {
@State var condition = true
@State var message = ""
var body: some View {
VStack {
if condition {
TextView(text: message)
} else {
TextView(text: message)
}
Button {
condition.toggle()
} label: {
Text("toggle")
}
TextField("message", text: $message)
}
}
}
// 初回表示時
TextView: @self changed.
// message変更時
TextView: @self changed.
// condition変更時
TextView: @self changed.
これでは、「完全に再描画」なのか「描画のアップデート」なのか区別がつきません。
型単位でしか利用できない
Self.
と付けることからわかるように、型単位でしか利用できません。
computed property
やfunction
に出したViewなど、任意の地点でのIdentityの変更を検知できません。
_highlightViewIdentityChanged
, _printViewIdentityChanged
Identityが変わるとStateが消えることを利用して、Identityが変わったタイミング(View「完全に再描画」されたタイミング)で
-
Viewを黄色にハイライトするmodifier:
_highlightViewIdentityChanged
-
printするmodifier:
_printViewIdentityChanged
の二つを作りました。
private struct PrintViewIdentityChanged: ViewModifier {
let message: String
@State private var loaded = false
func body(content: Content) -> some View {
content
.onAppear {
if !loaded {
print("identity changed: \(message)")
loaded = true
}
}
}
}
private struct HighlightViewIdentityChanged: ViewModifier {
@State private var loaded = false
func body(content: Content) -> some View {
content
.overlay {
if !loaded {
Color.yellow.opacity(0.5)
}
}
.onAppear {
withAnimation {
loaded = true
}
}
}
}
public extension View {
func _highlightViewIdentityChanged() -> some View {
modifier(HighlightViewIdentityChanged())
}
func _printViewIdentityChanged(_ message: String = "") -> some View {
modifier(PrintViewIdentityChanged(message: message))
}
}
これらを利用することで、@State
を持っていないViewでもIdentity変更の検知が可能です。
var body: some View {
VStack {
if condition {
TextView(text: message)
._highlightViewIdentityChanged()
._printViewIdentityChanged("text true")
} else {
TextView(text: message)
._highlightViewIdentityChanged()
._printViewIdentityChanged("text false")
}
Button {
condition.toggle()
} label: {
Text("toggle")
}
TextField("message", text: $message)
}
}
また、.id
でも検知可能な上、任意の場所・VStack
やText
など自分が作った型でなくとも利用可能です。
@State var count = 0
var body: some View {
VStack {
Text("\(count)")
._highlightViewIdentityChanged()
._printViewIdentityChanged("text \(count)")
.id(count)
Button {
count += 1
} label: {
Text("change id")
}
}
._highlightViewIdentityChanged()
._printViewIdentityChanged("VStack")
}
ぜひデバッグにお役立てください。
アンチパターンと解決案集
以下、自分が過去に目撃したことのあるView Identity的に良くないコードとその解決案を列挙します(思い出したら・見つけたら随時更新)
.redacted()
iOS14+でスケルトンViewを実装するためのmodifierです。
Not Good
@State var isLoading = ...
@State var data: (name: String, number: Int) = ...
let stub: (name: String, number: Int) = ("A", 1)
var body: some View {
List {
if isLoading {
content(data: stub)
.redacted(reason: .placeholder)
} else {
content(data: data)
}
}
}
@ViewBuilder
func content(data: (name: String, number: Int)) -> some View {
Text(data.name)
Text(data.number.description)
}
スケルトンViewと実際のViewの間でStructural Identityが別になっており、「完全に再描画」が起きています。
Good
var body: some View {
List {
content(data: isLoading ? stub : data)
.redacted(reason: isLoading ? .placeholder : [])
}
}
.redacted(reason: [])
は見た目に影響しないのでisLoading ? .placeholder : []
のように三項演算子を用いれば、スケルトンViewと実際のViewの間でViewのインスタンスを使いまわせます。
また、以下のようなextensionも同様にStrucutral Identityに影響しない形で記述できます。
Not Good
extension View {
@ViewBuilder
func skelton(isActive: Bool) -> some View {
if isActive {
self.redacted(reason: .placeholder)
} else {
self
}
}
}
Good
extension View {
func skelton(isActive: Bool) -> some View {
redacted(reason: isActive ? .placeholder : [])
}
}
Explicit Identityの時はどっちでも良い
以下のように、Text
がForEach
のExplicit Identityによって管理されている場合
ForEach
に渡しているstub
<->datas
間でidが変化することによってText
が「完全に再描画」されるため、ForEach
自体のStructural Identityを同一にしてもしなくても挙動は同じになります。
@State var isLoading = ...
@State var datas: [String] = ...
let stub = ["AAAAA", "BBBBB", "CCCC"]
var body: some View {
List {
if isLoading {
content(datas: stub)
.redacted(reason: .placeholder)
} else {
content(datas: datas)
}
// content(data: isLoading ? stub : data)
// .redacted(reason: isLoading ? .placeholder : [])
}
}
func content(datas: [String]) -> some View {
ForEach(datas, id: \.self) { data in
Text(data)
}
}
.hidden
Not Good
var body: some View {
if condition {
Text("hoge").hidden()
} else {
Text("hoge")
}
}
Good
var body: some View {
Text("hoge").opacity(condition ? 0 : 1)
}
.hidden
にはisActive: Bool
等の引数がないため、状況によって表示状態を切り替えたい場合に三項演算子が使えずif {}
を使う必要が出て、Identity的によくありません。
完全に挙動が同じかは分かりませんが、ほぼ同じ挙動の.opacity()
で代用できます。
.overlay(if:), .background(if:), .onChange(if:), etc..
以下はoverlayの例ですが、全部同じです。
if { self.xxx } else { self }
ではなく、.xxx { if {} }
で記述しましょう。
Not Good
@ViewBuilder
func overlay<Content: View>(if condition: Bool, content: Content) -> some View {
if condition {
self.overlay {
content
}
} else {
self
}
}
Good
func overlay<Content: View>(if condition: Bool, content: Content) -> some View {
overlay {
if condition {
content
}
}
}
参考文献
Discussion
とても有用な記事ありがとうございます!
一点こちらの enum の解説ですが、このようにすると A 案でも構造を揃えることができそうです!
コメントありがとうございます!!
この記法知りませんでした、、ありがとうございます、訂正しておきます!!