見出し画像

iOSの新規アプリをリアクティブな設計にしたお話

この記事は NAVITIME JAPAN Advent Calendar 2020 の 24日目の記事です。

こんにちは、ロング、ビッグ、ストーンです。ナビタイムジャパンでとあるアプリのリニューアルを行っております。

はじめに

今回、アプリのiOS版リニューアルを行うにあたって設計を見直しました。

その際、データフローの管理の可読性の観点などからリアクティブな設計としました。特に今回のアプリでは、会員状態や様々なデータを複数の画面・アプリ全体を通して監視・利用するシーンが多いため、リアクティブな設計にすることによるメリットが大きいと考えられたからです。

この記事では、そういった設計の内容と、それによって得られたメリットや所感などをお話しします。

また、リアクティブな実装を実現するにあたって、Combineを導入することにしました。こちらの導入の経緯、検討したことなどをお話しします。

Combineとは

CombineとはAppleがWWDC 2019にて発表した、非同期処理を宣言的でリアクティブな方法で記述できる新しいFrameworkです。
非同期処理は今までKVONotificationCenter、またサードパーティライブラリのRxSwiftなどで実現していたことが多いかと思いますが、Appleが公式にサポートする仕組みを新たに用意した形です。

なぜCombine?

今回Combineを採用したのにはいくつか理由があります。

まず最初に、今後のiOSアプリ開発のスタンダードになっていくと考えられたからです。
NotificationCenterやURLSessionにもCombineに対応したI/Fが存在することから、Appleは今後自社のFrameworkに積極的に導入することが見込まれました。

また、Appleが提供するFrameworkと言うことは、新iOSへのサポートもリリースと同時に対応すると思われます。これはアプリを長期間運用していく上で、重要な部分ではないかと私は考えています。

最後に、SwiftUIと組み合わせた際に、相性が良いことが挙げられます。
現在、我々のアプリはInterface BuilderでUIを作成していますが、今後SwiftUIへの移行も可能性として考えています。
その際に、Combineをベースとした開発を事前に行っておけば、SwiftUIへの移行も比較的スムーズに行えるのではないかと予想しています。

上記理由から、今回Combineの採用することとし、設計を考えていくこととしました。

今回の設計の方針と概要

・MVVM+レイヤードアーキテクチャとした
1つあたりのファイルサイズの肥大化を防ぎつつ、責務分離を実現するため、MVVMを採用しました。
PresentationレイヤーはViewControllerとViewModelに分け、Modelレイヤーは役割ごとにさらに細かく分割しています(詳細は後述します)。
また、画面遷移や、ViewControllerをまたぐような操作に関しては、Routerというクラスを用意し、ViewControllerから処理を切り出しました。ViewControllerやViewModelに記載することもできますが、今後アプリの規模が大きくなったときの肥大化を防ぎつつ、役割をより明確にすることができていると思います。

・責務分離を徹底したEmbedded Frameworkを用いたレイヤー分け
各レイヤーの参照の方向と、レイヤー内での参照保護を、ビルドレベルで強制させるため、Embedded Frameworkを用いて各レイヤーをモジュール化しました。
どのようにモジュール分けしたかは、後ほど説明します。

・単方向なデータフロー 
アプリ開発において、複数画面で状態を同期する必要がある場合や、状態を複数の箇所で保持してしまい不整合が起こる、といった問題に悩まされる人も多いかと思います。
そのような、アプリ内での状態管理にまつわる問題に悩まされることのないように、状態管理データフローは単方向としました。

単方向なデータフローの実装には、データを保持する役割のレイヤーから状態を監視するような形を取りました。各レイヤーのprotocolはinput/outputで分け、inputでは戻り値無し、データの取得結果はoutputからCombineのPublisherの形で流れてくるデータを監視します。

画像2

このように、Combineを使ってデータフローを単方向にするアプローチは、SwiftUIの状態とデータフローに関するドキュメントでも紹介されています。

単方向なデータフローは、今まではサードパーティのライブラリを使うことが多かったと思いますが、今後はCombineを使った実装が主流になっていくかと思います。

・DiffableDataSource + Combine のシナジー
Combineは、iOS 13.0から利用可能なDiffableDataSourceと非常に相性が良いと感じました。Combineを利用して流れてくるデータを監視し、DiffableDataSourceに対して更新を適応させるのみで、UIへの適応が可能になります。

また、`Diffable`という名の通り、更新前と更新後のデータ差分を抽出し、アニメーションでUI更新をしてくれる点も嬉しいポイントです。DiffableDataSourceで扱うデータは、同一性の検証のためHashableに適合する必要があります。

アプリで実装した、CombineとDiffableDatasourceを組み合わせた実装例を紹介します。UIに関わる処理なので、ViewControllerでの実装になります。

let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
                     collectionView, indexPath, item in
                         let cell = collectionView.dequeue(TitleCell.self, for: indexPath)
                         cell.apply(title: item.title, iconImage: item.image)
                         return cell
                 }
UICollectionViewに紐付いたUICollectionViewDiffableDatasourceを作成します。イニシャライザで、データに変更があった場合のCellのUI構築のコードをClosureとして渡します。作成したdataSourceは更新の際に使うため、ViewControllerのプロパティとして保持しておきます。
viewModel.output.observe()
         .receive(on: DispatchQueue.main)
         .sink { [self] items in
             guard var snapshot = dataSource?.snapshot() else {
                 return
             }
             snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .list))
             snapshot.appendItems(items, toSection: .list)
             dataSource?.apply(snapshot, animatingDifferences: true)
           }
         .store(in: &cancellables)
ViewModelからのデータ更新を監視し、UIに反映させます。
dataSourceに対して、更新後の状態のsnapshotを適用することで、リストUIの更新が行われます。


今回のアプリ開発の中で、 リストUIを実装する上でこのパターンを非常に多く用いました。
また、説明は省きますが、iOS 13.0から使えるようになったUICollectionViewCompositionalLayoutも、様々なレイアウトを構築する上で今回の手法に相性がよく、有用なものでした。

・今回のアプリ設計のレイヤー構成について
前述したように、それぞれ参照・依存するレイヤーが厳密に定められており、各レイヤーをEmbedded Frameworkで別モジュール化することで、ビルドレベルでの設計の保守が可能になっています。

今回はメインとなるApplicationTargetのPresentationレイヤーと、Embedded Frameworkを利用した4つのレイヤーの合計5つとなりました。各レイヤーの役割・別レイヤーへの依存関係、I/Fは以下のようになっています。

スクリーンショット 2020-12-16 16.20.56

Application(Presentation)レイヤー

- Role
 - UI表示とUIの操作にかかわる処理を行う
 - UserからのUI操作を受け、UseCaseの呼び出しを行う
- Dependency
 - UseCase、Commonへの参照を持つ
- Input
 - UserからのUI操作
- Output
 - UI表示

UseCaseレイヤー

- Role
 - UIに関わらない、アプリの仕様に必要な処理を行う
 - Domainレイヤーから返却されたデータやエラーを、アプリ仕様に合わせてApplicationレイヤーで解釈可能な形式に結合・変換する
- Dependency
 - Domain、Commonへの参照を持つ
- Input
 - データ取得/保存に必要な情報 (keyword/idなど)
 - `func fetchXXX(param: YYY)`
- Output
 - Domainから取得した結果を、
      アプリ仕様に合わせて加工したデータのPublisher
 - `func observeXXX() -> Publisher<XXX>` 

Domainレイヤー

- Role
 - プラットフォームのAPIやアプリ仕様に依存しない純粋な形での、データの保存と取得を実現する
 - 特定のAPIに依存させないため、DataSourceプロトコルを用意し、DIでプロトコルに適合するインスタンスを受け取る。そのインスタンスが特定のAPIを含んだ実行処理を持つため、Domainは環境に依存しないIFの提供が可能になる。
 - どのレイヤー・環境にも依存しない(Commonは例外)
- Dependency
 - Commonへのみ参照を持つ
- Input
 - データ取得/保存に必要な情報 (keyword/idなど)
 - `func fetchXXX(paramc: YYY)`
- Output
 - 取得した純粋なデータ型や結果のPublisher
 - `func observeXXX() -> Publisher<XXX>`

Infraレイヤー

- Role
 - プラットフォームのAPIやサーバーの仕様に依存したデータのやり取りを行う
 - Domainの用意した、DataSourceプロトコルを実現する
- Dependency
 - Domain、Commonへの参照を持つ
- Input/Output
 - データ取得と保存に必要な情報(keyword/idなど)を受け取り、リクエスト毎に対応したPublisherを直接返却する
 - `func getXXX(param: YYY) -> Publisher<XXX>`

Commonレイヤー

- Role
 - レイヤーをまたいで利用されるEntityやExtension、Utilityを持つ
 - すべてのレイヤーから参照が可能
- Dependency
 - どこへも参照を持たない
- Input/Output
 - 特になし(各Classによるため)

以上の階層化された5つのレイヤーを利用することで
- 責務分離の徹底
- 単方向なデータフロー
- 必要な処理、定義の共通化
- クラスの肥大化の防止
が実現できました。

レイヤーを分けたことにより、必要なクラス数、ファイル数などは当然増えました。しかし、レイヤーが整理され、各クラスの役割と実装するべき内容ががはっきりしているため、非常に運用しやすい状態になっています。
共通認識も明確なため、コードレビューやプルリクもスムーズに進めることができました。
 
・Combineの利用について
このようなアーキテクチャの中での、各レイヤー間のデータのやり取りにもCombineを利用しています。
以下は、Repositoryのサンプルコードです。概ねすべてのRepositoryは同様のコード構成になっています。

// InputのInterface
public protocol SampleRepositoryInput {
   func fetch(key: String)
}

// OutputのInterface
public protocol SampleRepositoryOutput {
   func observe() -> RelayPublisher<Result<String, Error>>
}

// Input / Outputを束ねる
public protocol SampleRepositoryInterface {
   var input: SampleRepositoryInput { get }
   var output: SampleRepositoryOutput { get }
}

// Repository本体。 SampleRepositoryInterfaceに適合します。
public final class SampleRepository: SampleRepositoryInterface {

   public var input: SampleRepositoryInput { self }
   public var output: SampleRepositoryOutput { self }

   // CurrentValueSubjectにすることで、値を別で保つ必要がなく、このSubjectのみでのデータ管理を保証できる
   private let subject = CurrentValueSubject<Result<String, Error>?, Never>(nil)
   private let dataSource: PoiDetailDataSource
   private var cancellables = Set<AnyCancellable>()

   public init(dataSource: PoiDetailDataSource) {
       self.dataSource = dataSource
   }
}

extension SampleRepository: SampleRepositoryInput {

   public func fetch(key: String) {
   
       // dataSource経由でデータを取得
       dataSource.search(key: key)
           .subscribe(on: DispatchQueue.global())
           .sink { [self] completion in
               if case .failure(let error) = completion {
                   // 結果をsubjectへSend
                   subject.send(.failure("取得に失敗"))
               }
               
           } receiveValue: { [self] response in
               // 結果をsubjectへSend
               subject.send(.success(response))
               
           }.store(in: &cancellables)
   }
}

extension SampleRepository: SampleRepositoryOutput {
   public func observe() -> RelayPublisher<Result<String, Error>> {
       // subjectからpublishを行い、outputのInterfaceへ適合させる
       subject.eraseToAnyPublisher()
   }
}
RepositoryはSubjectを持ち、Datasourceから取得したデータを購読可能な状態でPublisherとして公開します。

Subjectを利用することで、非同期のデータの取得・保持・監視が非常にシンプルな実装で行えました。
また、前述したとおり、リクエストなどを受け付けるInputのI/Fには戻り値はなく、データの引き渡しにはOutputでCombineのPublisherを渡して、Subscribeをしてもらう構造となっています。
このようなCombineを利用した実装により、データを利用する際はRepositoryに対して宣言的にデータの監視を行い、データの更新やリクエストに応じて自動的にデータを受け取ることが可能になります。

他にも、レイヤー間のI/FやUI周りだけでなく、位置情報の取得や、ログイン状態の監視など、アプリ内の各所で同様にCombineを利用しています。

所感

実際に、今回の設計の結果、アプリ全体でデータの扱い方が統一され、責務分離が適切に行われていた点などがうまく作用し、スムーズに開発が進みました。
また、リアクティブなFrameworkの扱いの経験があれば、Combineの利用に戸惑うことは少なかったと感じました。一方で、経験のないメンバーからは、学習に多少時間がかかったという意見もあがりました。
それでも、設計に組み込みやすく、一度ノウハウがわかれば開発スピードも向上し、導入するだけのメリットがありました。

最後に

今回はリニューアルアプリの設計の紹介と、Combineを利用したアプリ開発についてお話ししました。

今回採用したCombineを取り入れた設計は一例であり、必ずしもすべてのケースで上手くいくとは限りません。今後Combineを設計に取り入れる際の参考になれば幸いです。