Apple Watch - Watch OS4 でCoreData等を用いた開発 - Swift4

Apple Watch (Watch OS4)

この記事では、Apple Watch (以降Watch OS)の開発にあたっての私なりの開発メモを記します。
また、何の断りがなければ、Watch OSはWatch OS4の事を指しています。

環境としては、

  • XCode 9.4.x
  • Watch OS 4 (Watch Kit2)

での開発です。
特にCoreData等のリソース周りの資料があまり見つからなかったので、そこを重点的に記載しています。

また、今回の記事はTIME HACKERでApple Watch対応を行っている時に、主に私が悩んだ所ですので、CoreDataとのデータのやり取りを主にフォーカスしています。

これまでのWatch OS (初代Watch Kit)との違い

Watch OS開発は、これまで(初代Watch Kit)とは、特にリソースの取扱方法が異なってます。

その違いは、以下のAppleが展開している図を見ると明らかです。

f:id:project-unknown:20180902001023p:plain

これまでは、Apple WatchはiPhone上で動作させること前提の仕組みとなっていた為、CoreDataやUserDefaultsをApp Groupsの設定さえ行っていれば、共有リソースとしてアクセスすることが出来ました。

しかし、Watch Kit 2からは、Apple Watch上で独立して動作する事になり、
Apple Watchから共有リソースへのアクセスができなくなりました。

新しいWatch OSでのCoreData等のリソースアクセスの考え方 Watch Connectivity

UserDefaults等で取り扱う、一時的な設定等は、Apple Watchでそのまま使えば良いでしょう。
しかし、iPhone上のアプリとデータを共有する場合は、Apple Watch上のアプリからiPhoneアプリへデータの送受信を行うと言う考え方で取り扱う事になります。
このときに、Watch Connectivityを用いて送受信を行います。

図で言う、枠で囲った所がそれに当たります。

f:id:project-unknown:20180902001752p:plain

しかし、これには注意点があります。

これまでは単純に同一デバイス内のリソースファイルへアクセスしていたので、それなりに高速に取り扱うことが出来ましたが。
Apple WatchとiPhoneとは物理的にも分断されている通り、Sessionでのデータのやり取りになります。
当然、画像ファイル等のやり取りを行うと、その分データの送受信に時間がかかるので注意が必要です

画像ファイルをWebから取得して利用するのではなく、予めアプリに組み込んだものを利用するのであれば、iPhoneとApple Watch両方に同一画像を埋め込んでおくのが妥当だと思います。

では、実際にiPhoneとWatch OSとでリソースのやり取りを行うサンプルを造っていきます。

データ送受信部分

まずは、データの送受信を行う所を造ります。
イメージは、繰り返しになりますが、以下の図の用に考えます。

f:id:project-unknown:20180902003023p:plain

iPhone, Watch OS共にデータの送受信を行う為のSessionをこさえます。

iPhone App側の実装

以下のようにWatchManagerクラスを用意します。

WatchManagerは以下の役割を担います。

  • Watch OSとのSession
  • Watch OSから来たEventの種別を判定 (後述)

また、WatchManagerはSessionを取り扱う為、念の為シングルトン設計にしています。

import WatchConnectivity

class WatchManager: NSObject, WCSessionDelegate {

    let wcSession = WCSession.default
    
    private static let instance = WatchManager()
    
    public class var shared: WatchManager {
        
        return instance
    }
    

    func initWCSession() {
        if WCSession.isSupported() {
            wcSession.delegate = self
            wcSession.activate()
            print("[INFO] session activate")
        } else {
            print("[INFO] session error")
        }
    }

}

extension WatchManager {
    
    /// メッセージを受信した時の処理
    ///
    /// - Parameters:
    ///   - session:
    ///   - message:
    ///   - replyHandler:
    func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
        
        var result: [String: Any] = ["success" : false]
        replyHandler(result)
        
    }
    
}

/// Session Delegate
extension WatchManager {
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        print("[INFO] The session has completed activation.")
    }
    
    func sessionDidBecomeInactive(_ session: WCSession) {
        print("[INFO] The session has got into inactivation.")
    }
    
    func sessionDidDeactivate(_ session: WCSession) {
        print("[INFO] The session has deactivated")
    }
    
    func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
        print(userInfo)
    }
    
    func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
        print("[INFO] try to send message to watch")
        sendMessage()
    }
}

やっていることは、WCSessionオブジェクトを生成し、delegateを自分自身にしているだけです。
これを例えば、AppDelegate等で

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        WatchManager.shared.initWCSession()
    }
}

と、呼んであげるとWatch OS用のSessionを貼ることが出来ます。
後は、Watch OSからEventが飛んでくると、適宜Watch Manager内のDelegateが呼び出されます。

上記サンプルで

    func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
    }

このコードだけ、中身に軽い処理を入れているのは、今回のサンプルでは、主にこのDelegateを用いる為です。念の為。

Watch OS側の実装

iPhone App側の実装は出来たので、Watch OS側の実装を行います。
今回のサンプルでは、Watch OSが起動した際に、iPhone AppへSessionを開始するコードとなっています(この際はloadDataメソッドをコールします)。

import WatchKit
import Foundation
import WatchConnectivity

class InterfaceController: WKInterfaceController, WCSessionDelegate {
    
    let wcSession = WCSession.default
    
    override func awake(withContext context: Any?) {
        super.awake(withContext: context)
        
        // WCSessionの開始
        if WCSession.isSupported() {
            wcSession.delegate = self
            wcSession.activate()
        }
        
    }
    
    override func didAppear() {
        super.didAppear()
        
        print("[Info] didAppear")
        if wcSession.isReachable {
            print("[Info] reachable")
            loadData()
        } else {
            print("[Info] not reachable")
        }
    }
    
    override func willActivate() {
        super.willActivate()

    }
    
    override func didDeactivate() {
        super.didDeactivate()
    }

    func loadData() {
        let message = [ "message" :  "test"]
        wcSession.sendMessage(message, replyHandler: { replyDict in
            print("[INFO] responce action data")
            print(replyDict)

        }, errorHandler: { error in
            print(error)
        })

    }
}

// MARK: - Session Delegate
extension InterfaceController {
    
    func sessionReachabilityDidChange(_ session: WCSession) {
        print("[Info] sessionReachabilityDidChange")
        if wcSession.isReachable {
            print("[Info] reachable")
            loadData()
        } else {
            print("[Info] not reachable")
        }
    }
    
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        print("[Info] The session has completed activation.")
    }
    
    func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
        
        // iPhoneからのデータを受け取る
        print("[Info] didReceiveMessage")
    }
}

上記コードの、

        wcSession.sendMessage(message, replyHandler: { replyDict in
            print("[INFO] responce action data")
            print(replyDict)

        }, errorHandler: { error in
            print(error)
        })

この部分で、iPhone App側に通信を送っています。
ここで、例えばWatchManagerのメソッドを以下のように変更します。

    func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
        print(message)
        var result: [String: Any] = ["success" : false]
        replyHandler(result)
        
    }

これで実行すると、 以下の情報が出力されます。

[ "message" :  "test"]

ここまでで、iPhone AppとWatch OS Appとの接続の部分が出来ました

Watch OSとiPhoneとでCoreData等のデータのやり取りを行います。

ここに来て、やっと表題のお話が出来ます…。

これまでは、Watch OS AppとiPhone Appとの通信部分を造ってきたので、
この流れの上で、リソースデータのやり取りを行います。

といっても、設計はごく単純で、
すべてのデータのやり取りはiPhone側で行い、Watch OS側は適宜データの送受信のEventを送るだけです。

最初の方に載せた図で言うと、以下のようにApple Watchから来たEventのレシーバからCoreDataへアクセスし、データが必要であれば、返却する仕組みです。

f:id:project-unknown:20180902005106p:plain

前提として

今回のデータの送受信を行うにあたり、以下のデータ構造をDictionary形式でやり取りします。

Apple Watchからデータを取得するリクエストを投げる時

[
   "kind" : 1,
]

kind : 1はデータ取得を行いたい識別子です。

Apple Watchからデータを更新した際にリクエストを投げる時

[
    "kind" : 2,
    "data" : Any
]

この場合は、kind : 2として、一緒に更新したいデータを送信します。

では、コードの方を見てみます。

iPhone App側の実装

これまで書いてきた、WatchManagerのメソッドを以下のように変更します。

    func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
        
        var result: [String: Any] = ["success" : false]
        
        guard let kind = message["kind"] as? Int else {
            result["success"] = false
            
            replyHandler(result)
            return
        }
        
        switch kind {
        case 1:
            result["data"] = /* CoreDataからとってきたデータ */
            result["success"] = true

            return
        case 2:
            /* CoreDataのデータ更新 */
            result["success"] = true
            
            return

        default:
            replyHandler(result)
            return
        }
    }

kindで、処理の識別を行い、適宜CoreDataの操作を行います。
最後に結果をreplyHandlerに渡して上げるだけです。

Watch OS側の実装

iPhone Appでデータを受信して、適切なレスポンスを返せるようになったので、
今度はWatch OS側からEventを送信する方法です。

まずは、データを受け取る所ですが、
loadDataメソッドを以下のように改変します。

    func loadData() {
        let message = [ "kind" : 1]
        wcSession.sendMessage(message, replyHandler: { replyDict in
            print("[INFO] responce action data")
            print(replyDict)
            /* replyDictにCoreDataから取得した情報が入っているので、以後適切な処理を行う */
        }, errorHandler: { error in
            print(error)
        })

    }

データ受信の際は、kind : 1でSession通信を行えば、iPhone側でCoreDataにアクセスし、データを返却してくれます。

次に、データ更新は、上記loadDataと殆ど同じですが、念の為載せておきます。
データ更新ように、saveDataメソッドを追加しています。

    func saveData() {
        var message: [String: Any] = [:]
        message["kind"] = 2
        message["data"] = data
        
        wcSession.sendMessage(message, replyHandler: { replyDict in
            print(replyDict)
        }, errorHandler: { error in
            print(error)
        })
    }

これで、適切なタイミングでsaveDataを呼び出せば、保存したいデータをCoreDataに保存することが出来ます。

詰まった所

終わりに、私が盛大に詰まった所として…。
当たり前のように、iPhone AppのWatch ManagerでreplyHandlerを使っていますが、
Apple WatchでSession処理を行う際に、適切なDelegateをiPhone App側で実装していないと通信エラーとなります。

この詳細は、以下にまとめておきましたので、もし詰まってしまった場合の参考にしてください。

www.project-unknown.jp

最後に

Watch OSは他のWidget等と違って、これまでのWidget等と違って、考え方を大きくシフトしないとなかなか造りにくいです。
しかし、WebAPIを用いた実装を行っているんだと、考えると一気に考え方が楽になります。(Appleはここまで考えているんでしょうね・・・さすがや)

最後に宣伝となりますが、
この記事は、現在TIME HACKERでApple Watch対応を行っており、その際の備忘録として記したものです。

もう少しでリリースが見えている所ですので、是非とも皆さんお使いになってくださいね!

img