astamuse Lab

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

Gitlab CIからGoogle Cloud Buildを実行してみた

お久しぶりです。開発部のyanagitaです。
弊社ではGitリポジトリにGitlabを採用していて、CIはプロジェクトによってGitlab CIやJenkins、またその2つを組み合わせて使用しています。
今回は、Google CloudでCIサービスとして提供されているGoogle Cloud BuildをGitlab CIから実行するところまでを紹介します。

お詫び

Google Cloud Buildの実行には事前にサービスを有効にしておいたり、使用するアカウントにGoogle Cloud Buildの実行権限を与える必要がありますが、そのあたりは今回省略します。

Gitlab CIからGoogle Cloud Buildを実行する

以下は、.gitlab-ci.ymlです。下記の例はGoogle Cloud Buildを実行するただけの記述になります。

stages:
  - google_cloud_build

.google_cloud_build_template:
  stage: google_cloud_build
  image: google/cloud-sdk:313.0.0-alpine
  script:
    - echo $GCLOUD_AUTH_CODE | base64 -d > ./gcloud_auth.json
    - gcloud auth activate-service-account --key-file ./gcloud_auth.json
    - rm -f ./gcloud_auth.json
    - gcloud config set project $GCLOUD_PROJECT
    - gcloud builds submit . 

build_staging:
  variables:
    GCLOUD_PROJECT: stg_project
    GCLOUD_AUTH_CODE: $GCLOUD_AUTH_STG
  extends: .google_cloud_build_template
  rules:
    - if: $CI_COMMIT_BRANCH =~ /^(release|hotfix)_/

build_production:
  variables:
    GCLOUD_PROJECT: prd_project
    GCLOUD_AUTH_CODE: $GCLOUD_AUTH_PRD
  extends: .google_cloud_build_template
  rules:
    - if: $CI_COMMIT_TAG =~ /^\d{6}\.\d+-(release|hotfix)$/

流れに沿って解説します。
まずは、一番のキーとなるGoogle Cloud Buildの実行部分です。この部分はテンプレート形式で記述しています。理由についてはjobの解説時に説明します。

.google_cloud_build_template:
  stage: google_cloud_build
  image: google/cloud-sdk:313.0.0-alpine # 解説1
  script:
    - echo $GCLOUD_AUTH_CODE | base64 -d > ./gcloud_auth.json            #
    - gcloud auth activate-service-account --key-file ./gcloud_auth.json # 解説2
    - rm -f ./gcloud_auth.json                                           #
    - gcloud config set project $GCLOUD_PROJECT                          #
    - gcloud builds submit .                                             # 解説3

Google Cloud Buildの実行はgcloudコマンドを使用するためgoogle/cloud-sdkのイメージを使用します。(解説1)
gcloudコマンドの実行には事前に認証とGoogle Cloudのプロジェクトの紐付けを行う必要があります。ローカル等で認証を通す場合はブラウザの起動を挟んだりするのですが、gitlab ci上では行えないため認証情報が記述されたjsonファイルを使って認証を行います。その際、認証に必要なjsonを直接git上で管理するのは社内のGitリポジトリだとしても不安があるため、認証用のJsonの内容をBase64でエンコードした状態で環境変数($GCLOUD_AUTH_CODE)に格納しておき、認証直前でデコードした結果を認証用jsonとして復元する方式を取っています。 上記流れで復元した認証Jsonとプロジェクトの紐付けをgcloudコマンドで行います。(解説2)
記述中で2箇所環境変数を使用しています。理由についてはjobの解説時に説明します。
以下は、base64エンコードのコマンド例

$ base64 -i 認証用のjsonファイル
~~~~ エンコード結果 ~~~~~ # ← この結果を環境変数に実行時に格納しておきます。

最後にGoogle Cloud Buildの実行を行います。例だとcloudbuild.yamlをプロジェクトルートに配置しています。(解説3)

次に、jobの解説です。
弊社に限らずだと思いますが、ステージング環境と本番環境でGoogle Cloudのプロジェクトを分けて運用しているため、Google Cloud Buildを実行する環境ごとにプロジェクトや認証情報が変わってくることになります。
上記の環境差分を解消するため、Google Cloud Buildの実行環境ごとにJobを定義しています。例ではステージング環境向けと本番環境向けのJobを記述しています。

stages:
  - google_cloud_build

build_staging:  # ステージング環境向け
  variables:
    GCLOUD_PROJECT: stg_project
    GCLOUD_AUTH_CODE: $GCLOUD_AUTH_STG
  extends: .google_cloud_build_template  # 解説4
  rules:
    - if: $CI_COMMIT_BRANCH =~ /^(release|hotfix)_/

build_production: # 本番環境向け
  variables:
    GCLOUD_PROJECT: prd_project
    GCLOUD_AUTH_CODE: $GCLOUD_AUTH_PRD
  extends: .google_cloud_build_template # 解説4
  rules:
    - if: $CI_COMMIT_TAG =~ /^\d{6}\.\d+-(release|hotfix)$/

差分以外の定義に関しては、最初に解説したテンプレート形式での記述にすることで各Jobはテンプレートを継承し差分のみの記述で済むようになります。(解説4)
差分となる環境変数はvariablesで定義されています。

  • GCLOUD_PROJECT = Google Cloud Buildを実行するプロジェクト名
  • GCLOUD_AUTH_CODE = base64でエンコードされた認証Json

ここで、GCLOUD_AUTH_CODEは別の環境変数から値を定義する形を取っています。これはテンプレート側で同じ名前の環境変数を使用するために環境変数名の変更を行っています。
GCLOUD_AUTH_CODEに与えた環境変数の内容はProjectの Setting > CI/CD > Variablesで定義していて、内容はbase64でエンコードされた認証用のJson情報になっています。これで認証用のJsonをgit管理することなくGitla CI上の使用することができるようになります。

f:id:astamuse:20210707015648p:plain

以上でGitlab CIからGoogle Cloud Buildを実行する解説は終了です。

最後に

今回Gitlab CIからGoogle Cloud Buildの実行を解説しました。実際、Gitlab CIだけでも十分完結させることは可能です。ただ、うまく組み合わせることでより強力な環境でのビルドやGoogle Cloudの他サービスに柔軟に対応することができるようになります。

それではまたいつの日か

Docker環境でPXEブート環境を整えようとして苦しんだ話

f:id:astamuse:20181127232540j:plain

こんにちはnishikawaです。今回はDockerの勉強がてらにPXE環境の構築をしてみたので、そこでの気づきや結果などを書いていきます。

参考にしたのは、以前 弊社の元バンドマンであるgucci氏が書いてくれた記事を参考にしております。

lab.astamuse.co.jp

概要

Dockerを勉強するにあたり、PXE環境をDockerコンテナを用いて構築します。インストールするものはUbuntu Server 20.04 LTSですが、nginxの設定まで文章にしていたら記事が凄く長くなったので、この記事ではPXEでブートメニューを起動するところまでを対象とします。

最初やりたかったこと

自宅にPXE環境を構築するにあたり既存のネットワークを侵害しないようにNICが複数あるPCが必要だと思いました。理由はわざわざ資材をダウンロードしたりするのが面倒なのとSSH接続して設定などを行ったほうが楽だからです。で、自宅にワイヤレスインターフェースとイーサネットを備えたノートPCがあったので、これを利用して以下のような環境を構築しようと思いました。

これが悪夢の始まりと知らずに・・・

f:id:astamuse:20210526102758p:plain

結局構築したもの

で、多くの困難を乗り越えて最終的に構築したものが以下になります。見て分かるとおり、セグメントを物理的に分けてPXE環境を独立する予定が、同一セグメント内に集約されています。どうしてこうなったのかはこの後の文章をお読みください。

f:id:astamuse:20210526102804p:plain

最初にやった設定

では、最初構想していた環境を構築するための設定をつらつらと書いていきたいと思います。

gucci氏の記事ではtftpd-hpadhcpdを使用してましたが、同じものでやっても芸がないので今回はdnsmasqを使っていきたいと思います。

dnsmasqはDNSを提供するミドルウェアですが、DHCPやTFTPを提供する機能も持っています。なので、このコンテナを生成するためのDockerイメージを手始めに作成していきます。

Dockerイメージ作成

ディレクトリ構成は以下のような感じ

dnsmasq
├── Dockerfile
└── etc
    └── dnsmasq.conf

まずdnsmasq.confは以下のように設定を変更します。コンテナ内では root でプロセスを起動するのですが、デフォルトでは起動可能なユーザが設定されていないのでプロセスが立ち上がりません。そのためuser=rootを追加します。 あと、後ほど設定を追加するためにdnsmasq.dディレクトリ内の設定ファイルを取り入れられるようにしておきたいので、conf-dir=/etc/dnsmasq.dを有効にしておきます。

 # If you want dnsmasq to change uid and gid to something other
 # than the default, edit the following lines.
-#user=
+user=root
 #group=

・・・


 # Include another lot of configuration options.
 #conf-file=/etc/dnsmasq.more.conf
-#conf-dir=/etc/dnsmasq.d
+conf-dir=/etc/dnsmasq.d

設定を変更したらDockerfileを作成します。

FROM ubuntu:20.04

RUN apt-get update && apt-get install -y dnsmasq
VOLUME /etc/dnsmasq.d
VOLUME /var/lib/tftp

COPY etc/dnsmasq.conf /etc/dnsmasq.conf

CMD /usr/sbin/dnsmasq --conf-file=/etc/dnsmasq.conf && tail -f /dev/null

ポイントは/etc/dnsmasq.d/var/lib/tftpにアタッチポイントを作っておくところです。この後説明しますが、PXE用のdnsmasqの設定を後から/etc/dnsmasq.dに追加するのと、ブートローダを/var/lib/tftp配下に配置するのでここは必ず設定します。

後は先ほど変更したdnsmasq.confをCOPYでイメージ内に配置します。

最後に/usr/sbin/dnsmasqをdnsmasq.confを指定して起動します。ただ、この方法で起動するとバックグラウンドでプロセスを立ち上げてしまうためコンテナがすぐに終了してしまいます。

そのため最後にtailをフォアグラウンドプロセスとして立ち上げコンテナが落ちるのを防ぎます。

Dockerイメージの作成は以下のコマンドで行います。

# docker image build -t test-dnsmasq:1.0.0 .

Dockerコンテナ作成

次にdocker-composeを使用してコンテナを作成・管理したいと思います。

ディレクトリ構成は以下です。

/opt/pxe
├── dnsmasq
│   ├── etc
│   │   └── dnsmasq.d
│   │       └── pxe.conf
│   └── var
│       └── lib
│           └── tftp
│               ├── boot.img.gz
│               ├── ldlinux.c32
│               ├── mini.iso
│               ├── netboot.tar.gz
│               ├── pxelinux.0
│               ├── pxelinux.cfg
│               │   └── default
│               ├── ubuntu-installer
│               │   └── amd64
│               │       └── ...
│               └── xen
│                   └── ...
└── docker
    └── docker-compose.yml

まずはdnsmasqに設定を追加するため/opt/pxe/dnsmasq/etc/dnsmasq.d/pxe.confを作成して以下の設定を追記します。

#DHCP
interface=enp9s0,lo                          #インターフェス名を設定(今回はenp9s0という名前だったのでそれを設定しています)
bind-interfaces
dhcp-range=192.168.1.100,192.168.1.200,12h   #セグメントに沿ってレンジ設定(今回は192.168.1.0というネットワークでリース期間は12hを想定)
dhcp-option=option:netmask,255.255.255.0     #多分いらないと思うが、一応サブネットマスクを設定
dhcp-option=option:router,192.168.1.1        #これも多分いらないと思うがルータのIPを設定
dhcp-boot=pxelinux.0                         #ブートローダのファイル名

#TFTP
enable-tftp                                  #TFTP機能有効
tftp-root=/var/lib/tftp                      #TFTPのルートディレクトリ

/opt/pxe/dnsmasq/var/lib/tftp配下に以下のURLから資材を取得

http://archive.ubuntu.com/ubuntu/dists/focal/main/installer-amd64/current/legacy-images/netboot/

以下、docker-compose.ymlの設定です。

---
version: "3"
services:
  pxe_server:
    image: test-dnsmasq:1.0.0
    volumes:
      - /opt/pxe/dnsmasq/etc/dnsmasq.d:/etc/dnsmasq.d
      - /opt/pxe/dnsmasq/var/lib/tftp:/var/lib/tftp
    network_mode: "host"

以上まで準備ができたらコンテナを起動します。

# docker-compose up -d

これで普通ならうまくいくはずだったのですが、色んな要因が重なり多くの苦しみを味わう羽目になりました。

苦しみポイント1:netboot用資材が見つからない

gucci氏の記事ではUbuntu Server 18.04 LTSのインストールイメージで行っていたのでネットブート用の資材がISO内にあったのでしょうが、今回私が行ったUbuntu Server 20.04 LTSのISOには それがありませんでした・・・。

と言うわけで悶絶とまでは行かないまでもそれなりに調査に時間がかかりました。

苦しみポイント2:TFTPでブートローダが取得できない

Dockerコンテナを起動したので、いざOSインストール対象のPCを起動してみたのですが、以下のようなエラーが出てブートメニューが表示されません。

TFTP
PXE-E11: ARP timeout

どうやらTFTPでブートローダを落として読み込もうとしているのですが、アクセス対象のMACアドレスが解決できなくてエラーになっているようです。

これはなんだ?とずっと考えていたのですが、コンテナにarpコマンドを入れてarpテーブルを見てみたらなんとなく分かりました。以下が問題の箇所です。

Address                  HWtype  HWaddress           Flags Mask            Iface
192.168.11.29                    (incomplete)                              wlp7s0
192.168.1.112                    (incomplete)                              br-24cc64eb1e3c

・・・

Iface部分が本当は先程/opt/pxe/dnsmasq/etc/dnsmasq.d/pxe.confで設定したenp9s0になっていて欲しかったのですが、そうはなっていません。

ワイヤレスの方は正常に認識されているので、どうやらnetwork_mode: "host"によってコンテナに直接紐付けられたインターフェースはワイヤレスの方みたいです。これによりTFTPサーバへのアクセスができなかったのが原因だと考えます。

これを解決するため、docker-compose.ymlでインターフェースを指定できないか あれこれ調査しました。

苦しみポイント3:Dockerコンテナを起動するときにネットワークインターフェースを指定できない

という訳でDockerコンテナ起動時に紐付けるインターフェースを指定する方法を探したのですが、結論から言うと私は見つけることはできませんでした。

なので、複数の物理NICを持っているマシンでnetwork_mode: "host"を使用する時は思わぬ挙動になるため気をつけた方がよさそうというのが現時点での私の見解です。

以上から、当初想定していた環境の構築は抜本から考え直さないといけない状況になってしまいました。

そして改善へ

ひとしきり絶望した後、dnsmasqについてあれこれ調べていたらProxy DHCPという機能を見つけました。どうやらDHCPサーバが既に存在している環境においてPXEを提供するための機能らしいのでこれを使ってやってみることにしました。

やることは/opt/pxe/dnsmasq/etc/dnsmasq.d/pxe.confの設定変更で以下のように修正しました。

-#DHCP
-interface=enp9s0,lo
-bind-interfaces
-dhcp-range=192.168.1.100,192.168.1.200,12h
-dhcp-option=option:netmask,255.255.255.0
-dhcp-option=option:router,192.168.1.1
-dhcp-boot=pxelinux.0
+#Proxy DHCP
+port=0
+dhcp-range=192.168.11.0,proxy
+pxe-service=x86PC,"pxelinux",pxelinux

 #TFTP

・・・

これでコンテナを起動し直したらちゃんとブートメニューが表示されました。

まとめ

今回はDockerの勉強をしている過程で以下の知識を得ることができました。

  • Dockerコンテナに物理インターフェースを直接割り当てるにはnetwork_mode: "host"を使用する
  • 複数の物理NICを持っているマシンでDockerコンテナを起動する際network_mode: "host"を使用する場合はインターフェースを指定することができないので注意が必要
  • dnsmasqはDNSだけでなくDHCPとTFTPサーバとしても使える
  • Proxy DHCPという仕組みで既存のDHCPサーバがあるネットワーク環境下でPXEを追加で提供することができる

今回はgucci氏の記事を元にPXEサーバをdockerコンテナ化してみました。dockerをここまでしっかり触ったことがなかったので発見ばかりでしたが、とても苦しい・・・もとい、楽しい経験をしました。

今後はpreseedなどを駆使してOSのインストールも自動化できたら良いなと思ったので機会があったらやってみようと思います。

それでは。

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.