【iOS × watchOS】iPhoneアプリと連携できるApple Watchアプリを開発しよう!
若い人からご年配の方まで装着率が増加傾向にあるスマートウォッチ。使用率を見るとApple Watchユーザーが圧倒的に数を占めています。
そんなApple Watchで利用しているアプリもiOSアプリ同様に開発することが可能になっており、Apple Watch単体で動作するアプリやiPhoneと連携してデータをやり取りすることができるアプリも実現できます。
今回はApple Watchアプリを開発してみたい方をターゲットに「iPhoneと連携した簡単なToDoアプリ」を開発する流れとApple Watchアプリ開発のポイントなどをまとめていきたいと思います。
◇ こんな人に読んでほしい!
- iOSアプリは開発したことがある
- watchOSアプリは開発したことがない
- iOSとwatchOSアプリがどのようにデータをやり取りするか知りたい
- watchOSアプリでのポイントや注意点を知りたい
iOSと連携したApple Watchアプリを開発する流れ
「iPhoneと連携した簡単なToDoアプリ」を開発する流れの中でiOSとwatchOSが連携する部分の肝となる実装を見ていきます。
◇開発する流れ(iOS ↔︎ watchOS間の実装)
- iOSとwatchOSをターゲットに含んだプロジェクトを作成
- iOS側にwatchOS側と通信するためのクラスを作成
- watchOS側にiOS側と通信するためのクラスを作成
- データを送受信する部分の実装
◇アプリ概要 & 機能一覧
- ローカル保存はCore Dataを使用する
- iOS側からToDoタスクの追加と削除、タスク完了フラグの操作が行える
- watchOS側からタスク完了フラグを操作可能
- watchOS側にも常に最新データをローカル保存
今回は記事の都合上「iOS↔︎watchOS間のデータ通信部分の実装方法」にフォーカスを当てて紹介していきます。Core Dataを使用したローカル保存やUIの実装などは割愛させていただきましたが、GitHubにプロジェクト全体のソースコードをあげてありますのでプルしてお試しいただければと思います。
1. iOSとwatchOSをターゲットに含んだプロジェクトを作成
iOSと連携したwatchOSアプリを開発するためのプロジェクトを作成します。Xcodeを起動させて「watchOS」タブの中の「App」をクリックします。
今回はiOSと連携したアプリを開発したいので「Watch App with New Companion iOS App」にチェックを入れて次へ進みます。この際にApple Watch単体で動作するアプリを開発したい場合は「Watch-only App」にチェックを入れて進みます。違いは自動でiOSターゲットが生成されるかどうかです。また後からiOSターゲットやwatchOSターゲットを追加することも可能になっています。
プロジェクトの作成が完了すると以下のように「iOSターゲット:WatchToDoApp」と「watchOSターゲット:WatchToDoApp Apple Watch」が作られていることを確認できます。
2. iOS側にwatchOS側と通信するためのクラスを作成
iOS側からwatchOS側と通信を行うためのクラスを実装します。肝になるのはWCSessionクラスです。WCSession.activate()を実行することでwatchOSアプリと連携するためのセッションを有効にすることができます。
watchOSとの通信状態はWCSessionDelegate経由で検知することが可能です。Apple Watchに連携アプリがインストールされているかどうかや通信可能状態(スリープ状態ではない)かどうかなど通信を行うために必要な状態の変化を検知することが可能です。
このクラスに後でwatchOSにデータを送信する処理と受信する処理を追加していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
import UIKit import Combine import WatchConnectivity class WatchCommunicationManager: NSObject { static let shared = WatchCommunicationManager() // watchOSと通信可能かどうか(今回は使用していないが、外部に公開しておくと便利 public var isReachable: AnyPublisher<Bool, Never> { _isReachable.eraseToAnyPublisher() } private var _isReachable = CurrentValueSubject<Bool, Never>(false) private var session: WCSession // インスタンス化時にセッションをアクティベート init(session: WCSession = .default) { self.session = session super.init() if WCSession.isSupported() { // デリゲートをセット self.session.delegate = self // セッションをアクティベート self.session.activate() } } } extension WatchCommunicationManager: WCSessionDelegate { /// セッションのアクティベート状態が変化した際に呼ばれる func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { if let error = error { print("Watchエラー:\(error.localizedDescription)") } else { print("Watchセッション:アクティベート\(session.activationState)") print("Watch通信状態変化:\(session.isReachable)") _isReachable.send(session.isReachable) } } /// Watchアプリ通信可能状態が変化した際に呼ばれる func sessionReachabilityDidChange(_ session: WCSession) { print("Watch通信状態変化:\(session.isReachable)") _isReachable.send(session.isReachable) } /// セッションが非アクティブになった際に呼ばれる func sessionDidBecomeInactive(_ session: WCSession) { } /// セッションが無効になった際に呼ばれる func sessionDidDeactivate(_ session: WCSession) { } } |
3. watchOS側にiOS側と通信するためのクラスを作成
watchOS側にもiOSと通信を行うためのクラスを実装します。実装はiOS側に実装したものとほとんど同じ様な形で実装することができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import Combine import WatchConnectivity class iOSCommunicationManager: NSObject { static let shared = iOSCommunicationManager() // watchOSと通信可能かどうか(今回は使用していないが、外部に公開しておくと便利 public var isReachable: AnyPublisher<Bool, Never> { _isReachable.eraseToAnyPublisher() } private var _isReachable = CurrentValueSubject<Bool, Never>(false) private var session: WCSession init(session: WCSession = .default) { self.session = session super.init() if WCSession.isSupported() { self.session.delegate = self self.session.activate() } } } extension iOSCommunicationManager: WCSessionDelegate { /// セッションのアクティベート状態が変化した際に呼ばれる func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { if let error = error { print(error.localizedDescription) } else { print("セッション:アクティベート\(session.activationState)") _isReachable.send(session.isReachable) } } /// iOSアプリ通信可能状態が変化した際に呼ばれる func sessionReachabilityDidChange(_ session: WCSession) { print("通信状態が変化:\(session.isReachable)") _isReachable.send(session.isReachable) } } |
4. データを送受信する部分の実装
ここまででiOSとwatchOS間の通信を行うための土台を実装しました。ここからは実際にiOS↔︎watchOS間でデータを送受信する仕組みを実装していきます。
◇送受信するためのメソッド
1 |
open func sendMessage(_ message: [String : Any], replyHandler: (([String : Any]) -> Void)?, errorHandler: ((Error) -> Void)? = nil) |
データの送信を行うのはsendMessageメソッドです。(他にも色々あり用途が異なります。)これはiOS/watchOS関係なく同じメソッドを使用することができます。データは辞書[String:Any]形式でのみ送信できます。また送信が成功したかどうかはreplyHandlerで受け取ることが可能で、不要であればreplyHandlerには空のクロージャーを渡します。
◇データ送信側
1 2 3 4 5 6 |
let userDic: [String: String] = ["name": "ソニック太郎"] self.session.sendMessage(userDic, replyHandler: { message in print("送受信成功", message) } ) { error in print(error) } |
sendMessageメソッドで送信されたデータはWCSessionDelegateの以下のデリゲートメソッドで受信することができます。replyHandler(message)を呼び出せば送信側で送信結果を参照することができます。
◇データ受信側
1 2 3 4 5 |
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { replyHandler(message) // これを呼び出さないと送信側はACKを受け取れない guard let result = message["name"] as? String else { return } print(result) } |
iOS → watchOSへデータを送信する
ここでは「iOS側で保存されたローカルデータ(ToDoタスク)をwatchOS側へ送信してwatchOS側にも保存する処理」を実装します。
実際に先ほどのクラスに実装してみます。WatchCommunicationManagerには以下のようにデータを送信するメソッドを追加します。送信する独自クラスをJSONに変換してから辞書形式に詰めていきます。
1 2 3 4 5 6 |
/// Work送信 public func sendWorks(works: [Work]) { guard let json = jsonConverter.convertJson(works) else { return } let requestDic: [String: String] = [CommunicationKey.I_SEND_WORKS.rawValue: json] self.session.sendMessage(requestDic, replyHandler: { _ in }) } |
iOSCommunicationManagerにはデータを受信するデリゲートメソッドを用意し、受け取った辞書からキーを頼りにJSONを抜き出し、独自クラスへ変換していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
/// sendMessageメソッドで送信されたデータを受け取るデリゲートメソッド func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { sendWorks(message) } private func sendWorks(_ dic: [String : Any]) { print("データ受信:\(dic)") guard let key = CommunicationKey.checkForKeyValue(dic), key == CommunicationKey.I_SEND_WORKS else { return } // iOSからデータを取得 guard let json = dic[key.rawValue] as? String else { return } // JSONデータをString型→Data型に変換 guard let jsonData = String(json).data(using: .utf8) else { return } DispatchQueue.main.async { // 保存前にwatchOS側のローカルデータを全て削除 CoreDataRepository.deleteAllData() let decoder = JSONDecoder() decoder.userInfo[CodingUserInfoKey(rawValue: "managedObjectContext")!] = CoreDataRepository.context // JSONデータを構造体に準拠した形式に変換 if let works = try? decoder.decode([Work].self, from: jsonData) { // iOSより送信された最新のデータをローカルへ保存 CoreDataRepository.saveContext() self._works.send(works) } } } |
watchOS → iOSへデータを送信する
次は「watchOS側からToDoタスクの完了フラグを操作したイベントを送信し、iOS側のローカルデータを更新する処理」を実装します。
iOSCommunicationManagerからは更新対象のデータのIDを送信します。
1 2 3 4 5 |
/// フラグ更新 public func requestToggleFlag(work: Work) { let requestDic: [String: String] = [CommunicationKey.W_REQUEST_UPDATE_FLAG.rawValue: work.id?.uuidString ?? ""] self.session.sendMessage(requestDic, replyHandler: { _ in }) } |
WatchCommunicationManagerではwatchOSから送信されたIDを元に対象データの完了フラグを切り替えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
/// `sendMessage`メソッドで送信されたデータを受け取るデリゲートメソッド func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { replyHandler(message) // 受信したことを送信側に知らせる receiveMessage(message) } private func receiveMessage(_ dic: [String : Any]) { print("データ受信:\(dic)") guard let key = CommunicationKey.checkForKeyValue(dic), key == CommunicationKey.W_REQUEST_UPDATE_FLAG else { return } // watchOSから送信された更新対象のIDを取得 guard let id = dic[key.rawValue] as? String else { return } DispatchQueue.main.async { // 対象データの完了フラグを更新 let predicate = NSPredicate(format: "id == %@", id as CVarArg) let work: Work = CoreDataRepository.fetchSingle(predicate: predicate) work.flag = !work.flag CoreDataRepository.saveContext() self._isUptate.send(true) } } |
これでwatchOSとiOS間のデータの送受信部分の実装は完了になります。
Core DataのエンティティをJSONで変換して送受信するためには少しNSManagedObjectContextの取り扱いに注意が必要です。GithHubに全コードを公開しているので参考にしてみてください。
watchOSアプリ開発で気をつけること
- Swift UIでの開発
- iOSとwatchOSは通信可能状態が細かく変換する
- 送受信できるデータは辞書形式[String: Any]でプリミティブ型のみ
- データを送信するメソッドは一部シミュレーターでは動作しない
Swift UIでの開発
watchOS側の開発は以前はStoryboardを使用した開発でしたが、watchOS 7以降よりStoryboardを使用したUI設計は非推奨となりSwift UIでの実装が推奨される様になりました。
まだまだStoryboardでの案件も多い中でSwift UIに切り替える良いタイミングにもなるかと思います。
もちろんiOS側をStoryboardで、watchOS側もSwfit UIで開発することも可能になっています。
iOSとwatchOSは通信可能状態が細かく変換する
Apple Watchは秒数経過や腕を下ろすなど特定の操作でスリープモード(画面が暗くなる)に変化します。スリープモードに入るとiOSアプリとのデータの送受信も行えなくなるので先ほどのsendMessageでは送信したはずのデータがwatchOS側で取得できないといったことが発生します。
それを防ぐためには細かく通信可能状態をチェックしたり、スリープモード中でもデータを送信できるtransferUserInfoメソッドなどを使用したりして両OSのライフサイクルを考慮した設計をする必要があります。
送受信できるデータは辞書形式[String: Any]でプリミティブ型のみ
両OS間で送信できるデータは辞書形式[String: Any]のみとなっています。Anyなので独自のクラスなども渡せてしまいますが、それは期待通りに動作しません。
sendMessageなどのAny側に含まることができるのはプリミティブ型(StringやIntなど)限定と定義のコメントに記載されています。
公式:Property List Types and Objects
1 2 |
/** クライアントはこのメソッドを使用して、対応するアプリにメッセージを送信できます。特定のメッセージに対する応答を受け取りたいクライアントは、replyHandler ブロックを渡す必要があります。メッセージを送信できない場合、または応答を受信できない場合は、errorHandler ブロックがエラーで呼び出されます。 ReplyHandler と errorHandler の両方が指定されている場合は、そのうちの 1 つだけが呼び出されます。メッセージは送信アプリの実行中にのみ送信できます。メッセージが送信される前に送信側アプリが終了すると、送信は失敗します。対応するアプリが実行されていない場合、メッセージの受信時に対応するアプリが起動されます (iOS 対応アプリのみ)。メッセージ ディクショナリはプロパティ リスト タイプのみを受け入れることができます。 */ open func sendMessage(_ message: [String : Any], replyHandler: (([String : Any]) -> Void)?, errorHandler: ((Error) -> Void)? = nil) |
そのため独自のクラスなどを渡したい場合はJSONに変換して送信し、受信側でデコードすることで期待通りの実装を叶えることが可能です。
データを送信するメソッドは一部シミュレーターでは動作しない
WatchConnectivityフレームワークに定義されている両OS間でデータを送受信するメソッドは以下の4種類です。
- sendMessage・・・データを送信 / 受信されない場合は無効
- transferUserInfo・・・データを送信 / 受信されない場合はキューにスタック
- updateApplicationContext・・・データを送信 / 受信されない場合は更新して保管
- transferFile・・・ファイルを送信 / 受信されない場合はキューにスタック
受け取り側の状況によりキューに貯めることができるメソッドもあり、データの送信漏れが起きない様な実装をすることが可能になっています。
注意点としてはtransferUserInfoとtransferFileはシミュレーターでは動作しないので動作確認には実機を用いる必要があります。
終わりに
watchOSアプリの開発はこれまでのiOSアプリ開発とあまり変化がなく実装できることがわかりました。
データの通信絡みで多少癖はあるものの、用意されているAPIも直感的でわかりやすいものが多い印象です。既存のiOSプロジェクトにあとからwatchOSプロジェクトを追加することも可能になっているので既存のiOSアプリに追加して開発して見るのも楽しそうですね。
この記事が少しでもwatchOSアプリ開発に興味を持つきっかけになると幸いです。
ご覧いただきありがとうございました。
ソニックムーブは一緒に働くメンバーを募集しています
Wantedlyには具体的な業務内容のほかメンバーインタビューも掲載しております。ぜひご覧ください。