꿈꾸는 시스템 디자이너

Flutter 강좌 - [Firebase] FCM(Firebase Cloud Messasing) 사용법 본문

Development/Flutter

Flutter 강좌 - [Firebase] FCM(Firebase Cloud Messasing) 사용법

독행소년 2019. 11. 19. 16:15

Flutter Code Examples 강좌를 추천합니다.

  • 제 블로그에서 Flutter Code Examples 프로젝트를 시작합니다.
  • Flutter의 다양한 예제를 소스코드와 실행화면으로 제공합니다.
  • 또한 모든 예제는 Flutter Code Examples 앱을 통해 테스트 가능합니다.

Flutter Code Examples 강좌로 메뉴로 이동

Flutter Code Examples 강좌 목록 페이지로 이동

Flutter Code Examples 앱 설치 | Google Play Store로 이동

 

Flutter Code Examples - Google Play 앱

Are you a beginner at Flutter? Check out the various features of Flutter through the demo. Source code for all demos is also provided.

play.google.com


 

Flutter 강좌 시즌2 목록 : https://here4you.tistory.com/149

 

이번 강좌에서는 FCM(Firebase Cloud Messaging)의 사용법에 대해서 알아본다.

FCM이란 클라우드 메시징 혹은 푸시 메시징(Push Messaging) 서비스의 한 종류인데 적어도 안드로이드 진영에서는 거의 대부분이 FCM을 통해 메시지 서비스를 한다고 해도 과언이 아니다.

클라우드 메시징이란 클라우드에서 특정 기기(단수 혹은 복수 모두)에게 메시지를 전달하는 서비스다. 

클라우드 메시징의 개념은 심플하다 서버(혹은 서버군)가 하나 있을 것이고, 그 서버로부터 메시지를 수신할 다수의 클라이언트(스마트폰)가 있을 것이고 그들 간에 네트워크 연결(일반적으로 소켓통신)을 맺어 메시지를 전송하면 된다.

그런데 이게 실제로는 그렇게 쉽지가 않다. 아마도 서버는 고정 위치에 고정 IP 혹은 도메인 주소를 가질 것이다. 클라이언트가 서버로 메시지를 전송하려면 서버 IP 혹은 도메인 주소로 메시지를 전송하면 끝난다. 그런데 그 반대가 문제다.

서버가 특정 클라이언트에게 메시지를 전송하려면 그 클라이언트의 IP 주소를 알아야 네트워크 연결을 맺고 메시지를 전송할 수 있다. 그런데 스마트폰과 같은 모바일 기기는 고정 IP를 가지지 않는다. 사용자가 이동함에 따라 기지국이 바뀌거나 Wi-Fi 망이 바뀌면서 하루에도 수십 번씩 IP 주소가 바뀐다. 그러므로 서버가 클라이언트의 IP 주소를 통해 연결을 맺는 것은 사실상 불가능하다.

그럼 어떻게 하느냐? 클라이언트가 먼저 그리고 항상 서버에 연결을 맺고 있어서 내게 보낼 메시지가 있을 경우 이 연결을 통해 메시지를 전달해 달라고 하면 된다. 그리고 실제로(아마도) FCM도 이러한 방식으로 메시징 서비스를 한다.

 

이번 강좌에서 구현할 서비스는 FCM 외에도 Firestore와 Cloud Functions을 이용한다.

클라이언트에서는 Firestore에 등록된 사용자의 리스트를 리스트로 구성하고, 원하는 사용자를 선택해서 FCM 메시지를 전달하면 해당 사용자의 스마트폰으로 FCM 메시지가 전달되어 사용자에게 보고된다.

그런데 Firebase의 정책상 앱에서는 FCM 메시지를 직접 전송할 수 없다. FCM은 Firebase Admin SDK를 이용해서 전송할 수 있는데 Cloud Functions과 같이 관리자 권한을 가지는 환경에서만 이용할 수 있다. 그러므로 FCM을 전송하는 Cloud Function을 추가하고 그 함수 호출을 통해 특정 사용자에게 FCM을 전송하는 방식으로 서비스를 구현한다.

 

1. Cloud Function에 FCM 전송 함수 추가

지난 Cloud Functions에서 사용했던 index.js 파일에 다음과 같이 코드를 추가한 후 배포한다.

함수 작성 및 배포 방법은 지난 강좌를 참고한다.

const functions = require('firebase-functions');
const admin = require("firebase-admin");

admin.initializeApp();

exports.sendFCM = functions.https.onCall((data, context) => {
  var token = data["token"];
  var title = data["title"];
  var body = data["body"];

  var payload = {
    notification: {
      title: title,
      body: body
    }
  }

  var result = admin.messaging().sendToDevice(token, payload);
  return result;
})

코드를 살펴보면 FCM 기능을 제공하는 firebase-admin SDK를 import 했고 초기화 코드가 추가되었다. 실제 FCM을 전송하는 함수 sendFCM도 추가했다.

 

2. Firestore 보안 규칙 추가

Firestore 규칙 페이지에서 users 컬렉션에 대한 규칙을 추가한다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /FirstDemo/{document} {
      allow read: if request.auth.uid != null;
      allow create: if request.auth.uid == request.resource.data.author_uid;
      allow update, delete: if request.auth.uid == resource.data.author_uid;
    }
    
    match /users/{document} {
      allow read, write: if request.auth.uid != null;
    }
  }
}

users 컬렉션에는 각 사용자 기기의 FCM 토큰 값을 가지는 문서가 저장된다. Firesotre의 보안규칙에 대해서는 이전 강좌를 참고한다.

 

3. 앱 구현

이번 앱의 구현도 기존 강좌에서 사용한 Flutter Firebase 앱을 이용한다.

앱에서 FCM 메시지를 수신하기 위해서는 AndroidManifest.xml 파일에 FLUTTER_NOTIFICATION_CLICK 인텐트 필터가 추가되어야 한다.

<intent-filter>
  <action android:name="FLUTTER_NOTIFICATION_CLICK" />
  <category android:name="android.intent.category.DEFAULT" />
</intent-filter>

 

Flutter에서 FCM 메시지 수신을 위해 firebase_messaging 플러그인을 이용한다. 플러그인의 정보는 아래 링크에서 확인할 수 있다.

https://pub.dev/packages/firebase_messaging

 

pubspec.ymal 파일에 플러그인을 추가한다.

dependencies:
  firebase_messaging: ^5.1.8

 

전체 소스코드는 다음과 같다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:provider/provider.dart';
import 'package:flutter_firebase/firebase_provider.dart';
import 'package:cloud_functions/cloud_functions.dart';

FcmFirstDemoState pageState;

class FcmFirstDemo extends StatefulWidget {
  @override
  FcmFirstDemoState createState() {
    pageState = FcmFirstDemoState();
    return pageState;
  }
}

class FcmFirstDemoState extends State<FcmFirstDemo> {
  final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
  FirebaseProvider fp;
  bool didUpdateUserInfo = false;

  final Firestore _db = Firestore.instance;
  final FirebaseMessaging _fcm = FirebaseMessaging();

  // Firestore users fields
  final String fName = "name";
  final String fToken = "token";
  final String fCreateTime = "createTime";
  final String fPlatform = "platform";

  final TextStyle tsTitle = TextStyle(color: Colors.grey, fontSize: 13);
  final TextStyle tsContent = TextStyle(color: Colors.blueGrey, fontSize: 15);

  // Cloud Functions
  final HttpsCallable sendFCM = CloudFunctions.instance
      .getHttpsCallable(functionName: 'sendFCM') // 호출할 Cloud Functions 의 함수명
        ..timeout = const Duration(seconds: 30); // 타임아웃 설정(옵션)

  TextEditingController _titleCon = TextEditingController();
  TextEditingController _bodyCon = TextEditingController();

  Map<String, bool> _map = Map();

  @override
  void initState() {
    super.initState();

    // FCM 수신 설정
    _fcm.configure(
      // 앱이 실행중일 경우
      onMessage: (Map<String, dynamic> message) async {
        print("onMessage: $message");
        showDialog(
          context: context,
          builder: (context) => AlertDialog(
            content: ListTile(
              title: Text(message["notification"]["title"]),
              subtitle: Text(message["notification"]["body"]),
            ),
            actions: <Widget>[
              FlatButton(
                child: Text("OK"),
                onPressed: () => Navigator.of(context).pop(),
              )
            ],
          ),
        );
      },
      // 앱이 완전히 종료된 경우
      onLaunch: (Map<String, dynamic> message) async {
        print("onLaunch: $message");
      },
      // 앱이 닫혀있었으나 백그라운드로 동작중인 경우
      onResume: (Map<String, dynamic> message) async {
        print("onResume: $message");
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    fp = Provider.of<FirebaseProvider>(context);
    if (didUpdateUserInfo == false) updateUserInfo();

    return Scaffold(
      key: _scaffoldKey,
      appBar: AppBar(title: Text("FcmFirstDemo")),
      body: Column(
        children: <Widget>[
          Container(
            child: ListTile(
                title: Text("Auth UID"),
                subtitle: Text(
                    (fp.getUser() != null) ? fp.getUser().displayName : "")),
          ),
          Expanded(
            child: StreamBuilder<QuerySnapshot>(
              stream: _db.collection("users").snapshots(),
              builder: (BuildContext context,
                  AsyncSnapshot<QuerySnapshot> snapshot) {
                if (snapshot.hasError) return Text("Error: ${snapshot.error}");
                switch (snapshot.connectionState) {
                  case ConnectionState.waiting:
                    return Text("Loading...");
                    break;
                  default:
                    return ListView(
                      children:
                          snapshot.data.documents.map((DocumentSnapshot doc) {
                        Timestamp ts = doc[fCreateTime];
                        String dt = timestampToStrDateTime(ts);

                        if (!_map.containsKey(doc[fToken])) {
                          _map[doc[fToken]] = false;
                        }

                        return Card(
                          elevation: 2,
                          child: Container(
                            padding: const EdgeInsets.all(10),
                            child: Row(
                              children: <Widget>[
                                Expanded(
                                  child: Column(
                                    children: <Widget>[
                                      Row(
                                        children: <Widget>[
                                          Container(
                                            width: 80,
                                            child: Text("Name", style: tsTitle),
                                          ),
                                          Expanded(
                                              child: Text(doc[fName],
                                                  style: tsContent))
                                        ],
                                      ),
                                      Row(
                                        children: <Widget>[
                                          Container(
                                            width: 80,
                                            child: Text("platform",
                                                style: tsTitle),
                                          ),
                                          Expanded(
                                              child: Text(doc[fPlatform],
                                                  style: tsContent))
                                        ],
                                      ),
                                      Row(
                                        children: <Widget>[
                                          Container(
                                            width: 80,
                                            child: Text("createAt",
                                                style: tsTitle),
                                          ),
                                          Expanded(
                                              child: Text(dt, style: tsContent))
                                        ],
                                      ),
                                    ],
                                  ),
                                ),
                                Checkbox(
                                  value: _map[doc[fToken]],
                                  onChanged: (flag) {
                                    setState(() {
                                      print(flag);
                                      _map[doc[fToken]] = flag;
                                    });
                                  },
                                ),
                                IconButton(
                                  icon: Icon(Icons.message),
                                  tooltip: "custom message",
                                  onPressed: () {
                                    showMessageEditor(doc[fToken]);
                                  },
                                ),
                                IconButton(
                                  icon: Icon(Icons.send),
                                  tooltip: "send a sample message",
                                  onPressed: () {
                                    sendSampleFCM(doc[fToken]);
                                  },
                                )
                              ],
                            ),
                          ),
                        );
                      }).toList(),
                    );
                }
              },
            ),
          ),
          Container(
            child: RaisedButton(
              child: Text("Send Sampl FCM to Selected Device"),
              onPressed: sendSampleFCMtoSelectedDevice,
            ),
          )
        ],
      ),
    );
  }

  void updateUserInfo() async {
    print("업데이트");
    if (fp.getUser() == null) return;
    String token = await _fcm.getToken();
    if (token == null) return;

    var user = _db.collection("users").document(fp.getUser().uid);
    await user.setData({
      fName: fp.getUser().displayName,
      fToken: token,
      fCreateTime: FieldValue.serverTimestamp(),
      fPlatform: Platform.operatingSystem
    });
    setState(() {
      didUpdateUserInfo = true;
    });
  }

  void showMessageEditor(String token) {
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (context) {
        return AlertDialog(
          title: Text("Create FCM"),
          content: Container(
            child: Column(
              children: <Widget>[
                TextField(
                  autofocus: true,
                  decoration: InputDecoration(labelText: "Title"),
                  controller: _titleCon,
                ),
                TextField(
                  decoration: InputDecoration(labelText: "Body"),
                  controller: _bodyCon,
                )
              ],
            ),
          ),
          actions: <Widget>[
            FlatButton(
              child: Text("Cancel"),
              onPressed: () {
                _titleCon.clear();
                _bodyCon.clear();
                Navigator.pop(context);
              },
            ),
            FlatButton(
              child: Text("Send"),
              onPressed: () {
                if (_titleCon.text.isNotEmpty && _bodyCon.text.isNotEmpty) {
                  sendCustomFCM(token, _titleCon.text, _bodyCon.text);
                }
                Navigator.pop(context);
              },
            )
          ],
        );
      },
    );
  }

  // token에 해당하는 디바이스로 FCM 전송
  void sendSampleFCM(String token) async {
    final HttpsCallableResult result = await sendFCM.call(
      <String, dynamic>{
        fToken: token,
        "title": "Sample Title",
        "body": "This is a Sample FCM"
      },
    );
  }

  // ken리스트에 해당하는 디바이스들로 FCM 전송
  void sendSampleFCMtoSelectedDevice() async {
    List<String> tokenList = List<String>();
    _map.forEach((String key, bool value) {
      if (value) {
        tokenList.add(key);
      }
    });
    if (tokenList.length == 0) return;
    final HttpsCallableResult result = await sendFCM.call(
      <String, dynamic>{
        fToken: tokenList,
        "title": "Sample Title",
        "body": "This is a Sample FCM"
      },
    );
  }

  // koen에 해당하는 디바이스로 커스텀 FCM 전송
  void sendCustomFCM(String token, String title, String body) async {
    if (title.isEmpty || body.isEmpty) return;
    final HttpsCallableResult result = await sendFCM.call(
      <String, dynamic>{
        fToken: token,
        "title": title,
        "body": body,
      },
    );
  }

  String timestampToStrDateTime(Timestamp ts) {
    if (ts == null) return "";
    return DateTime.fromMicrosecondsSinceEpoch(ts.microsecondsSinceEpoch)
        .toString();
  }
}

 

FCM 수신 처리

FCM 수신을 위해 FirebaseMessaging의 인스턴스를 필드 _fcm에 저장한다. FCM의 수신처리는 initState 메서드에 구현하는데 configure 메서드를 이용해서 다음과 같은 상황에 대해 각각 구현한다.

  • onMessage: 앱이 실행 중인 상태에서 FCM 수신
  • onLaunch: 앱이 완전히 종료된 상태에서 FCM 수신
  • onResume: 앱이 닫혀있으나 백그라운드로 동작중인 상태에서 FCM 수신

onMessage 경우에는 AlertDialog를 통해 수신한 메시지를 화면에 출력하도록 구현하였다.

onLaunch와 onResume 디버그 창에 메시지를 출력하고 별다른 추가 동작은 없다. 앱이 foreground로 실행되지 않는 상태라면 플랫폼에서 FCM을 notification으로 처리하기 때문에 별다른 조치를 하지 않아도 된다.

 

Firestore에 FCM 토큰 저장/갱신

FCM에서는 사용자(클라이언트)의 식별자로 token을 이용한다. FCM을 수신할 장치마다 고유의 토큰을 가지는데 FirebaseMessaging 클래스의 getToken 메서드를 통해 구할 수 있다. 토큰을 users 컬렉션에 문서로 등록하여 FCM을 전송할 상대를 선택하는 데 사용한다.

 

FCM 전송 요청

앞서 앱에서는 FCM을 직접 전송할 수 없다고 설명했다. 대신 Cloud Function의 sendFCM 함수를 통해 FCM 전송을 요청하도록 구현하다. FCM을 전달할 수신자의 식별자로 FCM의 토큰을 sendFCM 함수의 파라미터로 전달한다. 만약 복수개의 장치로 동일 FCM을 전달하고자 할 때는 String 타입의 토큰 배열로 전달하면 된다.

 

테스트

두 개의 계정을 이용해서 각기 다른 장치에서 앱을 실행하면 다음과 같이 2명의 사용자가 자동 등록된다.

 

앱이 실행되고 있는 상태에서 메시지를 수신하게 되면 다음과 같이 AlertDialog를 통해 수신된 FCM이 출력된다.

 

앱이 종료되거나 닫힌 상태에서는 다음과 같이 Notification으로 메시지가 자동 처리된다.

 

 

Comments