yujiro's blog

「インターネット上で正しい答えを得る最善の方法は、質問することではない。間違った答えを投稿することだ」by ウォード・カニンガム

SwiftUI & Combine MVVM サンプル

概要

そろそろSwiftUI & Combine のキャッチアップとMVVM 構成のアーキテクチャについて考察しないといけないなーと思ったのでGW中にノリで色々いじってみた。

下記のような簡易ログイン画面を用意して、SwiftUI & Combine と MVVM + クリーンアーキテクチャ構成で、一通りの雰囲気が掴めるように実装までやってみた。

aft.gif (10.2 MB)

今更感があるし、ネット中に数々のサンプルが転がっているけど、この記事では下記に注力する。

  • ViewModel の責務・ルールを厳密にする
    • 特にView → ViewModelへの単方向依存にこだわったイベント設計
  • App, Domain, Infra の3層アーキテクチャ構成
  • ビジネスロジック(バリデーション)を含む仕様のイメージ

クラス図

f:id:bambookun:20210503231759p:plain

責務 & ルール

ViewModel

責務

  • Viewからイベントを受け取る
  • Combine@Publishedを使ったView <-> ViewModel のデータのバインディング、及びCombine@PassthroughSubjectを使って観測元(View)へイベントを通知する
  • 状況によりUseCase の実行をし、それにより取得したデータをCombine@PassthroughSubject にデータを反映、または無データでCombine@PassthroughSubject を操作することにより観測元(View)へイベントを通知する

ルール

  • ViewModel からView層への興味・関心をもってはいけない。
    • 依存はあくまでView → ViewModel の単方向であり、細かいView の修正によってViewModel の修正が発生するべきではない
    • ex:
      • Viewで表示する文字列をViewModelで定義・Viewに参照してはならない
        • Combine@PassthroughSubjectを使ってView にenum/各オブジェクトをイベント通知し、Viewの表示・整形はViewに任せる
    • 公開メソッド名で具体的なViewの名前をつけない
      • 公開メソッドの命名

      • の記載に関係するが、on〜 で具体的なView の名前が出てくるのは避ける
      • 例えば、onClickSubmitButton などは、Button からリンクへの変更などによってViewModel の変更が生じてしまうのでNG
      • onClickCreate といったようなイベントにフォーカスした名前にする。
  • 公開メソッドの命名はイベント系のon〜 に統一する
    • View からViewModel へのメソッドコールはイベント発生時のみ
    • post〜 やそれ系の命令系メソッドを公開するのは禁止する

ほか

App, Domain, Infra 三層構成はこちらと一緒。

依存の方向性に関するルールも上記と同様。

コードサンプル

LoginView.swift

import Combine
import SwiftUI

struct LoginView: View {
    @State private var emailAddressError: String?
    @State private var passwordError: String?
    @State private var showingAlert = false
    @State private var showingCompletedAlert = false
    @State private var showingMismatchAlert = false
    @State private var disposables = [AnyCancellable]()

    @ObservedObject private var viewModel: LoginViewModel

    init() {
        let repository = UserRepositoryImpl()
        let useCase = LoginUseCase(repository: repository)
        self.viewModel = LoginViewModel(useCase: useCase)
    }

    var body: some View {
        NavigationView {
            HStack {
                VStack {
                    HStack {
                        Text("会員情報")
                        Spacer()
                    }
                    .offset(x: 10.0, y: 0)
                    VStack {
                        TextField("メールアドレス",
                                  text: $viewModel.emailAddress,
                                  onEditingChanged: { _ in
                        }, onCommit: {})
                        if let emailAddressError = self.emailAddressError {
                            Text(emailAddressError)
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .foregroundColor(Color.red)
                                .font(Font.system(size: 14))
                        }
                        TextField("パスワード",
                                  text: $viewModel.password,
                                  onEditingChanged: { _ in
                        }, onCommit: {})
                        if let passwordError = self.passwordError {
                            Text(passwordError)
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .foregroundColor(Color.red)
                                .font(Font.system(size: 14))
                        }
                    }
                    .background(Color.white)
                    .padding(.bottom, 8)

                    Button("決定") {
                        viewModel.onTapSubmit()
                    }
                    .frame(width: 200, height: 50, alignment: .center)
                    .border(Color.blue, width: 2)

                    Spacer()
                }
                .offset(x: 0, y: 10.0)

                Spacer()
            }
            .navigationBarTitle("ログイン")
            .background(Color(red: 245 / 255, green: 245 / 255, blue: 245 / 255))
            .alert(isPresented: $showingAlert) {
                if showingMismatchAlert {
                    return Alert(title: Text("ログインが失敗しました"),
                                 message: Text("メールアドレスかパスワードが正しくありません"))
                } else {
                    return Alert(title: Text("ログインが成功しました"))
                }
            }
            .onAppear {
                self.startObserving()
            }
        }
    }

    private func startObserving() {
        viewModel.occuredLoginErrors.sink { error in
            self.emailAddressError = nil
            self.passwordError = nil
            error.errors.forEach {
                switch $0 {
                case .emptyEmail, .invalidEmail:
                    self.emailAddressError = $0.convertToString()
                case .emptyPassword:
                    self.passwordError = $0.convertToString()
                case .mismatch:
                    self.showingAlert = true
                    self.showingMismatchAlert = true
                }
            }
        }.store(in: &disposables)
        viewModel.completedLogin.sink {
            self.emailAddressError = nil
            self.passwordError = nil
            self.showingMismatchAlert = false
            self.showingCompletedAlert = true
            self.showingAlert = true
        }.store(in: &disposables)
    }
}

struct LoginView_Previews: PreviewProvider {
    static var previews: some View {
        LoginView()
    }
}

LoginViewModel.swift

import Combine
import Foundation

class LoginViewModel: ObservableObject {
    private let useCase: LoginUseCase

    private var disposables = [AnyCancellable]()

    @Published var emailAddress: String = ""
    @Published var password: String = ""

    var occuredLoginErrors = PassthroughSubject<LoginErrors, Never>()
    var completedLogin = PassthroughSubject<Void, Never>()

    init(useCase: LoginUseCase) {
        self.useCase = useCase
    }

    func onTapSubmit() {
        self.useCase.login(emailAddress: self.emailAddress,
                           password: self.password)
            .receive(on: DispatchQueue.global())
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    self.occuredLoginErrors.send(error)
                default:
                    break
                }
            }, receiveValue: {
                self.completedLogin.send()
            })
            .store(in: &disposables)
    }
}

LoginUseCase.swift

import Combine

class LoginUseCase {
    private var repository: UserRepository

    init(repository: UserRepository) {
        self.repository = repository
    }

    func login(emailAddress: String,
               password: String) -> Future<Void, LoginErrors> {
        var errors: [LoginError] = []
        if emailAddress.isEmpty {
            errors.append(LoginError.emptyEmail)
        }
        if password.isEmpty {
            errors.append(LoginError.emptyPassword)
        }
        if !errors.isEmpty {
            return Future<Void, LoginErrors> { promise in
                promise(.failure(LoginErrors(errors: errors)))
            }
        }
        return self.repository.login(emailAddress: emailAddress,
                                     password: password)
    }
}

UserRepository.swift

import Combine

protocol UserRepository {
    func login(emailAddress: String,
               password: String) -> Future<Void, LoginErrors>
}

UserRepositoryImpl.swift

import Combine

class UserRepositoryImpl: UserRepository {
    private static let permittedUsers: [String: String] = [
        "yujiro.takeyama@yujiro.com": "11111111",
        "hoge@hoge.com": "12345678"
    ]

    func login(emailAddress: String,
               password: String) -> Future<Void, LoginErrors> {
        return Future<Void, LoginErrors> { promise in
            if Self.permittedUsers[emailAddress] != nil,
                Self.permittedUsers[emailAddress] == password {
                promise(.success(()))
            } else {
                let errors = LoginErrors(errors: [LoginError.mismatch])
                promise(.failure(errors))
            }
        }
    }
}

LoginErrors.swift

struct LoginErrors: Error {
    let errors: [LoginError]

    init(errors: [LoginError]) {
        self.errors = errors
    }
}

enum LoginError: Error {
    case mismatch
    case emptyEmail
    case invalidEmail
    case emptyPassword
}

LoginErrorMapper.swift

extension LoginError {
    func convertToString() -> String? {
        switch self {
        case .emptyEmail:
            return "メールアドレスが入力されていません"
        case .invalidEmail:
            return "メールアドレスが不正です"
        case .emptyPassword:
            return "パスワードが入力されていません"
        case .mismatch:
            return nil
        }
    }
}

※ - LoginError モデル → エラー文字列にコンバートするためのextension - App層に用意 - App/Mapper/LoginErrorMapper.swift