[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その8:リファクタリング)

SwiftUI と CoreData を組み合わせたアプリの作り方を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Catalina 10.15.7
  • Xcode 12.2
  • iOS 14.2

リファクタリング概要

いろいろとコードを変更してきて、気になる点が増えてきたので、少し大規模に Refactoring します。

以下の点を直したいと考えてます。

  • ContentView をやめたい
  • TODOItem の isDone を操作する ViewModel のメソッド名 (toggleDone) を筆頭にメソッド名が不自然なものが多い
  • 変更のある箇所で、self.todoItems = self.todoItemStore.filteredItems(predicate) してるのをスマートにしたい

以下は、気になるけど まだ修正しないで置いて、必要性が高まったら改めて考えようと思っている点です。

  • 常に、CoreData 変更を行い、そこから Swift の Model を再生成しているのは、パフォーマンス的にOK?

ContentView をリネーム

非常に単純です、Xcode の機能を使用してリネームします。MyTODOMainView としました。

以下は具体的な手順です。

  1. Cmd + U で全てのテストを行い、パスすることを確認
  2. ContentView.swift 中の ContentView を選択し、コンテキストメニュー[Refactor]-[Rename...] を選択
  3. 関係する箇所が同時に表示され、見ながら変更できます。
  4. Cmd + U で 再度 全てのテストを行い、パスすることを確認したら、Commit。

Xcode の Refactoring メニューは、ファイル名まで変更してくれるので、便利です。

メソッド名の調整

isDone のフラグを toggle させるメソッドは、toggleIsDone とすることにします。

Model, ViewModel いずれにも相当するメソッドがありますが、統一しました。

そのほかにも、統一感がなかったので、統一することにしました。

ViewModel 内の todoItems の更新

これまでは、変更操作を行うメソッドの中で、直接 更新していました。

ある意味直感的ですが、忘れることが懸念されます。

ということで、自動化します。

基本的に、CoreData を変更したら todoItems を更新させることにします。

CoreData を変更すると、NotificationCenter から NSManagedObjectContextObjectsDidChange なる notification が送信されますので、それを契機に todoItems を更新することにしました。

ここまでに作ってきたコード

以下のファイルを作ってきました。

MyTODOModel.swift
Model 定義ファイル
MyTODOViewModel.swift
ViewModel 定義ファイル
MyTODOMainView.swift
View 定義ファイル
TODOItemView.swift
TODOItem 表示 View 定義ファイル
MyTODOApp.swift
App 定義ファイル
MyTODO.xcdatamodeld
CoreData モデル定義ファイル

MyTODOModel.swift

MyTODOModel.swift

//
//  MyTODOModel.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/10
//  © 2020  SmallDeskSoftware
//

import Foundation
import CoreData
import os

struct TODOItem : Identifiable, Hashable {
    static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItem", category: "TODOItem")

    var id: UUID? = nil
    var title: String = ""
    var detail: String = ""
    var isDone: Bool = false
    
    init(_ title: String,_ detail: String = "",_ isDone: Bool = false) {
        self.id = UUID()
        self.title = title
        self.detail = detail
        self.isDone = isDone
    }
}

// depend on CoreData
struct TODOItemStore {
    static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItemStore", category: "TODOItemStore")

    let container: NSPersistentContainer

    init(_ inMemory:Bool ) {
        container = NSPersistentContainer(name: "MyTODO")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }
    
    var items:[TODOItem] {
        var items:[TODOItem] = []
        let request:NSFetchRequest = CDTODOItem.fetchRequest()
        do {
            items = try container.viewContext.fetch(request).map(TODOItem.init)
        } catch {
            TODOItemStore.logger.error("error in fetching data from coredata")
        }
        return items
    }
    
    func filteredItems(_ predicate: NSPredicate? = nil) -> [TODOItem] {
        var items:[TODOItem] = []
        let request:NSFetchRequest = CDTODOItem.fetchRequest()
        if let predicate = predicate {
            request.predicate = predicate
        }
        do {
            items = try container.viewContext.fetch(request).map(TODOItem.init)
        } catch {
            TODOItemStore.logger.error("error in fetching data from coredata")
        }
        return items
    }
    
}

// MARK: CoreData dependent part
extension TODOItem {
    init(_ cdItem: CDTODOItem) {
        self.id = cdItem.id
        self.title = cdItem.title ?? ""
        self.detail = cdItem.detail ?? ""
        self.isDone = cdItem.isDone
    }
}

extension TODOItemStore {
    // create Item , then put into coredata
    @discardableResult
    func createTODOItem(_ title: String, _ detail: String = "",_ isDone: Bool = false) -> TODOItem {
        let newItem = TODOItem(title, detail)
        let description = NSEntityDescription.entity(forEntityName: "CDTODOItem", in: container.viewContext)!
        let newCDItem = CDTODOItem(entity: description, insertInto: container.viewContext)
        newCDItem.id = newItem.id
        newCDItem.title = newItem.title
        newCDItem.detail = newItem.detail
        newCDItem.isDone = newItem.isDone
        save()
        return newItem
    }
    func removeTODOItem(_ item: TODOItem) {
        guard let id = item.id else { return }
        let request: NSFetchRequest = NSFetchRequest(entityName: "CDTODOItem")
        request.predicate = NSPredicate.init(format: "id == %@", id as CVarArg)
        
        guard let items = try? container.viewContext.fetch(request),
              let cditem = items.first as? CDTODOItem, items.count == 1 else { return }

        container.viewContext.delete(cditem)
        save()
    }
    
    func toggleIsDone(_ item: TODOItem) {
        guard let id = item.id else { return }
        let request: NSFetchRequest = NSFetchRequest(entityName: "CDTODOItem")
        request.predicate = NSPredicate.init(format: "id == %@", id as CVarArg)
        
        guard let items = try? container.viewContext.fetch(request),
              let cditem = items.first as? CDTODOItem, items.count == 1 else { return }
        cditem.isDone = !cditem.isDone
        save()
    }

    func save() {
        if !container.viewContext.hasChanges { return }
        do {
            try container.viewContext.save()
        } catch {
            print(error)
        }
    }
}

MyTODOViewModel.swift

MyTODOViewModel.swift

//
//  MyTODOViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/10
//  © 2020  SmallDeskSoftware
//

import Foundation
import SwiftUI
import CoreData
import os
import Combine

class MyTODOViewModel : ObservableObject {
    var todoItemStore: TODOItemStore
    @Published var todoItems:[TODOItem] = []
    @Published var showUndoneItems:Bool = true
    var predicate: NSPredicate {
        if showUndoneItems {
            return NSPredicate(format: "isDone == false")
        }
        return NSPredicate(format: "isDone == true")
    }

    
    static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.MyTODOViewModel", category: "MyTODOViewModel")
    
    var cancellables: [AnyCancellable] = []

    init(_ inMemory: Bool) {
        self.todoItemStore = TODOItemStore(inMemory)
        todoItems = todoItemStore.filteredItems(predicate)

        cancellables.append(NotificationCenter.default
                                .publisher(for: Notification.Name.NSManagedObjectContextObjectsDidChange)
                                .sink { notification in
                                    self.updateTodoItems(notification)
                                })
    }

    func updateTodoItems(_ notification:Notification?) {
        MyTODOViewModel.logger.debug("updateTodoItems called")
        self.todoItems = todoItemStore.filteredItems(predicate)
    }
    
    func createTODOItem(_ title: String, _ detail: String = "") -> TODOItem{
        let newItem = todoItemStore.createTODOItem(title, detail)
        todoItems = todoItemStore.filteredItems(predicate)
        return newItem
    }
    
    func deleteTODOItem(_ item: TODOItem) {
        todoItemStore.removeTODOItem(item)
    }
    
    func toggleIsDone(_ item: TODOItem) {
        self.todoItemStore.toggleIsDone(item)
    }
    
    func toggleDisplayFilter() {
        showUndoneItems.toggle()
        updateTodoItems(nil)
    }
    
    static func previewViewModel() -> MyTODOViewModel {
        let viewModel = MyTODOViewModel(true)
        _ = viewModel.createTODOItem("TODOItem #1", "todo item detail")
        return viewModel
    }
}

MyTODOMainView.swift

MyTODOMainView.swift

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/10
//  © 2020  SmallDeskSoftware
//

import SwiftUI
import CoreData
import SwiftUIDebugUtil

struct MyTODOMainView: View {
    @EnvironmentObject var viewModel: MyTODOViewModel
    @State private var showNewItemView = false

    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.todoItems, id: \.self) { item in
                    TODOItemView(todoItem: item)
                        .onTapGesture {
                            self.viewModel.toggleIsDone(item)
                        }
                }
                .onDelete(perform: deleteItems)
            }
            .accessibility(identifier: "TODOList")
            .navigationTitle("MyTODO")
            .toolbar {
                #if os(iOS)
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
                #endif

                ToolbarItem(placement: .navigationBarTrailing) {
                    HStack {
                        Button(action: {
                            showNewItemView.toggle()
                        }) {
                            Label("Add Item", systemImage: "plus")
                        }
                        Button(action: {
                            viewModel.toggleDisplayFilter()
                        }) {
                            Label("Toggle", systemImage:viewModel.showUndoneItems ? "checkmark.square" : "square")
                        }
                    }
                }
            }
        }
        .sheet(isPresented: $showNewItemView) {
            NewTODOItemView(showNewItemView: $showNewItemView)
        }
    }
    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { viewModel.todoItems[$0] }.forEach(viewModel.deleteTODOItem)
        }
    }
}



struct NewTODOItemView: View {
    @EnvironmentObject var viewModel: MyTODOViewModel
    @Binding var showNewItemView: Bool
    @State private var itemTitle: String = ""
    @State private var itemDetail: String = ""

    var body: some View {
        VStack {
            Spacer()
            HStack {
                Text("Title : ")
                TextField("title", text: $itemTitle)
                    .accessibility(identifier: "NewTODOItemTitle")
            }
            .padding()
            HStack {
                Text("Detail: ")
                TextField("detail", text: $itemDetail)
                    .accessibility(identifier: "NewTODOItemDetail")
            }
            .padding()
            Spacer()
            HStack {
                Button(action: {
                    withAnimation {
                        _ = viewModel.createTODOItem(itemTitle, itemDetail)
                    }
                    showNewItemView.toggle()
                }, label: {
                    Text("OK")
                })
                .accessibility(identifier: "NewTODOItemOK")
                .padding()
                Button(action: {
                    showNewItemView.toggle()
                }, label: {
                    Text("Cancel")
                })
                .accessibility(identifier: "NewTODOItemCancel")
                .padding()
            }
            .padding()
            Spacer()
        }
        .padding()
        
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        MyTODOMainView().environmentObject(MyTODOViewModel.previewViewModel())
    }
}

TODOItemView.swift

TODOItemView.swift

//
//  TODOItemView.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/15
//  © 2020  SmallDeskSoftware
//

import SwiftUI

struct TODOItemView: View {
    let todoItem: TODOItem
    var body: some View {
        HStack {
            Image(systemName: todoItem.isDone ? "checkmark.square" : "square")
                .accessibility(identifier: "TODOItemIsDoneImage")
            VStack {
                Text(todoItem.title)
                    .font(.largeTitle)
                    .accessibility(identifier: "TODOItemTitleText")
                Text(todoItem.detail)
                    .font(/*@START_MENU_TOKEN@*/.body/*@END_MENU_TOKEN@*/)
                    .accessibility(identifier: "TODOItemDetailText")
            }
        }
    }
}

struct TODOItemView_Previews: PreviewProvider {
    static var previews: some View {
        TODOItemView(todoItem: TODOItem("Title", "Detail"))
    }
}

MyTODOApp.swift

MyTODOApp.swift

//
//  MyTODOApp.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/10
//  © 2020  SmallDeskSoftware
//

import SwiftUI
import os

@main
struct MyTODOApp: App {
    static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.MyTODOApp", category: "MyTODOApp")
    @StateObject var viewModel: MyTODOViewModel
    
    init() {
        let inMemory:Bool = ProcessInfo.processInfo.arguments.contains("TestWithInMemory")
        _viewModel = StateObject(wrappedValue: MyTODOViewModel(inMemory))
    }
    
    var body: some Scene {
        WindowGroup {
            MyTODOMainView()
                .environmentObject(viewModel)
        }
    }
}

MyTODO.xcdatamodeld

CoreData モデル定義
CoreData モデル定義

次回は、既存の TODOItem を編集できるようにします。

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です