Cloud FunctionsでFlutter AppにFCM Push通知を送る手順を解説

こんにちは、株式会社Pentagonでエンジニアをしているtsurumiです。
今回の記事はFirebase Cloud FunctionsでFlutterで作成したアプリについて、FCM(Firebase Cloud Message)で個別の端末へのPush通知を送る手順を解説してます。

【こんな人に読んで欲しい】

Flutterでアプリを作っていてPush通知の機能を実装したいと考えている方

【この記事を読むメリット】

Flutterでのpush通知の導入方法がわかる
Cloud Functionsでの簡単な実装方法がわかる

【結論】

今回のサンプルアプリはユーザー一覧から好きなユーザーをタップすると、相手にpush通知が届くという非常にシンプルなものになっています。
この記事を読んでカスタマイズしていただくと、例えばチャットを送ったタイミングでFunctionsを発火してメッセージ内容を送れます。
また、iosのpush通知は証明書周りの設定がややこしいので、今回はAndroidのみでpush通知を検証しています。ご了承下さい。

目次

Firebaseのプロジェクトを作成する

まず、Firebase consoleから新しいプロジェクトを作成しましょう。
Functionsを使用する場合はSpeak(無料)プランでは使用できないので、FirebaseのプランをSpeak(無料)からBlaze(従量制)へ変更しましょう。

Functionsの実装

Firebase cliをインストールする

npm install -g firebase-tools

Firebase にログインする

firebase login

ログインが完了したら先ほど作成したプロジェクトIDを確認しましょう

firebase projects:list

確認ができたら

firebase use (Project ID)

選択ができたら

firebase init

を実行します。

実行するとfirebaseのサービスのどのサービスを使用するのかを聞かれます。
今回は Functionsとemulatorsを選択します。

言語は JavaScriptTypeScriptを選択できますが、今回はTypeScriptを選択しましょう。

これらのコマンドを実行すると以下のようなファイル構造のものが作られます。

src/index.tsを開いてみましょう。

export const helloWorld = functions.https.onRequest((request, response) => {
     functions.logger.info("Hello logs!", {structuredData: true});
     response.send("Hello from Firebase!");
   });

上記のようなコードがあると思います。
まずは、この関数をローカルサーバーを立てて実行してみましょう

cd functions
npm run serve

ブラウザでhttp://localhost:4001/functions にアクセスできるようになります。
開くと下記のようなログがあると思います。

http://localhost:5001/{projectid}/asia-northeast1/helloWorld にアクセスしてみましょう。

すると、

が表示されることが確認できると思います。

次に、push通知を送る関数を作成します。

const pushMessage = (fcmToken: string, text: string) => ({
 notification: {
   title: "テスト",
   body: ${text},
 },
 token: fcmToken,
});

export const sendPushNotification =
 functions
     .https.onRequest(async (request, response) => {
       console.log(request.body);
       const token: string = request.body["data"]["token"];
       const res = await admin.messaging()
           .send(pushMessage(token, "テストテストテスト"));
       console.log(res);

       response.status(200).send(
           {
             "status": 200,
             "data": "ok!",
           }
       );
     });

上記のコードで完成です。

今回のpush通知はfcm tokenを各ユーザーのコレクションに保存しているので、そのtokenを使用して送るようなものになっています。

次にデプロイしましょう。
package.jsonのscriptの中に

“scripts”: {
  “deploy”: “tsc && firebase deploy --only functions:sendPushNotification”
}

を追加して

npm run deploy

を実行しましょう。
すると、firebase consoleのFunctionsを開くと

が確認できると思います。
これが確認できたら、問題ありません!

Flutter側の実装

今回、Flutterで使用するパッケージは以下のものです。

 hooks_riverpod: ^0.14.0+4
 flutter_hooks: ^0.17.0
 freezed_annotation: ^0.14.2
 firebase_analytics: ^8.3.1
 firebase_auth: ^3.1.0
 firebase_core: ^1.8.0
 firebase_messaging: ^10.0.9
 flamingo:
 flamingo_annotation:
 cloud_functions: ^3.0.5
 flutter_local_notifications: ^9.0.1
 flutter_app_badger: ^1.3.0
 rxdart: ^0.27.2
 freezed: ^0.14.2
 json_serializable:
 flamingo_generator:

次にfirestoreを使うので、Userコレクションのドキュメントのモデル定義をしていきましょう。
今回はflamingoというライブラリを使用していきます。

user.dart

import 'package:flamingo/flamingo.dart';
import 'package:flamingo_annotation/flamingo_annotation.dart';

part 'user.flamingo.dart';

class User extends Document<User> {
 User({
   String? id,
   DocumentSnapshot<Map<String, dynamic>>? snapshot,
   Map<String, dynamic>? values,
 }) : super(id: id, snapshot: snapshot, values: values);

 @Field()
 String? name;

 @Field()
 String? fcmToken;

 @override
 Map<String, dynamic> toData() => _$toData(this);

 @override
 void fromData(Map<String, dynamic> data) => _$fromData(this, data);
}

次にサインアップ・ログイン画面をFirebase authを活用して作成しましょう。

作成したら下記のコードを追加しましょう。

user_notifier.dart


@freezed
class UserState with _$UserState {
 const factory UserState({
   User? user,
   @Default(<User>[]) List<User> userList,
 }) = _UserState;
}

class UserNotifier extends StateNotifier<UserState> {
 UserNotifier(this._read) : super(const UserState());

 Future<void> init() async {
   await fetchUser();
   await updateUserFcm();
   await fetchUsers();
   logger!.info('user notifier done');
 }

 final Reader _read;

 DocumentAccessorRepository get documentAccessorRepository =>
     _read(documentAccessorProvider);
 AuthRepository get authRepository => _read(authServiceProvider);
 FirebaseMessagingRepository get firebaseMessagingRepository =>
     _read(firebaseMessagingProvider);
 FirebaseFunctionsRepository get firebaseFunctionsRepository =>
     _read(firebaseFunctionsRepositoryProvider);

 Future<void> fetchUser() async {
   final currentUser = authRepository.currentUser;
   if (currentUser == null) {
     return;
   }
   final user =
       await documentAccessorRepository.load<User>(User(id: currentUser.uid));
   state = state.copyWith(user: user);
 }

 Future<void> updateUserFcm() async {
   final token = await firebaseMessagingRepository.getToken();
   if (state.user == null) {
     return;
   }
   final updateUser = state.user!..fcmToken = token;
   await documentAccessorRepository.update(updateUser);
 }

 Future<void> createUser(String uid) async {
   final token = await firebaseMessagingRepository.getToken();
   final user = User(id: uid)
     ..name = ''
     ..fcmToken = token;

   await documentAccessorRepository.save(user);
 }

 Future<void> fetchUsers() async {
   if (state.user == null) {
     return;
   }
   final collectionPaging = CollectionPaging<User>(
     query: User().collectionRef,
     decode: (snap) => User(snapshot: snap),
   );

   final users = await collectionPaging.load();
   users.removeWhere((ele) => ele.id == state.user!.id);

   state = state.copyWith(userList: users);
 }

 Future<void> pushNotification(String fcmToken) async {
   await firebaseFunctionsRepository.sendNotification(fcmToken);
 }
}

document_accessor_data_source.dart

import 'package:flamingo/flamingo.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final documentAccessorProvider = Provider<DocumentAccessorRepository>((ref) {
 return DocumentAccessor();
});

auth_data_source.dart

final authServiceProvider = Provider<AuthRepository>((ref) {
 return AuthService();
});

/// ユーザ
class AuthService extends AuthRepository {
 final _auth = FirebaseAuth.instance;

 final _user = PublishSubject<User>();
 StreamSubscription<User?>? _authStateDisposer;

 @override
 Stream<User> get onAuthStateChanged => _user;

 @override
 void fetchAuthStateChanges() {
   _authStateDisposer ??= _auth.authStateChanges().listen((User? user) async {
     if (user != null) {
       _user.add(user);
     }
   });
 }

 @override
 Future<void> dispose() async {
   await _user.close();
   await _authStateDisposer?.cancel();
 }

 @override
 User? get currentUser => _auth.currentUser;

 @override
 String? get loggedInUserId => _auth.currentUser!.uid;

 @override
 bool get isLoggedIn => _auth.currentUser != null;

 @override
 LoginType loginType({User? user}) {
   if (user != null) {
     return _loginType(user);
   } else {
     return _loginType(_auth.currentUser);
   }
 }

 @override
 Future<UserCredential> signInWithAnonymously() => _auth.signInAnonymously();

 @override
 AuthCredential emailAuthProviderCredential(String email, String password) {
   return EmailAuthProvider.credential(email: email, password: password);
 }

 @override
 Future<UserCredential> createAccount(String email, String password) async {
   final userCredential = await _auth.createUserWithEmailAndPassword(
       email: email, password: password);
   return userCredential;
 }

 @override
 Future<UserCredential> linkWithCredential(AuthCredential credential) async {
   return currentUser!.linkWithCredential(credential);
 }

 @override
 Future<UserCredential> signInEmail(String email, String password) async {
   final userCredential = await _auth.signInWithEmailAndPassword(
       email: email, password: password);
   return userCredential;
 }

 @override
 Future<void> sendVerificationEmail() async {
   if (currentUser != null) {
     await currentUser?.sendEmailVerification();
   }
 }

 @override
 Future<void> passwordReset(String email) async {
   await _auth.sendPasswordResetEmail(email: email);
 }

 @override
 Future<void> signOut() async {
   await _auth.signOut();
 }

 @override
 Future<void> userDelete(User user) async {
   await user.delete();
 }

 LoginType _loginType(User? user) {
   if (user == null) {
     return LoginType.invalid;
   }
   if (user.isAnonymous) {
     return LoginType.anonymously;
   } else if (!user.isAnonymous &&
       user.email != null &&
       user.email!.isNotEmpty) {
     return LoginType.email;
   } else {
     return LoginType.invalid;
   }
 }

 @override
 FirebaseAuthResultStatus handleException(FirebaseAuthException e) {
   switch (e.code) {
     case emailAlreadyInUse:
       return FirebaseAuthResultStatus.emailAlreadyInUse;
     case invalidEmail:
       return FirebaseAuthResultStatus.invalidEmail;
     case operationNotAllowed:
       return FirebaseAuthResultStatus.operationNotAllowed;
     case weakPassword:
       return FirebaseAuthResultStatus.weakPassword;
     case userDisabled:
       return FirebaseAuthResultStatus.userDisabled;
     case userNotFound:
       return FirebaseAuthResultStatus.userNotFound;
     case wrongPassword:
       return FirebaseAuthResultStatus.wrongPassword;
     default:
       return FirebaseAuthResultStatus.undefined;
   }
 }

 @override
 String errorCodeMessage(FirebaseAuthResultStatus result) {
   switch (result) {
     case FirebaseAuthResultStatus.emailAlreadyInUse:
       return 'このメールアドレスはすでに登録されています。';
     case FirebaseAuthResultStatus.invalidEmail:
       return 'このメールアドレスは有効ではありません。';
     case FirebaseAuthResultStatus.operationNotAllowed:
       return 'メールアドレスとパスワードでのログインは有効になっていません。';
     case FirebaseAuthResultStatus.weakPassword:
       return 'パスワードの強度が十分ではありません。';
     case FirebaseAuthResultStatus.userDisabled:
       return 'このメールアドレスは無効になっています。';
     case FirebaseAuthResultStatus.userNotFound:
       return 'このメールアドレスのアカウントは存在しません。';
     case FirebaseAuthResultStatus.wrongPassword:
       return 'パスワードが間違ってます。';
     case FirebaseAuthResultStatus.undefined:
       return '';
   }
 }
}

firebase_messagin_data_source.dart

final firebaseMessagingProvider = Provider<FirebaseMessagingRepository>((ref) {
 final messaging = FirebaseMessaging.instance;
 return FirebaseMessagingDataSource(messaging);
});

class FirebaseMessagingDataSource implements FirebaseMessagingRepository {
 FirebaseMessagingDataSource(this._firebaseMessaging);
 final FirebaseMessaging _firebaseMessaging;

 final _message = PublishSubject<RemoteMessage>();
 final _openedApp = PublishSubject<RemoteMessage>();
 final _token = PublishSubject<String>();
 bool _isFetch = false;

 @override
 Stream<RemoteMessage> get message => _message;

 @override
 Stream<RemoteMessage> get openedApp => _openedApp;

 @override
 Stream<String> get token => _token;

 @override
 void fetchMessaging() {
   if (_isFetch) {
     return;
   }
   _isFetch = true;
   FirebaseMessaging.onMessage.listen((event) {
     logger!.fine('onMessage: ${event.data}');
     _message.add(event);
   });
   FirebaseMessaging.onMessageOpenedApp.listen((event) {
     logger!.fine('onMessageOpenedApp: ${event.data}');
     _openedApp.add(event);
   });
   _firebaseMessaging.onTokenRefresh.listen((event) {
     logger!.fine('onTokenRefresh: $event');
     _token.add(event);
   });
 }

 @override
 Future<void> requestPermission() async {
   if (Platform.isIOS) {
     await _firebaseMessaging.requestPermission();
   }
 }

 @override
 Future<String?> getToken() => _firebaseMessaging.getToken();

 @override
 Future<void> subscribeToTopic(String topic) =>
     _firebaseMessaging.subscribeToTopic(topic);

 @override
 Future<void> subscribeTopicAll() =>
     _firebaseMessaging.subscribeToTopic(_topicsAll);
}

/// Topics
const _topicsAll = 'all';

firebase_functions_data_source.dart

final firebaseFunctionsRepositoryProvider =
   Provider<FirebaseFunctionsRepository>((ref) {
 final functions = FirebaseFunctions.instance;
 return FirebaseFunctionsDataSource(functions);
});

const sendPushNotification = 'sendPushNotification';

class FirebaseFunctionsDataSource implements FirebaseFunctionsRepository {
 FirebaseFunctionsDataSource(this._functions) {
   // FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
 }

 final FirebaseFunctions _functions;

 @override
 Future<void> sendNotification(String fcmToken) async {
   final body = <String, dynamic>{
     'token': fcmToken,
   };

   final callable = _functions.httpsCallable(sendPushNotification);
   try {
     final res = await callable.call<String>(body);
     logger!.fine(res.data);
   } on FirebaseFunctionsException catch (e) {
     logger!.warning(
         'Code: ${e.code}\nmessage: ${e.message}\ndetails: ${e.details}');
   }
 }

 @override
 Future<void> helloWorld() async {}
}

サインアップ後にUserNotifier内のcreateUserの関数を呼び出して、Userドキュメントを作成しましょう。

次にアプリ起動時の処理を追加します。

main.dart

void main() async {
 WidgetsFlutterBinding.ensureInitialized();
 await Flamingo.initializeApp();
 runApp(ProviderScope(child: App()));
}

app.dart

class App extends HookWidget {
 const App({Key? key}) : super(key: key);

 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     title: F.title,
     navigatorKey: useProvider(navigatorKeyProvider),
     theme: ThemeData(
       primarySwatch: Colors.blue,
     ),
     localizationsDelegates: const [
       GlobalCupertinoLocalizations.delegate,
       GlobalMaterialLocalizations.delegate,
       ...AppLocalizations.localizationsDelegates,
     ],
     supportedLocales: AppLocalizations.supportedLocales,
     home: useProvider(appNotifierProvider.select((state) => state.isLoding))
         ? Container(
             alignment: Alignment.center,
             color: Colors.white,
             child: const CupertinoActivityIndicator(),
           )
         : const AppPage(),
   );
 }
}

app_notifier.dart

final appNotifierProvider = StateNotifierProvider<AppNotifier, AppState>((ref) {
 return AppNotifier(ref.read);
});

@freezed
class AppState with _$AppState {
 const factory AppState({
   @Default(true) bool isLoding,
 }) = _AppState;
}

class AppNotifier extends StateNotifier<AppState> {
 AppNotifier(this._read) : super(const AppState()) {
   _configure();
 }

 final Reader _read;

 UserNotifier get userNotifier => _read(userNotifierProvider.notifier);
 PushNotificationNotifier get pushNotificationNotifier =>
     _read(pushNotificationNotifierProvider);

 Future<void> _configure() async {
   await pushNotificationNotifier.removeBadge();

   await userNotifier.init();
   await pushNotificationNotifier.init();
   state = state.copyWith(isLoding: false);
 }
}

まず、最初にpushNotificationNotifier.removeBadgeを読んでいるのは、push通知がきた時にアプリのアイコンにバッチがついている場合にそれを消す処理です。
次に、userNotifierでログインしているuserの情報や、fcmトークンの更新、ユーザー一覧の取得などの処理をしています。

次のpushNotificationNotifier.init() はpush通知の権限の許可や、push通知をリッスン処理などを呼び出しています。
下記がコードです。

push_notification_notifier.dart

final pushNotificationNotifierProvider =
   ChangeNotifierProvider<PushNotificationNotifier>((ref) {
 return PushNotificationNotifier(ref.read);
});

class PushNotificationNotifier extends ChangeNotifier {
 PushNotificationNotifier(this._read);

 final Reader _read;

 FirebaseMessagingRepository get firebaseMessagingRepository =>
     _read(firebaseMessagingProvider);

 LocalNotificationRepository get localNotificationRepository =>
     _read(localNotificationDataSourceProvider);

 Future<void> init() async {
   await firebaseMessagingRepository.requestPermission();
   await localNotificationRepository.configure();
   firebaseMessagingRepository.fetchMessaging();
   await firebaseMessagingRepository.subscribeTopicAll();

   firebaseMessagingRepository.message.listen((event) async {
     if (Platform.isAndroid) {
       final notification = event.notification;
       if (notification == null) {
         return;
       }
       await localNotificationRepository.show(
         title: notification.title ?? '',
         body: notification.body ?? '',
       );
     }
   });

   firebaseMessagingRepository.openedApp.listen((event) async {
     logger!.info('openedApp');
     final isAppBadgeSupported = await FlutterAppBadger.isAppBadgeSupported();
     logger!.info('isAppBadgeSupported $isAppBadgeSupported');
     if (isAppBadgeSupported) {
       FlutterAppBadger.removeBadge();
     }
   });
 }

 /// バッジリセット
 Future<void> removeBadge() async {
   final isAppBadgeSupported = await FlutterAppBadger.isAppBadgeSupported();
   if (isAppBadgeSupported) {
     FlutterAppBadger.removeBadge();
   }
 }
}

local_notification_data_source.dart

final localNotificationDataSourceProvider =
   Provider<LocalNotificationRepository>((ref) {
 return LocalNotificationDataSource(FlutterLocalNotificationsPlugin());
});

class LocalNotificationDataSource implements LocalNotificationRepository {
 LocalNotificationDataSource(this._flutterLocalNotificationsPlugin);

 final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin;

 @override
 Future<void> configure({
   Future<dynamic> Function(String? payload)? onSelectNotification,
 }) async {
   const initializationSettingsAndroid =
       AndroidInitializationSettings('app_icon');
   const initializationSettingsIOS = IOSInitializationSettings(
     requestAlertPermission: false,
     requestBadgePermission: false,
     requestSoundPermission: false,
   );
   const initializationSettings = InitializationSettings(
       android: initializationSettingsAndroid, iOS: initializationSettingsIOS);

   await _flutterLocalNotificationsPlugin.initialize(initializationSettings,
       onSelectNotification: onSelectNotification);

   logger!.fine('configure done.');
 }

 @override
 Future<void> show({
   required String title,
   required String body,
   String? payload,
   String? androidChannelId,
   String? androidChannelName,
   String? androidChannelDescription,
 }) async {
   final androidPlatformChannelSpecifics = AndroidNotificationDetails(
     androidChannelId ?? _defaultAndroidChannelId,
     androidChannelName ?? _defaultAndroidChannelName,
     channelDescription:
         androidChannelDescription ?? _defaultAndroidChannelDescription,
     importance: Importance.max,
     priority: Priority.high,
     ticker: 'ticker',
   );
   const iOSPlatformChannelSpecifics = IOSNotificationDetails();

   final platformChannelSpecifics = NotificationDetails(
       android: androidPlatformChannelSpecifics,
       iOS: iOSPlatformChannelSpecifics);

   await _flutterLocalNotificationsPlugin
       .show(0, title, body, platformChannelSpecifics, payload: payload);
 }
}

const _defaultAndroidChannelId = 'high_importance_channel';
const _defaultAndroidChannelName = 'channel_name';
const _defaultAndroidChannelDescription = 'channel_description';

次にユーザ一覧ページを作成していきます。

class UserListPage extends TabWidgetPage {
 const UserListPage({Key? key}) : super(key: key);

 @override
 void onBottomNavigationTap() {}

 @override
 Widget build(BuildContext context) {
   final users = useProvider(userNotifierProvider).userList;
   final notifier = useProvider(userNotifierProvider.notifier);

   return Scaffold(
     appBar: AppBar(
       title: const Text('ユーザー一覧'),
     ),
     body: ListView(
       children: users.map((e) {
         return Column(
           children: [
             ListTile(
               onTap: () {
                 logger!.info(e.toData());
                 notifier.pushNotification(e.fcmToken!);
               },
               title: Text(e.name!),
             ),
             const SizedBox(
               height: 1,
               width: double.infinity,
               child: ColoredBox(
                 color: kAppBlack,
               ),
             ),
           ],
         );
       }).toList(),
     ),
   );
 }
}

↑のような画面ができると思います。
ユーザーのところをタップするとFunctionsが発火し、そのユーザーのところへpush通知が届く処理が走ります。

まとめ

以上で、FunctionsからFlutterのアプリへpush通知を送る手順になります。

なお、この実装をする際には2人分のユーザーが入りますので、シュミレーターまたは実機を2つ立ち上げましょう。

今回記事にしたコードをもっとカスタムすれば、LINEのようなメッセージを送った時にpush通知にメッセージを表示するなどもできます。

採用情報はこちら
目次