完全備忘録
最近、以下のextensionに出会いました。
extension Identifiable where Self: Hashable {
var id: Self { self }
}
これは、Identifiable
プロトコルに準拠する型がHashable
プロトコルを準拠する事によって適応されます。
そして、id
は型がSelf
(準拠する型)となって、以下のように返します。
struct Article: Identifiable, Hashable {
var title: String
}
let article = Article(title: "aaa")
print(article.id) // Article(title: "aaa")
このextensionによってIdentifiable
プロトコル、Hashable
プロトコルどちらの要求も満たす事ができます。
しかも、idを自分で指定しなくて良いから最初は便利だな〜と思って使おうとしていましたが以下の記事では使うことを推奨していませんでした。
今すぐ extension Identifiable where Self: Hashable をやめろ!
なぜ使ってはいけないのか
先ほどの記事を読んでみましたがまだまだSwiftUIの理解が乏しいため、あまり理解できませんでした...。
ただ、実際にコードを書きながら都度、調べながら何となく理解できました(認識が間違っているかもしれないので指摘して頂けるとありがたいです)。
前提
まず、前提としてSwiftUIのViewオブジェクトはStruct
で値型です。
なのでインスタンスを生成時には毎回、新しく生成されます。
struct Hoge {
var name: String
}
// hoge1, hoge2変数に対して、
//インスタンスのバイト列が直接、コピーされている
// つまり、変数に代入するたびに新しく生成される
var hoge1 = Hoge(name: "aaa")
var hoge2 = hoge1
hoge2.name = "bbbb"
print(hoge1.name) // aaa
print(hoge2.name) // bbb
そして、SwiftUIが登場する以前のUIKitではViewオブジェクトはclass
で参照型です。
参照型ではインスタンスを生成した際の挙動は値型と違い、インスタンス生成時はそのインスタンスを格納しているメモリアドレス
が格納されます。
class Hoge {
var name: String = "aaa"
}
// hoge1, hoge2変数に対して、インスタンスが格納されたメモリアドレスが代入される
var hoge1 = Hoge()
var hoge2 = hoge1
hoge2.name = "bbbb"
print(hoge1.name) // bbb
print(hoge2.name) // bbb
そして、この割り振られたメモリアドレスは一意
なので重複しません。
したがって、class
は同一のオブジェクトをインスタンスで識別することが可能
になります。
class HogeView {
// 何らかの処理
}
// view1, view2も同じメモリアドレスを参照しているのでHogeViewかどうか識別が可能
let view1 = HogeView()
let view2 = view1
ではSwiftUIではどうでしょうか?
SwiftUIは冒頭でも書きましたが、Viewオブジェクトはstructで値型
です。
値型は参照型と違って、インスタンス単位でそのViewオブジェクトが同一かどうかが分かりません、それはインスタンスを生成するたびに、毎回新しく生成されるから
です。
struct HogeView {
// 何らかの処理
}
// view1, view2も同じHogeViewだけど、コピーなので別物扱い
let view1 = HogeView()
let view2 = view1
これが記事内でも書いてた「データの代入するたびに新しいインスタンスが生成されるので、同一オブジェクトの比較をインスタンス比較でできなくなる
」ですかね。
なので、SwiftUIではViewオブジェクトを識別するためには明示的に
idを割り振る必要がありそうです。
検証
まずは、上記の記事のように2つの画面とArticle
を用意しました(ソースコードはほぼ同じなので割愛)。
そして、Viewの再描画を監視するために_printChanges()
を仕込んで問題箇所の修正前・修正後のアプリを実際に動かして比較していきたいと思います。
以下が実際の挙動と出力されたログです。
- 修正前
ArticleListView: @self, @identity, _articles changed.
ArticleDetailView: @self, @identity, _article changed.
ArticleListView: _articles changed.
- 修正後
ArticleListView: @self, @identity, _articles changed.
ArticleDetailView: @self, @identity, _article changed.
ArticleListView: _articles changed.
ArticleDetailView: @self, _article changed.
※ ログの見方は以下を参考にしました
View の更新トリガーを調べるために _printChanges() を利用する。
このログから分かるように、修正前では前画面のArticleListViewしか再描画されていません
。
修正後では、遷移先も再描画されている事が分かりますね。
不具合の原因
今回そもそも以下のコードが悪さをしていました。
extension Identifiable where Self: Hashable {
var id: Self { self }
}
例えば、このextensionではViewに対して以下のようなIdが割り振られるんでしたね。
Article(title: "kkahaad", body: "adddahdldid", like: false)
そして、記事では前画面でこのようなidをListの各要素に割り振って遷移先で状態を変更していました。
// 前画面
@State private var articles: [Article] = // 何らかの値
// 何らかの処理
NavigationView(content: {
List {
ForEach($articles) { article in
NavigationLink {
ArticleDetailView(article: article)
} label: {
//...
}
}
}
})
// 遷移先
@Binding var article: Article
// 何らかの処理
Button {
article.like.toggle()
} label: {
Image(systemName: article.like ? "heart.fill" : "heart")
}
したがって遷移先で状態を変更した事により、以下のようにidが変わってしまってSwiftUIがどのViewを再描画して良いかが分からなくなってしまい、前画面のViewだけが再描画され、遷移先のViewが再描画されなかった
という感じだと思われます。
Article(title: "kkahaad", body: "adddahdldid", like: false)
↓
Article(title: "kkahaad", body: "adddahdldid", like: true) // likeが変わった
画面遷移が起こった理由としては、調べてみると以下の記事が見つかりました。
【SwiftUI】Navigationlinkで遷移した先で値を更新すると、勝手に画面が戻る件
記事を参考にすると前画面が再描画された事により、NavigationLinkでDestination指定している遷移先の画面が破棄されるから
だそうですね。
まとめ
今考えると、状態は常に変化するのでView自身をidとして指定するのはかなり危ないですね。