182
144

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】17歳が一人でモバイルアプリを開発・リリースするまでの話 - Zikanri

Posted at

こんにちは。
18歳です。(タイトル詐欺です。)

今回初めて一人でアプリを作ったので、開発のきっかけ知見を共有しようかと思い、記事を書きました。

アプリを作ったのは17歳の時なので、この記事の中では私を華のセブンティーンとして扱ってください。
(実はこれ以上、年齢の話は出てきません)

記事がおもしろかったり、参考になったよという人は、
記事の評価やtwitter(@thoth000)のフォローをしてもらえると、とてもうれしいです。
次の記事作成のモチベーションになります。

それでは記事、始まります。

#開発したアプリ
zikanri_icon

アプリの名前はZikanriです。

価値アリ価値ナシの二種類の時間だけに限定して記録するアプリです。

記録を限定することで
毎日の価値がどれだけなのかがすぐに分かります!

価値アリ・価値ナシでもない「行方不明な時間」も見つけてあげることができます。

記録をSNSにシェアして、毎日の価値の変化を共有できるので、モチベーションを維持できます!

  • 休みの日についダラダラしてしまう人
  • 目標に向かって頑張る人

など、毎日の価値を改善したい人に使ってもらうことで、
時間を振り返って毎日をより良くするモチベーションを生み出します!

ダウンロードはこちらから!
ぜひレビューをお願いします!

Google Play で手に入れよう

#目次
開発したアプリ
アプリ開発を始めた経緯
開発の流れ
Flutterについて
開発の知見
 ChangeNotifier
 Widgetの画像化
 シェア機能
 HiveというDB
 BottomNavigation
 (初心者でもわかる)ダークモード
 チュートリアルの実装
 キーボードを開くときの工夫
 Rowの両端寄せの罠
 TextFieldの最大文字数
 SVGファイルの表示
 ファイルの整理
 アプリ公開のスクリーンショット
 アプリのリリースビルド
大事だと思う話
さいごに

#アプリ開発を始めた経緯
まずアプリ開発を始めた理由はなにかというと

コンテストに参加するために始めました。

毎年行われているアプリ甲子園というコンテストがあります。
そのコンテストに去年(2019)、先輩から誘われたということで年上だけのグループに一人ちょこんと参加したのが始まりです。

Flutterというクロスプラットフォームを使って開発をしました。
コンテストは残念な結果になってしまったので、今年は先輩のリベンジ的な立ち位置でひとり頑張っています。
応援していただけると嬉しいです。

先輩方とコンテストに参加し終わったあと、個人的にスキルの修得に力を入れ始めて、個人開発という流れになりました。

今回開発したアプリもこんな流れでFlutterを使って作っています。

#開発の流れ
開発期間は半年ほどです。
アイデアは登校中のバスの中とかで思いついたと思います。
一時期、「なんかアプリ作りたいな」と猛烈に思っていた時期があって、ぼーっとした顔で明後日の方向を見ながら、暇な時に考えていたと思います。

(Androidは)18歳にならないとアプリリリースができないことを知っていたので、のんびりと開発を進めていきました。

今年の春休みにギアを上げて無事完成させました。
(リリースは夏です。)

#Flutterについて
そもそもFlutterとはクロスプラットフォームに対応したアプリ開発キットです。
Googleさんが開発をしています。

キットというと語弊が生じるかもしれませんが、Dartというプログラミング言語を用いて、アプリのフロントエンドからバックエンドまで設計していきます。

公式のドキュメントがしっかりしているので、ここを見ればある程度のアプリは作れるようになると思います。
(Flutterの情報を仕入れるときは、基本的に英語のドキュメントを読むことになると思います。翻訳でも理解できるかな?…と思います。)
Flutter

またサードパーティ製のパッケージが豊富で、開発にとても便利に働きます。
Dart Packages

#開発の知見
ここからは実装の話になります。
Flutterを知らなくても興味を持ってくれた方は、学んでからこの記事をまた読んでくれるとうれしいです。(大事だと思う話に飛びます。
開発で詰まった箇所や良さそうなウィジェット、パッケージを紹介しようと思います。
個人の知見なので修正点があれば教えていただけるとありがたいです。(もちろん修正します。)

#ChangeNotifier
値の変更をピンポイントでウィジェットに伝えるために使いました。
Class 〇 with ChangeNotifierで定義して使うことができます。

ChangeNotifier(以下CNとします)はChangeNotifierProvider(以下CNP)の子クラスで使うことができます。

自分はCNを複数用いることにしたので、CNPのネストが深くなってしまいそうですが、そこはMultiPloviderで解決できます。

BAD
ChangeNotifierProvider(
  create: (_) => CN1(),
  child: ChangeNotifierProvider(
    create: (_) => CN2(),
    child: ChangeNotifierProvider(
      create: (_) => CN3(),
      child: child,
    ),
  ),
),

悪い例です。
CNPのネストには悪い部分が二つあると思ってます。

一つ目はダサいです。そのままの意味です。
二つ目はこれが原因でスプラッシュ画面の後に一瞬ブラックスクリーンになることがあります。私はこの原因解明にかなりの時間を費やしました…
きれいにアプリ起動ができなくなるので注意しましょう。

GOOD
MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => CN1(),),
    ChangeNotifierProvider(create: (_) => CN2(),),
    ChangeNotifierProvider(create: (_) => CN3(),),
  ],
  child: child,
),

MultiProviderを使うことで、きれいに実装できています。

#Widgetの画像化
screenshot

FlutterではWidgetを画像化することができ、それにはRepaintBoundaryを使います。
画像化したいWidgetをRepaintBoundaryで囲むことで、画像化できるWidgetを作ります。

RepaintBoundary(
  key: _globalKey, //グローバルキーを割り当てます
  child: child,
),

画像化のメソッドはこんな感じです。

import 'dart:ui' as ui;

Future exportToImage() async{
  RenderRepaintBoundary boundary = 
    _globalKey.currentContext.findRenderObject(); //ここでRepaintBoundaryにあてたキーを参照します。
  ui.Image image = await boundary.toImage(
    pixelRatio: 3.0, //値の変更可能
  );
  ByteData byteData = await image.toByteData(
    format: ui.ImageByteFormat.png,
  );
  final _pngBytes = byteData.buffer.asUint8List(); //生成された画像
}

#シェア機能
twitter

シェアするにはさまざまな方法がありますが、自分はesys_flutterを採用しました。

esys_flutterは画像もシェアできるので、上のRepaintBoundaryと組み合わせてウィジェットの画像をシェアすることもできます。便利ですね。

await Share.file(
  'title','image.png',_pngBytes,'image/png',text:'シェアする文章',
);

Shere.fileの中身は左から

箇所 説明
タイトル  シェアのタイトルです。シェアの動きには特に関係ないので、アプリのタイトルやシェアの内容を記入しましょう。
ファイル名  メールなどで共有するとき、ファイルの名前を指定できます。
ファイル  シェアするファイルです。
ファイル形式  (ここが大事です。)image/pngとすることでtwitterなどでシェアできるようになります。
テキスト  シェアする文章です。
シェアするときは、左の画像のようなメニューが出てきます。

#HiveというDB

注意
Hiveのversion 2.0で前バージョンと互換性がなくなるという話が出ています。
それを理解したうえでお読みください。

Hiveとは軽量で高速なNoSQLのDBです。

SharedPreferenceSQLiteと比較してもパフォーマンスが高いようです。
データはkey-valueで保存できます。

使い方を大まかに記します。
まずは以下をpubspec.yamlに通します。
バージョンは更新されるので、各自確認してください。

  • hive
  • hive_flutter
  • hive_generator

Hiveboxというデータベース概念があります。
box.openBox()でボックスを開くとデータベースにアクセスできるようになります。

ここで注意です。
boxのディレクトリはスマホのどこにでも自由に作っていいということは絶対にありえないので、適切な場所にboxのアクセスができるようにする必要があります。
それをしてくれるのがHive.initFlutter()です。
box.openBox()をする前に絶対にこの関数を実行しましょう。

await Hive.initFlutter(); //awaitはつけましょう。
await Hive.openbox('dog'); //dogという名前のboxを開きます。 
dog_box = Hive.box('dog'); //dog_boxが名前dogのboxになります。

boxに対するアクションを説明します。

関数名 アクション
box.put('key',value) boxを指定して、key-valueで値を保存できます。
box.get('key') boxを指定して、そのキーに対応する値を取り出せます。
box.clear() boxを指定して、dataの全クリアができます。
box.containsKey('key') boxを指定して、その中にキーがあるかを調べます。trueかfalseが帰ります。
box.delete('key') boxを指定して、キーを削除できます。
dog_box.put('count',3); //countというキーで3を保存します。
dog_box.put('list',[[],[],]) //リストも保存できます。

//put_null と deleteの違いについて
dog_box.put('count',null)
//dog_box.containsKey('count') => true
dog_box.delete('count')
//dog_box.containsKey('count') => false

主なものはこの5つになります。
特にawaitがなくても動きます。動作が途中で中断された際は動作前の状態に戻ってくれます。どこまでも賢いですね。(心配な人はawaitをつけてもいいです。)

box.openBox()をすることで、アクションを行えるようになるので気を付けましょう。

最後にアプリを閉じるときに、boxが開いているような状態は意図的でない限り良くないので、disposeで閉じるようにしましょう。

また、flutterの現version(stable:1.17.2)では、Hiveに必要なHiveGeneratorがエラーを吐きます。これを解決する方法は以下の通りです。オーバーライドで対応します。

pubspec.yaml
dependency_overrides:
  dartx: ^0.3.0

詳しくは
https://github.com/hivedb/hive/issues/247#issuecomment-606838497

#BottomNavigation

BottomNavigationBarFloatActionBottonを埋め込んだデザインにしてあります。
このデザインにした理由は、どこからでも移動なしで記録が行えるからです。

自分なりの実装方法を紹介します。
BottomNavigationBarは使わずにBottomAppBarを使います。
childはRowの中にIconButtonを入れました。

floatingActionButton: floatButton(),
floatingActionButtonLocation:FloatingActionButtonLocation.centerDocked,//BottomBarの中央に埋め込み
bottomNavigationBar:BottonAppBar(
  shape:CircularNotchedRectangle(),
  child:Row(
    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
    children:<Widget>[
      IconButton(),
      ...
      SizedBox(), //FloatActionButtonを埋め込むスペースを空ける
      IconButton(),
      ...
    ],
  ),
),

BottomAppBarで実装した理由はBottomNavigationBarではタッチ判定なしの空白を用意できなかったからです。(なにかしらやり方があるかと思いますが…)
少しでもユーザーの見えるところは妥協したくなかったので、愚直に実装しました。

#ダークモード
ダークモードは、上のChangeNotifierを使うことで実は超簡単に実装できます。
流れとしては、ChangeNotifier内にThemeDataを定義してMaterialAppで参照することでテーマの切り替えに対応できます。

Class ThemeNotifier with ChangeNotifier{
  bool _isDark = false;
  //アプリのテーマ
  ThemeData appTheme() => ThemeData(
    brightness: _isDark ? Brightness.dark : Brightness.light;
  );
  //モードの変更
  void changeTheme(){
    _isDark = !_isDark;
    notifyListeners();
  }
}

これでダークテーマへの対応ができました。
なんて簡単なんでしょうか。素晴らしいですね。
これをMaterialAppに適応させてみましょう。

MaterialApp(
  title:'Flutter',
  theme:Provider.of<ThemeNotifier>(context).appTheme(),
  home:HomePage(),
),

あとは適当なボタンにchangeThemeの関数を割り当てれば完成です。
これであなたはダークモード実装ができました。
友達に自慢できます。自慢しましょう。

ThemeDataではデフォルトのカラーなどの基本スタイルを設定できます。スタイルを部品ごとで定義するのではなくて、一つの場所で定義するとアプリ全体に統一感が出やすくていいと思います。

#チュートリアルの実装
アプリを使い方というのは、ユーザーは何も知らないわけなのでそれを説明する必要があります。ですが、説明はなるべく少なく直感的にユーザーが使えるようなアプリがいいアプリなのかなと私は思っています。

Zikanriは記録アプリなので、気軽に素早く記録を行える必要がありました。
なので、どうしてもショートカットなどの時短化機能を実装せざるを得ませんでした。
(ネガティブな風に言ってますが、時短化機能はいい機能だと思っています。)

なのでチュートリアルは他のアプリに比べて少し多いかなという印象です。

自分がアプリに求めるようなチュートリアル要素は以下の通りです。

  • ユーザーがしっかり理解するまで表示される。
  • ユーザーのアクションの邪魔をしない。
  • デザインに溶け込む
  • チュートリアルらしくしない

上の項目を考慮して、このアプリのチュートリアルについて考えました。

それはカードとして画面に溶け込ませることです。

画像で見ると分かりやすいと思います。

右上のボタンが押されるまでチュートリアルは表示され続けるので、ユーザーは好きなタイミングでチュートリアルを閉じることができます。

間違って閉じてしまった場合の対処法として、チュートリアルの一覧表示をするページも作っています。

#キーボードを開くときの工夫
多くのアプリではTextFieldなどでキーボードを開くと思いますが、開くときの注意点、そして工夫点を紹介します。

まず、注意点です。
最初にでるエラーの筆頭としてはやはりOverflowですよね。
これは画面をスクロール可能にすることで解消されます。
解消策としては、ListViewSingleChildScrollViewを使うのが一般的だと思います。
このエラーはググることで皆さん解決できていると思います。

次に、工夫点です。
実はうえの注意点を解消するだけでは、ユーザーが満足のいくアプリにはなっていないことが多いです。
理由は、キーボードによってTextFieldが隠れてしまう場合があるからです。
つまり、入力されている文字が見えないことが発生するわけです。
これをどのように解消するかというと、画面下に適切な空間を空けることで解消されます。

MediaQuery.of(context).viewInsets.bottomを使います。
これを使うとシステム側で使われるbottomの高さを取得できます。
これが分かればあとはSizedBoxを置いたりpaddingを使ったりで適切に対処できると思います。

画像のようにキーボードを開くだけで、適切な位置にTextFieldを持ってくることができました。
画像は、BottomSheetの下にMediaQuery.of(context).viewInsets.bottomの高さを持ってくることでできました。
(なのでBottomSheetの高さが高くなっているように見えます。)

#Rowの両端寄せの罠
 Widgetを横に並べるときには、たいていRowというWidgetが使われます。
このRowは大変便利です。

  • 横の並び方を変えられる
  • 横に並べてある高さの違うWidgetに対して、上揃えや中央揃えができる

のふたつの点が好きです。

しかし罠がありました。
それは、RowでWidgetを両端寄せにする場合です。
皆さんご存じのとおり、こんな感じで両端寄せが実現できます。


Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: <Widget>[
    ...
  ],
)

両端寄せといっても、言葉通りになるのはchildrenのなかのWidgetが二つの時だけです。
Widgetが三つ以上になると、両端が端に寄ってあとは均等に並べられます。

しかし先ほども言った通り、罠がありました。
それはRowchildに持つ親Widgetがサイズを持たない場合です。

普通に考えれば「サイズがないのに両端寄せなんてありえない」となるはずです。

しかし私は「エラー文コピペ野郎」だったので、何も考えずにGoogleさんやその親戚(Google翻訳)に解決策を見つけてもらいました。
Googleさん達は「親Widgetがサイズを持てばいい」という答えをくれました。

確かにそうです。サイズがないのが問題であれば、サイズを持たせればいいという発想です。

しかしそうなると、どのようにサイズを持たせるかという問題になります。
画面サイズ最大でサイズをとると仮定すると、
Expandを親として持ったり、SizedBoxContainerMediaQueryで画面サイズを与えたりと方法が思いつきます。

しかし、簡単な方法がありました。
それはSpacerというWidgetchildrenに組み込むことです。

Spacerはflexが1に設定されているだけの、いわゆる空間をあけるためだけのWidgetです。

  • 細かくサイズ指定して空間をあけるならSizedBox
  • 最大まで空間を開けたり、比率で空間のサイズを変えたいときはSpacer

と使い分けができると思います。


Row(
  children: <Widget>[
    //左にあるWidget達
    Spacer(),
    //右にあるWidget達
  ],
)

こうすることで、もれなく
「左にあるWidget達」は隙間なく左に寄せられ、
「右にあるWidget達」は隙間なく右に寄せられます。

#TextFieldの最大文字数
 文字を入力するときは、多くの場合TextFeildを使うと思います。
その中で何かのタイトルやユーザー名を入力してもらうときは、最大文字数を設定したいと考えると思います。
TextFieldにはその機能があるので、実際に実装してみましょう。

TextField(
  maxLength:30,
);

こうすると文字数を30文字に制限できます。
ですが、以下の写真のようになります。

人によっては黄色く囲った部分が邪魔だと思う人がいると思います。
私もその一人です。

 これを除去するにはmaxLengthを使わない別の方法で文字数を制限します。
以下の通りです。

TextField(
  inputFormatters: [
    LengthLimitingTextInputFormatter(30), //30文字に制限
  ],
);

inputFormattersではこれだけでなく数字だけの入力にしたりできます。
またList型なのでTextInputFormatter型を複数オプションとしてつけることができます。

#SVGファイルの表示
FlutterはデフォルトではSVGファイルをサポートしていないので、flutter_svgというパッケージをインポートします。


dependencies:
  flutter_svg: ^0.17.4

※現在(6/18)の最新バージョンは0.18.0です。

表示はこうします。

import 'package:flutter_svg/flutter_svg.dart';

SvgPicture.asset(
  'assets/images/image.svg', //画像パス
  width: 200,
  //ほかにも結構オプションがあります
),

自分はSVGファイルをチュートリアルの画像として使用しました!

flutter_svgの詳細はこちらから!

#スマホの縦画面固定
自分の作ったアプリは縦画面固定になっていますが、その方法を紹介します。
flutter 縦画面固定と調べると、よくこれが出てきます。

SystemChrome.setPreferredOrientations([
  DeviceOrientation.portraitUp, //縦画面固定にする
]);

これをmain.dartに書いてもいいんですが、ある時に挙動がおかしくなります。
それはアプリを開くときに横画面だった場合です。
MediaQueryで画面サイズを取っている場合には、もれなく最悪になります。

他に方法がないかと探しました。
(Androidに限った話だと思いますが、)AndroidManifest.xmlに以下を追記することでアプリを縦画面固定にすることができます。

AndroidMainfest.xml

(activityに書く)
android:screenOrientation="portrait"

横画面の起動時でも問題ないです。

#ファイルの整理
アプリ画面には関わってこないんですが、ディレクトリやファイルの整理をすることがかなり大事だと思っています。

  • 開発中に前書いたコードを見に行きたい
  • アプリリリース後に修正点が出てきた

このような場合の時どこに目的のものがあるのかが、格段に見つけやすくなるからです。

この画像を見てください。

これは自分が2月ごろに書いていたコードの一部です…
(よく私のPCの中で消されずに生存していたものです)

開発当初、自分は先に言ったことが全く頭になく、コード行が4桁に達するまで一つのファイルに書き込んでしまいました…

ここまでくると修正はかなり面倒です。
(fileに分ける度にimport部分でエラーが山ほど出ます…)

こうなると、ファイルの小分けに全くモチベが出なくなります。笑

新しいアプリ案の開発を始めましたが、そこでは積極的にファイルの小分けを進めています。

頭に入れていることといえば、

  • ファイル一つにclassはひとつにする(なるべく)
  • class名とファイル名を同等に(class名 : MyBag => file名 : my_bag)

くらいです。
ちなみにclass名やファイル名には命名規則があります。

  • class : UpperCamelCase => 先頭大文字・区切り大文字
  • ファイル・ディレクトリ : lowercase_with_underscore => 先頭小文字・区切り_
  • フィールド : lowerCamelCase => 先頭小文字・区切り大文字

あとはファイルの役割ごとに適切なディレクトリを作って振り分けるだけでファイル管理がかなり楽になると思います。

ぜひ気を付けてください。笑

#アプリ公開のスクリーンショット
iOSはAppStore、AndroidはGooglePlayStoreにデザインの優れた多くのアプリが公開されています。
それに見合うように私もアプリ紹介のデザインには力を入れました。

上の写真のようなスクリーンショットをデザインしました。
右のようなモックアップの素材はこちらからダウンロードしました。

iPhoneやPixelなど主要なデバイスが揃っています。

エミュレーターなどでスクリーンショットをとって、レイヤーで重ねると簡単にデバイスに画面をはめ込んだ写真を作れます。

スクリーンショットでアプリのダウンロード数が変化することもあるので、余裕があればデザインに力をいれてもいいと思います。

#アプリのリリースビルド
(Androidのお話です。)
ここで言えることはただ一つです。
作成したキーはなくさないようにしましょう。

一応ですが、参考はこちらから
https://flutter.dev/docs/deployment/android
https://qiita.com/Renkon117/items/80d9775eb870416d69bf

#大事だと思う話
アプリ開発に限らず開発全体、もっと範囲を広げて、人生で大切なことは何でしょうか?
私が思うにそれは自分で調べてみることだと思います。
調べることで新しい知識や発見を得られるからです。

Flutterは最近YouTubeにも取り上げられたりして、日本でのコミュニティは拡大しています。
ですが日本語の情報は少ないです。
何と比べて少ないかといえば、それは**英語(English)**の情報です。

今回のアプリ開発では、エラーを数えきれないほど吐かれました。
そんなエラーをどうやって解決したか、やはり調べることによってです。

調べるといっても基本的に英語で調べます。
理由はもちろん、日本語の情報が(まだ)少ないからです。
(もちろん日本語の素晴らしい記事もあります。)

日本語で調べたい情報がなくても、英語ならあったという場合がかなり多くありました。
それはエラーに限った話ではなくて、実装したい機能などもあります。

また最新の情報も英語で提供されることがほとんどです。
などなど、英語のドキュメントを読むといいことがたくさんあります。

開発者として調べることは自分でも大事にしていきたいです。


皆さん、林先生はご存じですか?
自分は最近、林先生が出してる本を読みました。

その中でエンジニアに共通するような内容が出てきました。それは、

質問は「意味を考えてすべし」ということです。

皆さんの身の回りには皆さんよりも「スゴい人」がいると思います。
皆さんそんな方に何も調べずに質問したりしていませんか?

質問の価値が小さいような質問ばかりすると、最悪の場合「こんなことも自分でできないのか」と呆れられます

もちろん「分からなかったらなんでも聞いてね」という方もいると思います。
しかし、少しは自分の力だけで解決できるようになりましょう。
すると「スゴい人」からの評価も上がると思います。

皆さんの周りには「スゴい人」がいるだけでなく、
それを超える「スゴいインターネット」があります。

活用しない手はありません。

#さいごに
ここまで読んでいただいてありがとうございました。
今回が初めて書いた記事だったんですが、楽しんでもらえましたか?

最近(執筆:5月)は、やはりコロナで外出をしにくい状況です。
この期間で、自分の時間の使い方を見直す人も多かったと思います。
こんな時だからこそ、Zikanriでそんな人たちをサポートして、新しい挑戦の手助けをしたいです。
みなさんの毎日の価値をぜひ高めてください。

またこのアプリはアプリ甲子園というコンテストに応募予定です。
アプリ改善のためにレビュー大募集中です!
いい評価も悪い評価も理由をつけてレビューしてくれると大変役立ちます!

コンタクトをとりたい方は、自分のツイッターのDMまでお願いします。

最後までありがとうございました。

182
144
1

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
182
144

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?