5. Firestore を用いたリアルタイム対戦機能
チャレンジ企画です。
5-1. Firestoreに繋ぐ準備をする
プロジェクトルートで以下のコマンドを実行し、必要なライブラリを取得しましょう。
flutter pub add firebase_core
flutter pub add cloud_firestoreflutter pub add firebase_core
flutter pub add cloud_firestorefirebase_options.dart をLibに追加します。
// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for web - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyCCWnRieFsedimIvgsjNevAdUSb0dm1faY',
appId: '1:807943088200:android:d989c8a1b020562321907d',
messagingSenderId: '807943088200',
projectId: 'tic-tac-toe-handson',
storageBucket: 'tic-tac-toe-handson.appspot.com',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyBZHELrAr4SRfXLYrxc4zF27P0R3sGsbi0',
appId: '1:807943088200:ios:125d7bfaa751582d21907d',
messagingSenderId: '807943088200',
projectId: 'tic-tac-toe-handson',
storageBucket: 'tic-tac-toe-handson.appspot.com',
iosBundleId: 'com.example.ticTacToeHandson',
);
}// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for web - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyCCWnRieFsedimIvgsjNevAdUSb0dm1faY',
appId: '1:807943088200:android:d989c8a1b020562321907d',
messagingSenderId: '807943088200',
projectId: 'tic-tac-toe-handson',
storageBucket: 'tic-tac-toe-handson.appspot.com',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyBZHELrAr4SRfXLYrxc4zF27P0R3sGsbi0',
appId: '1:807943088200:ios:125d7bfaa751582d21907d',
messagingSenderId: '807943088200',
projectId: 'tic-tac-toe-handson',
storageBucket: 'tic-tac-toe-handson.appspot.com',
iosBundleId: 'com.example.ticTacToeHandson',
);
}次に main.dart を修正します。
// importを追加
import 'package:firebase_core/firebase_core.dart';
import 'package:tic_tac_toe_handson/firebase_options.dart';
// asyncに修正
void main() async {
// 追加
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// 略
}// importを追加
import 'package:firebase_core/firebase_core.dart';
import 'package:tic_tac_toe_handson/firebase_options.dart';
// asyncに修正
void main() async {
// 追加
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// 略
}5-2. ビルド準備を進める
5-2-1. Androidでのビルド準備を進める
android/build.gradle に以下を追記します。
classpath 'com.google.gms:google-services:4.3.10'classpath 'com.google.gms:google-services:4.3.10'android/app/build.gradle に以下を追記します。
apply plugin: 'com.google.gms.google-services'apply plugin: 'com.google.gms.google-services'またdefaultConfigの中に記載がない場合、以下も追記します。
multiDexEnabled truemultiDexEnabled trueGitHub Discussions から google-services.json を取得し、android/appに追加します。
5-2-2. iOSでのビルド準備を進める
iOSフォルダをXcodeで開いたのちに、RunnerにGitHub Discussions で取得した GoogleService-Info.plist を追加します。
このとき、「Copy items if needed」にチェックを入れて追加してください。

これで基本的な準備は完了!
ハンズオン用に手動でしましたが、FlutterFireを使用することでコマンドで簡単にできます。
5-3. 実装を進める
5-3-1. modelにjsonコンバートメソッドを追加する
lib/model/tic_tac_toe.json のTicTacToeクラス内に以下を追加します。
freezed を使用することで、jsonコンバートはコマンド1発で作成可能ですが、ここでは自作してみましょう。
factory TicTacToe.fromJson(Map<String, dynamic> json) {
final flatBoard = List<String>.from(json['board']);
return TicTacToe(
// Firestore側を1次元配列にしているので、モデルの2次元配列とここで合わせる
[
List<String>.from(flatBoard.sublist(0, 3)),
List<String>.from(flatBoard.sublist(3, 6)),
List<String>.from(flatBoard.sublist(6, 9)),
],
Players(
playerX: json['players']['playerX'],
playerO: json['players']['playerO'],
),
json['currentPlayer'],
);
}
Map<String, dynamic> toJson() {
return {
// モデルが2次元配列なので、Firestore側の1次元配列にここで合わせる
'board': [...board[0], ...board[1], ...board[2]],
'players': {
'playerX': players.playerX,
'playerO': players.playerO,
},
'currentPlayer': currentPlayer,
};
}factory TicTacToe.fromJson(Map<String, dynamic> json) {
final flatBoard = List<String>.from(json['board']);
return TicTacToe(
// Firestore側を1次元配列にしているので、モデルの2次元配列とここで合わせる
[
List<String>.from(flatBoard.sublist(0, 3)),
List<String>.from(flatBoard.sublist(3, 6)),
List<String>.from(flatBoard.sublist(6, 9)),
],
Players(
playerX: json['players']['playerX'],
playerO: json['players']['playerO'],
),
json['currentPlayer'],
);
}
Map<String, dynamic> toJson() {
return {
// モデルが2次元配列なので、Firestore側の1次元配列にここで合わせる
'board': [...board[0], ...board[1], ...board[2]],
'players': {
'playerX': players.playerX,
'playerO': players.playerO,
},
'currentPlayer': currentPlayer,
};
}5-3-2. クラスを作成する
まずは、新しいファイルを作りましょう。 lib/repository/tic_tac_toe_repository.dart
続いて、クラスを作成します。
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:tic_tac_toe_handson/model/tic_tac_toe.dart';
/// 盤面のデータを管理するリポジトリ
final class TicTacToeRepository {
TicTacToeRepository();
// Firestoreインスタンス
final _client = FirebaseFirestore.instance;
// Firestoreのコレクション先
static const _collectionKey = 'tic_tac_toe';
// 対戦状況を保存するドキュメント先
String _documentKey(String playerX, String playerO) {
return '${playerX}_$playerO';
}
// jsonコンバート
CollectionReference<TicTacToe> _colRef() =>
_client.collection(_collectionKey).withConverter(
fromFirestore: (doc, _) => TicTacToe.fromJson(doc.data()!),
toFirestore: (entity, _) => entity.toJson(),
);
}import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:tic_tac_toe_handson/model/tic_tac_toe.dart';
/// 盤面のデータを管理するリポジトリ
final class TicTacToeRepository {
TicTacToeRepository();
// Firestoreインスタンス
final _client = FirebaseFirestore.instance;
// Firestoreのコレクション先
static const _collectionKey = 'tic_tac_toe';
// 対戦状況を保存するドキュメント先
String _documentKey(String playerX, String playerO) {
return '${playerX}_$playerO';
}
// jsonコンバート
CollectionReference<TicTacToe> _colRef() =>
_client.collection(_collectionKey).withConverter(
fromFirestore: (doc, _) => TicTacToe.fromJson(doc.data()!),
toFirestore: (entity, _) => entity.toJson(),
);
}5-3-3. getメソッドを追加する
リポジトリのクラスにFirestoreからデータを取得するメソッドを記載しましょう。
/// 盤面のデータを取得する
Stream<TicTacToe> get({
String playerX = 'X',
String playerO = 'O',
}) {
// ドキュメント名に変換する
final documentKey = _documentKey(playerX, playerO);
// スナップショットを取得し、モデルへ変換する
// データがない場合、モデルの初期状態を返す
return _colRef().doc(documentKey).snapshots().map(
(e) =>
e.data() ??
TicTacToe.start(
playerX: playerX,
playerO: playerO,
),
);
}/// 盤面のデータを取得する
Stream<TicTacToe> get({
String playerX = 'X',
String playerO = 'O',
}) {
// ドキュメント名に変換する
final documentKey = _documentKey(playerX, playerO);
// スナップショットを取得し、モデルへ変換する
// データがない場合、モデルの初期状態を返す
return _colRef().doc(documentKey).snapshots().map(
(e) =>
e.data() ??
TicTacToe.start(
playerX: playerX,
playerO: playerO,
),
);
}5-3-4. updateメソッドを追加する
リポジトリのクラスにFirestoreへデータを保存するメソッドを記載しましょう。
/// 盤面のデータを更新する
Future<void> update(TicTacToe ticTacToe) async {
// ドキュメント名に変換する
final documentKey =
_documentKey(ticTacToe.players.playerX, ticTacToe.players.playerO);
// モデルをjsonに変換し、firestoreへ保存する
await _colRef().doc(documentKey).set(ticTacToe);
}/// 盤面のデータを更新する
Future<void> update(TicTacToe ticTacToe) async {
// ドキュメント名に変換する
final documentKey =
_documentKey(ticTacToe.players.playerX, ticTacToe.players.playerO);
// モデルをjsonに変換し、firestoreへ保存する
await _colRef().doc(documentKey).set(ticTacToe);
}5-3-5. リポジトリをProvider化する
リポジトリのファイルに以下を追加します。
この後、getとupdateをそれぞれProvider化する際に使用します。
import 'package:flutter_riverpod/flutter_riverpod.dart';
final ticTacToeRepositoryProvider = AutoDisposeProvider<TicTacToeRepository>(
(ref) => TicTacToeRepository(),
);import 'package:flutter_riverpod/flutter_riverpod.dart';
final ticTacToeRepositoryProvider = AutoDisposeProvider<TicTacToeRepository>(
(ref) => TicTacToeRepository(),
);5-3-6. データを取得するProviderを作成する
新しいファイルを作りましょう。lib/provider/get_tic_tac_toe_provider.dart
以下を記載してください。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tic_tac_toe_handson/model/tic_tac_toe.dart';
import 'package:tic_tac_toe_handson/repository/tic_tac_toe_repository.dart';
final getTicTacToeProvider = AutoDisposeStreamProvider<TicTacToe>(
(ref) =>
// 対戦相手同士のIDを設定する(プレイヤー名は後ほど変更します)
ref.watch(ticTacToeRepositoryProvider).get(
playerX: 'Dash',
playerO: 'Sparky',
),
);import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tic_tac_toe_handson/model/tic_tac_toe.dart';
import 'package:tic_tac_toe_handson/repository/tic_tac_toe_repository.dart';
final getTicTacToeProvider = AutoDisposeStreamProvider<TicTacToe>(
(ref) =>
// 対戦相手同士のIDを設定する(プレイヤー名は後ほど変更します)
ref.watch(ticTacToeRepositoryProvider).get(
playerX: 'Dash',
playerO: 'Sparky',
),
);ticTacToeRepositoryProvider を使用しています。
RiverpodではこのようにProviderの中で別のProviderを組み合わせることが可能です。
FirestoreはWebSocketが基盤になっているため、リアルタイムでデータを送受信することが可能です。
その利点を活かして、今回はStreamでデータを取得するようにします。
5-3-7. データを保存するProviderを作成する
新しいファイルを作りましょう。lib/provider/update_tic_tac_toe_provider.dart
以下を記載してください。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tic_tac_toe_handson/model/tic_tac_toe.dart';
import 'package:tic_tac_toe_handson/repository/tic_tac_toe_repository.dart';
final updateTicTacToeProvider =
AutoDisposeFutureProviderFamily<void, TicTacToe>(
(ref, arg) => ref.watch(ticTacToeRepositoryProvider).update(arg),
);import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tic_tac_toe_handson/model/tic_tac_toe.dart';
import 'package:tic_tac_toe_handson/repository/tic_tac_toe_repository.dart';
final updateTicTacToeProvider =
AutoDisposeFutureProviderFamily<void, TicTacToe>(
(ref, arg) => ref.watch(ticTacToeRepositoryProvider).update(arg),
);引数を与えて、何かを実施したい場合、Family を付与することで実現できます。
今回は盤面の情報を引数にして渡したいので、使用しています。
ここでも ticTacToeRepositoryProvider を使用しています。ticTacToeRepositoryProvider を使用することでget用のProviderと同一の TicTacToeRepository クラスのインスタンスを参照することができます。
余談ですが、今回の場合は AsyncNotifier を使用することもできます。
色々な種類のProviderを使用したいという思いがあり、ハンズオンではこの形式にしました。
5-3-8. 作成したProviderをWidgetで使用する
getとupdateをそれぞれProviderにしたため、そちらをWidgetで使用しましょう。 lib/view/board.dart を修正します。
まずは参照の追加です。
import 'package:tic_tac_toe_handson/provider/get_tic_tac_toe_provider.dart';
import 'package:tic_tac_toe_handson/provider/update_tic_tac_toe_provider.dart';import 'package:tic_tac_toe_handson/provider/get_tic_tac_toe_provider.dart';
import 'package:tic_tac_toe_handson/provider/update_tic_tac_toe_provider.dart';次に使用するProviderを変更しましょう。
// final ticTacToe = ref.watch(ticTacToeProvider);
final ticTacToeStream = ref.watch(getTicTacToeProvider);// final ticTacToe = ref.watch(ticTacToeProvider);
final ticTacToeStream = ref.watch(getTicTacToeProvider);今、returnしているPaddingを以下のコードで囲みます。
return ticTacToeStream.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, __) => Center(child: Text('エラーが発生しました: ${error.toString()}')),
data: (ticTacToe) {
return Padding(
// 略
);
},
);return ticTacToeStream.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, __) => Center(child: Text('エラーが発生しました: ${error.toString()}')),
data: (ticTacToe) {
return Padding(
// 略
);
},
);Riverpodを使用すると AsyncValue を返却するProviderでは、このように when を使用することが可能です。
loading はデータがローディングの際に実施したい処理とWidgetを記載します。error はデータがエラーの際に実施したい処理とWidgetを記載します。data はデータが取得できた際に実施したい処理とWidgetを記載します。
このように非同期処理の内容をWidgetで簡単に取り扱うことが可能です。
では、最後にupdate用のProviderもそれぞれ変更しましょう。
// ref.read(ticTacToeProvider.notifier).state = ticTacToe.placeMark(row, col);
ref.read(updateTicTacToeProvider(ticTacToe.placeMark(row, col)),);// ref.read(ticTacToeProvider.notifier).state = ticTacToe.placeMark(row, col);
ref.read(updateTicTacToeProvider(ticTacToe.placeMark(row, col)),);// ref.read(ticTacToeProvider.notifier).state = ticTacToe.resetBoard();
ref.read(updateTicTacToeProvider(ticTacToe.resetBoard()),);// ref.read(ticTacToeProvider.notifier).state = ticTacToe.resetBoard();
ref.read(updateTicTacToeProvider(ticTacToe.resetBoard()),);これで準備は完了です!
5-4. リアルタイムでデームをプレイする
GithubDiscussions に対戦相手募集中のスレッドを用意しております。
対戦を待つ場合は、そちらに自身のプレイヤー名を記載してください。
対戦を申し込む場合は、返信形式でプレイヤー名を記載してください。
対戦相手が決まったら、get_tic_tac_toe_provider.dart を以下のように修正してください。
final getTicTacToeProvider = AutoDisposeStreamProvider<TicTacToe>(
(ref) =>
ref.watch(ticTacToeRepositoryProvider).get(
playerX: '申し込まれたプレイヤー名',
playerO: '申し込んだプレイヤー名',
),
);final getTicTacToeProvider = AutoDisposeStreamProvider<TicTacToe>(
(ref) =>
ref.watch(ticTacToeRepositoryProvider).get(
playerX: '申し込まれたプレイヤー名',
playerO: '申し込んだプレイヤー名',
),
);それでは遊んでみてください。
iOSでビルドした際に、Podfileに以下のコマンドが記載されている場合、コメントアウトすることで実行できます。
# target 'RunnerTests' do
# inherit! :search_paths
# end# target 'RunnerTests' do
# inherit! :search_paths
# endコントリビューター
