【iOS】Core Data + SwiftUIをMVVMで組む

はじめに

今回はタイトルのとおり、Core DataとSwiftUIをMVVMで実装してみたいと思います。

データはCore Dataに保存、画面はSwiftUIで作るとして、じゃあそれらをどうやって繋げればいいのか?
そんな問題にぶち当たっている人の助けになれば幸いです。

ここではサンプルとして、アプリでよくある「ユーザー登録画面とそれを一覧表示する画面」を作りながら説明したいと思います。

制作/動作確認環境は Xcode 11.3.1 / Swift5 です。

プロジェクト作成

Xcodeを起動し、メニューの File > New > Project… から新規にプロジェクトを作成します。

Core Dataを使ったプロジェクトの作成方法、基本的な使い方についてはこちらの記事でもまとめていますので、合わせてご参照ください。

Core Dataのモデル作成

XcodeのProject Navigatorから.xcdatamodeldファイルを選択し、モデル編集画面を開きます。

ユーザー情報を格納するエンティティを作成します。

↓のようにしてみました。

【エンティティ】
User
 ユーザー情報を表す

【属性】
admin
 Boolean型 / Not Optional + Default “NO” / 管理者権限を持つユーザーかどうか

userId
 String型 / Not Optional + Default “Empty String” / ログインID

nickname
 String型 / Not Optional + Default “Empty String” / 画面表示用の名前

password
 String型 / Not Optional + Default “Empty String” / パスワード
(※今回はサンプルなので平文で保存しちゃう)

NSManagedObjectのコード生成

モデル編集画面からENTITIESのUserを選択し、右ペインにあるCodegenを「Manual/None」にします。

次にメニューから Editor > Create NSManagedObject Subclass… を選択し、コードの生成を行います。

すると、以下の2ファイルが作成されるかと思います。

//
//  User+CoreDataClass.swift
//

import Foundation
import CoreData

@objc(User)
public class User: NSManagedObject {

}
//
//  User+CoreDataProperties.swift
//

import Foundation
import CoreData


extension User {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<User> {
        return NSFetchRequest<User>(entityName: "User")
    }

    @NSManaged public var userId: String?
    @NSManaged public var admin: Bool
    @NSManaged public var nickname: String?
    @NSManaged public var password: String?

}

EntityをIdentifiableに適用させる

Userエンティティの配列をSwiftUIのForEachで回したいので、ここでIdentifiableに適用させておきます。

また、属性をOptionalではなくしたので、ついでに「?」を消しておきます。

結果、↓こんな感じに。

//
//  User+CoreDataProperties.swift
//

import Foundation
import CoreData

extension User {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<User> {
        return NSFetchRequest<User>(entityName: "User")
    }

    @NSManaged public var userId: String
    @NSManaged public var admin: Bool
    @NSManaged public var nickname: String
    @NSManaged public var password: String

}

extension User: Identifiable {
}

Model作成

Core Dataの読み書きを行うModelを作成します。

ここではCoreDataModel(名前そのまんま)と、NSPersistentCloudKitContainerを拡張するためのextensionの2ファイルを作成します。

※Core Dataの基本的な使い方に関しては上でも紹介したこちらの記事に記載していますので、ここでの説明は省略いたします。

//
//  CoreDataModel.swift
//

import UIKit
import CoreData

class CoreDataModel {
    
    /// Persistent Container
    ///
    /// AppDelegateに定義されたものを参照
    private static var persistentContainer: NSPersistentCloudKitContainer! = (UIApplication.shared.delegate as? AppDelegate)?.persistentContainer
    
    /// Managed Object Context
    ///
    /// Contextを返却
    private static var context: NSManagedObjectContext {
        return persistentContainer.viewContext
    }
    
    /// 初期化
    ///
    /// インスタンス生成をさせないためのprivate化
    private init() {
    }
    
    /// 挿入
    static func insert(_ object: NSManagedObject) {
        context.insert(object)
    }
    
    /// 削除
    static func delete(_ object: NSManagedObject) {
        context.delete(object)
    }

    /// 保存
    static func save() {
        persistentContainer.saveContext(context: context)
    }
    
    /// 取り消し
    static func rollback() {
        persistentContainer.rollback(context: context)
    }
}

extension CoreDataModel {
    
    /// ユーザー情報一覧取得
    static func allUsers() -> [User] {

        let request = NSFetchRequest<NSFetchRequestResult>(entityName: "User")
        
        // nicknameでソート
        let sortDescriptor = NSSortDescriptor(key: "nickname", ascending: true)
        request.sortDescriptors = [sortDescriptor]
        
        do {
            return try context.fetch(request) as! [User]
        }
        catch {
            fatalError()
        }
    }
    
    /// 新規ユーザー情報生成
    static func newUser() -> User {
        let entity = NSEntityDescription.entity(forEntityName: "User", in: context)!
        let user = User(entity: entity, insertInto: nil)
        return user
    }
}
//
//  NSPersistentCloudKitContainer+Extension.swift
//

import CoreData

extension NSPersistentCloudKitContainer {
    
    /// 保存
    func saveContext(context: NSManagedObjectContext? = nil) {
        
        let context = context ?? viewContext
        
        // 変更がなければ何もしない
        guard context.hasChanges else {
            return
        }
        
        do {
            try context.save()
        }
        catch let error as NSError {
            print("Error: \(error), \(error.userInfo)")
        }
    }
    
    /// 取り消し
    func rollback(context: NSManagedObjectContext? = nil) {

        let context = context ?? viewContext
        
        // 変更がなければ何もしない
        guard context.hasChanges else {
            return
        }

        context.rollback()
    }
}

ViewModel作成

上で作成したModel(CoreDataModel)と後ほど作成するViewとを結ぶ、ViewModelを作成します。

ユーザー情報の一覧画面と登録画面の2つを作成するので、ViewModelもUserListViewModelUserRegisterViewModelの2つを作ります。

まずはUserListViewModelから↓

//
//  UserListViewModel.swift
//

import SwiftUI

class UserListViewModel: ObservableObject {
    
    /// ユーザー情報一覧
    @Published var users: [User]
    
    /// 初期化
    init() {
        self.users = []
    }
    
    /// データ全件取得
    func fetchAll() {
        users = CoreDataModel.allUsers()
    }
    
    /// 表示イベント
    func onAppear() {
        fetchAll()
    }
    
    /// 行削除イベント
    func onDeleteRows(offsets: IndexSet) {
        
        offsets.forEach { index in
            CoreDataModel.delete(users[index])
        }
        
        CoreDataModel.save()
        
        users.remove(atOffsets: offsets)
    }
}

ViewModelはObservableObjectプロトコルに適合したクラスとして定義します。

このObservableObjectは、適合したクラスをObserverパターンで言うところのSubjectにしてくれるプロトコルで、これを適合することにより、ViewModelが持つプロパティ(@Published付きで宣言されたもの)に変更が生じると、それをObserver(後述のViewにて、@ObservedObject付きで宣言されたもの)に通知してくれるようになります。

ここで作成したUserListViewModelの内容としては、

  • Userの一覧を保持する配列の宣言
  • CoreDataModelから全件引っ張ってくるメソッド
  • 画面が表示された時のイベントに対応するハンドラ
  • リストの行の削除が行われた時のイベントに対応するハンドラ

となっています。

続けて、UserRegisterViewModel

//
//  UserRegisterViewModel.swift
//

import SwiftUI

class UserRegisterViewModel: ObservableObject {
    
    /// 入力チェックエラー
    enum ValidationError: String {
        case none = ""
        case emptyNickname = "表示名を入力してください。"
        case emptyUserId = "ユーザーIDを入力してください。"
        case emptyPassword = "パスワードを入力してください。"
        case mismatchedPassword = "パスワードが一致しません。"
    }
    
    /// ユーザー情報
    @ObservedObject var user: User
    
    /// 確認用パスワード
    @Published var password2: String = ""
    
    /// アラートダイアログ表示フラグ
    @Published var isPresentedAlert: Bool = false
    
    /// 入力チェックエラー
    @Published var validationError: ValidationError = .none
    
    /// 編集モード判定
    private var isEditMode: Bool = false
    
    /// 画面タイトル
    var title: String {
        return isEditMode ? user.nickname : "新規ユーザー"
    }
    
    /// エラーメッセージ
    var errorMessage: String {
        return validationError.rawValue
    }

    /// 初期化
    init(_ user: User? = nil) {
        
        if user == nil {
            self.user = CoreDataModel.newUser()
        }
        else {
            self.user = user!
            self.isEditMode = true
            self.password2 = user!.password
        }
    }
    
    /// 保存処理
    func save() {
        
        // 入力チェック
        validationError = validation()
        
        guard validationError == .none else {
            isPresentedAlert = true
            return
        }
        
        // 新規登録の場合、挿入する
        if !isEditMode {
            CoreDataModel.insert(user)
        }
        
        // コミット
        CoreDataModel.save()
    }
    
    /// キャンセル処理
    func cancel() {
        CoreDataModel.rollback()
    }
    
    /// 入力チェック
    private func validation() -> ValidationError {
        
        if user.nickname.isEmpty {
            return .emptyNickname
        }
        
        if user.userId.isEmpty {
            return .emptyUserId
        }
        
        if user.password.isEmpty {
            return .emptyPassword
        }
            
        if user.password != password2 {
            return .mismatchedPassword
        }
        
        return .none
    }
}

ここでは1件のユーザー情報を保持するためのプロパティを、@ObservedObject var user: User として定義しています。

iOS13より、NSManagedObjectはデフォルトでObservableObjectプロトコルに適合されるようになりました。

ので、NSManagedObject自体がすでにSubjectであり、ViewModelと同様、Viewにて@ObservedObject付きで宣言することでそのままView要素とのバインドが可能となっています。

ただ、参照をViewで持ってしまうとViewModelのロジックでいろいろできないので、ここではViewModelで持つようにしています。

その他、ここで作成したUserRegisterViewModelの内容としては、

  • Userを引数とするinitメソッド
    • nilを渡されたら新規登録、既存のUserオブジェクトを渡されたらその編集とする
  • 画面表示/操作に必要なプロパティ各種
  • 登録の保存処理を行うメソッド
  • 登録のキャンセル処理を行うメソッド
  • 入力された内容をチェックするメソッド(Private)

となっています。

View作成

最後にViewを作成します。

作るのはユーザーの一覧画面であるUserListViewと、Listの各行となるUserListRow、それに登録画面であるUserRegisterViewの3ファイルです。

まずはUserListViewから↓

//
//  UserListView.swift
//

import SwiftUI

struct UserListView: View {
    
    /// ViewModel
    @ObservedObject var model: UserListViewModel
    
    /// 初期化
    init() {
        self.model = UserListViewModel()
    }
    
    var body: some View {
        NavigationView {
            List {
                ForEach(self.model.users) { user in
                    NavigationLink(destination: UserRegisterView(user)) {
                        UserListRow(user)
                    }
                }
                .onDelete(perform: self.model.onDeleteRows(offsets:))
            }
            .navigationBarTitle(Text("ユーザーリスト"), displayMode: .inline)
            .navigationBarItems(trailing: NavigationLink(destination: UserRegisterView(), label: {
                Image(systemName: "person.badge.plus")
                    .font(.system(size: 20))
                    .foregroundColor(.blue)
            }))
            .onAppear(perform: self.model.onAppear)
        }
    }
}

上で作ったUserListViewModelを@ObservedObject var model: UserListViewModelとして宣言しており、初期化メソッドにてインスタンス生成しています。
(ここらへんは依存性逆転の原則とかを考えるとあまりよろしくないのですが、今回の本題からは外れるためひとまず良しとします)

また、List配下のForEachにてViewModelが持つUserの配列を回しており、各行となるUserListRowを作っています。

最初にUserをIdentifiableに適合させているためこう書いていますが、逆にIdentifiableに適合させずにForEach(self.model.users, id: \.objectID)のようにしてもループさせることができます。

あとはSwiftUIにある程度慣れている方なら難しくはないかと思います。

NavigationViewを使って右上にボタンを用意し、それが押されたら新規の登録画面へ、リストの各行が押されたらそのUserの編集の登録画面へそれぞれ遷移し、また各行にonDeleteを付けることで削除も可能なようにしています。

ListにonAppearを付けて、登録画面から戻ってきた際にリストの更新を行う(User一覧を再取得する)ようにもしています。
(ここは新規登録が行われた時のみとか、工夫が必要かもしれません)

UserListRowはこんな感じです↓

//
//  UserListRow.swift
//

import SwiftUI

struct UserListRow: View {
    
    /// ユーザー情報
    @ObservedObject var user: User
    
    /// 初期化
    init(_ user: User) {
        self.user = user
    }
    
    var body: some View {
        HStack(alignment: .center, spacing: 0) {
            
            VStack(alignment: .leading, spacing: 0) {
                
                // 表示名
                Text(self.user.nickname)
                    .font(.system(size: 16))
                    .foregroundColor(.black)
                
                // ユーザーID
                Text(self.user.userId)
                    .font(.system(size: 12))
                    .foregroundColor(.gray)
            }
            
            Spacer()
            
            // 管理者権限マーク
            if self.user.admin {
                Image(systemName: "checkmark.seal")
                    .font(.system(size: 20))
                    .foregroundColor(.green)
            }
        }
        .padding(.trailing, 8)
    }
}

UserListRowはSubViewで特にロジックもないため、Userを直接持ってしまっています。

最後にUserRegisterViewです↓

//
//  UserRegisterView.swift
//

import SwiftUI

struct UserRegisterView: View {
    
    @Environment(\.presentationMode) var presentationMode
    
    /// ViewModel
    @ObservedObject var model: UserRegisterViewModel
    
    /// 初期化
    init(_ user: User? = nil) {
        self.model = UserRegisterViewModel(user)
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            
            Spacer()
                .frame(height: 16)
            
            Toggle(isOn: self.model.$user.admin) {
                Text("管理者権限")
            }
            
            TextField("表示名", text: self.model.$user.nickname)

            TextField("ユーザーID", text: self.model.$user.userId)
            
            SecureField("パスワード", text: self.model.$user.password)
            SecureField("パスワード 確認用", text: self.$model.password2)
            
            Spacer()
        }
        .padding([.leading, .trailing], 16)
        .navigationBarTitle(Text(self.model.title), displayMode: .inline)
        .navigationBarItems(leading: Button(action: {
            
            // 登録/編集キャンセル
            self.model.cancel()
            
            // 画面を閉じる
            self.presentationMode.wrappedValue.dismiss()
            
        }, label: {
            Text("Cancel")
                .foregroundColor(.blue)
            
        }), trailing: Button(action: {
            
            // 保存
            self.model.save()
            
            if self.model.validationError == .none {
                // 画面を閉じる
                self.presentationMode.wrappedValue.dismiss()
            }
            
        }, label: {
            Text("Save")
                .foregroundColor(.blue)
        }))
            .alert(isPresented: self.$model.isPresentedAlert) {
                Alert(title: Text("確認"), message: Text(self.model.errorMessage))
        }
    }
}

デザインは考えずに、シンプルに入力項目を並べただけ。

入力後、SaveまたはCancelボタンが押されたらViewModelの各処理を呼び出し、前画面に戻るようにしています。(Saveの場合は入力チェックが通って保存された場合のみ)

また、TextFieldなど各入力項目にUserが直接バインドされていますので、Saveボタンを押した後はそのままCore Dataのinsert(新規の場合のみ)とsaveを呼べば保存がされます。

編集時に内容をキャンセルしたい場合は、rollbackを呼べばキャンセルされます。

動かしてみる

SceneDelegate.swiftにてルートのViewをUserListViewに変更したらビルドを行い、実行してみます。

👍

おわりに

いかがでしたでしょうか?

私自身、まだまだMVVMについては勉強中ですし、記事の途中で触れた「依存性逆転の原則」やその他の原則、アーキテクチャなどを考えると、今回の実装もまだまだ改善の余地がありまくりなのではないかと思います。

ただ、そこらへんはこれからも精進するとして、ひとまずはこれでCore DataとSwiftUIを合わせて利用するひとつの手段が見えたのではないかと思います。

今回の記事が誰かのお役に立てれば幸いです。

それでは。