はじめに
Watchアプリで iOSアプリが持つデータを取得して表示するアプリを実装しました。
プロジェクトのターゲットバージョンは、以下の通りです。
- 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()
バックグラウンドでデータが転送され、パフォーマンスや電力の状況によってすぐに転送されない場合があります。
- https://developer.apple.com/documentation/watchconnectivity/wcsession/1615667-transferfile
- https://developer.apple.com/documentation/watchconnectivity/wcsession/1615671-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
非常に厳しいサイズ制限なので、コンテンツ系の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/