LoginSignup
9
10

More than 3 years have passed since last update.

WatchアプリでiOSアプリが持つデータを取得して表示する

Last updated at Posted at 2019-12-31

はじめに

Watchアプリで iOSアプリが持つデータを取得して表示するアプリを実装しました。

syncronize-watch-items.gif

プロジェクトのターゲットバージョンは、以下の通りです。

  • watchOS 6
  • iOS 13

Watchアプリ・iOSアプリで SwiftUI を採用しています。

また、iOSアプリで持つデータは、Realm を使って管理しています。

Watchアプリとの通信について

watchOS 6 から 専用の App Store が追加されました。

初代Watch Kitでは、iPhone上で動作させること前提の仕組みとなっており、共通リソースで共通のデータソースへのアクセスが容易だったそうです。

しかし、それ以降の Watch Kit では、独立して動作する仕組みとなり Watch Connectivity でうまくやり取りする必要があります。

そうしたアップデートを経て、ついに専用の App Store ができ、完全に独立したアプリも開発できるようになりました。

通信の方法

Watchアプリ と iOSアプリ でデータのやり取りをするには、WatchConnectivityフレームワークWCSession を使うことになります。

そのなかで大きく分けて2種類の方法があります。

バックグラウンドで ファイル または Dictionary を転送する

transferFile() または transferUserInfo()

バックグラウンドでデータが転送され、パフォーマンスや電力の状況によってすぐに転送されない場合があります。

メッセージを送信する

sendMessage() または sendMessageData()

実行中のWatchアプリでメソッドを呼び出すと、対応するiOSアプリがバックグラウンドで起動され、到達可能になります。

実装のイメージ

今回は、sendMessageData() を使用して iOSアプリ と API通信をするイメージ で実装しました。

iOS側の実装

今回は、起動時に WCSession をアクティベーションするので、SceneDelegate にそのメソッドを追加します。

    // SceneDelegate.swift

    let watchManager = WatchManager.shared
    watchManager.initWcSession()

シングルトンな感じで実装しています。

    // WatchManager.swift

import WatchConnectivity

class WatchManager: NSObject {

    let wcSession = WCSession.default
    let watchHandler = WatchHandler()

    private static let watchManager = WatchManager()
    public class var shared: WatchManager {
        return watchManager
    }

    func initWcSession() {
        if wcSession.activationState == .activated {
            return
        }

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

extension WatchManager: WCSessionDelegate {
        func session(_ session: WCSession, didReceiveMessageData messageData: Data, replyHandler: @escaping (Data) -> Void) {
        let previewMessage = String(bytes: messageData, encoding: .utf8)!
        print("[INFO] The session has get message. \(previewMessage)")

        watchHandler.handle(messageData: messageData, replyHandler: replyHandler)
    }
    // 必要なメソッドを定義する (省略)
}

ここからはハンドラの実装です。ルーティングのようなイメージで作りました。

final class WatchHandler {

    let itemDataService: ItemDataServiceProtocol

    init(itemDataService: ItemDataServiceProtocol) {
        self.itemDataService = itemDataService
    }

    convenience init() {
        let itemDataService = ItemDataService()
        self.init(itemDataService: itemDataService)
    }

    func handle(messageData: Data, replyHandler: @escaping (Data) -> Void){

        let decodedData = try? JSONSerialization.jsonObject(with: messageData, options: []) as? [String: Any]

        guard let data = decodedData, let actionName = data["action"] as? String else {
            return replyHandler(errorJson("Missing Parameter 'action' field."))
        }

        // アクションの定義
        switch actionName {
        case "getItems": return getItems(replyHandler: replyHandler)
        case "getItemImage": return getItemImage(decodedData: data, replyHandler: replyHandler)
        default: return replyHandler(errorJson("Missing Action identifier."))
        }
    }

      private func getItems(replyHandler: @escaping (Data) -> Void) {
                // サービスからデータを取得する
                itemDataService.findAll { results, error in
        guard error == nil, let items = results?.elements else {
              replyHandler(self.errorJson("Failed to find all items. \(error)"))
            return
        }

        let items = Array(items.elements)
        let watchListItems = items.map { (item: MItem) -> [String: Any] in
            return [
                "id": item.id,
                "title": item.title,
                "category": item.categoryRaw,
            ]
        }

        guard let itemsJson = try? JSONSerialization.data(withJSONObject: ["items": watchListItems]) else {
            replyHandler(self.errorJson("Failed to encode json."))
            return
        }

        replyHandler(itemsJson)
    }
}

Realmのデータを取得するサービスで修正した点

今までは、インスタンス変数で realm を定義して使いまわしていましたが、各メソッド内で try? Realm() するように変更しました。

WatchHandlerでデータを取得する際に、アプリ画面の ViewModel で使用するものと異なるスレッドで実行されてエラーになりました。

Realmでは、そういったアクセスに柔軟に対応できるようになっているので、都度 Realmインスタンス を生成することで解決しました。

画像の送信について

WCSessionでサイズの大きいデータを送ることができません。

WCErrorCodeMessageReplyFailed -> WCErrorCodePayloadTooLarge

Appleのドキュメントでは明示されていませんが、ソースコード内の定義を辿ると次のように定義されているようです。

  • 65,536 bytes (65.5 KB) for a message
  • 65,536 bytes (65.5 KB) for a user info
  • 262,144 bytes (262.1 KB) for an application context

https://stackoverflow.com/questions/33025915/how-big-can-the-payload-be-when-sending-data-via-watchconnectivity

非常に厳しいサイズ制限なので、コンテンツ系のWetchアプリを作ることは難しそうです。

今回、Base64を分割して送る方法を試しました。先ほどの WatchHandler 内の実装ですが、抜粋しています。

func getItemImage(decodedData: [String: Any], replyHandler: @escaping (Data) -> Void) {
    guard let itemId = decodedData["id"] as? String,
          let page = decodedData["page"] as? Int else {

                replyHandler(self.errorJson("Missing parameter."))
        return
    }

        // (省略)

        let partSize: Int = 50000

        guard let encodedImage = uiImage.pngData()?.base64EncodedString() else {
                // Error
                return
        }

        let startOffset: Int = (page - 1) * partSize > 0 ? (page - 1) * partSize : 0
        let endOffset: Int = base64.endIndex.encodedOffset > (page * partSize - 1) ? (page * partSize - 1) : base64.endIndex.encodedOffset
        let start = base64.index(encodedImage.startIndex, offsetBy: startOffset)
        let end = base64.index(encodedImage.startIndex, offsetBy: endOffset)

        let partData = encodedImage[start..<end]

        guard let response = try? JSONSerialization.data(withJSONObject: [
                "itemId": itemId,
            "page": page,
            "partSize": partSize,
            "dataSize": encodedImage.count,
            "partData": partData
        ]) else {
            replyHandler(self.errorJson("Failed to encode json."))
            return
        }

        replyHandler(response)
}

シミュレータの動作ですが、レスポンス時間が早くて3秒程度かかるので、500KBぐらいあるような画像は現実的な転送はできそうにありませんでした。

モバイル端末にとって、通信が大きなバッテリー消費要因なのでその対策がしっかりとされているといった印象です。

Watch側の実装

こちらでは、リストを実装する画面でアクティベートも行っています。通信もここで全て行うようにしました。詳細画面で使う画像も、ここで取得したものを渡すだけなイメージです。

class HomeViewModel: NSObject, ObservableObject {

        @Published var image: UIImage? = nil

        // これをonAppearで実行します
        func activateWcSession() {
        if wcSession.activationState == .activated {
            return
        }

        if WCSession.isSupported() {
            wcSession.delegate = self
            wcSession.activate()
        }
    }

        func getItemImage(id: String, page: Int = 1, tempData: String = "") {
        if wcSession.isReachable {
            print("[INFO] wcSession is Reachable.")
            let dataDict:[String: Any] = ["action": "getItemImage", "id": id, "page": page]

            guard let data = try? JSONSerialization.data(withJSONObject: dataDict, options: .prettyPrinted) else {
                print("[INFO] Failed to encode data on getItemImage")
                return
            }

            let previewRequest = String(bytes:data, encoding: .utf8)!
            print("[WC Request] \(previewRequest)")

            wcSession.sendMessageData(data, replyHandler: { replyData in

                let previewResponse = String(bytes:replyData, encoding: .utf8)!
                print("[WC Response] getItemImage \(previewResponse)")

                guard let replyDict = try? JSONSerialization.jsonObject(with: replyData) as? [String: Any],
                      let replyPage = replyDict["page"] as? Int,
                      let dataSize = replyDict["dataSize"] as? Int,
                      let partSize = replyDict["partSize"] as? Int,
                      let partData = replyDict["partData"] as? String else {

                    return
                }

                                // 取得済みのデータと結合します
                let data: String = tempData + partData

                                // データサイズに達していなければページアップしてリクエストします
                if data.count < dataSize {
                    self.getItemImage(id: id, page: replyPage + 1, tempData: data)
                } else {
                    DispatchQueue.main.async {
                        guard let data = Data(base64Encoded: data) else {
                            print("missing data")
                            return
                        }
                        let uiImage = UIImage(data: data)
                        self.image = image
                   }
                }
            })
        } else {
            print("[INFO] wcSession is not Reachable.")
        }
    }
} 

メモ: 発生したエラー

WCSession counterpart app not installed といったエラーだった。

シミュレータのiPhoneの「My Watch」アプリで「Show Apple App on Apple Watch」を一旦オフにして再度Xcodeから実行すると動作しました。

https://forums.developer.apple.com/thread/14889

参考

http://www.project-unknown.jp/entry/apple-watch1#これまでのWatch-OS-初代Watch-Kitとの違い

https://developer.apple.com/documentation/watchkit/keeping_your_watchos_content_up_to_date

https://developer.apple.com/documentation/watchconnectivity/wcsession

https://dev.classmethod.jp/smartphone/iphone/watchos3-bg-refresh-3/

9
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
10