Track Santa with Realm: Part 2

お待たせしました 👋

先週のチュートリアルに挑戦していただけた方は、アプリを起動すると以下のような画面が表示されていると思います。

The finished Santa tracking app

(私は先週のステップ7.4でテスト用のピンの位置をサンフランシスコにしましたが、もしみなさんが違う場所を指定していた場合ピンは別の位置に立っていることでしょう。私の現在位置もサンフランシスコなので地図もここのようになっていますが、皆さんの現在位置に応じて地図も別の場所を指していると思います。シミュレーターがどのように現在位置を把握しているのかまったくわかりませんが、とにかくそうなっていると思います。)

これだけでも立派なアプリですが、正直まだ大したことはしていません。実際、ピンの位置以外はハードコートされたものです。さっそく修正していきましょう。

このパートでは、すべてのOutletをRealmから取得した値で設定していきます。そのためにはいくつかのモデル定義の追加が必要になりますが、これは次回Realm Object Serverへ接続するためにも重要な作業です。

1. モデルの追加

現時点でハードコートされている値を表現するためのモデル定義を追加していきます。UIをみればわかるように、以下のものをモデルとして表現する必要があります。

  • サンタクロース到着までの時間
  • サンタクロースが今何をしているか
  • サンタクロースがいる場所の天気
  • 配り終えていないプレゼントの数

これらを順番に作っていきましょう。

サンタクロース到着までの時間

サンタクロースが到着する時刻がわかっていれば、サーバーに問い合わせるまでもなくデバイス上で計算することができます。到着時刻に関する情報は、来週サーバーに接続して取得します。これらの情報をSantaクラスに追加しておきます。

class Santa: Object {
    // currentLocation等の先週のコードはこの上
    
    let route = List<Stop>()
    
    // ignoredProperties はこの下
}

サンタクロースの経路というのは、いつおよびどこという2つの情報のリストとして表すことができます。これをStopというクラスで表現します。ドキュメントの「1対多のリレーションシップ」を見ていただければわかると思いますが、モデルのリストはListクラスで表現することができます(名前の通りですね)。モデルのリストを表現する際の書き方は、上記のコードの通りです。このままではStopクラスが定義されていないためコンパイルエラーになってしまします。Stopクラスを新たなファイルStop.swiftに定義しましょう。

class Stop: Object {
    dynamic var location: Location?
    dynamic var time: Date = Date(timeIntervalSinceReferenceDate: 0)
    
    convenience init(location: Location, time: Date) {
        self.init()
        self.location = location
        self.time = time
    }
}

繰り返しになりますが、Stopはサンタクロースがいつどこに到着するかを表しています。言うまでもなくサンタクロースのスケジュールはとてもきっちりしているので、遅れたり早く着くことはありません。彼は何百年も繰り返しプレゼントを配っているので、スケジュールの正確さに関しては信用できます。

先週のデータモデルの定義のステップで追加したLocationクラスを再利用しています。また、到着日時を表すためにDate型のプロパティも追加しています。このDate型からもわかるように、Realmもモデル定義にはSwift 3 のFoundation classも利用可能です。

記事の更新情報を受け取る

ここでもconvenience initializerを定義しています。このことについて先週言及するのを忘れていました。ドキュメントの「モデルクラスにカスタム定義のイニシャライザを追加する」にあるように、モデルクラスのカスタムイニシャライザはconvenienceキーワードが必須で、親クラスのイニシャライザをsuperで呼び出すのではなくself.init()を使う必要があります。これはSwiftのintrospectionの制限によるものです。

到着時刻の計算アルゴリズムについては以降の回で考えることにします。素晴らしい解決方法を用意しているので心配しないでください。実はもう書き終わっているのでお約束できます。

サンタクロースが今何をしているか

地図の下に今サンタクロースが何をしているか表示する欄があります。おそらくどこかを飛び回っているか、プレゼントを配っているか、ミセス・クロースとお話をしているかでしょう。みなさんがどう感じたかはわかりませんが、私にはこれはSwiftの列挙型(enum)で表すのにぴったりだと思いました。残念なことに、現時点のRealmは列挙型を直接扱うことができません。ただし、列挙型自体ではなくそのrawValueを扱うことはできます。

列挙型のActivityのために新たなファイルActivity.swiftを作成してください。

enum Activity: Int {
    case unknown = 0
    case flying
    case deliveringPresents
    case tendingToReindeer
    case eatingCookies
    case callingMrsClaus
}

この列挙型はIntで表すことができるように定義されています。そしてIntであればRealmで扱うことが可能です。Stringで表現することもできましたが、列挙型を使いたかったのでIntにしています。ここでは値をはっきりさせるために1つめの要素に対して0を明示しています。Swiftコンパイラは、値が未指定の要素には一つ前の値に1を加えた値を使うようになっています。このようにしておくことで、他のプラットフォームとモデル定義を共通化する際にわかりやすくなります。

それぞれの値に対応するテキストをextensionとしてActivityのすぐあとにに定義しておきます。View Controllerや専用のクラス等で変換を行うこともできますが、シンプルにするために同じファイルに定義します。

extension Activity: CustomStringConvertible {
    var description: String {
        switch self {
        case .unknown:
            // "❔ なにをしているか不明です..."
            return "❔ We're not sure what Santa's up to right now…"
        case .callingMrsClaus:
            // "📞 奥さんと電話中です!"
            return "📞 Santa is talking to Mrs. Claus on the phone!"
        case .deliveringPresents:
            // "🎁 プレゼントを配っています!"
            return "🎁 Santa is delivering presents right now!"
        case .eatingCookies:
            // "🍪 おやつをたべています。"
            return "🍪 Santa is having a snack of milk and cookies."
        case .flying:
            // "🚀 つぎの家まで移動中です。"
            return "🚀 Santa is flying to the next house."
        case .tendingToReindeer:
            // "𐂂 トナカイのお世話をしています。"
            return "𐂂 Santa is taking care of his reindeer."
        }
    }
}

実際のアプリではこれらの文字列はローカライズする必要があるかと思いますが、私は英語しか話せません。😓 みなさんはそれぞれの言語に翻訳していただいてもかまいませんし、内容自体を書き換えてしまってもかまいません。

Activityの定義が完成したので、これもサンタクロースに追加しましょう。

class Santa: Object {
    // 現在位置や経路の情報はこの上
    
    private dynamic var _activity: Int = 0
    var activity: Activity {
        get {
            return Activity(rawValue: _activity)!
        }
        set {
            _activity = newValue.rawValue
        }
    }
    
    // ignoredPropertiesはこの下
}

activityプロパティの型は列挙型のActivityとして扱えるようにするのが自然です。この情報をRealmで扱えるようにするため、内部ではIntとして扱います。プライベート変数としてInt型の_activityプロパティを定義し、外部に見せる際はActivityに変換します。これで、Realmのモデル定義が列挙型のプロパティを持っているように見せることができました!

最後に、前回のモデル定義の際と同様に、Realmに対してactivityプロパティを無視するように指定します。

class Santa: Object {
	// Properties are all up here
	
	// この関数は前回定義済みです。
    override static func ignoredProperties() -> [String] {
        // 前回定義した配列に "activity"を追加します。
        return ["currentLocation", "activity"]
    }
}

サンタクロースがいる場所の天気

この情報はRealm Object Serverの使い方を学習する際に扱います。現時点ではいったん気にしないでおきます。

配り終えていないプレゼントの数

このプロパティはそのままです。数なのでIntで表現します!

class Santa: Object {
    // いままでの諸々のプロパティはこの上
    
    dynamic var presentsRemaining: Int = 0
    
    // ignoredPropertiesはこの下
}

忘れずにdynamicを指定するということ以外はとくに気をつけることはありません。

ダミーデータの追加

ここまででいくつかプロパティを追加したので、ダミーデータも更新しましょう。

extension Santa {
    static func test() -> Santa {
        let santa = Santa()
        santa.currentLocation = Location(latitude: 37.7749, longitude: -122.4194)
        santa.activity = .deliveringPresents
        santa.presentsRemaining = 42
        return santa
    }
}

前回同様、ダミーデータは自由に変更してOKです。

アプリケーションを実行し、モデル定義が正しくコンパイルされることを確認してください。モデル定義をかなり変更したので、アプリケーションを実行するとRealmが「マイグレーションが必要」ということを意味する例外をスローします。ここでは、一旦アプリケーションを削除(実機の場合と同じくアイコンを長押しです)してから再度実行してください。UIに関するコードはいじっていないので、見た目は前回と変わっていないと思います。アプリが起動できたということは、Realmが新しいモデル定義を取り込んだということを意味します。

2. RealmのデータをUIに反映

前回、アプリケーションの起動時にサンタクロースの位置に応じてピンを立てるコードを書きました。今回は、より多くの情報をUIに反映させるとともに、データの変更に対してリアクティブにUIを更新するように拡張します。

  1. まずは、接続しただけになっていたアウトレットを使っていきます。SantaTrackerViewControllerの中にupdateメソッドを定義し、以下のようにSantaクラスの情報からUIを更新する処理を記述してください。

    class SantaTrackerViewController: UIViewController {
        // Properties
        // viewDidLoad
           
        private func update(with santa: Santa) {
            mapManager.update(with: santa)
            let activity = santa.activity.description
            let presentsRemaining = "\(santa.presentsRemaining)"
            DispatchQueue.main.async {
                self.activityLabel.text = activity
                self.presentsRemainingLabel.text = presentsRemaining
            }
        }
    }
    

    最初にサンタクロースの情報を前回作成したマップマネージャに渡して地図を更新させます。その後、activityとプレゼントの残数をラベルに反映します。前回と同様、update(with:)はメインスレッド以外から呼ばれることがあるため直接UIKitを操作するとクラッシュ💥します。解決策も前回と同様で、UIKit関連の処理は明示的にメインスレッドに処理をさせてください。

    到着時間については次回サンタクロースのルート情報をサーバーから受け取る際受け取る際に、天気についてはさらにその次のRealm Object Server���回に扱います。いまのところはUIの更新はこれでOKです。

  2. 次に、データの変更に対してリアクティブなコードに変えていきます。Realmはリアクティブパターンに従ってコードが書けるように設計されています。つまり、データの更新を起点にアプリが動作するということです。Realmを使ってリアクティブに記述する方法はいくつかあり詳細はドキュメントの「通知」の節で紹介されていますが、今回はKVOを使います。これは、KVOが唯一単一のオブジェクトに対して通知を受け取る方法であるという理由からです(2017年の初旬に、コレクション通知ベースの新しいAPIの導入も予定されています)。KVOのAPIは少し見た目がよくないので、今回は簡単なラッパーを用意しました。

    class Santa: Object {
        // All of the existing code
    
        // これを保持しておかないと、通知が受け取れません
        private var observerTokens = [NSObject: NotificationToken]()
    
        // サンタクロースのプロパティ、およびプロパティのプロパティに対する変更の監視をセットアップします
        func addObserver(_ observer: NSObject) {
            // 必要なすべてのプロパティの変更通知をセットアップします
            // どれか一つでinitialを受け取る必要があります。どれでもかまいません
            addObserver(observer, forKeyPath: #keyPath(Santa._currentLocation), options: .initial, context: nil)
            // 位置情報の変更の通知をセットアップします
            addObserver(observer, forKeyPath: #keyPath(Santa._currentLocation.latitude), options: [], context: nil)
            addObserver(observer, forKeyPath: #keyPath(Santa._currentLocation.longitude), options: [], context: nil)
               
            addObserver(observer, forKeyPath: #keyPath(Santa._activity), options: [], context: nil)
            addObserver(observer, forKeyPath: #keyPath(Santa.presentsRemaining), options: [], context: nil)
               
            observerTokens[observer] = route.addNotificationBlock {
                // self owns this route, so it will always outlive this closure
                [unowned self, weak observer] changes in
                switch changes {
                case .initial:
                    // 処理を簡単にするため、"route"に対するKVOの呼び出しを偽装します
                    observer?.observeValue(forKeyPath: "route", of: self, change: nil, context: nil)
                case .update:
                    observer?.observeValue(forKeyPath: "route", of: self, change: nil, context: nil)
                case .error:
                    fatalError("Couldn't update Santa's info")
                }
            }
        }
    
        func removeObserver(_ observer: NSObject) {
            observerTokens[observer]?.stop()
            observerTokens.removeValue(forKey: observer)
            removeObserver(observer, forKeyPath: #keyPath(Santa._currentLocation))
            removeObserver(observer, forKeyPath: #keyPath(Santa._currentLocation.latitude))
            removeObserver(observer, forKeyPath: #keyPath(Santa._currentLocation.longitude))
            removeObserver(observer, forKeyPath: #keyPath(Santa._activity))
            removeObserver(observer, forKeyPath: #keyPath(Santa.presentsRemaining))
        }
    }
    

    このラッパーのおかげで通知を簡単に使うことができます。はじめに、通知不要なプロパティを除いたすべてのプロパティにオブザーバーを登録します。その際、#keyPathキーワードを使うことでコンパイラーがtypoがないことをチェックしてくれます。最後に、routeに対するRealmのコレクション通知をKVOの通知に偽装します。こうすることで、KVOの場合と同じコードで処理することができるようになります。

  3. UIをリアクティブにするために必要なのは、このラッパーを使うことだけです。SantaTrackerViewControllerの中のviewDidLoad()の最後の部分を書き換えましょう。

    override func viewDidLoad() {
        // 最後以外は前回のコードと同じです
        super.viewDidLoad()
    
        // MapManagerを準備
        mapManager = MapManager(mapView: mapView)
    
        // Realmからサンタクロースの情報を取り出す
        let realm = try! Realm()
        let santas = realm.objects(Santa.self)
    
        // 情報が存在しない場合はダミー情報を書き込む
        if santas.isEmpty {
            try? realm.write {
                realm.add(Santa.test())
            }
        }
           
        // 非オプショナルな型に変換
        if let santa = santas.first {
            // 元々はMapManagerを呼び出すコードがありましたが、不要になりました!
            santa.addObserver(self)
        }
    }
    

    変えたのは、MapManagerのupdate(with:)を呼ぶ代わりに自身をsantaのオブザーバーとして登録する部分です。update(with:)は変更が通知された際に呼び出されます。

    通知が不要になったらオブザーバーの登録を解除することを忘れないでください。今回はシンプルなアプリケーションなので解除を忘れても問題にはなりませんが、実際のアプリでは忘れずに行うべきです。以下はそのサンプルです。

    deinit {
        let realm = try! Realm()
        let santas = realm.objects(Santa.self)
        if let santa = santas.first {
            santa.removeObserver(self)
        }
    }
    

    独自の変更監視のセットアップと後始末を行いました。でもちょっとまってください。変更通知のハンドラーは?もちろんこれも書かないといけません。viewDidLoad()の下に、以下のKVOリスナーメソッドを新たに追加してください。

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if let santa = object as? Santa {
            update(with: santa)
        } else {
            super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
        }
    }
    

    今回はKVOをとてもシンプルな使い方をしているので、引き数についてはほとんど気にする必要はありません。変更通知がSantaに対するものだということの確認だけはおこなってください。Santaに対するものであればupdate(with:)を呼び出し、そうでなければAppleのドキュメントに従い親クラスへ移譲しています。

    ここまでできたらアプリケーションを実行してみてください。ダミーデータがUIに反映されていると思います。もしうまく行っていたらおめでとうございます!新しモデル定義と、データの変更に対するリアクティブなUIを手に入れました。これで次回のRealm Object Server対応に向けて準備は完了です。Realm Object Serverの嬉しいところは、アプリケーションを対応させるための修正がほとんど不要であるというところです。UIをリアクティブにしてあるので、サーバーからの変更も今までのローカルでの変更とおなじように通知されます。そのため、サーバーからのデータを特別扱いする必要はありません。

    いずれにしろ、次回をご覧いただけばはっきりします。それまではクリスマスショッピング🎁をお楽しみください!

About the content

This content has been published here with the express permission of the author.


Michael Helmbrecht

Michael designs and builds things: apps, websites, jigsaw puzzles. He’s strongest where disciplines meet, and is excited to bring odd ideas to the table. But mostly he’s happy to exchange knowledge and ideas with people. Find him at your local meetup or ice cream shop, and trade puns.

4 design patterns for a RESTless mobile integration »

close