はじめに
こんにちは。HIGの清水です。
本記事は、フューチャー Advent Calendar 2023の5日目の記事です。
昨日は、 @shibukawa さんの「HTTPでも双方向ソケット通信にチャレンジしてみた(けどやめた)」でした。
今年は社内でも珍しく、iOSアプリ開発のためにSwiftを読んだり書いたりする機会が多かった一年でした。
そんな一年の締めも、Swiftに関する記事を書いていきたいと思います。
内容
今回は、CoreData に登録したデータの変更記録を JSON 形式で出力するする際に詰まった点についてお話します。
以下の順でお話していきます。
- オブジェクトをJSON形式にエンコードして出力する
- CoreDataのEntityモデルをJSON形式で出力する
先に結論
NSManagedObject
は Codable
ではないため、NSManagedObject
を継承したクラスをJSONなどにエンコードする際には、カスタムエンコードロジックの実装が必要になる。
環境
- macOS Ventura 13.6.1(22G313)
- Xcode 15.0.1
オブジェクトをJSON形式にエンコードして出力する
Swift で自前で用意したクラスや構造体をエンコード可能にするためには、Encodable
に準拠する必要があります。
Encodable
に準拠する書き方に関しては、Apple の公式ドキュメント に詳しく記載してあります。
要約すると、Encodable
を継承すると良いため、以下のように書けます。
import Foundation
// Encodable を継承している。
class Item: Encodable {
var count: Int
var name: String
init(count: Int, name: String) {
self.count = count
self.name = name
}
}
let item = Item(count: 5, name: "hoge")
let encoder = JSONEncoder()
let data: Data = try encoder.encode(item)
if let jsonString = String(data: data, encoding: .utf8) {
print(jsonString)
}
Playground での実行結果は以下です。JSON形式で出力出来ていることが確認できます。
{"count":5,"name":"hoge"}
CoreDataのEntityモデルをJSON形式で出力する
先ほどのItem
クラスにCoreDataのNSManagedObject
を継承したいと思います。
import Foundation
import CoreData
@objc(Item)
public class Item: NSManagedObject, Identifiable {
@NSManaged public var count: Int16
@NSManaged public var name: String
}
以下のように、Encodable
を継承しても、JSON形式で出力できません。
import Foundation
import CoreData
@objc(Item)
- public class Item: NSManagedObject, Identifiable {
+ public class Item: NSManagedObject, Identifiable, Encodable {
@NSManaged public var count: Int16
@NSManaged public var name: String
}
let encoder = JSONEncoder()
let data: Data = try encoder.encode(item)
if let jsonString = String(data: data, encoding: .utf8) {
print(jsonString)
}
{}
NSManagedObject
に Encodable
を継承する場合、一手間必要でした。
独自にエンコードロジックを用意する必要があります。
CodingKey
が、エンコード(又はデコード)に必要なキーを列挙しており、
encode(to encoder: Encoder)
が今回自前で用意したエンコード用の関数になります。
import Foundation
import CoreData
@objc(Item)
public class Item: NSManagedObject, Identifiable, Encodable {
@NSManaged public var count: Int16
@NSManaged public var name: String
+
+ enum CodingKeys: CodingKey {
+ case count, name
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(count, forKey: .count)
+ try container.encode(name, forKey: .name)
+ }
}
これで NSManagedObject
クラスのオブジェクトをJSON形式で出力できるようになりました。
{"name":"Hoge","count":6}
追記: なぜカスタムロジックが必要だったのか
結論、Item
クラスに継承したNSManagedObject
クラスが、Codable
な型ではなかったからだと考えています。
公式リファレンスに以下の記載があります。
The simplest way to make a type codable is to declare its properties using types that are already Codable. These types include standard library types like String, Int, and Double; and Foundation types like Date, Data, and URL. Any type whose properties are codable automatically conforms to Codable just by declaring that conformance.
「Codable
(Encodable
かつ Decodable
)にする最も簡単な方法は、既にCodable
になっている型を使ってプロパティを宣言することです。」とあります。
NSManagedObject
クラスを継承する前のItem
クラスは、Int
型とString
型のプロパティが存在します。
ここで、公式リファレンスでそれぞれの型の説明について見てみます。
公式リファレンスの Int > Relationships > Conforms To の一覧を見ると確かに、Int
が「Encodable
かつ Decodable
」であることが分かります。
String
についても同様に、「Encodable
かつ Decodable
」であることが確認できます。
ここで、NSManagedObject
クラスについて同様に確認すると、「Encodable
かつ Decodable
」ではないことが分かりました。
元々のItem
クラスは全てのプロパティが「Encodable
かつ Decodable
」であったため、Encodable
を継承するだけでエンコードできた。
NSManagedObject
クラスを継承したItem
クラスは、NSManagedObject
自体が「Encodable
かつ Decodable
」ではないため、カスタムエンコードロジックの実装が必要だった。
上記が私が調べた結論です。
検証してみた
CoreData に登録したデータをアプリ上でJSONで出力させてみました。
変更したcount
とname
がJSON形式で表示されていることが分かると思います。
コードは、動画の下に添付しています。
Item.swift
import Foundation
import CoreData
@objc(Item)
public class Item: NSManagedObject, Identifiable, Encodable {
@NSManaged public var count: Int16
@NSManaged public var name: String
enum CodingKeys: CodingKey {
case count, name
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(count, forKey: .count)
try container.encode(name, forKey: .name)
}
}
ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.name, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
DetailView(item: item)
} label: {
HStack {
Text(item.count.description)
Text(item.name.description)
}
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.count = 0
newItem.name = "Sample"
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)")
}
}
}
}
DetailView.swift
import SwiftUI
import CoreData
import Foundation
struct DetailView: View {
@Environment(\.managedObjectContext) private var viewContext
@State private var count: Int16 = 0
@State private var name: String = ""
@State private var jsonString: String = ""
var item: Item
var body: some View {
VStack {
Button(count.description) {
count = count + 1
}
TextField(item.name, text: $name)
.border(.black)
.padding()
.onAppear {
name = item.name
}
Button("Print JSON") {
let encoder = JSONEncoder()
do {
let data: Data = try encoder.encode(item)
if let jsonStr = String(data: data, encoding: .utf8) {
print(jsonStr)
jsonString = jsonStr
}
} catch {}
}
Text("JSON = " + jsonString)
}
.toolbar {
ToolbarItem {
Button(action: saveItem) {
Text("Save")
}
}
}
}
private func saveItem() {
withAnimation {
item.count = count
item.name = name
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)")
}
}
}
}
さいごに
明日は、 @starswirl_k さんの「【React/TypeScript】今年のクリスマスはreact-konvaで雪を降らす」です。
お楽しみに。