[Flutter] Flutter로 알람 앱 기능 구현하기

6 분 소요

흔히 생각하는 알람 앱을 Flutter를 이용하여 만들고 싶었습니다. 알람 시간이 되면 앱이 실행되고 알람 화면이 띄워져 알람이 울리는 것처럼요. 하지만 Flutter로 구현된 예제를 쉽게 찾을 수 없었습니다. 다행히 random-alarm를 찾게 되었고 많은 수정을 거친 코드를 공유하고자 이 글을 작성했습니다.

참고

  • 안드로이드에서만 작동합니다.
  • Flutter 3.0.1, Dart 2.17 버전을 기준으로 작성되었습니다.

샘플

작동 순서

먼저 앱의 알람 기능은 다음과 같은 순서로 작동합니다.

  1. android_alarm_manager로 알람을 설정한다.
  2. 알람 시간이 되어 android_alarm_managerAlarmBroadcastReceiver.onCreate함수가 호출된다.

    이 함수에서 SharedPreference를 이용하여 알람 ID 플래그를 생성하고, 앱을 실행한다.

  3. 앱이 실행되고 polling_worker를 이용하여 플래그가 생성되었는지 확인한다.
  4. 플래그가 존재한다면 상태를 변경하여 알람 화면을 띄운다.

요구사항

권한

안정적으로 알람 앱을 구현하기 위해서는 아래 권한이 필요합니다. 이는 안드로이드 10 이상에는 반드시 허용되어야합니다.

  1. 다른 앱 위에 표시(Display over other apps): 알람이 울렸을 때 앱을 최상단에 띄우기 위해 필요합니다.
  2. 배터리 최적화 무시(Ignore battery optimization): 배터리 최적화 기능(Doze mode) 때문에 가끔 알람이 제대로 동작하지 않을 때가 있는데 이를 방지합니다. 이전에는 Dart 콜백 코드에서 알람 플래그를 설정했었습니다(oneShot 함수에서의 callback을 말함). 이 콜백은 JobIntentService에서 enqueueWork를 통하여 실행이 되는데, 배터리 최적화 기능(도즈 모드)로 인해 딜레이가 있었습니다. 하지만 현재는 Dart 부분 콜백 대신 AlarmBroadcastReceiver에서 플래그를 설정하기 때문에 이 권한이 필요 없다고 판단됩니다. 또한 플러그인에서 내부적으로 setAlarmClock()을 이용하여 알람을 설정하기 때문에 잠자기 모드와 상관이 없다고 보입니다. 추가적인 정보는 잠자기 모드에 관한 안드로이드 문서에서 확인할 수 있습니다.

Android에서의 MainActivity.kt 수정

MainActivity에서 onCreate함수에 다음과 같이 추가해주세요. 알람 화면이 잠금화면에서 올바르게 나올 수 있도록 코드를 추가하는 것 입니다. 아래의 코드는 코틀린을 기준으로 작성되었습니다. (참고로 안드로이드 코드를 수정하실 때는 프로젝트의 안드로이드 폴더를 따로 열어서 수정하시면 편리합니다.)

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
        setShowWhenLocked(true)
        setTurnScreenOn(true)
        window.addFlags(WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON)
    } else {
        window.addFlags(
            WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED    // deprecated api 27
            or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD     // deprecated api 26
            or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON   // deprecated api 27
            or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
        )
    }
    val keyguardMgr = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        keyguardMgr.requestDismissKeyguard(this, null)
    }
}

패키지

필요한 패키지들은 다음과 같으며 해당 패키지의 README.md를 꼭 확인하세요.

  1. android_alarm_manager_plus pub package

    알람을 설정할때 이용할 플러그인입니다.

    알람이 작동할때, 즉 목표한 시간이 되었을 때 플래그 형성 및 앱을 실행하기 위해서는 플러그인 수정이 필요합니다. 제가 folk 해놓은 깃허브를 이용하시거나 해당 플러그인을 아래처럼 수정하세요.

    이미 수정된 플러그인 이용하기

    dependencies:
      android_alarm_manager_plus:
        git:
          url: https://github.com/jja08111/plus_plugins.git
          path: packages/android_alarm_manager_plus
          ref: alarm_app
    

    직접 수정하기

    먼저 AlarmFlagManager.java 파일을 플러그인의 안드로이드 부분에 다음과 같이 생성하세요. 이때 SharedPreferences의 put 유형을 Long으로 해야 플러터 코드 int로 읽어올 수 있는 것에 주의하세요.

    android_alarm_manager_plus 플러그인을 통해 설정된 알람의 ID를 SharedPreferences를 이용하여 저장하는 것을 볼 수 있습니다. 이 플래그는 Flutter 코드에서 알람을 작동시킬때 사용 할 것입니다.

    AlarmFlagManager.java

    package dev.fluttercommunity.plus.androidalarmmanager;
    
    import android.content.Context;
    import android.content.Intent;
    import android.content.SharedPreferences;
    
    public class AlarmFlagManager {
    
      private static final String FLUTTER_SHARED_PREFERENCE_KEY = "FlutterSharedPreferences";
      private static final String ALARM_FLAG_KEY = "flutter.alarmFlagKey";
    
      static public void set(Context context, Intent intent) {
        int alarmId = intent.getIntExtra("id", -1);
    
        SharedPreferences prefs = context.getSharedPreferences(FLUTTER_SHARED_PREFERENCE_KEY, 0);
        prefs.edit().putLong(ALARM_FLAG_KEY, alarmId).apply();
      }
    }
    

    그 다음 AlarmBroadcastReceiver.java파일을 다음과 같이 수정하세요.

    코드에서 플래그를 설정하고 화면을 키고, 앱을 실행하여 화면의 최상단으로 띄우는 것을 볼 수 있습니다.

    AlarmBroadcastReceiver.java

    package dev.fluttercommunity.plus.androidalarmmanager;
    
    import android.content.BroadcastReceiver;
    import android.content.Context;
    import android.content.Intent;
    import android.os.Build;
    import android.os.PowerManager;
    
    public class AlarmBroadcastReceiver extends BroadcastReceiver {
      @Override
      public void onReceive(Context context, Intent intent) {
        AlarmFlagManager.set(context, intent);
    
        PowerManager powerManager = (PowerManager)
          context.getSystemService(Context.POWER_SERVICE);
        PowerManager.WakeLock wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK |
          PowerManager.ACQUIRE_CAUSES_WAKEUP |
          PowerManager.ON_AFTER_RELEASE, "AlarmBroadcastReceiver:My wakelock");
    
        Intent startIntent = context
          .getPackageManager()
          .getLaunchIntentForPackage(context.getPackageName());
    
        if (startIntent != null)
          startIntent.setFlags(
            Intent.FLAG_ACTIVITY_REORDER_TO_FRONT |
              Intent.FLAG_ACTIVITY_NEW_TASK |
              Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
          );
    
        wakeLock.acquire(3 * 60 * 1000L /*3 minutes*/);
        context.startActivity(startIntent);
        AlarmService.enqueueAlarmProcessing(context, intent);
        wakeLock.release();
    
        if (Build.VERSION.SDK_INT < 31)
          context.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
      }
    }
    
  2. provider pub package

    상태관리를 위해 이용합니다.

  3. shared_preferences pub package

    알람 플래그를 형성할 때 이용할 것입니다.

  4. permission_handler pub package

    앱에서 사용자에게 권한을 요구할 때 필요한 플러그인입니다.

이와 같은 준비가 되면 알람 앱을 만들 준비가 끝났습니다.

기능 구현

알람 객체와 리스트를 구현하여 관리한다고 가정하고 이 글에서는 기능적인 부분 위주로 다루도록 하겠습니다.

1. 알람 설정

먼저 android_alarm_manager를 이용하여 목표하는 시간에 알람을 설정합니다. 이때 alarmClock을 true로 하여 안드로이드 내부에서 정확한 알람 시계로 작동할 수 있도록 합니다. 그리고 알람 작동시 스마트폰의 화면을 켜기 위해 wakeup 또한 true로 설정합니다. 재부팅시 알람이 작동할 수 있도록 rescheduleOnReboot도 true로 설정합니다.

void emptyCallback() {} // 이 함수는 최상단에 위치해야 한다. 즉 클래스 내부이면 static으로 정의해야 한다.

AndroidAlarmManager.oneShotAt(
  dateTime,
  id,
  emptyCallback,
  alarmClock: true,
  wakeup: true,
  rescheduleOnReboot: true,
);

위의 emptyCallback 함수를 보면 내부에 내용이 없는데요. 이용하지 않을 것이기 때문에 알람 설정을 위해 형식적으로 둔 것입니다.

플러그인의 콜백함수를 이용하지 않고 AlarmBroadcastReceiver.java를 이용하여 알람을 작동시키는 이유는 시스템에 의해 Dart 코드가 지연되어 작동할 수 있기 때문입니다. 지연되어 작동하면 알람이 제대로 동작하지 않을 위험성이 커집니다.

2. AlarmBroadcastReceiver.onCreate 호출

알람 시간이 되면 AlarmBroadcastReceiver.onCreate가 실행됩니다. 이때 위에서 설명한대로 플래그가 형성되고 앱이 실행됩니다.

저는 플래그를 확인 및 삭제하는 클래스를 아래와 같이 구현했습니다. 아래에서 alarmFlagKey가 위에서 설명한 flutter.alarmFlagKey의 뒷부분과 일치하는 것을 볼 수 있습니다. 이 클래스의 사용은 뒤에 설명하겠습니다.

alarm_flag_manager.dart

import 'package:shared_preferences/shared_preferences.dart';

class AlarmFlagManager {
  static final AlarmFlagManager _instance = AlarmFlagManager._();

  factory AlarmFlagManager() => _instance;

  AlarmFlagManager._();

  static const String _alarmFlagKey = "alarmFlagKey";

  Future<int?> getFiredId() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.reload();
    return prefs.getInt(_alarmFlagKey);
  }

  Future<void> clear() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove(_alarmFlagKey);
  }
}

3. Flag 탐색 후 상태변경

알람 플래그를 형성했으니 AlarmPollingWorker로 탐색을 하여 플래그가 있다면 알람이 울린 상태로 변경해야합니다. 이때 탐색기는 다음 두 가지 경우에 실행됩니다.

  • 앱 실행시 -> 앱의 매인 클래스 진입시
  • 앱을 종료하지 않고 다시 진입한 후 -> 앱 메인 루트 화면에서 WidgetsBindingObserver 이용

두 번째 경우에 앱이 실행 중일때 알람이 울린 경우도 포함됩니다. 그렇다면 어떻게 위와 같은 사항을 탐색할까요? 바로 앱의 메인 UI에 해당하는 곳에 WidgetsBindingObserver를 이용하면 됩니다. 이는 앱이 종료되는지 혹은 다시 진입했는지 등 앱의 라이프 사이클을 관찰하는 믹스인이라고 생각하시면 됩니다.

탐색이 된 경우 아래의 AlarmState의 상태를 변경하면 됩니다.

앱 실행시

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await AndroidAlarmManager.initialize();

  final AlarmState alarmState = AlarmState();

  // 앱 진입시 알람 탐색을 시작한다.
  AlarmPollingWorker().createPollingWorker(alarmState);

  ...

  runApp(MultiProvider(
    providers: [
      ChangeNotifierProvider(create: (context) => alarmState),
    ],
    child: MyApp(),
  ));
}

앱 재진입시

class SomeScreen extends StatefulWidget {
  @override
  _SomeScreenState createState() => _SomeScreenState();
}

class _SomeScreenState extends State<ObserveAlarm>
    with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

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

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {

      ...생략

      case AppLifecycleState.resumed:
        AlarmPollingWorker().createPollingWorker(context.read<AlarmState>());
        break;
    }
  }

  ...생략
}

alarm_polling_worker.dart

여기서 알람 플래그를 탐색하고 삭제하는 것을 볼 수 있습니다.

class AlarmPollingWorker {
  static final AlarmPollingWorker _instance = AlarmPollingWorker._();

  factory AlarmPollingWorker() {
    return _instance;
  }

  AlarmPollingWorker._();

  bool _running = false;

  /// 알람 플래그 탐색을 시작한다.
  void createPollingWorker(AlarmState alarmState) async {
    if (_running) return;

    debugPrint('Starts polling worker');
    _running = true;
    final int? callbackAlarmId = await _poller(10);
    _running = false;

    if (callbackAlarmId != null) {
      if (!alarmState.isFired) {
        alarmState.fire(callbackAlarmId);
      }
      await AlarmFlagManager().clear();
    }
  }

  /// 알람 플래그를 찾은 경우 해당 알람의 Id를 반환하고, 플래그가 없는 경우 `null`을 반환한다.
  Future<int?> _poller(int iterations) async {
    int? alarmId;
    int iterator = 0;

    await Future.doWhile(() async {
      alarmId = await AlarmFlagManager().getFiredId();
      if (alarmId != null || iterator++ >= iterations) return false;
      await Future.delayed(const Duration(milliseconds: 25));
      return true;
    });
    return alarmId;
  }
}

alarm_state.dart

class AlarmState extends ChangeNotifier {
  int? get callbackAlarmId => _callbackAlarmId;
  int? _callbackAlarmId;

  bool get isFired => _callbackAlarmId != null;

  void fire(int alarmId) {
    _callbackAlarmId = alarmId;
    notifyListeners();
    debugPrint('Alarm has fired #$alarmId');
  }

  void dismiss() {
    _callbackAlarmId = null;
    notifyListeners();
  }
}

4. 메인 화면에서 알람 화면으로 변경

이제 상태를 변경하였으니 알람 화면을 보여주면 됩니다. 저는 AlarmObserver라는 클래스를 만들어 화면을 분기했습니다. AlarmState.isFired가 true이면 알람 화면을, 아니면 홈 화면을 보여줍니다. 저는 앞서 소개한 WidgetsBindingObserver를 이곳에 적용했습니다.

alarm_observer.dart

class AlarmObserver extends StatefulWidget {
  final Widget child;

  AlarmObserver({
    Key? key,
    required this.child,
  }) : super(key: key);

  @override
  _AlarmObserverState createState() => _AlarmObserverState();
}

class _AlarmObserverState extends State<AlarmObserver>
    with WidgetsBindingObserver {

  ...생략

  @override
  Widget build(BuildContext context) {
    return Consumer<AlarmState>(builder: (context, state, child) {
      Widget? alarmScreen;

      if (state.isFired) {
        final callbackId = state.callbackAlarmId!;
        Alarm? alarm = context.read<AlarmListProvider>().getAlarmBy(callbackId);
        if (alarm != null) {
          alarmScreen = AlarmScreen(alarm: alarm);
        }
      }
      return IndexedStack(
        index: alarmScreen != null ? 0 : 1,
        children: [
          alarmScreen ?? Container(),
          widget.child,
        ],
      );
    });
  }
}

위에서 보이는 Alarm, AlarmScreen, AlarmListProvider 클래스는 flutter_alarm_app을 참고하시면 됩니다.

마치며

추가적으로 알람을 주 단위로 반복하여 설정하는 기능, 알람 다시 울림 기능(스누즈) 등을 구현할 수도 있습니다.

Flutter로 알람 앱의 기본적인 기능을 구현해봤습니다. 알람 기능만 네이티브로 따로 구현하는 것도 좋은 방법이라고 생각합니다. 이상 긴 글 읽어 주셔서 감사합니다!

수정

  • 2021-04-02 초기 업로드
  • 2021-07-09 내용 추가, 보완 및 Null-safety 적용
  • 2021-08-03 callback함수 삭제 및 플래그 설정 위치 변경
  • 2022-05-30 상태관리를 provider로 하도록 수정 및 예제 레파지토리 추가
  • 2022-06-02 배터리 최적화 무시 권한 내용 수정

댓글남기기