SwiftUI & Combine MVVM サンプル
概要
そろそろSwiftUI & Combine のキャッチアップとMVVM 構成のアーキテクチャについて考察しないといけないなーと思ったのでGW中にノリで色々いじってみた。
下記のような簡易ログイン画面を用意して、SwiftUI & Combine と MVVM + クリーンアーキテクチャ構成で、一通りの雰囲気が掴めるように実装までやってみた。
今更感があるし、ネット中に数々のサンプルが転がっているけど、この記事では下記に注力する。
- ViewModel の責務・ルールを厳密にする
- 特にView → ViewModelへの単方向依存にこだわったイベント設計
- App, Domain, Infra の3層アーキテクチャ構成
- ビジネスロジック(バリデーション)を含む仕様のイメージ
クラス図
責務 & ルール
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で表示する文字列をViewModelで定義・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