【Swift】UserDefaults の Dictionary にオブジェクト型データを保存する

開発中のアプリのデータ管理に UserDefaults を使っています。Objective-C時代から使っていましたが、多少忘れていたことも多かったので、今更ながら復習を兼ねて記事を書きます。

表題の通り、内容的にはオブジェクト型のデータを固有の id を key として Dictionary に格納するというものになります。UserDefault の入門的な内容ではありませんので悪しからず。

ちなみに、アプリの仕様上、膨大なデータになることは想定されないため、外部サーバにDBは立てず、お手軽な UserDefaults を採用しました。

予め、膨大なデータとなることが予想される場合や、想定される最大値が読めない場合は、大人しく MySQL 等で DB を立てることをお薦めします

モデルプロトコルの定義

protocol BaseModel: Hashable, Codable, Identifiable {
    
    static var dataKey: String { get }
    static var uniqueIdKey: String { get }
    
    var id: String { get set }
    var name: String { get set }
    var amount: Int { get set }
    var order: Int { get set }
}

まず、オブジェクト型のモデルプロトコルを定義しました。アプリの仕様上似たようなデータとなった為、全てこのプロトコルに従ったデータとするようにしました。

Codable, Hashable, Identifiable

保存するときに Data 型に Encode 及び、Data 型から Decode する為、Codable に準拠しています。

また、ForEach 等で一覧表示する時の都合で HashableIdentifiable にも準拠しています。

Key 文字列の定義

static var dataKey: String { get }
static var uniqueIdKey: String { get }

オブジェクトごとに格納する Dictionary を別にするため、それぞれの Key 文字列static な変数として持たせます。

派生オブジェクト型の定義

struct Income: BaseModel {
    
    static var dataKey: String = "incomes"
    static var uniqueIdKey: String = "income_unique_id"
    
    var id: String
    var name: String
    var amount: Int
    var order: Int
}

struct Expense: BaseModel {
    
    static var dataKey: String = "expenses"
    static var uniqueIdKey: String = "expense_unique_id"
    
    var id: String
    var name: String
    var amount: Int
    var order: Int
    
    var category: String
}

上記は開発中アプリで使っている型となります。Income は収入、Expense は支出という意味です(リリースしたらいずれまた詳しく解説する記事も書きたいと思います)。

先程のプロトコルに準拠しなくては行けませんので、同名のメンバを定義します。ただし、Expensecategory というメンバを追加しています。

UserDefaultsデータ管理クラス(全体)

protocol DataProtocol {
    
    func updateIncome(_ income: Income)
    func updateExpense(_ expense: Expense)
    
    func deleteIncome(_ income: Income)
    func deleteExpense(_ expense: Expense)

    func fetchIncomes() -> [Income]
    func fetchExpenses() -> [Expense]
}

class UserDefaultsData: DataProtocol {
    
    let userDefaults = UserDefaults.standard
    
    init() {
        if self.userDefaults.string(forKey: Income.uniqueIdKey) == nil {
            self.userDefaults.set("00000000", forKey: Income.uniqueIdKey)
        }
        if self.userDefaults.string(forKey: Expense.uniqueIdKey) == nil {
            self.userDefaults.set("00000000", forKey:  Expense.uniqueIdKey)
        }        
        if self.userDefaults.dictionary(forKey: Income.dataKey) == nil {
            self.userDefaults.set(Dictionary<String, Data>(), forKey: Income.dataKey)
        }
        if self.userDefaults.dictionary(forKey: Expense.dataKey) == nil {
            self.userDefaults.set(Dictionary<String, Data>(), forKey: Expense.dataKey)
        }
    }

    func getIncomeUniqueId() -> String {
        return getUniqueId(Income.self)
    }

    func getExpenseUniqueId() -> String {
        return getUniqueId(Expense.self)
    }

    private func getUniqueId<T: BaseModel>(_ type: T.Type) -> String {
        guard let id = self.userDefaults.string(forKey: T.uniqueIdKey) else {
            return ""
        }
        guard var integerId = Int(id) else {
            return ""
        }
        integerId += 1
        let uniqueId = String(format: "%08d", integerId)
        self.userDefaults.set(uniqueId, forKey: T.uniqueIdKey)
        return uniqueId
    }
    
    func fetchIncomes() -> [Income] {
        return fetch(Income.self)
    }
    
    func fetchExpenses() -> [Expense] {
        return fetch(Expense.self)
    }
    
    private func fetch<T: BaseModel>(_ type: T.Type) -> [T] {
        var datas = [T]()
        if let values = self.userDefaults.dictionary(forKey: T.dataKey) {
            for (_, value) in values {
                if let data = try? JSONDecoder().decode(T.self, from: value as! Data) {
                    datas.append(data)
                }
            }
        }
        datas.sort { $0.order < $1.order }
        return datas
    }
    
    func updateIncome(_ income: Income) {
        update(targetData: income)
    }
    
    func updateExpense(_ expense: Expense) {
        update(targetData: expense)
    }
    
    private func update<T: BaseModel>(targetData: T) {
        guard let data = try? JSONEncoder().encode(targetData) else {
            return
        }
        if var values = self.userDefaults.dictionary(forKey: T.dataKey) {
            values[targetData.id] = data
            self.userDefaults.set(values, forKey: T.dataKey)
        }
    }
    
    func deleteIncome(_ income: Income) {
        delete(targetData: income)
    }
    
    func deleteExpense(_ expense: Expense) {
        delete(targetData: expense)
    }
    
    private func delete<T: BaseModel>(targetData: T) {
        if var values = self.userDefaults.dictionary(forKey: T.dataKey) {
            values.removeValue(forKey: targetData.id)
            for (_, value) in values {
                guard var tempData = try? JSONDecoder().decode(T.self, from: value as! Data) else {
                    continue
                }
                if tempData.order < targetData.order {
                    continue
                }
                tempData.order -= 1
                if let data = try? JSONEncoder().encode(tempData) {
                    values[tempData.id] = data
                }
            }
            self.userDefaults.set(values, forKey: T.dataKey)
        }
    }
}

まず、DataProtocol というプロトコルを定義して、UserDefaultsData クラスに継承しています。プロトコルを定義した理由は、モックデータや他のデータ取得元に応じたクラスに切り替えられるようにするためです

具体的には以下のような使い方となります。

class AppData {
    
    static let shared = AppData()
    private init() {
        self.data = UserDefaultsData()
//        self.data = MockData()
//        self.data = FirebaseData()
    }
    
    let data: DataProtocol
}

コメントアウトしている self.data = 〇〇 の部分を切り替えるだけでデータ取得元を切り替えられます。そのため、各メソッドを読んでいるコードを書き換える必要はありません。

// データ取得元を替えても問題なし
let incomes = AppData.shared.data.fetchIncomes()

init(イニシャライザー)

init() {
    if self.userDefaults.string(forKey: Income.uniqueIdKey) == nil {
        self.userDefaults.set("00000000", forKey: Income.uniqueIdKey)
    }
    if self.userDefaults.string(forKey: Expense.uniqueIdKey) == nil {
        self.userDefaults.set("00000000", forKey:  Expense.uniqueIdKey)
    }        
    if self.userDefaults.dictionary(forKey: Income.dataKey) == nil {
        self.userDefaults.set(Dictionary<String, Data>(), forKey: Income.dataKey)
    }
    if self.userDefaults.dictionary(forKey: Expense.dataKey) == nil {
        self.userDefaults.set(Dictionary<String, Data>(), forKey: Expense.dataKey)
    }
}

固有ID(String)とデータ(Dictionary)を初期化しています。固有IDは UUID() でも良かったのですが、今回は8桁の数字文字列で、データを作成する度にインクリメントするという仕様にしてみました(生成順(ID順)で並び替えってこともできるので)。

getUniqueId(固有ID取得)メソッド

func getIncomeUniqueId() -> String {
    return getUniqueId(Income.self)
}

func getExpenseUniqueId() -> String {
    return getUniqueId(Expense.self)
}

private func getUniqueId<T: BaseModel>(_ type: T.Type) -> String {
    guard let id = self.userDefaults.string(forKey: T.uniqueIdKey) else {
        return ""
    }
    guard var integerId = Int(id) else {
        return ""
    }
    integerId += 1
    let uniqueId = String(format: "%08d", integerId)
    self.userDefaults.set(uniqueId, forKey: T.uniqueIdKey)
    return uniqueId
}

データ種別ごとの公開メソッドを定義し、内部的な処理は非公開メソッドに共通化しました。

コールする度にインクリメントされた固有のIDが返されます。

update(保存・更新)メソッド

func updateIncome(_ income: Income) {
    update(targetData: income)
}
    
func updateExpense(_ expense: Expense) {
    update(targetData: expense)
}
    
private func update<T: BaseModel>(targetData: T) {
    guard let data = try? JSONEncoder().encode(targetData) else {
        return
    }
    if var values = self.userDefaults.dictionary(forKey: T.dataKey) {
        values[targetData.id] = data
        self.userDefaults.set(values, forKey: T.dataKey)
    }
}

同様にデータ種別ごとの公開メソッドを定義し、内部的な処理は非公開メソッドに共通化しています。

受け取ったデータを JSONEncoder().encodeData 型に変換します。そして、Dictionary に T.dataKey をキーとしてアクセスし、id をキーとして代入しし、新規保存、または更新をしています。

delete(削除)メソッド

func deleteIncome(_ income: Income) {
    delete(targetData: income)
}
    
func deleteExpense(_ expense: Expense) {
    delete(targetData: expense)
}
    
private func delete<T: BaseModel>(targetData: T) {
    if var values = self.userDefaults.dictionary(forKey: T.dataKey) {
        values.removeValue(forKey: targetData.id)
        for (_, value) in values {
            guard var tempData = try? JSONDecoder().decode(T.self, from: value as! Data) else {
                continue
            }
            if tempData.order < targetData.order {
                continue
            }
            tempData.order -= 1
            if let data = try? JSONEncoder().encode(tempData) {
                values[tempData.id] = data
            }
        }
        self.userDefaults.set(values, forKey: T.dataKey)
    }
}

T.dataKeyDictionary を取得し、対象データの id をキーとして削除しています。

また、アプリの仕様で並び順を表す order を持っているため、削除後に全体の並び順を詰める処理をしています。

fetch(取得)メソッド

func fetchIncomes() -> [Income] {
    return fetch(Income.self)
}
    
func fetchExpenses() -> [Expense] {
    return fetch(Expense.self)
}
    
private func fetch<T: BaseModel>(_ type: T.Type) -> [T] {
    var datas = [T]()
    if let values = self.userDefaults.dictionary(forKey: T.dataKey) {
        for (_, value) in values {
            if let data = try? JSONDecoder().decode(T.self, from: value as! Data) {
                datas.append(data)
            }
        }
    }
    datas.sort { $0.order < $1.order }
    return datas
}

最後に fetch です。

T.dataKeyDictionary を取得後、1つ1つ取り出して、JSONDecoder().decode で Data 型 から オブジェクトの型 に変換し、配列に格納しています。

Dictionary は順不同で取得されるため、order で昇順にソートしてから返しています。

以上、開発中アプリのコードを例に取り、UserDefaults とオブジェクト型の扱いについて解説しました。

似たようなアプリを作っている方の参考になれば幸いです。