10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

大学で創作支援系の研究をしているyasudexiと申します。

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

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

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

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

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

UXで考慮した点

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

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

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

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

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

UXについて考えた軌跡

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

まず、今回作ったのはメモスタジオ - 上手い文章を書けるメモ帳というアプリで、いってしまえばただのメモアプリです。

スクリーンショット 2020-06-04 16.16.53.png

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

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

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

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

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

スクリーンショット 2020-06-04 16.54.33.png

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

実装上のTips

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

MaterialApp Widgetから順に、大体の構成がわかるように奥に入って説明をしていきます。
なお、状態管理はProvider + 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: ~,

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

ダークテーマ実装上のTips

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

色の指定について

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

スクリーンショット 2020-05-29 16.08.13.png

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

3. routingの設定

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

また、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については公式の解説ページを確認しながら実装し、部分的にわからないところを他の記事の内容で埋めていくと理解が早まります。

これによりfetchされたデータを使って諸々の描画をするProvider周りの実装についてはFlutterでproviderを使った画面実装の最小サンプルが参考になるかと思います。

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

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

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

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

また、デフォルトで用意されているものではデザイン的にあまり、という場合には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 + StatelessWidgetStatefulWidgetのどちらで実装しても良いかと思います。
わかりやすいと思った方が答えです。

Bottom NavigationのTips

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

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

これらは両者のデザインシステムが関係していることなのですが、度々困ることがあるかと思います。
Cupertinoで実装する場合にはflutter/samples - 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
Build and release an Android app

アプリのアイコン

アプリのアイコン画像に関してはflutter_launcher_iconsを利用して作成しましょう。元画像のサイズは512x512か1024x1024くらいあれば十分です。

iOS

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

プライバシーポリシーが必須

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

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

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

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

TestFlight

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

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

Android

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

両方の留意点

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

さいごに

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

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

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

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

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?