三連休に本気出したらFlutterでアプリを作成できるか検証してみた

## 変更履歴
2021年9月13日　Githubのリンクを変更（中身は変わっていない）

初稿
2020年3月25日　Flutter 2.0未対応

# はじめに
*「俺はまだ本気を出してない」*ってことで、この前の三連休に本気出してみました。
「三連休でも頑張ったら、アプリ作れるもんだぜ」というノリと勢いを伝えるのが本記事の目的です。:laughing:

# 作ったもの
`「早口言葉を音声認識で入力して敵を倒すゲームアプリ」`
![実際のスマホ画面.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277737/dab94df5-6703-d54c-6b50-e618cf16975c.gif)
こんな感じ。

# 知れること・知れないこと
知れること

- 「作ってやるぞ」という気合い。
- Flutter初心者がぶつかるであろう壁についての知見。
- 「自分でもできるかも」という自信が湧いてくる（**かも**）。

知れないこと

- DB接続を利用した処理の実装（追加機能でやりたい）
- 認証機能の実装（追加機能でやりたい）

# 記事作成者のステータス

- もうすぐエンジニア3年目。
- 普段はJavaでWeb系の開発やっている。
- 一度だけFlutterのハンズオンに参加した経験あり。
- ハンズオンに参加しただけで、どう足掻いてもFlutterもアプリ開発も初心者。

# まずFlutterって？
[Wiki](https://ja.wikipedia.org/wiki/Flutter)より
>Googleによって開発されたフリーかつオープンソースのモバイルアプリケーションフレームワークである。FlutterはAndroidやiOS向けのアプリケーションの開発に利用されている。

よく聞く特徴としてはAndroidでもiOSでも動く「クロスプラットフォームフレームワーク」の部分ではないかと思います。

また今回注目したいのは[公式][1]のここの部分
>Fast Development
>Paint your app to life in milliseconds with Stateful Hot Reload. Use a rich set of fully-customizable widgets to build native interfaces in minutes.

ざっくり「早く開発できる」と書いてあるものと判断。（Hot Reloadの説明とも取れますが）
**「本当に？」**ってことで今回作ってみます。

# フライングしたこと
本記事については冒頭まで書いた状態にしておきました。
ハンズオンに参加した関係で開発環境の構築も済です。
環境構築については、[公式ドキュメント](https://flutter.dev/docs/get-started/install/chromeos)や[Flutter 開発環境構築手順 (2019年 保存版)](https://qiita.com/tomy0610/items/896dc8ec9ba95c33194f)などなどご参照ください。

# 以降の記事の書き方について
3日間、実際にやったことを書いていきます。
（ちなみに私にとっての「1日」とは「夜寝るまで」を意味しています）
また「壁」と称したところが`Flutter`に関する内容を特に書いた場所なので、
「`Flutter`の開発の参考にしよう！！」という方は「壁」を中心に読んでいただければ幸いです。

# 1日目
## 開発環境の確認
まずは開発環境を確認。
`flutter doctor`の結果は以下の通りです。

```console
$ flutter doctor
[✓] Flutter (Channel stable, v1.12.13+hotfix.8, on Mac OS X 10.15.3 19D76, locale ja-JP)

[✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2)
[✓] Xcode - develop for iOS and macOS (Xcode 11.3.1)
[✓] Android Studio (version 3.6)
~~以下省略~~
```

開発は`Android Studio`で実施しました。

## 設計
この三連休のために温めておいた頭の中の設計図（そう大層なもんではないですが）を、外出しします。
iPadに（買ったのに滅多に使わない）Apple Pencilでお絵描き↓
（「途中から字が手書きやん」という苦情は受け付けないです）

<img width="600" height="500" alt="ファイル_001.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277737/264da794-d2e8-1159-62d9-99b9d4ccb6bf.png">


**アプリ概要**
一言でまとめると`「早口言葉を音声認識で入力して敵を倒すゲームアプリ」`。

**左の画面**

- ホーム画面
- レベル別のプレイ画面を選択（出てくる早口言葉が異なる）

**右の画面**

- プレイ画面
- ランダムでレベルに応じた早口言葉を表示
- 敵の表示
- 音声入力でテキストを入力（逆にいうと音声入力しかできない）
- やり直しボタン
- 攻撃ボタン

**共通**

- Drawerにホームに戻るためのメニュー用意

### 壁1 : 「何が作れるのかなぁ」
Flutterでどういったアプリが実現できるのか。
……正直そこまで突き詰めてはないのですが、私は設計のとき、以下の`Widget`一覧眺めてました。（参考までに）

[Flutter:Widget一覧][2]
 (画像がついていて、イメージがしやすい)
[Widget catalog][3]
(公式情報)

## 実装（1） 
「Start a new Flutter project」でポチポチしてプロジェクト作ったら、実装の開始です!!

### 壁2 : 「デザインどんな感じにしようかなぁ」
後からいくらでも変えられるのでしょうが、最初はアプリの色とか全体的なデザインとか気になるものです。
一般的にはアプリ全体のデザイン（色）を指定するために`MaterialApp`の`ThemeData`をいじることが多いようです。

（今回は以下のような感じにしました）

```main.dart
      ~略~
      theme: ThemeData(
        primaryColor: Colors.blueGrey[600],
        accentColor: Colors.blueGrey[600],
      ),
      ~略~
```


参考
[Flutterでアプリ全体を統一感のあるデザインにする方法][4]
[FlutterのThemeを理解する](https://itome.team/blog/2019/12/flutter-advent-calendar-day12/)
[materialpalette.com](https://www.materialpalette.com/) (色の参考)

[Flutterでアプリ全体を統一感のあるデザインにする方法][4]の記事をよく読むと、本来はテキストのスタイルとかもThemeDataにまとめておいた方がいいみたいですね。（今回は所々個別で指定しています）

1日目の残りは、ホーム画面とDrawerを作成して終了!!
1日目は手を動かすよりは、サイトやドキュメントを読んでいる時間の方が多かったです。

### 1日目進捗
<img width="450" height="300" alt="1日目.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277737/cc3c2922-c51d-e91f-eae2-bd20dc096f50.gif">

Drawerの中のアイコン（家マークや×マーク）はFlutterで用意されているアイコンクラスを利用してます。多分Flutterでアプリ開発するときはほぼ確実に使うと思います。↓
[Icon-classの公式情報][5]

# 2日目
## 実装(2)
### 壁3 : 「画面遷移どうやってやるんだろう」
実装していると、ほぼ確実に画面遷移したくなるはず。
~私が参考にした記事~
[FlutterのNavigatorで画面遷移](https://qiita.com/granoeste/items/19c119ffc36a016e6223)
[Flutterの画面遷移](https://qiita.com/tatsu/items/38cd85efd93005b95af9)

今回は、MaterialAppインスタンス生成時にroutesを指定するやり方を取っています。

```main.dart
      ~略~
      routes: <String,WidgetBuilder>{
        '/home': (BuildContext context) => MyHomePage(title: title),
        '/play/basic': (BuildContext context) => PlayPage(title: title, level: 'basic'),
        '/play/advanced': (BuildContext context) => PlayPage(title: title, level: 'advanced'),
      },
      ~略~
```
ホームに遷移させるときの書き方↓

```
  Navigator.of(context).pushReplacementNamed("/home");
```

### 壁4 : 「【StatelessWidget】と【StatefulWidget】って？」
**多分ここが一番、めちゃくちゃ大事なところです!!!!**
他のところはぼんやりでも実装書き進められるのですが、ここは手が止まってでも理解すべきところかと思います。

☆要点がまとまっていて、とても助けられた記事。
[Flutterの基礎][6]

#### StatelessWidget
Stateの概念を持たないWidget。「StatelessWidget」を継承して実装します。
動的に変化しない画面要素を作るときに利用します。
本アプリではホーム画面やDrawerメニューで使いました。

- (参考)Drawerメニューの実装

```drawer_menu.dart
import 'package:flutter/material.dart';

class DrawerMenu extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: ListView(
        padding: EdgeInsets.zero,
        children: <Widget>[
          DrawerHeader(
            decoration: BoxDecoration(
              color: Colors.blueGrey,
            ),
            child: Text(
              'メニュー',
              style: TextStyle(
                color: Colors.white,
                fontSize: 24,
              ),
            ),
          ),
          ListTile(
            // home画面
            leading: Icon(Icons.home),
            title: Text('ホーム'),
            onTap: () {
              Navigator.of(context).pushReplacementNamed("/home");
            },
          ),
          ListTile(
            leading: Icon(Icons.close),
            title: Text('閉じる'),
            onTap: () {
              Navigator.pop(context);
            },
          ),
        ],
      ),
    );
  }
}

```

[Flutterの基礎][6]でも説明がありますが、StatelessWidgetを継承したら、「`build`メソッド書きなさい」ってなるので、`build`メソッドでWidgetもしくはテキストを返すように実装します。
（上記コードはDrawerのWidgetを返した場合）

returnの後につらつらとコードを書くのは違和感あるかもしれませんが、
やってみた印象としては、最初は下手に切り分けたりせず、そのままつらつら書いた方が良さそうです。

#### StatefulWidget
Stateの概念を持つWidget。「StatefulWidget」を継承して実装します。
動的に変化させたい画面要素を作るときに利用します。
今回はプレイ画面で使いました。

「完全に理解した」レベルの説明。「実装を進める」ための理解であり、正確さには欠けると思うので参考程度でお願いします。

- (参考)playページの実装一部

```play_page.dart
class PlayPage extends StatefulWidget {
  PlayPage({Key key, this.title, this.level}) : super(key: key);

  final String title;
  final String level;

  // アロー関数を用いて、Stateを呼ぶ
  @override
  State<StatefulWidget> createState() => _PlayPage();
}
```
StatefulWidget自体はとても単純です。createState()メソッドが必須で、Stateクラスを返すように書けばOK。上記のcreateState()の部分はほぼ呪文レベルです。

```play_page.dart
const List<String> baseThemeList = [
  '生麦生米生卵',
  '隣の客はよく柿食う客だ',
  'バスガス爆発',
  '裏庭には二羽ニワトリがいる',
  '赤パジャマ黄パジャマ青パジャマ',
  '赤巻紙青巻紙黄巻紙',
  '老若男女',
  '旅客機の旅客'
];

~~中略~~

class _PlayPage extends State<PlayPage> with SingleTickerProviderStateMixin {

  SpeechRecognition _speech;
  bool _speechRecognitionAvailable = false;
  bool _isListening = false;
  int _hp = 5;

  String _themeText = '';
  String transcription = '';

  String _image = '';

  Language selectedLang = languages.first;

  @override
  initState() {
    super.initState();
    activateSpeechRecognizer();
    setTongueTwister(widget.level);
    if (widget.level == basic) {
      _image = 'images/kaiju.png';
      _hp = 5;
    }
    if (widget.level == advanced) {
      _image = 'images/fantasy_mahoujin_syoukan.png';
      _hp = 7;
    }
  }
~~中略~~
 @override
  Widget build(BuildContext context) {
    return Scaffold(
　　ほにゃらら
~~中略~~
  void setTongueTwister(String level) {
    if (level == basic) {
      int index = Random().nextInt(baseThemeList.length);
      setState(() => _themeText = baseThemeList[index]);
    }
    if (level == advanced) {
      int index = Random().nextInt(advancedThemeList.length);
      setState(() => _themeText = advancedThemeList[index]);
    }
    // どちらの条件でもなかった場合
    if(level != basic && level != advanced) {
      int index = Random().nextInt(baseThemeList.length);
      setState(() => _themeText = baseThemeList[index]);
    }
  }
~~略~~
```

`with SingleTickerProviderStateMixin`は「オフスクリーン時に不要なアニメーションでリソースを消費しないようにするための設定」だそうです（[参考](https://webbibouroku.com/Blog/Article/flutter-animation-basic)）呪文として書いています。

（私のイメージで説明しますが）Stateのフィールドの値は`動的に変化させることができる・動的に変化させたい値を定義`します。(今回は敵のHPとかお題の早口言葉とか)
※ ただのフィールドなので変化しない値も当然定義できます。

Stateクラスには状態を管理するメソッドが複数存在しますが、今回は利用した`initState()`,`build()`,`setState()`についてだけ説明します。

- `initState()`

Stateの初期化を行います。(敵のHPとかお題の早口言葉は、今回levelに応じて、初期化しています)

- `build()`

基本的にStatelessWidgetの`build()`と同じノリで書いてOKです。

- `setState()`

setState()の中で値を変化させると、それが動的に画面に反映されます。
例えば、上記のコードの`setTongueTwister`メソッドの中には`setState(() => _themeText = baseThemeList[index]);`といったコードが存在するので、`setTongueTwister`を呼び出すたびに`_themeText`(早口言葉のお題)が変更されることになります。

### 壁5 : 「画像貼りたい!」
参考記事は以下です。難しくはないと思います。
[Flutterで画像を表示する方法【まとめ】](https://qiita.com/yu124choco/items/a2710ec004d3425a2a0b)

今回は、[イラスト屋様様](https://www.irasutoya.com/)の絵を貼っています。

### 壁6 : 「パッケージを導入したい！」
本アプリで必要なのは、音声認識のパッケージですね。今回は`flutter_speech`を入れました。

参考記事
[【Flutter】パッケージ導入手順](https://qiita.com/akeome/items/0a6ebf3af402fdf62c79)

パッケージ探すサイト自体は[https://pub.dev/flutter](https://pub.dev/flutter)です。
導入方法も各パッケージのページに説明が書いてあります。
余談ですが、`flutter_speech`の`Example`の[コード](https://pub.dev/packages/flutter_speech#-example-tab-)は大変勉強になりました。`flutter_speech`の実装は8割型`Example`の内容になっています。

### 壁7 : 「実機で動かしたい!」
「動かしたい」というより、エミュレータが音声認識非対応だったので、デバッグ時点で自分のスマホにアプリをインストールする必要がありました。
参考記事
[[flutter]実機でデバッグする方法
](https://crowd37.com/flutter-zikkidedebugsuruhouhou/)

PCとスマホを繋げられる環境必須です。

**2日目進捗**
<img width="450" height="300" alt="2day.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277737/8737c5dd-0a60-5a87-dd93-e68675eb3121.gif">

実はこの時点では、「攻撃」=（「音声認識の終了」+「お題との文字列比較とHPの処理」)が思うように動かなかったのですが、不貞寝。

# 3日目
3日目については、本アプリ特有の話が多いです。
## 実装(3)
「早口言葉レベル2」の実装と、設計でも出し切れていなかった細かい機能追加を実装します。

**「攻撃」のちょっと工夫**
まず2日目にうまくいかなかった「攻撃」の部分

```play_page.dart
  void attack() async {
    if (_isListening) {
      stop();
      await Future.delayed(Duration(milliseconds: 500));
    }
    if (_themeText == transcription) {
      setState(() => _hp = _hp - 1);
      setTongueTwister(widget.level);
    }
    if (_hp <= 0) {
      setState(() => _image = 'images/victory.png');
      setState(() => _themeText = '終了!!');
    }
  }
```
`_isListening` …音声認識中か否か
`_themeText`……入力すべき早口言葉
`transcription`……音声入力したテキスト

`stop()`メソッドは音声入力を終了するメソッド（[flutter_speechのExampleのコード](https://pub.dev/packages/flutter_speech#-example-tab-)参照）です。

`stop()`してから`transcription`が更新するまでラグがあるようだったので、処理を0.5秒待つように実装しました。（これで即座に攻撃できるようになりました）
`async`つけて「0.5秒待つ」は`await Future.delayed(Duration(milliseconds: 500)`で書いてます。

**HPの表示＋お題の早口言葉変更機能+プレイ画面からホーム画面に戻るボタン**
<img width="450" height="300" alt="細々とした追加機能.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277737/7ccd3bc8-2831-384d-9e33-e48777d5a951.gif">

これにて、完成！！
（最初のgif画像をもう一度）私のスマホの画面を撮影した画像です。
![実際のスマホ画面.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277737/dab94df5-6703-d54c-6b50-e618cf16975c.gif)
`Listening...`中に音声入力しています。
「攻撃」するたびにHPが「1」減って、HP「0」になると「VICTORY」(手描き)が出てくるという寸法です！！

[Githubのソースページ](https://github.com/beeeyan/flutter-voice-game/tree/ver1)

ちなみにレベル2もちゃんと実装してあるので、気になる人はcloneして
[「実機で動かしたい!」](#壁7 : 「実機で動かしたい!」)やってみてください。

# 課題（本アプリ固有の話）
- 漢字を平仮名に変換してから文字列比較をする。

`flutter_speech`が自動で漢字変換してくれていて、現状漢字のままで文字列比較を行っています。実際は両方平仮名に変換して比較すべきだと思っています。今回は後回し状態です。

# 想定外（本アプリ固有の話）
- `flutter_speech`が滑舌があやしい場合も、けっこう認識してくれる。

滑舌鍛えたい人とかにニーズあるかなぁーと思ってたんですが、パッケージがちょっと優秀すぎでした。ちょこっとシビアな音声認識パッケージとかあったら嬉しい……

- 音声聴き終わってでないとテキスト表示されない。

実装前は、しゃべっている途中でもテキストが表示されるイメージをしていました。実際はしゃべり終わった後でした。「攻撃」ボタン押すまで、テキスト表示なしの状態となってしまいました。

# 終わりに~Flutterによる開発は早かったのか~
いかがだったでしょうか？ 「すごい」と思う人もいれば「3日もあってこの程度か！」と思う人もいることでしょう。

実際、一日中PC画面を睨み付けて、、目を血走らせながらコード書いていたか、、と言われると…**そういう訳ではないです。**
**めちゃくちゃお昼寝もしてました。**

コードも数えてみると、**500行も書いてなかったです。**
環境構築はしてないですが、それでも3日あれば今回のレベルのものを作るには余力がある印象でした。
「開発が早くできる」というのは、割と的を得ている気がします。

**みなさんも三連休に「ひとアプリ」作ってみてはいかがでしょうか？**

この記事で、よりFlutterの開発が楽になること・「アプリ開発してみようかな」という人が増えることを願っております。
以上です！！

[1]:https://flutter.dev/
[2]:https://qiita.com/matsukatsu/items/e289e30231fffb1e4502
[3]:https://flutter.dev/docs/development/ui/widgets
[4]:https://qiita.com/sekitaka_1214/items/e25f2f3b282b0ff382cf
[5]:https://api.flutter.dev/flutter/material/Icons-class.html
[6]:https://qiita.com/nkmk1215/items/7d73b4fd8cb5ec5ac486