[CoreData] Transformable な Attribute の使い方

     
CoreData の Transformable の使い方。

環境&対象

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

  • macOS Monterey 13 Beta6
  • Xcode 14.0 Beta6
  • iOS 16.0 beta

この記事では ベータ版に依存する機能は使っていません。

CoreData

CoreData では、Entity のアトリビュート型としてさまざまなタイプがサポートされています。

Apple のドキュメントは、こちら

既存の型を組み合わせて使うこともできますが、Transformable という設定を行うことで、独自の型をそのまま 保存することもできます。

以下では、この Transformable という設定の使い方を説明します。

Transformable

transformable 自体の定義は、こちら

定義だけなので、あまり意味はありません。

使い方については、非常に簡素に書かれていますが、こちらに書かれています。

ざっくり説明すると、サポートされていないデータ型を、ValueTransformer を使って、Data に変換して 保存しておこうというものです。実際の変換は、ValueTransformer を登録しておくことで、CoreData が自動的に行ってくれます。

ValueTransformer 自体は、CoreData だけではなく、Cocoa Binding でもよく使われていたクラスです。
Apple のドキュメントは、こちら

実際には、セキュリティの問題もありますので、ValueTransformer そのものではなく、NSSecureUnarchiveFromData クラス(もしくは、そのサブクラス)の transformedValue(_:) や reverseTransformedValue(_:) が使用されます。

使用する ValueTransformer を DataModel Inspector 上でモデルに登録し、さらに実行時に登録することで、変換が行われるようになります。

// Register the transformer at the very beginning.
// .colorToDataTransformer is a name defined with an NSValueTransformerName extension.
ValueTransformer.setValueTransformer(ColorToDataTransformer(), forName: .colorToDataTransformer)

実装して確認

文章だけではわかりにくいので、実際に、使ってみます。

保存する 独自タイプ

今回は、以下のような class を作成し、Transformable として CoreData に保存することにします。

public class MyClass: NSObject, Codable {
    var type: Int
    var title: String
    override init() {
        self.type = Int.random(in: 0...9)
        self.title = "Hello" + "\(Int.random(in: 10...19))"
    }
}

JSONDecoder/JSONEncoder を使って Data に変換できるようにするつもりなので、Codable に準拠させています。

ValueTransformer 定義

最初に、MyClass を変換するための ValueTransformer を作ります。

class MyClassTransformer: NSSecureUnarchiveFromDataTransformer {
    override class var allowedTopLevelClasses: [AnyClass] {
        return [MyClass.self]
    }
    override class func transformedValueClass() -> AnyClass {
        MyClass.self
    }
    override class func allowsReverseTransformation() -> Bool {
        true
    }
    
    override func transformedValue(_ value: Any?) -> Any? {
        guard let data = value as? Data else { fatalError("invalid data type") }
        let myClass = try! JSONDecoder().decode(MyClass.self, from: data)
        return myClass

    }
    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let myClass = value as? MyClass else { fatalError("invalid data type") }
        return try! JSONEncoder().encode(myClass)
    }
}

以下、概要です。
・ValueTransformer ではなく、NSSecureUnarchiveFromDataTransformer を継承して作成します
・allowedTopLevelClasses を override して、対象クラスを許諾しています
・allowsReverseTransformation は true を返すようにして、両方向への変換を可能にします
・transformedValueClass は、 MyClass.self を返します(通常の ValueTransformer と同じです)
・transformedValue/reverseTransformedValue も 通常の ValueTransformer の実装と同様です

Encode/Decode 自体は、JSONEncoder/JSONDecoder を使っています。

CoreData モデルの修正

CoreData の Model には、以下のような設定を行います。

(CoreData を選択したときに作成されるプロジェクトをベースに作業しています)
すでに作成されている Item に myClass と言う名前の Attribute を追加し、そのタイプを "Transformable" にします。

addTransformableType

追加した Attribute (myClass のこと) を選択した状態で、Inspector で、詳細を設定します。

ValueTransformerSetting
Inspector に選択要素の詳細が表示されない時
CoreData Model Editor のバグなのか、単に Attribute を選択しただけでは Inspector が更新されないことが(頻繁に)ありました。
Entitiy の選択 -> Attribute の選択 というステップをあらためて行うと、Inspector に該当 Attribute の詳細が正しく表示されました。(あくまで経験則です)
コード生成
Xcode の メニュー "Create NSManagedObject subclass..." を使って、定義した Entity から、NSManagedObject を継承したクラスのコードを作成することができますが、上記の設定を行っていると、生成されるコードで Entity の Attribute の型は 指定した型になっていることが確認できます。(Inspector に自分で記入しているので、そんなに不思議ではありませんが・・・)

実装コード

CoreData オプションを指定したときに作成されるプロジェクトをベースに作ったコードです。

MyClass, MyClassTransformer 等、すべて Content.swift に追記しています。

ポイントとなる ValueTransformer は説明ずみなので、特に追加の説明はありません。

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/09/05
//  © 2022  SmallDeskSoftware
//

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults

    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        VStack {
                            Text("Item at \(item.timestamp!, formatter: itemFormatter)")
                            Text("MyClass type : \(item.myClass?.type ?? -5)")
                            Text("MyClass title: \(item.myClass?.title ?? "NoTitle")")
                        }
                    } label: {
                        Text(item.timestamp!, formatter: itemFormatter)
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
            Text("Select an item")
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = Item(context: viewContext)
            newItem.timestamp = Date()
            newItem.myClass = MyClass()

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { items[$0] }.forEach(viewContext.delete)

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
}

private let itemFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateStyle = .short
    formatter.timeStyle = .medium
    return formatter
}()

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
    }
}

class MyClassTransformer: NSSecureUnarchiveFromDataTransformer {
    override class var allowedTopLevelClasses: [AnyClass] {
        return [MyClass.self]
    }
    override class func transformedValueClass() -> AnyClass {
        MyClass.self
    }
    override class func allowsReverseTransformation() -> Bool {
        true
    }
    
    override func transformedValue(_ value: Any?) -> Any? {
        guard let data = value as? Data else { fatalError("invalid data type") }
        let myClass = try! JSONDecoder().decode(MyClass.self, from: data)
        return myClass

    }
    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let myClass = value as? MyClass else { fatalError("invalid data type") }
        return try! JSONEncoder().encode(myClass)
    }
}

public class MyClass: NSObject, Codable {
    var type: Int
    var title: String
    override init() {
        self.type = Int.random(in: 0...9)
        self.title = "Hello" + "\(Int.random(in: 10...19))"
    }
}

次に説明しているような SQLite Viewer を使って、保存されたデータを確認すると、MyClass が保存されていることを確認することができます。

 SavedData

Tips for CoreData

CoreData を使って、アプリを開発していると、きちんと保存できたか気になることがあります。

以下、アプリで作成したデータを確認する方法を説明します。

SQLite browser

CoreData は、デフォルトでは、SQLite として保存しています。ですので、SQLite のデータファイルを読めるアプリを使って、データファイルを開くことで、保存されているデータを確認することができます。

私は、DB Browser for SQLite を使っています。

# DB Browser for SQLite が使いにくいわけではありませんが、おすすめの Viewer があったら教えていただけると嬉しいです。

SQLiteファイル位置

(Document-based でない)アプリで CoreData を使ってデータを保存すると、以下のパスにファイルが作成されます。
上に書いたような SQLite Viewer で開くとデータを確認することができます。

CoreData の sqlite ファイルが保存されているパス
/Users/[User]/Library/Containers/[AppBundleID]/Data/Library/Application Support/[AppName]/[AppName].sqlite

MEMO
Web を探すと、iOS Simulator でのファイル位置の情報はよく見るのですが、macOS での情報がなかなか見つからずたまに困ります。
ということで、自分向けにメモ。

なお、iOS Simulator でのファイル位置は、
~/Library/Developer/CoreSimulator/Devices/[DeviceID]/data/Containers/Data/Application/[AppID]/Library/Application Support/[AppName].sqlite
# DeviceID は、Xcode で "Device and Simulators" で確認する必要があります。

実機では、以下のようになります。
/var/mobile/Containers/Data/Application/[AppID]/Library/Application Support/[AppName].sqlite

実際には、以下のコードを使ってアプリ内から保存フォルダ名称を確認するのが一番お手軽です。

        if let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {            print("file exists at \(url)")
        }

まとめ

CoreData で Transformable タイプの Attribute を使う方法を見てきました。

CoreData で Transformable タイプの Attribute を使う
  • CoreData Model Editor で Attribute のタイプに Transformable を指定する
  • ValueTransformer を定義し Data との相互変換を可能にする
  • 定義した ValueTransformer は、登録する

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

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMastery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle"という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。