Webしか触ったことのないアプリ未経験者がFlutterでiOS・Androidのアプリを個人開発でリリースした話

![flutter-logo-sharing.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90682/51736336-8794-61ba-4b6e-18e916113924.png)
こんにちは、[@y_temp4](https://twitter.com/y_temp4)です。

普段は主にフリーランスエンジニアとして Web のフロントエンド周りの開発をしており、これまでアプリの開発というものはほぼやったことがありませんでした。

しかし今回、最近流行りの Flutter を使って iOS・Android 両対応のアプリをリリースしてみましたので、そこで得た知見や、開発の流れを共有していこうと思います。

## 開発したアプリ

今回リリースしたのは「レジスタンス vs スパイ」というアプリです。

![resistance-vs-spy.001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90682/ccf5d5e2-681f-6b70-f471-e9cebc2cb3eb.png)

[iOS のリンクはこちら](https://apps.apple.com/jp/app/%E3%83%AC%E3%82%B8%E3%82%B9%E3%82%BF%E3%83%B3%E3%82%B9vs%E3%82%B9%E3%83%91%E3%82%A4/id1457212071?l=ja&ls=1)
[Android のリンクはこちら](https://play.google.com/store/apps/details?id=com.y_temp4.resistance)

以前職場の人とやったボードゲーム「**レジスタンス**」がとてもおもしろかったので、アプリで作ってみようと思い開発に挑戦してみました。

**人狼ライクなボードゲーム**で、プレイ人数が 5 ~ 10 人と最低でも 5 人必要なのですが、人狼が好きな人ならハマると思うのでぜひプレイしてみてください 👍

## これまでの開発の経験

アプリ開発の流れを書いていく前に、開発者である私自信のアプリ開発の経験について共有します。

- 普段は Web のフロントエンドの開発がメイン
- アプリの経験はほぼなし（ほんの少しだけ [Monaca](https://ja.monaca.io/) に触れたことがあるくらい）
- Flutter は少し前にインストールしてチュートリアルだけやったことがあった

自分は完全に Web 系のエンジニアで、**アプリの開発はほぼ未経験**でした。これまではネイティブに触れた経験はほぼなくて、React Native や Monaca などクロスプラットフォーム系の開発基盤に少し興味を持っていたくらいです。

Flutter に関しては半年くらい前に触ったことがあったのですが、チュートリアルをやったくらいでそのときにはがっつり開発とまではいきませんでした（当時、特に作りたいものもなかったので）。

### 開発の手順

では実際に、開発をする上での手順をまとめていきます。

### 1. 画面構成を決める

まずはじめに、アプリの画面構成を考えました。

アプリの遊び方としては、アプリがインストールされた端末をプレイヤーに順番に回していくスタイル（よくある人狼アプリと同じような感じ）を想定していたので、以下を満たす必要がありました。

- 画面が順番に切り替わっていく
- 前の画面には戻れない（前のプレイヤーの情報が見れてしまうので）

そこで、アプリの画面構成としてはスクリーンを複数作成し、それを順番に切り替えていくような感じにしました。

これを、トップページにある「新しいゲームを始める」ボタンから始められるようにします。

また、アプリの途中では戻るボタンは使えませんが、いつでもゲームを破棄してホームに戻れるように、画面の右上に「ホームに戻るボタン」を配置することにしました。

最後に、トップページでは遊び方へのリンクも欲しかったので配置しました。

<img width="414" alt="IMG_3244.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90682/e6d438dd-5ec8-fbf2-7e69-b1f46b4fb157.png">


```main.dart
import 'package:flutter/material.dart';
import 'package:resistance/models/app.dart';
import 'package:resistance/screens/home.dart';
import 'package:resistance/screens/select_member_count.dart';
import 'package:resistance/screens/add_user.dart';
import 'package:resistance/screens/check_position_before.dart';
import 'package:resistance/screens/check_position.dart';
import 'package:resistance/screens/discussion_time.dart';
import 'package:resistance/screens/select_mission_member.dart';
import 'package:resistance/screens/vote_of_confidence.dart';
import 'package:resistance/screens/vote_result.dart';
import 'package:resistance/screens/command_mission.dart';
import 'package:resistance/screens/show_mission_result.dart';
import 'package:resistance/screens/command_mission_before.dart';
import 'package:resistance/screens/show_game_result.dart';
import 'package:resistance/screens/how_to_play.dart';
import 'package:scoped_model/scoped_model.dart';

void main() {
  final app = AppModel();

  runApp(
    ScopedModel<AppModel>(
      model: app,
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'レジスタンス vs スパイ',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        textTheme: TextTheme(
          display4: TextStyle(
            fontFamily: 'Arial',
            fontWeight: FontWeight.w800,
            fontSize: 24,
            color: Colors.black,
          ),
        ),
      ),
      initialRoute: '/',
      routes: {
        '/': (context) => Home(),
        '/select_member_count': (context) => SelectMemberCount(),
        '/add_user': (context) => AddUser(),
        '/check_position_before': (context) => CheckPositionBefore(),
        '/check_position': (context) => CheckPosition(),
        '/discussion_time': (context) => DiscssionTime(),
        '/select_mission_member': (context) => SelectMissionMember(),
        '/vote_of_confidence': (context) => VoteOfConfidence(),
        '/vote_result': (context) => VoteResult(),
        '/command_mission_before': (context) => CommandMissionBefore(),
        '/command_mission': (context) => CommandMission(),
        '/show_mission_result': (context) => ShowMissionResult(),
        '/show_game_result': (context) => ShowGameResult(),
        '/how_to_play': (context) => HowToPlay(),
      },
    );
  }
}
```

### 2. 状態管理の方法を決める

アプリでは、画面をまたいで状態を保持しておく必要がありました。

Fulutter における状態管理の方法をあまり知らなかったので、とりあえず自分は「グローバルなステートがあればいいや」と思いました。

結果的には [scoped_model](https://pub.dev/packages/scoped_model) を使い、1 つだけ app というモデルにアプリすべての状態を保持して管理することにしました。

```app.dart
import 'dart:math';
import 'package:scoped_model/scoped_model.dart';

...

class AppModel extends Model {
  int _memberCount = 0;
  List _users = [];

...

  int get memberCount => _memberCount;
  String get memberCountString => '$_memberCount';
  List get users => _users;

...

  void setMemberCount(int number) {
    _memberCount = number;
  }

...

}
```

（※長いので省略しています。ちなみに、コードは全体だと 300 行弱くらいです。）

今回のアプリ制作では、結果的にはこれであまり困ることなく開発を進められました。状態がそこまで多くないアプリであれば、これでもなんとかなるかもしれません。

### 3. 各画面を実装していく

あとは愚直に画面を作成していくだけです。各画面ではそれぞれ地味にロジックを考える必要があって少し悩むこともありましたが、一応最後まで実装することができました。

`2. 状態管理の方法を決める`で定義した scoped_model から値やメソッドを呼び出す際は`ScopedModel.of<AppModel>(context)`を利用します。

参考までに、最初の画面のコードを貼っておきます。

```select_member_count.dart
import 'package:flutter/material.dart';
import 'package:resistance/models/app.dart';
import 'package:scoped_model/scoped_model.dart';
import 'package:resistance/functions/to_home.dart';

class SelectMemberCount extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('プレイ人数の設定'), actions: [
        IconButton(icon: Icon(Icons.home), onPressed: () => toHome(context)),
      ]),
      body: Center(
          child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 64),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  MyCustomForm(),
                ],
              ))),
    );
  }
}

class MyCustomForm extends StatefulWidget {
  @override
  MyCustomFormState createState() {
    return MyCustomFormState();
  }
}

class MyCustomFormState extends State<MyCustomForm> {
  final numberController = new TextEditingController();
  String memberCount = '5';

  void onClick(BuildContext context) {
    ScopedModel.of<AppModel>(context).setMemberCount(int.parse(memberCount));
    Navigator.pushReplacementNamed(context, '/add_user');
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
        scrollDirection: Axis.horizontal,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text('プレイ人数：',
                style: TextStyle(
                  fontSize: 18,
                )),
            DropdownButton<String>(
              value: memberCount,
              items:
                  <String>['5', '6', '7', '8', '9', '10'].map((String value) {
                return DropdownMenuItem<String>(
                  value: value,
                  child: Text(
                    value,
                    style: TextStyle(
                      fontSize: 18,
                    ),
                  ),
                );
              }).toList(),
              onChanged: (value) {
                setState(() {
                  memberCount = value;
                });
              },
            ),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16.0),
              child: FlatButton(
                onPressed: () => onClick(context),
                color: Colors.blue,
                child: Text(
                  '次へ',
                  style: TextStyle(color: Colors.white),
                ),
              ),
            ),
          ],
        ));
  }
}
```


## 苦労したポイント

開発する中で大変だった点をまとめてみます。

### ホームに戻る挙動

画面右上のホームに戻るボタンを実装する際、ホームに戻りますか？というダイアログに対して

- はい → ホームに戻る
- いいえ → 元の画面に戻る

という挙動を実装するのに若干手間取ったのを覚えています。最終的には実装できたので、動作するコードを貼っておきます。

`Navigator.of(context).pop()`と`Navigator.pushNamedAndRemoveUntil(context, '/', (_) => false)`がポイントですね。

```to_home.dart
import 'package:flutter/material.dart';
import 'package:resistance/models/app.dart';
import 'package:scoped_model/scoped_model.dart';

void toHome(context) {
  showDialog(
    context: context,
    builder: (BuildContext context) {
      // return object of type Dialog
      return AlertDialog(
        title: Text("確認"),
        content: Text('ホームに戻ります。現在のゲームのデータはリセットされますが、よろしいですか？'),
        actions: <Widget>[
          // usually buttons at the bottom of the dialog
          FlatButton(
            child: Text("いいえ"),
            onPressed: () {
              Navigator.of(context).pop();
            },
          ),
          FlatButton(
            child: Text("はい"),
            onPressed: () {
              Navigator.pushNamedAndRemoveUntil(context, '/', (_) => false);
              ScopedModel.of<AppModel>(context).reset();
            },
          ),
        ],
      );
    },
  );
}
```

### Admob が本番環境に反映されない

このアプリは [firebase_admob](https://pub.dev/packages/firebase_admob) を使って広告を表示していますが、本番環境にて広告が表示されずかなり悩んでいました。

が、結果的に原因は[このリンク](https://github.com/flutter/flutter/issues/28733#issuecomment-545214679)にあるように、Admob 側で「お支払い」の設定をしていないことが原因でした･･･。

初めて Admob を使った収益化をする方は、気をつけたほうが良さそうです。

## さいごに

全体を通しての感想としては、

- Dart は普段 JS / TS を書いている人であれば親しみやすい
- 実装自体は慣れれば割とスムーズにいけそう
- アプリ独特のリリースまでの流れが面倒だった

といった感じでしょうか。やはりアプリ特有のリリース手順や、開発環境の重さ（エミュレータの起動など）は慣れない部分もあり、Web の開発はその点楽だったんだな･･･と感じました。

とはいえ、これでアプリ開発は終了ではなく、今後も機会があればぜひ Flutter には触れていけたらなと思います👍

また、全体通して設計には苦労したので、もし「自分だったらこのように実装するのにな〜」などといったアドバイスがありましたら、コメントにて教えていただけますと幸いです🙇‍♂️

---

もし今回の記事が参考になった方はぜひいいねしていただけますと嬉しいです😄

最後まで読んでいただき、ありがとうございました！

[レジスタンス vs スパイの iOS のリンクはこちら](https://apps.apple.com/jp/app/%E3%83%AC%E3%82%B8%E3%82%B9%E3%82%BF%E3%83%B3%E3%82%B9vs%E3%82%B9%E3%83%91%E3%82%A4/id1457212071?l=ja&ls=1)
[レジスタンス vs スパイの Android のリンクはこちら](https://play.google.com/store/apps/details?id=com.y_temp4.resistance)