MVVM + Clean ArchitectureのiOSアプリ設計


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で、ライブラリはAlamofirePromiseKit を使用しています。

フォルダ構成は以下の通りです。

├── 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 の商標または登録商標です。

記載されている会社名、製品名は、各社の登録商標または商標です。

©JCB Co., Ltd. 20︎21