開発中のアプリのデータ管理に 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 等で一覧表示する時の都合で Hashable・Identifiable にも準拠しています。
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 は支出という意味です(リリースしたらいずれまた詳しく解説する記事も書きたいと思います)。
先程のプロトコルに準拠しなくては行けませんので、同名のメンバを定義します。ただし、Expense は category というメンバを追加しています。
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().encode で Data 型に変換します。そして、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.dataKey で Dictionary を取得し、対象データの 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.dataKey で Dictionary を取得後、1つ1つ取り出して、JSONDecoder().decode で Data 型 から オブジェクトの型 に変換し、配列に格納しています。
Dictionary は順不同で取得されるため、order で昇順にソートしてから返しています。
以上、開発中アプリのコードを例に取り、UserDefaults とオブジェクト型の扱いについて解説しました。
似たようなアプリを作っている方の参考になれば幸いです。
コメントを残す