【part 10】AppApp のリファクタリング過程紹介【Realm編②】

どうも。Reoです。パート10です。

今回は前回の続きで、Realm周りを修正していきます。今回もクソコードを晒していくぜ。

 

環境

  • Xcode11.3.1
  • Swift 5.1.3
  • iOS13.3
  1.  AppApp のリファクタリングを始めます!【SwiftLint 導入編】
  2. 【part 2】AppApp のリファクタリング過程紹介【SwiftLint導入後のError解消編】
  3. 【part 3】AppApp のリファクタリング過程紹介【SwiftLint導入後のWarning解消編】
  4. 【part 4】AppApp のリファクタリング過程紹介【コーディング規約編】
  5. 【part 5】AppApp のリファクタリング過程紹介【ディレクトリ構成編】
  6. 【part 6】AppApp のリファクタリング過程紹介【R.swift導入編】
  7. 【part 7】AppApp のリファクタリング過程紹介【画像・色の管理編】
  8. 【part 8】AppApp のリファクタリング過程紹介【Carthage/LicensePlist編】
  9. 【part 9】AppApp のリファクタリング過程紹介【Realm編①】

 

リポジトリ→ uruly/AppApp

 

今回の目標

前回は、Realmのオブジェクトのオプショナルの見直しをするところまでやりました。

変更したところ、マイグレーションが必要になったため、今回はそのマイグレーションからやっていきます。

今回の目標は、Realm編の終了です。依存している画面がぶっ壊れてしまっているので、ちゃんと機能するようにするのは難しいです。develop ブランチは動くようにしておきたいので、ちょっと develop – build/v1.1.0 でも生やしてそこにマージしようと思います。

 

オブジェクトの変更

まずはオブジェクトを欲しい形にします。

  • AppRealmData -> App
  • ApplicationData -> 削除したい。
  • AppLabelRealmData -> Label

この ApplicationData がいらないんですよね。

現在の ApplicationData の役割は、アプリとラベルの紐付け、ラベル内でのAppの位置の保存を行なっていて、多分ないほうがいい気がしてます。

 

これまでのオブジェクト


final class AppRealmData: Object {

    @objc dynamic var id: String = ""
    @objc dynamic var name: String = ""
    @objc dynamic var developer: String = ""
    @objc dynamic var urlString: String = ""
    @objc dynamic var image: Data?
    @objc dynamic var date: Date = Date()

    override static func primaryKey() -> String? {
        return "id"
    }
}

final class AppLabelRealmData: Object {

    @objc dynamic var id: String = ""
    @objc dynamic var name: String = ""
    @objc dynamic var color: Data?
    @objc dynamic var order: Int = 0
    @objc dynamic var explain: String = ""

    override static func primaryKey() -> String? {
        return "id"
    }
}

final class ApplicationData: Object {

    @objc dynamic var id: String = ""
    @objc dynamic var app: App?
    @objc dynamic var label: Label?
    @objc dynamic var rate: Double = 0
    @objc dynamic var order: Int = 0
    @objc dynamic var memo: String = ""

    override static func primaryKey() -> String? {
        return "id"
    }
}

 

変更後のオブジェクト

こうしたい!


final class App: Object {

    @objc dynamic var uid: String = ""
    @objc dynamic var appStoreID: String = ""
    @objc dynamic var name: String = ""
    @objc dynamic var developer: String = ""
    @objc dynamic var urlString: String = ""
    @objc dynamic var image: Data?
    @objc dynamic var rate: Double = 0
    @objc dynamic var order: Int = 0
    @objc dynamic var memo: String = ""
    @objc dynamic var date: Date = Date()

    override static func primaryKey() -> String? {
        return "uid"
    }
}

final class Label: Object {

    @objc dynamic var id: String = ""
    @objc dynamic var name: String = ""
    @objc dynamic var color: Data?
    @objc dynamic var order: Int = 0
    @objc dynamic var explain: String = ""

    let apps: List<App> = .init()

    override static func primaryKey() -> String? {
        return "id"
    }
}

この2つのオブジェクトだけでいいはずなんや。

複数のラベルに同じAppが入る場合があり、それぞれにメモを分けられる設定にしてあるので、Appのuidも変更しました。(これまでのidにはAppStoreのidが入っていたので重複することがあった)

 

マイグレーション(失敗編)

マイグレーションを書きます。

前回作ったDatabaseManager内に書いていきます。

怖いので段階を踏んでやっていきます。

import Foundation
import RealmSwift

final class DatabaseManager {

    static let shared = DatabaseManager()

    private let schemaVersion: UInt64 = 7

    private var realm: Realm {
        do {
            return try Realm(configuration: configuration)
        } catch {
            fatalError(error.localizedDescription)
        }
    }

    private lazy var configuration: Realm.Configuration = {
        var configuration = Realm.Configuration(schemaVersion: schemaVersion, migrationBlock: migrationBlock)
        let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: .groupID)
        configuration.fileURL = url?.appendingPathComponent("db.realm")

        return configuration
    }()

    private lazy var migrationBlock: RealmSwift.MigrationBlock? = {
        return .init { [weak self] (migration, oldSchemaVersion) in
            self?.migration(migration, from: oldSchemaVersion)
        }
    }()
}

// MARK: - Migration

extension DatabaseManager {

    private func migration(_ migration: Migration, from oldSchemaVersion: UInt64) {
        if oldSchemaVersion < 4 {
            migrationTo4(migration)
        }
        if oldSchemaVersion < 7 {
            migrationTo7(migration)
        }
    }

    private func migrationTo4(_ migration: Migration) {
        migration.enumerateObjects(ofType: "AppRealmData") { _, new in
            new!["urlString"] = ""
        }
        migration.enumerateObjects(ofType: "AppLabelRealmData") { _, new in
            new!["explain"] = ""
        }
    }

    private func migrationTo7(_ migration: Migration) {
        migration.enumerateObjects(ofType: "AppLabelRealmData") { (old, new) in
            let label = migration.create(Label.className())
            label["id"] = old?["id"]
            label["name"] = old?["name"]
            label["color"] = old?["color"]
            label["order"] = old?["order"]
            label["explain"] = old?["explain"]
        }
    }
}

こんな感じで migration(_:from:) 内に追記して各バージョンごとに処理を書くようにしました。

 

Label のマイグレーション

ラベルは名前の変更と、各オブジェクトのオプショナルを変更しました。

private func migrationTo7(_ migration: Migration) {
    migration.enumerateObjects(ofType: "AppLabelRealmData") { (old, _) in
        let label = migration.create(Label.className())
        label["id"] = old?["id"]
        label["name"] = old?["name"]
        label["color"] = old?["color"]
        label["order"] = old?["order"]
        label["explain"] = old?["explain"]
    }
}

これで、AppLabelRealmData -> Label への移行ができました。

移行ができました?まって、データがない。

ああーん (´;ω;) まって... 失敗だー (´;ω;)

 

失敗談。。。。

マイグレーション処理を書かずに、スキーマバージョンを上げたことによって、primaryKey 以外のもので、自動でマイグレーションされたものが空になってしまいました。

@objc dynamic var name: String? を name: String = “” に変更したものと

@objc dynamic var name: String! を name: String = “” に変更したものがぶっ飛んでしまいました。

これは、オプショナルを変更後にスキーマバージョン変えたらいけるやろと思ってあげたために消えた模様です。

 

消えたものと消えてないものがあるので、ちょっとこのまま突き進みます。

反省点として、開発版とリリース版でターゲットを変えましょう…ということですね。次ぐらいに増やします。もう遅いけれど…。

はい!とりあえず、移行処理は書けたはず!

悲しみながらアプリ削除して develop ブランチに戻してマイグレーションがちゃんとうまくいくかやってみます。マイグレーション処理をするために毎度スキーマバージョンを上げないといけないのも結構きついので、開発環境と分けないとダメですね。

マイグレーション(成功!)

やっとできた…。枠組みは失敗したものと同じです。

// MARK: - Migration

extension DatabaseManager {

    private func migration(_ migration: Migration, from oldSchemaVersion: UInt64) {
        if oldSchemaVersion < 6 {
            migrationTo6(migration)
        }
    }

    private func migrationTo6(_ migration: Migration) {
        migration.enumerateObjects(ofType: "AppLabelRealmData") { (old, _) in
            let label = migration.create(Label.className())
            label["id"] = old?["id"]
            label["name"] = old?["name"] ?? ""
            label["color"] = old?["color"]
            label["order"] = old?["order"]
            label["explain"] = old?["explain"] ?? ""
        }

        migration.enumerateObjects(ofType: "ApplicationData") { (old, _) in
            guard let oldApp = old?["app"] as? MigrationObject else {
                fatalError("Label not found")
            }
            let app = migration.create(App.className())
            app["uid"] = UUID().uuidString
            app["appStoreID"] = oldApp["id"]
            app["name"] = oldApp["name"]
            app["developer"] = oldApp["developer"]
            app["urlString"] = oldApp["urlString"]
            app["image"] = oldApp["image"]
            app["date"] = oldApp["date"]

            app["rate"] = old?["rate"]
            app["order"] = old?["order"]
            app["memo"] = old?["memo"]

            // ラベルに App を紐づける
            migration.enumerateObjects(ofType: Label.className()) { (_, newLabel) in
                guard let labelID = newLabel?["id"] as? String else { return }
                guard let oldLabel = old?["label"] as? MigrationObject, let oldLabelID = oldLabel["id"] as? String else { return }
                guard labelID == oldLabelID else { return }
                guard let apps = newLabel?["apps"] as? List else { return }
                newLabel?["apps"] = apps + [app] as Any
            }
        }
        migration.deleteData(forType: "ApplicationData")
        migration.deleteData(forType: "AppRealmData")
        migration.deleteData(forType: "AppLabelRealmData")
    }

}

めっちゃきつかった。なかなか汚いコードな気がしてならんけど、できたぞ…。

それぞれサラッと解説していきます。

 

クラス名の変更

クラス名の変更は以下のように行なっています。

// 古いクラスは削除したので、文字列で指定する。(AppLabelRealmData.className()とはできない)
migration.enumerateObjects(ofType: "AppLabelRealmData") { (old, _) in
    // 新しいオブジェクトを作る
    let label = migration.create(Label.className())
    // 値を移し替える
    label["id"] = old?["id"]
    label["name"] = old?["name"] ?? ""
    label["color"] = old?["color"]
    label["order"] = old?["order"]
    label["explain"] = old?["explain"] ?? ""
}

// 最後に古いクラスのオブジェクトを削除する
migration.deleteData(forType: "AppLabelRealmData")

特に付け加える値等がなければそのまま移し替えるだけで大丈夫です。

 

オプショナル

@objc dynamic var name: String? を name: String = “” に変更したものは、oldの値がnilの場合があります。

nilだった場合にはクラッシュするので空文字を入れるようにしてあげました。

// オプショナルの変更を行ったもののマイグレーション
label["name"] = old?["name"] ?? ""
// 変更してないもの
label["color"] = old?["color"]

 

ラベルオブジェクトに新しくAppを紐付け

これまではApplicationDataにAppLabelRealmData(Label)とAppRealmData(App)を紐づけていました。

それを、ApplicationDataを削除して、Labelに直接Appを持たせるようにしました。Label内でのAppへのメモや並び順はApp自身が持つようにしました。

ApplicationDataの数だけAppを作成したいので、ApplicationDataを取得して、そこから新しいAppを作成します。

migration.enumerateObjects(ofType: "ApplicationData") { (old, _) in
    // ApplicationData から App を取得する
    guard let oldApp = old?["app"] as? MigrationObject else {
        fatalError("App not found")
    }
    // App を新しく作成する
    let app = migration.create(App.className())
    app["uid"] = UUID().uuidString
    app["appStoreID"] = oldApp["id"]
    app["name"] = oldApp["name"]
    app["developer"] = oldApp["developer"]
    app["urlString"] = oldApp["urlString"]
    app["image"] = oldApp["image"]
    app["date"] = oldApp["date"]

    // 元々ApplicationDataが持っていたデータを移し替える
    app["rate"] = old?["rate"]
    app["order"] = old?["order"]
    app["memo"] = old?["memo"]

    // ラベルに App を紐づける
    migration.enumerateObjects(ofType: Label.className()) { (_, newLabel) in
        guard let labelID = newLabel?["id"] as? String else { return }
        guard let oldLabel = old?["label"] as? MigrationObject, let oldLabelID = oldLabel["id"] as? String else { return }
        guard labelID == oldLabelID else { return }
        guard let apps = newLabel?["apps"] as? List else { return }
        newLabel?["apps"] = apps + [app] as Any
    }
}

// 最後に ApplicationData と AppRealmData を削除する
migration.deleteData(forType: "ApplicationData")
migration.deleteData(forType: "AppRealmData")

 

AppはApplicationDataに紐づけられていないものはないはずなので、クラス名のリネーム等もこの中で行ってしまいます。

ラベルにAppを紐づけるところがなかなかうまくいかなくてハマりました。

ごちゃごちゃしちゃいましたが、古いApplicationDataが保持しているラベルIDと一致したラベルの apps に 新しく作った App を追加しています。

 

削除の順番

ApplicationData 等の古いデータを削除をするときの順番にもハマりました。

例えば、今回はLabelのマイグレーションを先に行なったので、マイグレーション後すぐに削除処理を行うと、クラッシュしました。

Attempting to delete a model’s data in a migration when another linking model has also been removed crashes #3686

こちらのIssueを参照して解決しました。削除する順番の問題だったようです。

ApplicationDataでAppLabelRealmDataを保持しているので、先にApplicationDataを削除してからでないと消せないっぽいです。

なので、以下の順番で削除をしています。

migration.deleteData(forType: "ApplicationData")
migration.deleteData(forType: "AppRealmData")
migration.deleteData(forType: "AppLabelRealmData")

 

取得・保存・削除

ようやくマイグレーションを終えたので、実際に保存処理等を各オブジェクトに書きます。

直すところが多すぎるので、雰囲気だけ紹介します。

extension Label {

    static func getAll() -> [Label] {
        let sortProperties = [SortDescriptor(keyPath: "order", ascending: true)]
        return .init(DatabaseManager.shared.objects(Label.self, filter: nil, sortedBy: sortProperties))
    }

    static func add(_ label: Label) throws {
        try DatabaseManager.shared.add(label)
    }

    static func delete(_ label: Label) throws {
        try DatabaseManager.shared.delete(label)
    }
}

こんな感じにしています。

保存や削除時にエラーだった場合にアラートを出すなど、失敗したことを伝えたいので、DatabaseManager側でcatchするようになっていたものを、使う側へ投げるように変更しました。

extension DatabaseManager {
    // 変更前
    func add(_ object: T) {
        do {
            realm.beginWrite()
            realm.add(object, update: .all)
            try realm.commitWrite()
        } catch let error {
            print(error.localizedDescription)
        }
    }

  // 変更後
    func add(_ object: T) throws {
        realm.beginWrite()
        realm.add(object, update: .all)
        try realm.commitWrite()
    }
}

実際に画面で利用する場合には

do {
    try Label.add(label)
} catch {
    // アラートの表示
}

といった感じで使う予定です。

 

ラベルの色の取得

これまではラベルの色を取得するときに、毎回使うところで Data -> UIColor の処理を行なっていましたが、変数を用意してそこで変換するようにします。

extension Label {

    var uiColor: UIColor? {
        guard let color = color else { return nil }
        return try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: color)
    }
}

これで使うときは label.uiColor で使えるようになりました。

 

次回

今回の反省を踏まえて、開発用のターゲットを追加します。

自分で使いたいから作ったアプリなので、開発中にデータが消えるのは普通に辛いです。

データを消さないようにしながら開発していても、ちょっとの失敗で消えることはあります。大事なのは本番環境のデータを消さないことであって、開発中は試行錯誤するためにも何度も消してやり直せるようにしておきたいです。

 

おわりに

消えたデータもあるけれど、AppStore の ID は残っているので、コレクションしてたアプリは復元できます。よかった。

とにかく、マイグレーションをするのはとても大変ですわ。

開発中はアプリを消したり、 Realm.Configuration.deleteRealmIfMigrationNeeded = true にしたりすることで、マイグレーションをあまり意識せずに作業することが多いと思います。

こうやって作業ができるうちに、ちゃんとDB設計をしておくことが大切ですね…。あと開発版とリリース版を分けるのも大事…。

 

とりあえずRealm編は今回で終わりです。実際に利用する際に処理を書き加えていきます。

多分今回のリファクタリングで一番めんどくさいところだったはず。一つ山を越えた感がありますね。でもほとんど全部のUIがぶっ壊れているので、先は長いです。

 

なんかめっちゃ長くなっちゃった。

それではでは。また次回。

 

AppApp ,

Comments...

コメントは認証制です。詳しくは下記の注意をお読みください。お気軽にコメントお願いします!

Write a Comment

コメント時の注意

「Twitter」「Facebook」「Google+」「WordPress」のいずれかのアカウントをお持ちの方は各アカウントと連携することでコメントできます。 コメントしたことはSNSに流れませんので、アカウントをお持ちの方はこちらの方法でコメントを投稿して下さると嬉しいです。 アカウントをお持ちでない方はメールアドレスで投稿することができます。 初回コメント時は承認後に表示されます。

Related Memo...

UITableView.RowAnimation の .none はアニメーションするよ

UITableView.RowAnimation の .none はアニメーションがnoneなわけじゃなく、デフォルトの設定を使うよという意味らしい。

The inserted or deleted rows use the default animations.

なのでアニメーションしちゃう。今更の気づき。

 

iOS

記事を書くほどでもないけれどメモっておきたいこと

テスト投稿。

例えばiphone7 の画面サイズ

750 × 1334
半分375 × 667

iOS

UINavigationController + UIScrollView の組み合わせで使っている時に謎の余白ができる時

UINavigationController + UIScrollView の組み合わせで使っていて、UIScrollView 上に AutoLayout で上下左右0で View を設置しているのに、30px程度上にずれてしまうとき。

`navigationController.navigationBar.isTranslucent = false` にすると直るかもしれない。

ScrollView上のコンテンツとNavigationBarの重なっているところが透過していたら多分これで直せるはず。

通常のターゲットではちゃんと動いているのに、iOSSnapshotTestCase を用いたテストでだけこの対応が必要なのよくわからないけれど。。。

iOS
more