CleanArchitectureを使ったサンプルアプリを作成したので、説明していきたいと思います
サンプルアプリ
まず、作成したアプリについてです
Qiitaのクライアントです
認証 | 投稿一覧 | 詳細 | ユーザー投稿一覧 | 詳細 |
---|---|---|---|---|
ソースコード
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"
ここを修正すれば動きます
Clean Architectureとは
ドメイン駆動開発(DDD)やユースケース駆動開発(UCDD)を意識して、ビジネスロジックをUIやFrameworkから引き離し、それぞれの層毎に役割と責任を分離したArchitectureになります
概念などについては割愛しますが下記の記事を何ども読み込みました
まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて
各レイヤーと対象のサンプルコード
下記がCleanArchitectureのLayerの構造になっています
※ 今回は上記の絵に加え、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を生成します
ItemListDataStoreFactory
はItemListDataStore
を返すようにしています
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
- PresentationLayerで使用する主にViewのレンダリングに最適されたもの
サンプルコード
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
- UIの表示やイベントのハンドリングを行います
- ビジネスロジック処理はしません
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さん、本当にありがとうございました