JCB デジタルソリューション開発部 アプリチームの渡辺です。
以前までWeb系の開発経験しかなかった私ですが、2022年4月からMyJCBというスマートフォンアプリのiOS開発を担当しています。
そのような経緯から本プロジェクトで採用されているiOSアプリ設計の概要についてまとめたいと思い、本記事を執筆いたしました。
MVVM
iOS開発の中でしばしば問題になるのが、FatなViewControllerです。
プロジェクトの規模が大きくなればなるほど、ViewControllerはFatになりやすく複雑化してしまいます。
そこで本プロジェクトで採用しているのがMVVMです。
Viewに関するロジックをModel側におくことで、FatなViewControllerになりにくい構成としています。
またMVVMの特徴でもあるデータバインディングによって、データの更新がされたら自動的に画面の更新がされ、データの統一性を担保しています。
Clean Architecture
さらにコードを管理する中でそれぞれのレイヤーの責務が明確化されるように、Clean Architectureの思想を取り入れています。
層 | フォルダ | 概要 |
---|---|---|
Data | DataSource | 外部リソースの情報を管理する |
Domain | Model UseCase |
ビジネスロジックを管理する |
Presentation | Presenter ViewController StoryBoard |
画面の情報を管理する |
画面表示までのフロー図
MVVMとClean Architectureの思想を組み合わせて、本プロジェクトの初期画面読み込みから更新したデータの画面を表示までの流れは以下のようになります。
サンプルコード
今回はユーザー情報を表示するサンプルコードを例に、上記のフロー図でどのようなコードを書いているのか解説していきたいと思います。
言語はSwiftで、ライブラリはAlamofire とPromiseKit を使用しています。
フォルダ構成は以下の通りです。
├── Data │ ├── DataSource │ │ └── Http │ │ └── UserDataSource.swift │ └── Entity │ ├── Request │ │ └── UsersRequest.swift │ └── Response │ └── UserResponse.swift ├── Domain │ ├── Model │ │ └── UserModel.swift │ └── UseCase │ ├── Output │ │ └── UserUseCaseOutput.swift │ └── UserUseCase.swift ├── Presentation │ ├── Presenter │ │ ├── Output │ │ │ └── UserPresenterOutput.swift │ │ └── UserPresenter.swift │ ├── Storyboard │ │ └── Home.storyboard │ └── ViewController │ └── Home │ ├── HomeViewController.swift │ └── Parts │ └── HomeTableViewCell.swift
①初期画面読み込み
まずユーザーがアプリを起動し、画面の初期読み込みが行われます。
今回はストーリーボードで画面を作成していて、以下のような構成にしています。
②Modelの初期化
HomeViewControllerをインスタンス化する際に、UserModelの初期化を行います。
UserModelでは初期化するためのクラスと構造体を記載します。
// Presentation/ViewController/Home/HomeViewController.swift import UIKit class HomeViewController: UIViewController { var model = UserModel(users: []) }
// Domain/Model/UserModel.swift import Foundation class UserModel { var users: [User] init(users: [User]) { self.users = users } } struct User { var id: String var name: String init(id: String, name: String) { self.id = id self.name = name } }
③画面描画後、④Presenterメソッド呼び出し
HomeViewControllerのviewDidLoadで画面が描画された後、UserPresenterのgetUsersメソッドを呼びます。
// Presentation/ViewController/Home/HomeViewController.swift import UIKit class HomeViewController: UIViewController { var model = UserModel(users: []) var presenter = UserPresenter() override func viewDidLoad() { super.viewDidLoad() presenter.getUsers() } }
⑤UseCaseメソッド呼び出し
UserPresenterではHomeViewControllerの呼び出したメソッド内で、UserUseCaseのgetUsersメソッドを呼びます。
// Presentation/Presenter/UserPresenter.swift import Foundation class UserPresenter { var useCase = UserUseCase() func getUsers() { useCase.getUsers() } }
⑥DataSourceメソッド呼び出し
UserUseCaseではUserPresenterの呼び出したメソッド内で、UserDataSourceのgetUsersメソッドを呼びます。
// Domain/UseCase/UserUseCase.swift import Foundation import PromiseKit class UserUseCase { var userDataSource = UserDataSource() func getUsers() { userDataSource.getUsers().done{ // API取得後の実装 }.catch { error in print(error) } } }
⑦API呼び出し、⑧API結果取得、⑨データをUseCaseに渡す
UserDataSourceではUserUseCaseの呼び出したメソッド内でAPIの結果を取得し、取得したデータをUserUseCaseに渡します。
API取得のためのリクエスト情報とレスポンス情報は、Entityフォルダ内に記載しています。
// Data/DataSource/Http/UserDataSource.swift import Foundation import PromiseKit import Alamofire class UserDataSource { func getUsers() -> Promise<[UserResponse]> { let request = UsersRequest() return response(url: URL(string: request.url + request.port + request.path)!) } func response<Response: Codable> (url: URL, method: HTTPMethod = .get) -> Promise<Response> { return Promise<Response> { resolver in AF.request(url, method: method) .responseData{ response in switch response.result { case .success: do { let decoder = JSONDecoder() guard let data = response.data else { return } let result = try decoder.decode(Response.self, from: data) resolver.fulfill(result) } catch { resolver.reject(error) } case .failure(let error): resolver.reject(error) } } } } }
// Data/Entity/Request/UsersRequest.swift import Foundation struct UsersRequest { let url = "http://localhost" let port = ":3000" let path = "/users" }
// Data/Entity/Response/UserResponse.swift import Foundation struct UserResponse: Codable { let id: Int let name, username, email, phone, website: String }
⑩データをPresenterに渡す
UserDataSourceのAPIでデータの取得が完了できたら、UserUseCaseのgetUsersメソッドのクロージャーが呼ばれます。
そのクロージャーの中でAPIから取得したデータを、デリゲートメソッドでUserPresenterに渡します。
// Domain/UseCase/UserUseCase.swift import Foundation import PromiseKit class UserUseCase { var userDataSource = UserDataSource() weak var delegate: UserUseCaseOutput! func getUsers() { userDataSource.getUsers().done{ result in self.delegate.setUsers(result: result) }.catch { error in print(error) } } }
// Domain/UseCase/Output/UserUseCaseOutput.swift import Foundation protocol UserUseCaseOutput: AnyObject { func setUsers(result: [UserResponse]) }
⑪クロージャーを定義してデリゲートメソッドとして処理を委任
UserPresenterでUserUseCaseのデリゲートメソッドを準拠させ、その中でデータバインディングを行うクロージャーを定義します。
そのクロージャーをデリゲートメソッドでHomeViewControllerに渡します。
またUserModelで、API取得の結果からデータの更新をするupdateUsersメソッドを追加します。
// Presentation/Presenter/UserPresenter.swift import Foundation class UserPresenter { var useCase = UserUseCase() weak var delegate: UserPresenterOutput! func getUsers() { useCase.delegate = self useCase.getUsers() } } extension UserPresenter: UserUseCaseOutput { func setUsers(result: [UserResponse]) { self.delegate?.setUsers { model in model.updateUsers(result: result) } } }
// Presentation/Presenter/Output/UserPresenterOuput.swift import Foundation typealias BindViewModelBlock<T> = (_ model: T) -> Void protocol UserPresenterOutput: AnyObject { func setUsers(bindViewModel: BindViewModelBlock<UserModel>) }
// Domain/Model/UserModel.swift import Foundation class UserModel { // 省略 func updateUsers(result: [UserResponse]) { self.users = result.map{ return User(id: String($0.id), name: $0.name) } } } // 省略
⑫委任されたデリゲートメソッドでデータバインディング、⑬画面表示
HomeViewControllerでUserPresenterのデリゲートメソッドを準拠させ、データバインディングを行うクロージャーを実行します。
実行後テーブルを更新して、更新したデータの画面を表示します。
// Presentation/ViewController/Home/HomeViewController.swift import UIKit class HomeViewController: UIViewController { var model = UserModel(users: []) var presenter = UserPresenter() override func viewDidLoad() { super.viewDidLoad() presenter.delegate = self presenter.getUsers() } } // TableViewのデリゲートメソッドは省略 extension HomeViewController: UserPresenterOutput { func setUsers(bindViewModel: (UserModel) -> Void) { bindViewModel(model) tableView.reloadData() } }
画面が更新されたら、表示結果は以下のようになります。
まとめ
ここまで、iOSアプリ設計の概要やその流れについてまとめていきました。
他にも、複数チームのおけるコーディングルールの策定やユニットテストや自動テストなど、本プロジェクトでは様々な取り組みをしているので、そちらの話も今後記載していきたいと思います。
最後になりますが、JCB では我々と一緒に働きたいという人材を募集しています。 詳しい募集要項等についてはリンク先の採用ページをご覧下さい。
本文および図表中では、「™」、「®」を明記しておりません。
Google Cloud, GCP ならびにすべての Google の商標およびロゴは Google LLC の商標または登録商標です。
記載されている会社名、製品名は、各社の登録商標または商標です。