Flutterでアプリを作って公開したので、リリースまでの知見を公開する

大学で創作支援系の研究をしている[yasudexi](https://twitter.com/yasudexi)と申します。

この記事では、
初めてFlutterでアプリをApple Store, Android Storeに公開をしたのでそれまでの

- UXで考慮した点
- 実装上のTips
- 公開までの手順

あたりについて公開しようと思います。
デザインのプロセスなども初めてでしたので間違っている部分もあるかもしれません。その場合には優しく切ってくださると助かります。

また、詳しい実装周りに関しては他の方々が素晴らしい知見を公開してくださっているので↓、本記事ではまだあまり触れられていない実装上の細かいTipsにフォーカスして説明します

- [[Flutter] package:provider の各プロバイダの詳細](https://qiita.com/kabochapo/items/a90d8438243c27e2f6d9)

(追記) と思っていたら、とても参考になった記事２つが削除されていたので、追々書いていければと思います。

# UXで考慮した点
まず、アプリ開発中に意識したUXについてです。
UXとは『ユーザー体験』という意味で、UIとよく一緒の文脈で語られますが異なる言葉です。
例えば非常にかっこいい(UIが美しい)サイトであっても、

- ローディング時間が長い
- ユーザーの入力を受け付けないアニメーションが強制される

などの場合、UXが悪いとされます。
個人的な解釈ですが、UXには『煩わしさを感じさせない』(−を消す)と、『機能を体験した際のこれいいなというやつ』(+を増す)の２通りがあると思っており、以下ではケースとして今回の私の思考を書き記しておきます。

UIについては最低限整えてあげる必要はあるかと思いますが、個人的には、**機能面に優位性を持たせる個人開発のアプリではUIの優先順位はかなり低いと思います。**

UI/UXの参考書・ページ:

- [ノンデザイナーズデザインブック](https://www.amazon.co.jp/dp/B01LW2P4CP)
- [はじめてのUIデザイン](https://peaks.cc/books/ui_design)
- [UXデザイナーが理解しておきたい6つのキーワード](https://goodpatch.com/blog/about-ux/)
- [5 Shocking Examples of Awful Website UX](https://blog.spiralytics.com/shocking-examples-of-awful-website-ux)

## UXについて考えた軌跡
というわけで何を考えて作ったのかについて触れます。

まず、今回作ったのは[メモスタジオ - 上手い文章を書けるメモ帳](https://apps.apple.com/us/app/%E3%83%A1%E3%83%A2%E3%82%B9%E3%82%BF%E3%82%B8%E3%82%AA-%E4%B8%8A%E6%89%8B%E3%81%84%E6%96%87%E7%AB%A0%E3%82%92%E6%9B%B8%E3%81%91%E3%82%8B%E3%83%A1%E3%83%A2%E5%B8%B3/id1514417011?l=ja&ls=1)というアプリで、いってしまえばただのメモアプリです。

![スクリーンショット 2020-06-04 16.16.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190157/c7d27088-98cc-488d-278f-3438fe6af15e.png)

**想定するユーザーは自分**であり、自身が既存のメモアプリを使っていて困った部分などを解消したもの(UXを向上させたもの)を作りたいと考えました。
これは上述した中ではプラスを際立たせる方なのかなと思います。

- メモアプリは人に送る文章を一旦書いてみる場所として使うことが多いので、**上手な文章を書きたい**
- 長くなることも多いので**一瞬で目次を作って飛べるようにしたい**
- iOSのメモアプリは１画面しか開くことができないが、**メモを書いてる時にすぐ参照できる『サイドメモ』も書きたい**

また、一方でマイナスを感じさせないUXという点では、今のような点でiOSのデフォルトのメモアプリを参考にしました。

- ファーストビューをメモ一覧にする
- １タップで新規メモを開けるようにする
    - ユーザーが最速でたどり着きたいのはおそらく『最新のメモ』か『新規メモ』のはずなので、そこまでの導線の優先順位を一番高くする

上記を気にして作ったファーストビューがこちらです。

<img width="480" alt="スクリーンショット 2020-06-04 16.54.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190157/6be454a2-85ae-9794-a364-786d9bcf6534.png">

プラスの向上の方はアプリのデモ動画をみていただけると幸いです。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">「こと」とか「という」みたいな、文章を下手にみせるフレーズを執筆中にハイライトするメモアプリをリリースしました🙌<br><br>・人に送る用途の文章を一旦メモする人<br>・メモにすぐに目次を持たせたい人<br>・メモを書いてる時に『メモのためのメモ』を管理したい人<br><br>などに使っていただければ嬉しいです！ <a href="https://t.co/vNFM1tK5wc">pic.twitter.com/vNFM1tK5wc</a></p>&mdash; やすでぃ (@yasudexi) <a href="https://twitter.com/yasudexi/status/1268457927687340032?ref_src=twsrc%5Etfw">June 4, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 実装上のTips
ここからはFlutterに特有の話になります。
最初に書いておきますが、コア機能の実装の部分に関しては、TextFieldをほぼ再実装みたいなことをしていて自身でもまだUX面で納得していないところがあるので触れません。

`MaterialApp` Widgetから順に、大体の構成がわかるように奥に入って説明をしていきます。
なお、状態管理は[Provider](https://pub.dev/packages/provider) + [Sqflite](https://pub.dev/packages/sqflite)を利用しています。

## MaterialApp Widget

```main.dart
class App extends StatelessWidget {
  const App({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      localizationsDelegates: const [  // 1. localeの設定
        DefaultCupertinoLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: const [
        Locale('ja', 'JP'),
      ],
      debugShowCheckedModeBanner: false,

      // 2. themeの設定
      theme: Provider.of<AppPropertyData>(context).buildTheme(),
      // darkTheme: ThemeData.dark(),

      // 3. routingの設定
      initialRoute: '/initial',
      onGenerateRoute: router,
    );
  }
}
```


### 1. localeの設定
Android端末ではデフォルトでは日本語のフォントが中華フォントとなります。
これを直すためには`pubspec.yaml`内で以下のように追記し、`MaterialApp`内で1.の部分のように指定します。

```pubspec.yaml
dependencies:

  flutter_localizations:
    sdk: flutter
```

### 2. themeの設定
続いて、themeの設定をします。
毎回色などのスタイルを指定するのはあまり得策とはいえないので、ここで`PrimaryColor`だけでも設定しておくことをお勧めします。
また、 `darkTheme: ThemeData.dark()`のように設定しておくことで、『デバイス側ののデフォルトの設定に応じて』変化させることが可能です。
(つまり、『デバイス側の設定を踏襲する』かつ『アプリ内で可変』という状態にするためには別で設定する必要があります)

```theme.dart
ThemeData defaultThemeData = ThemeData.light().copyWith(
    primaryColor: Colors.cyan[800],
    accentColor: Colors.cyan[800],
    floatingActionButtonTheme: ~,
    appBarTheme: ~,
    tabBarTheme: ~,
    bottomSheetTheme: ~,
```

ここでは`AppBar`や`Icon`のデフォルトの色を設定する`primaryColor`や、`FloatingActionButton`のテーマを設定しています。

#### ダークテーマ実装上のTips
また、ダークテーマとの切り替えを行う際の描画に関しては、
『毎回contextからテーマを参照して比較して指定する』というのは実装を見にくくしてしまう結果になりかねません。
ですので、`Theme.of(context).brightness == Brightness.light`などで条件分岐を行うのがオススメです。

#### 色の指定について
Primary Colorの指定にはこちらのサイトを見ると想像がつきやすいです。
https://material.io/resources/color/

![スクリーンショット 2020-05-29 16.08.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190157/7b12aad9-c9fe-3d91-3041-9641e03ea28a.png)

基本的にはMaterial Colorを指定する方法で問題はないはずですが、もしもう少し別の色も見てみたいという場合には以下のサイトなどが参考になります。
https://www.schemecolor.com/

### 3. routingの設定
UXのところでも説明しましたが、
「ファーストビューをメモの一覧、そこから一つ戻るとフォルダの一覧」という実装をするため、以下のようにルーティングを設定します。
アプリのルートは`/`ですが`initialRoute`で`/initial`を設定しているためこの挙動が実装可能です。

また、[fluro](https://pub.dev/packages/fluro)などのルーティングのためのライブラリを使うことも良いかと思いますが、個人開発レベルの大きさならばデフォルトで用意されている関数を使って実装するのが良いかと思います。


```router.dart
final RouteFactory router = (settings) {
  switch (settings.name) {
    case '/':
      return MaterialPageRoute(builder: (context) {
        return const FoldersScreen();
      });
      break;
    case '/initial':
      return MaterialPageRoute(builder: (context) {
        return const Home();
      });
      break;
    default:
      return MaterialPageRoute(builder: (context) {
        return const Home();
      });
      break;
```


#### Single Source of Truth
なお、これは個人的な意見になりますが、routingを挟む遷移に関しては引数は渡さないように設計した方が良いと思います。
今回はroutingでは引数のやり取りはせず、状態の管理は全てProviderに一任しました。

これはReactにおける`single source of truth`という概念に類似しているので、興味があれば調べてみると良いかと思います。
実際に、別のアプリの開発時にはroutingによる引数の受け渡しをしていたこともあったのですが、私の経験上予期せぬエラーにつながりがちでした。

## Home.dart

続いて、MaterialAppを開いた後に最初に開くWidgetについてです。

```home.dart
class Home extends StatelessWidget {
  const Home({Key key}) : super(key: key);

  Future<void> _initData(BuildContext context) async {
    // 1. DBからのfetch + 描画するdataのinit
    await Provider.of<MemoData>(context, listen: false).fetchMemos();
    // (中略)
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _initData(context),
      builder: (BuildContext context, AsyncSnapshot snapshot) {
        // 2. future builderの結果によって処理を分岐する
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.hasError) {
            return Scaffold(
              body: SafeArea(child: Center(child: Text('${snapshot.error}'))),
            );
          } else {
            return Pages();
          }
        } else {
          return const Scaffold(
              body: SafeArea(
            child: Center(
              child: CircularProgressIndicator(),
            ),
          ));
        }
      },
    );
  }
}
```

### 1. DBからのfetch + 描画するdataのinit

アプリの起動時にデータを`shared_preferences`および`sqflite`からfetchします。
前者は『初回起動か否か』などを管理するkey-value storeとして、後者は通常のRDBとして使い分けます。

sqfliteについては[公式の解説ページ](https://flutter.dev/docs/cookbook/persistence/sqlite)を確認しながら実装し、部分的にわからないところを他の記事の内容で埋めていくと理解が早まります。

これによりfetchされたデータを使って諸々の描画をするProvider周りの実装については[Flutterでproviderを使った画面実装の最小サンプル](https://qiita.com/yasudadesu/items/954e1aec02c006159e8e)が参考になるかと思います。


### 2. future builderの結果によって処理を分岐する

`Future Builder`では、データを返す場合と返さない場合で処理が一段階分岐します。

**今回はデータを返さない(DBからfetchしてそれをProviderで管理する)ので、**
`処理が完了したか→エラーがないか`の順番にチェックしています。

**データを返す場合は**
`処理が完了したか→データを持っているか`という風にチェックをします。
(内部のif文で`snapshot.hasData`をチェックします)

また、デフォルトで用意されているものではデザイン的にあまり、という場合には[flutter_spinkit](https://pub.dev/packages/flutter_spinkit)を使うと良いかと思います。

## Pages.dart

続いて、Bottom Navigationを用いたページのコントロールためのWidgetです。

```pages.dart
class Pages extends StatelessWidget {
  const Pages({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final provider = Provider.of<BottomTabData>(context);
    return Scaffold(
      body: tabScreen[provider.currentTab],
      bottomNavigationBar: const BottomNavigation(),
    );
  }
}
```

ここについては正直`Provider + StatelessWidget`、`StatefulWidget`のどちらで実装しても良いかと思います。
わかりやすいと思った方が答えです。


### Bottom NavigationのTips

`CupertinoApp`なWidgetで作っている場合と`MaterialApp`な場合で作る場合、名前と挙動が異なります(ここでは混乱を避けるためどちらもBottom Navigation: BNと呼びます)。

まず前者の場合、画面遷移後もBNが消えません。
それに対して後者の場合、画面遷移によってBNが消えます。

これらは両者のデザインシステムが関係していることなのですが、度々困ることがあるかと思います。
Cupertinoで実装する場合には[flutter/samples - veggieseasons](https://github.com/flutter/samples/tree/master/veggieseasons)
このサンプルリポジトリのsettings周りの実装が参考になります。


## その他のTips

上記の他にも実装の方針上の知りたいことは諸々あるかとは思いますが、長くなるのも宜しくないと思うのでそれぞれ一行でまとめます。
他にも何かあれば一行でまとめたものをコメントでいただければ幸いです。

- Column以下のListViewには`shrinkWrap: true`を入れよう
- webページを開くなら`url_launcher`を使おう(webviewをわざわざ使わなくて良い)
- OSSライセンスページは`showLicensePage`で自動生成しよう
- IconはMaterialIconで困ったら`FontAwesomeIcons`を使おう
- ScaffoldのbodyはとりあえずSafeAreaで囲んでおこう


# 公開までの手順

最後は公開までの手順です。
基本的には公式の手順通りにすれば問題ないのですが、やや面倒なこともあったのでログとして残しておきます。

[Build and release an iOS app](https://flutter.dev/docs/deployment/ios)
[Build and release an Android app](https://flutter.dev/docs/deployment/android)

## アプリのアイコン
アプリのアイコン画像に関しては[flutter_launcher_icons](https://pub.dev/packages/flutter_launcher_icons)を利用して作成しましょう。元画像のサイズは512x512か1024x1024くらいあれば十分です。


## iOS
まずはiOSでのポイントについて。後半の２つは知らなかった単語です。

### プライバシーポリシーが必須
iOSアプリではプライバシーポリシーが必須です。後述するApple Store Connectにて入力する必要があります。
私は以下のページから条件に応じて生成→github.ioで静的なページとして公開しています。
この方法であればサーバー代などもかからず履歴についてもしっかりと保管できますので、オススメです。

[https://app-privacy-policy-generator.firebaseapp.com/](https://app-privacy-policy-generator.firebaseapp.com/)
https://github.com/yasudadesu/policy-pages

### Apple Store Connect
自前のiOSのアプリを管理する、Developerのためのサービスです。
アプリ自体はこのサービスに直接アップロードするのではなく、Xcodeでバージョンを指定→`flutter build ios`でビルド→Xcodeから Apple Store Connectに送信という手順をとります。

また、個人的にひっかかったのが以下の**Appプレビューとスクリーンショット**画面です。

![スクリーンショット 2020-06-04 16.02.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190157/0fde6883-d9f5-7d18-ae9c-0ee3b91ad72a.png)

- それぞれのディスプレイサイズの解像度・縦横比率に完全にマッチした画像でないとアップロードできない
- 画面を半分くらいにして作業していると`iPad Pro(第二世代)`が画面外に隠れ、審査に出せないことに焦ることになる

あたりはあらかじめ知っておくと良いかと思います。

### TestFlight
テスト用にアプリを配布するためのサービスです。
一応審査が必要なはずですが、その審査が通れば多くのデバイスに対して常に最新のソフトを提供できます。
今回は利用していませんが、今度作成するアプリでは使ってみようと考えています。

と、いうのも実はリリースして公開しているのはバージョン`1.0.3`でして、`1.0.0~1.0.2`はあまりテストができていなかったため公開した瞬間にバグに気付いて停止、ということを繰り返していたからです。

## Android
続いてAndroidですが、こちらは特にひっかかるところはなかったです。
アプリのビルド先として`aab`と`apk`の２形式のファイルがあるのですが、`aab`だけで問題ありません。


## 両方の留意点

- 連絡先は自身のTwitterアカウントなどでよい模様です。
- アップデート時には直すべき部分が異なります
    - iOSの場合、Xcodeを開いてBuild Versionを変更
    - Androidの場合、pubspec.yamlから変更

# さいごに
初めて型つき言語を体験して、型なしの言語には戻れないなという開発体験をしました(UXがいい！）
ちょっと実装の闇に触れたために本文執筆の際のUXが一部失われてしまっているかもしれないのですが、使っていただければ嬉しいです。

最後に、Flutterを実装していて良かったと思うことを３つにまとめます。

- web,モバイルの開発が初めてでも親しみやすい構文
- 英語でのドキュメントが非常に充実している
- 型付言語のため、予期せぬエラーにあう頻度が非常に少ない
- サンプル実装やパッケージの公開なども盛んで、毎回新しい発見があり楽しい

ここまで読んでいただきありがとうございました。