SwiftUI + CombineでMVVM

はじめに

こんにちは、Octでスマホアプリの開発をしているzigeninです。 SwiftUIとCombineによるMVVMの実装のポイントを解説します。 ログイン画面とログイン後の画面があるだけのサンプルアプリを題材とします。

前提

  • Apple公式のSwiftUIのチュートリアルは大体やり終えている
  • RxSwiftを触ったことがある
  • MVVMを知っている

サンプルアプリのソースコード

https://github.com/KamikazeZirou/SwiftUI-MVVM

サンプルアプリの動作環境

  • XCode 11.1でビルド
  • iPhone 11 Pro Max 13.1

サンプルアプリの画面構成

※ユーザIDが"foobar@example.com"、パスワードが"password"のときのみログインは成功します。

サンプルアプリのクラス構成

ViewはSwiftUIとCombineを両方使用します。
ViewModelとModelはCombineのみ使用します。
AnyPublisher, FutureはRxSwiftのObservableやSingleに相当します。

View

CotentView (ルート画面)

struct CotentView: View {
    @EnvironmentObject var session: Session

    var body: some View {
        VStack {
            if self.session.isLogin {
                HomeView()
                    .environmentObject(self.session)
            } else {
                LoginView()
                    .environmentObject(self.session)
            }
        }
    }
}

ログイン状態によって画面を切り替える

ログインはLoginView、ログアウトはHomeViewで行っており、ログイン状態はこれらのView内で更新されます。このとき、ログイン状態の変化を検知して、ContentViewに表示を切り替えたいです。

そこで、@EnvironmentObjectを利用します。 @EnvironmentObjectをViewのメンバ変数に付けると、複数View間で値の共有ができます。 また、値が変化したときに、View#bodyのgetterを呼んで表示も更新してくれます。

この実装例では、ログイン状態を保持するSessioinクラスのオブジェクトに@EnvironmentObjectを付けることで、やりたかったことを実現しています。

LoginView (ログイン画面)

struct LoginView: View {
    @EnvironmentObject var session: Session
    @ObservedObject private var vm = LoginViewModel()

    var body: some View {
        VStack {
            Text("Learning SwifUI-MVVM")
                .font(.title)

            TextField("User ID", text: $vm.userId)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .autocapitalization(UITextAutocapitalizationType.none)

            SecureField("Password", text: $vm.password)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            if (!vm.validationText.isEmpty) {
                Text(vm.validationText)
                    .font(.caption)
                    .foregroundColor(Color.red)
            }

            Button(action: {
                _ = self.vm.login()
                    .sink(receiveCompletion: { completion in
                        print("receiveCompletion:", completion)
                    }, receiveValue: { user in
                        print("userId:", user.id)
                        self.session.user = user
                        self.session.isLogin = true
                    })
            }) {
                Text("Login")
            }
            .disabled(!vm.canLogin)
            
        }.padding()
    }
}

ViewModelの状態が変わったら表示を更新する

@EnvironmentObject, @ObservedObjectがポイントです。

    @EnvironmentObject var session: Session
    @ObservedObject private var vm = LoginViewModel()

@EnvironmentObjectをメンバ変数sessionに付けているのは、 ログイン時(session#isLoginをtrueにした時)に、 ContentViewの表示をLoginViewからHomeViewに切り替えるためです。

LoginViewModelに@ObservedObjectを付けているのは、 LoginViewModelのメンバ変数が変化したときにLoginViewの表示更新するためです(LoginViewModel#bodyのgetterが呼ばれる)。

ログインボタンの有効/無効の制御

            TextField("User ID", text: $vm.userId)
                ...

            SecureField("Password", text: $vm.password)
                ...

            Button(action: {
                ...
            }) {
                Text("Login")
            }
            .disabled(!vm.canLogin)

処理の流れ

  1. TextField/SecureFieldの入力値を変更する
  2. LoginViewModelはuserId/passwordの値の変化を検知
  3. LoginViewModelはuserId/passwordの両方とも空でないならLoginViewModel#canLoginをtrueにする
  4. canLoginが変化した場合、LoginView#bodyのgetterが呼ばれる
  5. Button#disabled(LoginViewModel#canLogin)により、ログインボタンの有効/無効が変化する

ポイント
TextField/SecureFieldの第2引数はBindingとなっていますが、ユーザ入力値の変化はこの引数に伝わります。 ここでは、第2引数にLoginViewModelのuserIdとpasswordをPublisherに変換したもの(CombineのクラスでRxSwiftのObservableのようなもの)を指定しています。それにより、LoginViewModelはTextField/SecureFieldの値の変化を検知できます。

canLoginの変化後に、bodyのgetterが呼ばれるのは前述の@ObservableObjectのおかげです。

ログイン

            Button(action: {
                _ = self.vm.login()
                    .sink(receiveCompletion: { completion in
                        print("receiveCompletion:", completion)
                    }, receiveValue: { user in
                        print("userId:", user.id)
                        self.session.user = user
                        self.session.isLogin = true
                    })
            }) {
                Text("Login")
            }
            .disabled(!vm.canLogin)

処理の流れ

  1. Loginボタンを押す
  2. action内のclosureが呼ばれる
  3. LoginViewModel#login() (AnyPublisherを返す)
  4. ログイン成功時には、AnyPublisher#sinkのreceiveValueが呼ばれる
  5. ログインの成否によらず、AnyPublisher#sinkのreceiveCompletionが呼ばれる

ポイント
AnyPublisherとsinkが重要です。これらは、RxSwiftでいえば、Observableとsubscribeに相当します。

補足
ログイン成功時、AnyPublisher#sinkのreceiveValue内でsession.isLoginをtrueにしています。これによって、親のContentViewのbodyのgetterが呼ばれて、bodyがLoginViewからログイン後のViewであるHomeViewに切り替わります。結果、ログイン後の画面が表示されます。

ViewModel

LoginViewModel

final class LoginViewModel: ObservableObject {
    // MARK: Private
    private let authProvider: AuthProviderProtocol
    @Published private var isBusy: Bool = false
    
    // MARK: Input
    @Published var userId: String = ""
    @Published var password: String = ""
    
    // MARK: Output
    @Published private(set) var canLogin: Bool = false
    @Published private(set) var validationText: String = ""
    
    // MARK: Action
    func login() -> AnyPublisher<User, Error> {
        isBusy = true
        validationText = ""
        
        return authProvider.login(userId: userId, password: password)
            .receive(on: RunLoop.main)
            .handleEvents(receiveCompletion: { [weak self] completion in
                switch completion {
                case .finished:
                    self?.validationText = ""
                case .failure:
                    self?.validationText = "Incorrect ID or password"
                }
                
                self?.isBusy = false
            })
            .eraseToAnyPublisher()
    }
    
    init(authProvider: AuthProviderProtocol = AuthProvider()) {
        self.authProvider = authProvider
        
        _ = Publishers
            .CombineLatest3($userId, $password, $isBusy)
            .map { (userId, password, isBusy) in
                return !(userId.isEmpty || password.isEmpty || isBusy)
            }
            .receive(on: RunLoop.main)
            .assign(to: \.canLogin, on: self)
    }
}

ViewがViewModelの値の変化を監視できるようにする

final class LoginViewModel: ObservableObject {

ObservableObjectというprotocolを付けます。 これで、LoginViewModelのメンバ変数に@ObservableObjectを付けられるようになり、ViewからViewModelの状態を変化を監視できるようになります。

ViewModelのメンバ変数を監視できるようにする

    @Published private var isBusy: Bool = false
    
    // MARK: Input
    @Published var userId: String = ""
    @Published var password: String = ""
    
    // MARK: Output
    @Published private(set) var canLogin: Bool = false
    @Published private(set) var validationText: String = ""

メンバ変数に@Publishedを付けることで、Publisher(RxSwiftのObservableのようなもの)としてもアクセスできます。具体的には、$<メンバ変数名>とするとPublisherとしてアクセスできます。このPublisherを使えば、メンバ変数の値の変化を監視できます。

ユーザID/パスワードが両方とも空でなければ、ログイン可能にする

    init(authProvider: AuthProviderProtocol = AuthProvider()) {
        self.authProvider = authProvider
        
        _ = Publishers
            .CombineLatest3($userId, $password, $isBusy)
            .map { (userId, password, isBusy) in
                return !(userId.isEmpty || password.isEmpty || isBusy)
            }
            .receive(on: RunLoop.main)
            .assign(to: \.canLogin, on: self)
    }

userId/passwordの値の変化を監視して、両方とも空でなければ、ログイン可能にします(canLoginをtrueにする)。

userId/passwordは別々のAnyPublisherになっていますが、 それらをまとめて監視するために、Publishers#CombineLatest()を使います。考え方はRxSwiftと同じです。

mapもRxSwiftと同じです。mapは、Streamに流れてくる値を変換します。

receive(on:)は、RxSwiftのobserveOnと同じで、それ以降のStreamのスレッドを指定します。 ViewModelのメンバ変数の更新や外部への通知は、メインスレッドにしたいので、 RunLoop.mainを指定しています。

assign(to:on:)は、 最終的にStreamの値を代入する先を指定します。 RxSwiftでいえば、以下と同等です。

subscribe(onNext: { canLogin in self.canLogin = canLogin })

ログイン

    func login() -> AnyPublisher<User, Error> {
        isBusy = true
        validationText = ""
        
        return authProvider.login(userId: userId, password: password)
            .receive(on: RunLoop.main)
            .handleEvents(receiveCompletion: { [weak self] completion in
                switch completion {
                case .finished:
                    self?.validationText = ""
                case .failure:
                    self?.validationText = "Incorrect ID or password"
                }
                
                self?.isBusy = false
            })
            .eraseToAnyPublisher()
    }

いくつかポイントを解説します。

ログイン処理の本体は、Model層のAuthProviderに委譲しています。

ログイン完了時、成否に応じて、エラーメッセージ(LoginViewModel#validationText)を更新したいです。 LoginViewModel#validationTextを更新するのは、副作用を伴う処理です。 RxSwiftであれば、副作用を伴う処理を行う時、do(onNext:onCompleted:)を使うかと思います。 Combineでは、AnyPublisher#handleEvents()で同じことができます。

最後のeraseToAnyPublisherは、型をAnyPublisher<User, Error>にするためです。 Combineだとメソッドをチェーンするごとに戻り値の型が変わってしまいます。 ここの例では、eraseToAnyPublisherを呼ぶ前は、Publishers.HandleEvents<Publishers.ReceiveOn<Future<User, Error>, RunLoop>>という型になっています。 これをそのまま返すと扱いづらいのでeraseToAnyPublisherを呼んで、シンプルなAnyPublisher<User, Error>に変換しておきます。

Model

AuthProvider(ログイン/ログアウトを実際に行うクラス)

final class AuthProvider: AuthProviderProtocol {
    func login(userId: String, password: String) -> Future<User, Error> {
        
        return Future<User, Error> { promise in
            // This closure is unexpectedly called synchronously.
            // Therefore, wrap it with DispatchQueue.global().async
            DispatchQueue.global().async {
                // Intended to network communicate
                Thread.sleep(forTimeInterval: 1.0)
                
                 if userId == "foobar@example.com" && password == "password" {
                     promise(.success(User(id: userId, name: "foobar")))
                 } else {
                     promise(.failure(AuthError.invalidIdOrPassword))
                 }
            }
        }
    }
    ...
}

非同期 かつ バックグラウンドでログイン処理を行う

こういうケースでは、RxSwiftではSingleを使うと思います。Combineにも類似のFutureがありますので、それを使います。 Future<Output, Failure>という形式で、Outputは成功時にStreamに流す値の型、Failureは失敗時のエラーの型 です。 RxSwiftのSingleと同じく、1回だけ値が流れます。

注意点は、Futureのコンストラクタに渡したclosureは、Futureのコンストラクタから同期的に呼ばれるということです。 ですので、Futureを使って通信などを行いたいなら、直接closure内で通信するのではなく、DispatchQueue.global().asyncなどを挟む必要があります。

最後に

当面、業務ではSwiftUIとCombineを使うことはなさそうです*1。しかし、Octが標準的に使っているStoryBoardやRxSwiftが時代遅れになる可能性がもあったので、SwiftUI + Combineを学んでみました。

SwiftUIは、StoryboardでUIを組み立てるとソースコードでUIを組み立てる方法の良い所取りだと思います。ソースコードでUIのレイアウトをかけるのは気持ちが良いですし、コードレビューもしやすいです。その一方で、画面のPreviewを表示できるので、UIの組み立てが快適です。

現在、アンテナが高い先人たちがいるものの、SwiftUIとCombineの情報はあまりない印象です。そういった状況ですが、この記事が多少は誰かの役に立てば幸いです。

参考ページ

Using Combine
Combineについて、Apple公式のドキュメントより分かりやすいです!

*1:OctはBtoBであり、iOS 12以前を使用しているお客様もいるためです。また、現状のSwiftUI/Combineはいろいろ足りていない感が強いです