hachinoBlog

hachinobuのエンジニアライフ

iOS CleanArchitectureを使ってみた感想

CleanArchitectureを使ったサンプルアプリを作成したので、説明していきたいと思います

サンプルアプリ

まず、作成したアプリについてです

Qiitaのクライアントです

認証 投稿一覧 詳細 ユーザー投稿一覧 詳細
Simulator Screen Shot 2016.10.05 21.49.02.png Simulator Screen Shot 2016.10.10 15.48.59.png Simulator Screen Shot 2016.10.10 15.55.20.png Simulator Screen Shot 2016.10.10 15.53.32.png Simulator Screen Shot 2016.10.10 15.53.24.png

ソースコード

https://github.com/hachinobu/CleanQiitaClient

使い方

一応そのままビルドしてもビルドは通りますが認証していない状態なのでAPIコール制限とストックボタンなどは動かないです

認証したい場合は下記からアプリケーションの登録をしてください https://qiita.com/settings/applications/new

リダイレクト先のURLをサンプルソースコードでは固定にしてしまっているので clean-qiita-client://oauth で登録すると楽です Client IDとClient Secretを取得したらそれぞれ、サンプルソースコードの Secrets.swiftというファイルを開き、

public static let clientId: String = "****"
public static let clientSecret: String = "****"

を書き換えればOKです

先に記載したリダイレクト先のURLについても、違う名前で登録したのであれば、

public static let redirectUrlScheme: String = "clean-qiita-client"

ここを修正すれば動きます

環境

Xcode8 + Swift3.0 cocoapods-1.1.0.rc.2以上

Clean Architectureとは

ドメイン駆動開発(DDD)やユースケース駆動開発(UCDD)を意識して、ビジネスロジックをUIやFrameworkから引き離し、それぞれのレイヤー毎に役割と責任を分離したArchitectureになります

概念などについては割愛しますが下記の記事を何ども読み込みました

まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて

各レイヤーと対象のサンプルコード

下記がCleanArchitectureのLayerの構造になっています

layer.png

※ 今回は上記の絵に加え、PresentationLayerにRoutingという層を作っています

それでは各レイヤーについてサンプルコードを見ながら説明します

DataLayer

  • 通信やデータ管理のロジックを担当するレイヤー

DataStore

  • データを取得/更新する処理を担当
  • 取得先がAPIならAPI用のDataStore,取得先がDBならDBから取得用のDataStoreができる
  • あるデータを取得する際に取得先(APIやDB)が複数ある場合、RepositoryからFactoryクラスを用いてDataStoreを生成する
  • APIの場合、Endpointごとにつくるイメージ

DataStoreサンプルコード

protocol ItemListDataStore {
    func fetchAllItemList(page: Int?, perPage: Int?, handler: @escaping (Result<GetAllItemListRequest.Response, SessionTaskError>) -> Void)
}

struct ItemListDataStoreNetworkImpl: ItemListDataStore {
    
    func fetchAllItemList(page: Int?, perPage: Int?, handler: @escaping (Result<GetAllItemListRequest.Response, SessionTaskError>) -> Void) {
        let request = GetAllItemListRequest(page: page, perPage: perPage)
        Session.send(request, callbackQueue: nil, handler: handler)
    }
    
}

struct ItemListDataStoreFactory {
    
    static func createItemListDataStore() -> ItemListDataStore {
        return ItemListDataStoreNetworkImpl()
    }
    
}

ItemListDataStoreNetworkImplはQiitaの記事一覧をAPIから取得するDataStoreです

ItemListDataStoreFactoryはQiitaの記事一覧を取得するDataStoreを生成します 本サンプルではAPI経由でしかデータを取得しないようになっているので必ずItemListDataStoreNetworkImplを生成して返すようにしています またNetwork部分はAPIKitに依存したサンプルコードになっています

例えば記事一覧をAPIからだけでなく、キャッシュしているDBからデータを取得したい場合などは、ItemListDataStoreFactoryの中で取得すべき条件に応じて分岐処理を書いて正しいデータの取得先となるDataStoreを生成します

ItemListDataStoreFactoryItemListDataStoreを返すようにしています ItemListDataStoreは記事一覧を取得するDataStoreのI/Fです APIからデータを取得するDataStoreもDBからデータを取得するDataStoreも共にItemListDataStoreに準拠することでDataStoreを使うRepositoryは何の意識もすることがなくI/Fであるデータ取得のメソッドを叩くだけで良くなります

Entity

  • DataStoreで扱うことができるデータの静的なモデル
  • データ取得先から取得したデータのValueObject
  • EntityはPresentationLayerでは使用しない(Viewのレンダリングなどに使われない)

Entityサンプルコード

import Foundation
import ObjectMapper

public struct ItemEntity {
    public var renderedBody: String?
    public var body: String?
    public var coediting: Bool?
    public var createdAt: String?
    public var group: GroupEntity?
    public var id: String?
    public var isPrivate: Bool?
    public var tagList: [TagEntity]?
    public var title: String?
    public var updatedAt: String?
    public var url: String?
    public var user: UserEntity?
}

extension ItemEntity: Mappable {
    
    public init?(map: Map) {
        
    }
    
    mutating public func mapping(map: Map) {
        renderedBody <- map["rendered_body"]
        body <- map["body"]
        coediting <- map["coediting"]
        createdAt <- map["created_at"]
        group <- map["group"]
        id <- map["id"]
        isPrivate <- map["private"]
        tagList <- map["tags"]
        title <- map["title"]
        updatedAt <- map["updated_at"]
        url <- map["url"]
        user <- map["user"]
    }
    
}

APIのレスポンスのJSONをObjectMapperでマッピングしてValueObjectとなるEntityを生成しています

Repository

  • DomainLayerのUseCaseにデータ取得のCRUD相当のI/Fを提供する
  • 取得したいデータのDataStoreをFactoryクラスを用いて取得し、そのDataStoreに対してデータ取得/更新のリクエストを行う
  • DataLayerとDomainLayerのI/Fを担う

今回DataLayerの1つとしてRepositoryを書いています

サンプルコード

import Foundation
import APIKit
import Result

public protocol ItemListRepository {
    func fetchItemList(page: Int?, perPage: Int?, handler: @escaping (Result<GetAllItemListRequest.Response, SessionTaskError>) -> Void)
}

public struct ItemListRepositoryImpl: ItemListRepository {
    
    public static let shared: ItemListRepository = ItemListRepositoryImpl()
    
    public func fetchItemList(page: Int?, perPage: Int?, handler: @escaping (Result<GetAllItemListRequest.Response, SessionTaskError>) -> Void) {
        let dataStore = ItemListDataStoreFactory.createItemListDataStore()
        dataStore.fetchAllItemList(page: page, perPage: perPage, handler: handler)
    }
    
}

Repositoryはデータ取得/更新のリクエストを出すDataStoreを生成時に注入される必要がないため生成コストを考慮してシングルトンで実装しています

UseCaseからfetchItemListメソッドが叩かれたら、取得したいデータのDataStoreを生成するFactoryからDataStoreを取得し、そのDataStoreにデータ取得/更新のリクエストを実行します

DomainLayer

Model

サンプルコード

import Foundation

public struct ListItemModel {
    public let id: String
    public let userName: String
    public let title: String
    public let tagList: [String]
    public let profileImageURL: String
}

主にModelの値をViewに表示します

Translator

  • UseCaseで取得したEntityをPresentationLayerで使用するModelへ変換する
import Foundation

protocol Translator {
    associatedtype Input
    associatedtype Output
    
    func translate(_: Input) -> Output
}
import Foundation
import DataLayer

struct ListItemModelsTranslator: Translator {
    
    func translate(_ entities: [ItemEntity]) -> [ListItemModel] {
        return entities.map { ListItemModelTranslator().translate($0) }
    }
    
}

struct ListItemModelTranslator: Translator {
    
    func translate(_ entity: ItemEntity) -> ListItemModel {
        
        let id = entity.id ?? ""
        let userName = entity.user?.id ?? ""
        let title = entity.title ?? ""
        let tagList = entity.tagList?.flatMap { $0.name } ?? []
        let profileImageURL = entity.user?.profileImageUrl ?? ""
        
        return ListItemModel(id: id, userName: userName, title: title, tagList: tagList, profileImageURL: profileImageURL)
        
    }
    
}

ここではItemEntityからListItemModelに変換しています ここでの変換処理は主にOptionalを外したりViewに最適化の処理をしています

最適化といえど金額Int(1000)からFormattingされた金額String(¥1,000)などはしません この処理はPresentationLayerのView(ViewModel)で行うべきです

UseCase

  • ユースケースに必要なロジック処理を記述する
  • UIには直接関与しない(View,ViewControllerから直接参照されない)
  • PresentationLayerで使用するModelを生成するためにRepositoryを複数持つ可能性がある(複数APIを叩かなければ生成できないModelなど
  • Repositoryを叩いてEntityを取得してTranslatorでModelへ変換させる
  • 必要なModelを生成するための材料となるEntityが全て揃うまでの待ち合わせなどもここで行う

サンプルコード

public protocol ItemListUseCase {
    func fetchItemList(page: Int?, perPage: Int?, handler: @escaping (Result<[ListItemModel], SessionTaskError>) -> Void)
}

public struct AllItemListUseCaseImpl: ItemListUseCase {
    
    let repository: ItemListRepository
    
    public init(repository: ItemListRepository) {
        self.repository = repository
    }
    
    public func fetchItemList(page: Int?, perPage: Int?, handler: @escaping (Result<[ListItemModel], SessionTaskError>) -> Void) {
        
        repository.fetchItemList(page: page, perPage: perPage) { result in
            
            let r = generateItemModelsResultFromItemEntitiesResult(result: result)
            handler(r)
            
        }
        
    }
    
}

fileprivate func generateItemModelsResultFromItemEntitiesResult(result: Result<[ItemEntity], SessionTaskError>) -> Result<[ListItemModel], SessionTaskError> {
    
    guard let itemEntities = result.value else {
        return Result.failure(result.error!)
    }
    let listItemModels = ListItemModelsTranslator().translate(itemEntities)
    return Result.success(listItemModels)
    
}

取得すべきデータのI/Fを提供しているRepositoryが注入されているので、そのRepositoryのI/Fを叩くとDataLayerで取得したEntityを取得できるので、そのEntityをTranslatorにかけ最終成果物となるModelを生成します

UseCaseを使うPresenterは、画面を生成するのに何個APIを叩く必要があるといったことは知る必要はなくViewにレンダリングさせるModelを要求するだけで、UseCase内で全てRepositoryなどを管理して通信の待ち合わせなどをしModelを生成します

PresentationLayer

Presenter

  • Viewからイベントを受け取り、必要があればイベントに応じたUseCaseを実行する
  • イベントに応じてViewにレンダリングすべきUIを指定する
  • UseCaseから受け取ったデータをViewへ渡す
  • UseCaseから受け取ったデータを管理する

サンプルコード

import Foundation
import DomainLayer

public protocol ItemListPresenter {
    
    weak var view: ItemListPresenterView? { get set }
    
    func setupUI()
    func refreshData()
    func fetchMorePageItem()
    func reachedBottom(index: Int, isAnimation: Bool)
    func selectedItem(index: Int)
    
}

public protocol ItemListPresenterView: class {
    func showErrorAlert(message: String)
    func setupNavigation(title: String)
    func setupRefreshControl()
    func segueItemDetailScreen(itemId: String)
    func reloadView(itemListSummaryProtocol: ItemListSummaryProtocol)
    func startIndicator()
    func stopIndicator()
}

ItemListPresenterプロトコルは一覧画面のViewからイベントなどを受け取って処理するPresenterのI/Fを提供しています setupUIメソッドではUIの初期化 refreshDataメソッドでは一覧の表示に必要なデータの取得 などです Viewがイベントやライフサイクルに応じてPresenterのI/Fを叩きます

ItemListPresenterViewはItemListPresenterの処理が完了した時に、Viewがどのような振る舞いをすれば良いのかのI/Fを提供しています ViewがItemListPresenterViewに準拠していればPresenterからレンダリングすべきデータを受け取ったり、Presenterからの要求を受け取ることができます

ItemListPresenterの実装です

import Foundation
import DomainLayer
import Kingfisher
import Networking
import APIKit
import Result

public class AllItemListPresenterImpl: ItemListPresenter {
    
    weak public var view: ItemListPresenterView?
    let useCase: ItemListUseCase
    
    private var currentPage: Int = 1
    let perPage = 20
    
    private var listItemModels: [ListItemModel] = [] {
        didSet {
            reloadView(listItemModels: listItemModels)
        }
    }
    private var isFinishMoreLoad: Bool = false
    
    public init(useCase: ItemListUseCase) {
        self.useCase = useCase
    }
    
    public func setupUI() {
        view?.setupNavigation(title: "全ての投稿")
        view?.setupRefreshControl()
    }
    
    public func refreshData() {
        
        ImageCache.default.clearMemoryCache()
        
        let firstPage = 1
        useCase.fetchItemList(page: firstPage, perPage: perPage) { result in
            
            switch result {
            case .success(let listItemModels):
                self.currentPage = firstPage
                self.listItemModels = listItemModels
                
            case .failure(.responseError(let qiitaError as QiitaError)):
                self.view?.showErrorAlert(message: qiitaError.message)
                
            case .failure(let error):
                self.view?.showErrorAlert(message: error.localizedDescription)
            }
            
        }
    }
    
    public func fetchMorePageItem() {
        
        view?.startIndicator()
        let nextPage = currentPage + 1
        useCase.fetchItemList(page: nextPage, perPage: perPage) { result in
            
            self.view?.stopIndicator()
            guard let listItemModels = result.value else {
                return
            }
            self.currentPage = nextPage
            if listItemModels.count == 0 {
                self.isFinishMoreLoad = true
            }
            self.listItemModels = self.mergeItemList(currentListItemModels: self.listItemModels, fetchListItemModels: listItemModels)
            
        }
    }
    
    public func reachedBottom(index: Int, isAnimation: Bool) {
        let bottomIndex = listItemModels.count - 1
        guard bottomIndex == index && !isAnimation && !isFinishMoreLoad else {
            return
        }
        fetchMorePageItem()
    }
    
    public func selectedItem(index: Int) {
        guard listItemModels.count > index else {
            return
        }
        let item = listItemModels[index]
        view?.segueItemDetailScreen(itemId: item.id)
    }
    
    private func mergeItemList(currentListItemModels: [ListItemModel], fetchListItemModels: [ListItemModel]) -> [ListItemModel] {
        return fetchListItemModels.reduce(currentListItemModels) { (currentList, fetchItem) in
            return currentList.contains { $0.id == fetchItem.id } ? currentList : currentList + [fetchItem]
        }
    }
    
    private func reloadView(listItemModels: [ListItemModel]) {
        let summaryVM = ItemListSummaryVM(itemList: listItemModels)
        view?.reloadView(itemListSummaryProtocol: summaryVM)
    }
    
}

Viewのイベントに対応したメソッドを実装しています 一覧画面を表示するために必要なデータの取得をUseCaseに命令したり 表示するUIの初期設定やRefreshControlの有無などを判断してViewに命令を出しています

View(ViewController)

  • Viewのライフサイクル・レンダリング・ユーザーのタッチイベントなどのイベントをPresenterに通知する
  • Presenterから受けたModelのデータやステータスによりViewの表示を切り替える

サンプルコード

import UIKit
import DomainLayer
import Utility

fileprivate extension Selector {
    static let refreshAction = #selector(ItemListViewController.refreshData)
}

public class ItemListViewController: UITableViewController {

    @IBOutlet weak var indicatorCircleView: IndicatorCircleView!
    private var presenter: ItemListPresenter! {
        didSet {
            presenter.view = self
        }
    }
    
    fileprivate var routing: ItemListRouting!
    
    fileprivate var itemListSummaryVM: ItemListSummaryProtocol! {
        didSet {
            tableView.reloadData()
        }
    }
    
    public func injection(presenter: ItemListPresenter, routing: ItemListRouting) {
        self.presenter = presenter
        self.routing = routing
    }
    
    override public func viewDidLoad() {
        super.viewDidLoad()
        presenter.setupUI()
        presenter.refreshData()
    }

    override public func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    func refreshData() {
        presenter.refreshData()
    }
    
    // MARK: - Table view data source
    override public func numberOfSections(in tableView: UITableView) -> Int {
        let numberOfSection = itemListSummaryVM == nil ? 0 : 1
        return numberOfSection
    }
    
    override public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return itemListSummaryVM.fetchItemCount()
    }
    
    override public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as ListItemCell
        let displayItemVM = itemListSummaryVM.fetchListItemDisplayProtocol(index: indexPath.row)
        cell.setupContents(displayVM: displayItemVM)
        
        return cell
    }
    
    override public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        presenter.reachedBottom(index: indexPath.row, isAnimation: indicatorCircleView.isAnimating())
    }
    
    // MARK: - Table view delegate
    override public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableViewAutomaticDimension
    }
    
    override public func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableViewAutomaticDimension
    }
    
    override public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        presenter.selectedItem(index: indexPath.row)
    }

}

extension ItemListViewController: ItemListPresenterView {
    
    public func showErrorAlert(message: String) {
        refreshControl?.endRefreshing()
        routing.presentErrorAlert(message: message)
    }
    
    public func setupNavigation(title: String) {
        self.title = title
    }
    
    public func setupRefreshControl() {
        guard refreshControl == nil else {
            return
        }
        refreshControl = UIRefreshControl()
        refreshControl?.tintColor = .qiitaMainColor
        refreshControl?.addTarget(self, action: .refreshAction, for: .valueChanged)
    }
    
    public func segueItemDetailScreen(itemId: String) {
        routing.segueItem(id: itemId)
    }
    
    public func reloadView(itemListSummaryProtocol: ItemListSummaryProtocol) {
        refreshControl?.endRefreshing()
        itemListSummaryVM = itemListSummaryProtocol
    }
    
    public func startIndicator() {
        indicatorCircleView.startAnimation()
    }
    
    public func stopIndicator() {
        indicatorCircleView.stopAnimation()
    }
    
}

イベントに応じてPresenterのI/Fを叩いているのが分かります またItemListPresenterViewに準拠することで、Presenterからの指示のもとViewが一覧を表示したりエラーダイアログを出したり命令を受けます Viewは渡されたデータをレンダリングしたり、Presenterから指示された通りの処理をするだけです View本来の役割です

各レイヤーはDIできるようになっており、ViewControllerのInjectメソッドで依存性が注入されます

Routing

  • 画面遷移をつかさどる
  • DIもここでおこなう

サンプルコード

public protocol AuthScreenRouting: Routing {
    func segueAllItemListScreen()
}
import Foundation
import DataLayer
import DomainLayer
import PresentationLayer

public struct AuthScreenRoutingImpl: AuthScreenRouting {
    
    weak public var viewController: UIViewController?
    
    public init() {
        
    }
    
    public func segueAllItemListScreen() {
        
        let repository = ItemListRepositoryImpl.shared
        let useCase = AllItemListUseCaseImpl(repository: repository)
        let presenter = AllItemListPresenterImpl(useCase: useCase)
        
        let navigationController = UIStoryboard(name: "ItemListScreen", bundle: Bundle(for: ItemListViewController.self)).instantiateInitialViewController() as! UINavigationController
        let viewController = navigationController.topViewController as! ItemListViewController
        
        let routing = AllItemListRoutingImpl()
        routing.viewController = viewController
        viewController.injection(presenter: presenter, routing: routing)
        
        UIApplication.shared.delegate?.window??.rootViewController = navigationController
        let appDelegate = UIApplication.shared.delegate
        appDelegate?.window??.rootViewController = navigationController
        appDelegate?.window??.makeKeyAndVisible()
        
    }
    
}

認証画面後に一覧画面が表示されるので認証画面から一覧画面に遷移する際に呼ばれます

ここでは一覧画面を構成する上で必要なRepository,UseCase,Presenter,Routingを一覧画面のViewControllerに注入しています DIができる設計になっているので簡単にレイヤー間を差し替えることが可能です

これだけレイヤーが分かれていて、かつDIしやすい設計になっているメリットの一例として [投稿一覧画面]→[詳細画面]に遷移した際に、ユーザー名のリンクをタップすると[ユーザー投稿一覧画面]に遷移します 次に[ユーザー投稿一覧画面]から記事を選択すると[詳細画面]に遷移しますが、ここでまたユーザー名をタップした場合、Pushで[ユーザー投稿一覧画面]に遷移してしまうのは変ですよね? [ユーザー投稿一覧画面]から遷移してきているだからpopするだけで良い

こんな時、注入するRoutingを変えてあげるだけで他のレイヤーは何一つ変えることなく上記の画面遷移を実現できるのです

サンプルコードでいうと

下記は[投稿一覧]→[詳細]に遷移した場合のユーザー名をタップした挙動を記述しているRoutingです

import Foundation
import DataLayer
import DomainLayer
import PresentationLayer

class ItemRoutingImpl: ItemRouting {
    
    weak var viewController: UIViewController? {
        didSet {
            viewController?.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
        }
    }
    
    func segueItemList(userId: String) {
        
        let repository = UserItemListRepositoryImpl.shared
        let useCase = UserItemListUseCaseImpl(repository: repository, userId: userId)
        let presenter = UserItemListPresenterImpl(useCase: useCase)
        
        let navigationController = UIStoryboard(name: "ItemListScreen", bundle: Bundle(for: ItemListViewController.self)).instantiateInitialViewController() as! UINavigationController
        let vc = navigationController.topViewController as! ItemListViewController
        
        let routing = UserItemListRoutingImpl()
        routing.viewController = vc
        vc.injection(presenter: presenter, routing: routing)
        
        viewController?.navigationController?.pushViewController(vc, animated: true)
        
    }
    
}

ここではpushでタップしたユーザーの一覧画面に遷移するよう指示しています

次に、[ユーザー投稿一覧]→[詳細]に遷移してからユーザーのリンクをタップした際の挙動を記載したRoutingです

import Foundation
import DataLayer
import DomainLayer
import PresentationLayer

class UserItemRoutingImpl: ItemRoutingImpl {
    
    override func segueItemList(userId: String) {
        let _ = viewController?.navigationController?.popViewController(animated: true)
    }
    
}

ここでは詳細画面でユーザー名をタップした際に1つ前の画面に戻るようにしています

一覧画面から記事を選択した際の詳細画面のViewControllerにRepository,UseCase,Presenter,RoutingをDIできるようになっているので上記のようなことが簡単に実現可能なのです

Clean Architectureを使ってみて

メリット

  • 各レイヤーの責務がしっかり分かれるのでどこに何を書くかとか迷わない
  • レイヤーをこれだけ細かく切るのでDIできる設計にすれば変更要求に強いプログラムがかける
  • スケールしやすい
  • テスト書きやすい

デメリット

  • 学習コスト
  • コード量が多くなる
  • とりあえずモノを世に出さないとというスピード最重視の0->1フェーズスタートアップでは使わないかも

個人的には使ってみて凄く良かった やはりViewを本来のレンダリングだけすれば良いといった役割にできること、変更要求の強さに魅力を感じています アプリはViewが一番変わると思うのでCleanArchitectureを導入しないにしても、PresentationLayerの概念だけは意識しながら分けてあげると良いなと思います

謝辞

今回の説明はほぼ

まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて

から引用させていただいてます

@koutalou 本当にありがとうございました

CleanArchitectureのサンプルコードを書く上での基盤や構造などを教えてくれたyukiasai ありがとうございました!