yujiro's blog

エンジニアリング全般の事書きます

iOSアプリにクリーンアーキテクチャをいれてみた

この記事では、iOSアプリで実際にクリーンアーキテクチャを取り入れてみた例を紹介します。

クリーンアーキテクチャが何か、というところについてはここでは割愛します。

定義・前提等の全体感は省略させていだき、iOSアプリに取り入れる上で必要なクリーンアーキテクチャの知識は触れられればと思っています。

構成

まず、全体の構成要素は以下になりました。

図1 f:id:bambookun:20191104201001p:plain

レイヤー部分の説明からいくと、レイヤーは3層に分けています。

App・Domain・Infraの3つで、これらをEmbedded Framework化しています。

Embedded Framework を取り入れることによるメリットは大きく下記の2つだと考えていて、

  • 依存ルールが敷かれる
    • App はすべてのレイヤーを参照できる
    • Domain は自身以外なにも認識できない
    • Infra はDomain だけ認識できる
  • 差分ビルドによるビルド時間短縮

です。

1点目が特に重要で、開発者がルールから逸脱した実装をできないようにする、のがミソです。

その中でも特筆すべきはDomain レイヤの要素から、外レイヤに矢印が伸びないようにしているところで、ポイントはRepository のインターフェースです。

クリーンアーキテクチャにおいては、下記の円状の図で紹介されますが、

図2 f:id:bambookun:20191103222826j:plain

参照元 : https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

内側のものは外側を知ってはいけない、というのが大前提として存在します。

今回は図1であるように、大きく3層に分けて、Domainレイヤーが最も内側に来る要素となります。

なので、Domain から外側のレイヤーに向かって矢印が向くのはNGとなります。

従って、Repository のインタフェースをDomainレイヤーに置き、Infraレイヤーにある実態(RepositoryImpl)からimport して実装を書きます。

つまり依存関係逆転の法則(DIP)を用いています。

処理はUseCase からRepository へと流れますが、依存の関係は逆、ということになります。

各レイヤー・構成要素の役割

次に、各レイヤー・構成要素の役割です。

App

クリーンアーキテクチャでは、View 側はView とPresenter で構成されています。

Presenter はHumble Object パターンというデザインパターンが基になっています。

Humble Objet パターンについては、クリーンアーキテクチャ本に懇切丁寧にこれ以上ないぐらい分かりやすく書かれているので、引用させていただきます。

Humble Objectパターンは、ユニットテストを実行する人が、テストしにくい振る舞いとテストしやすい振る舞いを分離するために生み出されたデザインパターンである。アイデアは非常にシンプルだ。振る舞いを2つのモジュールまたはクラスに分割するだけである。ひとつのモジュールは「Humble(控えめ)」で、ここではテストが難しい振る舞いのみが含まれる。もうひとつのモジュールには、Humble Objectから取り除かれたテストしやすい振る舞いが含まれる。 たとえば、GUIユニットテストを書くのは難しい。なぜなら、画面に適切な要素が表示されているかを確認するテストを書くのが非常に難しいからだ。しかし、GUIの振る舞いの大部分は、簡単にテストできる。Humble Objectパターンを使えば、2種類の振る舞いをPresenterとViewの2つのクラスに分けられる。

「Clean Architecture 達人に学ぶソフトウェアの構造と設計 - 第23章 プレゼンターとHumble Object」

例えば「画面描画時に通信をして、ラベルにユーザー名を表示する」という要件があったとした場合、以下のようになります。

protocol Presentable {
    func onViewDidLoad()
}

protocol Viewable {
    func showUserNameLabel(userName: String)
}

class Presenter: Presentable {
    ...
    func onViewDidLoad() {
        ... 通信処理
        self.view?.showUserNameLabel(userName: String)
    }
}

class ViewController: UIViewController {
    ...
    override func viewDidLoad() {
        super.viewDidLoad()
        self.presenter.onViewDidLoad()
    }
}

extension ViewController: Viewable {
    func showUserNameLabel(userName: String) {
        self.userNameLabel.text = userName
    }
}

Presenterが依存するのは、ViewのインターフェースにしてDIすれば、容易にテストが書けます。

一つやりがちなミスとして代表的なのが「Presenter に返り値のあるメソッドを作ってしまう」ことです。

これだとViewがPresenterから値を取得してから後続処理をすることになりますので、View側からロジックを排除することに失敗しています。

あくまでViewは各ライフサイクル、イベントの発生時にPresenterに通知するのみに留めておくべきです。

マッパーとViewDataについて

ViewData は「各Viewに1対1で対応する値オブジェクト」という位置づけにしています。

任意の単位で生成しているカスタムViewに対応するデータです。

カスタムViewは対応するViewDataを持たせて、容易にデータの反映ができるようにします。

さて、このViewDataをいつ、どこで生成するかという話ですが、Presenter がプレゼンテーションを行うときにマッパー経由で生成して、ViewControllerに渡します。

生成はマッパーが行います。

Entity からViewDataに変換するマッパーがEntityの拡張になっており、そのマッパーがViewDataをnew して返します。

マッパーは拡張ではなくstatic なものでもいいのですが、主にはEntity の値を基にバリューオブジェクトを生成しているだけですので、拡張のほうが楽だなというところでそうしているぐらいです。

ちなみに後で紹介するEntity の生成も、マッパーをクラス拡張させて作成しています。

このPreseter がEntityから ViewDataを生成して ViewController に渡してViewを描画する所までをコードで書いてみます。

フリマアプリっぽい感じで、商品ページのフッターに価格といいねボタンとコメントボタンがあるのを想定してコードを書いてみます。

■ Entity

struct Product {
    let title: String
    let description: String
    let price: Int
    let categoryId: Int
    let likeCount: Int
    let comments: [Comment]
    let userId: Int
}

struct Comment {
    let userId: Int
    let comment: String
}

■ Mapper

extension Product {
    func convertToProductBottomViewData() -> ProductBottomViewData {
        return ProductBottomViewData(likeCountLabelText: self.likeCount.description,
                                     commentCountLabelText: self.comments.count.description,
                                     priceLabelText: self.price.description)
    }
}

■ ViewData

struct ProductBottomViewData {
    let likeCountLabelText: String
    let commentCountLabelText: String
    let priceLabelText: String
}

■ Presenter

class ProductDetailPresenter: ProductDetailPresentable {
    
    ...
    
    func presentProductDetail() {
        // 通信コールバック...
        self.view?.showProductBottomView(entity.convertToProductBottomViewData())
    }
}

■ ViewController

class ProductDetailViewController: ProductDetailViewable {
    
    @IBOutlet weak var productBottomView: ProductBottomView
    
    ...
    
    func showProductBottomView(_ viewData: ProductBottomViewData) {
        self.productBottomView.show(viewData)
    }
}

■ カスタムビュー

class ProductBottomView: UIView {
    @IBOutlet weak var likeCountLabel: UILabel
    @IBOutlet weak var commentCountLabel: UILabel
    @IBOutlet weak var priceLabel: UILabel
    ...

    func show(_ viewData: ProductBottomViewData) {
        self.likeCountLabel = viewData.likeCountLabelText
        self.commentCountLabel = viewData.commentCountLabelText
        self.priceLabel = viewData.priceLabelText
        ...
    }
}

Viewcontroller はカスタムビューにデータを渡してViewを描画するように指示します。

Domain

Domain の構成要素はエンティティ、ユースケースです。

これらについての説明をしますと、

エンティティ : ビジネスロジックの結果がデータとして格納されてる値オブジェクト

ユースケース : UIからの窓口。UIから処理を受け取ったらデータを収集し、エンティティを作成し、UIに戻す。

といった具合です。

さらにここではマッパーを登場させています。

データ(上図 Schema)からエンティティを生成するためのものです。

主にビジネスロジックはここに集約されています。

Schema はInfra のところでも説明しますが、API から取得できるJSONデータをオブジェクトに変換したものです。

ネイティブアプリにおけるビジネスロジックとは、という永遠のテーマはありますが、ここでは「UI以外のロジック」という捉え方をしています。

ここまでの定型的なパターンを紹介したいと思いますが、あまりビジネスロジックがないと参考にならなそうです。

なので、フリマアプリを想定した商品ページでユーザー情報と商品情報を元に商品詳細エンティティを作成する、といったところを例にあげたいと思います。

■ Schema

struct ProductDetailSchema {
    let title: String
    let description: String
    let price: Int
    let categoryId: Int
    let likeCount: Int
    let comments: [Comment]
    let userId: Int
    let buyable: Bool
    
    struct Comment {
        let userId: Int
        let comment: String
    }
}

■ Entity

struct ProductDetail {
    let title: String
    let description: String
    let price: Int
    let likeCount: Int
    let comments: [Comment]
    let userId: Int
    let buyable: Bool
}

struct Comment {
    let comment: String
    let isOwn: Bool
}

struct User {
    let id: String
    let name: String
    let birthday: Date
    let login: Bool
    let authenticated: Bool
}

■ マッパー

extension ProductDetailSchema {
    
    func convertToProductDetail(user: User) -> ProductDetail {
        return ProductDetail(title: self.title,
                             description: self.description,
                             price: self.price,
                             likeCount: self.likeCount,
                             comments: self.getComments(user),
                             userId: self.userId,
                             buyable: self.getBuyable(user))
    }
    
    private func getBuyable(_ user: User) -> Bool {
        return user.login && user.authenticated
    }
    
    private func getComments(_ user: User) -> Comment {
        return self.comments.map {
            Comment(comment: $0.comment,
                    isOwn: $0.userId == user.id)
        }
    }
}

■ UseCase

class ProductUseCaseImpl: ProductUseCase {
    
    
    func fetchProductDetail(productId: String) -> Single<ProductDetail> {
        return Single.create { subscriber in
            let user = self.userRepository.getUserLocal() //ユーザーエンティティをローカルデータから取得
            self.productRepository.getDetail(productId: productId) // APIからデータ(スキーマ)を取得
              .subscribe(onSuccess: { [weak self] schema in
                schema.convertToProductDetail(user)
            }, onError: { error in
                subscriber(.error(error))
            })
            .disposed(by: self.disposeBag)
        }
    }
}

簡単な例ですが、ポイントはマッパーがビジネスロジックを適用して、ビジネスルールを値として作成し、それを基にentityを生成しているところです。

ここでのビジネスルールはユーザーがログイン、本人認証をしていないと購入手続きができない、といったものです。

そしてユースケースがデータを収集してエンティティを生成するところまで担っています。

非同期処理はRx を使っていて、各ストリーム処理もユースケースが担うようにつくります。

Infra

インフラの構成要素は、リポジトリ(実態)です。

リポジトリAPI、DB、ファイルまたはメモリからデータを取得、格納、変更、削除します。

API から取得したものはCodable を使ってJSONからオブジェクトに変換します。

DB はローカルDBです。iOSだとCoreData とかレルム を使うことが多いでしょう。

ファイルはplist ですね。

これら2つは永続化したいもの、例えばユーザー情報とか検索に使った情報が例としてあげられると思います。

メモリはインスタンス変数を使います。1時的にデータを保存させたいときにリポジトリインスタンス変数にキャッシュさせておきます。

よくあるのが、画面描画時にデータを取得するんだけど、ボタン押下時など他のイベント発生時に再度Entityがほしい、といったときにリポジトリインスタンス変数にキャッシュさせておきます。

Infra がやることはデータに関する処理ですので、クリーンアーキテクチャの説明をするうえでのInfra の説明は特に必要ないかと思います。

単体テストについて

僕はユニットテストを書くのは、Presenter,ユースケース, リポジトリ にしています。

それぞれで目的をあげると

Presenter : View のメソッドが適切にコールされている(ない)か

ユースケース :

  • 期待された通りにリポジトリのメソッドがコールされている(ない)か
  • 正しくentityが生成されているか(マッパーが正しく機能しているか)

リポジトリ :

  • 期待したとおりのエンドポイントにAPIリクエストが行われること
  • 定義したjsonデータからスキーマオブジェクトに変換されること

です。

詳細、具体的な書き方については別の記事でかければと思っています。

終わりに

以上、iOSアプリでクリーンアーキテクチャを実装してみた紹介でした。

正直「ネイティブアプリにおけるビジネスロジック」においてはかなりの議論事項でして、最近ではAPI のほうで全部吸収すべき、という意見が強いと思っています。

そういう風潮にある理由として一番でかいのはHTTP通信コストです。

ビジネスロジックAPIに寄せていくとなると、基本的に1画面1エンドポイントの方針でAPIがつくられることになります。

その画面で必要なビジネスルールはそのエンドポイントの1回の通信で賄えるので、アプリで用意するビジネスロジックもほぼなくなってきます。

先程Domain のところで紹介したのも、わざわざローカルデータからユーザー情報を引っ張ってきてビジネスルールを生成するようなこともなくなりますね。

その場合はクリーンアーキテクチャで設計するメリットが薄まっていきます。多分やめたほうがよいです。MVP や MVVM などのアプリケーションアーキテクチャに留めておきましょう。

一方、APIがRESTful 思想に基づいている場合は、各DBテーブルのデータがシンプルに返ってくるといった感じですので、データを組み合わせてロジックを生成していくことになるのでクリーンアーキテクチャはかなり有用だなと思っています。

参考

https://www.amazon.co.jp/Clean-Architecture-%E9%81%94%E4%BA%BA%E3%81%AB%E5%AD%A6%E3%81%B6%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%E3%81%AE%E6%A7%8B%E9%80%A0%E3%81%A8%E8%A8%AD%E8%A8%88-Robert-C-Martin/dp/4048930656

blog.cleancoder.com