Closed38

Flutter アプリを Apple Watch から操作する

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

このスクラップでは Flutter で作成したカメラアプリを Apple Watch から操作できるようにするまでの過程を記録する。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Flutter 側を準備する

動作確認のための簡単なアプリを作ってみよう。

VS Code のコマンドパレットから Flutter: New Project を実行する。

テンプレートとしては Empty Application を選ぶ。

プロジェクト名は hello_apple_watch とした。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

XCode での設定

コマンド
open ios/Runner.xcworkspace


よくわからないが Bundler Identifier をユニークっぽいものに変えてみた

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

基本のカウンターアプリ

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends HookWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context) {
    final counter = useState(0);

    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Count: ${counter.value}'),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            counter.value += 1;
          },
          child: const Icon(Icons.plus_one),
        ),
      ),
    );
  }
}

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Apple Watch の初期化

しばらく使っていない Apple Watch なのでパスワードすら忘れてしまった。

まずは初期化する方法を探そう。

https://support.apple.com/ja-jp/108348

これがサイドボタンだと思ってずっと Digital Crown を押してたが何もならなくてどうしようとなったが気が付けて良かった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

iPhone のアップデートが必要

かったるいが iOS 16.7.10 をダウンロードしてインストールしよう。

これで Flutter アプリが起動しなくなるようなことがありませんように。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

iPhone のアップデートが終わった

1〜2 時間くらいかかった、今日はこれで終わりかな。

iPhone をアップデートしても Apple Watch の初期設定を進められなかった時は心が折れそうになったが、Apple Watch の i ボタンを押して言語設定を日本語に設定してから色々といじっていたらうまくいった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Apple Watch からのメッセージ受信

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_watch_os_connectivity/flutter_watch_os_connectivity.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends HookWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context) {
    final counter = useState(0);
    final flutterWatchOsConnectivity =
        useMemoized(() => FlutterWatchOsConnectivity());

    useEffect(() {
      flutterWatchOsConnectivity.configureAndActivateSession();
      final subscription =
          flutterWatchOsConnectivity.messageReceived.listen((message) {
        print(message.data);
      });

      return () => subscription.cancel();
    }, []);

    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Count: ${counter.value}'),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            counter.value += 1;
          },
          child: const Icon(Icons.plus_one),
        ),
      ),
    );
  }
}

こんなので良いのだろうか?

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

デベロッパーモード

To use 'iPhone' for development, enable Developer Mode in Settings → Privacy & Security.

iOS をアップデートしたからだろうか?

設定アプリのプライバシーとセキュリティからデベロッパーモードを ON にしたら解決したようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

起動はするが真っ白

一旦 useEffect をコメントアウトして起動してからコメントアウトを外したら起動するようになった。

なんかこういうの嫌だなー

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

警告メッセージを控えておく

Connecting to VM Service at ws://127.0.0.1:59256/LO74mpspiN8=/ws
[ERROR:flutter/shell/common/shell.cc(1055)] The 'sstonn/flutter_watch_os_connectivity_callback' channel sent a message from native to Flutter on a non-platform thread. Platform channel messages must be sent on the platform thread. Failure to do so may result in data loss or crashes, and must be fixed in the plugin or application code creating that channel.
See https://docs.flutter.dev/platform-integration/platform-channels#channels-and-platform-threading for more information.
[WC] Application context data is nil
2
[WC] WCSession counterpart app not installed
[WC] Application context data is nil

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ウォッチアプリを作る

コマンド
open ios/Runner.xcworkspace


File > New > Target から watchOS App を追加する


Flutter 側が helloAppleWatch だったのでこちらは helloFlutterApp にしよう


helloFlutterApp Watch App が追加された、AppTests や AppUITests はテスト用のようだ

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ボタンの追加


右上の「+」アイコンをクリックするとコントロールの一覧が表示されるのでドラッグ&ドロップ

ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Button("Count up") {}
        }
        .padding()
    }
}

#Preview {
    ContentView()
}


Count up ボタンが表示された

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ChatGPT に聞いてみた

プロンプト
Apple Watch から Flutter アプリにメッセージを送信するプログラムの書き方を教えてください!下記まで書きました!

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Button("Count up") {}
        }
        .padding()
    }
}

#Preview {
    ContentView()
}
ContentView.swift
import SwiftUI
import WatchConnectivity

class ConnectivityProvider: NSObject, WCSessionDelegate {
    static let shared = ConnectivityProvider()

    private override init() {
        super.init()
        if WCSession.isSupported() {
            WCSession.default.delegate = self
            WCSession.default.activate()
        }
    }

    func sendMessageToPhone(_ message: [String: Any]) {
        if WCSession.default.isReachable {
            WCSession.default.sendMessage(message, replyHandler: nil) { error in
                print("Error sending message: \(error.localizedDescription)")
            }
        }
    }

    // 必要に応じて他のデリゲートメソッドを実装できます。
    func session(_: WCSession, activationDidCompleteWith _: WCSessionActivationState, error _: Error?) {}
    func session(_: WCSession, didReceiveMessage message: [String : Any]) {
        print("Message received: \(message)")
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Button("Count up") {
                ConnectivityProvider.shared.sendMessageToPhone(["action": "countUp"])
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}
ios/Runner/AppDelegate.swift
import UIKit
import Flutter
import WatchConnectivity

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate, WCSessionDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        if WCSession.isSupported() {
            let session = WCSession.default
            session.delegate = self
            session.activate()
        }

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    func session(_: WCSession, activationDidCompleteWith _: WCSessionActivationState, error _: Error?) {}

    func session(_: WCSession, didReceiveMessage message: [String: Any]) {
        if let action = message["action"] as? String {
            DispatchQueue.main.async {
                // Flutter との通信
                let flutterViewController = self.window?.rootViewController as! FlutterViewController
                let channel = FlutterMethodChannel(name: "com.example.watch", binaryMessenger: flutterViewController.binaryMessenger)
                channel.invokeMethod("watchMessage", arguments: action)
            }
        }
    }
}
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const platform = MethodChannel('com.example.watch');
  String message = "No message received";

  
  void initState() {
    super.initState();
    platform.setMethodCallHandler((call) async {
      if (call.method == "watchMessage") {
        setState(() {
          message = "Received action: ${call.arguments}";
        });
      }
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Flutter Watch App')),
      body: Center(child: Text(message)),
    );
  }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Apple Watch 側のコード

ContentView.swift
import SwiftUI
import WatchConnectivity

struct ContentView: View {
    var body: some View {
        VStack {
            Button("Count up") {
                ConnectivityProvider.shared.sendMessageToPhone(["action": "countUp"])
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

class ConnectivityProvider: NSObject, WCSessionDelegate {
    static let shared = ConnectivityProvider()

    private override init() {
        super.init()
        if WCSession.isSupported() {
            WCSession.default.delegate = self
            WCSession.default.activate()
        }
    }
    
    func sendMessageToPhone(_ message: [String: Any]) {
        if WCSession.default.isReachable {
            WCSession.default.sendMessage(message, replyHandler: nil) { error in
                print("Error sending message: \(error.localizedDescription)")
            }
        }
    }

    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {}
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ビルドの設定


Runner の General タブで Frameworks, Libraries, and Embedded Content セクションの + ボタンをクリックする


Watch アプリが表示されるので選択して追加する


Runner の General タブ Identity セクションで Bundle Identifier を確認する

このサンプルでは com.loremipsum.helloAppleWatch にしている。


この値を Watch App の WKCompanionAppBundleIdentifier に設定する


Watch App の Bundle Identifier が .watchkitapp になったが大丈夫なのだろうか?

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

VS Code から Flutter デバッグを開始できない

DEBUG CONSOLE
Launching lib/main.dart on iPhone in debug mode...
Automatically signing iOS for device deployment using specified development team in Xcode project: Z8PWHVS69Q
Watch companion app found.
Xcode build done.                                            9.1s
Failed to build iOS app
Could not build the precompiled application for the device.
Error (Xcode): Cycle inside Runner; building could produce unreliable results.
Cycle details:
→ Target 'Runner': ExtractAppIntentsMetadata
○ Target 'Runner' has copy command from '/Users/susukida/workspace/hello_apple_watch/build/ios/Debug-watchos/helloFlutterApp Watch App.app' to '/Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Watch/helloFlutterApp Watch App.app'
○ That command depends on command in Target 'Runner': script phase “Thin Binary”
○ Target 'Runner' has process command with output '/Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Info.plist'
○ Target 'Runner' has copy command from '/Users/susukida/workspace/hello_apple_watch/build/ios/Debug-watchos/helloFlutterApp Watch App.app' to '/Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Watch/helloFlutterApp Watch App.app'

Raw dependency cycle trace:

target:  ->

node: <all> ->

command: <all> ->

node: /Users/susukida/Library/Developer/Xcode/DerivedData/Runner-fwtgxsefujmgskczzkkmufjxktjk/Build/Intermediates.noindex/Runner.build/Debug-iphoneos/Runner.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata ->

command: P0:target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49-:Debug:ExtractAppIntentsMetadata ->

node: <target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49--fused-phase6-copy-files> ->

command: P0:::Gate target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49--fused-phase6-copy-files ->

node: <Copy /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Watch/helloFlutterApp Watch App.app> ->

CYCLE POINT ->

command: P0:target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49-:Debug:Copy /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Watch/helloFlutterApp Watch App.app /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-watchos/helloFlutterApp Watch App.app ->

node: <target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49--fused-phase5--cp--embed-pods-frameworks> ->

command: P0:::Gate target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49--fused-phase5--cp--embed-pods-frameworks ->

node: <target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49--fused-phase4-thin-binary> ->

command: P0:::Gate target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49--fused-phase4-thin-binary ->

node: <execute-shell-script-18c1723432283e0cc55f10a6dcfd9e02f1eee2015e8ff5ebcd27678f788c2826-target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49-> ->

command: P2:target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49-:Debug:PhaseScriptExecution Thin Binary /Users/susukida/Library/Developer/Xcode/DerivedData/Runner-fwtgxsefujmgskczzkkmufjxktjk/Build/Intermediates.noindex/Runner.build/Debug-iphoneos/Runner.build/Script-3B06AD1E1E4923F5004D2608.sh ->

node: /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Info.plist/ ->

directoryTreeSignature: Z ->

directoryContents: /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Info.plist ->

node: /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Info.plist ->

command: P0:target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49-:Debug:ProcessInfoPlistFile /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Info.plist /Users/susukida/workspace/hello_apple_watch/ios/Runner/Info.plist ->

node: /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Watch/helloFlutterApp Watch App.app ->

command: P0:target-Runner-18c1723432283e0cc55f10a6dcfd9e0288a783a885d8b0b3beb2e9f90bde3f49-:Debug:Copy /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-iphoneos/Runner.app/Watch/helloFlutterApp Watch App.app /Users/susukida/workspace/hello_apple_watch/build/ios/Debug-watchos/helloFlutterApp Watch App.app

Error launching application on iPhone.


Exited (1).
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

色々やってたら起動できそうになった

  • Apple Watch を開発者モードにする
  • Deployment Target を 9.6 にする
  • iPhone からケーブルを抜いて差し直す


Deployment Target の設定

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Apple Watch からカウントアップできた!

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_watch_os_connectivity/flutter_watch_os_connectivity.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends HookWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context) {
    final counter = useState(0);
    final flutterWatchOsConnectivity =
        useMemoized(() => FlutterWatchOsConnectivity());

    useEffect(() {
      flutterWatchOsConnectivity.configureAndActivateSession();
      final subscription =
          flutterWatchOsConnectivity.messageReceived.listen((message) {
        if (message.data.containsKey('action')) {
          if (message.data['action'] == "countUp") {
            counter.value += 1;
          }
        }
      });

      return () => subscription.cancel();
    }, []);

    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Count: ${counter.value}'),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            counter.value += 1;
          },
          child: const Icon(Icons.plus_one),
        ),
      ),
    );
  }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

スムーズに進んだ

初めての試みだったので色々と難航するかと思っていたが意外とスムーズに進んだ。

まだわからないことだらけだがとりあえず動くものができて良かった。

一旦スクラップはクローズしてまた何かでつまづいたら追記していくことにしよう。

このスクラップは7日前にクローズされました