日比谷音楽祭おさんぽアプリ2021 開発の裏側を語る / クライアント編

by Takuya Sakuma Taichi Moriya | July 01, 2021
event | #flutter

本ブログでは『日比谷音楽祭公式おさんぽアプリ2021』(以下、おさんぽアプリ)の開発の裏側についてクライアント編、サーバ編の2回に分けてご紹介します。 今回はサーバ編に先立って、クライアント編をお届けします。


この記事の概要

  • ProviderChangeNotifierを用いたモダンな状態管理によりクライアントサイド未経験者が多い中でも素早いキャッチアップができた
  • Google Mapに依存せずに地図機能を実装する方法を示した
  • 録画機能などレイアウト以外のIOはFlutterのみで実装できないためネイティブの知識が必要だと再認識した
  • Streamを活用して非同期処理の遅延を解消しFlutter上で音楽ゲームのようなシステムを実装した
  • 実装を通して感じたFlutterの使用感をまとめた

今年のおさんぽアプリは、昨年度利用したFluttergRPCが好感触であったことを受け、再び同じフレームワークを使用して開発しました。昨年度の記事はこちらをご覧ください。クライアントの開発メンバーは21新卒2名(砂賀・岸)と22卒内定者2名(佐久間・守谷)、フルリモートでの開発でした。昨年度からの知見や実装を利用できたものの、Flutterを利用する上で工夫が必要な機能が増えたため、やはり大きな挑戦となりました。

この記事では、内定者のみでどのように開発を進めていったのか、またおさんぽアプリではどのような技術を使用したのかについて、クライアントチームのメンバーがお届けします。なお、フレームワークについては昨年とほぼ同様なため、今年は各機能の実装に注目します。Flutterによる凝ったレイアウト及び機能の一例として見ていただければと思います。まとめるとこの記事は

  • Flutter開発を検討している人
  • おさんぽアプリ2021 開発の裏側を知りたい人

を対象にして

  • 単純なレイアウトだけでは済まない演奏や地図などをFlutterでどのように開発するか
  • 具体的に役立ったFlutter開発のパターン

などをご紹介します!

今年のおさんぽアプリ

今年の日比谷音楽祭は、

  • コロナ禍での開催ということもあり安全面、安心面のサポートをアプリで行う
  • 親子3世代対象の音楽祭において、主にお子様を対象とした簡易的に楽器を演奏する体験の提供する

というコンセプトの元開発が進められました。このコンセプトを受けてクライアント側では次の機能を開発しました。

  • 混雑度表示機能
  • 音の結晶集め機能
  • 集めた音の結晶での演奏機能

前者二つは安全・安心面のサポートを行うための機能です。アプリの地図上に混雑度を可視化することでユーザー自身での自衛手段となり、また結晶集めによって人々を誘導することで密集場所をできるだけ作らないような工夫をしました。

ところが、今年の日比谷音楽祭は新型コロナウイルス感染拡大の影響でオンラインでの開催になってしまいました。 元々は日比谷公園を活用して、「公園で楽しむ音楽祭アプリ」でしたが、急遽内容を変更して、「おうちで楽しむ音楽祭アプリ」としてリリースすることになりました。さらに昨年度とは異なり、オンライン開催の決定がGW明けだったため新機能は追加できず、上記三種類の内、演奏以外の機能を全て削除することになってしまいました。音の結晶は集められなくなってしまったため、音の結晶は初めから全て開放した状態での提供となりました。

以下にリリース予定だったストア画像を示します。一つ一つの機能についての細かい説明は省略しますが、これらの画像から搭載予定だった機能の概要を掴めると思います。

内定者同士での開発のようす

今年は昨年度の資源を有効に活用するため、再度Flutterを採用しました。それでもFlutterの開発経験者は1名だけでしたが、頼りきりにならず互いに協力しながら開発を進めました。 限られた工数、学生主体の開発、見積もり経験の浅いメンバーの中で、納期を守りつつ開発を進めていく上には、可視化されたタスク管理を行う必要があったため、JIRAのスクラムボードを用いることにしました。なお、納期がずらせない案件で大まかなスケジュールはウォータフォール的に決まっているため、全体を巻き込んだ正統派なスクラムは実践できませんでした。そのため工数をより正確に見積もることで、納期に間に合わないといった最悪の状況をいち早くキャッチできることを意識しました。 工数の見積もりにはストーリーポイントを利用しました。

  • メンバーごとにスキルが大きく異なる
  • フルタイムで従事していない

という環境において、相対的な基準で見積もれるストーリーポイントは理にかなっていました。 毎週クライアントの開発メンバーでプランニングポーカーを行い、今までのタスクを考慮しながら話し合ってポイントを割り振りました。

技術解説

ここからはFlutterでの開発について振り返ります。まず結論としてFlutterは開発元がGoogleということもあり、ドキュメントも充実していてキャッチアップしやすいフレームワークだと感じました。UI作成においては機能が行き届いており、マテリアルデザインから外れたものや後述する地図機能などのリッチなレイアウトでも問題なく対応できました。 その一方でFlutterはあくまでレイアウト作成に主眼を置いているのだと再認識させられました。今回のおさんぽアプリの目玉となった演奏機能のように音楽や録画などその他のIOが混ざるとネイティブとの通信が必要なため、実装に様々な工夫をせざるを得ませんでした。そんな中でも怯まず必要なUI・ロジックを洗い出すことで効率的に開発できました。

以降では使用した技術や今回開発した機能を実例として示しながら、Flutterの優れている点、工夫の必要な点を体感していただけたら幸いです。

開発を助けた技術

今回の開発を通して特に優秀だと感じた技術はChangeNotifierProviderです。これらはFlutter上で状態管理を実現するパターンの一つです。昨年状態管理に利用したBLoCパターンと同様に、レイアウトとロジックを分離することで保守性の高いコードを実現できます。この章では使用感や発生しうる問題とその対応策について述べていきます。

導入の経緯

昨年の開発にはBLoCパターンを用いていました。しかしながら開発を行っていたエンジニアから以下のような問題が報告されていました。

  • 冗長な記述
  • Streamの学習コスト
  • 複雑な状態管理の難しさ

まず1つ目についてですが、BLoCパターンでは出力でStreamを使うのでWidgetへのバインドにはStreamBuilderを使う必要があります。複数の状態を使いたい場合には更にStreamBuilderを重ねる必要があり、見通しの悪いコードになっていました。2つ目の学習コストの高さについてはBLoCは入出力をStream/Sinkに限定しているので、RXに代表されるようなStreamの概念の理解をしないと実装することが出来ません。またStream自体の学習コストの高さもあり、慣れるまでに時間がかかっていました。3つ目については複雑な状態を管理するにはStreamをどんどん変換しなければならず、追いにくい部分が生まれてしまう点です。

それぞれについて見ていくと、全体的にStreamに起因する問題が多かったことが分かります。 後述する ChangeNotifier はその点を隠蔽できるので導入することに決定しました。

ChangeNotifierについて

ChangeNotifierはシンプルに通知を送ることの出来るクラスで、notifyListenersメソッドを呼ぶことで状態の変化を通知することができます。ChangeNotifierをprovideするChangeNotifierProviderは通知を受け取ってWidgetの再描画を行います。BLoCパターンと違い、入出力にStreamSinkは使わずにProvider.ofで取得するだけで値にアクセスできます。これによりStreamBuilderをネストしていくような冗長な記述を減らせます。 また ChangeNotifierを扱う上でStreamの知識は必要不可欠ではありません。

ここまで良い点を紹介していましたがもちろん懸念点もあります。それは再描画のパフォーマンスです。ChangeNotifierを使うときに使用するChaneNotifierProviderは変更を通知された際に自身より下のWidgetを再描画します。その為大量にWidgetがある場合や複雑な画面の場合はパフォーマンスに問題が出る場合があります。これに対して、対応策としてChangeNotifierを使うWidgetを末端にする、context.readcontext.selectを使って意識的に再描画の範囲を指定する、といった方法が挙げられます。

導入してみて

実際に開発に使用してみて昨年挙げられていた問題は解決されたと思います。特に学習コストは大幅に減り、初めてFlutterを使うエンジニアでも苦労することなくキャッチアップできました。また懸念点として挙げられていたパフォーマンス面についてはStreamと使い分けることで問題を回避しました。Streamを採用した機能についてはデモ機能で詳しく述べます。

以下が簡単なサンプルです。去年の記事ではオーソドックスなBLoCパターンで書いてたので見比べてみると違いがわかると思います。

class CountBloc extends ChangeNotifier {

  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvier(
      create: (_) => CountBloc(),
      child: CountPage(),
    );
  }
}

class CountPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final bloc = Provider.of<CountBloc>(context);
    return Scaffold(
      appBar: AppBar(title: Text('Count Page')),
      body: Center(
        child: Text(bloc.count)
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: bloc.increment,
        child: Icon(Icons.add),
      ),
    );
  }
}

今回はChangeNotifier+Providerで十分快適に状態管理できましたが、これから開発していくなら新しく登場したRiverpodProviderを置き換えるとよりモダンな構成になると思います。state_notifierfreezedを使うことで更に安全な構成を取ることもできますが、学習コストもそれなりにかかるので開発の規模や状態の複雑さを加味して選定するのが良いと思います。

地図機能

オンライン開催の都合上、リリースされたアプリからは削除されてしまいましたが、地図機能は今回のおさんぽアプリの目玉機能の一つでした。この地図機能では単に現在地を表示するだけでなく、次のような機能の実装を予定していました。

  • 混雑度
  • 音色収集

どちらもコロナ禍での安全面、安心面のサポートする機能です。前者は公園内で混雑が予想される箇所をモニタリングし、どのくらいの人が集まっているかを動的に表示します。後者は公園内のあちこちに配置することで人々が音の結晶を集めに分散する意図で実装しました。音色は近づくとタップして獲得できるようになっており、獲得した音色は演奏画面で利用できるようになります。なお、リリースしたアプリでは地図機能の削除に伴ってこの音色が初めから全て解放された形になりました。

さて、Flutterで地図機能を実装する場合、google_maps_flutterの利用が考えられます。このパッケージはFlutter公式が適用しており、Googleマップをアプリ内に埋め込めるようになります。マップ上にオリジナルのピンを設置する機能やタップなどのイベントを受け取る機能もあり、大抵のユースケースではこのパッケージでカバーできるようになっています。 しかし、今回のアプリでは以下の懸念点がありました。

  • 混雑度の見た目は動的に変化する
  • 音色の見た目は現在地に近づくと変化する

見た目が変化するUIはFlutterを利用していればStatefulWidgetなどで簡単に実装できますが、このパッケージではUI表示が内部で完結しており、基本的にMapsObjectを継承したクラスのオブジェクトしか表示できません。もちろんWidget表示も不可能ではありませんが、パッケージに対する練度が必要です。 学習コストも考慮した結果、今回はgoogle_maps_flutterを利用せず一から実装することになりました。

google_maps_flutterを利用する上では混雑度や音色収集はネックとなる機能でしたが、Flutterの標準機能を使う場合はWidgetを利用して簡単に実装できます。本章ではFlutterで地図機能の実装の仕方について検討し、そのレイアウト作成能力について言及します。

最終的には以下の動画に示すようなシステムを作成します。

GPSから受け取った座標を地図上に現在地(青丸)として表示し、ピンなども同時に表示します。ピンなどのアイコンは地図を拡大縮小しても、画面上の大きさが変化しない仕様です。

システム全体の実装

いきなり地図を実装しろと言われると動揺してしまいますが、必要な機能を一つ一つ整理すれば実装の糸口が見えてきます。 地図に必要な機能を分析すると次のようになります。

  • GPSの取得可能
  • 緯度経度情報をFlutter上の座標に変換可能
  • 地図を移動拡大縮小可能
  • 地図上のピンは地図の拡大縮小に独立

一つ目に関してはネイティブから値をとる必要があり、二つ目に関しては高校レベルの数学知識から実装できます。後者二つに関してはFlutterの機能を利用して実装できます。したがってそれぞれ単体の実装はそれほどネックにはならないことがわかります。

GPSの取得

GPSの取得にはgeolocatorを用いました。geolocatorではgetPositionStreamメソッドによってGPS情報をStreamとして読み出すことができ、非常に扱いやすかったです。 実際には、GPSを利用するためにはrequestPermissionメソッドを利用して権限を要求しなければならないので、以下のようにラップして使用しました。

Stream<Position> _getPositionStream() async* {
    // 権限の要求
    final permission = await Geolocator.requestPermission();
    
    // 権限があればStreamを返す
    if (permission == LocationPermission.always ||
        permission == LocationPermission.whileInUse) {
      yield* Geolocator.getPositionStream();
    }
  }

今回のユースケースでは値を読み出す以外に用途はなく複雑な処理を要求しないため、問題なく実装を進めていけました。

座標変換の実装

緯度経度をいきなりFlutter上の座標に変換できないので、「緯度経度→地図画像上の座標→Flutter上の座標」という順番で変換します。 まず「緯度経度→地図画像上の座標」の変換です。前提として以下の図における地点$A$、$B$、$C$に対する緯度経度及び地図画像上の座標がそれぞれ既知とし、地点$P$の地図画像上の座標を求めることを目標とします。すると緯度経度座標上で辺$AB$に対する$AX$の割合を求めることで地点$P$の$x$座標が、辺$AC$に対する$AY$の割合を求めることで地点$P$の$y$座標が求まります。

triangle

例えば地点$A$、$B$、$C$の緯度経度がそれぞれ$(100, 100)$、$(101, 100)$、$(100, 101)$であり、地図画像座標が$(0, 0)$、$(1000, 0)$及び$(0, 1000)$、地点Pの緯度経度が$(100.7, 100.6)$とした場合を考えます。この時、緯度経度座標において、

$$ AX:AB=0.7:1 $$

より、$AX$は$AB$に対して$0.7$倍の長さなので、地図座標における地点$X$の$x$座標は

$$ (Aのx座標-Bのx座標) \times 0.7=700 $$

とわかります。同様にして地点$Y$の$y$座標は

$$ (Aのy座標-Cのy座標) \times 0.6=600 $$

と求まります。したがって地点$P$の地図上の座標は$(700, 600)$となります。 実際には地図は傾いているため、単純にはいきません。今回は三角形の性質を利用して地点$P$の計算式を導出します。

三角形$ABP$について、点$X$は頂点$P$から辺$AB$に下ろした垂線の足です。三角形の垂線とその足による内分点の性質より、次の関係が成り立ちます。

$$ AB : AX= 2AB^2 : AB^2+AP^2-PB^2 $$

よって各地点間の距離が求まれば割合が計算され、点$X$の$x$座標、すなわち点$P$の$x$座標が求まります。同様にして点$P$の$y$座標も求めます。今回はこの式を使って緯度経度情報を地図画像上の座標に変換しました。

続いて「地図画像上の座標→Flutter上の座標」の変換です。この変換は緯度経度とは異なり一方の座標系が傾いていないので、単純な比の計算で求まります。地図画像の大きさはピクセル数からわかり、画面の大きさはMeduaQueryから取得できるので、この変換は次のように実装できます。

double calcRatioOfImageToScreen(BuildContext context) {
  return (MediaQuery.of(context).size.width) / mapWidth;
}

ここで、mapWidthには地図画像の横幅のピクセル数が代入されているものとします。

移動拡大縮小の実装

移動拡大縮小はInteractiveViewerを利用することでその全てが可能となります。使い方は公式動画が非常よく纏まっているのでそちらをご覧ください。全編英語ですが日本語字幕もあるので問題なく視聴できます。

今回は画面に収まりきらない画像として地図があり、その上からピンや現在地などを描画していきます。この場合、以下のサンプルのようにInteractiveViewerの子WidgetにはStackを指定し、その要素として地図やピンを描画します。Stackを利用することで地図とピンを重ねて表示しているのです。

Widget _buildMap() {
  return InteractiveViewer(
    minScale: 1,
    maxScale: 3,
    child: Center(
      child: Stack(
        overflow: Overflow.visible,
        alignment: Alignment.center,
        children: [
          // まずは地図画像を表示
          Image.asset('path/to/map.png'),
          // Pinや現在地点を地図の上から描画していく
          _buildPin(context),
          // ...省略
        ],
      ),
    ),
  );
}

ここで、_buildPinメソッドの内部では前述の座標変換を施しているものとします。

地図の拡大縮小に独立させる方法

前述のInteractiveViewerは子Widgetを等しく拡大縮小してしまいます。次のGIF画像をご覧ください。地図上に表示しているピンも同様に縮小されるとピンが小さすぎて文字が読みづらくなってしまいます。またピンが拡大されてしまうとピンが画面を占領し、地図が見づらくなってしまいます。そのため、ピンは地図の拡大縮小に影響されないようにしなければなりません。

この問題を解決するにはInteractiveViewerTransformationControllerを追加する必要があります。TransformationControllerInteractiveViewer下で移動拡大を制御します。つまりピンチやドラッグによって地図を移動拡大縮小した時、その変換行列がTransformationControllerに保持されるようになっています。この保持されている拡大率を参照し、その逆数でWidgetを拡縮することでInteractiveViewerによる拡大を打ち消します。なお、拡大率の取得にはTransformationControllervalue.getMaxScaleOnAxisメソッドを使用します。

今回はScaleFixerクラスを作成し、このWidgetでラップされたWidgetをTransformationControllerの拡大縮小から独立させます。 以下は実装のサンプルです。

class ScaleFixer extends StatelessWidget {
  const ScaleFixer({
    Key key,
    @required this.child,
  }) : super(key: key);
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final controller = context.watch<TransformationController>();
    // Transforn.scaleでchildをscale倍に拡大する
    return Transform.scale(
      scale: 1 / controller.value.getMaxScaleOnAxis(),
      child: child,
    );
  }
}

実装を通して

以上で地図機能の用件を満たし、冒頭で示した動画のような操作が可能となります。地図機能は一見複雑で実装方針の建てにくい機能に思えましたが、一つ一つの機能を噛み砕いていくと結局Flutterの標準機能で大部分を実装できました。 この機能の実装を通して、Flutterはレイアウトにおいて多少複雑であっても公式で用意されているWidgetで十分賄えることがわかります。

録画機能

今回のアプリは、ユーザーの皆さんが演奏している様子をSNSに共有したり、保存できるように録画機能が備わっています。演奏画面の右上にあるボタンをボタンをタップすると録画が開始されて、標準のアルバムアプリに保存されるというものです。

実装について

この機能をどうやってFlutterで実装していくかというと、残念ながら現時点ではネイティブコードを呼び出す形でしか実現できません。実装するにあたり、当初は公開されているflutter_screen_recordingというプラグインを用いての実装を試みました。 しかしながら実際に試してみると一部端末では音が取れない、録画ができないといった問題が発生し自分たちでパッチを実装していく方針になりました。

前述した通りネイティブを使って録画を実装しないといけないのでiOSではReplayKit、AndroidではMediaProjectionを使用して録画を実装します。幸いなことに、Flutterでネイティブのメソッドを呼び出すのはとても簡単で以下のようにMethodChannelを通じて文字列と任意の値を送ることができます。

final _recordingChannel = const MethodChannel('RecordingChannel');

Future<void> startRecording() {
  await _recordingChannel.invokedMethod<bool>('startRecording')
}

ネイティブ側ではMethodCallで流れてきた文字列や値を取得できるので、応じたメソッドを呼び出してあげればいいだけです。メソッドが増えてきたら、値付きのEnumなどで分岐させるとよりすっきりした形でかけると思います。Swift及びKotlinでの実装を以下に示します。

public class ScreenRecordingPlugin: NSObject, FlutterPlugin {
  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    if call.method == "startRecording" {
      startRecording(result)
    } else if call.method == "stopRecording" {
      stopRecording(result)
    }
  }
} 
class ScreenRecordingPlugin(private val registrar: Registrar): MethodCallHandler, PluginRegistry.ActivityResultListener {
  override fun onMethodCall(call: MethodCall, result: Result) {
    if (call.method == "startRecording") { 
      startRecording(result)
    } else if (call.method == "stopRecording") {
      stopRecording(result)
    }
  }
}

ファイルのパスさえ返せれば再生することもファイルを操作することもできるので録画終了時に以下のようにパスを返してあげるようにします。

func stopRecording(_ handler: @escaping FlutterResult) {
  recorder.stopCapture {[weak self] error in
    self?.videoWriter?.finishWriting {
      handler(String(path))
    }
  }
}
fun stopRecording(result: Result) {
  mediaRecorder.stop()
  result.success(path)
}

ここまでの流れでアプリ固有の領域には動画が保存されている状態になったので、後はこれを標準の写真アプリに保存してあげる処理を入れれば完成です。

標準のアプリへの保存も同様にネイティブでの実装が必要になるのですが、動作に問題がないプラグインが公開されていたので今回はこちらのimage_gallery_saverを使用するだけの実装にしました。お気づきの方もいると思いますが、ストレージへの書き込みや音声の取得、画面の取得など多岐に渡って権限が必要です。こちらも通常ならネイティブの実装になりますが、今回はpermission_handlerを使用して権限を取得しています。

実装を通して

この機能を通して一番厳しかった点は様々な機種でのデバッグです。録画周りは実装が端末依存な部分も大きく、動作してるかを確かめるのにQAさんに様々な機種でデバッグしてもらいました。しかしながらQAさんの方から上がってくる報告に対してエンジニア側で環境を再現できなく、どう対処するか途方に暮れたこともありました。 今回はログの提出ももちろんですが、提出できない用に開発環境のみにFirebase Crashlyticsなども入れて対処しました。

Flutterはネイティブのエンジニアから見てもかなり便利になりましたが、レイアウト以外のほとんどのIOではFlutterのみで実装できないため、ある程度ネイティブの機能やコードについて知っておくのも大切です。

デモ機能

今回のおさんぽアプリは演奏機能が備わっています。本来は音色を日比谷公園内で集める仕様でしたが残念ながらオンライン開催となり、全楽器が初めから解放される仕様となりました。

そんな演奏画面にはピアノが弾けないユーザでも楽しめるようにデモ・ガイド演奏機能を搭載しています。デモ演奏では伴奏に合わせて自動的に楽器を演奏し、ガイド演奏では次にどの鍵を叩くのかを鍵色の変化で知らせます。つまり、実質的に音楽ゲームをFlutter上で実装することが要求されていたのです。

音楽ゲームにおいて、当然ながら音を鳴らす機能は必須です。しかし前述の通りFlutterはあくまでレイアウト作成をサポートしたフレームワークであるため、それ以外のIOにはMethodChannelを用いてネイティブと通信します。MethodChannelは非同期的に処理されます。さらに、譜面の各音符はタイミングや押し込み時間がそれぞれ異なるため、独立した非同期的な処理が要求されます。録画機能ではインタラクティブな動作が必要ないため、この点は問題にはなりませんが、音楽ゲームにおいては致命的となり得ます。したがって音源、ロジック、そしてレイアウトがそれぞれインタラクティブかつ非同期的に動作するシステムを作成しなければなりません。この章ではこうした非同期処理を多く扱う機能をどのように実装していったかをお見せします。

なお、デモ機能とガイド機能は共通点が多いため、以降ではデモ機能に絞って事例を示します。

システム全体の実装

非同期処理による問題は実際に作成して見なければわからないことも多いです。例えば非同期処理によって発生する遅延は素直に実装したとしてもほとんど知覚できないほどかもしれないのです。そこでまずはMethodChannelの問題は無視して、処理内容を分析し純粋に実装するところから始めていきます。

まずはシステム全体の分析です。デモ機能の処理の流れは次のようになります。

  1. メロディーの打鍵タイミングに合わせて特定の音源再生を開始する
  2. メロディーの打鍵タイミングに合わせて特定の鍵を青くする
  3. メロディーの離鍵タイミングに合わせて特定の音源再生を終了する
  4. メロディーの離鍵タイミングに合わせて特定の鍵を白くする

したがって、デモ機能は打鍵・離鍵タイミングに合わせて何かしらの処理を行います。 以降ではロジックを担当するクラスを打鍵タイミング管理クラス、音源を管理するクラスを音源クラス、レイアウトクラスを鍵UIクラスとします。冒頭の通り、各クラスは非同期的にやりとりすることを想定します。Flutter及びDartではこうした非同期処理のサポートが充実しており、Streamを介して通信できます。WidgetにおいてはStreamBuilderを利用して動的なUIを作成できます。以下に使用例を示します。

// broadcastで作成すると複数箇所で購読できる
final controller = StreamController<T>.broadcast();

// addすると購読者全てにvalueが伝達される
controller.add(value);

// 通常はlistenで購読する
controller.stream.listen((value) {
  // valueにaddされたデータが入る
});

// WidgetではStreamBuilderで動的なUIを実装できる 
StreamBuilder<T>(
  stream: controller.stream,
  builder: (context, snapshot) {
    // snapshot.dataにaddされたデータが入る
    final data = snapshot.data;

    // 返戻するWidgetが描画される
    // return Widget
  },
);

今回の場合、打鍵タイミング管理クラス鍵UIクラス音源クラスStreamを介して通信します。なお、今回のプロジェクトでは、レイアウトとロジックの接続にChangeNotifierを利用していますが、ここでは以下の理由によりStreamを利用した方がより自然に書けると判断しました。

  • 音源クラスとの接続にはStreamを利用した方が自然
  • 打鍵タイミングは「状態」というよりは「イベント」である
  • 鍵の数だけ状態を同時に管理する必要があり、利用側で意識しなければパフォーマンスが低下してしまう

以下にデモ機能のシーケンス図を示します。

sequence2

打鍵タイミング管理クラスは何かしらの処理が必要なタイミングでStream(正確にはSinkに追加し、Streamで読み出しますが、ここでは簡単にStreamとして扱います)にイベントを発行します。鍵UIクラス音源クラスはこのStreamを購読し、取得したイベントに合わせて色変更や音源再生など各々の処理を実行します。

音ズレ問題の対処

全体のシステムを作成できたので何か問題が発生していないか確認します。デモ演奏を再生してみるとAndroidにおいて以下のように伴奏と打鍵タイミングが一致しておらず、ズレた印象となっています。iOSにおいてはこうしたズレは発生していませんでした。

やはり最初にMethodChannelの問題を無視したことが原因でした。私たちのプロジェクトでは音源再生にjust_audioというプラグインを用いました。このプラグインでは以下に示すようにsetAssetで音源を読み込み、playで再生する、という仕様になっています。どちらも非同期で動作し、内部ではMethodChannelを用いてネイティブ側と通信をしています。setAssetは読み込みが完了すると同時にawaitを抜けるのに対してplayは音源の再生が終わるまでawaitを抜けません。また、一つのAudioPlayerにつき同時に再生できる音源の数は一つという仕様があります。

final player = AudioPlayer();

// 音源の読み込み(アセットを読み込むとawaitを抜ける)
await player.setAsset('path/to/asset.mp3');

// 音源の再生(音源の再生が終わるとawaitを抜ける)
await player.play();

今回作成するピアノは連打によって同時に複数個の音源を再生する可能性があるため、鍵タップ時にAudioPlayerを生成し、読み込み及び再生する仕様にしていました。そのため、打鍵イベント時に音源を読み込むところから始めていてはどうしてもワンテンポ遅れて聞こえてしまいました。この問題は以下の二点で構成されます。

  • 音源の読み込み(setAsset)が打鍵タイミングより前に完了しなければならない
  • play呼び出しによって実際に音が聞こえるまでの時間は制御できず、予め実行することも不可能

まず、デバッグによって前者の時間を計測すると、機種やタイミングによってばらつきが大きく、最大で1000ms程度だとわかりました。そこで、打鍵イベントよりも前にイベントを発行し、音源を読み込ませます。さらに、後者のplayメソッドによる遅延を考慮すると打鍵イベント受け取り時に再生しても間に合わないが分かっているので、音源クラスはこの新たなイベントで再生までを担うことにしました。具体的には打鍵前イベントのオブジェクトに再生時刻を変数として持たせ、打鍵イベントなしで音源クラスが適切なタイミングで音を鳴らせるようにしました。

内容が込み入ってきたので再度シーケンス図で整理します。図の赤い部分に注目してください。打鍵イベントを受け取る代わりに打鍵前イベントを受け取ると音源を読み込み、同時に送られてきた再生時間を参照して音を鳴らします。

sequence3

これで少なくとも前者の懸念事項は解決したので、ここからはplay呼び出しから再生までにかかる遅延問題に取り組みます。前述のようにplayメソッドは音源の再生が終わるまでawaitを抜けません。したがってplay呼び出しからユーザに音が聞こえるまでの時間は取得しづらいため、QAの方にテストをお願いし、体感で計測することにしました。 すると幸いなことに端末ごとの遅延時間に差がなく、どれも150ms程度の遅れが生じていました。したがって打鍵前イベントで得られた再生時刻の150ms前にplayを呼び出せばちょうど良いタイミングで音が鳴ります。

実装の全体像

ここからは実装したコードのイメージを示します。エラー処理やその他の問題への対処、及びガイド機能を省略した簡略版であることに注意してください。

まずは打鍵タイミング管理クラスです。

abstract class KeyStateChangedEvent {}

// 打鍵前イベント
class ScheduledPressEvent implements KeyStateChangedEvent {
  // 再生時間を変数として保持
  const ReleaseEvent(this.playTime);
  final Duration playTime;
}

// 打鍵イベント
class PressEvent implements KeyStateChangedEvent {
  // ...省略
}

// 打鍵後イベント
class ReleaseEvent implements KeyStateChangedEvent {
  // ..省略
}

// 打鍵タイミング管理クラス
class AutoPlayBloc {
  final StreamController<KeyStateChangedEvent>[] _keyEventStreamControllers;

  // デモでは打鍵前イベントのために1000msだけ楽譜を前もって再生する
  final Duration offset = const Duration(milliseconds: 1000);

  Stream<KeyStateChangedEvent> getEventStream(int keyIndex) {
    return _keyEventStreamControllers[keyIndex].stream;
  }

  Future<void> playDemo() {
    final player = AudioPlayer();

    // 音源の読み込み(アセットを読み込むとawaitを抜ける)
    await player.setAsset('path/to/asset.mp3');

    // 譜面の再生
    _playSheetMusicforDemo();
    await Future<void>.delayed(offset);

    // 音源の再生
    await player.play();
  }

  void _playSheetMusicforDemo() {
    // sheetMusicからノーツが送られてくる
    sheetMusic.play().listen((note) async {
      _keyEventStreamControllers[note.keyIndex].add(ScheduledPressEvent(offset));
      await Future<void>.delayed(offset);
      _keyEventStreamControllers[note.keyIndex].add(PressEvent());

      // 音を鳴らす時間だけ待つ
      await Future<void>.delayed(note.duration);
      _keyEventStreamControllers[note.keyIndex].add(ReleaseEvent());
    });
  }
}

KeyStateChangedEventEnumではなくクラスとして実装することでイベントにパラメータを持たせられます。ScheduledPressEventは、受け取った音源クラスが指定されたタイミング(1000ms後)に音を鳴らせるようにplayTimeを定義しました。sheetMusicは譜面を表しており、ここでは深く触れませんが、打鍵タイミングにノーツが発行するオブジェクトだと認識してください。ところが、実際の打鍵タイミングよりも前に打鍵前イベントが必要なため、譜面はoffset分だけ先に再生開始します。さらに注目すべきはStreamを利用したことでAutoPlayBlocは利用側を意識しなくて良い点です。Streamを利用しなければ、このクラスがイベントごとにIO処理を呼び出すことになり、システムの変更に弱い設計になっていた可能性があります。 続いて利用側である鍵UIクラス音源クラスの実装のイメージは次のようになります。

// 鍵UIクラス
class PianoKey extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 利用する側は打鍵タイミング管理クラスの存在を知っていてStreamを購読する
    final autoBloc = context.read<AutoPlayBloc>();
    return StreamBuilder<KeyStateChangedEvent>(
        stream: autoBloc.getEventStream(keyIndex),
        builder: (context, snapshot) {
             // Eventに合わせた描画処理
        }
    );
  }
}

// 音源クラス
class PianoKeyBloc {
  // Androidの時のみ再生オフセットを設ける
  PianoKeyBloc()
      : _offset = Duration(milliseconds: Platform.isAndroid ? 150 : 0);
  
  // ...省略

  Future<void> setAutoPlayStream(
    Stream<KeyStateChangedEvent> keyEventStream,
  ) async {
    await _keyEventSubscription?.cancel();
    _keyEventSubscription = keyEventStream
        .listen((keyEvent) {
        // Eventに合わせて再生処理
        if (keyEvent is ScheduledPressEvent) {
          _playWithDelay(keyEvent);
        } else if (event is ReleaseEvent) {
          _player.stop();
        }
        // ...省略
    });
  }

  Future<void> _playWithDelay(ScheduledPressEvent event) async {
    final stopWatch = Stopwatch()..start();
    await _player.setAsset('path/to/asset.mp3');

    // 読み込みにかかった時間とオフセットを考慮して再生時間まで待つ
    await Future<void>.delayed(event.playTime - stopWatch.elapsed - _offset);
    await _player.play();
  }
}

鍵UIクラスStreamBuilderで動的にレイアウト作成します。なお、打鍵管理クラスであるAutoPlayBlocProviderによって提供されているものとします。 音源クラスScheduledPressEventを受け取り、自動で再生処理を行っています。_playWithDelayではStopwatchを利用してアセット読み込みにかかった時間を計測し、その値とオフセットを考慮して再生時間まで待ちます。また、音源クラスは実際には打鍵管理クラスに依存していないため、打鍵管理クラスに状態が増えるなどの変化が生じても影響されません。

実装を通して

デモは非同期的な処理の多い複雑な機能でしたが、いきなり実装に取り掛からず、予め必要な処理を分析したことで、一つ一つの問題を冷静に対処できました。音ズレ問題によってロジックの変更が必要だとわかった際にも、UIに関係しているのはKeyStateChangedEventに制限されていたので、鍵UIクラスの変更は新たに追加された不要なイベントを無視するだけに止められました。Dartは言語仕様がわかりやすかったため、こうしたシステムの考案には時間をとられましたが、実装自体は短時間で行えました。一方でそもそもシステムに工夫が生じた原因の一端はFlutterとネイティブ側の通信、もしくは外部パッケージにありました。特に今回使用したjust_audioは何も考えずに音源を再生できる分、playメソッドで再生するまでにかかるオーバヘッドも大きく、最終的にボトルネックとなってしまいました。外部パッケージを利用したことで、ネイティブの記述を隠蔽できた代わりに打鍵前イベントというロジックレベルの変更が生じてしまったのです。

Flutterの使用感

昨年、OS依存機能が多い場合にはネイティブで開発した方が良いと結論づけたのにもかかわらず今年度は多くのネイティブに依存した機能をFlutter上で開発しました。結果として前章までに述べてきたような音ズレや録画など多くの問題に直面しました。それでもリリースに間に合わせられたのは

  • サポートが充実している
    • ドキュメント
    • CLI
    • 外部パッケージ
  • Dart自体は言語仕様的に書きやすい
    • await/async
    • Stream
    • MethodChannel

という点が大きく、特にキャッチアップをする上で非常に重要でした。今年のメンバーは一人以外Flutterの経験がなかったため、学習しやすい環境が整っていたことは非常に助かりました。また、Dartの機能も豊富で開発をしていて辛くなるような部分は少なかったのも助けになりました。ネイティブのロジックを使用する場面もありましたが、呼び出しはMethodChannelで簡単に行え、記述もSwiftとKotlinで行えるのは開発体験として非常に高かったです。

ここからはこうした実装を通して感じたFlutterの優秀な点や工夫が必要な点についてまとめます。

Flutterの優秀な点

昨年の記事によく纏まっているように、Flutterはクロスプラットフォームフレームワークとして非常に多くの利点を持ちます。その中でも今回のプロジェクトを通して強く感じた利点は次の通りです。

  • レイアウトを実装する上で必要な機能は揃っている
  • キャッチアップが容易

地図機能で示したようにレイアウトを組む上では特に苦労することはなく、ほとんどの機能は高レベルAPIであるWidgetを並べるだけで実装できました。実装に苦労が少なかった裏側には前述のようにFlutterは公式のドキュメントが豊富に用意されているという点も大きく、探せば実例と共に詳細な仕様が記述されているため思い通りに実装できました。加えてDartは可読性の高い言語で宣言時に{}で引数を囲むことで利用側でも引数の明示を強制でき、ほとんど無意識に他人が読めるコードを書けました。これらが相まって、Flutter未経験者でも容易にキャッチアップできました。また、ChangeNotifierをはじめとしてロジックとレイアウトを分離するパターンも容易に実現できるため、自動的にUIの変更に強い実装も実現できました。

Flutterの工夫が必要な点

一方で以下は今回の開発で度々問題となりました。

  • レイアウト以外のIOにはMethodChannelを用いてネイティブと通信する必要がある

MethodChannel自体は録画機能で示したように記述しやすく、ネイティブ側もSwiftやKotolinといったモダンな言語を利用できるため、使用感は悪くありませんでした。しかし、言い換えればレイアウト以外のIOを実装するためには、Flutter及びDartの知識だけでは不十分であるということです。特にAndroidには端末依存な部分が多いため、これらを解決する能力が必要です。さらにMethodChannelは非同期的に動作するため、リアルタイム性が要求されると、ロジックレベルの工夫が必要な場面もありました。

外部パッケージを利用することでMethodChannelの利用を隠蔽できますが、結局一般化のためにパフォーマンスが犠牲になっている可能性にも留意が必要でした。また、必ずしも整備が行き届いているわけではなく、自分たちで細かい検証が必須なので、根本的な解決に到るとは限りません。今回のようなリモート開発では手元にたくさんの端末を用意することが難しい場面もあり、検証が後手に周ってしまったのでプロジェクト初期から考慮に入れるべきでした。

おわりに

この記事では、『日比谷音楽祭公式おさんぽアプリ2021』の開発の裏側をクライアントの観点からご紹介しました。日比谷音楽祭2021は終わってしまいましたが、9月1日までの期間限定で遊べるアプリとなっております。ぜひこちらからインストールして触ってみていただけると嬉しいです。

クライアントチームからひとこと

佐久間 拓哉

クライアントサイドの開発が初めてでしたがデプロイまでの一通りの経験をさせていただきました。各所とやりとりしながらの開発は非常に刺激的でしたが、周りのサポートやFlutterの書きやすさも相まって非常に快適に開発を進められました!

岸 直輝

普段はサーバーサイドの開発をしていてクライアントサイドの開発をするのは初めてだったのですが、チームで色々教えてもらいながら多くのことを学ぶことができました。UIの作り方から状態管理までまるっと知れて知識の幅が広がりました!

砂賀 開晴

開発メンバーが時期によって4人〜11人とかなり流動的に変化するチームで、リードエンジニアをするというかなり貴重な経験を得ることができました!開発をリードしていくという立場になってから気づいた自分の反省点や強みを、今後の成長に生かしていきたいです!

守谷 太一

flutterでの開発は初めてでしたが思っていた以上にdartの機能が優れていることやflutterの学習コストの少なさとチームのサポートも相まってとても開発しやすかったです!緊急事態宣言などのリアルなイベントの影響をうける開発は初めてだったのでとても勉強になりました!