はじめに
今回初めてAppleWatch開発を行いました。(WatchOS + SwiftUI)
WatchOSに関する情報はスマホアプリ(iOS)より比較的少なく基本公式ドキュメントを参照する必要があり中々大変だったため、そこから得た知見でWatchアプリ開発を行う中で詰まったポイントを残しておきます。
前提
スマホアプリ
対象OS: iOS
フレームワーク: Flutter (3.13.0)
Xcode: 15.0.1
Watchアプリ
対象OS: WatchOS 9.0 ※Androidは無し
Xcode: 15.0.1
詰まったポイント
1. WatchOS9と10でヘッダー部のUIが異なる(navigationTitle)
2023年9月中旬頃にWatchOS 10の配信が開始されたのでWatchOS10の挙動を確認したところ、
WatchOS 9と10で同じ実装でもUIが異なるという事象を発見しました。
WatchOS 9
WatchOS 10
時計の下に表示されるようになり、若干右寄りに変更された。
※一部画像は加工してます。
原因と対策
navigationTitleを用いてヘッダーのUIを作成していましたが、WatchOS 10から微妙に表示位置が変わった模様。
公式のWatchOS10に関するドキュメントを参照してみるものの、特に言及されているそれらしき項目もありませんでした。関連情報でググってみても同じく殆どヒットせず。
対応方針を試行錯誤した結果、以下のような形でWatchOS9と10でUIを振り分けるようなカスタムなヘッダーUIを作成して対応しました。
ViewModifier
struct NavigationTitleForWatchOS10: ViewModifier { let showBackButton: Bool init(_ showBackButton: Bool) { self.showBackButton = showBackButton } func body(content: Content) -> some View { content .navigationBarBackButtonHidden(!showBackButton) } }
ViewのExtension
extension View { @ViewBuilder func navigationTitle(_ showBackButton: Bool = false) -> some View { if #available(watchOS 10, *) { self.modifier(NavigationTitleForWatchOS10(showBackButton)) } else { modifier(NavigationTitle(showBackButton)) } } }
変更後のWatchOS10のヘッダーUI
- .navigationTitleの利用を諦め、ヘッダーのタイトル表示も諦める。
- カスタマイズされた戻るボタンの実装を諦め、標準の戻るボタンだけを表示する。
2. NavigationLinkで複数画面を一度に戻そうとすると前の画面が一瞬残る
NavigationLinkだと前の画面が残ってしまう
当初画面遷移は基本的にNavigationLinkを用いて画面遷移をしており、
A -> B -> C -> D -> Eという画面スタック状態からE->Bに一度に遷移するような、一度に複数画面をPopできる方法を模索しておりました
調べた結果、B ~ E画面へ遷移する際に毎度フラグ値を引数リレーで渡しながら画面遷移はNavigationLinkで遷移し、最後のE画面で渡されたフラグをtrueに変更すると共にdissmissを各画面で実行して順繰りで画面を閉じていくことで、一度にE->Bに画面を戻すことはできました。
しかし、上記方法だとE->Bに戻る際にC、D画面も一瞬見えており、結果的に一枚ずつ画面がPopされるような挙動になるという事象に困っておりました。(理想としては一気にE->BにPopするアニメーションとしたい)
iOSであればUINavigationControllerを活用するこで回避できそうではありますが、こちらはWatchOSに対応しておらず、結果的にNavigationLink以外での方法を模索することにしました。
NavigationStackを活用
そこで、WatchOS9から使えるようになったNavigationStackのnavigationdestination活用し、本件の回避策対応を実施しました。
具体的には、navigationdestination
を活用できれば1枚ずつPopされるような挙動にはならずに一気に画面遷移ができるようだったので、以下のように一元的に画面遷移を管理するクラスを作成することで解決できました。このクラスを各種必要なクラスでEnvironmentObjectを用いてインスタンス化し、都度append、removeして画面を遷移をするようにしました。
画面一元管理クラス
import SwiftUI enum ScreenNames: Hashable { case AScreen case BScreen case CScreen } class DestinationManager: ObservableObject { @Published var path: [ScreenNames] = [] } struct DestinationManagerModifier: ViewModifier { @EnvironmentObject var destinationManager: DestinationManager func body(content: Content) -> some View { NavigationStack(path: $destinationManager.path) { content .navigationDestination(for: ScreenNames.self) { screenName in switch screenName { case .AScreen: AScreenView() case .BScreen: BScreenView() case .CScreen: CScreenView() } } } } }
View側
import SwiftUI struct AScreen: View { @EnvironmentObject private var destinationManager: DestinationManager @State private var isTransition: Bool var body: some View { ZStack { Text("test") }.navigationTitle() .onChange(of: isTransition) { isTransition in if isTransition == true { destinationManager.path.removeLast(3) // 複数画面一気に戻る } } } }
これを利用することによって戻る時に途中の画面が表示されずに一度に画面遷移することができました。
3. 振動(haptic feedback)とカスタム音声再生は同時実行できず、振動中は心拍数取得ができない
特定のタイミングでユーザに対して振動とカスタム音声を流して継続的に心拍数を取得したかったのですが、以下の制約がありました。
- 1. 振動時は自動で音声が流れる。
- 2. 振動継続中は心拍数が取得できない。
Do not call this method while gathering heart rate data using HealthKit. When you engage the haptic engine, HealthKit stops gathering heart rate data until after the haptic engine finishes.
また、振動パターンによってそれぞれ異なる音声が振動時に自動で流れます。
自動で流れる音声に関してはWatchOS制御でどうしようも出来なさそうだったので、振動時には自動で流れる音声の後にカスタム音声を流すような形で実装を行いました。
4. 振動を連続で行なってはいけない
特定の操作を行うまで振動(Haptic Feedback)し続けて、何回も繰り返し振動を鳴らす想定で実装を考えておりました。しかし、以下公式記載の通り連続で振動を行うのはタブーなようです。
Do not call this method multiple times in quick succession
素直に公式に従い、単発振動の実装に変更しました。
5. 心拍数をリアルタイムで取得するためにはアプリ側でワークアウト(WorkoutSession)開始が必要
AppleWatchであれば、取得しようと思えばいつでもリアルタイムの心拍数が取得できるものと考えておりました。
しかし、心拍数は概ね5分隔程で蓄積されており、リアルタイムで心拍数が必要なユースケースの場合は明示的にworkoutの開始が必要なようです。WorkoutSessionが開始されると、HealthKitに情報が保存され、その後心拍数はHealthKit経由で最新の値を取得することができます。(Flutterだとhealth_kit_reporterのquantityQuery
より取得可)
そのため、接続しているスマホアプリ側からワークアウトを開始することによって継続的に心拍数は取得できるようになりました。
For example, all workout sessions generate high-frequency heart rate samples;
補足
因みに心拍数は必ずしも実機(iPhone x AppleWatch)だけでしか取得できないわけではなく、Simulatorを使っていた場合でも一応ダミーの心拍データが定期的に送信はされるようです。
厳密に心拍数を取得したいユースケースはなく、とりあえず心拍データさえ取れれば良いという場合はSimulatorだけでも開発自体は可能です。
6. Watchのデバッグがやり辛い
私の案件ではスマホアプリをFlutterで開発しており、以下理由(※)でXcodeのコンソール上でWatch側のデバッグがやりにくいという状況が発生しておりました。
※Watchアプリをデバッグ実行する際にスマホアプリ側がデバッグビルドだと起動できないため(iOSのデバッグビルド制約によるもの)、スマホアプリ側のschemeでBUildConfigurationをRelease設定に変更する必要があった。
都度変更するのも面倒だったため、Watchのログを見るためには事前にWatchとペアリングされたiPhoneにSysdiagnose for watchOSのProfileをインストールし、Mac標準アプリのコンソールを開いてWatchログを確認しました。
ただ、このログが出る時は出てくれるのですが殆どの場合出力してくれないという問題を抱えておりました…。
(いわゆるおま環
な可能性もあります)
Watch側のログを見たくても見れない状況は中々に厳しく、何度もMac、スマホ、Watchの再起動及び、SysdiagnoseのProfile再インストールなどを繰り返すとたまにログ出力されるという状況でした。
複数人いる開発メンバー全員が同じ状況だったので、安定してWatchのログを確認できる解決策などあれば是非お伺いしたかったです…。
まとめ
探しても情報が出てこない、実機デバッグがうまく動作しない、iOSだとできるのにWatchOSに対応していないAPIがあったりと中々大変な点が多い印象のWatchOS開発でした。
大変ではあるもののSwiftUIで実装はできるので、SwiftUIに知見のある方であれば大体のUI等は問題無く組めると思います。是非チャレンジしてみてください。