astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

Flutterでお勉強時間管理用のタイマーアプリを作った

こんにちは、rinoguchi です。過去二回は、Dataprocの話名寄せの話 などを書いたりしてました。

今回は趣向を変えて、子供のお勉強時間を管理するために iOS/Androidで動くネイティブアプリを Flutter で作ったので、その話をしようと思います。

最終的に作ったアプリは、以下のようなものです。

f:id:astamuse:20210418151708g:plain

Flutter の特徴

まずは、Flutter を知らない人のために、Flutter の特徴を紹介しておきます。

  • 【○】一つのコードベースで、クロスプラットフォーム(Android/MacOS/Windows/Linux/iOS/Web)で動作するアプリを作れるため、開発・運用・学習コストが小さい
  • 【○】特に UI に関しては、独自のレンダリングエンジンで動くため、端末・OSに依存しない
  • 【○】flutter 独自の UI コンポーネント(Widget)が豊富で、Widget を組み合わせて簡単に UI を作れる
  • 【×】一般的な端末固有機能は対応しているが、全て対応してるわけではなく、最新機能の対応は遅れる
  • 【○】Google がリードする OSS なので、開発継続性の観点で安心感がある
  • 【○】Adobe XD からコードをジェネレートできる(らしい)

今回は、マテリアルデザインの UI が個人的に好きなのと、我が家にもiOS/Androidユーザがいるのでクロスプラットフォームにしたかったので、Flutter を採用することにしました。

作ったもの

我が家では「勉強した時間だけ iPad/Switch で遊んでいい」というルールを設けているのですが、勉強時間は過大報告し、遊び時間は過小報告するという事案が多発しています。
これを解決するため、以下の要件を満たすシンプルなタイマーアプリを作ることにしました。

  • 勉強している時は時間をプラスできて、遊んでる時は時間をマイナスできるタイマー
  • 時間が00:00:00になったら通知してアラームを鳴らす

この記事では、本アプリ構築を通じて学んだ「Flutter アプリの基礎的な構成」と「苦労した技術要素」について解説してみようと思います。

基礎的な構成を確認する

最初期の段階で作った「Startボタンでタイマーを起動して、Stopボタンで停止するだけのタイマーアプリ」を元に、Flutter アプリの基礎的な構成を確認してみたいと思います。知ってるよという方は読み飛ばしてください。

f:id:astamuse:20210418153256g:plain

ソースコードは以下の通り。60行程度の比較的シンプルなコードです。

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

void main() {
  runApp(MaterialApp(home: TimerSamplePage())); // ここが Widget ツリーの起点
}

class TimerSamplePage extends StatefulWidget { // 状態を持ちたいので StatefulWidget を継承
  @override
  _TimerSamplePageState createState() => _TimerSamplePageState();
}

class _TimerSamplePageState extends State<TimerSamplePage> {
  Timer _timer; // この辺が状態
  DateTime _time;

  @override
  void initState() { // 初期化処理
    _time = DateTime.utc(0, 0, 0);
    super.initState();
  }

  @override
  Widget build(BuildContext context) { // setState() の度に実行される
    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: () { // Stopボタンタップ時の処理
              if (_timer != null && _timer.isActive) _timer.cancel();
            },
            child: Text("Stop"),
          ),
          FloatingActionButton(
            onPressed: () { // Startボタンタップ時の処理
              _timer = Timer.periodic(
                Duration(seconds: 1), // 1秒毎に定期実行
                (Timer timer) {
                  setState(() { // 変更を画面に反映するため、setState()している
                    _time = _time.add(Duration(seconds: 1));
                  });
                },
              );
            },
            child: Text("Start"),
          ),
        ],
      )
    ]));
  }
}

ここからは、このコードの解説をしていこうと思います。

まず、runApp()で、MaterialApp という Widget を画面にアタッチしています。 この Widget は一般的なマテリアルデザインのアプリケーションを提供するためのもので、ここを起点にUIを構成する Widget ツリーを組み立てています。

void main() {
  runApp(MaterialApp(home: TimerSamplePage()));
}

このシンプルなタイマーアプリの Widget ツリーは、以下の通りです。ソースコードと照らし合わせてみてください。なお、この Widget ツリーは、Flutter Devtools で簡単に確認できます。各Widgetの配置やサイズを確認できるのでとても便利です。

f:id:astamuse:20210416092849p:plain

タイマーで計測中の時間を画面上に表示するために状態を持つ必要があるため、TimerSamplePageStatefulWidgetを継承しています。通常の Widget は、状態を持つことはできません。

class TimerSamplePage extends StatefulWidget {
  @override
  _TimerSamplePageState createState() => _TimerSamplePageState();
}

状態を保持する_TimerSamplePageStateクラスでは、initState()で状態を初期化した後にbuild()を実行して UI を構築します。
その後は、setState()が呼ばれるたびbuild()を実行して UI に変更を反映します。
Flutter は、build()の再実行を高速化するように最適化されているため、Widget のインスタンスを個別に変更する必要はありません。

class _TimerSamplePageState extends State<TimerSamplePage> {
  Timer _timer; // こらが状態
  DateTime _time; // こらが状態

  @override
  void initState() {
    _time = DateTime.utc(0, 0, 0);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // 省略
    );
  }
}

この部分で、現在の時間(_time)を00:00:00形式で表示しています。

Text(
  DateFormat.Hms().format(_time),
  style: Theme.of(context).textTheme.headline2,
),

最後に、Startボタンをタップした際に、1秒毎に定期的に処理を実行する Timer を起動しています。
この Timer により、定期的に「_timeに1秒追加する関数」が実行され、さらにsetState()することでbuild()が再実行され、UI に変更が反映されます。

FloatingActionButton(
  onPressed: () {
    _timer = Timer.periodic(
      Duration(seconds: 1),
      (Timer timer) {
        setState(() {
          _time = _time.add(Duration(seconds: 1));
        });
      },
    );
  },
  child: Text("Start"),
)

ここまでで、Flutter アプリの基礎的な構成の説明は終わりですが、いかがでしたでしょうか。
次の項からは、実装していて苦労した技術要素について紹介していきます。

アラームを鳴らす方法

「時間が00:00:00になったらアラームを鳴らす」という要件のために、アラームを鳴らす方法を 探したところ Android Alarm Manager を発見したのですが、これはもちろん iOS では動きません。

調べていくと Flutter Ringtone Player という、 Android でも iOS でも動く着信音を鳴らすプラグインを見つけたので、これを利用することにしました。

依存関係を pubspec.yaml を記載して

dependencies:
  flutter_ringtone_player: ^3.0.0

dartファイルにimport文を追加し、

import 'package:flutter_ringtone_player/flutter_ringtone_player.dart';

以下のようにFlutterRingtonePlayerplay()playNotification()などの関数を呼び出すことでアラームを鳴らすことができます。とてもシンプルですね。

// アラームを開始する
FlutterRingtonePlayer.play(
  android: AndroidSounds.notification, // Android用のサウンド
  ios: const IosSound(1023), // iOS用のサウンド
  looping: true, // Androidのみ。ストップするまで繰り返す
  asAlarm: true, // Androidのみ。サイレントモードでも音を鳴らす
  volume: 0.1, // Androidのみ。0.0〜1.0
);

// 以下の3つは典型的なパターンへのショートカット
FlutterRingtonePlayer.playNotification(); // 一度だけ音を鳴らす
FlutterRingtonePlayer.playAlarm(); // アラーム音を止めるまで鳴らし続ける。Androidの場合はサイレントモードでも音が出る
FlutterRingtonePlayer.playRingtone(); // 着信音を止めるまで鳴らし続ける。Androidでもサイレントモード時は音が出ない

// アラームを停止する
FlutterRingtonePlayer.stop();

とはいえ、多少の注意点があります。iOS ではlooping: trueが効かず、一回実行しただけでアラームが止まってしまう点です。
これではアラームに気づかない可能性があるので、iOS の場合は Timer で定期的にFlutterRingtonePlayer.playAlarm()を実行する形にすることで対処しました。

import 'dart:async';
import 'dart:io';
import 'package:flutter_ringtone_player/flutter_ringtone_player.dart';

class Alarm {
  Timer _timer;

  /// アラームをスタートする
  void start() {
    FlutterRingtonePlayer.playAlarm();
    if (Platform.isIOS) {
      _timer = Timer.periodic(
        Duration(seconds: 4),
        (Timer timer) => {FlutterRingtonePlayer.playAlarm()},
      );
    }
  }

  /// アラームをストップする
  void stop() {
    if (Platform.isAndroid) {
      FlutterRingtonePlayer.stop();
    } else if (Platform.isIOS) {
      if (_timer != null && _timer.isActive) _timer.cancel();
    }
  }
}

通知を行う方法

次はアプリがフォアグラウンドにいない時でも、タイマーが終了したことに気づけるように通知を行うことにしました。Flutter Local Notification というプラグインが良さそうです。
これはサーバを介さずにローカル通知を行うことができるプラグインで、iOS / Android に対応しています。

まずは依存関係を pubspec.yaml に追記します。

dependencies:
  flutter_local_notifications: ^5.0.0+1

次に、Android用に /android/app/src/main/res/drawable/ フォルダ配下に通知アイコンの画像(app_icon.png)を配置します。

後は、FlutterLocalNotificationsPluginのインスタンスを生成して、initialize()して、zonedSchedule()を実行すると、ローカル通知がスケジュールされます。

ボタンを押したら5秒後にローカル通知を行うだけのサンプルアプリを作りました。

f:id:astamuse:20210418153849g:plain

実際にソースコードは以下の通りです。

import 'dart:math' as math;

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

void main() async {
  await setup();
  runApp(MaterialApp(home: NotificationSamplePage()));
}

Future<void> setup() async {
  tz.initializeTimeZones();
  var tokyo = tz.getLocation('Asia/Tokyo');
  tz.setLocalLocation(tokyo);
}

class NotificationSamplePage extends StatelessWidget {
  // インスタンス生成
  final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

  /// ローカル通知をスケジュールする
  void _scheduleLocalNotification() async {
    // 初期化
    flutterLocalNotificationsPlugin.initialize(
      InitializationSettings(
          android: AndroidInitializationSettings('app_icon'), // app_icon.pngを配置
          iOS: IOSInitializationSettings()),
    );
    // スケジュール設定する
    int id = (new math.Random()).nextInt(10);
    flutterLocalNotificationsPlugin.zonedSchedule(
        id, // id
        'Local Notification Title $id', // title
        'Local Notification Body $id', // body
        tz.TZDateTime.now(tz.local).add(const Duration(seconds: 5)), // 5秒後設定
        NotificationDetails(
            android: AndroidNotificationDetails('my_channel_id', 'my_channel_name', 'my_channel_description',
                importance: Importance.max, priority: Priority.high),
            iOS: IOSNotificationDetails()),
        uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
            child: FloatingActionButton(
      onPressed: _scheduleLocalNotification, // ボタンを押したら通知をスケジュールする
      child: Text("Notify"),
    )));
  }
}

いくつかポイントを説明しておきます。

  • 通知をzonedSchedule()を使用してスケジュール設定する場合は、TZDateTime でタイミングを指定する必要があるため、main()の中でsetup()を呼んでロケーションを設定している
  • Android用の設定のAndroidInitializationSettings('app_icon')の部分で先ほど配置したアイコン画像を指定している
  • IOSInitializationSettings()には何も指定してないが、ここで callback 関数を渡すことで、通知を受け取った際に何らかの処理を実行できる。しかし、これは iOS 10 以前のオプションなので使えない
  • zonedSchedule()で通知のIDを指定しますが、同じIDで複数の通知をスケジュールすると上書きされて最後の1回だけが実行される。またこのIDは通知スケジュールをcancel()際にも利用する
  • AndroidNotificationDetails()で通知チャネルIDを指定している
    • Android8.0以降の場合、音とバイブレーションは通知チャネルに関連付けられており、最初に作成されたときにのみ構成できる。通知を表示/スケジュールすると、指定したIDのチャネルがまだ存在しない場合は作成される。別の通知で同じチャネルIDが指定して、別の音やバイブレーションパターンを指定しても、何も変更されない
  • リアルタイムで通知を実行したい場合は、zonedSchedule()ではなくshow()を利用する

すこし癖がありますが、ドキュメントに詳しく書いてあるので、なんとなく理解できた気がします。

懺悔

これまで紹介した技術を使って、冒頭の動画のお勉強時間管理用のタイマーアプリを作ったわけですが、、、

実は、このアプリは iOS および Android ともに、エミュレータ上での動作確認では意図通りに動いていたのものの、実際のデバイスではバックグラウンドに遷移した際に上手く動かないことが分かりました。
これはバックグラウンドに遷移した際に Timer 自体が停止するためです。

これを解決する方法としては、

  • 案1) Timer をバックグラウンドタスクとして実行する
  • 案2) アプリがバックグラウンドに遷移したら時間記録+通知スケジュール登録して、フォアグラウンドに復帰したら差分時間をタイマー反映+通知スケジュール解除する

というのが思いつくものの、どちらも一筋縄ではいかなそうなので、とりあえずはフォアグラウンドでは上手く動いているので、普段使わない古いiPadで画面ロックしないよう設定した上で起動しっぱなしで運用してみようと思います。

[追記] 上記の案2)で上手く解決できたので、別記事を記載しました。
Flutter でバックグラウンドでも動くタイマーアプリを作った -rinoguchiブログ-

感想

今回、初めて Flutter でネイティブアプリを実際に作ってみましたが、Flutter はとても良くできていて UI の構築部分は API を眺めながらサクサク作れました。
問題は、OS・端末固有の事情で Flutter 及びプラグインが要件を満たす機能を提供してくれていない時で、そのようなケースの解決はかなり大変な印象です。また、普段からネイティブアプリを作ってる人には常識なのかもしれませんが、エミュレータでは動くけど実機で動かないケースも怖いですね。

ソースコード環境構築手順 も別途公開してますので、興味があればご覧ください。

さいごに

アスタミューゼでは、エンジニア・デザイナー・PM・コンサルタントを募集中です。ご興味のある方は遠慮なく採用サイトからご応募ください。お待ちしています。

Copyright © astamuse company, ltd. all rights reserved.