Flutter iOS - 사용자가 앱을 강제 종료할 때 이벤트 핸들링하기

박건희·2022년 2월 26일
4

Flutter

목록 보기
4/4

Intro

프론트엔드는 유저가 할 수 있는 모든 사악한(?) 행위로부터 안전장치를 달아놓는 것이 중요하다. 이게 곧 클라이언트단의 안정성이라고도 말할 수 있을 것 같다.

앱 개발을 하면서 동일한 버튼을 빠른 시간에 두번 누르는 간단한 행위부터 시작해서, 현란한 유저의 손가락 무빙을 따라가지 못해 REST API가 중복 호출 되거나 꼬이는 상황을 머릿속에서 잘 생각할 수 있고, 대처할 수 있는 사람이 좋은 프론트엔드 개발자가 아닐까 싶다. (프론트엔드 개발자가 아니라 QA의 역량인가??)

앱의 종류와 상관없이 항상 대비해야 하는 이벤트로 앱 강제 종료를 빼먹을 수 없다.
중간에 유저가 나가도 큰 상관없는 화면들이 있는 반면, 유저의 상태를 확실하게 컨트롤 하기 위해서 강제 종료되는 앱이 특정 코드를 실행시키게 만들어야 되는 화면도 있다.

Flutter로 개발을 하며 유저가 앱을 강제 종료 시킬 때 iOS에서 핸들링 하는 법을 찾느라 꽤나 애를 먹었다. 그 방법을 소개하고자 한다.


📍WidgetsBindingObserver를 이용한 방법

WidgetsBindingObserver란?

구글링을 하다보면 전부 WidgetsbindingObserver라는 플러터 클래스를 활용한 방법 밖에 나오지 않는다. WidgetsbindingObserver는 AppLifeCycle 리스너 인스턴스를 생성해서 원하는 Widget에서 앱의 상태를 관찰하고 이벤트를 핸들링할 수 있는 클래스다. 예제로 살펴보자

1. 기본 설정

일단 stateful widget을 선언할 때 with WidgetsBindingObserver로 해당 class를 상속 받는다.
(공식 문서 참조)

그 후 initState에 WidgetsbindingObserver를 추가해주고, dispose 할 때 제거해준다.


  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

2. AppLifeCycleState 리스너 설정 (⭐️중요)

addObserver가 된 페이지에선 현재 앱의 LifeCycle 상태를 받을 수 있고, 아래와 같은 리스너 함수를 통해 구현할 수 있다.

void didChangeAppLifecycleState(AppLifecycleState state) { 
	switch (state) { 
    	case AppLifecycleState.resumed: 
        	setState(() {
            	_text = 'Resumed!'; 
            }); 
            print('resumed'); 
            break; 
       	case AppLifecycleState.inactive: 
        	print('inactive'); 
            break; 
     	case AppLifecycleState.detached: 
        	print('detached'); 
            // DO SOMETHING!
            break; 
      	case AppLifecycleState.paused: 
        	print('paused'); 
            break; 
      	default: 
        	break; 
	} 
}

https://api.flutter.dev/flutter/dart-ui/AppLifecycleState.html 를 읽어보면 각각의 상태 (resumed, inactive, detached, paused)가 뭔지 알 수 있다.

강제 종료할 때 발생하는 state는 AppLifecycleState.detached며, 앱이 강제종료 될 때 DO SOMETHING! 부분만 채워주면 되는 것이다!

예제 코드 전체

import 'package:flutter/widgets.dart';

class LifecycleWatcher extends StatefulWidget {
  
  _LifecycleWatcherState createState() => _LifecycleWatcherState();
}

class _LifecycleWatcherState extends State<LifecycleWatcher> with WidgetsBindingObserver {
  AppLifecycleState _lastLifecycleState;

  
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  void didChangeAppLifecycleState(AppLifecycleState state) { 
	switch (state) { 
    	case AppLifecycleState.resumed: 
        	setState(() {
            	_text = 'Resumed!'; 
            }); 
            print('resumed'); 
            break; 
       	case AppLifecycleState.inactive: 
        	print('inactive'); 
            break; 
     	case AppLifecycleState.detached: 
        	print('detached'); 
            // DO SOMETHING!
            break; 
      	case AppLifecycleState.paused: 
        	print('paused'); 
            break; 
      	default: 
        	break; 
	} 
}

  Widget build(BuildContext context) { 
  	return Scaffold( 
    	appBar: AppBar(title: Text(widget.title)), 
        body: Center(child: Text(_text))); 
  }


}

근데 왜 아이폰에서 왜 안돼..??

하지만 막상 해보니 다른 상태 (paused, inactive, resumed)는 잘 작동하고 로그도 잘 찍히는데 detached만 안됐다. 계속 구글을 디깅하다 보니 iOS는 detached 이벤트를 못듣고, 플러터에서 이것을 해결할 방법은 없다고 한다. ( 확인사살 링크1 확인사살 링크2 )


안드로이드는 되는데 iOS는 왜 안돼!!

그래서 paused 상태 (i.e. 앱이 백그라운드에 있는 상태)에서 이벤트를 핸들링해주는 편법을 쓴다고 한다

하지만 우리 어플은 실시간 음성 채팅 서비스를 지원하기 때문에 앱이 백그라운드로 간다고 맘대로 api를 호출하면 안됐다... 즉 백그라운드 모드일때는 아무일도 일어나지 않고, 강제종료 되는 순간에만 특정 API를 호출해야 했다.

그래서 다음의 방법을 찾았다.


📍 네이티브 코드와 소통해서 해결하자!

FlutterMethodChannel

iOS & 안드로이드 host가 Flutter app과 직접적으로 채널을 연결해서 서로 메시지를 패싱하는 방법이 있다. 당연히 Bidirectional한 채널이고, Flutter app에서 native platform에 alert를 보내는 것도 되고, native platform에서 Flutter app으로 alert를 보내는 것도 가능하다. 이를 통해 다음의 기능을 구현할 것이다:

  1. iOS host가 강제종료를 감지해서 Flutter로 alert를 보낸다.
  2. Flutter app은 1번의 alert를 받을 경우 죽기 전에 필요한 api를 전부 쏘고 죽는다.

플러터 공식 문서더 자세한 설명으로 이뤄진 블로그 를 보길 추천한다.

1. AppDelegate 수정

프로젝트를 Xcode로 열어 본 뒤에 Runner > Runner > AppDelegate를 들어가면 swift로 작성된 코드가 보인다. 그 내용을 아래와 같이 바꿔준다

import UIKit
import Flutter

@UIApplicationMain@objc class AppDelegate: FlutterAppDelegate {

    var applicationLifeCycleChannel: FlutterBasicMessageChannel!

    override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        GeneratedPluginRegistrant.register(with: self)

        applicationLifeCycleChannel = FlutterBasicMessageChannel(
            name: "applicationLifeCycle",
            binaryMessenger: (window?.rootViewController as! FlutterViewController).binaryMessenger,
            codec: FlutterStringCodec.sharedInstance())
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    override func applicationWillTerminate(_ application: UIApplication) {
        applicationLifeCycleChannel.sendMessage("applicationWillTerminate")
    }
	// 앱을 다시 화면에 띄웠을 때
//    override func applicationWillEnterForeground(_ application: UIApplication) {
       // applicationLifeCycleChannel.sendMessage("applicationWillEnterForeground")
//    }

	// 앱이 백그라운드로 갔을 때
    //override func applicationDidEnterBackground(_ application: UIApplication) {
        //applicationLifeCycleChannel.sendMessage("applicationDidEnterBackground")
   // }
}

Tip: import Flutter 부분에서 Flutter를 찾을 수 없다는 에러가 나올수도 있는데, 이는 Xcode가 13버젼으로 업그레이드 되면서 생긴 버그고, 실제 빌드에는 문제가 없다

원래 코드랑 달라진 점은
1. FlutterBasicMessageChannel타입의 변수를 선언해서
2. application이 시작할 때 binary messenger를 넣어주고
3. 앱이 terminate 될 때 'applicationWillTerminate'라는 메시지를 채널을 통해 보내주는 것

우리는 강제 종료만 컨트롤 할 것이기 때문에 applicationWillTerminate 함수만 사용한다.

2. Flutter에서 메시지를 받고 event handling

이벤트를 핸들링하고 싶은 페이지에 아래와 같은 코드를 작성한다.

static const applicationLifecycleChannel = BasicMessageChannel<String>('applicationLifeCycle', StringCodec());
static const kApplicationWillTerminate = 'applicationWillTerminate';


  
  void initState() {
    applicationLifecycleChannel.setMessageHandler((message) async {
      switch(message) {
        case kApplicationWillTerminate:
          // 강제 종료될 때 여기서 핸들링!
          break;
        default:
          break;
      }
      return message;
    });
    super.initState();
  }
  1. swift의 FlutterBasicChannel에서 보낸 메시지를 Flutter의 BasicMessageChannel에서 받는다. (이 때 주고 받는 메시지는 기본적으로 String 타입이다)
  2. 위 1-3번에서 swift에서 앱이 강제종료될 때 보내기로 한 String, "applicationWillTerminate"를 받을 경우 특정 액션을 하라는 setMessageHandler 메소드를 불러준다.

공식문서나 따른 예제들을 봐도 굳이 MessageHandler를 파괴해줄 필요는 없고, 그냥 선언만 해주면 되는 것 같다.

이것을 통해 iOS에서도 앱을 swipe해서 강제 종료 할 때 특정 액션을 하는 걸 가능케 했다! 예아!!

😅 근데 시간이 너무 짧아..

기뻐하기엔 아직 일렀다... 강제 종료 할 때마다 짧은 코드 한두줄은 무조건 실행 됐으나, API는 호출 될 때도 있고 안 될때도 있었다. 이런 불안정한 상태의 앱을 출시할 수는 없었다.

울며 겨자 먹기로 이해도 할 수 없는 swift 관련 질문들을 찾아보다가 이유와 답을 찾을 수 있었다.

applicationWillTerminate의 한계

찾아보니 swift native 개발을 하는 사람들도 비슷한 문제를 겪고 있었고, 이런 이유를 찾을 수 있었다.

The issue is not, that 5 seconds (or something like 5 seconds) isn't enough time. And you do not start a task synchronously, because you have no handler. It is still a asynchronous call.

-applicationWillTerminate: is send on shut-down of the app. When it returns, the app is shot-down, even there are additional background tasks. So the background task is stopped, too. Therefore the background task only has milliseconds or less to finish.

다른 종류의 이벤트와는 다르게, 앱이 강제종료 되면 background task가 있던 말던 앱과 관련된 모든 task가 멈추기 때문에 applicationWillTerminate가 호출 되는 순간 앱은 몇 밀리세컨드 밖에 일을 못하고 종료된다는 내용이다.

Flutter의 WidgetsBindingObserver의 설명에는 앱이 강제종료 되어도 Flutter engine이 몇 초 동안 살아있기 때문에 모든 문제를 handling할 수 있다고 써져 있었는데, 이런 OS간의 차이점 때문에 iOS에선 제대로 작동하지 않는 것 같기도 하다.

솔루션!

많은 시간을 소비했지만 답은 의외로 간단했다.. 확실하진 않지만 AppDelegate.swift의 function stack에서 applicationWillTerminate라는 함수를 call 했다가 이 함수의 동작이 끝나고 function stack에서 사라지면 앱이 종료되는 구조를 띄고 있는 것 같았다.
따라서 해당 함수를 이렇게 바꿔주었다.

override func applicationWillTerminate(_ application: UIApplication) {
        applicationLifeCycleChannel.sendMessage("applicationWillTerminate")
        sleep(2); // <- 이 부분을 추가!!!
    }

실제로 앱을 swipe 해서 강제 종료한 뒤에도 2초 정도 살아있는 side effect가 있었지만 어떤 환경에서도 모든 API가 전부 불러졌다. 아드레날린이 펌핑 되며 이 맛에 코딩하지~~~ 라며 내적 댄스를 추었다.

끝내며...

사실 클라이언트 쪽에선 앱이 강제 종료 될 때 '야 나 꺼진다~'라고 서버에 아주 가벼운 notification만 보내는 것이 Best Practice 였다고 생각한다. 나머지 일은 서버가 해주고, 클라이언트는 최대한 적은 일만 하는 것이 더 나았다고 생각한다. 하지만 빠른 시간안에 일을 해야했었고 서버가 갑자기 이 일을 대신 해주면 그로써 발생하는 에러를 찾는데 시간이 더 오래 걸릴 것 같았다...

어찌 됐든 이번에 유저가 앱을 강제 종료 했을 때 이벤트를 처리하는 방법의 아주 매운 맛(?)을 보았던 것 같고, 다음에 프로젝트 할 때는 이런 부분까지 감안해서 서버와 클라이언트 사이의 load balancing을 잘 설계해야 한다는 것을 배웠다.

끝으로 결국 앱 프론트엔드의 끝은 Native라는 걸 배우며 다음 주 부터 Swift를 공부하기로 마음 먹었다!! 오히려 좋아~

Reference

Flutter detect killing off the app
https://stackoverflow.com/questions/52074265/flutter-detect-killing-off-the-app
How to execute code before app exit flutter
https://stackoverflow.com/questions/60184497/how-to-execute-code-before-app-exit-flutter


When AppLifeCycleState.detached gets called?
https://stackoverflow.com/questions/61846907/when-applifecyclestate-detached-gets-called
AppLifeCycleState.detached is not called when app is closed quickly
https://github.com/flutter/flutter/issues/57594


Writing custom platform-specific code
https://docs.flutter.dev/development/platform-integration/platform-channels?tab=type-mappings-swift-tab
How can I listen UIApplication lifecycle iOS in flutter
https://stackoverflow.com/questions/58065632/how-can-i-listen-uiapplication-lifecycle-ios-in-flutter
Flutter hybrid development: a detailed guide to communication between Android and flutter
https://programming.vip/docs/a-detailed-guide-to-communication-between-android-and-flutter.html


ApplicationWillTerminate NSURLSession Possible?
https://stackoverflow.com/questions/28465946/applicationwillterminate-nsurlsession-possible

profile
CJ ENM iOS 주니어 개발자

6개의 댓글

comment-user-thumbnail
2022년 3월 3일

오 결국 서버가 해야 할 일이었군.. 갓드 이때 마지막 주에 해내고 기뻤던 기억이 나네요 ㅎ.ㅎ ㅜㅜ

1개의 답글
comment-user-thumbnail
2022년 9월 8일

대단하시네요~ 이런 방법들이 있다니~~

1개의 답글
comment-user-thumbnail
2023년 8월 27일

이미 시간이 지난 글이긴한데 AppDelegate에서 sleep을 주었으나, 저는 너무 종료가 잘되네요.. ㅋㅋㅋ
서버가해야하는걸 앱에서 처리하려니 진짜 답답하네여

답글 달기