1
/
5

abceed iOSの設計アーキテクチャを改善しました

※この記事は下記note記事の転載になります。

https://note.com/globee/n/n63d6c178cf31

アプリチームリードエンジニアの鈴木 俊裕です。
アプリ開発チームの体制はそこまで大きく変わっていませんが、設計アーキテクチャの観点で少しブレークスルーがありましたので、その過程と現在の状況を共有したいと思います。

目次

  1. Clean Architectureのこれまでの運用
  2. 神UseCase
  3. UseCase, Presenterに何を書くのか問題
  4. Clean Architecture + Flux
  5. Presenterは交通整理役👮🏻‍♂️
  6. ケーススタディ
  7. コード見せて
  8. まとめ

Clean Architectureのこれまでの運用

abceedのiOSアプリはもともと"ゆるいClean Architecture"を採用して運用していたのですが、基本的にPresenterは存在しませんでした。ViewControllerから直接UseCase.findXXXといったAPIコールをするようなイメージですので、当然ViewControllerは結構Fatでした。スレッドの制御も暗黙的で、例えば複数スレッドから同じDictionaryへアクセスすることによるクラッシュも頻発していました。

とりあえずこれは良くないよねということで、新規で作る画面についてはUseCaseとPresenterにロジックがまとまるようにして行きました。APIコールなどデータ取得はデータレイヤー(Repository)に寄せて、Viewから見えるインターフェースにはならないようにしていきました。

神UseCase

方針自体は良かったのですが、今度はUseCaseにロジックが集中してしまい、神UseCaseが出来上がってしまうケースがありました。UseCaseとPresenterも双方向にイベントが流れており、メンテナンス性も低い状態になってしまいました。

これについてチームで振り返りを行い、単方向イベントフローを担保することを目的に、Action,Dispatcher,Storeを設計コンポーネントとして用意することになりました。

UseCase, Presenterに何を書くのか問題

上記方針に基づいて神UseCaseもリファクタリングされ、結果的にこれは良い方向に働くことが確認できました。Action => Dispatcher => UseCase => Store => Presenter => Viewという単方向イベントフローの担保によって、想像以上にコードの見通しが良くなりました。

ただ、実際に作ってみると更なる懸念事項が出てきました。そもそもClean ArchitectureにおけるUseCaseやPresenter、それぞれに何を書くべきかというところが曖昧だったのです。

Clean Architecture + Flux

結論から共有すると、以下のような棲み分けに落ち着いています。いくつか守るべき鉄則があり、太字にしています。

- Action: 動詞のインターフェースを定義する。
- Dispatcher: UseCaseとPresenterが購読する。
- UseCase: Actionで指示されたことだけやる。計算結果の状態があるならStoreやRepositoryに反映する。基本的に中間状態は持たない。(Rx.withLatestFromも基本的にはダメ)
- Store: UseCaseからcommitされた状態を保持して、Reactiveな状態として公開するだけ。Viewの状態は持たない。
- Presenter: 交通整理(後述)

「Viewの状態は持たない」のは意識すればそんなに難しくはないですが、「Actionで指示されたことだけ」「中間状態は持たない」この2つをちゃんとやるのは意外と難しいです。しかし、ちゃんと守るとそれだけ見通しの良いシンプルなコードの集まりになります。もちろんTDDみたいなもので、100%ちゃんと守っているとそれはそれでコストになるので、匙加減が大事だと思っています。

Presenterは交通整理役👮🏻‍♂️

Presenterはちょっとポイントがあって、細かく見ると以下の2種類の作業をします。

- Actionで指示されたことだけやって、結果をまたActionに渡す。
- Storeの状態をViewに反映する。

基本的にActionとStoreからいろんなイベントが飛んでくるので、それを内部(UseCase)や外部(View)に適切に振り分ける交通整理の役割です。一般的にPresenterがUIKitにべったり依存するケースってよく見るのですが、それとは異なる位置付けです。基本的にUIKitの依存は排除し、PresenterOutputのprotocolを介してのみView側に指示を送ります。

ケーススタディ

例えば「数秒ごとにAPIをpollingして🍑があれば画面に反映する」仕様があった場合、この設計なら以下のように実装するとスッキリします。

- Presenterでタイマー発火=>Action.checkForUpdate()を呼ぶ。
- UseCaseでcheckForUpdateの指示を受けてAPIコール、レスポンスをStoreにコミットする。(APIコールが戻ってこないときにflatMapFirstなのかflatMapLatestなのかはケースバイケース)
- PresenterがStoreから流れてきたレスポンスを見て🍑があればView用のデータに変換したり(しなかったり)してoutputに渡す。
- Viewが🍑を画面に表示する。

コード見せて

うまく伝わってないかもしれないので、コードも共有します。新規開発だとこの設計を繰り返し適用するので、Xcode Templateを用意して以下のようなコードが一瞬で生成されるようにしてあります。(var outputのweakつけ忘れが激減しました。)

以下のような点がポイントでしょうか。

- enumのネームスペースでクラスやデータ型の命名を簡潔に
- Action,UseCase,Presenterの依存関係を適切に生成するBuilder
- 画面専用のエラー型

import RxRelay
import RxSwift

/// ネームスペース
enum Hello {
   enum ErrorType: Error {}
}

protocol HelloPresenterOutput: AnyObject {
}

extension Hello {

   final class Builder {

       private let action: Action
       private let dispatcher = Dispatcher()
       private let useCase: UseCase
       private let store = Store()

       init() {
           action = Action(dispatcher: dispatcher)
           useCase = UseCase(dispatcher: dispatcher, store: store)
       }

       func buildPresenter() -> Presenter { Presenter(action: action, dispatcher: dispatcher, store: store) }
       func buildViewController() -> ViewController { ViewController(self, presenter: buildPresenter()) }
   }

   final class Action {

       let dispatcher: Dispatcher

       init(dispatcher: Dispatcher) {
           self.dispatcher = dispatcher
       }
   }

   final class Dispatcher {
   }

   final class UseCase {

       private let dispatcher: Dispatcher
       private let store: Store
       private let userRepository: UserRepositoryType
       private let scheduler: SchedulerFactory
       private let disposeBag = DisposeBag()

       init(
           dispatcher: Dispatcher,
           store: Store,
           userRepository: UserRepositoryType = UserRepository.shared,
           scheduler: SchedulerFactory = SchedulerFactoryImpl.shared
       ) {
           self.dispatcher = dispatcher
           self.store = store
           self.userRepository = userRepository
           self.scheduler = scheduler
       }

       deinit { LogTrace() }
   }

   final class Store {
       @PublishedProperty(value: nil)
       var error: Property<ErrorType?>

       func commitError(_ value: ErrorType?) {
           _error.projectedValue.accept(value)
       }
   }

   final class Presenter {
       typealias Output = HelloPresenterOutput

       private weak var output: Output?
       let action: Action
       private let dispatcher: Dispatcher
       private let store: Store
       private let scheduler: SchedulerFactory
       private let disposeBag = DisposeBag()

       init(
           action: Action,
           dispatcher: Dispatcher,
           store: Store,
           scheduler: SchedulerFactory = SchedulerFactoryImpl.shared
       ) {
           self.action = action
           self.dispatcher = dispatcher
           self.store = store
           self.scheduler = scheduler
       }

       deinit { LogTrace() }

       func inject(_ output: Output) {
           self.output = output
       }
   }
}

まとめ

iOSアプリの現在の設計を共有してみましたが、いかがだったでしょうか?画面遷移やデータ共有については触れてませんが、今回はここまでにしておきます。
ここまでくるのに1年間苦労しましたが、開発速度が以前に比べて格段に上がったと思います。鉄則を守ることでこれ以上ないほどシンプルなコードになるので、以前のように「テストを書かないとちゃんと動くか不安」というモチベーションでテスト実装することがほとんどなくなりました。本当にコアなビジネスロジックくらいにしか今はテスト書いてないです。この状態であれば少ない人的リソースでもUIテストやCIなど本当に重要な課題にリソースを割くことができそうだなと感じています。

ただ、そのような重要な改善Issueはカンバンボードに山積しているままですし、テスト書く時間ないよりはあった方がいい(楽しいし知見になる)ので、Globeeの事業に興味をお持ちのあなた、ぜひ一緒にいい開発組織を作りませんか?
ベンチャーらしくスピード感のある意思決定が魅力の1つと思いますが、デザインチームも新たに動き出しており、今後さらに開発は活発化していくと思います。
ご応募お待ちしています!

株式会社Globeeでは一緒に働く仲間を募集しています
1 いいね!
1 いいね!
同じタグの記事
今週のランキング