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

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

参考記事
下記の素晴らしい記事を参考にしよう。

Flutter 側を準備する
動作確認のための簡単なアプリを作ってみよう。
VS Code のコマンドパレットから Flutter: New Project
を実行する。
テンプレートとしては Empty Application
を選ぶ。
プロジェクト名は hello_apple_watch
とした。

XCode での設定
open ios/Runner.xcworkspace
よくわからないが Bundler Identifier をユニークっぽいものに変えてみた

起動
F5 で無事に起動した。
コマンドパレット的には Debug: Start Debugging
のようだ。

flutter_hooks のインストール
flutter pub add flutter_hooks

基本のカウンターアプリ
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),
),
),
);
}
}

Apple Watch の初期化
しばらく使っていない Apple Watch なのでパスワードすら忘れてしまった。
まずは初期化する方法を探そう。
これがサイドボタンだと思ってずっと Digital Crown を押してたが何もならなくてどうしようとなったが気が付けて良かった。

iPhone のアップデートが必要
かったるいが iOS 16.7.10 をダウンロードしてインストールしよう。
これで Flutter アプリが起動しなくなるようなことがありませんように。

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

次にやること
下記の記事などを参考にして flutter_watch_os_connectivity パッケージを追加して設定してみるか。

flutter_watch_os_connectivity について

インストール
flutter pub add flutter_watch_os_connectivity

Apple Watch からのメッセージ受信
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),
),
),
);
}
}
こんなので良いのだろうか?

デベロッパーモード
To use 'iPhone' for development, enable Developer Mode in Settings → Privacy & Security.
iOS をアップデートしたからだろうか?
設定アプリのプライバシーとセキュリティからデベロッパーモードを ON にしたら解決したようだ。

起動はするが真っ白
一旦 useEffect をコメントアウトして起動してからコメントアウトを外したら起動するようになった。
なんかこういうの嫌だなー

警告メッセージを控えておく
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

ウォッチアプリを作る
open ios/Runner.xcworkspace
File > New > Target から watchOS App を追加する
Flutter 側が helloAppleWatch だったのでこちらは helloFlutterApp にしよう
helloFlutterApp Watch App が追加された、AppTests や AppUITests はテスト用のようだ

ボタンの追加
右上の「+」アイコンをクリックするとコントロールの一覧が表示されるのでドラッグ&ドロップ
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Button("Count up") {}
}
.padding()
}
}
#Preview {
ContentView()
}
Count up ボタンが表示された

ChatGPT に聞いてみた
Apple Watch から Flutter アプリにメッセージを送信するプログラムの書き方を教えてください!下記まで書きました!
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Button("Count up") {}
}
.padding()
}
}
#Preview {
ContentView()
}
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()
}
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)
}
}
}
}
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)),
);
}
}

Apple Watch 側のコード
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?) {}
}

ビルドの設定
Runner の General タブで Frameworks, Libraries, and Embedded Content セクションの + ボタンをクリックする
Watch アプリが表示されるので選択して追加する
Runner の General タブ Identity セクションで Bundle Identifier を確認する
このサンプルでは com.loremipsum.helloAppleWatch
にしている。
この値を Watch App の WKCompanionAppBundleIdentifier に設定する
Watch App の Bundle Identifier が .watchkitapp
になったが大丈夫なのだろうか?

実行できない
Apple Watch が古いからだろうか?

watchOS のバージョン確認
Apple Watch の設定アプリで 一般 > 情報 と移動したところ 9.6.3 のようだ。

Watch OS の Bundle ID
com.loremipsum.helloAppleWatch.watchkitapp
のようにする必要があるかも知れない。

シミュレーターでは起動した
これは実機とは通信できるのだろうか?

VS Code から Flutter デバッグを開始できない
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).

XCode でもビルド失敗
同じエラーメッセージのようだ。

参考になりそう

現状の Build Phases
Embed Watch Content を Thin Binary よりも前に持っていこう
これで起動したら嬉しいなー

起動した
ありがとうインターネット、ありがとう Chat GPT。

Apple Watch にもデベロッパーモードがあるようだ

色々やってたら起動できそうになった
- Apple Watch を開発者モードにする
- Deployment Target を 9.6 にする
- iPhone からケーブルを抜いて差し直す
Deployment Target の設定

Apple Watch 実機で起動できた
この感動は忘れない。

つながっている
なんとまあ、素晴らしい。

Apple Watch からカウントアップできた!
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),
),
),
);
}
}

変更が反映されないのはちょっと気になる
手動でリロードすれば大丈夫なのでまあ良いか。

スムーズに進んだ
初めての試みだったので色々と難航するかと思っていたが意外とスムーズに進んだ。
まだわからないことだらけだがとりあえず動くものができて良かった。
一旦スクラップはクローズしてまた何かでつまづいたら追記していくことにしよう。