【Flutter】Pigeon で watchOS アプリを追加する

2024/12/01に公開

初めに

今回は Pigeon を用いて、Flutter で書かれた iOS 側のアプリケーションと、Swift で書かれた watchOS 側のアプリケーションの連携を行いたいと思います。この記事では、Pigeon を使用してネイティブコードを生成し、Flutter と watchOS 間でのデータ通信を実現する方法について詳しく解説します。

記事の対象者

  • Flutter 学習者
  • watchOS の実装をしたい方
  • Flutter アプリに Apple Watch の実装を追加したい方

目的

今回は先述の通り、Pigeon を用いて Flutter アプリと watchOS のアプリとの連携を目的とします。
Flutter と watchOS との連携ができるようになれば、Flutter のアプリで実装した機能を切り出してより多くのユーザーに使ってもらうことが可能になります。
Pigeon や watchOS 連携に関しては以前二つの記事を書いていました。

Pigeon を利用した iOS 側との連携
https://zenn.dev/koichi_51/articles/61c45c2c30312b

iOS 側と watchOS 側との連携
https://zenn.dev/koichi_51/articles/0b26a80841b4ed

これらを基に、今回の Flutter と watchOS の連携を進めていきます。

今回実装するコードは以下の GitHub リポジトリで公開しています。必要に応じてご参照いただければと思います。

https://github.com/Koichi5/pigeon-sample

やらないこと

  • Android 側の実装
  • Flutter 側の細かなUI実装

要約

今回の記事は非常に長くなってしまったため、この記事の要約をはじめにしておきたいと思います。

  • Flutter のアプリに watchOS のアプリを追加する場合は、それぞれの連携を分けて考える必要がある
  • Flutter と Swift との連携は Pigeon で実装可能
  • iOS と watchOS との連携は WCSession で実装可能

図にまとめると以下のようになっていて、この記事の全てを読まなくても実際に実装が必要になった場面で思い出していただくだけでも良いと思います。

実装

今回は以下の手順で実装を進めていきます。

  1. 準備
  2. Pigeon で Flutter, Swift のコード生成
  3. Flutter でアプリの実装
  4. iOS と watchOS との連携実装

最終的には以下の動画のようにそれぞれの本の勉強時間をタイマーで記録して、保存できるようなアプリを作成します。仕組みとしては、 Apple Watch からの操作を検知して、Flutter で書かれた Firestore へのデータ保存処理が実行されるという流れです。

https://youtu.be/c58yLZFo-Ew

今回実装するアプリの流れは以下のようになっていて、今どの部分を触っているかを頭の片隅に置きながら実装すると混乱しなくて良いと思います。

Apple Watch からデータを保存する

Firestore からデータを取得して Apple Watch で表示する

1. 準備

まずは以下の手順で今回の実装に必要な準備を進めていきます。

  1. Flutter 側の準備
  2. Firebase 側の準備
  3. watchOS の準備
  4. Simulator の準備

1. Flutter 側の準備

Flutter 側では必要なパッケージの追加を行います。
以下のパッケージの最新バージョンを pubspec.yamlに記述していきます。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  pigeon: ^22.5.0
  cloud_firestore: ^5.4.4
  firebase_core: ^3.6.0
  flutter_hooks: ^0.20.5
  hooks_riverpod: ^2.6.0
  freezed_annotation: ^2.4.4
  gap: ^3.0.1
  riverpod_generator: ^2.6.2
  riverpod_annotation: ^2.6.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.13
  freezed: ^2.5.7

または

以下をターミナルで実行

flutter pub add pigeon cloud_firestore firebase_core flutter_hooks hooks_riverpod freezed_annotation gap riverpod_generator riverpod_annotation
flutter pub add -d build_runner freezed

これで Flutter 側の準備は完了です。

2. Firebase 側の準備

次に Firebase 側の準備に移ります。
Firebase では以下のステップを完了させて、Cloud Firestore でのデータ操作が可能な状態にしておきます。セキュリティルールなどで操作がブロックされていないかどうか確認しておきます。

  • プロジェクトの作成
  • Flutter プロジェクトとの紐付け
  • Cloud Firestore の有効化

3. watchOS の準備

次に watchOS の準備に移ります。
【Swift】iOS アプリに watchOS を追加すると同じような手順で、 Flutter のアプリに watchOS のターゲットを追加していきます。

まずは Runner.xcworkspace を Xcode で開きます。
VSCode の場合は ios > Runner.xcworkspace のディレクトリを右クリックして、「Reveal in Finder」を選択して、開いたファイルをクリックすると Xcode で開くことができます。

Xcode で Runner.xcworkspace を開いたら、上部のタブから File > New > Target ... を選択します。

以下のようなダイアログが表示されるので、 watchOS > App を選択して「Next」を押します。

次に Product Name を入力して、今回は iOS アプリと連携する watchOS のアプリを作成するため「Watch App for Existing iOS App」にチェックを入れて「Finish」を押します。筆者の手元では「MyWatch」という Product Name で作成しています。

作成された watchOS 関連のフォルダは水色で表示されているかと思います。Xcode上ではこれらは Folder として認識されていて、今後の実装で支障が出る可能性があるため、それぞれのフォルダで右クリックして「Convert to Group」を選択しておきます。

詳しく知りたい方は以下の記事をご参照ください。
https://zenn.dev/koichi_51/articles/78d43303591037

次に iOS アプリ側(Runner)の General の「Frameworks, Libraries, and Embedded Content」の項目に MyWatch を追加しておきます。

また、Build Phase のエラーが出る可能性があるため、以下の画像のような順番にしておきます。

  1. Target Dependencies
  2. Rub Build Tool Plug-ins
  3. [CP]Check Pods Manifest.lock
  4. Run Script
  5. Link Binary With Libraries
  6. Embed Watch Content
  7. Compile Sources
  8. Embed Frameworks
  9. Copy Bundle Resources
  10. Thin Binary
  11. [CP]Embed Pods frameworks

次に watchOS 側の設定を見ていきます。
watchOS 側の Bundle Identifier の項目を {iOS側のBundle Identifier}.watchkitapp となるように変更します。

今回のプロジェクトでは iOS 側の Bundle Identifier が com.example.pigeonSample であるため、以下の画像の赤枠部分のように watchOS 側の Bundle Identifier を com.example.pigeonSample.watchkitapp としておきます。

最後に watchOS 側の Info.plistWKCompanionAppBundleIdentifier の項目を iOS アプリの Bundle Identifier に変更します。

これで watchOS の準備は完了です。

4. Simulator の準備

次に Simulator の準備に移ります。
【Swift】iOS アプリに watchOS を追加するで紹介した方法と同じで、互いにペアリングされた iPhone と Apple Watch の Simulator を用意しておきます。

今回は Simulator を使用しますが、 iPhone, Apple Watch 共に実機を使用した方がより確実に動作確認できるため、実機がある場合はそちらを使用した方が良いかと思います。

これで準備は完了です。

2. Pigeon で Flutter, Swift のコード生成

次に Pigeon を用いて Flutter と Swift のコードを生成していきます。
Pigeon の実装は 【Flutter】Pigeon でネイティブと連携するで行なったものと同じような手順になります。

まずは lib ディレクトリの直下に pigeons ディレクトリを作成して、そこに book.dart というファイルを作成します。
Pigeon は Dart で書かれたファイルをもとにそれぞれのプラットフォームのコードを生成します。その生成の元になるのがこのファイルです。作成した book.dart を以下のように編集します。

lib/pigeons/book.dart
import 'package:pigeon/pigeon.dart';

(PigeonOptions(
  dartOut: 'lib/book/book.g.dart',
  dartOptions: DartOptions(),
  kotlinOut:
      'android/app/src/main/kotlin/dev/flutter/pigeon_example_app/Book/Book.g.kt',
  kotlinOptions: KotlinOptions(),
  swiftOut: 'ios/Runner/Book/Book.g.swift',
  swiftOptions: SwiftOptions(),
))

class Book {
  Book({
    this.id,
    required this.title,
    required this.publisher,
    required this.imageUrl,
    required this.lastModified,
  });
  String? id;
  String title;
  String publisher;
  String imageUrl;
  int lastModified;
}

class Record {
  Record({
    this.id,
    required this.book,
    required this.seconds,
    required this.createdAt,
    required this.lastModified,
  });
  String? id;
  Book book;
  int seconds;
  int createdAt;
  int lastModified;
}

// iOS, watchOS側で行いたい処理
()
abstract class BookFlutterApi {
  
  List<Book> fetchBooks();
  void addBook(Book book);
  void deleteBook(Book book);
  
  List<Record> fetchRecords();
  void addRecord(Record record);
  void deleteRecord(Record record);
}

それぞれ詳しく見ていきます。

以下ではコードの自動生成に関する設定を行なっています。
dartOut では Dart 側のコードを生成する場所を指定しています。
kotlinOut, swiftOut も同様で、各プラットフォームのコードをどこに出力するかを決めています。
また、今回は特に指定していませんが、 dartOptions, kotlinOptions, swiftOptions でそれぞれのコードを生成する際のオプションを設定できます。

(PigeonOptions(
  dartOut: 'lib/book/book.g.dart',
  dartOptions: DartOptions(),
  kotlinOut:
      'android/app/src/main/kotlin/dev/flutter/pigeon_example_app/Book/Book.g.kt',
  kotlinOptions: KotlinOptions(),
  swiftOut: 'ios/Runner/Book/Book.g.swift',
  swiftOptions: SwiftOptions(),
))

以下では各プラットフォームで共通して使用するクラスの定義をしています。
Pigeon の生成元となるファイルで定義されたクラスなどは各プラットフォームのコードに変換されて自動生成されます。
今回は本と記録のデータを共通して作成しています。それぞれのプロパティの説明はコメントの通りです。この辺りのコードを共通化して生成してくれるのがとても便利ですね。

class Book {
  Book({
    this.id,
    required this.title,
    required this.publisher,
    required this.imageUrl,
    required this.lastModified,
  });
  String? id;
  String title;    // 本のタイトル
  String publisher;    // 本の出版社
  String imageUrl;    // 本の書影の画像URL
  int lastModified;    // 最後に変更された日時
}

class Record {
  Record({
    this.id,
    required this.book,
    required this.seconds,
    required this.createdAt,
    required this.lastModified,
  });
  String? id;
  Book book;    // 勉強した本
  int seconds;    // 勉強時間
  int createdAt;    // 記録の作成日時
  int lastModified;    // 最後に変更された日時
}

以下では FlutterApi アノテーションを使って、 Swift 側から Flutter 側を呼び出す際に使用する関数を定義しています。今回は Swift 側からの呼び出しに応じて Flutter で書かれている Firestore への保存処理などを実行したいので、FlutterApi アノテーションを使っています。
@async がついている関数は非同期処理の関数として生成することができます。
それぞれの関数の説明はコメントの通りです。

()
abstract class BookFlutterApi {
  
  List<Book> fetchBooks();    // 保存されている本の一覧取得
  void addBook(Book book);    // 本のデータ追加
  void deleteBook(Book book);    // 本のデータ削除
  
  List<Record> fetchRecords();    // 保存されている記録の一覧取得
  void addRecord(Record record);    // 記録の追加
  void deleteRecord(Record record);    // 記録の削除
}

これで Pigeon のコード生成の元となる book.dart の実装は完了です。
最後にコードの自動生成を行います。プロジェクトのルートディレクトリで以下のコマンドを実行します。
コマンドにあるように、先ほど作成したファイルのパスを Pigeon の input として渡しています。
これでこのパスにあるファイルをもとに Pigeon でコードの自動生成が行われます。

flutter pub run pigeon --input lib/pigeons/book.dart

実際に生成されたコードは以下のようになっています。
生成されたコードの1行目にもありますが、生成されたコードを直接編集したとしても、再度コード生成を行うと編集した差分が消されてしまうため、生成されたコードを編集したい場合は生成元(今回は lib/pigeons/book.dart)のファイルを編集します。

生成された Swift 側のコード
https://github.com/Koichi5/pigeon-sample/blob/main/ios/Runner/Book/Book.g.swift

生成された Flutter 側のコード
https://github.com/Koichi5/pigeon-sample/blob/main/lib/book/book.g.dart

これで Pigeon のコード生成は完了です。

3. Flutter でアプリの実装

次に Flutter 側のアプリ実装に移ります。
Flutter 側は以下の手順で実装していきます。

  1. API の実装
  2. 画面の実装
  3. main.dart の実装

1. API の実装

まずは API の実装を行います。
APIの実装は先ほど Pigeon で生成した lib/book/book.g.dart のコードをもとに行います。
生成されたコードを見ると abstract class として定義されている BookFlutterApi が見つかるかと思います。ここに定義されている関数は Swift 側からの呼び出しに応じて実行されます。

ただ、 abstract であるためそのまま使用することはできません。また、 Firestore への保存処理などもないため追加で実装する必要があります。

lib/book ディレクトリに book_flutter_api_impl.dart ファイルを作成します。
ここでは生成された BookFlutterApi を継承して、 Firestore への保存処理などを担当する BookFlutterApiImpl クラスを作成します。
コードは以下の通りです。

lib/book/book_flutter_api_impl.dart
import 'dart:async';

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:pigeon_sample/book/book.g.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'book_flutter_api_impl.g.dart';

class BookFlutterApiImpl extends BookFlutterApi {
  final books = FirebaseFirestore.instance.collection('books');
  final records = FirebaseFirestore.instance.collection('records');
  
  Future<List<Book>> fetchBooks() async {
    final QuerySnapshot querySnapshot = await books.get();
    final List<Book> bookList = querySnapshot.docs.map((doc) {
      final data = doc.data() as Map<String, dynamic>;

      return Book(
        id: data['id'] ?? doc.id,
        title: data['title'] ?? '',
        publisher: data['publisher'] ?? '',
        imageUrl: data['imageUrl'] ?? '',
        lastModified: data['lastModified'] ?? 0,
      );
    }).toList();

    return bookList;
  }

  
  void addBook(Book book) {
    final docRef = books.doc(book.id ?? books.doc().id);
    docRef.set({
      'id': docRef.id,
      'title': book.title,
      'publisher': book.publisher,
      'imageUrl': book.imageUrl,
      'lastModified': book.lastModified,
    });
  }

  
  void deleteBook(Book book) {
    if (book.id != null) {
      books.doc(book.id).delete();
    }
  }

  
  Future<List<Record>> fetchRecords() async {
    final QuerySnapshot querySnapshot = await records.get();
    final List<Record> recordList = querySnapshot.docs.map((doc) {
      final data = doc.data() as Map<String, dynamic>;

      final bookData = data['book'] as Map<String, dynamic>;
      final Book book = Book(
        id: bookData['id'] ?? '',
        title: bookData['title'] ?? '',
        publisher: bookData['publisher'] ?? '',
        imageUrl: bookData['imageUrl'] ?? '',
        lastModified: bookData['lastModified'] ?? 0,
      );

      return Record(
        id: data['id'] ?? doc.id,
        book: book,
        seconds: data['seconds'] ?? 0,
        createdAt: data['createdAt'] ?? 0,
        lastModified: data['lastModified'] ?? 0,
      );
    }).toList();

    return recordList;
  }

  
  void addRecord(Record record) {
    final docRef = records.doc(record.id ?? records.doc().id);
    docRef.set({
      'id': docRef.id,
      'book': {
        'id': record.book.id ?? '',
        'title': record.book.title,
        'publisher': record.book.publisher,
        'imageUrl': record.book.imageUrl,
        'lastModified': record.book.lastModified,
      },
      'seconds': record.seconds,
      'createdAt': record.createdAt,
      'lastModified': record.lastModified,
    });
  }

  
  void deleteRecord(Record record) {
    if (record.id != null) {
      records.doc(record.id).delete();
    }
  }
}


BookFlutterApiImpl bookApi(Ref ref) {
  return BookFlutterApiImpl();
}


Future<List<Book>> books(Ref ref) async {
  final bookApi = ref.read(bookApiProvider);
  return await bookApi.fetchBooks();
}


Future<List<Record>> records(Ref ref) async {
  final bookApi = ref.read(bookApiProvider);
  return await bookApi.fetchRecords();
}

それぞれ詳しく見ていきます。

以下では Firestore のコレクションを定義しています。
本のデータは books、記録のデータは records コレクションとして定義しています。

final books = FirebaseFirestore.instance.collection('books');
final records = FirebaseFirestore.instance.collection('records');

以下では本の一覧を取得する fetchBooks メソッドを定義しています。
QuerySnapshot として取得したデータを Book のリストとして返却することで Flutter 側で扱いやすいようにしています。また、Pigeon でコード生成する際に @async をつけたため、非同期で処理するようにしています。

Future<List<Book>> fetchBooks() async {
  final QuerySnapshot querySnapshot = await books.get();
  final List<Book> bookList = querySnapshot.docs.map((doc) {
    final data = doc.data() as Map<String, dynamic>;

    return Book(
      id: data['id'] ?? doc.id,
      title: data['title'] ?? '',
      publisher: data['publisher'] ?? '',
      imageUrl: data['imageUrl'] ?? '',
      lastModified: data['lastModified'] ?? 0,
    );
  }).toList();

  return bookList;
}

以下では本のデータを追加する addBook メソッドを定義しています。
ドキュメントのIDを先に取得してそのIDと一緒に本のデータを追加しています。

void addBook(Book book) {
  final docRef = books.doc(book.id ?? books.doc().id);
  docRef.set({
    'id': docRef.id,
    'title': book.title,
    'publisher': book.publisher,
    'imageUrl': book.imageUrl,
    'lastModified': book.lastModified,
  });
}

以下では本のデータを削除する deleteBook メソッドを定義しています。

void deleteBook(Book book) {
  if (book.id != null) {
    books.doc(book.id).delete();
  }
}

記録の一覧取得、追加、削除の処理に関しても基本的に同様であるため割愛します。

以下では BookFlutterApiImpl のインスタンスを保持する bookApiProvider, 本の一覧データを返却する booksProvider, 記録の一覧を返却する recordsProvider の三つを定義しています。
これらの Provider を用いて次の章で画面の実装を行います。


BookFlutterApiImpl bookApi(Ref ref) {
  return BookFlutterApiImpl();
}


Future<List<Book>> books(Ref ref) async {
  final bookApi = ref.read(bookApiProvider);
  return await bookApi.fetchBooks();
}


Future<List<Record>> records(Ref ref) async {
  final bookApi = ref.read(bookApiProvider);
  return await bookApi.fetchRecords();
}

APIの実装は完了です。 BookFlutterApiImpl で実装した関数は Flutter と Swift のどちらからでも実行可能であるため、 Flutter と watchOS のどちらから呼び出された場合でも同じ処理を統一して実行することが可能です。

2. 画面の実装

次は Flutter 側の画面の実装を進めていきます。
今実装した BookFlutterApiImpl の関数を実行することで本や記録の取得、追加などができるため、それを画面で見られる形にしていきます。

なお、「やらないこと」の章で述べたように今回は Flutter の画面実装のリッチな表現は行いません。必要に応じて見た目を変えていただければと思います。

作成する画面は以下です。

  • 本の一覧画面
  • 本の詳細画面
  • 本の追加画面
  • 記録の一覧画面
  • タブ画面

それぞれのコードは以下の通りです。

本の一覧画面

lib/book/screens/book_list_screen.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pigeon_sample/book/book_flutter_api_impl.dart';
import 'package:pigeon_sample/book/screens/book_add_screen.dart';
import 'package:pigeon_sample/book/screens/book_detail_screen.dart';

class BookListScreen extends HookConsumerWidget {
  const BookListScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final booksAsyncValue = ref.watch(booksProvider);  // 非同期で本の一覧取得
    return Scaffold(
      body: booksAsyncValue.when(
        data: (books) {
          return ListView.builder(
            itemCount: books.length,
            itemBuilder: (context, index) {
              final book = books[index];
              return ListTile(
                title: Text(book.title),  // 本のタイトル
                subtitle: Text(book.publisher),  // 本の出版社
                onTap: () {
                  Navigator.push(
                    context,
                    MaterialPageRoute(  // 本の詳細画面へ
                      builder: (context) => BookDetailScreen(book: book),
                    ),
                  );
                },
              );
            },
          );
        },
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stack) => Center(child: Text('エラーが発生しました: $error')),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.push(  // 本の追加画面へ
            context,
            MaterialPageRoute(builder: (context) => const BookAddScreen()),
          );
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

本の詳細画面

lib/book/screens/book_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pigeon_sample/book/book.g.dart';
import 'package:pigeon_sample/book/book_flutter_api_impl.dart';

class BookDetailScreen extends HookConsumerWidget {
  final Book book;  // 一覧画面から Book のデータ受け取り

  const BookDetailScreen({super.key, required this.book});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final isTiming = useState(false);  // タイマーが作動中かどうかのフラグを保持
    final elapsedSeconds = useState(0);  // 勉強時間の秒数を保持
    final stopwatch = useMemoized(() => Stopwatch());  // ストップウォッチの状態保持
    final bookApi = ref.watch(bookApiProvider);

    void updateTime() {
      if (stopwatch.isRunning) {
        Future.delayed(const Duration(seconds: 1), () {  // 1秒ごとにタイマー更新
          if (stopwatch.isRunning) {
            elapsedSeconds.value = stopwatch.elapsed.inSeconds;
            updateTime();
          }
        });
      }
    }

    void startStopwatch() {
      isTiming.value = true;
      stopwatch.start();
      updateTime(); 
    }

    void stopStopwatch() {
      isTiming.value = false;
      stopwatch.stop();
      elapsedSeconds.value = stopwatch.elapsed.inSeconds;
    }

    void resetStopwatch() {
      stopwatch.reset();
      elapsedSeconds.value = 0;
    }

    void saveRecord() async {
      final record = Record(
        id: null,
        book: book,
        seconds: elapsedSeconds.value,  
        createdAt: DateTime.now().millisecondsSinceEpoch,
        lastModified: DateTime.now().millisecondsSinceEpoch,
      );
      bookApi.addRecord(record);  // 記録の追加処理

      if (context.mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('記録を保存しました')),
        );
      }
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('本の詳細'),
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              Text(
                book.title,
                style: Theme.of(context).textTheme.titleLarge,
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 8),
              Text(
                '出版社: ${book.publisher}',
                style: Theme.of(context).textTheme.bodyMedium,
              ),
              const SizedBox(height: 20),
              ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Image.network(
                  book.imageUrl,
                  height: 200,
                  errorBuilder: (context, error, stackTrace) {
                    return const Icon(Icons.book, size: 100);
                  },
                ),
              ),
              const SizedBox(height: 32),
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Column(
                    children: [
                      Text(
                        _formatTime(elapsedSeconds.value),
                        style: Theme.of(context).textTheme.displayMedium,
                      ),
                      const SizedBox(height: 16),
                      Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          ElevatedButton.icon(
                            onPressed: isTiming.value ? stopStopwatch : startStopwatch,
                            icon: Icon(isTiming.value ? Icons.pause : Icons.play_arrow),
                            label: Text(isTiming.value ? 'ストップ' : 'スタート'),
                            style: ElevatedButton.styleFrom(
                              minimumSize: const Size(120, 40),
                            ),
                          ),
                          const SizedBox(width: 12),
                          OutlinedButton.icon(
                            onPressed: elapsedSeconds.value > 0 ? resetStopwatch : null,
                            icon: const Icon(Icons.refresh),
                            label: const Text('リセット'),
                            style: OutlinedButton.styleFrom(
                              minimumSize: const Size(120, 40),
                            ),
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 16),
              if (elapsedSeconds.value > 0 && !isTiming.value)
                ElevatedButton.icon(
                  onPressed: saveRecord,
                  icon: const Icon(Icons.save),
                  label: const Text('記録を保存'),
                  style: ElevatedButton.styleFrom(
                    minimumSize: const Size(200, 48),
                    backgroundColor: Theme.of(context).primaryColor,
                    foregroundColor: Colors.white,
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }

  String _formatTime(int seconds) {
    final hours = (seconds ~/ 3600).toString().padLeft(2, '0');
    final minutes = ((seconds % 3600) ~/ 60).toString().padLeft(2, '0');
    final remainingSeconds = (seconds % 60).toString().padLeft(2, '0');
    return '$hours:$minutes:$remainingSeconds';
  }
}

本の追加画面

lib/book/screens/book_add_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pigeon_sample/book/book.g.dart';
import 'package:pigeon_sample/book/book_flutter_api_impl.dart';

class BookAddScreen extends HookConsumerWidget {
  const BookAddScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final titleController = TextEditingController();  // 本のタイトルのコントローラー
    final publisherController = TextEditingController();  // 本の出版社のコントローラー
    final imageUrlController = TextEditingController();  // 本の書影の画像URLのコントローラー

    final isLoading = useState(false);  // ローディング中かどうかのフラグを保持

    return Scaffold(
      appBar: AppBar(
        title: const Text('本の追加'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: isLoading.value
            ? const Center(child: CircularProgressIndicator())
            : Column(
                children: <Widget>[
                  TextField(
                    controller: titleController,
                    decoration: const InputDecoration(labelText: 'タイトル'),
                  ),
                  TextField(
                    controller: publisherController,
                    decoration: const InputDecoration(labelText: '出版社'),
                  ),
                  TextField(
                    controller: imageUrlController,
                    decoration: const InputDecoration(labelText: '画像URL'),
                  ),
                  const SizedBox(height: 20),
                  ElevatedButton(
                    onPressed: () {
                      if (titleController.text.isEmpty) {
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(content: Text('タイトルを入力してください')),
                        );
                        return;
                      }
                      isLoading.value = true;
                      final bookApi = ref.read(bookApiProvider);
                      final book = Book(
                        id: null,
                        title: titleController.text,
                        publisher: publisherController.text,
                        imageUrl: imageUrlController.text,
                        lastModified: DateTime.now().millisecondsSinceEpoch,
                      );
                      bookApi.addBook(book);  // 本の追加処理
                      isLoading.value = false;
                      Navigator.pop(context);
                    },
                    child: const Text('保存'),
                  ),
                ],
              ),
      ),
    );
  }
}

記録の一覧画面

lib/book/screens/record_list_screen.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pigeon_sample/book/book_flutter_api_impl.dart';

class RecordListScreen extends HookConsumerWidget {
  const RecordListScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final recordsAsyncValue = ref.watch(recordsProvider);  // 記録の一覧取得

    return Scaffold(
      body: recordsAsyncValue.when(
        data: (records) {
          if (records.isEmpty) {
            return const Center(child: Text('記録がありません'));
          }
          return RefreshIndicator(  // Pull To Refresh が可能に
            onRefresh: () async {
              ref.invalidate(recordsProvider);
            },
            child: ListView.builder(
              itemCount: records.length,
              itemBuilder: (context, index) {
                final record = records[index];
                final date = DateTime.fromMillisecondsSinceEpoch(record.createdAt);
                final formattedDate = '${date.year}/${date.month}/${date.day}';
                return ListTile(
                  title: Text(record.book.title),
                  subtitle: Text(
                      '勉強時間: ${_formatTime(record.seconds)} 日付: $formattedDate'),
                );
              },
            ),
          );
        },
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stack) => Center(child: Text('エラーが発生しました: $error')),
      ),
    );
  }

  String _formatTime(int seconds) {
    final minutes = (seconds ~/ 60).toString().padLeft(2, '0');
    final remainingSeconds = (seconds % 60).toString().padLeft(2, '0');
    return '$minutes:$remainingSeconds';
  }
}

タブ画面
本の一覧画面と記録の一覧画面の切り替えができるタブ画面を実装しています。

lib/book/screens/tab_screen.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pigeon_sample/book/screens/book_list_screen.dart';
import 'package:pigeon_sample/book/screens/record_list_screen.dart';

class TabScreen extends HookConsumerWidget {
  const TabScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('ライブラリ'),
          bottom: const TabBar(
            tabs: [
              Tab(text: '本'),
              Tab(text: '記録'),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            BookListScreen(),
            RecordListScreen(),
          ],
        ),
      ),
    );
  }
}

これで画面の実装は完了です。
Flutter 側の画面で本や記録の一覧表示、追加などができるようになりました。

3. main.dart の実装

Flutter 側で最後に main.dart の実装を行います。
コードは以下の通りです。

lib/main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pigeon_sample/book/book.g.dart';
import 'package:pigeon_sample/book/book_flutter_api_impl.dart';
import 'package:pigeon_sample/book/screens/tab_screen.dart';
import 'package:pigeon_sample/firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  BookFlutterApi.setUp(BookFlutterApiImpl());

  runApp(const ProviderScope(child: MyApp()));
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Battery Level App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const TabScreen()
    );
  }
}

以下のコードで、Pigeonで生成したAPIのセットアップを行なっています。
実際の内容としては Flutter と Swift との連携を行うための Method Channel を確立しています。この Method Channel を通してデータのやり取りを行います。

振り返りになりますが、 BookFlutterApiImpl には本や記録データの Firestore への保存や一覧取得などが実装してあります。それを setUp メソッドの引数に渡すことで、 BookFlutterApiImpl にあるメソッドが Swift からの呼び出しに応じて実行されるようになります。

逆にこのセットアップの記述がないと Flutter と Swift との間の Method Channel が確立できないため Swift から Flutter のコードを呼び出すことができなくなります。

BookFlutterApi.setUp(BookFlutterApiImpl());

これで Flutter 側の実装は完了です。

4. iOS と watchOS との連携実装

次に iOS と watchOS との連携の実装に移ります。

今までの実装で Flutter と iOS は Pigeon で連携しました。次は iOS と watchOS との連携を行う必要があります。具体的には、 watchOS 側から処理が呼び出された際にそれが iOS に伝わり、さらにそれが Flutter 側へ伝わっていく必要があります。
iOS と watchOS との連携は WCSessionDelegate で行います。

iOS と watchOS との連携は以下の手順で進めていきます。

  1. iOS 側の実装
  2. watchOS 側の実装

1. iOS 側の実装

今回は iOS のアプリの特にUIに関しては Flutter 側で実装が完了しています。
したがって今回の iOS 側の実装の役割は Flutter と watchOS のやり取りの架け橋になることです。

まずは BookAppDelegate を編集していきます。なお、自分の手元では BookAppDelegate となっていますが、通常の場合は AppDelegate のような名前になっているかと思います。
コードは以下の通りです。

ios/Runner/BookAppDelegate.swift
import Flutter
import WatchConnectivity
import UIKit

@main
@objc class BookAppDelegate: FlutterAppDelegate {
    var flutterEngine: FlutterEngine?
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // FlutterEngineを初期化
        flutterEngine = FlutterEngine(name: "MyFlutterEngine")
        flutterEngine?.run()
        
        GeneratedPluginRegistrant.register(with: flutterEngine!)
        
        // FlutterViewControllerをflutterEngineを使用して初期化
        let flutterViewController = FlutterViewController(engine: flutterEngine!, nibName: nil, bundle: nil)
        
        // windowを設定し、rootViewControllerにFlutterViewControllerを設定
        self.window = UIWindow(frame: UIScreen.main.bounds)
        self.window?.rootViewController = flutterViewController
        self.window?.makeKeyAndVisible()
        
        _ = WCSessionManager.shared
        
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
    
    func fetchBooks(completion: @escaping ([Book]) -> Void) {
        guard let flutterEngine = flutterEngine else {
            print("Flutter Engine is not available.")
            completion([])
            return
        }
        
        DispatchQueue.main.async {
            let api = BookFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
            api.fetchBooks { result in
                switch result {
                case .success(let books):
                    print("Successfully fetched books from Flutter.")
                    print("Fetched Books: \(books)")
                    completion(books)
                case .failure(let error):
                    print("Error calling Flutter fetchBooks method: \(error.localizedDescription)")
                    completion([])
                }
            }
        }
    }
    
    func addBook(book: Book) {
        guard let flutterEngine = flutterEngine else {
            print("Flutter Engine is not available.")
            return
        }
        
        DispatchQueue.main.async {
            let api = BookFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
            api.addBook(book: book) { result in
                switch result {
                case .success():
                    print("Successfully called Flutter addBook method.")
                case .failure(let error):
                    print("Error calling Flutter addBook method: \(error.localizedDescription)")
                }
            }
        }
    }
    
    func deleteBook(book: Book) {
        guard let flutterEngine = flutterEngine else {
            print("Flutter Engine is not available.")
            return
        }
        
        DispatchQueue.main.async {
            let api = BookFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
            api.deleteBook(book: book) { result in
                switch result {
                case .success():
                    print("Successfully called Flutter deleteBook method.")
                case .failure(let error):
                    print("Error calling Flutter deleteBook method: \(error.localizedDescription)")
                }
            }
        }
    }
    
    func fetchRecords(completion: @escaping ([Record]) -> Void) {
        guard let flutterEngine = flutterEngine else {
            print("Flutter Engine is not available.")
            completion([])
            return
        }
        
        DispatchQueue.main.async {
            let api = BookFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
            api.fetchRecords() { result in
                switch result {
                case .success(let records):
                    print("Successfully fetched records from Flutter.")
                    print("Fetched Records: \(records)")
                    completion(records)
                case .failure(let error):
                    print("Error calling Flutter fetchRecords method: \(error.localizedDescription)")
                    completion([])
                }
            }
        }
    }
    
    func addRecord(record: Record) {
        guard let flutterEngine = flutterEngine else {
            print("Flutter Engine is not available.")
            return
        }
        
        DispatchQueue.main.async {
            let api = BookFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
            api.addRecord(record: record) { result in
                print("Record in addRecord(iOS): \(record)")
                switch result {
                case .success():
                    print("Successfully called Flutter addRecord method.")
                case .failure(let error):
                    print("Error calling Flutter addBook method: \(error.localizedDescription)")
                }
            }
        }
    }
    
    func deleteRecord(record: Record) {
        guard let flutterEngine = flutterEngine else {
            print("Flutter Engine is not available.")
            return
        }
        
        DispatchQueue.main.async {
            let api = BookFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
            api.deleteRecord(record: record) { result in
                switch result {
                case .success():
                    print("Successfully called Flutter deleteBook method.")
                case .failure(let error):
                    print("Error calling Flutter deleteBook method: \(error.localizedDescription)")
                }
            }
        }
    }
}

それぞれ詳しく見ていきます。

以下では FlutterEngine, FlutterViewController の初期化を明示的に行なっています。
WCSessionManager に関してはまだ実装していないためエラーになるかと思います。

// FlutterEngineを初期化
flutterEngine = FlutterEngine(name: "MyFlutterEngine")
flutterEngine?.run()

GeneratedPluginRegistrant.register(with: flutterEngine!)

// FlutterViewControllerをflutterEngineを使用して初期化
let flutterViewController = FlutterViewController(engine: flutterEngine!, nibName: nil, bundle: nil)

// windowを設定し、rootViewControllerにFlutterViewControllerを設定
self.window = UIWindow(frame: UIScreen.main.bounds)
self.window?.rootViewController = flutterViewController
self.window?.makeKeyAndVisible()

_ = WCSessionManager.shared

return super.application(application, didFinishLaunchingWithOptions: launchOptions)

以下では本の一覧取得処理を実装しています。
Pigeon で生成された BookFlutterApi の引数に flutterEngine.binaryMessenger を渡すことで API を呼び出すことができ、 fetchBooks を実行してその結果を返すようにしています。
これで Flutter 側で実装されている、Firestore から本の一覧を取得する処理を実行して、そのデータを取ってくることができるようになります。

func fetchBooks(completion: @escaping ([Book]) -> Void) {
    guard let flutterEngine = flutterEngine else {
        print("Flutter Engine is not available.")
        completion([])
        return
    }
    
    DispatchQueue.main.async {
        let api = BookFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
        api.fetchBooks { result in
            switch result {
            case .success(let books):
                print("Successfully fetched books from Flutter.")
                print("Fetched Books: \(books)")
                completion(books)
            case .failure(let error):
                print("Error calling Flutter fetchBooks method: \(error.localizedDescription)")
                completion([])
            }
        }
    }
}

以下では本の追加処理を実装しています。
fetchBooks メソッドでは返り値として本のリストを返していましたが、このメソッドでは返り値を持たず、Flutter 側で実装されている本の追加処理を実行するようにしています。

func addBook(book: Book) {
    guard let flutterEngine = flutterEngine else {
        print("Flutter Engine is not available.")
        return
    }
    
    DispatchQueue.main.async {
        let api = BookFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
        api.addBook(book: book) { result in
            switch result {
            case .success():
                print("Successfully called Flutter addBook method.")
            case .failure(let error):
                print("Error calling Flutter addBook method: \(error.localizedDescription)")
            }
        }
    }
}

以下では本の削除処理を実装しています。
このメソッドも返り値を持たず、 Swift 側でこのメソッドが発火すると Flutter 側に定義されている本の削除処理が実行され、Firestore から本のデータが削除されます。

func deleteBook(book: Book) {
    guard let flutterEngine = flutterEngine else {
        print("Flutter Engine is not available.")
        return
    }
    
    DispatchQueue.main.async {
        let api = BookFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
        api.deleteBook(book: book) { result in
            switch result {
            case .success():
                print("Successfully called Flutter deleteBook method.")
            case .failure(let error):
                print("Error calling Flutter deleteBook method: \(error.localizedDescription)")
            }
        }
    }
}

Record の実装に関しても同様になっているため割愛します。

Pigeon でのおかげで、基本的には生成された BookFlutterApi に用意されているメソッドを適切に呼び出してやることで Swift から Flutter のコードを呼び出すことができます。
これで iOS と Flutter の連携は完了です。

次に iOS と watchOS の連携を進めていきます。
iOS と watchOS は WCSessionDelegate で相互に通信を行います。
その WCSessionDelegate を管理するための WCSessionManager を作成します。

コードは以下の通りです。

ios/Runner/WCSessionManager.swift
import Foundation
import WatchConnectivity

class WCSessionManager: NSObject, WCSessionDelegate {
    
    static let shared = WCSessionManager()
    
    private override init() {
        super.init()
        setupWCSession()
    }
    
    private var session: WCSession {
        return WCSession.default
    }
    
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if let error = error {
            print("WCSession activation failed: \(error.localizedDescription)")
        } else {
            print("WCSession activated with state: \(activationState.rawValue)")
        }
    }
    
    func sessionDidBecomeInactive(_ session: WCSession) {
        print("session did become inactive")
    }
    
    func sessionDidDeactivate(_ session: WCSession) {
        print("session did deactivated")
    }
    
    private func setupWCSession() {
        if WCSession.isSupported() {
            session.delegate = self
            session.activate()
        }
    }
    
    func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
        if let action = message["action"] as? String {
            switch action {
            case "fetchBooks":
                print("iOS: Received fetchBooks action")
                DispatchQueue.main.async {
                    (UIApplication.shared.delegate as? BookAppDelegate)?.fetchBooks { books in
                        // データ取得後にreplyHandlerで結果を返す
                        let booksData = books.map { book in
                            return [
                                "id": book.id ?? "",
                                "title": book.title,
                                "publisher": book.publisher,
                                "imageUrl": book.imageUrl,
                                "lastModified": book.lastModified
                            ]
                        }
                        replyHandler(["books": booksData])
                    }
                }
                
            case "addBook":
                let title = message["title"] as? String ?? "None"
                let publisher = message["publisher"] as? String ?? "None"
                let imageUrl = message["imageUrl"] as? String ?? "None"
                let lastModified = message["lastModified"] as? Int ?? 0
                
                DispatchQueue.main.async {
                    (UIApplication.shared.delegate as? BookAppDelegate)?.addBook(book: Book(
                        title: title,
                        publisher: publisher,
                        imageUrl: imageUrl,
                        lastModified: Int64(lastModified)
                    ))
                }
                replyHandler(["reply" : "OK"])

            case "deleteBook":
                let id = message["id"] as? String ?? "None"
                let title = message["title"] as? String ?? "None"
                let publisher = message["publisher"] as? String ?? "None"
                let imageUrl = message["imageUrl"] as? String ?? "None"
                let lastModified = message["lastModified"] as? Int ?? 0
                DispatchQueue.main.async {
                    (UIApplication.shared.delegate as? BookAppDelegate)?.deleteBook(book: Book(
                        id: id,
                        title: title,
                        publisher: publisher,
                        imageUrl: imageUrl,
                        lastModified: Int64(lastModified)
                    ))
                }
                replyHandler(["reply" : "OK"])

            case "fetchRecords":
                print("iOS: Received fetchRecords action")
                DispatchQueue.main.async {
                    (UIApplication.shared.delegate as? BookAppDelegate)?.fetchRecords() { records in
                        let recordsData = records.map { record in
                            return [
                                "id": record.id ?? "",
                                "book": [
                                    "id": record.book.id ?? "",
                                    "title": record.book.title,
                                    "publisher": record.book.publisher,
                                    "imageUrl": record.book.imageUrl,
                                    "lastModified": record.book.lastModified
                                ],
                                "seconds": record.seconds,
                                "createdAt": record.createdAt,
                                "lastModified": record.lastModified
                            ]
                        }
                        replyHandler(["records": recordsData])
                    }
                }
                
            case "addRecord":
                if let bookDict = message["book"] as? [String: Any] {
                    let book = Book(
                        id: bookDict["id"] as? String ?? "",
                        title: bookDict["title"] as? String ?? "",
                        publisher: bookDict["publisher"] as? String ?? "",
                        imageUrl: bookDict["imageUrl"] as? String ?? "",
                        lastModified: bookDict["lastModified"] as? Int64 ?? 0
                    )
                    let seconds = message["seconds"] as? Int ?? 0
                    let createdAt = message["createdAt"] as? Int ?? 0
                    let lastModified = message["lastModified"] as? Int ?? 0
                    DispatchQueue.main.async {
                        (UIApplication.shared.delegate as? BookAppDelegate)?.addRecord(record: Record(
                            book: book,
                            seconds: Int64(seconds),
                            createdAt: Int64(createdAt),
                            lastModified: Int64(lastModified)
                        ))
                        replyHandler(["status": "success"])
                    }
                } else {
                    print("Error: Invalid book data")
                    replyHandler(["status": "failure", "error": "Invalid book data"])
                }

            case "deleteRecord":
                let book = message["book"] as? Book ?? Book(id: "id", title: "title", publisher: "publisher", imageUrl: "imageUrl", lastModified: 0)
                let seconds = message["seconds"] as? Int ?? 0
                let createdAt = message["createdAt"] as? Int ?? 0
                let lastModified = message["lastModified"] as? Int ?? 0
                DispatchQueue.main.async {
                    (UIApplication.shared.delegate as? BookAppDelegate)?.deleteRecord(record: Record(
                        book: book,
                        seconds: Int64(seconds),
                        createdAt: Int64(createdAt),
                        lastModified: Int64(lastModified)
                    ))
                }
                replyHandler(["reply" : "OK"])
                
            default:
                print("Unknown action: \(message)")
                replyHandler(["reply" : "OK"])
            }
        }
    }
}

上記のコードでやっていることは以下の6つです。

  • インスタンスの保持
  • 初期化処理
  • watchOS との通信のセッションの活性化が完了した際の処理
  • watchOS との通信のセッションが停止する際の処理
  • watchOS とのデータの通信が完了してセッションが停止する際の処理
  • watchOS からメッセージを受け取った際の処理

インスタンスの保持
以下では WCSessionManager のインスタンスを保持しています。
shared を参照することでこの Manager 以外からでもアクセスできるようになります。

static let shared = WCSessionManager()

初期化処理
以下では Manager の初期化処理を行なっています。 setupWCSession の中では delegate の設定とセッションの活性化を行なっています。初期化処理でセッションの活性化を行うことで watchOS とのデータの通信ができるようになります。

private override init() {
    super.init()
    setupWCSession()
}

watchOS との通信のセッションの活性化が完了した際の処理
以下ではセッションの活性化が完了した際の処理を実装しており、エラーがある場合はエラーを出力し、エラーがない場合はそのステータスを表示しています。

func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
    if let error = error {
        print("WCSession activation failed: \(error.localizedDescription)")
    } else {
        print("WCSession activated with state: \(activationState.rawValue)")
    }
}

watchOS との通信のセッションが停止する際の処理
以下では watchOS との通信のセッションが停止する際の処理を記述しており、セッションの停止がわかるように print しています。

func sessionDidBecomeInactive(_ session: WCSession) {
    print("session did become inactive")
}

watchOS とのデータの通信が完了してセッションが停止する際の処理
以下ではデータの通信が完了してセッションが停止する際の処理を記述しており、print でセッションの停止がわかるようにしています。

func sessionDidDeactivate(_ session: WCSession) {
    print("session did deactivated")
}

watchOS からメッセージを受け取った際の処理
この部分の処理が多く書かれているため分けて見ていきます。

didReceiveMessage という名前の通り、watchOS からメッセージを受け取った際の処理をここに記述します。メッセージを受け取った後に、そのメッセージの呼び出し元(今回は watchOS)に対してデータを返すための replyHandler も用意されています。

func session(
    _ session: WCSession,
    didReceiveMessage message: [String : Any],
    replyHandler: @escaping ([String : Any]) -> Void
) {

以下では watchOS から送られてきた message の中で action というデータを読み取り、その違いによって switch文で実行する処理を切り替えています。

action の値が fetchBooks の場合には先ほど実装した BookAppDelegatefetchBooks を実行し、返り値のデータを replyHandler で watchOS 側に返却しています。

振り返ってみると、 BookAppDelegate で実装した fetchBooks メソッドは Flutter 側に問い合わせて、Firesotre から本の一覧データを取得するものであったため、この実装により、 watchOS からの呼び出しに応じて Flutter のコードを実行して Firestore のデータを取得し、それを watchOS 側に渡すという流れができています。

if let action = message["action"] as? String {
    switch action {
    case "fetchBooks":
        print("iOS: Received fetchBooks action")
        DispatchQueue.main.async {
            (UIApplication.shared.delegate as? BookAppDelegate)?.fetchBooks { books in
                // データ取得後にreplyHandlerで結果を返す
                let booksData = books.map { book in
                    return [
                        "id": book.id ?? "",
                        "title": book.title,
                        "publisher": book.publisher,
                        "imageUrl": book.imageUrl,
                        "lastModified": book.lastModified
                    ]
                }
                replyHandler(["books": booksData])
            }
        }

以下では actionaddBook だった際の処理を記述しています。
fetchBooks の場合と同様で BookAppDelegate に定義してあるメソッドを実行しています。

watchOS からメッセージを送る際、そのメッセージは Book のようにカスタムの型で渡すことができないようなので、message に含まれるそれぞれのデータを取り出して、それを Book に変換して addBook メソッドに渡しています。

case "addBook":
    let title = message["title"] as? String ?? "None"
    let publisher = message["publisher"] as? String ?? "None"
    let imageUrl = message["imageUrl"] as? String ?? "None"
    let lastModified = message["lastModified"] as? Int ?? 0
    
    DispatchQueue.main.async {
        (UIApplication.shared.delegate as? BookAppDelegate)?.addBook(book: Book(
            title: title,
            publisher: publisher,
            imageUrl: imageUrl,
            lastModified: Int64(lastModified)
        ))
    }
    replyHandler(["reply" : "OK"])

以下では actiondeleteBook だった際の処理を記述しています。
上記二つのメソッドと同様で、 BookAppDelegate に定義されているメソッドを実行しています。
watchOS からこのメソッドの呼び出しがあれば BookAppDelegatedeleteBook メソッドが発火し、 Flutter 側の deleteBook メソッドが実行されて Firestore の本のデータが削除されます。

case "deleteBook":
    let id = message["id"] as? String ?? "None"
    let title = message["title"] as? String ?? "None"
    let publisher = message["publisher"] as? String ?? "None"
    let imageUrl = message["imageUrl"] as? String ?? "None"
    let lastModified = message["lastModified"] as? Int ?? 0
    DispatchQueue.main.async {
        (UIApplication.shared.delegate as? BookAppDelegate)?.deleteBook(book: Book(
            id: id,
            title: title,
            publisher: publisher,
            imageUrl: imageUrl,
            lastModified: Int64(lastModified)
        ))
    }
    replyHandler(["reply" : "OK"])

Record に関しても基本的に上記の実装と同様で、 action に以下の三つのどれかが割り当てられており、その値に応じて BookAppDelegate に定義されている関数を実行するようになっています。

  • fetchRecords
  • addRecord
  • deleteRecord

これで iOS 側の実装は完了です。

2. watchOS 側の実装

ここまでで、watchOS からの呼び出しに応じて Flutter のコードが実行されるまで実装できました。最後に watchOS 側から処理を呼び出す実装を行います。

watchOS の実装は以下の手順で進めていきます。

  1. モデルの定義
  2. iOS との通信を行う実装
  3. UIの実装

1. モデルの定義

watchOS のターゲットを作成した際、「MyWatch Watch App」のように Runner とは異なるディレクトリが作成されたかと思いますが、 watchOS の実装はこのディレクトリで行います。

iOS 側では Pigeon で生成した Book.g.swift というファイルがあり、そこに Flutter と共通化されたモデルや Flutter で呼び出したい処理がありました。しかし、それらは watchOS 側には共有されないため、同じものを watchOS 側でも定義する必要があります。

したがって、以下の二つのモデルを追加していきます。

Book

MyWatch Watch App/Book.swift
import Foundation

struct Book: Identifiable {
    var id: String
    var title: String
    var publisher: String
    var imageUrl: String
    var lastModified: Int64

    init?(dictionary: [String: Any]) {
        guard let id = dictionary["id"] as? String,
              let title = dictionary["title"] as? String,
              let publisher = dictionary["publisher"] as? String,
              let imageUrl = dictionary["imageUrl"] as? String,
              let lastModifiedValue = dictionary["lastModified"] as? Int64 else {
            print("Failed to parse Book from dictionary: \(dictionary)")
            return nil
        }

        self.id = id
        self.title = title
        self.publisher = publisher
        self.imageUrl = imageUrl
        self.lastModified = lastModifiedValue
    }
}

Record

MyWatch Watch App/Record.swift
import Foundation

struct Record: Identifiable {
    var id: String
    var book: Book
    var seconds: Int64
    var createdAt: Int64
    var lastModified: Int64

    init?(dictionary: [String: Any]) {
        guard let id = dictionary["id"] as? String,
              let bookData = dictionary["book"] as? [String: Any],
              let book = Book(dictionary: bookData),
              let seconds = dictionary["seconds"] as? Int64,
              let createdAt = dictionary["createdAt"] as? Int64,
              let lastModified = dictionary["lastModified"] as? Int64 else {
            return nil
        }

        self.id = id
        self.book = book
        self.seconds = seconds
        self.createdAt = createdAt
        self.lastModified = lastModified
    }
}

二つのモデルで定義されているそれぞれのプロパティや型は iOS 側の Book.g.swift で定義されているものと同じです。しかし、 watchOS 側で定義したモデルでは init の処理で [String: Any] 型の dictionary を受け取り、それを各モデルに変換する形にしています。

iOS と watchOS との通信で BookRecord といった独自の型が使用できないため、一度受け取ったデータを独自の型に変更するためにこのようにしています。

モデルの定義はこれで完了です。

2. iOS との通信を行う実装

次に iOS とデータのやり取りを行う部分の実装をしていきます。
iOS 側では実装した WCSessionManager で実装しましたが、 watchOS 側でも同じ名前で非常に似た実装になっています。

コードは以下の通りです。

MyWatch Watch App/WCSessionManager.swift
import Foundation
import WatchConnectivity

class WCSessionManager: NSObject, ObservableObject, WCSessionDelegate {
    static let shared = WCSessionManager()
    private override init() {
        super.init()
        setupWCSession()
    }
    
    @Published var books: [Book] = []
    @Published var records: [Record] = []
    
    private var session: WCSession {
        return WCSession.default
    }
    
    private func setupWCSession() {
        print("setupWCSession fired")
        if WCSession.isSupported() {
            session.delegate = self
            session.activate()
        }
    }
    
    func fetchBooks() {
        print("fetch books fired")
        if session.isReachable {
            let message = ["action": "fetchBooks"]
            session.sendMessage(message, replyHandler: { response in
                if let booksData = response["books"] as? [[String: Any]] {
                    let receivedBooks = booksData.compactMap { Book(dictionary: $0) }
                    DispatchQueue.main.async {
                        self.books = receivedBooks
                        print("Updated books: \(self.books)")
                    }
                } else {
                    print("No books found in response.")
                }
            }, errorHandler: { error in
                print("Error requesting books: \(error.localizedDescription)")
            })
        } else {
            print("WCSession is not reachable.")
        }
    }
    
    func fetchRecords() {
        print("fetch records fired")
        if session.isReachable {
            let message = ["action": "fetchRecords"]
            session.sendMessage(message, replyHandler: { response in
                if let recordsData = response["records"] as? [[String: Any]] {
                    print("recordsData: \(recordsData)")
                    let receivedRecords = recordsData.compactMap { Record(dictionary: $0) }
                    DispatchQueue.main.async {
                        self.records = receivedRecords
                        print("Updated records: \(self.records)")
                    }
                } else {
                    print("No records found in response")
                }
            }, errorHandler: { error in
                print("Error requesting records: \(error.localizedDescription)")
            })
        } else {
            print("WCSession is not reachable.")
        }
    }
    
    func addRecord(book: Book, seconds: Int) {
        print("add record fired")
        print("add record book: \(book)")
        if session.isReachable {
            let message: [String: Any] = [
                "action": "addRecord",
                "book": [
                    "id": book.id,
                    "title": book.title,
                    "publisher": book.publisher,
                    "imageUrl": book.imageUrl,
                    "lastModified": book.lastModified
                ],
                "seconds": seconds,
                "createdAt": Int(Date().timeIntervalSince1970),
                "lastModified": Int(Date().timeIntervalSince1970)
            ]
            
            session.sendMessage(message, replyHandler: { response in
                if let status = response["status"] as? [[String: Any]] {
                    print(status)
                }
            })
        }
    }
    
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if let error = error {
            print("WCSession activation failed: \(error.localizedDescription)")
        } else {
            print("WCSession activated with state: \(activationState.rawValue)")
        }
    }
    
    func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
        DispatchQueue.main.async {
            if let action = message["action"] as? String {
                switch action {
                case "fetchedBooks":
                    print("watchOS: Received fetchedBooks action")
                    if let booksData = message["books"] as? [[String: Any]] {
                        let receivedBooks = booksData.compactMap { Book(dictionary: $0) }
                        self.books = receivedBooks
                        print("Updated books: \(self.books)")
                    }
                case "fetchedRecords":
                    print("watchOS: Received fetchedRecords action")
                    if let recordsData = message["records"] as? [[String: Any]] {
                        let receivedRecords = recordsData.compactMap { Record(dictionary: $0) }
                        self.records = receivedRecords
                        print("Updated records: \(self.records)")
                    }
                default:
                    break
                }
            }
        }
    }
}

以下の部分は iOS 側の実装と同じで、 shared としてインスタンスを保持して、初期化処理で iOS 側とのセッションの確立を行なっています。
セッションが活性化することで iOS 側とのデータのやり取りが可能になります。

static let shared = WCSessionManager()
private override init() {
    super.init()
    setupWCSession()
}

以下では iOS 側に対して本や記録の一覧を取得する処理を呼び出して、返り値が返ってきた際に保存するための変数を定義しています。 @Published して、次の章で実装するUI側からデータを読み取れるようにしています。
本来であれば BookManager, RecordManager のように分割して管理した方が良いかと思います。

@Published var books: [Book] = []
@Published var records: [Record] = []

以下では fetchBooks という名前で、 iOS 側に対して本の一覧を取得するよう要求する関数を定義しています。
message として ["action": "fetchBooks"] を定義して、 sendMessage メソッドに渡しています。これで iOS 側にメッセージが送信されます。

iOS 側では action の値に応じて実行する処理を切り替えるような実装がありました。fetchBooks の場合は Flutter 側に本の一覧データの取得を要求するような実装になっていました。したがって、この watchOS 側の fetchBooks メソッドが発火すれば、Flutter から本の一覧データを取得することができ、その返り値が replyHandlerresponse に含まれています。取得した本の一覧データは Book 型に変換されて、リストとして books に格納されます。

func fetchBooks() {
    print("fetch books fired")
    if session.isReachable {
        let message = ["action": "fetchBooks"]
        session.sendMessage(message, replyHandler: { response in
            if let booksData = response["books"] as? [[String: Any]] {
                let receivedBooks = booksData.compactMap { Book(dictionary: $0) }
                DispatchQueue.main.async {
                    self.books = receivedBooks
                    print("Updated books: \(self.books)")
                }
            } else {
                print("No books found in response.")
            }
        }, errorHandler: { error in
            print("Error requesting books: \(error.localizedDescription)")
        })
    } else {
        print("WCSession is not reachable.")
    }
}

以下では、 fetchBooks と同様に、記録の一覧データを取得するよう iOS 側に対してメッセージを送信しています。replyHandlerresponse として返ってきたデータは Record 型に変換され、 records 変数にリストとして格納されます。

func fetchRecords() {
    print("fetch records fired")
    if session.isReachable {
        let message = ["action": "fetchRecords"]
        session.sendMessage(message, replyHandler: { response in
            if let recordsData = response["records"] as? [[String: Any]] {
                print("recordsData: \(recordsData)")
                let receivedRecords = recordsData.compactMap { Record(dictionary: $0) }
                DispatchQueue.main.async {
                    self.records = receivedRecords
                    print("Updated records: \(self.records)")
                }
            } else {
                print("No records found in response")
            }
        }, errorHandler: { error in
            print("Error requesting records: \(error.localizedDescription)")
        })
    } else {
        print("WCSession is not reachable.")
    }
}

以下では記録の追加を行うメソッドを定義しています。上記二つのメソッドと同様で、 iOS 側に記録の追加を要求するようになっています。必要なデータは message として定義され、記録をつけたい本のデータや勉強時間などのデータが含まれます。

func addRecord(book: Book, seconds: Int) {
    print("add record fired")
    print("add record book: \(book)")
    if session.isReachable {
        let message: [String: Any] = [
            "action": "addRecord",
            "book": [
                "id": book.id,
                "title": book.title,
                "publisher": book.publisher,
                "imageUrl": book.imageUrl,
                "lastModified": book.lastModified
            ],
            "seconds": seconds,
            "createdAt": Int(Date().timeIntervalSince1970),
            "lastModified": Int(Date().timeIntervalSince1970)
        ]
        
        session.sendMessage(message, replyHandler: { response in
            if let status = response["status"] as? [[String: Any]] {
                print(status)
            }
        })
    }
}

今回実装したのは上記の三つのメソッドのみですが、watchOS から本を追加や削除ができるように追加実装して見ても良いと思います。

3. UIの実装

最後に watchOS 側のUIの実装を行います。
実装するのは以下の4つの画面です。

  1. 本の一覧画面
  2. 本の詳細画面
  3. 記録の一覧画面
  4. タブ画面

それぞれコードは以下の通りです。

本の一覧画面
取得した結果が空の場合はボタンを表示させて、sessionManager.fetchBooks() で本の一覧を取得しています。
また、refreshable にも同様の処理を割り当てていますが、watchOS では動作しない場合があるようなので、別の実装で実現する必要があるかもしれません。

MyWatch Watch App/BookListView.swift
import SwiftUI

struct BookListView: View {
    @ObservedObject var sessionManager = WCSessionManager.shared

    var body: some View {
        NavigationStack {
            if (sessionManager.books.isEmpty) {
                Button(action: {
                    sessionManager.fetchBooks()
                }, label: {
                    Image(systemName: "arrow.trianglehead.clockwise")
                })
            }
            List(sessionManager.books) { book in
                NavigationLink(destination: BookDetailView(book: book)) {
                    BookRow(book: book)
                }
            }
            .refreshable {
                print("refresh books fired")
                sessionManager.fetchBooks()
            }
            .navigationTitle("Books")
        }
    }
}

struct BookRow: View {
    let book: Book
    
    var body: some View {
        HStack {
            AsyncImage(url: URL(string: book.imageUrl)) { image in
                image.resizable()
            } placeholder: {
                Image(systemName: "book.closed.fill")
            }
            .frame(width: 30, height: 45)
            .cornerRadius(5)
            
            VStack(alignment: .leading) {
                Text(book.title)
                    .font(.headline)
                Text(book.publisher)
                    .font(.subheadline)
                    .foregroundStyle(Color.secondary)
            }
            .padding()
        }
    }
}

本の詳細画面
以下では選択した本の詳細データを表示し、タイマーで勉強時間を計測して保存できるようにしています。

MyWatch Watch App/BookDetailView.swift
import SwiftUI

struct BookDetailView: View {
    let book: Book
    @ObservedObject var sessionManager = WCSessionManager.shared
    
    @State private var seconds: Int = 0
    @State private var isStudying: Bool = false
    @State private var timer: Timer?
    
    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 10) {
                HStack {
                    AsyncImage(url: URL(string: book.imageUrl)) { image in
                        image.resizable()
                    } placeholder: {
                        ProgressView()
                    }
                    .frame(
                        width: geometry.size.width * 0.2,
                        height: geometry.size.width * 0.2 * 1.5
                    )
                    .cornerRadius(5)
                    
                    Text(book.title)
                        .lineLimit(2)
                        .fixedSize(horizontal: false, vertical: true)
                        .font(.headline)
                        .multilineTextAlignment(.leading)
                        .padding()
                }
                
                Text(formatTime(seconds: seconds))
                    .font(.title2)
                    .bold()
                
                HStack {
                    Button(action: {
                        isStudying.toggle()
                        if isStudying {
                            startTimer()
                        } else {
                            stopTimer()
                        }
                    }) {
                        Text(isStudying ? "Stop" : "Start")
                            .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(StudyButtonStyle(isStudying: isStudying))
                    
                    Button(action: {
                        saveRecord()
                    }) {
                        Text("Save")
                            .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(SaveButtonStyle(isDisabled: seconds == 0))
                    .disabled(seconds == 0)
                }
                .padding()
            }
            .padding()
            .navigationTitle("Studying")
        }
    }
    
    private func startTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            seconds += 1
        }
    }
    
    private func stopTimer() {
        timer?.invalidate()
        timer = nil
    }
    
    private func saveRecord() {
        sessionManager.addRecord(book: book, seconds: seconds)
        stopTimer()
        seconds = 0
        isStudying = false
    }
    
    private func formatTime(seconds: Int) -> String {
        let minutes = seconds / 60
        let seconds = seconds % 60
        return String(format: "%02d:%02d", minutes, seconds)
    }
}

struct StudyButtonStyle: ButtonStyle {
    var isStudying: Bool = false

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .background(isStudying ? Color.red : Color.blue)
            .foregroundColor(.white)
            .cornerRadius(8)
            .scaleEffect(configuration.isPressed ? 0.9 : 1.0)
    }
}

struct SaveButtonStyle: ButtonStyle {
    var isDisabled: Bool = false

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .background(isDisabled ? Color.gray : Color.blue)
            .foregroundColor(.white)
            .cornerRadius(8)
            .scaleEffect(configuration.isPressed ? 0.9 : 1.0)
    }
}

記録の一覧画面
以下では sessionManager.fetchRecords で記録の一覧を取得して表示しています。

MyWatch Watch App/RecordListView.swift
import SwiftUI

struct RecordListView: View {
    @ObservedObject var sessionManager = WCSessionManager.shared
    
    var body: some View {
        NavigationStack {
            if(sessionManager.records.isEmpty) {
                Button(action: {
                    sessionManager.fetchRecords()
                }, label: {
                    Image(systemName: "arrow.trianglehead.clockwise")
                })
            }
            List(sessionManager.records) { record in
                RecordRow(record: record)
            }
            .refreshable {
                print("refresh records fired")
                sessionManager.fetchRecords()
            }
            .navigationTitle("Record")
        }
    }
}

struct RecordRow: View {
    let record: Record
    
    var body: some View {
        let imageUrl = URL(string: record.book.imageUrl)
        HStack {
            AsyncImage(url: imageUrl) { image in
                image.resizable()
            } placeholder: {
                Image(systemName: "book.closed.fill")
            }
            .frame(width: 30, height: 45)
            .cornerRadius(5)
            VStack(alignment: .leading, spacing: 8) {
                Text(record.book.title)
                    .font(.headline)
            }
            Spacer()
            Text(formatTime(seconds: Int(record.seconds)))
                .font(.title3)
                .fontWeight(.bold)
        }
    }
    private func formatTime(seconds totalSeconds: Int) -> String {
        let hours = totalSeconds / 3600
        let minutes = (totalSeconds % 3600) / 60
        let seconds = totalSeconds % 60
        
        var components = [String]()
        
        if hours > 0 {
            components.append("\(hours)")
        }
        
        if minutes > 0 {
            // hoursが0の場合はゼロ埋めしない
            let minuteString = hours > 0 ? String(format: "%02d", minutes) : "\(minutes)"
            components.append(minuteString)
        }
        
        if seconds > 0 {
            // hoursまたはminutesが0より大きい場合はゼロ埋め
            let secondString = (hours > 0 || minutes > 0) ? String(format: "%02d", seconds) : "\(seconds)"
            components.append(secondString)
        }
        
        // すべての値が0の場合は"0"を返す
        if components.isEmpty {
            return "0"
        } else {
            return components.joined(separator: " : ")
        }
    }
}

タブ画面
以下では、本の一覧画面と記録の一覧画面をタブで切り替えられるように TabView を実装しています。

MyWatch Watch App/HomeTabView.swift
import SwiftUI

struct HomeTabView: View {
    var body: some View {
        TabView {
            BookListView()
                .tabItem {
                    Text("Book List")
                }.tag(1)
            RecordListView()
                .tabItem {
                    Text("Record List")
                }
                .tag(2)
        }
    }
}

この TabView を watchOS のホーム画面に設定すれば実装は完了です。
これで実行すると以下の動画のように Apple Watch で保存した記録が iOS のアプリと Firestore の両方で確認できるかと思います。

https://youtu.be/c58yLZFo-Ew

まとめ

最後まで読んでいただいてありがとうございました。

非常に長い記事になってしまいましたが、「Flutter アプリの機能を Apple Watch に切り出す」という需要は少なからずあるのではないかと思います。

「Flutter と iOS」 や 「iOS と watchOS」 の連携のみであれば、参考にできる資料が比較的多くあったため実装できました。しかし今回の Flutter と watchOS の連携は、実装例が少なかったり、実装している最中にどの部分を実装しているのか混乱したりして時間がかかりました。

これから実装する方の参考に少しでもなれば嬉しいです。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://cheesecakelabs.com/blog/flutter-apps-apple-watch-integration/

https://github.com/LdrPontes/flutter-pigeon-applewatch

https://noifuji.hateblo.jp/entry/2023/05/10/131451

Discussion