はじめに

今回初めて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から使えるようになったNavigationStacknavigationdestination活用し、本件の回避策対応を実施しました。

具体的には、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. 振動継続中は心拍数が取得できない。

Apple公式引用

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

自動で流れる音声に関しては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_reporterquantityQueryより取得可)

そのため、接続しているスマホアプリ側からワークアウトを開始することによって継続的に心拍数は取得できるようになりました。

Apple Watch で心拍数が測定されるタイミング

Running workout sessions

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等は問題無く組めると思います。是非チャレンジしてみてください。