実行時エラー「Bound preference ...」の回避方法
アプリ実行時に次章のエラーが発生し、回避策の発見までできました。
また原因の考察も行ったため記録も兼ねて記事にします。
発生したエラー
Bound preference MapScopeRegistryKey tried to update multiple times per frame.
再現方法
実行環境
macOS: Sequoia 15.7.2(24G325)
Xcode: Version 26.2 (17C52)
Device: iPhone15 (Simulator)
iOS: 18.6 (22G86)
最小再現可能例(MRE)
import SwiftUI
struct ContentView: View {
var body: some View {
MapCoordinatePickerView()
}
}
import MapKit
import SwiftUI
struct MapCoordinatePickerView: View {
@Namespace private var mapScope
@State private var cameraPosition: MapCameraPosition = .region(MKCoordinateRegion())
@State private var currentCenter: CLLocationCoordinate2D = CLLocationCoordinate2D()
var body: some View {
TabView {
Map(position: $cameraPosition, scope: mapScope) {
Annotation("title", coordinate: currentCenter) {}
}
.mapScope(mapScope)
.onMapCameraChange { context in
currentCenter = context.region.center
}
.onAppear {
cameraPosition = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(),
span: MKCoordinateSpan(
latitudeDelta: 0.05,
longitudeDelta: 0.05
)
)
)
}
}
}
}
画面表示直後に当該エラーが発生します。
あまり整然としてませんが、これ以上何かを削ると再現しません。
解決方法
上記コードで実行されている動作は残しつつ、当該エラーを回避する方法を3つ紹介します。
詳細は考察の章で記載します。
1 NavigationStackで囲う
import MapKit
import SwiftUI
struct MapCoordinatePickerView: View {
@Namespace private var mapScope
@State private var cameraPosition: MapCameraPosition = .region(MKCoordinateRegion())
@State private var currentCenter: CLLocationCoordinate2D = CLLocationCoordinate2D()
var body: some View {
TabView {
+ NavigationStack {
Map(position: $cameraPosition, scope: mapScope) {
Annotation("title", coordinate: currentCenter) {}
}
.mapScope(mapScope)
.onMapCameraChange { context in
currentCenter = context.region.center
}
.onAppear {
cameraPosition = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(),
span: MKCoordinateSpan(
latitudeDelta: 0.05,
longitudeDelta: 0.05
)
)
)
}
+ }
}
}
}
2 onMapCameraChange内の処理回数を抑制する
実験してみたところ、MREのコードにおけるonMapCameraChange内の処理が4回実行されていることがわかりました。
私の場合ここの処理は座標が変更された時にだけ行われていれば良かったので、以下のようにコードを変更して処理回数を抑制しました。
この方法でもエラーが発生しなくなりました。
import MapKit
import SwiftUI
struct MapCoordinatePickerView: View {
@Namespace private var mapScope
@State private var cameraPosition: MapCameraPosition = .region(MKCoordinateRegion())
@State private var currentCenter: CLLocationCoordinate2D = CLLocationCoordinate2D()
var body: some View {
TabView {
Map(position: $cameraPosition, scope: mapScope) {
Annotation("title", coordinate: currentCenter) {}
}
.mapScope(mapScope)
.onMapCameraChange { context in
+ guard
+ currentCenter.latitude != context.region.center.latitude
+ && currentCenter.longitude != context.region.center.longitude
+ else { return }
currentCenter = context.region.center
}
.onAppear {
cameraPosition = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(),
span: MKCoordinateSpan(
latitudeDelta: 0.05,
longitudeDelta: 0.05
)
)
)
}
}
}
}
3 onMapCameraChange内の処理をディスパッチする
考察の章でも述べますが、これは良い解決方法ではないと考えています。
import MapKit
import SwiftUI
struct MapCoordinatePickerView: View {
@Namespace private var mapScope
@State private var cameraPosition: MapCameraPosition = .region(MKCoordinateRegion())
@State private var currentCenter: CLLocationCoordinate2D = CLLocationCoordinate2D()
var body: some View {
TabView {
Map(position: $cameraPosition, scope: mapScope) {
Annotation("title", coordinate: currentCenter) {}
}
.mapScope(mapScope)
.onMapCameraChange { context in
+ DispatchQueue.main.async {
currentCenter = context.region.center
+ }
}
.onAppear {
cameraPosition = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(),
span: MKCoordinateSpan(
latitudeDelta: 0.05,
longitudeDelta: 0.05
)
)
)
}
}
}
}
考察
エラーメッセージの意味
まず本エラーの意味について考えてみます。
Bound preference MapScopeRegistryKey tried to update multiple times per frame.
preferenceとはサブビューからコンテナに向けて画面描画の設定値を送信する仕組みのことのようです。
またこの文脈におけるframeは1回の画面描画サイクルのことを言っていそうです。
まとめると「1回の画面描画処理の中で設定値(MapScopeRegistryKey)を複数回変えてはいけない」と解釈できます。
画面描画をしている最中にその設定値が変わってしまったら描画に一貫性がなくなってしまいますから納得できますね。
それとMapScopeRegistryKeyは名前から今回のMREにおけるmapScopeと関係しそうです。
本エラーの類似事例を調べていたところ以下の記事に次のコメントがありました。
It sounds like you have a cycle in your updates.
更新にサイクルが発生していることによってエラーが発生しているのではないかとのことなので、ここでも前述の「設定値を複数回変えてはいけない」という解釈と一致しています。
複数回設定値が更新される理由
では今回のMREではなぜ1フレーム内で設定値が複数回変わってしまうのかを考えます。
MREの構造を分解して考える
MREの構成要素ごとに特徴を確認します。
- TabView: 子Viewを評価する際、同一フレームで表示用とレイアウト計算用の2つの評価が発生する
- @Namespace + mapScope: 設定値(PreferenceKey)として登録される
- onAppearのcameraPosition更新: bodyの再評価トリガー
- onMapCameraChangeのcurrentCenter更新(※): bodyの再評価トリガー
※ onMapCameraChangeのcurrentCenter更新箇所にprint文を置いたところ、4回実行されていることがわかりました。
上記をもとに単一フレーム内で行われる処理の内容を時系列で並べます。
- bodyの評価が行われる(1回目)
- TabViewが表示用評価で子View(Map)を評価した結果、MapがmapScopeを登録
-> MapScopeRegistryKey:1回目の更新 - TabViewがレイアウト計算用評価で子View(Map)を評価した結果、MapがmapScopeを登録
-> MapScopeRegistryKey:2回目の更新 - onAppear内でcameraPositionを更新した結果、bodyが再評価される(2回目)
- onMapCameraChange内でcurrentCenterを更新した結果、bodyが再評価される(3回目)
この時系列で考えると3の時点ですでに2回目のMapScopeRegistryKey更新が発生しているため4以降は関係ないように思えます。
しかしonAppearなどの処理を取り除くとエラーが再現しなくなることから、無関係ではないと考えられます。
これについては情報が不足しておりうまく説明をつけることができませんでした。
ただTabViewの特徴とmapScopeの登録が絡み合って今回のエラーが起きていることはつかめました。
設定値更新を1回に抑える方法
設定値更新を1回に抑える方法は解決方法の章で紹介したコードの通りですが、なぜそれぞれのコードで設定値更新が抑えられたのかを考えます。
1 NavigationStackで囲う
NavigationStackには表示対象Viewを1フレームにつき1処理に収めると言う特徴があるそうです。
この特徴によりTabViewによる2回評価を1フレーム目と2フレーム目に分離することができ、結果としてMapScopeRegistryKey更新が1フレームに対して1回ずつに抑えることができるという理解です。
この解決方法のメリットとしてはSwiftUIの構成要素同士でエラーを庇うことができる点です。
TabViewによる分岐した評価処理をNavigationStackがそれぞれ別フレームで処理してくれることにより、表面的に見れば何も問題が起きていないように見えます。
デメリットとしてはそもそもNavigationStackを置く必要のない場所に置くことになり、UIが変わってしまうことです。
2 onMapCameraChange内の処理回数を抑制する
onMapCameraChangeについては時系列における4以降であり、ほとんど推測の域になります。
私の推測では4回行われるcurrentCenter更新処理の間にもMapScopeRegistryKey更新が2回発生する箇所があるのではないかと考えています。
そのためcurrentCenter更新処理を1回に抑えることにより、結果としてMapScopeRegistryKey更新も1回に抑えられるという理解です。
この方法のメリットはUIが変わらないことです。また余分な更新処理も行われないため、(ほとんど変わらないと思いますが)パフォーマンス負荷が下がる点です。
デメリットは本エラーを知らない人にとって、この処理回数抑制のコードが何のために書かれているのかわからない点だと思います。
またデメリットとは違い、私の理解が間違っていることによってこの解決策が実際は解決策になっていないというリスクがあります。
3 onMapCameraChange内の処理をディスパッチする
これは問題の根本解決ではなく、偶然解消できているに過ぎないと考えています。
currentCenter更新をDispatchQueue.main.asyncでメインキューの最後に実行され、結果的に別フレームに分かれて処理をすることになります。
しかし別フレームに分けることを目的とした処理ではないため、ディスパッチした処理が1つ目のフレーム処理内で実行される可能性もあると考えています。
この方法のメリットはUIが変わらないことです。
一方でデメリットとしては本エラーを知らない人にとって何の意味のあるコードなのかがわからないことです。
そしてリスクとしては別フレームに分けることが保証されていないため、一度エラーが解消したように見えても再発する可能性があることです。
まとめ
私が遭遇した今回のエラーは1フレーム内で設定値が複数回更新されることによって発生するようです。
そのため設定値更新回数を1回に抑えることによって解消されます。
フレームワークの内部処理に踏み込まないと理解することや本質的な解消ができず、個人的にSwiftUIのバグと考えられるためAppleに報告しようと思います。
Discussion