34
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ViDRep(仮)という SwiftUI 向けのアーキテクチャを作ってみた

Last updated at Posted at 2021-05-27

TL;DR

下記のリポジトリにサンプルプロジェクトを上げたのでご参考に:

CatSaysHi.gif

ちなみに読み方は「ヴィッドレップ」です。ただこれ考案当初に付けた名前ですが、今色々微妙に変わったし、そもそもキーボードで ViDRep すっごい打ちづらいので、新しい名前が欲しいかも。

謝辞

いきなりですが、上記のサンプルプロジェクトの制作にあたって、パブリック API として https://cataas.com/ を利用させていただきました。可愛い猫を返してくれる手軽な REST API ですので、ぜひお試しください!(なお筆者は実は犬派です。)

なぜまた新しいアーキテクチャを作ったか

まあ世の中にすでにいっぱいアーキテクチャありますよね、古き良き MVC や MVP から、最近巷で話題の Redux や TCA など、今いろんなアーキテクチャが提案されて色んな意味でアーキテクチャ戦国時代ですね(違)。そんな中なぜ既存のアーキテクチャを使わずにまた新しいアーキテクチャを考案するかというと、今のところ「SwiftUI に特化したアーキテクチャがない1」というのが一番のきっかけとなった原因です。せっかく会社業務で SwiftUI オンリーで新しいプロジェクト作れるんだから、SwiftUI で手に入れた色々な新しい機能や書き方をフルに活用したいです。

なのでどうせアーキテクチャを作るなら、どんなアーキテクチャを作りたいのか、ということを考えると、5つの目標を設定しました:

  1. 単一方向データフロー:Flux などのおかげで最近再び市民権を得つつある(違)単一方向のデータフロー。やっぱ単一方向の方が、データの流れが追いやすいですよね。
  2. Single Source of Truth:まあこれ SwiftUI の哲学ですね、守らない手はないでしょう。
  3. ObservableObject などの Combine 機能の活用:Combine と SwiftUI の相性が非常にいいので、できればもう RxSwift などの外部ライブラリーを使いたくないですね。
  4. テスト可能:まあ言うまでもないですね。テストできない設計なんて誰得なのか。
  5. スケーラブル:プロジェクトって時期によって規模が変わってくるから、やはり大きすぎるのも小さすぎるのも大変ですよね。可能ならそれに合わせて設計自体もスケーラビリティ持てるといいなと思いました。まあ大抵の設計も「雰囲気」で厳密な正しい仕様にはならないことが多いですが、その「雰囲気」によって出る「曖昧さ」を「明確化」したいですね。

というわけで実際の社内プロジェクトにも適用してみて、最終的に仕上がったのがこの ViDRep というアーキテクチャです。

どんなアーキテクチャ

一枚の図で示すなら、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 のテンプレコードとほぼ変わらないです。ここでやったことは二つだけで:

  1. @StateObject として AppResolver を持ちます。
  2. アプリの最初の画面として 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)
        // ...
        }
    }
    
}

AppResolverObservable として、アプリに必要な AppRepository などのコンポーネントを作ります;更に AppRouterResolverDelegate として、必要なビューもここで作ってあげて返してあげます。

また、AppRepository がアプリの唯一の Single Source of Truth なので、AppResolverobjectWillChange パブリッシャーは、AppRepository のそれを返します。

そして先程の makeView() の内容は実は AppRoutermakeAppView だと判明したので、次に 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 つに分けられます。ちなみにデータと部品を分けて定義するのは、enumstruct によってネームスペースが簡単に作られるからです。また、データ表示に必要な Statestate() メソッドで取得してもらいますが、ここで例えば複数の画面で同じ Scene ファイルを流用した場合、必要なデータの出しわけのために state(parameter: Parameter) のように引数を渡しても問題ないです。この画面はその処理がないですが、隣の ImagePreviewScene はこのように作られています。必要かどうかの判断基準として、わかりやすいものならこの Scene 固有のプロパティー、例えば ImagePreviewScene なら var image: CatImage のようなプロパティーなら、state() メソッドの引数として渡して問題ないと考えています。

依存定義が済んだら次に実装に入りますが、上記の部品は全て protocol で定義されており、特に RepositoryObservableObject に適用する必要があるので、protocol の存在型をそのままプロパティーの型として使えないため、ジェネリクスを使う必要があります。それ以外は普通の View の実装と大差はありません。表示に必要なデータは Repository から取得すればいいし、逆に何かデータインプットなどのアクションを起こすなら Dispatcher を通して処理してもらいます。また、必要に応じて onChangeRepository から取得した特定のデータを監視することも可能です。最後に画面遷移が必要になったら、同じく 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 つの目標を挙げました。簡単に設計と実装のそれぞれの解説を読んだ今、改めてその目標を達成できたかみてみましょう。

  1. 単一方向データフロー:これはもう見ての通り、View→Controller→Model→View の一方通行のデータフローですね。
  2. Single Source of Truth:全ての情報源は Repository にあります。あなたは常に Repository を信用できます。
  3. ObservableObject などの Combine 機能の活用:Repository は ObservableObject なので、View からの利用は非常にしやすいし、本当に素直にそのまま利用しているだけなので、変な落とし穴とかもあまりないと思います。
  4. テスト可能:Reducer は純粋関数なのでそのままテストしやすいし、Dispatcher もモック入れてあげればテスト可能です。さらに ViewInspector とかのライブラリーを入れてあげれば View もユニットテストが可能です。
  5. スケーラブル:コンパクトにしたいなら Reducer や APIClient を省いたり、逆にきっちりしたいなら Router や Resolver を入れてあげたりすることも可能で、かつどこに何を入れる/無くすべきかを明確しているので、多分さまざまな規模のプロジェクトに適用可能かと思います。サンプルプロジェクトは一番きっちりな作りにしていますが。

うん、振り返ってみると、我ながら悪くない出来だと思います(自画自賛)。

じゃあ俺のプロジェクトに ViDRep を導入すべきか/ViDRep の弱点は何か

設計に銀の弾丸はないとよく言われます。ご自身のプロジェクトの特性と、さまざまな設計の良し悪しを把握した上でご判断ください。例えば ViDRep ならこんなメリデメがあると思います:

メリット

  • SwiftUI に特化した設計のため、SwiftUI の特性を活かしている
  • テストのしやすさを確保しながら、さまざまな規模のプロジェクトに対応可能
  • 設計自体は外部のライブラリーに依存しない

デメリット

  • 明確な ViewModel がないため、描画ロジックが複雑なアプリなら、Repository の依存適合の実装でコードがやや Fat になりがち
    • ViewModel を入れるという手もありますが、そうすると一応 View と ViewModel のデータバインディングという双方向のデータフローに対処する必要があり、人によって好みが分かれる
  • SwiftUI の描画特性に強く依存しているため、UIKit のプロジェクトには適用しにくい
  • 機能によって Dispatcher が持つ依存が膨らみがちで、サーバ通信や永続化処理などと言ったあまり Controller 層が直接ハンドリングしないようなものも Dispatcher がハンドリングしている

と言ったところでしょうか。ご自身のプロジェクトの状況をよく分析した上でご検討ください。

ViDRep って名前、覚えづらいし言いづらいし打ちづらい。どうにかならんの?

はい、おっしゃる通りです。新しい名前募集したいです。

  1. 正確には TCA は一応 SwiftUI 向けに作られたアーキテクチャではありますが、導入するのには TCA のライブラリーが必要なのが個人的にネックでした。アーキテクチャをライブラリーに依存させたくないので。

34
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?