Flutter 에서 FCM Push 연동을 하면서 원하는 동작이 잘 이루어 지지 않았습니다.
앱의 완성도를 위해서는 앱의 어느 상황에 있던지 상관없이, 푸시 메시지를 누르면 원하는 화면을 열어주는 작업이 필요합니다.
예를들어, 채팅 메시지 푸시 메시지가 오고 그걸 클릭했을 때, 앱이 구동되는 것 뿐만 아니라 채팅 화면을 열어주는 로직이 있다면 사용자 이탈을 줄일 수 있을 것입니다.
일련의 과정에서 부딫힌 문제점과 그 해결책을 공유해보고자 합니다.
그렇다면, FCM 연동하면서 고생한 부분은 무엇일까?
1. 앱 Killed 상태일 때, 푸시 메시지 클릭 시 데이터를 받아오기
2. 앱 Background 상태일 때, 푸시 메시지 클릭 시 데이터를 받아오기
(위에서 데이터라 함은, FCM push payload를 의미합니다.)
payload란?
fcm push message 데이터 양식이라고 보면 됩니다.
(google 문서 : https://firebase.google.com/docs/cloud-messaging/concept-options?hl=ko
실제로 저는 아래의 규칙을 만들어서 백엔드 팀과 소통을 하고 있습니다.
Firebase payload 규칙
app 에서 필요한 firebase payload 포멧
- payload format
- 계층별 설명
- notification
- title : push message 타이틀
- body : push message 설명
- data
- action : 이동할 화면 코드값
- 코드값 정리 (예시)
- home : 홈 화면 진입
- setting : 마이페이지 진입
- null : 메인화면 진입
- 코드값 정리 (예시)
- action : 이동할 화면 코드값
- notification
- 계층별 설명
{
"message":{
"notification":{
"title":"안녕하세요 연락이 왔어요.",
"body":"xxx님, 메시지를 확인해 주세요"
},
"data" : {
"action": "setting"
},
"android": {
"priority": "normal",
"notification": {
"channel_id": "my_project"
}
},
"apns": {
"headers": {
"apns-priority": "5"
}
},
"webpush": {
"headers": {
"Urgency": "high"
}
}
}
}
먼저 fcm push 연동에 필요한 라이브러리는 뭐가 있을까?
1. https://pub.dev/packages/firebase_messaging
기본적인 푸시 연동에 필요한 라이브리리
2. https://pub.dev/packages/flutter_local_notifications
푸시 메시지를 시각적으로 보여주기 위한 라이브러리
라이브러리를 붙이면서 고생했던 부분 중 하나는, android notification 아이콘 적용입니다.
iOS의 경우엔 기본 launcher를 잘 가져다가 사용하기 때문에 따로 처리가 필요하지 않았습니다.
하지만 AOS 에 경우엔 foreground 일 경우에 아이콘 지정을 따로 해주지 않으면 push notification 아이콘이 제대로 나오지 않습니다.
flutter notification 초기화 하는 부분에 아이콘 세팅을 꼭 잘 지정해줘야 한다!
final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
... 코드 생략 ...
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: DarwinInitializationSettings(),
);
_flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); //TODO 해당 코드 로직이 효과있는지 테스트
await _flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse,
onDidReceiveBackgroundNotificationResponse: _onDidReceiveBackgroundNotificationResponse,
);
AndroidInitializationSettings('@mipmap/ic_launcher') 부분을 참고해주시면 됩니다.
또 무슨 고생을 했더라...
라이브러리 가이드에는 앱이 foreground, background, killed 될 때의 상황 커버가 모두 가능하다고 되어있습니다. (만🤔)
그런데 막상 가이드대로 따라했더니 foreground 일 때만 message payload를 읽어올수 있더라..😭
1. 앱이 killed 상태일 때
2. 앱이 background 상태일 때
위의 케이스에서 푸시 노티를 클릭했을 때 데이터를 읽고 원하는 화면으로 이동해야하는 미션이 남아있습니다.
처리하면서 가장 고생을 한 부분은 Android 와 iOS 에 따라서 각 상황의 처리 방법이 다르다는 것입니다.
먼저 foreground 일 때 처리방법을 살펴보도록 하겠습니다.
- Android
푸시 데이터를 받는 함수와 Notification 클릭 시점이 다르다는 특징이 있습니다.
위에서 언급한 라이브러리인 flutter_local_notifications 도 활용해서 직접 notification 생성을 하는 과정이 필요합니다.
(iOS의 경우 Notification은 iOS 시스템에서 알아서 만들어줍니다. 따라서 Android 일 경우에만 notification 직접 생성을 하도록 분기 처리를 해야합니다.
분기를 하지 않을 경우, iOS는 시스템 notification 과 직접 생성한 notification 이 나오게 되어 중복으로 2개가 나오게 되는 버그를 발견하게 될 것입니다.)
Fcm payload 데이터를 가져오는 함수를 작성합니다.
String? _pushCacheDataForAndroidForeground; // 푸시 캐시 데이터 (메인으로 이동 후 이동할 화면 데이터)
class FcmNotificationHandler {
// 코드 생략 ...
/// foreground 일 때 action 값 임시저장 하고 notification 을 보여주는 함수 실행
/// 적용 플랫폼 : Android
if (Platform.isAndroid) {
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
final action = message.data['action'];
_pushCacheDataForAndroidForeground = action;
talker.log("FirebaseMessaging.onMessage action: $action");
_showNotification(message.notification!);
});
}
}
FirebaseMessaging.onMessage 함수에서 payload를 읽어와 private 전역 변수인 _pushCacheDataForAndroidForeground 에 캐싱을 해놓습니다.
그 다음으로, 클릭 했을 때 의 시점의 코드를 작성합니다.
/// flutter Local Notification 설정은 Android 에서만 활용됨
await _flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _onDidReceiveNotificationResponseInAndroid,
);
/// foreground 에서 푸시 메시지를 클릭 했을 때 호출되는 함수
/// 적용 플랫폼 : Android
void _onDidReceiveNotificationResponseInAndroid(NotificationResponse response) {
talker.log('onDidReceiveNotificationResponse response: ${response.payload.toString()}');
final pushActionGlobalState = usePushActionGlobalState();
if (_pushCacheDataForAndroidForeground != null) {
pushActionGlobalState.action.value = _pushCacheDataForAndroidForeground;
}
}
flutter_local_notifications 의 함수인 onDidReceiveNotificationResponse 함수를 활용합니다.
해당 함수에서는 payload 값을 가져올 수 없어서, 이전에 캐싱해놓은 데이터를 끌어와서 활용합니다.
- iOS
FirebaseMessaging.onMessageOpenedApp 함수를 활용합니다.
해당 함수는 iOS의 경우 background, foreground 상태일 때 notification 을 클릭했을 경우를 모두 처리할 수 있습니다.
FirebaseMessaging.onMessageOpenedApp.listen(_firebaseMessagingBackgroundHandler);
/// background 에서 푸시 클릭했을 때 호출되는 함수
/// 적용 플랫폼 : Android (background 일 때만), iOS (background, foreground 일 경우)
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final lifecycleGlobalState = useAppLifecycleGlobalState();
final lifecycle = await lifecycleGlobalState.readStorage();
talker.log("onDidReceiveNotificationResponse Handling a background message: $message, lifecycleState: $lifecycle");
if (Platform.isAndroid && lifecycle == AppLifecycleState.paused) return;
// Isolate Thread 방식으로 인해 secureStorage에 저장
final action = message.data['action'];
if (action != null) {
final pushActionGlobalState = usePushActionGlobalState();
pushActionGlobalState.action.value = action; // iOS 의 경우 foreground 에서 event invoke 하기 위한 로직
pushActionGlobalState.saveStorage(action);
}
}
여기까지 처리된 상태는 아래와 같습니다.
Android | iOS | |
background | 🅾️ | |
foreground | 🅾️ | 🅾️ |
killed |
자 그 다음으로 background 일 때 처리방법을 살펴봅시다.
남아있는 Android background 시의 notification 클릭 시점을 알아내려면, 바로 위의 iOS background 처리를 하기 위한 함수를 같이 사용하면 됩니다.
다만! Android 의 경우엔 notification 을 클릭 하지 않아도, 해당 함수가 invoke 되기 때문에 추가적인 작업이 필요합니다.
Android 의 경우, 앱이 background에 있었을 때 notification을 클릭하지 않아도 호출이 되고, background에 있다가 notification 을 클릭 했을 때도 호출이 됩니다.
firebase messaging 라이브러리의 Android 에서의 버그라고 할 수 있죠.
그래서 어떻게 해결을 했냐 하면, 전자의 경우인 '앱이 background에 있었을 때' 의 경우를 제외시키는 방법으로 해결을 했습니다.
바로 코드를 보시죠.
if (Platform.isAndroid && lifecycle == AppLifecycleState.paused) return;
안드로이드의 경우, App lifecycle의 경우 최종적으로 AppLifecycleState.paused 로 전환이 됩니다.
따라서 Android 플랫폼이면서 AppLifecycleState.paused 일 경우엔 해당 함수 scope를 return 처리를 합니다.
/// background 에서 푸시 클릭했을 때 호출되는 함수
/// 적용 플랫폼 : Android (background 일 때만), iOS (background, foreground 일 경우)
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final lifecycleGlobalState = useAppLifecycleGlobalState();
final lifecycle = await lifecycleGlobalState.readStorage();
talker.log("onDidReceiveNotificationResponse Handling a background message: $message, lifecycleState: $lifecycle");
if (Platform.isAndroid && lifecycle == AppLifecycleState.paused) return;
// Isolate Thread 방식으로 인해 secureStorage에 저장
final action = message.data['action'];
if (action != null) {
final pushActionGlobalState = usePushActionGlobalState();
pushActionGlobalState.action.value = action; // iOS 의 경우 foreground 에서 event invoke 하기 위한 로직
pushActionGlobalState.saveStorage(action);
}
}
이제 여기까지 처리 범위가 늘어났습니다.
Android | iOS | |
background | 🅾️ | 🅾️ |
foreground | 🅾️ | 🅾️ |
killed |
마지막으로 남은 killed 상태일 때의 해결 방법입니다.
이 방법은 아주 쉽습니다.
FirebaseMessaging.instance.getInitialMessage() 를 이용해서 처리하면 됩니다.
Android, iOS 분기를 할 필요없이 공통으로 적용되기 때문에 쉽게 처리할 수 있습니다.
/// App Terminated 상태일 때 메시지 받는 역할
/// 적용 플랫폼 : Android, iOS
FirebaseMessaging.instance.getInitialMessage().then(
(message) {
final pushActionGlobalState = usePushActionGlobalState();
final action = message?.data['action'];
pushActionGlobalState.action.value = action;
talker.log('FirebaseMessaging.instance.getInitialMessage, action: $action');
return true;
},
);
Android | iOS | |
background | 🅾️ | 🅾️ |
foreground | 🅾️ | 🅾️ |
killed | 🅾️ | 🅾️ |
모든 케이스 적용이 완료되었습니다~~🎉🎉🎉
추가적으로 더 이야기 하고 싶은 부분이 있습니다!
푸시 데이터 정보 관리를 해주는 PushActionGlobalState 구성을 어떻게 했는지, 그리고 SharedPreference 를 이용해서 데이터를 저장하고 불러오는 이유에 대해서 정리를 해보도록 하겠습니다.
PushActioinGlobalState 구성
push message 컨트롤을 GlobalState 변수를 만들어서 관리해주고 있습니다.
주의할 점은 GlobalState에서 활용되는 push 데이터를 사용하고 나서 잘 지워줘야 한다는 것입니다.
가끔 push 데이터가 남아있어서 푸시 메시지를 클릭하지 않았는데도 특정 화면으로 이동되는 기본적인 실수를 조심해야 합니다.
GlobalState 관리는 아래처럼 Singleton 형식으로 관리하고 있습니다.
class _PushActionGlobalState {
final ValueNotifier<String?> action;
final actionKey = "actionKey";
_PushActionGlobalState({required this.action});
void saveStorage(String? action) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
if (action == null) {
prefs.remove(actionKey);
} else {
prefs.setString(actionKey, action);
}
}
Future<String?> readStorage() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs.getString(actionKey);
}
}
_PushActionGlobalState? _pushActionGlobalState;
_PushActionGlobalState usePushActionGlobalState() {
return _pushActionGlobalState ??= _PushActionGlobalState(action: ValueNotifier<String?>(null));
}
저장할 때 action 변수 외에 saveStorage 부분이 왜 존재하나요?
_firebaseMessagingBackgroundHandler 함수는 일반 함수가 아니라 Isolate Thread 방식으로 구동된다는 것입니다.
Isolate 방식은 서로 다른 프로세스를 구동하는 방식으로 앱간에 메모리 공유가 되지 않습니다.
다른 메모리 공간에 있기 때문에 변수가 따로 존재한다고 보면 됩니다. action 변수에 setting을 하더라도,해당 함수에서는 적용된 값을 인지할 수 없습니다.
따라서 secureStorage에 저장해서 전달하는 방식으로 문제를 해결하였습니다.
(✨Isolate 방식과 일반 thread 구동 방식의 차이점에 대해서 궁금하시다면, https://boilerplate.tistory.com/71 글을 참고해주세요!)
push 적용을 하면서 해결되었다고 생각했지만 QA 검수에서 iOS 동작이 안되는 걸 확인했고, 원인을 찾고 또 고치느라 한참 시간을 허비했습니다. 소위 삽질을 했다고 보면 되죠. 🤣
이렇게 정리를 했으니, 다음 프로젝트 적용엔 더 빠르게 적용할 수 있겠죠? 하하하
그럼 이만~
'flutter' 카테고리의 다른 글
SMS 인증번호 자동으로 입력해주기 (1) | 2025.01.06 |
---|---|
Flutter Webview 연동하기 (0) | 2024.09.24 |
Flutter Mvvm Clean Architecture Guide (0) | 2024.09.13 |
flutter go_router vs auto_route (0) | 2024.09.11 |
flutter thread 분석 (0) | 2024.06.05 |