ルーティング先 URL をスタンプしてナビゲーション同一性を一元管理
ルーティング時に UIViewController に URL をスタンプし、同一性判定をコントローラ型ではなくその URL で行うよう変更しました。これにより、同一クラスの異なるインスタンスが URL で正しく区別され、誤った pop/replace が防止されます。
背景
これまで NavigationHierarchyController の visitingSamePage と visitingPreviousPage は、スタック上のコントローラを Visitable/VisitableViewController にキャストできない場合に 型の同一性 でページを判定していました。カスタムネイティブコントローラが同じクラスでも異なる URL で表示されるケースで、スタック上のインスタンスが区別できず、push すべき場面で pop/replace が走っていました。
この問題は、iOS 側だけでなく Android では既に ルーティング時に位置情報をスタンプ する設計で解決されており、iOS でも同様の単一情報源に統一する必要がありました。
技術的な変更
routedLocation プロパティの追加
UIViewController に routedLocation: URL? を associated‑object で付与する拡張が新規作成されました。実装は objc_setAssociatedObject / objc_getAssociatedObject を用い、キーは malloc(1)! で確保したユニークポインタです。
import UIKit
private let routedLocationKey = malloc(1)!
extension UIViewController {
/// The location the navigator routed this view controller to, stamped at route time.
var routedLocation: URL? {
get { objc_getAssociatedObject(self, routedLocationKey) as? URL }
set { objc_setAssociatedObject(self, routedLocationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
}
ルーティング時にスタンプを付与
NavigationHierarchyController.route(controller:proposal:) 内で、ナビゲーション対象のコントローラに controller.routedLocation = proposal.url を設定します。これにより、メイン・モーダルスタックのすべてのルーティング経路で同一情報が付与されます。
else {
controller.routedLocation = proposal.url
// …既存ロジックはそのまま
}
同一性判定ロジックの刷新
visitingSamePage(同様に visitingPreviousPage)は、トップコントローラの routedLocation と提案 URL を isSameLocation(as:pathProperties:) で比較します。routedLocation が無い場合はデバッグで logger.warning を出し、リリースビルドでは false を返して新規ページとして push します。
private func visitingSamePage(on navigationController: UINavigationController, via proposal: VisitProposal) -> Bool {
guard let topVC = navigationController.topViewController else { return false }
guard let location = topVC.routedLocation else {
logger.warning("Top view controller \(topVC) has no routed location; treating the visit as a new page.")
return false
}
return location.isSameLocation(as: proposal.url, pathProperties: proposal.properties)
}
テストケースの追加
カスタムコントローラが異なる URL で push、同一 URL で replace、前ページへ戻る pop が正しく行われることを検証するテストが追加され、未スタンプ状態のコントローラがスタックに存在した場合でも安全に push されることを確認しています。
設計判断
objc_setAssociatedObject の採用理由
- UIKit のスタック変化に追随: バックスワイプやシステムポップといった UI 操作はコントローラ単位で行われ、外部構造に依存しません。コントローラに直接紐付くことで、状態が常に正しいまま保たれます。
- ライフサイクル管理が自動: オブジェクトが解放されると同時に associated object も解放され、余計なクリーンアップコードが不要です。
- Android の実装と対等: Android ではバックスタックエントリの引数バンドルに位置情報を保持しており、iOS でも同様の「エントリ単位」保存手段として最も近い実装です。
単一情報源への統一とフォールバック戦略
以前は Visitable.initialVisitableURL と routedLocation を併せて比較していましたが、スタンプが必ず存在する前提に統一し、未スタンプは 警告 + push という安全策に置き換えました。これにより、型比較のバグ が根本的に除去され、開発者は「URL が唯一の同一性指標」だと確信できます。
アサーション vs. ロガー
assertionFailure ではなく logger.warning を使用したのは、開発時にバグを顕在化させつつ、リリースビルドでクラッシュを防ぐためです。UIKit では外部コードが任意に pushViewController できるため、ハードな precondition は実運用上リスクが高くなります。
まとめ
この PR は ルーティング先 URL をコントローラにスタンプし、ナビゲーション同一性の判定を URL のみで行うよう改めました。objc_setAssociatedObject による安全な永続化と、未スタンプ時のフォールバック戦略により、カスタムビューコントローラでも正確な push/replace/pop が保証されます。結果として、iOS のナビゲーションロジックが Android とパリティを取り、同一性バグが根本的に解消されました。