TL;DR
下記のリポジトリにサンプルプロジェクトを上げたのでご参考に:
ちなみに読み方は「ヴィッドレップ」です。ただこれ考案当初に付けた名前ですが、今色々微妙に変わったし、そもそもキーボードで ViDRep
すっごい打ちづらいので、新しい名前が欲しいかも。
謝辞
いきなりですが、上記のサンプルプロジェクトの制作にあたって、パブリック API として https://cataas.com/ を利用させていただきました。可愛い猫を返してくれる手軽な REST API ですので、ぜひお試しください!(なお筆者は実は犬派です。)
なぜまた新しいアーキテクチャを作ったか
まあ世の中にすでにいっぱいアーキテクチャありますよね、古き良き MVC や MVP から、最近巷で話題の Redux や TCA など、今いろんなアーキテクチャが提案されて色んな意味でアーキテクチャ戦国時代ですね(違)。そんな中なぜ既存のアーキテクチャを使わずにまた新しいアーキテクチャを考案するかというと、今のところ「SwiftUI に特化したアーキテクチャがない1」というのが一番のきっかけとなった原因です。せっかく会社業務で SwiftUI オンリーで新しいプロジェクト作れるんだから、SwiftUI で手に入れた色々な新しい機能や書き方をフルに活用したいです。
なのでどうせアーキテクチャを作るなら、どんなアーキテクチャを作りたいのか、ということを考えると、5つの目標を設定しました:
- 単一方向データフロー:Flux などのおかげで最近再び市民権を得つつある(違)単一方向のデータフロー。やっぱ単一方向の方が、データの流れが追いやすいですよね。
- Single Source of Truth:まあこれ SwiftUI の哲学ですね、守らない手はないでしょう。
-
ObservableObject
などの Combine 機能の活用:Combine と SwiftUI の相性が非常にいいので、できればもう RxSwift などの外部ライブラリーを使いたくないですね。 - テスト可能:まあ言うまでもないですね。テストできない設計なんて誰得なのか。
- スケーラブル:プロジェクトって時期によって規模が変わってくるから、やはり大きすぎるのも小さすぎるのも大変ですよね。可能ならそれに合わせて設計自体もスケーラビリティ持てるといいなと思いました。まあ大抵の設計も「雰囲気」で厳密な正しい仕様にはならないことが多いですが、その「雰囲気」によって出る「曖昧さ」を「明確化」したいですね。
というわけで実際の社内プロジェクトにも適用してみて、最終的に仕上がったのがこの ViDRep というアーキテクチャです。
どんなアーキテクチャ
一枚の図で示すなら、ViDRep はこんな感じのアーキテクチャです:
見ての通り、大きく分けて 3 つのレイヤーがあります:View、Controller と Model です。ええ、また MVC の派生です。データフローもまた原初 MVC(注:UIKit で広く使われている Cocoa-MVC ではない)と同じく、View→Controller→Model→View の流れです。MVC の最大のメリットは、画面要件、機能要件、そしてそれらに必要な状態をちょうどきれいに View、Controller と Model にそれぞれ当てはめられるので、比較的に学習コストも低いところですね。低レベルなデータのやり取りに Infrastructure というレイヤーもありますが、後述しますがこれを Controller 層に統合することも許容です。
そして詳しくみていきますと、それぞれのレイヤーにはまたさまざまなコンポーネントがあります。順番に解説していきましょう:
View
まず View レイヤーにあるのは Scene と ViewComponents、そして図には書いてないですが SwiftUI 特有の ViewModifiers もあります。それぞれどんな役目かというと:
- Scene:いわゆる「画面」です。そして画面にとって必要な依存、つまり後で登場する Dispatcher や Repository も、所有するのはこの Scene です。他のコンポーネントは基本的にはそういった依存を持たずに、必要なデータの書き出しやアクションの実行は、
@Binding
やクロージャで賄うようにしています。Scene 言うまでもなく、はさまざまな ViewComponents や ViewModifiers で構成されます。また、画面によっては再利用も可能ではありますが、多くはない想定です。 - ViewComponents:画面を構成する部品です。これらは再利用を前提として作られます。予想ですが ViewComponents の粒度を Atomic Design に合わせて Atom から、一応最大 Template まで対応可能な想定です。逆にいうと、Atomic Design を導入されているプロジェクトなら、Atomic Design に合わせて ViewComponents をさらに Atom、Molecules、Organisms と Templates に細分化しても OK なはずです。そういう意味では、
Scene
は Atomic Design なら Pages に対応していると言って差し支えないです。 - ViewModifiers:まあいうほどもないですが、単純に
SwiftUI.ViewModifiers
を集めたものだけですね。なので図にも載せていないです。
Controller
次に Controller レイヤーを見ていきます。ここは主に Dispatcher と Reducer があります。それぞれの役目は:
- Dispatcher:画面とか、通知センターとか、さまざまな外部環境から受け取ったアクションをどう料理するかを管理し、そして処理した結果を Repository に書き込むのも Dispatcher です、なので Dispatcher は Repository への依存を持ちます。そして Scene が持つ Dispatcher への依存に適合します。ちなみにですが名前は Redux からパクってきました。
- Reducer:実際のビジネスロジックです。こちらは Dispatcher に呼び出されて、処理を行ったらそのまま結果を返せばいいだけなので、テストがしやすいために、状態を一切持たず、完全に純粋関数で構成されるべきです。理屈上では Reducer も依存を持たないはずです。ただしプロジェクトの規模によっては、Reducer 自体を省いてロジックを全部 Dispatcher 内で書いても特に問題ありません、純粋関数ほどテスト書きやすくはないですが、Dispatcher も Mock の Repository を与えてあげればテスト可能ですので。ちなみにこれも言うまでもなく名前は Redux からパクってきました。
Infrastructure
Model を見る前に、先に Controller とやりとりしてる Infrastructure を見てみましょう:
- API Client:まあ言うほどもなく、ネットワーク通信の部品ですね。こちらは他の部品への依存は基本的にないはずですが、例えばログイン後の Token とかを自分の中ではなく Repository に保存したい場合は、Repository への依存を持っても問題ないです
- Database:これも言うほどもなく、永続化の部品ですね。API Client と同じく他の部品への依存はないです。
また、これらは Reducer と同じく、プロジェクトの規模によっては省いて全部 Dispatcher 内で書いても問題ありません。テストは通信なら Stub ライブラリー使えばいいですし、永続化はテスト用のコンテナを使えば一応可能ですので。
Model
そして最後 Model を見ましょう:
- Repository:いわゆる Single Source of Truth の溜まり場です。アプリのありとあらゆる状態は可能な限りここに集約し、全てのデータはそのまま受け入れて何も加工せずに所持するだけのものです。そしてそれらのデータは
@Published
で持つべきで、Repository 自体もObservableObject
に適合すべきです。そうすれば View が@ObservedObject
として Repository を持つと、SwiftUI の仕組みそのまま利用できます。
また、ここでは書かれていないですが、実際のプロジェクトの作成にあたって必要な各種データ構造も、基本この Model 層で作ります。ただし、例えば実際のサーバの仕様に依存するレスポンスのデータ構造定義などのような、プロジェクト全体で使われるものではないデータ構造は、それに準ずるレイヤー内で定義し、そしてその利用もそのレイヤーもしくはコンポーネント内に留まるべきです。
ちなみに私の最初の構想では、Infrastructure は Controller ではなく、Model の Repository とやりとりをするように作っていました、Source of Truth を管理する Repository がそのまま正しいデータをネットに投げたら永続化したりさせれば管理がしやすいと思ったからです。しかし実際やってみると、やはり非同期通信の管理が面倒だったり、データをそのまま全く加工せずに処理するのは実質不可能ですので、これらのデータを Source of Truth とみなさずに、Dispatcher に処理をさせることにしました。むしろ今の方がスッキリします。
また、同じく図には載せてないですが、設計によっては画面遷移を管理する Router や、DI を管理する Resolver なども作れます。サンプルプロジェクトはまさにそのようにしています。もちろん規模が小さいプロジェクトなら省いて問題ないです。そして SwiftUI はルータが作りにくいですが、一応可能ではあります。ルータの実装だけ興味ある場合はこちらのリポをぜひご参考にしてください。
どう実装されているか
詳しく全部解説すると非常に長くなるので、各種レイヤーや部品は代表的なものだけ解説しますね。他のものも全部同じ思想で実装されているので、一つ理解すれば残りも理解しやすいはずです。
アプリの入り口
まずはアプリの入り口を見てみましょう。SwiftUI のおかげでアプリの入り口が非常に簡潔かつわかりやすくなったので、そのコードを全部出しても問題ないくらいですね:
import SwiftUI
@main
struct ViDRep_SampleApp: App {
@StateObject var appResolver = AppResolver()
var body: some Scene {
WindowGroup {
appResolver.makeView()
}
}
}
Xcode のテンプレコードとほぼ変わらないです。ここでやったことは二つだけで:
-
@StateObject
としてAppResolver
を持ちます。 - アプリの最初の画面として
appResolver.makeView()
を呼び出します。
ViDRep の解説にも書いた通り、図には書いてないですが規模に応じて Resolver や Router などを入れてもいい設計ですので、ここもこのように Resolver から View を作ってもらう作りにしています。では AppResolver
がどのように作っているかみていきましょう。
Resolver
AppResolver
は流石にこんな短くはできないので、ここで大事なところだけピックアップしておきましょう:
import SwiftUI
import Combine
final class AppResolver: ObservableObject {
private lazy var repository = AppRepository()
// ...
var objectWillChange: ObservableObjectPublisher {
repository.objectWillChange
}
}
extension AppResolver {
@ViewBuilder
func makeView() -> some View {
appRouter.makeAppView()
}
}
extension AppResolver: AppRouterResolverDelegate {
@ViewBuilder
func appRouterNeedsView(for viewID: ViewID) -> some View {
switch viewID {
case .searchScene:
SearchScene(routerDelegate: appRouter, dispatcher: imageSearchingDispatcher, repository: repository)
// ...
}
}
}
AppResolver
は Observable
として、アプリに必要な AppRepository
などのコンポーネントを作ります;更に AppRouterResolverDelegate
として、必要なビューもここで作ってあげて返してあげます。
また、AppRepository
がアプリの唯一の Single Source of Truth なので、AppResolver
の objectWillChange
パブリッシャーは、AppRepository
のそれを返します。
そして先程の makeView()
の内容は実は AppRouter
の makeAppView
だと判明したので、次に Router を見ていきましょう。
Router
Router もコードが非常に長くなるので、ここでは肝心な部分だけ解説します。特に Router 内の状態で画面遷移をさせる仕組みはこちらのリポに解説がありますのでご参考にしてください。
protocol AppRouterResolverDelegate: AnyObject {
associatedtype ResolvedView: View
@ViewBuilder func appRouterNeedsView(for viewID: ViewID) -> ResolvedView
}
final class AppRouter<ResolverDelegate: AppRouterResolverDelegate> {
weak var resolverDelegate: ResolverDelegate?
@Published var searchSceneRoute: SearchSceneRoute?
@Published var savedImageListSceneRoute: SavedImageListSceneRoute?
init(resolverDelegate: ResolverDelegate) {
self.resolverDelegate = resolverDelegate
}
}
extension AppRouter {
@ViewBuilder
func searchScene() -> some View {
if let resolver = resolverDelegate {
resolver.appRouterNeedsView(for: .searchScene)
.injectRouter(self, with: .searchScene)
}
}
// ...
}
extension AppRouter {
func makeAppView() -> some View {
TabView {
NavigationView {
searchScene()
.uiNavigationBarHidden(true)
}
.tabItem { Text("Search") }
// ...
}
}
}
ここで先程 Resolver で解説した ResolverDelegate が登場します。Router が画面遷移の管理で「この画面ください」と ResolverDelegate 通じて Resolver に要求しますと、要求された画面が帰ってきます。どんな画面なのかを管理しやすいためには ViewID
を使っていますが、こちらも上記の SwiftUI-RouterDemo のリポに解説してありますのでご参考にしてください。そして makeAppView
の中で、Resolver からもらった生の画面を、ルータを入れてあげたり、tabItem
などの画面遷移の観点から見た画面の設定をしてあげて、できたものを返します。
Scene
全部の画面を解説する必要は特にないので、ここで今までも出てきた SearchScene
を例としてみていきましょう。もちろん全部のコードは長いので、肝心な部分だけ抜粋します:
import SwiftUI
enum SearchSceneRoute {
case didFinishSearch(image: CatImage)
}
protocol SearchSceneRouterDelegate: AnyObject {
func viewNeedsRoute(to route: SearchSceneRoute)
}
enum SearchSceneAction {
case inputText(String)
case searchImage
}
protocol SearchSceneDispatcherObject: AnyObject {
func runAction(_ action: SearchSceneAction)
}
struct SearchSceneState {
var inputText: SearchTextInput
var searchResult: AsyncState<CatImage, GeneralError>
}
protocol SearchSceneRepositoryObject: ObservableObject {
func state() -> SearchSceneState
}
struct SearchScene<RouterDelegate: SearchSceneRouterDelegate,
Dispatcher: SearchSceneDispatcherObject,
Repository: SearchSceneRepositoryObject>: View {
weak var routerDelegate: RouterDelegate?
var dispatcher: Dispatcher
@ObservedObject var repository: Repository
private var textBinding: Binding<String> {
.init(get: { repository.state().inputText.value },
set: { dispatcher.runAction(.inputText($0)) })
}
private var searchResult: AsyncState<CatImage, GeneralError> {
repository.state().searchResult
}
// ...
var body: some View {
VStack {
TextField("Search cat pics with text!", text: textBinding)
// ...
}
.onChange(of: searchResult, perform: handleSearchResult(_:))
}
}
extension SearchScene {
private func handleSearchResult(_ result: AsyncState<CatImage, GeneralError>) {
switch result.data {
case .retrieved(let image):
routerDelegate?.viewNeedsRoute(to: .didFinishSearch(image: image))
// ...
}
}
}
struct SearchScene_Previews: PreviewProvider {
private final class Mock: SearchSceneRouterDelegate,
SearchSceneDispatcherObject,
SearchSceneRepositoryObject {
// ...
}
private static let mock = Mock()
static var previews: some View {
SearchScene(routerDelegate: mock,
dispatcher: mock,
repository: mock)
}
}
色々省いても結構長く見えますが、全部で 3 つくらいのパーツに分けられると思います:必要な依存の定義、View
自体の実装、そしてプレビューの実装です。
まず必要な依存の定義ですが、この画面は画面遷移に必要な Route
データと RouterDelegate
部品、アクションを行うための Action
データと DispatcherObject
部品、最後データ表示に必要な State
データと RepositoryObject
部品の 3 つに分けられます。ちなみにデータと部品を分けて定義するのは、enum
と struct
によってネームスペースが簡単に作られるからです。また、データ表示に必要な State
は state()
メソッドで取得してもらいますが、ここで例えば複数の画面で同じ Scene
ファイルを流用した場合、必要なデータの出しわけのために state(parameter: Parameter)
のように引数を渡しても問題ないです。この画面はその処理がないですが、隣の ImagePreviewScene
はこのように作られています。必要かどうかの判断基準として、わかりやすいものならこの Scene
固有のプロパティー、例えば ImagePreviewScene
なら var image: CatImage
のようなプロパティーなら、state()
メソッドの引数として渡して問題ないと考えています。
依存定義が済んだら次に実装に入りますが、上記の部品は全て protocol
で定義されており、特に Repository
は ObservableObject
に適用する必要があるので、protocol
の存在型をそのままプロパティーの型として使えないため、ジェネリクスを使う必要があります。それ以外は普通の View
の実装と大差はありません。表示に必要なデータは Repository
から取得すればいいし、逆に何かデータインプットなどのアクションを起こすなら Dispatcher
を通して処理してもらいます。また、必要に応じて onChange
で Repository
から取得した特定のデータを監視することも可能です。最後に画面遷移が必要になったら、同じく RouterDelegate
を呼び出して処理を移譲します。
最後はプレビューですが、必要な依存は全て抽象化して同じファイルに定義したので、簡単にモックを作れます。これで画面をプレビューすることも手軽にできます。
Dispatcher
ViewComponents や ViewModifiers は特筆するようなことはないので、飛ばして Controller レイヤーにいきましょう。まずは Dispatcher として ImageSearchingDispatcher
をみていきましょう:
import Foundation
import Combine
protocol SearchTextReducerObject: AnyObject {
func makeSearchTextInput(from text: String) -> SearchTextInput
}
protocol ImageSearchingAPIClientObject: AnyObject {
func searchImage(with line: String) -> AnyPublisher<CatImage, GeneralError>
}
protocol ImageSearchingDispatcherRepositoryObject: AnyObject {
var searchText: SearchTextInput { get set }
var downloadedImage: AsyncState<CatImage, GeneralError> { get set }
}
final class ImageSearchingDispatcher<Reducer: SearchTextReducerObject,
APIClient: ImageSearchingAPIClientObject,
Repository: ImageSearchingDispatcherRepositoryObject> {
let reducer: Reducer
let apiClient: APIClient
let repository: Repository
// ...
}
extension ImageSearchingDispatcher {
func setSearchText(_ text: String) {
repository.searchText = reducer.makeSearchTextInput(from: text)
}
// ...
}
extension ImageSearchingDispatcher: SearchSceneDispatcherObject {
func runAction(_ action: SearchSceneAction) {
switch action {
case .inputText(let input):
setSearchText(input)
// ...
}
}
}
Dispatcher も同じく、まずは必要な依存を定義してから自分を実装します。そして最後には Scene が必要な依存としての適合を行います。慣れてきたら特筆するようなことはないですよね。
Reducer
次に Dispatcher と同じレイヤーにある Reducer をみてみましょう:
import Foundation
final class CatImageSearchTextReducer {
func validate(_ text: String) -> SearchTextInput.LocalValidation {
// ...
}
}
extension CatImageSearchTextReducer: SearchTextReducerObject {
func makeSearchTextInput(from text: String) -> SearchTextInput {
return .init(value: text, localValidation: validate(text))
}
}
こちらももう特に特筆するようなことはないですよね。Reducer 自体は依存が必要ないはずなので、依存の定義も必要ないです。自分のやることを実装して、Dispatcher が定義した依存に適合するだけです。
APIClient
次に Infrastructure のものを見ていきましょう。まずは APIClient です:
import Foundation
import Combine
final class CataasAPIClient {
// ...
}
// ...
extension CataasAPIClient {
private func send(line: String) -> AnyPublisher<CatImage, GeneralError> {
guard let percentEncodedLine = line.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
return Fail(error: .unknownError(TaskError.unableToMakePathComponent(line: line))).eraseToAnyPublisher()
}
let request = makeURLRequest(percentEncodedText: percentEncodedLine)
return URLSession.shared.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: CataasJSONResponse.self, decoder: decoder)
.mapError(mapError(_:))
.flatMap(downloadImage(from:))
.eraseToAnyPublisher()
}
}
extension CataasAPIClient: ImageSearchingAPIClientObject {
func searchImage(with line: String) -> AnyPublisher<CatImage, GeneralError> {
return send(line: line)
}
}
APIClient の作り方の解説記事ではないので、やはり具体的な通信の仕方については割愛します。これも他の部品と同じく、自分の処理を実装して、そして Dispatcher が定義した依存に適合するだけです。また Database も同じレイヤーのもので、設計の観点では作り方も一緒なので、こちらも割愛します。
Repository
いよいよ最後の Model 層に入ります。Repository を見てみましょう:
import Foundation
import Combine
final class AppRepository: ObservableObject {
@Published var _searchText: SearchTextInput = .init("")
@Published var _downloadedImage: AsyncState<CatImage, GeneralError> = .none
@Published var _savedImages: SavedImageList = .init(images: [])
private let savedImagesSemaphore = DispatchSemaphore(value: 1)
}
extension AppRepository: ImageSavingDispatcherRepositoryObject {
var savedImages: SavedImageList {
get {
savedImagesSemaphore.wait()
defer { savedImagesSemaphore.signal() }
return _savedImages
}
set {
savedImagesSemaphore.wait()
DispatchQueue.main.async {
defer { self.savedImagesSemaphore.signal() }
self._savedImages = newValue
}
}
}
}
extension AppRepository: SearchSceneRepositoryObject {
func state() -> SearchSceneState {
.init(inputText: _searchText,
searchResult: _downloadedImage)
}
}
// ...
Repository はプロジェクトを横断する全ての状態の溜まり場ですので、必要な状態を全部 @Published
で作ります。そして @Published
プロパティーを書き込むときはメインスレッドから行う必要なので、そのスレッドの切り替えも Repository 内で行います。その際、例えば配列や辞書など、マルチスレッドによるデータの読み書きを保証したい場合は、排他処理もこの中に入れます。ただしここで大事なのは、Repository 自体はデータに一切加工せず、もらったデータをそのまま受け入れることです。Repository はあくまで Source of Truth です。ここでデータの処理が紛れ込まれたら、データの正確性を保証する責務が Controller から Model 層に漏れてしまい Fat です。
また、他のデータ定義も同じく Model 層で行っていますが、設計レベルで解説するようなものはほとんどないので、一つだけこのプロジェクトの至る所にあるデータを紹介してこの章を終わりにします。
AsyncState
現代のアプリにとってオンライン通信はほぼほぼ必要不可欠で、そのため非同期処理がなくてはならないものです。しかし見ての通り、@ObservedObject
を使う以上、明確に Publisher
を宣言して Repository からもらわない限り、データ自体の流れを onReceive
で購読できません。もちろんそれを宣言するという手もありますが、@State
で直接宣言するプロパティーと異なり、変数の本体と Publisher
を別々で宣言する必要があります。そして onReceive
自体は描画内容に関知しないので、描画したい場合はまた自分で @State
状態を持つ必要が出てきて、結局 Source of Truth が分散してしまうリスクがあります。また、流れをもらうだけでは「取得中」といった状態がわかりにくいのも課題です。もちろん、ものによっては確かに Publisher
で欲しい場合もありますが、多くの場合はやはりデータそのものをもらいたいだけなので、データだけをもらって尚且つ「取得中」や「取得失敗」などの状態も判別しやすいために、AsyncState
を作りました:
import Foundation
struct AsyncState<Value, Error: Swift.Error> {
enum Data {
case none
case fetching
case retrieved(Value)
case failed(Error)
}
let id = UUID()
let data: Data
init(_ data: Data) {
self.data = data
}
}
extension AsyncState: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.id == rhs.id
}
}
extension AsyncState: ExpressibleByNilLiteral {
init(nilLiteral: ()) {
self.data = .none
}
}
// ...
AsyncState
は状態を表す Data
と、固有の ID として生成時に UUID
を新たに付与します。UUID
を外から注入できないように(Memberwise Initializer を自動で作らないように)本体の実装で引数が data
だけのイニシャライザーを作ります。そして AsyncState
自体の比較はこの UUID
だけ比較させればいいです。
状態は .none
.fetching
.retrieved(Value)
と .failed(Error)
の 4 種類あります。まだ取得できてないなら .none
、取得しているなら .fetching
、成功したら .retrieved
、そして失敗したら .failed
になります。当然データの流れではないので失敗時の購読解除の回避処理とかも必要ないので非常に手軽です。また .fetching
があるおかげでローディング画面を出す制御も非常にしやすいです。
ViDRep は目標を達成できたか
「なぜまた新しいアーキテクチャを作ったか」の章では、ViDRep の考案にあたって 5 つの目標を挙げました。簡単に設計と実装のそれぞれの解説を読んだ今、改めてその目標を達成できたかみてみましょう。
- 単一方向データフロー:これはもう見ての通り、View→Controller→Model→View の一方通行のデータフローですね。
- Single Source of Truth:全ての情報源は Repository にあります。あなたは常に Repository を信用できます。
-
ObservableObject
などの Combine 機能の活用:Repository はObservableObject
なので、View
からの利用は非常にしやすいし、本当に素直にそのまま利用しているだけなので、変な落とし穴とかもあまりないと思います。 - テスト可能:Reducer は純粋関数なのでそのままテストしやすいし、Dispatcher もモック入れてあげればテスト可能です。さらに ViewInspector とかのライブラリーを入れてあげれば View もユニットテストが可能です。
- スケーラブル:コンパクトにしたいなら Reducer や APIClient を省いたり、逆にきっちりしたいなら Router や Resolver を入れてあげたりすることも可能で、かつどこに何を入れる/無くすべきかを明確しているので、多分さまざまな規模のプロジェクトに適用可能かと思います。サンプルプロジェクトは一番きっちりな作りにしていますが。
うん、振り返ってみると、我ながら悪くない出来だと思います(自画自賛)。
じゃあ俺のプロジェクトに ViDRep を導入すべきか/ViDRep の弱点は何か
設計に銀の弾丸はないとよく言われます。ご自身のプロジェクトの特性と、さまざまな設計の良し悪しを把握した上でご判断ください。例えば ViDRep ならこんなメリデメがあると思います:
メリット
- SwiftUI に特化した設計のため、SwiftUI の特性を活かしている
- テストのしやすさを確保しながら、さまざまな規模のプロジェクトに対応可能
- 設計自体は外部のライブラリーに依存しない
デメリット
- 明確な ViewModel がないため、描画ロジックが複雑なアプリなら、Repository の依存適合の実装でコードがやや Fat になりがち
- ViewModel を入れるという手もありますが、そうすると一応 View と ViewModel のデータバインディングという双方向のデータフローに対処する必要があり、人によって好みが分かれる
- SwiftUI の描画特性に強く依存しているため、UIKit のプロジェクトには適用しにくい
- 機能によって Dispatcher が持つ依存が膨らみがちで、サーバ通信や永続化処理などと言ったあまり Controller 層が直接ハンドリングしないようなものも Dispatcher がハンドリングしている
と言ったところでしょうか。ご自身のプロジェクトの状況をよく分析した上でご検討ください。
ViDRep って名前、覚えづらいし言いづらいし打ちづらい。どうにかならんの?
はい、おっしゃる通りです。新しい名前募集したいです。
-
正確には TCA は一応 SwiftUI 向けに作られたアーキテクチャではありますが、導入するのには TCA のライブラリーが必要なのが個人的にネックでした。アーキテクチャをライブラリーに依存させたくないので。 ↩