iOS Clean Architectureで遊んでた話
初めに
今年に入ったくらいからiOS Clean Architectureについて調べたり,チマチマと実装してみて遊んでたりしてたので,そのまとめです.
iOS Clean Architectureとは
概要
詳しい説明は省きますが,iOS Clean Architectureとは,Clean ArchitectureをiOSに適用したアーキテクチャの名称です.
Clean Architectureとは,ソフトウェア開発において,ビジネスロジックを中心に考え,その他のUIや各種Framework,API Client等はそのビジネスロジックに依存するような構成にし,なおかつビジネスロジック以外のものを複数のレイヤに分けることによって関心と責務の分離を目指したアーキテクチャです.
ドメイン駆動開発のDomain Layer・Data Layerをさらに細かくレイヤ分けたしたような構成になっています.いわゆるMVC系統のアーキテクチャを採用して開発を進めるにあたり,Modelの定義がプログラマによってまちまちで,命名もバラバラになってしまっていたものを,きちんと整理して属人性を廃した命名・構成になるようにと提案されたものって感じでしょうか.
出典: The Clean Architecture – 8th Light
上図を用いて説明します.中心の黄色い領域(Entities)と赤い領域(Use Cases)がドメイン(ビジネスロジック)となっており,アプリケーションの中核を成します.EntitiesがAPI Responseや帳票データ等,業務における一般的なロジック(データ構造)を扱っているのに対し,Use Casesはアプリケーション(ここではiOS)固有のロジックを担っています.
一番外側の青い領域は外界となっており,DBやAPI Server,アプリのUI等を扱っています.
そして,それらの間にある緑色の領域(Controllers)がI/Fとなっており,外界とドメインを結ぶ役割を果たします.
上図の依存関係を示す矢印を見ても分かる通り,外界からI/Fを通してドメインに依存する構成となっており,その逆の依存は一切ありません.内側のレイヤはより外側のレイヤのことは一切知らず,自分自身とより内側のレイヤに関する知識のみを持ちます.
このような構成にすることで,比較的安定しているドメインにのみ依存する構成にすることができ,UI等の変更に強いアプリケーションを開発することが可能となります.
細かい構成
実際に実装するにあたり,いろいろと試行錯誤し最終的に下図のような構成に落ち着きました.
Presentation Layer
このレイヤはユーザの目に実際に触れる部分を含んでいます.
Presenter
- Domain LayerとのI/F
- 各種UIイベント等に対し,どのように処理するかを決定し,ViewControllerへ伝える
- 必要に応じてUseCaseを実行する
- UseCaseから受け取ったデータ(Model)をViewControllerへ渡す
- ViewControllerがどうなっているかは知らない
ViewController
- iOSにおけるUIViewControllerを継承したもの
- UIイベントを自身だけで処理せず,どのように対処するかを逐一Presenterへと問い合わせる
- Presenterからの指示によりViewの状態等を変更する
View/Cell
- iOSにおけるUIViewを継承したもの
- 実際にユーザの目に触れる部分
- ViewControllerがどうなっているかは知らない
Domain Layer
このレイヤはビジネスロジックを直接扱う部分を含んでいます.
UseCase
- 各種ユースケースを記述する
- Presenterからの要求に対応する
- 必要に応じてRepositoryへデータ取得を要求
- Repositoryから受け取ったデータ(Entity)をTranslatorを介してModelに変換した上でPresenterへ渡す
- PresenterやRepositoryがどうなっているかは知らない
Translator
- Data Layerで扱うデータ形式であるEntityを,Presentation Layerで扱うデータ形式であるModelへと(相互)変換する
Model
- Presentation Layerで扱うデータ形式
- Data Layerでは扱われない
Data Layer
このレイヤは各種生データを扱う部分を含んでいます.
Repository
- Domain LayerとのI/F
- UseCaseからの要求に対し,DataStoreへ実際のデータ取得を要求する
- 状況に応じて,DBから生データ取得したりAPI経由で取得したりする
- DataStoreから受け取ったデータ(Entity)をUseCaseへ渡す
- DataStoreがどうなっているかは知らない
DataStore
- データを実際に取得更新する
- DBやAPI Server,Realm等,処理の対象毎に用意する
- Factory Patternを用いてRepositoryがDataStoreの種別を意識しなくてもいいようにする
Entity
- Data Layerで扱うデータ形式
- Presentation Layerでは扱われない
たまに出てくる,「〜〜がどうなっているかは知らない」って部分は,依存性逆転の原則を用いてより内側のものがProtocolを定義し,より外側のものがそのProtocolに準拠することにより実装します.
TL;DR 実装してみた
ダラダラとGitHub Repository Searcher的なものを実装してました.
Common
Builder
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
protocol ViewControllerBuilder { associatedtype ViewController: UIViewController static func build() -> ViewController } struct GitHubRepositoryTableViewControllerBuilder: ViewControllerBuilder { typealias ViewController = GitHubRepositoryTableViewController static func build() -> ViewController { let viewController = GitHubRepositoryTableViewController() let wireframe = GitHubRepositoryWireframe(viewController: viewController) let dataStore = GitHubRepositoryDataStoreImpl() let repository = GitHubRepositoryRepositoryImpl(dataStore: dataStore) let useCase = GitHubRepositoryUseCaseImpl(repository: repository) let presenter = GitHubRepositoryPresenterImpl(useCase: useCase, wireframe: wireframe) dataStore.inject(repository: repository) repository.inject(useCase: useCase) useCase.inject(presenter: presenter) presenter.inject(viewController: viewController) viewController.inject(presenter: presenter) return viewController } } |
VCのBuilderです.DIコンテナの役割も果たしています.
Wireframe
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
protocol WireFrame { associatedtype ViewController: UIViewController init(viewController: ViewController) } struct GitHubRepositoryWireframe: WireFrame { typealias ViewController = GitHubRepositoryTableViewController fileprivate weak var viewController: GitHubRepositoryTableViewController? init(viewController: ViewController) { self.viewController = viewController } func showDetail(repositoryModel: GitHubRepositoryModel) { let nextViewController = GitHubRepositoryDetailViewControllerBuilder.build() nextViewController.setRepositoryModel(repositoryModel) self.viewController?.navigationController?.pushViewController(nextViewController, animated: true) } } |
VC間の遷移を一手に担います.
Presentation Layer
Presenter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
protocol GitHubRepositoryPresenter: class { func didTapClearButton() func didTapSearchButton(text: String?) func didSelectRepository(repositoryModel: GitHubRepositoryModel) } protocol GitHubRepositoryPresenterInput: class { func setRepositoriesModel(_ repositoriesModel: GitHubRepositoriesModel) func setSearchBarText(_ text: String) func endSearching() func showLoadingView() func hideLoadingView() } final class GitHubRepositoryPresenterImpl: GitHubRepositoryPresenter { fileprivate let useCase: GitHubRepositoryUseCase fileprivate let wireframe: GitHubRepositoryWireframe fileprivate weak var viewController: GitHubRepositoryPresenterInput? init(useCase: GitHubRepositoryUseCase, wireframe: GitHubRepositoryWireframe) { self.useCase = useCase self.wireframe = wireframe } func inject(viewController: GitHubRepositoryPresenterInput) { self.viewController = viewController } func didTapClearButton() { self.viewController?.setRepositoriesModel(GitHubRepositoriesModel()) self.viewController?.setSearchBarText("") self.viewController?.endSearching() } func didTapSearchButton(text: String?) { self.viewController?.showLoadingView() self.useCase.searchRepositories(repositoryName: text) } func didSelectRepository(repositoryModel: GitHubRepositoryModel) { self.viewController?.endSearching() self.wireframe.showDetail(repositoryModel: repositoryModel) } } // MARK: - GitHubRepositoryUseCasePresentationInput extension GitHubRepositoryPresenterImpl: GitHubRepositoryUseCasePresentationInput { func useCase(_ useCase: GitHubRepositoryUseCase, didSearchRepositories repositories: GitHubRepositoriesModel) { self.viewController?.setRepositoriesModel(repositories) self.viewController?.hideLoadingView() } } |
GitHubRepositoryPresenterInput ProtocolがViewControllerに対して準拠を要求するプロトコルとなります.
ViewController
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
final class GitHubRepositoryTableViewController: UIViewController { fileprivate lazy var repositoryTableView: UITableView = self.createRepositoryTableView() fileprivate lazy var repositorySearchBar: UISearchBar = self.createRepositorySearchBar() fileprivate lazy var emptyLabel: UILabel = self.createEmptyLabel() fileprivate lazy var loadingView: UIActivityIndicatorView = self.createLoadingView() fileprivate var presenter: GitHubRepositoryPresenter? = nil fileprivate var repositories: [GitHubRepositoryModel] = [] override func viewDidLoad() { super.viewDidLoad() self.navigationItem.title = "Repository Searcher" self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Clear", style: .plain, target: self, action: #selector(self.didTapClearButton(_:))) self.view.addSubview(self.repositoryTableView) self.repositoryTableView.tableHeaderView = self.repositorySearchBar self.repositoryTableView.addSubview(self.emptyLabel) self.view.addSubview(self.loadingView) } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() self.layoutGitHubRepositoryTableView() self.layoutRepositorySearchBar() self.layoutEmptyLabel() self.layoutLoadingView() } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } func inject(presenter: GitHubRepositoryPresenter) { self.presenter = presenter } // Viewの生成・レイアウト部分は省略しました } // MARK: - Action Method extension GitHubRepositoryTableViewController { @objc fileprivate func didTapClearButton(_ sender: UIButton) { self.presenter?.didTapClearButton() } } // MARK: - UITableViewDataSource extension GitHubRepositoryTableViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return self.repositories.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: GitHubRepositoryTableViewCell.cellIdentifier, for: indexPath) if let c = cell as? GitHubRepositoryTableViewCell { c.configure(self.repositories[indexPath.row]) } return cell } } // MARK: - UITableViewDelegate extension GitHubRepositoryTableViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let repository = self.repositories[indexPath.row] self.presenter?.didSelectRepository(repositoryModel: repository) } } // MARK: - UIScrollViewDelegate extension GitHubRepositoryTableViewController: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { if self.repositorySearchBar.isFirstResponder { self.repositorySearchBar.resignFirstResponder() } } } // MARK: - UISearchBarDelegate extension GitHubRepositoryTableViewController: UISearchBarDelegate { func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { searchBar.resignFirstResponder() self.presenter?.didTapSearchButton(text: searchBar.text) } } // MARK: - GitHubRepositoryPresenterDelegate extension GitHubRepositoryTableViewController: GitHubRepositoryPresenterInput { func setRepositoriesModel(_ repositoriesModel: GitHubRepositoriesModel) { self.repositories = repositoriesModel.repositories self.emptyLabel.isHidden = !self.repositories.isEmpty self.repositoryTableView.reloadData() } func setSearchBarText(_ text: String) { self.repositorySearchBar.text = text } func endSearching() { if self.repositorySearchBar.isFirstResponder { self.repositorySearchBar.resignFirstResponder() } } func showLoadingView() { self.loadingView.startAnimating() } func hideLoadingView() { self.loadingView.stopAnimating() } } |
各種Delegateの中でPresenterを介しているところがポイントです.これ,どこまでPresenterでやってどこまでVCでやればいいのかまだ確実な線引きをできていないんですよね.cellの生成部分とかどうしよう.
Domain Layer
UseCase
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
protocol GitHubRepositoryUseCase: class { func searchRepositories(repositoryName: String?) func repository(_ repository: GitHubRepositoryUseCaseDataInput, didSearchRepositories repositories: GitHubRepositoriesEntity) } protocol GitHubRepositoryUseCasePresentationInput: class { func useCase(_ useCase: GitHubRepositoryUseCase, didSearchRepositories repositories: GitHubRepositoriesModel) } protocol GitHubRepositoryUseCaseDataInput: class { func searchRepositories(repositoryName: String) } final class GitHubRepositoryUseCaseImpl: GitHubRepositoryUseCase { fileprivate let repository: GitHubRepositoryUseCaseDataInput fileprivate weak var presenter: GitHubRepositoryUseCasePresentationInput? init(repository: GitHubRepositoryUseCaseDataInput) { self.repository = repository } func inject(presenter: GitHubRepositoryUseCasePresentationInput) { self.presenter = presenter } func searchRepositories(repositoryName: String?) { if let name = repositoryName, !name.isEmpty { self.repository.searchRepositories(repositoryName: name) } else { self.presenter?.useCase(self, didSearchRepositories: GitHubRepositoriesModel()) } } func repository(_ repository: GitHubRepositoryUseCaseDataInput, didSearchRepositories repositories: GitHubRepositoriesEntity) { let repositoriesModel = GitHubRepositoriesTranslator.translate(repositories) self.presenter?.useCase(self, didSearchRepositories: repositoriesModel) } } |
GitHubRepositoryUseCasePresentationInput ProtocolがPresenterに対して準拠を要求するプロトコル,GitHubRepositoryUseCaseDataInput ProtocolがRepositoryに対して準拠を要求するプロトコルとなります.
Translator
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
protocol Translator: class { associatedtype Input associatedtype Output static func translate(_ entity: Input) -> Output } final class GitHubRepositoriesTranslator: Translator { typealias Input = GitHubRepositoriesEntity typealias Output = GitHubRepositoriesModel static func translate(_ entity: Input) -> Output { let repositories: [GitHubRepositoryModel] = entity.items.map { GitHubRepositoryTranslator.translate($0) } return GitHubRepositoriesModel(repositories: repositories) } } final class GitHubRepositoryTranslator: Translator { typealias Input = GitHubRepositoryEntity typealias Output = GitHubRepositoryModel static func translate(_ entity: Input) -> Output { let name = entity.name let fullName = entity.full_name let owner = GitHubRepositoryOwnerTranslator.translate(entity.owner) let isPrivate = entity.private let description = entity.description let watchersCount = entity.watchers_count let stargazersCount = entity.stargazers_count let forksCount = entity.forks_count return GitHubRepositoryModel(name: name, fullName: fullName, owner: owner, isPrivate: isPrivate, description: description, watchersCount: watchersCount, stargazersCount: stargazersCount, forksCount: forksCount) } } final class GitHubRepositoryOwnerTranslator: Translator { typealias Input = GitHubRepositoryOwnerEntity typealias Output = GitHubRepositoryOwnerModel static func translate(_ entity: Input) -> Output { let name = entity.login let avatarURLString = entity.avatar_url return GitHubRepositoryOwnerModel(name: name, avatarURLString: avatarURLString) } } |
Model
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
struct GitHubRepositoriesModel { var repositories: [GitHubRepositoryModel] = [] } struct GitHubRepositoryModel { let name: String let fullName: String let owner: GitHubRepositoryOwnerModel let isPrivate: Bool let description: String let watchersCount: Int let stargazersCount: Int let forksCount: Int } struct GitHubRepositoryOwnerModel { let name: String let avatarURLString: String } |
Data Layer
Repository
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
protocol GitHubRepositoryRepository: class { func dataStore(_ dataStore: GitHubRepositoryRepositoryInput, didSearchRepositories repositories: GitHubRepositoriesEntity) } protocol GitHubRepositoryRepositoryInput: class { func searchRepositories(repositoryName: String) } final class GitHubRepositoryRepositoryImpl: GitHubRepositoryRepository { fileprivate let dataStore: GitHubRepositoryRepositoryInput fileprivate weak var useCase: GitHubRepositoryUseCase? init(dataStore: GitHubRepositoryRepositoryInput) { self.dataStore = dataStore } func inject(useCase: GitHubRepositoryUseCase) { self.useCase = useCase } func dataStore(_ dataStore: GitHubRepositoryRepositoryInput, didSearchRepositories repositories: GitHubRepositoriesEntity) { self.useCase?.repository(self, didSearchRepositories: repositories) } } // MARK: - GitHubRepositoryUseCaseDataInput extension GitHubRepositoryRepositoryImpl: GitHubRepositoryUseCaseDataInput { func searchRepositories(repositoryName: String) { self.dataStore.searchRepositories(repositoryName: repositoryName) } } |
GitHubRepositoryRepositoryInput ProtocolがDataStoreに対して準拠を要求するプロトコルとなります.RepositoryRepositoryの命名は我ながらナンセンスだと思います.
DataStore
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
protocol GitHubRepositoryDataStore: class { } final class GitHubRepositoryDataStoreImpl: GitHubRepositoryDataStore { fileprivate weak var repository: GitHubRepositoryRepository? func inject(repository: GitHubRepositoryRepository) { self.repository = repository } } // MARK: - GitHubRepositoryRepositoryInput extension GitHubRepositoryDataStoreImpl: GitHubRepositoryRepositoryInput { func searchRepositories(repositoryName: String) { GitHubAPIClient.searchRepositories(query: repositoryName) { [weak self] response in switch response { case .success(let value): guard let `self` = self else { return } self.repository?.dataStore(self, didSearchRepositories: value) case .error(let error): print("error: \(error)") } } } } |
API Clientについては省略
Entity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
struct GitHubRepositoriesEntity: Mappable { var items: [GitHubRepositoryEntity] = [] init?(map: Map) { } mutating func mapping(map: Map) { self.items <- map["items"] } } struct GitHubRepositoryEntity: Mappable { var id = 0 var name = "" var full_name = "" var owner = GitHubRepositoryOwnerEntity() var `private` = false var description = "" var watchers_count = 0 var stargazers_count = 0 var forks_count = 0 init?(map: Map) { } mutating func mapping(map: Map) { self.id <- map["id"] self.name <- map["name"] self.full_name <- map["full_name"] self.owner <- map["owner"] self.private <- map["private"] self.description <- map["description"] self.watchers_count <- map["watchers_count"] self.stargazers_count <- map["stargazers_count"] self.forks_count <- map["forks_count"] } } struct GitHubRepositoryOwnerEntity: Mappable { var login = "" var id = 0 var avatar_url = "" init?(map: Map) { } init() { self.login = "" self.id = 0 self.avatar_url = "" } mutating func mapping(map: Map) { self.login <- map["login"] self.id <- map["id"] self.avatar_url <- map["avatar_url"] } } |
ObjectMapperを用いて初期化処理を簡易化しています.
ある程度自分の中の構成が固まってきたらKuri等を用いてファイル生成を半自動化するとよさげです.
終わりに
Qiitaばっかりじゃなくたまにはこちらにも技術系の記事書こうと思って書きました.疲れました.
最近のコメント