Help us understand the problem. What is going on with this article?

Flutterでアプリを作った時のあれこれ

More than 1 year has passed since last update.

全然アプリの知識が無いお門違いの人間なのに、酔った勢いで調子乗ってトリを取ってしまい泣きながら書いてます。そのため技術的な内容はあまりなく流行りのQiitaポエムとなっておりますが温かい目で見てもらえると助かります。

Flutter #2 Advent Calendar 2018 最終日の記事です。

作ったアプリ

個人的な話ですが4月よりボルダリングにハマっており
- 音ゲーの様にクリア管理をしたい・友人と競いたい
- エンジニアの特性上ログを残したい
- ボルダリングの能力を数値としてはっきり見たい

という願望のもとプライベートで以下のようなアプリをFlutterで作成しました。

movie.gif

技術選定

昨今、アプリを作成するならば android / ios両方で動作するように作成しなければなりません。しかし、個人でのアプリを作成では、工数を考えると両OSを個別にnative開発する余裕もなく、クロスプラットフォームライブラリに頼るのが妥当でした。

1.png

候補としてはWeb/Xamarin/React Native/Unityあたりを考えていました。

宗教上や界隈的な理由UIカスタマイズをガンガンやって行くことが予想され、プラットフォームUIを直接使われるとカスタマイズの自由度が低い、パフォーマンス面、結局両方コードを個別に見なければならない、など懸念点が多々あり、それならGPUで画面描画されるUnityで無理やり作るかー?などと考えてました。

2.png

そうこう構想している時、ちょうどGoogleI/OにてFlutterが発表されました!
調査していくと、
- 描画が独自であるためOS感で差異がでない/カスタマイズが容易
- UIライブラリもMaterialDesignがかなり揃っている
- 描画エンジンもChromeと同じものでパフォーマンスも問題なさそう
- HotReloadで爆速開発
- dart packagesでライブラリ環境も揃っている
とのことで、1週間ほどチュートリアルを舐めたり、サンプルアプリのコードを眺め、実用に耐えれると判断し採用に至りました。

ちなみに、本アプリの初回コミット (無駄なそこそこ古参あっぴる)
3.png

設計

最終的にBLoCを採用しました。

一番始めはMVC的な感じでアプリを作っていました。が、web界隈で流行っていると聞くReduxの魅力に負け、middlewareやrx、thunkを駆使し2週間かけ一度すべて作り直しました。が、採用して2ヶ月経った頃に

  • 個人アプリ如きにここまでの冗長過ぎるコードは必要か??
  • 非同期やstreamめちゃ辛いぞ??
  • 再描画範囲大きすぎてパフォーマンス怖いぞ??(この時はあまり理解しておらずSceneトップでStoreConnectorでviewModelに変換するやばいコードでした)
  • Twitterの様にフォロワーを次々辿ってStackする遷移どうstore設計すればいいんだ!?

などなど問題を抱えこれらに耐えきれず、BLoCにまた2週間かけ移行しました。

4.png

本アプリのBLoCでは、widgetTreeのルートに全体で利用するデータ達を含むRootBLoCを置き、更に、シーン毎で個別に使うデータを含むSceneBLoCを配置する形としています。

5.png

またBLoCの原則に入力にもreduxの様にstreamにアクションを投げる形となっていますが、個人的に過剰な設計と考えこのルールを無視してBLoCクラス内に関数を作成して処理するようにしてます。そのへんReduxと違い柔軟に対応できるBLoCのシンプルさは大変いいです。

プロジェクト構成

このアプリでは以下の様にpackageを分割してアプリを構成しています。

6.png

分割してる理由としては、
- 個人的趣味嗜好
- サーバーサイドにFirebaseを利用してますが、変更するかもという懸念
- AngularDartを使うかもしれないのでコードを流用しやすい形にする
- 依存関係がはっきりしているので変なコードになりづらい
などありこの様な形にしております。

モデル

モデルでは、ユーザー情報やジム情報などシンプルなデータ型を定義しています。

機能としてはフィールド定義以外に
- jsonシリアライズ・デシリアライズ
- イミュータブル・ビルダー・rebuild
- ==
- hashCode
- toString
となっております。

モデルクラスのコード生成

上記で上げた機能はdartにはデフォルトで存在しておらず各々が自分で用意する必要があります。

が、これらを手動で記述するにはとても冗長なコードであり、単純なコードがあるがゆえにフィールドの項目が増えれば増えるほど人的ミスが発生しやすくなります。そのため、このプリではコード生成という機械的補助を用いる事にしました。

第一候補 json_serializable

よく記事で見ますし記述も簡単でとても使いやすかったっです。が、名前の通りjsonのシリアライズ・デシリアライズ機能しかなく、Reduxなどで使うための要望は満たすことはできないため不採用にしました。

第二候補 built_value

クラスに継承が必要などピュアではない特殊な記述が必要で学習など大変ですが、上記の要望は全て満たすものだったため、こちらを採用させて頂いています。(そもそも言語機能レベルで用意してほしいです)

built_valueの使い方

パッケージに以下の物を加えます。

dependencies:
  built_collection: '>=2.0.0 <5.0.0'
  built_value: '>=5.5.5 <7.0.0'

dev_dependencies:
  build_runner: ^1.0.0
  built_value_generator: ^6.2.0

その後以下のような雛形に従いクラスを作成します。
(\$CLASS_NAME\$、\$FILE_NAME\$は自身のクラス名ファイル名に置き換えてください)
(もちろんこの段階ではエラーは沢山出ます)

import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';

part '$FILE_NAME$.g.dart';

abstract class $CLASS_NAME$ implements Built<$CLASS_NAME$, $CLASS_NAME$Builder> {
  static Serializer<$CLASS_NAME$> get serializer => _$$CLASS_NAME$Serializer;

  $CLASS_NAME$._();
  factory $CLASS_NAME$([updates($CLASS_NAME$Builder b)]) = _$$$CLASS_NAME$;
}

その後プロジェクトのrootフォルダにて以下のコマンドを実行することで、\$FILE_NAME\$.g.dartというファイル名でコードが自動生成されます。

Flutter packages pub run build_runner build

本プロジェクトでのbuilt_valueの使い方

Firestoreはキーバリューなシステムであり、取得したデータをそのままデシリアライズすると生成されたオブジェクトにIDが含まれません。そのため、キーとなるIDもオブジェクトで保持したいが、シリアライズには含めたくないという願望が生まれ、簡単ですが以下の様な基底クラスを作成することで、デシリアライズするときにオブジェクトに無理やりIDを入れ、シリアライズするときに抜き取るというアプローチで対応しました。

import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:boulrec_repository/serializer/serializers.dart';

abstract class ModelBase {
  static const String ID = "id";

  @BuiltValueField(wireName: ID)
  String get id;

  // 辞書からクラスオブジェクトへ変換
  static T fromMap<T>(Serializer<T> s, String id, Map<String, dynamic> data) {
    if (data == null) return null;
    data[ID] = id; // IDを辞書に追加
    return serializers.deserializeWith(s, data);
  }

  // クラスオブジェクトから辞書へ変換
  Map<String, dynamic> toMapBySerializer<T>(Serializer<T> s) {
    Map<String, dynamic> ans = serializers.serializeWith(s, this);
    ans.remove(ID); // IDを辞書から削除
    return ans;
  }
}

利用側のサンプル

library user;

import 'package:boulrec_repository/model/modelBase.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';

part 'user.g.dart';

abstract class User extends Object
    with ModelBase
    implements Built<User, UserBuilder> {
  static const String NAME = "name"; // jsonのキーを定義しておくと何かと便利です
  // シリアライザー
  static Serializer<User> get serializer => _$userSerializer;
  // この様なアノテーションでシリアライズするときのキーを指定できます
  @BuiltValueField(wireName: NAME)
  String get name;
  // built_valueのおまじない
  User._();
  factory User([updates(UserBuilder b)]) = _$User;
  // factoryは用途に合わせて複数作ります
  factory User.fromInit({String id, String name = ""}) {
    return _$User._(
      id: id,
      name: name,
    );
  }
  // 今回の肝のデシリアライズ
  factory User.fromMap(String id, Map<String, dynamic> data) =>
      ModelBase.fromMap(serializer, id, data);
  // 今回の肝のシリアライズ
  Map<String, dynamic> toMap() => toMapBySerializer(serializer);
}

// デシリアライズしたときの初期値はここで設定できます
// アップデートでフィールドを増やすがnullableは嫌だと言う時の初期値保証などで利用できます
abstract class UserBuilder implements Builder<User, UserBuilder> {
  String id = "";
  String name = "";

  factory UserBuilder() = _$UserBuilder;
  UserBuilder._();
}

すでに治ってるか不明ですが、firebase storeのDateTimeシリアライズに少し手が必要だったり、enumの扱い、独自シリアライザーを含めるなどなどtipsはいくつかありますが、ここでは長くなるので割愛させていただきます...

Repository Interface / Firebase

モデルで定義したデータ達をリポジトリ(サーバーやローカルストレージ)から取得する箇所です。

特に変なことはしていないので割愛させていただきます。
(果たしてFirebaseから乗り換える事はあるのだろうか...)

BLoC

機能単位で必要となるデータの取得、データ加工を行っています。

1_giaQZugU17XFsOU-7VhLdw.png
Build reactive mobile apps with Flutter (Google I/O ’18) — YouTube より

設計を移行する時は純粋なBLoCとして以下を参考にさせて頂きながら作っていました。
上の方でも書きましたが、抜本的思想であるstreamのみのインターフェースは無視して、制作効率や可読性を優先させ純粋な関数を生やして処理させています。

【BLoC/RxDart入門】 Flutterの公式チュートリアルを書き換えてみる

FlutterでBLoCを扱う上で別の問題点として、streamを利用する場合後処理としてstreamに対してcloseを呼ぶ必要があります。しかし、例にあるようなInheritedWidgetを継承したデータを保持するコンテナ的Widgetを作成しているにもかかわらず、BLoCオブジェクトの生成は外のStateFullWidgetで行い、そこのdisposeで破棄をする手間がありました...

そこで自前で書いてもいいがきっとpubにあるだろう〜と漁っていたら上記を解決してくれる以下のpackageがあり利用させてもらってました。
bloc_pattern | Flutter Package

が、このFlutter AdventCalendarを用意して頂いたmono0926様が同じようにこの問題点について言及されており、それを解決するpackageを作成されていたため、せっかくなので乗り換えてみました。

BLoCのcloseの為だけに記述していたStateFullWidgetの撲滅や、各シーン毎に作成していた冗長なBLoCProviderの撲滅が出来とてもありがたいです。

bloc_provider | Flutter Package

(1プロバイダーに1つのblocしか持てない点や、なぜか特定の条件下でofでの取得で失敗するなど問題点はありますが今後に期待してます)

アプリケーション

このアプリではファイルやフォルダを、基本以下の様な階層構造にしたがって作成しています。モデルやビジネス周りを別packageにしているため、かなりシンプルになったのではないかと思います。

lib
├ main
│ ├ main.dart
│ ├ その他アプリ全体に関わるコード.dart
│ ├ ...
│
├ components
│ ├ 様々な画面で使い回すwidget達.dart
│ ├ ...
│
├ scene
│ ├ シーングループ名
│ │ ├ シーン名
│ │ │ ├ ...
│ │ │
│ │ ├ ...
│ │
│ ├ ...
│
├ i18n

コンポーネント

ボタンやアバターなど頻繁に使い回すwidgetや、アプリ特有のwidget、肥大化したwidgetはここで管理しています。

Androidなどで使い回す様なCustomViewを作る労力と比べ、単にWidgetの階層の一部を切り出したものをクラス化するだけで使い回しの効くWidgetが作れるのは本当にFlutterのいいところだと思います。

また、FloatingButtonのように、この自作アバターWidgetの子供にもHeroWidgetを埋め込んでいるため、利用するだけで勝手に画面間でヒーローしてくれるなど便利です。
hero.gif

アニメーション

はじめは高度なアニメーションを市販ツールを流用して作成できるLottieの利用を考え、以下のpackageをストーカーしてましたが、やはり実装が難しいのか不完全かつ更新が止まっているため利用を諦めました。(1.0でPlatformViewが実装されたので、またこのプラグインの開発が活発化することに期待してます。)

lottie_Flutter | Flutter Package
fluttie | Flutter Package

しかし、意外に以下の様な凝ったwidgetも、FlutterのAnimationとCustomPainterを利用し、ゴリゴリcanvasにPathなどを記述すれば、アニメーション込みで両方共たった200行ほどで組み込めてしまったので問題ありませんでした。

pin.gif
rader.gif

が、言うて結構辛いのは間違いないので今後凝ったアニメーションを組み込む時は、Lottieを頑張るか先日発表のあった「Flare」の利用を検討したいです。(Flareはまだ先が不安なので個人的には控えたいですが...)

シーン

シーンを構成するクラスは、シーン毎以下のように定義するようにしています。

  • index.dart
  • view.dart
  • drawer.dart
  • style.dart
  • その他widget.dart

ファイル名

今までクラス名をファイル名にする習慣しかなかったのですが、dartのファイル名をスネークケースにする事への違和感 ふと振り返るとフォルダ(etc:Login)とファイル名(login_scene.dart)と重複して情報をもつ冗長さに常々疑問を感じており、ファイル名と役割が一致していたほうがわかりやすいのでは!?という発見のもと、上記の様な形に収まりました。

参考 : GeekyAnts/FlatApp-Firebase-Flutter: Flap App with Firebase in Flutter by GeekyAnts.

index.dart

シーンのトップクラス(etc : class LoginScene)です。

役割はblocとviewの管理、そしてボタンを押した時などのアプリに関わるイベントの処理を記述しその関数をviewに渡しています。はじめBLoCの管理は、サンプルに習ってBLoCProviderを継承した専用のクラスを作成してましたが、冗長な感じがしたので直接templateを使いBLoCProviderで保持するようにしています。また複数BLoCを保つ場合は、連続でBLoCProviderを入れ子にする少しイケてない感じなってしまっています...

view.dart / drawer.dart

画面レイアウトを定義するクラス(etc : LoginView)です。

レイアウトなので処理は基本一切書かない決まりで、イベントもindex.dartからもらったFunctionをそのままボタンのonTapなどで呼ぶようにしています。また、動的なデータの表示はもちろんBLoCで引っ張ってきて、streamBuilderを用いてviewに反映する様にしており、ここでも極力処理を書かないルールのため、表示するためのデータ加工はBLoC側で行うようにしています。

style.dart

見た目を司るstyle系はviewに記述すると長くなるので、cssとhtmlを分割するようなイメージで分けています。
はじめは、ファイル内にTextStyleなどを静的なものとして列挙していたのですが、HotReloadが効かないため、クラス内に定義を保持し、viewのコンストラクタでstyleオブジェクトを生成する事で、HotReloadにも対応しました。

class LoginStyle {
  final TextStyle title = const TextStyle(
    color: Colors.white,
    fontSize: 18.0,
  );
  const LoginStyle();
}

おまけ

以下の様にレイアウトは同じでも、モードによって表示内容を変更したい場合があります。

← 通常                編集モード →
7.png

この画面の様に、変化するWidgetが数個なら楽ですが多数となると大変になるイメージです。しかし、Flutterはコードによってレイアウトを生成してるため、よくあるパターンである「interfaceを定義し、モード毎に継承したクラスを作成し、表示するwidgetを切り替える」という事が可能でした。

abstract class ProblemDetailWidgetsInterface {
  // タイトル
  Widget getTitle(BuildContext context);

  // クリア状態
  Widget getClearState();

  // ...
}
  @override
  void initState() {
    super.initState();

    if (isEditMode) {
      _detailWidgets = ProblemDetailEditWidgets();
    } else {
      _detailWidgets = ProblemDetailInfoWidgets();
    }
  }

Dart Packages

image.png

packageは車輪の再発明をせずに済むのでとても便利なものです。

が、残念な事にまだ若い界隈なだけあって、バグや機能が足りないなど問題が発生するものが多々あります。(Dart2.0の時はほんと大変でした...)しかも、packageによっては全然修正されなかったりpullRequestが通らないなどがあり、なかなかつらいです...そのため、問題を発見したらパッケージマネージャーから利用するのではなく、githubから落としてきて自分で修正するというワークフローは、Flutterのpackageを使う上で特に覚悟が必要かとおもいます。

せっかくなのでこのアプリを制作してて、まだ未解決の問題をいくつか紹介したいと思います。

image_picker

このプラグインは写真を撮りそのデータを取得したり、端末のイメージライブラリから写真を選択して取得できるというとても便利なものです。

しかし、このプラグインを入れ1ヶ月ほど利用していたらアプリの使用ディレクトリサイズがギガを超えており、なんだ!?と思い調査した結果...
- jpegのQualityを最大で保存している
- 撮った写真をリサイズした場合、ファイルがリークする
という問題がありました。

9.png

両方ともPull Requestを投げて一ヶ月以上経っていますが、通る気配はありません...

cached_network_image / Flutter_cache_manager

ネットワークから取得した画像をURLをキーにローカルにキャッシュする仕組みを提供するライブラリです。

このアプリでは画像のアップロード時、FireStorageに画像をアップロードすると同時に、FireStoreにURLではなく「パス」を保存しています。これは、FireStorageの仕様上ダウンロードURLは変更することができるためです。

ここで問題になってくるのがキャッシュのキーで、画像がキャッシュ済みであってもパスからURLに変換するために一度通信をする必要があるため、画像の表示が遅れてしまう、オフラインでは表示すらされません。

8.png

それを解決するために、現在URLではなくパスでもキャッシュ確認ができるプルリクを投げていますが、なかなかリポジトリに反映されません。。。

他の記事などを見ていると、アップロード時に画像URLを取得していまいFirestoreにアップしている方法をよく見るのですがどちらがいいのでしょう??

制作フロー

10.png

個人制作のためそこまで厳密には決めていませんが、基本的には上記の図の様なシンプルな流れで制作してます。

作業時間

普段社畜をし、その他プライベートな活動(ボルダリングなど)をしたりとなかなか時間を取るのが難しい社会人。家に帰る途中まではそれはとても素晴らしいモチベーションがあっても、家に帰った途端食事やアニメ、漫画、ゲーム、ネットサーフィンをついついやってしまい虚無に終わる事しかありません。

そのため、今日は予定ないぞ!という朝はカバンにPCを入れ、帰りにせっかくPC持ってきたしスタバに寄ってやるかーという様に、「やる!」という意思決定を前に持ってくる事で無理やり作業を進めるようにしています。世の勤勉なエンジニア様やクリエーター様達ほんとすごい

タスク

趣味で何かを作る時はずっとTrelloを利用していたので、その流れでTrelloを使ってます。
最近はBitBucketのissueも利用するようにしてきてます。

時代に取り残されているのでなにかいいものがあったら教えていただきたいです。

デザイン

FlutterはHotReloadの機能がとても優秀なので、コーディングしながら画面を思考錯誤することが全然可能だと思います。が、下記の様な小さいポップアップですら2作業日かけてしまうぐらいデザイン能力が低いのでしっかりAdobeXDを利用して事前にデザインを作成してから実装するようにしています。よく記事とかで見る奥さんがデザイナーさんとか死ぬほど羨ましい

スクリーンショット 2018-12-24 20.38.31.png

当たり前の事かもしれませんが、デザイン・実装を順序立てて作業を進める事で悩む時間や、頭の切り替え、手戻りが少なく済むので、個人開発だろうとこの方法で進めて良かったと強く感じてます。

私個人が業務でFlutterを使うことは絶対に無いですが、もし業務で役割分担してFlutterアプリの開発を行うならば、上記で紹介したview.dartやstyle.dartはhtmlやcssに考え方が近いため、そこのレイヤーもデザイナーさんに学習してもらって任せるべきだと考えています。
(良いものを作るためには、担当者がイテレーションを回しやすい環境、というものが大切だと考えているので)

コーディング・リリース

時代に取り残された人間のため、使い慣れたbitbucket/git flowで作業を進めてます。
未だTestやCI、リリース作業の自動化などは出来ていないので今後の課題としています...

おわりに

Flutterが今後生き残り続けるか分かりませんが、個人的には大変気に入ってるフレームワークなので、流行ることを祈ってこの記事を〆させて頂きます。1


  1. どうでもいいですが、クリスマスと程遠い業界の人間たちがなぜクリスマスを毎日ワクワク待ち侘びるようなイベントをやってるのでしょうね? 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした