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

by Naoto Ishida Kenichi Ebinuma Yuki Ono Kaisei Sunaga Yaya Watanabe | June 25, 2020
event | #dena #dena-engineers-blog #flutter #grpc

前回に引き続き、『日比谷音楽祭公式おさんぽアプリ2020』(以下、おさんぽアプリ)のクライアント編をお伝えします。


この記事の概要

  • 事前に新機能で必要なUI・ロジックを洗い出すことで効率的に開発した
  • gRPCによりモックを簡単に作ることができ、サーバー/クライアントで同時に開発できた
  • 時間と慣れが必要なSupernovaについてデザイナーさんと進め方を工夫した
  • 最大9曲の音楽を同期して再生するため、audioplayersを活用した
  • Flutterの良かった点、悪かった点、辛かった点を振り返った

今年のおさんぽアプリでは、FlutterやgRPCなど、いま話題の新しい技術を使用しています。 クライアントの開発メンバーは20新卒4名(石田・海老沼・小野・渡部)と21卒内定者1名(砂賀)、フルリモートでの開発、かつ0からの開発ということで、大きな挑戦をしました!

本記事では、内定者のみでどのように開発を進めていったのか、またおさんぽアプリではどのような技術を使用したのかについて、クライアントチームのメンバーがお届けします!

関連記事

こちらの記事も合わせてご覧ください。

今年のおさんぽアプリ

今年の日比谷音楽祭は新型コロナウイルス感染拡大の影響で中止になってしまいました。 元々は日比谷公園を活用して、「公園で楽しむ音楽祭アプリ」でしたが、急遽内容を変更して、「おうちで楽しむ音楽祭アプリ」ということで、日比谷音楽祭のウェブサイトやYoutubeなどを訪れて答えを探しながら、クイズに答えることで日比谷音楽祭に出演するミュージシャンをゲットできるというとても楽しいおさんぽアプリに変貌しました!

全員集めると、このアプリでしか聴けない豪華ミュージシャンによる演奏を楽しむ事が出来るので、続きはアプリをインストールしてみてください!

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

昨年度のおさんぽアプリはiOS/Android用に別々に実装していたため、今年は0からのスタートでした。 開発メンバーにはFlutterの開発の経験者は1人もいませんでしたが、ベテランエンジニアが準備してくれていた参考資料をもとに開発進めました。 勤務開始後は上司からの指示を待つのではなく、内定者たちも自主的にGithubのissueを確認したり追加しつつ、どんどん実装を進めていきます。 このようにDeNAでは積極的に自分から仕事を取りに行く姿勢で仕事をしています!

また、ライブラリの動作確認・検証も内定者たちで行いつつ、導入しています。 本来のおさんぽアプリでは二次元バーコードを読み取って、ミュージシャンを集めるという要件がありました。 Flutterには二次元バーコード用のライブラリは沢山あります。 しかしAndroid/iOSにて動作確認をしてみると、バージョンが高かったり、GithubでのStarが多いライブラリでも思ったように動かない場合があり、ライブラリの検証の重要性を強く感じました。

Flutterでの開発に慣れてくると、内定者同士でレビューし合うことでアプリの質を上げていくことができました。

日比谷音楽祭の中止が決まってからの対応について

中止が決まってから、新機能とデザインが確定したのが4月末でした。 変更点は、チケット機能とマップ機能の削除、新機能として4種類のクイズ画面追加、クイズ用のAPI対応です。 4月から20卒のメンバーは研修のため開発に参加することができず、1人で1週間以内にこれらの機能を開発する必要がありました。

チケット機能(図左)やマップ機能などは廃止し、新機能としてクイズ(図右)を追加

チケット機能(図左)やマップ機能などは廃止し、新機能としてクイズ(図右)を追加

できるだけ効率よく開発するために、以下の3つのことに気を付けました。

1つ目は、事前に新機能で必要なUI・ロジックを洗い出すことです。共通化できるUIやロジックであったり、画面遷移のパターンをまずは整理しました。 例えばクイズ機能に必要な画面は以下の4種類です。

  • クイズ回答画面
  • 正答画面
  • 誤答画面
  • 通信エラー画面

また、その中でも

  • 画面下部でキーボード入力をする場合がある
  • 全画面ではなく、ダイアログ表示をする

といった難しい要件がありました。

アプリ開発において、キーボードの挙動は非常に厄介です。画面下部で入力する場合、入力欄が隠れてしまう問題があります。 Flutterでは幸い、SingleChildScrollViewを使うことで画面をスクロールしてテキストの入力欄が隠れないようにすることができるため、すんなりと対応できました。

次に、ダイアログ表示をする点です。ダイアログ内で複数の画面遷移がある場合、ダイアログの大きさが画面に合わせて変化する点や、画面間でのデータの受け渡しが難しくなる問題があります。 試行錯誤した結果、ダイアログの大きさは、ダイアログ内の画面に合わせてアニメーションを行うようにしました。また、画面間のデータ受け渡しは共通の親ウィジェットを定義し、そこから正答・誤答などのデータを扱う設計としました。

少し面倒なUI設計でしたが事前に洗い出したおかげで、これらの条件を考えつつ実装を進めることができました。

2つ目に気を付けて開発した点は、焦って開発しないことです。急がば回れ的な考えで、基本に従って以下のように開発を進めました。

  • 事前にUI・ロジックの洗い出しを行う
  • 洗い出した部分をBLoCパターンに基づいてUIとロジックに分離する(BLoCに関しては後述)
  • 実装する

3つ目は依頼する必要がある部分(デザインの追加など)は、できるだけ先に依頼しておくことです。 開発で足踏みするのは出来るだけ避けたかったため、新機能の実装方法を洗い出す段階で気を付けていました。 今回はエラー画面の考慮漏れがあったため、クイズの回答画面などを作っている間に質問を投げておきました。 幸い依頼する点は他に無かったためスムーズに開発を進めることができました。

以上の3つの点はかなり基本的な部分だと思います。ですが、経験的に遠回りしたほうが結果的には早く実装できることが多かったため、いつも以上にこれらの点を意識して開発しました。 結果的には素早く・バグも少ない良い実装をすることができました。

使用技術の解説

gRPC

クライアント・サーバ間の通信にはgRPCを使用しています。

gRPCとは、HTTP/2を使ったハイパフォーマンスなRPCフレームワークです。Protocol BuffersというIDLを用いてデータやインターフェースを定義することによって、サーバー/クライアントのコードを言語ごとに自動生成することができ、通信を行うことができます。 対応言語はこちらをご覧ください。

以下のProtocol Buffersを定義することで、簡単にクライアントのコードを生成することができます。

インターフェース・データ構造が定義されたProtocol Buffers

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}
// The response message containing the greetings
message HelloReply {
  string message = 1;
}

自動生成されたコードを利用したクライアント(Dart)の実装

Future<void> main(List<String> args) async {
  final channel = ClientChannel(
    'localhost',
    port: 50051,
    options: const ChannelOptions(credentials: ChannelCredentials.insecure()),
  );
  final grpcClient = GreeterClient(channel);

  final name = args.isNotEmpty ? args[0] : 'world';

  try {
    var response = await grpcClient.sayHello(HelloRequest()..name = name);
    print('Greeter client received: ${response.message}');
    response = await grpcClient.sayHelloAgain(HelloRequest()..name = name);
    print('Greeter client received: ${response.message}');
  } catch (e) {
    print('Caught error: $e');
  }
  await channel.shutdown();
}

Protocol Buffersによってサーバー/クライアント間のインターフェースが既に定義されているため、モックを簡単に作成することができ、サーバー・クライアントが同時並行で開発を進めることができるという大きなメリットがありました。 また、レスポンスのためのクラスが自動で生成されているため、サーバーから受け取ったデータをわざわざDart用にパースする必要がありません。

クライアント側としては、gRPCは非常に使い勝手がよく、また使いたいと思う技術でした! サーバー編でも述べられていますが、gRPCを使っていて不便だった点は、動作確認のためにcurlが使えない点です。 代わりにgrpcurlとよばれるツールを使って動作確認を行いました。

Supernova

Supernovaは、SketchやAdobe XDといったプロトタイピングツールから、各種プラットフォームのUIを生成できるツールです。 主要なプラットフォームはほとんどサポートしていて、具体的にはiOS、Android、React Native、Flutterのコード生成に対応しています。

実際に使ってみた印象としては、時間と慣れが必要だと感じました。 理由としては、各UIのどの部分がボタンで、どの部分が画像なのかといった部分を細かく指定する必要があるからです。 指定しなかった場合、生成されたコードがかなり汚くなってしまいます。

また、Sketchファイルの更新があると、最初からボタンなどを指定し直す必要もあるため、デザインの更新が難しいといった問題もあります。

そのため、デザイナーさんと事前に以下のような相談をすると良いかと思いました。

  • Sketchではできるだけシンボル化してもらう
    • アプリで共通のUI(ボタンなど)がある場合は、シンボル化しておくとコード生成がきれいになる(シンボルとはSketchの機能の1つで、要素をシンボルに登録することで元のシンボルを変更すると、そのシンボルを元に作られた要素も同時に変更される)
  • UIコード生成用として、アプリで使う画面とコンポーネントのみを定義したSketchファイル用意してもらう
    • 関係のない画面が含まれていると、コード生成時に手動で削除する手間が発生する
    • ファイルを分割することで、Supernova の動作を重くせず、UIコードを生成できる
  • UIに更新があった場合は、差分の画面等のみを含んだSketchファイルを用意してもらう
    • コードを手動で直した方が早い場合もあるため、Sketchファイルを用意すべきか相談すると良い
  • 各画面の名前をスネークケースで書いてもらうこと
    • スネークケースで書くことでDart公式の命名規則に一致するファイルを生成することができる
    • 例えば、Sketchでmain_screenとすると、main_screen.dartファイルにMainScreenクラスが生成される

ただ、もちろん良い点もあります。初期段階で全ての画面を大雑把に生成できるため、UIのチェックがしやすい点や、画像リソースや色のリソースを生成してくれる点です。 特に画像リソースは2x、3xといったスケールされた画像リソースも生成してくれるため、かなり手間が省けた印象です。

所感としては、大雑把にUIチェックがしたい場合は便利ですが、細かい実装まで行うことは現実的ではありません。 今回のような、比較的小規模なアプリでは便利に使えると思いました。ただ、初回のUI生成時以外は使える場面が少なかったため、既存のコードに対してUI差分を適用してくれる機能があると、継続的にSupernovaでUI生成ができると感じました。

Rive.app

集めたミュージシャンの演奏アニメーションはRive(旧 Flare)というアニメーションツールで作成しています。

実機では非常にヌルヌルとアニメーションするので、本当に楽しそうに演奏しているみたいです!

Riveで作成したアニメーションをFlutter上で表示するには、 flare_flutterを使用します。 コード例は以下の通りで、FlareActorウィジェットでラップするだけでとても簡単にアニメーションを追加できます。

@override
  Widget build(BuildContext context) {
    return new FlareActor("assets/Filip.flr",  // アニメーションファイル
      animation:"idle", // アニメーション名
      isPaused: false, // アニメーション再生状態
      alignment:Alignment.center,
      fit:BoxFit.contain,
    );
  }

短い開発期間の中でRiveを使用するにあたり工夫したことは、Flutterで問題なく表示できるかを検証するために簡素なアニメーションをデザイナーさんに用意してもらい、先に機能検証を実施しました。 表示が問題ない事を確認した後に、デザイナーさんにフルのアニメーションで作成いただいたため、開発/デザインともに手戻りがない状態で実装を進める事ができました。

また、Riveの基本機能は無償で使うことができるので、 自分でアニメーションを作ってみると面白いかもしれません。

またRiveを使ったアニメーションについても7月にDeNA DESIGN BLOGで紹介予定なので、お楽しみ下さい!

audioplayers

今回のアプリは、音楽を最大で9曲を同期しながら同時に再生する必要がありました。そのような機能に対応したプラグインをできるだけ探しましたが、流石に見つかりませんでした。

自前で実装することもできますが、Androidでは複雑な実装をする必要があったため、ここは諦めて音楽再生には、audioplayersプラグインを使用しました。こちらのプラグインは、audioplayerという別プラグインの派生で、複数の楽曲を同時に再生することができます。ただし、同期的に再生できないため、音ズレの心配がありました。

内部的にはiOSでAVPlayerクラス、AndroidでMediaPlayerクラスまたはSoundPoolクラスを使用しています。Flutter上では、これらのクラスを抽象化したAudioPlayerクラスを取得できます。

アプリでは最大9曲を同時に再生するため、AudioPlayerクラスのインスタンスを管理させるSyncPlayerという新たな音楽再生クラスを作成しました。楽器単位で音源を管理しているため、SyncPlayerに対して楽器の有効・無効を伝えると、それに合った音源の有効・無効を切り替える仕様になっています。

心配していた音ズレはあまり感じられませんでしたが、以下のようなsin波と逆位相のsin波を使って無音になるかどうかを確認したところ、音が聞こえたため厳密には音ズレは発生していました。(音は波の足し合わせで表されるため、音ズレがない場合は無音になります。)

sin波と逆位相のsin波

sin波と逆位相のsin波

実用上の問題はありませんでしたが、無線イヤホンを使うと音ズレが顕著になったため、対策をすることになりました。

まずは、音楽再生を始めた直後に数ミリ秒のディレイを追加しました。このようにすることで、音楽のバッファリングがある程度完了するのを待機することができます。最悪音楽が流れ始める危険もありますが、今回は音源が決まっていて、その音源の先頭には無音な時間があったので特に問題なく実装できました。

次に、ディレイ完了後に音楽の先頭へシークするようにしました。音楽の先頭部分のバッファリングはすでに完了しているので、先頭にシークして同時に再生を開始すれば遅延なく音楽を流せると考えたからです。

これらの対策を行なった結果、音ズレはほとんど分からなくなるレベルまで減らすことができました。

また、音楽再生において苦労した点として、たくさんの音楽を同時に流すとiOS 13の端末でエラーが起きる問題がありました。具体的には、別アプリからの再生割り込み時に、音楽再生を停止してAVAudioSessionを無効化しようとしても、

Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session

とエラーになってしまう問題です。 いろいろと修正を試みましたが、OS側の処理が挟まっている関係で修正できませんでした。しかし、その翌日にiOS 12では発生しないことを確認しました。

iOS 13にはオーディオ・ビデオ周りの更新が含まれていて、個人的にもバグにぶつかった経験がありました。これも恐らくiOS 13のバグだろうということで修正は行いませんでした。

BLoCパターンアーキテクチャ

状態管理のアーキテクチャはBloCパターンを主に採用しました。BLoCとは Business Logic Component の略で、UIからビジネスロジックを分離する設計パターンのことです。UIからビジネスロジックを分離することで保守性を高めることができます。 Streamを使う場合画面遷移等で不要になったものを破棄(dispose)する必要があるのですが、 provider パッケージを利用することで解決しています。 BLocパターンを今回はDartの StreamRxDart を使用して実装しました。

以下がStreamを用いたBLoC実装のサンプルコードです。

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Provider<CountBloc>(
      create: (_) => CountBloc(),
      dispose: (_, bloc) => bloc.dispose(),
      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: StreamBuilder<int>(
          initialData: 0,
          stream: bloc.count,
          builder: (context, snapshot) {
            return Text(snapshot.data);
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: bloc.increment,
        child: Icon(Icons.add),
      ),
    );
  }
}

class CountBloc {
  int _count = 0;
  final _countController = StreamController<int>.broadcast();
  Stream<int> get count => _countController.stream;

  void increment() {
    _count++;
    _countController.sink.add(_count);
  }

  void dispose() {
    _countController.close();
  }
}

Flutterの状態管理のアーキテクチャは ScopedModel/BLoC/Redux などいくつか選択肢がありますが、個人的には小規模のものであればシンプルに書ける ChangeNotifierStateNotifier 、大規模になってきたら flutter_bloc あたりが良さそうだなと思っています。 中止になってしまった DeNA TechCon 2020 のアプリは flutter_bloc を使用しています。気になる方は こちらも読んでみてください。

Flutterを使用して感じたこと

Flutterの良い点・悪い点・辛かった点を、クロスプラットフォームフレームワークのXamarin.Formsと比較しつつ書いていきます。

Flutterの良かったポイント

  • flutter doctorがかなり親切
    • 「Xcode足りないよー」や、「ライセンスに同意してねー」といったことをURL付きで教えてくれる
    • Android StudioやVisual Studio Codeのプラグインインストールの案内もしてくれる
  • Android/iOSのデバッグが安定している
    • Xamarin.Formsではかなり不安定だったりしたので、開発効率がとても良かった
  • Hot Reload
    • UIを保存するだけで、デバッグ中の画面に反映される
    • かなり便利で、これだけのためにFlutterを使いたくなる
  • Hot Restart
    • Hot Restartをすると、デバッグしたままアプリを再起動できる
    • これもかなり便利な機能
  • 独自の描画系を持っている
    • 描画にはSkiaを使用していて、クロスプラットフォームでもパフォーマンスが高い
    • Flutter 1.17から、iOSでMetalをサポートしたのでパフォーマンスがより良くなった

Flutterの悪かったポイント

  • OSネイティブのUI表現に弱い
    • これは独自の描画系を持っていることの弊害
    • 基本的にはマテリアルUIをベースに作る
    • Cupertino系のウィジェットを使えば、iOSのUIもある程度は表現できる
    • Xamarin.Formsは逆のアプローチで、OSネイティブのUIを使用している
  • アップデートへの追従に少し時間がかかる
    • 最新バージョンのXcodeではビルドができないことがあった(Xcode 11.4 Supportで修正済み)
    • 他のフレームワークも追従に遅れることはあるので仕方ない点ではある
  • プラグインによってはObjective-Cで書かれている場合がある
    • 以前はSwiftを公式サポートしていなかったため、昔に作られたプラグインではこの構成が多い
  • OS依存機能を使いにくい
    • MethodChannel経由でOS依存機能を使用するが、複雑な機能を作ろうとすると手間がかかる
    • MethodChannelはシングルトンな使い方をするため、OS依存機能のインスタンスを複数作る処理に向いていない

Flutterの辛かったポイント

シェア機能はOS依存な部分も多かったため、問題点がいくつかありました。

  • 各OS(iOS/Android)での依存問題だけでなく、各SNSでの共有機能に関する問題があるため、複雑
  • OS/SNSのバージョンによる制限/仕様変化への対応が面倒
  • 上記のような問題により、Flutter でシェア関係のライブラリは多いものの、全て微妙
    • 今回は、ほとんどのシェア機能を自前で実装することに
  • 画像の保存
    • iOS/Androidともに権限をとる必要があり、ネイティブコードを書かざるを得なかった

Flutterを使ってみた感想

Flutterの良い点悪い点などを色々と書きましたが、総合的に見てFlutterはとても優秀に感じています。 OS依存の開発では時間を取られますが、その開発時間はHot Reloadなどの便利な機能で十分カバーできていた印象です。 また、言語にDartを使用しますが、async、awaitといった機能もあるため個人的に不満はあまりなかったです。

加えて、チームの新卒メンバーは全員Flutter未経験でしたが、とてもスムーズにアプリを開発できました。おそらくFlutterのツール周りの出来の良さや、UI設計のしやすさといった部分が良い方向に働いたのかと思います。

今後、Flutterを選ぶかネイティブを選ぶかは、OSに依存した機能の多さで決めるのが良いと感じています。OS依存の機能があまりなければ、iOSまたはAndroid単体でリリースするアプリであっても、Flutterを選択肢に入れて良いと思いました。

おわりに

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

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

石田 直人

Flutter、フルリモートなど初めてづくしの開発でしたが、アプリがリリースされたのを見て本当に感動しました。内定者としてこのような機会をいただけたことに感謝です!

海老沼 健一

優秀な同年代のエンジニアと1からアプリを作ったのは良い経験になりました。実際に当日アプリが使われているのをTwitterやYouTubeで見て凄く嬉しかったです。 また、Flutterは初めてでしたが、UIを非常にスムーズに実装できたので良いなと思いました。今後も使う機会があれば積極的に使っていきたいです。

小野 雄紀

iOS のネイティブアプリ開発は経験してきましたが、クロスプラットフォーム開発、Flutter については初めてで、とても沢山のことを学ばせていただきました。出社したのは初日だけでフルリモート開発という点や、コロナの影響で中止とはなったもののリアルイベントが絡んだプロダクトであるという点など、技術的な部分以外での面白さもたくさんあり、とても良い経験となりました。ありがとうございました!

砂賀 開晴

初めてのFlutter経験で、Xamarin.Formsやネイティブ開発との違いを楽しみつつアプリを開発できました。サーバーサイドやデザイナーさんとの連携など、チーム開発ならではの経験もできて、エンジニアとして非常に成長できたと思います。ありがとうございました!

渡部 椰也

個人でのアプリ開発経験はありましたが、実務経験は初めての体験でした。 メンバーからアドバイスを沢山もらいながら、どんどん面白い機能が実装されていく様子がとても刺激的で本当に楽しかったです。 またリリース後にはTwitterやYoutubeにて沢山反応があり、すごく幸せな気持ちになりました!