iOS
設計
Swift
CleanArchitecture

アイスタイル的 iOS設計ベストプラクティス

アイスタイルでiOS開発をしています @mishimay です。
アイスタイルアドベントカレンダーの17日目の記事を担当します。

アイスタイルでは日々新しい試みを行っていますが、iOS設計のベストプラクティスがだんだんと収束してきたので一旦まとめておこうと思います。

目指す設計

  • 理解しやすい
  • 開発しやすい
  • テストしやすい

方針

上記設計を実現するための基本的な戦略は、各コンポーネントを 疎結合 にすることです。

具体的には以下のような設計を目指します。

  • コンポーネントの役割を明確にする
  • 依存関係を明確にする

現在、Clean Architecture 1 や MVVM などの設計手法を参考に以下のような設計を行っています。

設計

Untitled.png

  • 依存関係を一方通行にする
  • 円の内側ほど抽象度が高く一般性がある
  • 外側から内側は参照可
  • 内側は外側の事情を知らなくて良い
  • 内側から外側を参照したい場合は 依存関係逆転の原則 2 で解決する

Model層

  • 基本的には複数のプロパティによって構成される
  • あまり多くのことをやりすぎない
    • ビジネスロジックはUseCase層に書く
    • データの取得や保存はInfrastructure層に書く

UseCase層

  • ビジネスロジックを書く
    • 例えば、APIを呼んでレスポンスを加工して永続化する、というような一連のまとまった処理を記述する

ViewModel層

  • Viewに表示するデータを用意する
    • Viewからイベントを受け取って、ModelやUseCaseやInfrastructureを使ってデータを生成・収集・加工し、画面表示用データをViewに返す
  • 1View : 1ViewModel の関係

View層

  • ユーザ操作を受け取る
  • 画面を描画する
    • あくまでViewModelから受け取ったデータの表現に努める
  • UIViewControllerもこの層

Infrastructure層

  • API通信やDBのCRUD実装が主
    • ~Repository という命名にしている
  • 他、実装の具体性が高いものはここに置いている
  • 依存関係逆転の原則を利用したいため protocol を用意しておく

Utility群

  • 各層から利用される便利メソッドや便利クラス
    • 例えば ArrayDictionary への便利extension追加など
  • どの層から利用しても良い

実装例

RxSwift、RxCocoaを活用して以下のようなコードを書いています。

Model層

struct User {
    let name: String
    let email: String
}
  • 基本的に struct にする

UseCase層

class LoginUseCase {

    private let authenticationRepository: AuthenticationRepositoryProtocol
    private var myDataRepository: MyDataRepositoryProtocol

    init(authenticationRepository: AuthenticationRepositoryProtocol = AuthenticationRepository(),
         myDataRepository: MyDataRepositoryProtocol = MyDataRepository()) {
        self.authenticationRepository = authenticationRepository
        self.myDataRepository = myDataRepository
    }

    func invoke(email: String, password: String) -> Observable<User> {
        return authenticationRepository.login(email: email, password: password)
            .map { (response) -> User in
                self.myDataRepository.token = response.token
                return response.user
        }
    }

}
  • メソッドは invoke() だけにする
    • そのUseCaseの目的を明確にする
  • Repositoryを使う場合、実体をインジェクションできるように初期化メソッドの引数で渡せるようにする
    • 型は protocol を指定する
    • 本当はあまり良くないが、便宜のためデフォルト値として実体を指定している
  • 非同期処理を行う場合はObservableを返す

ViewModel層

class LoginViewModel {

    enum Result {
        case success(String)
        case failure(String)
    }

    private let loginUseCase = LoginUseCase()

    // inputs
    let email = Variable<String>("")
    let password = Variable<String>("")
    let loginTap = PublishSubject<Void>()

    // outputs
    lazy var loginResult: Observable<String> = {
        self.loginTap.flatMapFirst { _ -> Observable<String> in
            self.loginUseCase.invoke(email: self.email.value, password: self.password.value)
                .map { (user) in
                    "\(user.name)さんようこそ!"
                }
                .catchErrorJustReturn("ログインに失敗しました。")
        }
    }()

}
  • 入力用のプロパティと出力用のプロパティを用意する
  • 非同期処理を行う場合はObservableを使用する
  • タップイベントはflatMapFirstでつなげる
    • ボタン連打対策
  • ViewへはObservableを返す
    • Observableは一度errorが流れると止まってしまうため、errorをキャッチして通常の値に変換して流す
    • ここでは .catchErrorJustReturn を使っている
  • Viewへ返すObservableは lazy var で用意する
    • self. で他のObservableを参照できるため便利(init内では self. が使えない)
  • Repositoryを参照したい場合、UseCaseと同様に、実体をインジェクションできるよう初期化メソッドで渡せるようにする

View層

class LoginViewController: UIViewController {

    @IBOutlet private weak var emailTextField: UITextField!
    @IBOutlet private weak var passwordTextField: UITextField!
    @IBOutlet private weak var loginButton: UIButton!

    private let viewModel = LoginViewModel()
    private let disposeBag = DisposeBag()

    class func instantiate() -> LoginViewController {
        let controller = R.storyboard.login.instantiateInitialViewController()!
        return controller
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.loginResult.subscribe(onNext: { (message) in
            // message表示
        }).disposed(by: disposeBag)

        emailTextField.rx.text.orEmpty.bind(to: viewModel.email).disposed(by: disposeBag)
        passwordTextField.rx.text.orEmpty.bind(to: viewModel.password).disposed(by: disposeBag)
        loginButton.rx.tap.bind(to: viewModel.loginTap).disposed(by: disposeBag)
    }

}
  • ViewはただViewModelから受け取ったデータ表示するだけに努める
    • データの加工はできるだViewModelで行う
  • disposeBag を使うのはViewだけにする
    • ストリームの到着点がViewという考え方
  • 1ViewController : 1Storyboard にして、Storyboardからインスタンスを生成するファクトリメソッドを用意している
    • 多人数開発では1Storyboardにするメリットは多い 3

Infrastructure層

protocol AuthenticationRepositoryProtocol {
    func login(email: String, password: String) -> Observable<(user: User, token: String)>
}

class AuthenticationRepository: AuthenticationRepositoryProtocol {
    func login(email: String, password: String) -> Observable<(user: User, token: String)> {
        // API接続
        ...
    }
}
  • protocol を用意する

Utility群

extension String {
    var ...
    func ...
}
  • 各extensionはここに置いている

難しいところ

  • ViewModelがどこまで責任を負うか
    • たとえば、ViewがUIColorを使いたいとき、UIColorをViewModelで生成するのか、それとも種別だけ返してもらってUIColorはView側で生成するのか

さいごに

現時点での試行錯誤の結果をかいつまんで書いてみました。
まだまだ改善ポイントは多いと思いますが、何かの参考にしていただけたら幸いです。

明日は @aokis さんです!