少し前に会社のブログで以下の記事を書きました。
Flutterでお勉強時間管理用のタイマーアプリを作った

このアプリには、「アプリがバックグラウンドに遷移するとタイマーが停止してしまう」という致命的な問題がありましたが、その対処方法が分かったので、紹介したいと思います。

問題点

アプリがバックグラウンドに遷移すると、アプリが一時停止してしまい、タイマーが止まってしまう。

対応方針

アプリがバックグラウンドに遷移したタイミングと、フォアグラウンドに復帰したタイミングで以下の処理を行う作戦でいこうと思います。

  • バックグラウドに遷移したタイミング
    • その時点の時間を元に、ローカル通知をスケジュール登録する(ローカル通知はバックグラウンドでも時間がくれば実行される)
    • バックグラウンドに遷移した日時を記録する
  • フォアグラウンドに復帰したタイミング
    • ローカル通知タイマーを停止する
    • フォアグラウンドに復帰した日時とバックグラウンドに遷移した日時の差分を、タイマーに反映する

注) Flutter ではアプリをバックグラウンドで起動させっぱなしにする方法はなさそうなので、上記を採用しました。

Flutter のライフサイクルイベント

Flutter のライフサイクルイベントをリッスンする方法については、iOS はこちら 、Androidは こちら に記載されており、ライフサイクルイベントのEnum値の説明も こちら にあります。

  • inactive
    • アプリはフォアグラウンドで実行されているが、非アクティブ(ユーザインプットを受け付けない)な状態
    • iOSでは、通話中、TouchIDリクエストへの応答時、アプリスイッチャーまたはコントロールセンターに入るとき、またはFlutterアプリをホストしているUIViewControllerに移行中の状態
    • Androidでは、分割画面アプリ、電話、ピクチャーインピクチャーアプリ、システムダイアログ、別のウィンドウなど、別のアクティビティに焦点を合わせると、アプリはこの状態に移行する
    • この状態のアプリはいつpausedに移行してもおかしくない前提になっている
  • paused
    • アプリがバックグラウンドに遷移した状態
    • アプリは表示されておらず、ユーザ入力に応答しない
  • resumed
    • アプリがフォアグラウンドに復帰した状態
    • アプリは表示され、ユーザー入力に応答する
  • detached
    • アプリは Flutter でホストされているが、ビューからは切り離されている
    • アプリ起動中(エンジンが初期化されてビューをアタッチ中)か終了中の状態

実際に試してみると、今回は pausedresumed に遷移するイベントを拾って、「対応方針」に書いたことを実行すれば良さそうです。

ちなみに、このイベントをする方法はとても簡単です。

StateクラスでWidgetsBindingObserverを継承してあげて、initState()addObserver()を実行して、didChangeAppLifecycleState()を上書きしてあげるだけです。

class _TimerPageState extends State<TimerPage> 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) {
    print(state); // 好きな処理を書く
  }
}

タイマーアプリのバックグラウンド対応

説明用に10秒をカウントダウンするだけのアプリを作ってみました。

アプリがバックグラウンドに遷移しても、

  • 時間がゼロになったら通知を飛ばす
  • 時間がゼロになる前にフォアグラウンドに復帰したら、通知をキャンセルして、時間を進める

という対応をしています。

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:intl/intl.dart';
import 'package:timezone/data/latest.dart' as tz;
import 'package:timezone/timezone.dart' as tz;

void main() async {
  _setupTimeZone();
  runApp(TimerApp());
}

// タイムゾーンを設定する
Future<void> _setupTimeZone() async {
  tz.initializeTimeZones();
  var tokyo = tz.getLocation('Asia/Tokyo');
  tz.setLocalLocation(tokyo);
}

/// タイマーアプリ
class TimerApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Life cycle Event Sample Timer',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: TimerPage(title: 'Life cycle Event Sample Timer'),
    );
  }
}

/// タイマーページ
class TimerPage extends StatefulWidget {
  TimerPage({Key key, this.title}) : super(key: key);
  final String title;
  @override
  _TimerPageState createState() => _TimerPageState();
}

/// タイマーページの状態を管理するクラス
class _TimerPageState extends State<TimerPage> with WidgetsBindingObserver {
  final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
  Timer _timer; // タイマーオブジェクト
  DateTime _time = DateTime.utc(0, 0, 0).add(Duration(seconds: 10)); // タイマーで管理している時間。10秒をカウントダウンする設定
  bool _isTimerPaused = false; // バックグラウンドに遷移した際にタイマーがもともと起動中で、停止したかどうか
  DateTime _pausedTime; // バックグラウンドに遷移した時間
  int _notificationId; // 通知ID

  /// 初期化処理
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  /// ライフサイクルが変更された際に呼び出される関数をoverrideして、変更を検知
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.paused) {
      // バックグラウンドに遷移した時
      setState(_handleOnPaused);
    } else if (state == AppLifecycleState.resumed) {
      // フォアグラウンドに復帰した時
      setState(_handleOnResumed);
    }
  }

  /// アプリがバックグラウンドに遷移した際のハンドラ
  void _handleOnPaused() {
    if (_timer.isActive) {
      _isTimerPaused = true;
      _timer.cancel(); // タイマーを停止する
    }
    _pausedTime = DateTime.now(); // バックグラウンドに遷移した時間を記録
    _notificationId = _scheduleLocalNotification(_time.difference(DateTime.utc(0, 0, 0))); // ローカル通知をスケジュール登録
  }

  /// アプリがフォアグラウンドに復帰した際のハンドラ
  void _handleOnResumed() {
    if (_isTimerPaused == null) return; // タイマーが動いてなければ何もしない
    Duration backgroundDuration = DateTime.now().difference(_pausedTime); // バックグラウンドでの経過時間
    // バックグラウンドでの経過時間が終了予定を超えていた場合(この場合は通知実行済みのはず)
    if (_time.difference(DateTime.utc(0, 0, 0)).compareTo(backgroundDuration) < 0) {
      _time = DateTime.utc(0, 0, 0); // 時間をリセットする
    } else {
      _time = _time.add(-backgroundDuration); // バックグラウンド経過時間分時間を進める
      _startTimer(); // タイマーを再開する
    }
    if (_notificationId != null) flutterLocalNotificationsPlugin.cancel(_notificationId); // 通知をキャンセル
    _isTimerPaused = false; // リセット
    _notificationId = null; // リセット
    _pausedTime = null;
  }

  // タイマーを開始する
  void _startTimer() {
    _timer = Timer.periodic(Duration(seconds: 1), (Timer timer) {
      setState(() {
        _time = _time.add(Duration(seconds: -1));
        _handleTimeIsOver();
      });
    }); // 1秒ずつ時間を減らす
  }

  // 時間がゼロになったらタイマーを止める
  void _handleTimeIsOver() {
    if (_timer != null && _timer.isActive && _time != null && _time == DateTime.utc(0, 0, 0)) {
      _timer.cancel();
    }
  }

  /// タイマー終了をローカル通知
  int _scheduleLocalNotification(Duration duration) {
    print('notification scheduled.');
    flutterLocalNotificationsPlugin.initialize(
      InitializationSettings(android: AndroidInitializationSettings('app_icon'), iOS: IOSInitializationSettings()), // app_icon.pngを配置
    );
    int notificationId = DateTime.now().hashCode;
    flutterLocalNotificationsPlugin.zonedSchedule(
        notificationId,
        'Time is over',
        '',
        tz.TZDateTime.now(tz.local).add(duration),
        NotificationDetails(
            android: AndroidNotificationDetails('your channel id', 'your channel name', 'your channel description',
                importance: Importance.max, priority: Priority.high),
            iOS: IOSNotificationDetails()),
        uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime);
    return notificationId;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
      Text(
        DateFormat.Hms().format(_time),
        style: Theme.of(context).textTheme.headline2,
      ),
      Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          FloatingActionButton(
            onPressed: () {
              if (_timer != null && _timer.isActive) _timer.cancel();
            },
            child: Text("Stop"),
          ),
          FloatingActionButton(
            onPressed: _startTimer,
            child: Text("Start"),
          ),
        ],
      )
    ]));
  }
}

ライフサイクルイベントのpausedresumedに「対応方針」で書いた内容を実装しているだけなので、説明は割愛します。コメントを多めに書いてるので、 興味がある方は参考にしてみてください。
手元で動かしてみたい方は、実際動かした ソースコード も公開してます。

妥協したポイント

こちら、一見完璧に見えるのですが、タイマーアプリなので、本当はローカル通知ではなくアラーム(鳴り続ける)として実行したかったです。

しかし、アラームを鳴らすための Flutter Ringtone Player は、その瞬間に着信音を鳴らすプラグインで、スケジュール登録はできず、他にアラームをスケジュール登録できるようなプラグインも見当たらないので今回は諦めました。

さいごに

今回、バックグラウンドに遷移してもちゃんと時間を進めて、時間が来たら通知してくれるタイマーアプリを作ってみました。
少し妥協した点はあるものの、Flutterおよびプラグインが提供してくれている範囲内で、シンプルに実現できたのがよかったです。

ちなみに、子供がYoutubeやアマゾンプライムを見る端末がiPadなのですが、App Storeに登録するのは毎年1万円ぐらいかかるらしいのでお金を払う気が起きず、Android Studioからリリースモードで直接実機にデプロイして使っています。2,3日で有効期限が切れるので、しょっちゅうデプロイが必要なのが面倒ですが(涙)