【SwiftUI】MVVM構造でログイン機能を実装してみた



自身の知識の整理と備忘録も兼ねて「**Swift UI × MVVMアーキテクチャ**」でログイン機能を実装する流れをまとめてみます。

間違っている点やこうした方が良くなるなどの意見がありましたらコメントくださると嬉しいです。完成したものはGitHubにて公開しています！

https://github.com/amefure/iOS-Login-function-following-MVVM-architecture

# 環境

+  Swift UI
+  Xcode
+  Firebase 
+  Cocoa Pods
+  MVVMアーキテクチャ

ログイン機能を実装するにあたって全て自分で実装したわけではなくGoogleが提供しているクラウドサービスである**FirebaseのAuthentication**を利用していきます。


Authentication(以下Auth)を組み込むだけでユーザー情報の登録や操作、ログイン状態の管理、確認メールやリセットメールなどのメール送信機能を簡単に実装でき、かつ**無料の範囲でも1日3000人まで利用可能**になっています。

[【Swift UI/Firebase】Authentication導入方法と使い方！ログイン機能の実装](https://appdev-room.com/swift-firebase-realtime-authentication)

# 概要
今回作成するログイン機能は以下の3つのプロバイダアカウントを使用できるように実装します。もしiOSアプリを公開するならAppleアカウントでのログインを実装しないとリジェクトされることもあるので注意してください。

1. E-mail/パスワード
1. Googleアカウント
1. Appleアカウント

またログイン機能を実装するにあたって必要になりそうな機能を洗い出してみます。

+ 新規登録
+ ログイン(サインイン)
+ ログアウト(サインアウト)
+ 退会
+ パスワードリセット

英語の意味的にログインではなく「サインイン」が正しいようなのでサインインを使います。


# 設計

正しいかどうかわかりませんが**MVVMアーキテクチャに倣って整理**してみました。

https://appdev-room.com/swift-mvvm

## M：Models
+ Auth操作モデル
+ 各プロバイダ操作モデル

モデルとして切り出すのはAuthの操作です。ユーザー登録やサインイン、退会処理などAuthを直接操作するようなAPIを実装します。

![58E1AF22-AFC8-4485-AD6F-05817026612F.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/b9f84176-0299-012b-02aa-a7fa02f54320.png)

## V：View
+ サインイン画面
+ 新規登録画面
+ パスワードリセット画面
+ Googleアカウントでの登録
+ Appleアカウントでの登録
+ サインイン時のみ表示される画面
+ サインアウト&退会画面

必要となる画面は上記の通りです。

## VM： ViewModel
+ Auth絡みのモデルを操作するViewModel
+ バリデーション

モデルを操作するためのViewModelとバリデーションロジック用のViewModelを実装します。

# 前準備
まずは「Firebase × Xcode」のセットアップを行います。流れだけざっと確認しておきます。

1. プロジェクトの作成
1. Firebaseのセットアップ
1. FirebaseプロジェクトにiOSアプリを追加
1. Cocoa Podsの組み込み&ライブラリ導入
1. エントリーポイントに初期化コードを記載
1. 各プロバイダを使用可能にする


## プロジェクトの作成
まずはXcodeよりSwiftプロジェクトを作成します。

![77FDFBF0-005C-4293-8AF8-FD7FAAEFE479.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/648af42c-5c56-b087-a079-4cf339ac4c6c.png)

今回は**インターフェースをSwift UIで構築**していきます。

## Firebaseのセットアップ
Firebaseにログインして**Firebaseプロジェクトを作成**します。

作成したらAuthを使えるように「**始める**」をクリックします

![6A4602BC-85D7-4B72-8690-36C01BDD708A.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/3880c978-ef1e-0b6b-a8f8-887b0f192e23.png)

サインイン方法を選択できるので「**メール/パスワード**」と「**Google**」「**Apple**」を選択して進みます。


![0E7223D7-F686-4436-BBD8-680EBFD1A7EE.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/2ada6aa6-e2b2-d5f1-1c3d-f04583196a92.png)

[【Swift UI/Xcode】Firebaseの導入方法！Cocoa Podsでの使い方](https://appdev-room.com/swift-firebase)


## FirebaseプロジェクトにiOSアプリを追加
FirebaseプロジェクトにiOSアプリを追加します。
![6BB50E7E-9A9B-4F7D-AF4E-AAE2BAD4B2A8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/c77a5681-adb0-4e0b-07aa-40e40899e29e.png)

Swiftプロジェクトにダウンロードしたり**GoogleService-Info.plist**」を組み込みます。


![03411DEE-9553-4F79-B11A-C54008EA8CE5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/f686ef58-73b1-e2f6-8e53-7a0a6cec6069.png)


「Copy items if needed」と「Create groups」にチェックを入れて「Finish」をクリックします。

![0BAA1332-D111-4C44-9945-527A060DB90B.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/0b910528-4d0e-235d-b004-66c5e224b9b5.png)

## Cocoa Podsの組み込み&Authの導入
続いてライブラリ管理ツールである「Cocoa Pods」をプロジェクトに組み込みながらAuthとGoogleアカウントサインインに必要なライブラリを導入します。
ターミナルで以下のコマンドを打ち込み「PodFile」を生成します。

```ruby:ターミナル
$ cd プロジェクトまでのパス
$ pod init
```
生成できたらPodFile内に各ライブラリをインストールするコードを追加します。
```ruby:PodFile
# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target '(プロジェクト名)' do

  use_frameworks!

  pod 'FirebaseAuth'
  pod 'GoogleSignIn'

end
```
最後に実行すればプロジェクトにAuthが組み込まれます。

```ruby:ターミナル
$ pod install
```

[【Swift UI】CocoaPodsのインストール方法と使い方！](https://appdev-room.com/swift-cocoapods)

## エントリーポイントに初期化コードを記載

```swift:TestFirebaseApp
import SwiftUI
import FirebaseCore // 追加

// 追加
class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        FirebaseApp.configure()
        return true
    }
}

@main
struct TestFirebaseApp: App {
    // 追加
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
```

これで**下準備は完成**です。

[【Swift UI/Firebase】Authentication導入方法と使い方！ログイン機能の実装](https://appdev-room.com/swift-firebase-realtime-authentication)

## 「Sign in with Apple」を実装するために

Appleアカウントでのサインイン(= 「Sign in with Apple」)を実装するためには前準備がいくつか必要です。

1. App IDの登録と「Sign in with Apple」の有効
1. Xcodeの「Signing & Capability」にと「Sign in with Apple」の追加
1. Appleサインインボタンの実装

[【Swift UI/Firebase】AuthenticationでSign in with Appleでログインを実装する方法](https://appdev-room.com/swift-firebase-authentication-apple)

## Googleアカウントでのサインインを実装するために
Googleアカウントでのサインインを実装するためにもいくつか前準備が必要です。

1. カスタムURLスキームの追加
1. AppDelegateクラスへの追記
1. Googleサインインボタンの実装

[【Swift UI/Firebase】AuthenticationでGoogleアカウントでログインを実装する方法！](https://appdev-room.com/swift-firebase-authentication-google)

# ログイン機能の処理をMVVMに分割していく
浅はかな知識ながらMVVMアーキテクチャを意識してサインイン機能の責務を分割していきました。

## Model
結果モデルは大元1つと各プロバイダごとに1つずつ、エラーモデル1つになりました。

1. AuthModel.swift
1. EmailAuthModel.swift
1. GoogleAuthModel.swift
1. AppleAuthModel.swift
1. AuthErrorModel.swift

```swift:AuthModel.swift
import UIKit
import FirebaseAuth
import FirebaseCore
import AuthenticationServices
import SwiftUI

class AuthModel {

    // MARK: - シングルトン
    static let shared = AuthModel()
    
    // MARK: - リファレンス
    private  let auth = Auth.auth()

    
    // MARK: - 
    public func getCurrentUser() -> User? {
        return auth.currentUser
    }
    
    public let defaultName = "no name"
    
    // MARK: - Sign In for Credential
    public func credentialSignIn(credential: AuthCredential,completion : @escaping (Result<Bool, Error>) ->  Void ){
        self.auth.signIn(with: credential) { (authResult, error) in
            if error == nil {
            if authResult?.user != nil{
                if authResult!.user.displayName == nil{
                    // Appleアカウントの場合は存在しない
                }
                completion(.success(true))
            }
            }else{
                completion(.failure(error!))
            }
        }
    }
    
    // MARK: - サインアウト処理
    public func SignOut(completion : @escaping (Result<Bool, Error>) ->  Void ){
        do{
            try auth.signOut()
            print("SignOut")
            completion(.success(true))
        } catch let signOutError as NSError {
            completion(.failure(signOutError))
        }
    }
    
    // MARK: -  各プロバイダ退会処理 & Appleアカウントは直呼び出し
    public func withdrawal(completion : @escaping (Result<Bool, Error>) ->  Void ){
        if let user = auth.currentUser {
            user.delete { error in
                if error == nil {
                    print("退会成功")
                    completion(.success(true))
                } else {
                    print("退会失敗")
                    completion(.failure(error!))
                }
            }
        }
    }
}
```
## ViewModel

Authに絡んだ各Modelを一括で操作するViewModelにしました。なかなか大きくなってしまいましたがここをもう少し切り分けた方が良かったのかな？

1. AuthViewModel.swift
1. ValidationViewModel.swift

```swift:AuthViewModel.swift

import UIKit
import FirebaseAuth
import FirebaseCore
import AuthenticationServices // Apple

class AuthViewModel:ObservableObject {
    
    // MARK: - シングルトン
    static let shared = AuthViewModel()
    
    private var auth = AuthModel.shared
    private let emailAuth = EmailAuthModel.shared
    private let googleAuth = GoogleAuthModel.shared
    private let appleAuth = AppleAuthModel.shared
    
    private let errModel = AuthErrorModel()
    
    @Published var errMessage:String = ""
    
    private func switchResultAndSetErrorMsg(_ result:Result<Bool,Error>) -> Bool{
        switch result {
        case .success(_) :
            return true
        case .failure(let error) :
            print(error.localizedDescription)
            self.errMessage = self.errModel.setErrorMessage(error)
            return false
        }
    }
    
    public func resetErrorMsg(){
        self.errMessage = ""
    }
    
    // MARK: -
    public func getCurrentUser() -> User? {
        return self.auth.getCurrentUser()
    }
    
    /// サインアウト
    public func signOut(completion: @escaping (Bool) -> Void ) {
        self.auth.SignOut { result in
            completion(self.switchResultAndSetErrorMsg(result))
        }
    }
    
    /// 退会
    public func withdrawal(completion: @escaping (Bool) -> Void ) {
        self.auth.withdrawal { result in
            completion(self.switchResultAndSetErrorMsg(result))
        }
    }
    
    
}

// MARK: - Email
extension AuthViewModel {
    
    /// サインイン
    public func emailSignIn(email:String,password:String,completion: @escaping (Bool) -> Void ) {
        emailAuth.signIn(email: email, password: password) { result in
            completion(self.switchResultAndSetErrorMsg(result))
        }
    }
    
    /// 新規登録
    public func createEmailUser(email:String,password:String,name:String,completion: @escaping (Bool) -> Void ) {
        emailAuth.createUser(email: email, password: password, name: name) { result in
            completion(self.switchResultAndSetErrorMsg(result))
        }
    }
    
    /// 再認証→退会
    public func credentialEmailWithdrawal(password:String,completion: @escaping (Bool) -> Void ) {
        emailAuth.reAuthUser(pass: password) { result in
            if self.switchResultAndSetErrorMsg(result) {
                self.withdrawal { result in
                    completion(result)
                }
            }
        }
    }
    
    /// リセットパスワード
    public func resetPassWord(email:String,completion: @escaping (Bool) -> Void ) {
        emailAuth.resetPassWord(email: email) { result in
            completion(self.switchResultAndSetErrorMsg(result))
        }
    }
    
}

// MARK: - Google
extension AuthViewModel {
    
    /// サインイン
    public func credentialGoogleSignIn(completion: @escaping (Bool) -> Void ) {
        googleAuth.getCredential { credential in
            if credential != nil {
                self.auth.credentialSignIn(credential: credential!) { result in
                    completion(self.switchResultAndSetErrorMsg(result))
                }
            }
        }
    }
    
    /// 再認証→退会
    public func credentialGoogleWithdrawal(completion: @escaping (Bool) -> Void ) {
        self.credentialGoogleReaAuth { result in
            if result {
                self.withdrawal { result in
                    completion(result)
                }
            }
        }
    }
    
    /// 再認証
    private func credentialGoogleReaAuth(completion: @escaping (Bool) -> Void ) {
        googleAuth.getCredential { credential in
            if credential != nil {
                self.googleAuth.reAuthUser(user: self.auth.getCurrentUser()!, credential: credential!) { result in
                    completion(self.switchResultAndSetErrorMsg(result))
                }
            }
        }
    }
    
}

// MARK: - Apple
extension AuthViewModel {
    
    ///  サインイン
    public func credentialAppleSignIn(credential:AuthCredential,completion: @escaping (Bool) -> Void ) {
        self.auth.credentialSignIn(credential: credential) { result in
            print("Apple Login")
            completion(self.switchResultAndSetErrorMsg(result))
        }
    }
    
    /// Firebase
    public func getHashAndSetCurrentNonce() -> String {
        let nonce = appleAuth.randomNonceString()
        appleAuth.currentNonce = nonce
        return appleAuth.sha256(nonce)
    }
    
    /// ボタンクリック後の結果分岐処理
    public func switchAuthResult(result:Result<ASAuthorization, Error>) -> AuthCredential?{
        return appleAuth.switchAuthResult(result: result)
    }
    
    //    public func editUserInfo(credential:AuthCredential,name:String,completion: @escaping (Bool) -> Void ) {
    //        if let user = auth.getCurrentUser() {
    //            appleAuth.editUserNameApple(user: user, credential: credential, name: name) { result in
    //                completion(result)
    //            }
    //        }
    //    }
}
```

## MVVMアーキテクチャに倣った構造
今回はMVVMアーキテクチャに倣った構造でログイン機能を実装したかったので以下のポイントに注意しました。

+ 各要素の疎結合
+ View-ViewModel間はデータバインディング
+ Model-View間は直接的に関わらない
+ Model-ViewModel間はデータを渡し合う
+ ViewModelが仲介役
+ Modelは依存性をなくす

んー？意識はしてみたものの完璧にできているか自信はありません。。

まだまだ勉強は必要そうです。。
ご覧いただきありがとうございました！
この記事で作成したログイン機能はGitHubに公開しています！

https://github.com/amefure/iOS-Login-function-following-MVVM-architecture