🔄

Tree・Stack based Navigation のメリット・デメリットを理解して、SwiftUI Navigation を使いこなす

2023/09/24に公開

前回は SwiftUI における Tree-based navigation および Stack-based navigation について説明するための記事を書きました。

https://zenn.dev/kalupas226/articles/e5a010f7858796

上の記事では、それぞれの Navigation 自体がどんなものなのかということについては説明できたものの、それぞれのメリット・デメリットなどは説明することができていませんでした。

また、メリットを理解してそれぞれの navigation style を使いこなすことで解決可能な課題というものも存在します。

本記事では、主にそれらの説明を書いていこうと思います。

まずは前回紹介した Tree-based navigation と Stack-based navigation それぞれのメリット・デメリットを紹介します。

メリットとデメリットについても、前回紹介した Article に記載されているため、そちらを紹介していきます。

Tree-based navigation のメリット

まず Tree-based navigation のメリットを三つ紹介していきます。

まず一つ目に紹介されているのは、Tree-based navigation は Navigation をモデル化する方法として非常に簡潔だという話です。
後で具体的に言及しますが、Stack-based と比較すると Tree-based は有効な Navigation だけを表現できるというメリットがあります。
例えば、前の記事ではアイテム詳細画面はアイテム編集画面の状態を保持しており、そのおかげでアイテム詳細画面からアイテム編集画面への遷移を正確に記述することができていました。

// アイテム詳細画面の状態をモデル化した構造体
struct State {
  // 遷移先の画面であるアイテム編集画面の状態を明確に保持する必要がある
  @PresentationState var editItem: EditItemFeature.State?
  // ...
}

一方で、アイテム編集画面からアイテム詳細画面への無効な遷移は仕組み上記述することができないようになっています (もちろん、アイテム編集画面がアイテム詳細画面の状態を保持するような構造にすれば可能となりますが)。
このことから、Tree-based navigation は遷移に必要な状態を正確に保持しておけば良いという意味で非常にわかりやすく簡潔な手段で Navigation を表現できる手段と言えそうです。

また上記に関連して、Tree-based navigation はアプリがサポートしている有限個の Navigation しか記述できないようになっています。
これは、有効な Navigation しか表現できないため、当然と言えば当然だと思います。

マルチモジュール化している場合、そのモジュールがより自己完結的になる

Tree-based navigation はマルチモジュール化している場合にもメリットがあります。

例えば、アイテム詳細画面のロジックと View を保持する ItemDetailFeature というモジュールがあった場合、アイテム詳細画面からアイテム編集画面への遷移を実現するために、必然的に ItemDetailFeature がアイテム編集画面のモジュールである ItemEditFeature に依存する必要が出てきます。
このことから、Xcode Previews でアイテム詳細画面からアイテム編集画面への遷移を確認することができるようになったり、アイテム詳細画面とアイテム編集画面がどのように機能として統合されるのかを確認するためのテストを容易に記述することができるようになります。

様々な種類の Navigation に対応できる

Tree-based navigation の最後のメリットとしては、様々な種類の Navigation に対応できるという点が挙げられます。

Tree-based navigation は、前回の記事で説明したように Navigation のための状態を Optional として表現する Navigation です。
そのため、その約束さえ守っていればどんな Navigation を使っても問題ありません。
例えば、sheet はもちろん、navigationDestination を用いて Push 遷移を実現することもできますし、alert や popover など様々な種類の Navigation に対応することができます。

後で詳しく説明しますが Stack-based navigation は Push 遷移しか表現できないため、ここは Tree-based navigation と Stack-based navigation の大きな使い分けのポイントになるでしょう。

Tree-based navigation のデメリット

次に Tree-based navigation のデメリットについても三つ紹介していきます。

複雑あるいは再帰的な Navigation の表現が難しい

例えば、ある映画アプリがあるとします。
その映画アプリでは以下のような操作ができるとします。

  • 最初に映画一覧が表示される
  • 映画一覧からは映画詳細に遷移できる
  • 映画詳細からは、その映画に関わっている俳優一覧に遷移できる
  • 俳優一覧からは俳優詳細に遷移できる
  • 俳優詳細からは、その俳優が関わっている映画詳細に遷移できる

このような機能があるアプリで、画面ごとにモジュール化を行ったとすると、容易にモジュール間の循環参照問題が起きてしまいます。
Swift では、モジュール間の循環参照問題が起きてしまえば、コードをコンパイルすることができなくなってしまうため、マルチモジュール化を行う際は、この循環参照問題に注意しつつ、循環参照問題を解決するための仕組みを取り入れたりすることが多いと思います。

Tree-based navigation は、前述の通り遷移元の画面が遷移後の画面を知る必要がある構造となっているため、複雑なアプリになってくると、この循環参照問題に遭遇する確率は高くなっていくでしょう。

コンパイルする必要があるコードを増やす原因になる

また Tree-based navigation は、その構造上例えばアイテム詳細画面をコンパイルするためには、アイテム編集画面をコンパイルしなければいけない構造となります。
これは、コンパイルしなければいけない対象が単純に増えてしまうため、コンパイル時間の増加に繋がってしまいますし、依存している機能が壊れていれば共倒れで依存元も壊れてしまうというリスクがあります。

SwiftUI のバグに遭遇しやすい

最後の Tree-based navigation のデメリットとしては、SwiftUI のバグに遭遇しやすいという話が挙げられています。

歴史的に見て、Tree-based navigation を実現するための API である NavigationLink や sheet、alert などはバグに遭遇する確率が高いようです。
実際、そういった SwiftUI のバグに困らされている事例は見かけてきた気がしますし、自分自身も何度か遭遇してきた気がします。

Stack-based navigation のメリット

Tree-based navigation のメリット・デメリットについては紹介し終わったので、次は Stack-based navigation のメリット・デメリットを見ていくことにしましょう。
まずはメリットから三つ紹介していきます。

複雑あるいは再帰的な Navigation が簡単に表現できる

これは Tree-based navigation のデメリットとして紹介した「複雑あるいは再帰的な Navigation の表現が難しい」とは対照的なメリットとなっています。

例えば、先ほど紹介したマルチモジュール化された映画アプリであれば、Stack-based navigation の場合、以下のように表現できます。

let path: [Path] = [
  .movie(/* ... */),
  .actors(/* ... */),
  .actor(/* ... */)
  .movies(/* ... */),
  .movie(/* ... */),
]

この Collection は単純な Collection であり、Stack-based navigation を使えば、例えば movieactors に依存しない方法で設計することができます (例えば親画面に全ての遷移を任せるなどすれば)。

このように Stack-based navigation では、複雑だったり再帰的だったりするような Navigation でも簡単に表現することができます。
これは Stack-based navigation を利用する大きなメリットであると思います。

Stack に保持されている各機能は完全に切り離すことができる

また、先ほど紹介したメリットに関連して、Stack に保持されている各機能は依存しないように設計できるという特徴があるため、各機能を完全に切り離すことができます。つまり、循環参照問題を気にすることなく、画面や機能単位でのマルチモジュール化を行うことができます。

これも Tree-based navigation と比較した話にはなっていますが、Tree-based navigation を実現するための API と比較すると、Stack-based navigation を実現するための iOS 16 から利用できるようになった NavigationStack API はバグが少ないようです。
正直これについては、自分自身検証しきれていない部分でもありますし、NavigationStack で困っている人も結構見かけている気はするので、気持ち程度のメリットなのかもしれません。

Stack-based navigation のデメリット

次に Stack-based navigation のデメリットも三つ紹介していきます。

Tree-based navigation は Navigation をモデル化する方法として簡潔であり、安全性もあるという話を先ほど紹介しました。

これとは対照的に、Stack-based navigation では全く無意味な遷移を表現することが可能になってしまうというデメリットがあります。

例えば、Stack-based navigation では以下のような Collection を保持することが容易にできてしまいます。

let path: [Path] = [
  .edit(/* ... */),
  .detail(/* ... */)
]

本来、アイテム詳細画面からアイテム編集画面に遷移することは理解できると思いますが、その逆のアイテム編集画面からアイテム詳細画面への遷移という意図していない遷移も簡単に表現できてしまいます。

他の例として、以下のように同じ画面から同じ画面に複数回遷移するという無意味な遷移も表現できてしまいます。

let path: [Path] = [
  .edit(/* ... */),
  .edit(/* ... */),
  .edit(/* ... */),
]

Previews やテストで機能間の挙動が検証できなくなる

Stack-based navigation では、機能間が依存することなく Navigation を表現できるというようなメリットを紹介しましたが、このメリットがある一方で、Previews やテストで機能間の挙動は検証できなくなってしまうというデメリットがあります。

例えば Tree-based navigation では、アイテム詳細画面はアイテム編集画面に依存する構造となっていたことから、アイテム詳細画面の Previews でアイテム編集画面への遷移を確認することができました。
また、それらの機能間のテストも統合的に書くことができました。

Stack-based navigation で、機能間を依存させることなく設計した場合、それらのメリットを享受することはできなくなってしまいます。

ドリルダウン (Push) 型の Navigation にしか対応できない

最後に、これは Stack-based navigation の大きなポイントですが、Stack-based navigation は Collection で Navigation のための状態を管理するというその性質上、SwiftUI におけるドリルダウン型の Navigation を表現する API である NavigationStack の利用を強いられることになります。

つまり、Stack-based navigation で sheet や alert などの表現はできないということになります。

Stack・Tree based navigation は結局どう使えば良いのか?

ここまでで Point-Free が提唱している Stack-based navigation と Tree-based navigation について、簡単に紹介してきました。
それぞれの Navigation にはメリット・デメリットがあることを紹介しましたが、結局これらの Navigation はどう使っていくのが良いのでしょうか。

ここからは現時点の自分の考え方が含まれてしまいますが、参考程度に書いておこうと思います。

まず前提として、「このアプリには Push 遷移以外存在しません」みたいなアプリではない限り、Stack-based navigation だけでアプリを作るということは難しく、どこかのタイミングで Tree-based navigation を利用することになります。
一方で、Tree-based navigation では、Push 遷移も含めたあらゆる Navigation を表現できるため、やろうと思えば Tree-based navigation だけでもアプリを作ることはできると思います。

そこで、自分が思う Navigation の一つの使い分けのポイントとしては、アプリが「マルチモジュール化されている・する予定がある」という話があるかなと思っています。
Stack-based navigation は性質的に、モジュール分離をしていないと得られるメリットが軽減してしまいます。
そのため、マルチモジュール化されていたり、あるいはマルチモジュール化する予定が少しでもあるのであれば、Stack-based navigation と Tree-based navigation をうまく使い分けると良いと思っています。
(個人的な話で言えば、今はマルチモジュール化された環境でしかコードを書いていないため、自分は Navigation の使い分けについて模索する必要がある立場です。マルチモジュール化の是非に関しては本記事の本質から逸れるため触れません。)

では「マルチモジュール化されている・する予定がある」という場合に、どのように Navigation を使い分けるべきかという話については、自分もまだ模索中ではありますが、一旦は以下の方向が良さそうかなと思いやってみています。

  • アプリの Root に近い画面からの Push 遷移は基本的に Stack-based navigation で管理する
  • アプリの Root に近い画面と同じ Stack にある画面から sheet, alert などを表示したい場合は Tree-based navigation を利用する (正確には利用するしかない)
  • sheet などで表示している画面の Navigation は全て Tree-based navigation で管理する

具体的な API を出しながら上記の構成を説明してみようと思います。
例えば TabView ベースのアプリがあり、Tab は四種類あり、それぞれの Tab に表示されている画面では色々 Push 遷移して行ったり、sheet を表示したり alert を表示したりするとします。
そして、表示された sheet 上の画面では Push 遷移したり alert を出したりするとします。

上に書いた箇条書きの話にこの状況を照らし合わせると、

  • それぞれの Tab に表示されている画面 (Root に近い画面) からの Push 遷移は基本的に NavigationStack(path:) を使って管理する
  • Tab に表示されている画面と同じ Stack にある画面から sheet, alert などを表示したい場合は、sheet, alert API を利用する
  • 表示された sheet 上の画面では NavigationStack(path:) を使っての Stack 管理はせずに、NavigationStack で包んであげて navigationDestination によって Push 遷移を表現するようにする

というようなイメージです。
上記はベーシックな iOS アプリの話になってしまっているため、もっと複雑な Navigation を行うようなアプリだったり、iOS 以外のプラットフォームという話になってきた場合には、もっと考慮しなければいけないことが増えてしまう可能性は大いにありそうです。

実際自分も試してみている段階であり、机上の空論感も否めません。
しかし、個人的には以下のように考えていることから、ある程度の妥当性はありそうかなとは思っています。

  • Root に近い画面の Stack 上では、再帰的な Navigation が起きる可能性が高い気がしているため、Stack-based navigation が有効な場面が多そう
  • sheet などの API を使って Navigation する目的として「重要でスコープが狭いタスクに注意を引く」というものがあり (詳細は前の記事参照)、sheet 上で再帰的な Navigation が起こるような構造になってしまっているのであれば、それはアプリの設計を見直した方が良いのかも。そのため、sheet で表示した先の画面では Stack-based navigation ではなく、Tree-based navigation を選択すると良さそう

このように考えた場合、Tree-based navigation の実装はそこそこ単純ではありますが、Stack-based navigation をどのように実装するかを考える必要があるかもしれません。
Pure SwiftUI であれば、Root に近い画面に Navigation を実行してもらうために、Root に近い画面から遷移先の画面にクロージャーを引き回して行ったり、Navigation 用のオブジェクトを用意してそれによって担保するという方向性などが考えられそうです。
自分の場合であれば、現状は TCA を使っているプロジェクトでしか開発していないため、TCA が用意してくれている Stack-based navigation の仕組み を使うと、Stack-based navigation を非常に簡単に実現できています。特に、Root に近い画面と深く Stack していった先の画面のやり取りは結構辛くなるポイントだと思いますが、そこを簡単に表現できるのが魅力でおすすめです (他にも魅力は色々ありますが、ここでその話をするのはやめておきます)。

おわりに

本記事では、Tree-based navigation と Stack-based navigation のメリット・デメリットについて、swift-composable-architecture のドキュメントを参考に紹介しつつ、最後には個人的な Navigation に対する考え方についても説明しました。

特に、最後の個人的な考え方に対しては、

  • それはやっぱり机上の空論だと思うよ
  • もっと良い Navigation の考え方があるよ

みたいなところはぜひ知りたいと思っているので、もしそういった話がありそうであれば教えて頂けたら嬉しいです🙏

Discussion