hisurga.net

個人開発ログ

Flutterで競馬収支管理アプリを作成しました

個人開発として終業後と土日を利用してコツコツ実装していたモバイルアプリですが、ようやく公開することができました。

アプリの目的からして対象ユーザーは限られますが、もし競馬が趣味であればぜひとも試していただきたいです。

さて、初版リリースを達成したところで、HistBetを作成するまでに利用した技術やサービスを共有したいと思います。

作成するに至った経緯

私は趣味で競馬(主にネット投票)を楽しんでいるのですが、結局自分がいくら勝っているのか、あるいは負けているのかを把握していませんでした。

自分のリターンを把握していない賭け事ほど愚かなものはありません。

そこでマネーフォワードのようにアプリで収支を管理できたら便利だと考えて探してみたのですが、自分の納得いくアプリが見つからなかったため、「だったら自分が納得するアプリを作ろう!」に至りました。

使った技術やサービス

  • Flutter
  • Firebase
    • Firestore
    • Cloud Functions for Firebase
  • Codemagic + GitHub

Flutter

HistBetはiOS/Android両プラットフォーム向けにリリースしていますが、それを可能にしたのはFlutterのおかげです。多少の分岐はありますが、8-9割を共通のコードで作成できています。

構成としてはChangeNotifier + Providerを採用しました。シンプルに書けそうですし、ある程度規模が大きくなっても問題ない手法と考えての採用です。ただFlutterは進化が早いので、この設計に固執せずにいたいとは思います。

結果的には楽しく進められたので、Flutterの選択は正解でした。

Firebase

  • ユーザのアカウント管理 (Firebase Auth + Firestore)
    • Googleサインイン
    • Appleサインイン
    • 匿名認証
  • ユーザーデータ管理 (Firebase Auth + Firestore)
    • 収支管理
    • 馬券管理
  • 課金管理 (Firebase Auth + Firestore + Cloud Functions)
    • 課金データ管理
    • 課金レシートの認証

これら全てでFirebaseを利用しています。

個人で上記機能の実装はかなりの工数を必要としますが、それを大幅に短縮させられるのがFirebaseです。

FlutterとFirebaseの相性も良く、情報も多くあったのでハマりポイントは少なめでした。

Codemagic

元々Flutter向けに作られたCI/CDサービスとのことです。

個人開発でCI/CDが必要かと疑問に思われるかもしれませんが、

  • 常にクリーンな環境でビルドが実行される
  • GitHubにpushするだけでストアへバイナリアップまでしてくれる

といったメリットがあり、個人開発でも採用価値があると思います。

それに個人開発程度の規模であれば無料プランでも十分やりくりできる範囲です。

今はテストコードをほとんど書いていないのでストアアップ専用になっていますが、いずれテストコードをしっかり書いてCodemagic上のクリーンな環境でテストするシステムも構築したいと考えています。

Flutter + Firebaseという構成

時間、人員が限られている個人開発において、iOS/Android両プラットフォーム向けアプリをリリースするのであれば、Flutter + Firebaseは間違いなく候補の1つになると思います。

思い描いたデザインや仕様をサクサク形にできる体験はたまりません。

今後のプラン

本アプリの機能は、実装したい機能のまだ一部です。アプリとして成り立つレベルに至り次第、1.0.0として公開しました。

そのため、しばらくはHistBetの機能拡充を目標に開発を進めていくつもりです。

並行して今回の開発で得た知見を共有し、これからFlutter/Firebase開発を頑張る人の役に立てればなと思います。

Flutter Widgetをz軸方向に動かす方法 (elevation)

ほとんどのWidgetに用意されているelevationプロパティですが、これはz軸方向への階層を表しています。

floatingActionButton: FloatingActionButton(
    elevation: 2,
    onPressed: _incrementCounter,
    tooltip: 'Increment',
    child: Icon(Icons.add),
    ), 

「マテリアルデザインではz軸方向が存在し、パーツとパーツの物理的な重なり合いでデザインされる」
といった話はよく聞くと思いますが、elevationはそのz軸を示します。

わかりやすくするため、2つ並んだRaisedButtonがあるとします。elevationは指定していないので初期値です。

RaisedButton(
    child: Text("Button1"),
    onPressed: () {},
),
RaisedButton(
    child: Text("Button2"),
    onPressed: () {},
),

f:id:hisurga:20200726134154p:plain:w400

elevationを10にするとこうなります。

RaisedButton(
    elevation: 10,
    child: Text("Button1"),
    onPressed: () {},
),
RaisedButton(
    child: Text("Button2"),
    onPressed: () {},
),

f:id:hisurga:20200726134529p:plain:w400

参考にさせていただいたサイト

elevation property - Material class - material library - Dart API

Flutter Firestoreで複数ドキュメントに渡るTransactionを張る方法

Firestoreのトランザクションを複数のドキュメントに張る方法です。

1つのドキュメントを読み取り、その後同じドキュメントで書くような場合は単純です。(例えばインクリメント)

トランザクション内でgetした後にupdateもしくはsetすればいいだけですね。

final CollectionReference booksRef = _db.collection('/books');

await _db.runTransaction((Transaction tx) async {
  print('Transaction start');
  DocumentSnapshot book1Snapshot = await tx.get(booksRef.document('book1'));

  if (book1Snapshot.exists) {
    await tx.update(booksRef.document('book1'),
       <String, dynamic>{'title': 'book_title1'});
  }
}).then((value) {
  // 成功した時の処理
  print('ok');
}).catchError((err) {
  // 失敗した時の処理
  print(err);
});

複数のドキュメントに渡らせる

ドキュメントAの値を元にドキュメントBの値を変える場合はどうしましょう。

例えば、本Aのタイトルを本Bにも同じタイトルでセットするとします。
素直にbook1のタイトルを取得してbook2をupdateすればよさそうです。

final book1 = await _db.collection('counters').document('counter1').get();
final titleBook1 = book1['title'];
        
await _db.collection('books').document('book2').updateData(<String, dynamic>{'title': titleBook1});

ではここに「book1のタイトルは時折変更されることがある」と条件をつけた場合はどうしましょう。

仮に上記の例でbook1を取得した直後にbook1のタイトルが変わってしまった場合、
book1とbook2のタイトルが異なってしまいます。

トランザクションを使う

それを防ぐ用途としても、トランザクションは便利です。
トランザクションでは読み取ったドキュメントが変更された場合、処理中のトランザクションを一度ロールバックして初めからやり直します。

それならbook1とbook2で異なってしまうことはありませんね。
シンプルに実装してみます。

final CollectionReference booksRef = _db.collection('/books');

await _db.runTransaction((Transaction tx) async {
  print('Transaction start');
  DocumentSnapshot book1Snapshot = await tx.get(booksRef.document('book1'));

  if (book1Snapshot.exists) {
    await tx.update(booksRef.document('book2'),
        <String, dynamic>{'title': book1Snapshot.data['title']});
  }
}).then((value) {
  // 成功した時の処理
  print('ok');
}).catchError((err) {
  // 失敗した時の処理
  print(err);
});

しかし残念ながらこれでは動きません。トランザクションが複数回試行された後に、以下のエラーが吐き出されます。

PlatformException(9, Transaction failed all retries.: Every document read in a transaction must also be written in that transaction., null)

トランザクション内でreadしたドキュメントはは必ずその後writeしなければいけないようです。

公式ドキュメントには以下のように記載されています。

トランザクションを使用する場合は、次の点に注意してください。

  • 読み取りオペレーションは書き込みオペレーションの前に実行する必要があります。
  • トランザクションが読み取るドキュメントに対して同時編集が影響する場合は、トランザクションを呼び出す関数(トランザクション関数)が複数回実行されることがあります。
  • トランザクション関数はアプリケーションの状態を直接変更してはなりません。
  • クライアントがオフラインの場合、トランザクションは失敗します。

その中のこの一文について

読み取りオペレーションは書き込みオペレーションの前に実行する必要があります。

私は「書き込みたいならその前に読み取ってね」「読むだけなら別にルールはないよ」としか捉えられなかったのですが、英文だと下記のようになっています。

Read operations must come before write operations.

これならRead後にはwriteが必須と読めなくもないですね。英語大事。

コードに反映してみます。
「何も書かないwrite」の方法がわからなかったので、とりあえず同じ値でupdateしました。

await _db.runTransaction((Transaction tx) async {
  print('Transaction start');
  DocumentSnapshot book1Snapshot = await tx.get(booksRef.document('book1'));

  if (book1Snapshot.exists) {
    await tx.update(booksRef.document('book1'),
        <String, dynamic>{'title': book1Snapshot.data['title']});
    await tx.update(booksRef.document('book2'),
        <String, dynamic>{'title': book1Snapshot.data['title']});
  }
}).then((value) {
  print('ok');
}).catchError((err) {
  print(err);
});

無事Firestoreに反映されました。

ちなみに、私はこのルールに気づかずにハマっていました。
その原因がこちらです。AndroidとiOSで挙動に差があるようです。(Flutter for Androidかcloud_firestoreのバグ?)

hisurga.hatenablog.com

参考にさせていただいたサイト

トランザクションとバッチ書き込み  |  Firebase

firebase - Update multiple documents in a single transaction with dart and Firestore - Stack Overflow

Flutter AndroidとiOSでFirestoreトランザクションの挙動が異なる

FlutterでFirestoreトランザクションを試していた時にハマった話です。

現象

AndroidとiOSでFirestoreトランザクションの挙動が異なる

  • Android : トランザクションに失敗しているのに成功として処理される
  • Android : トランザクション中でthrowするとエラーをキャッチできない

環境

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^0.4.0
  cloud_firestore: ^0.12.5+2
[✓] Flutter (Channel stable, v1.5.4-hotfix.2, on Mac OS X 10.14.5 18F132, locale ja-JP)
 
[✓] Android toolchain - develop for Android devices (Android SDK version 28.0.3)
[✓] iOS toolchain - develop for iOS devices (Xcode 10.2.1)
[✓] Android Studio (version 3.4)
[!] VS Code (version 1.35.0)
    ✗ Flutter extension not installed; install from
      https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter
[✓] Connected device (2 available)

トランザクション失敗例

book1をreadして得たタイトルbook2のタイトルにupdateで反映させようとしています。

しかし、book1のread後にbook1に対して何もwriteしていないので、トランザクションは失敗します。

final CollectionReference booksRef = _db.collection('/books');

await _db.runTransaction((Transaction tx) async {
  print('Transaction start');
  DocumentSnapshot book1Snapshot = await tx.get(booksRef.document('book1'));

  if (book1Snapshot.exists) {
    await tx.update(booksRef.document('book2'),
        <String, dynamic>{'title': book1Snapshot.data['title']});
  }
}).then((value) {
  print('ok');
}).catchError((err) {
  print(err);
});

iOS上で実行した結果

複数回トランザクションが失敗した後、エラーが出力しました。

flutter: Transaction start
flutter: Transaction start
flutter: Transaction start
flutter: Transaction start
flutter: Transaction start
flutter: Transaction start
flutter: PlatformException(9, Transaction failed all retries.: Every document read in a transaction must also be written in that transaction., null)

Android上で実行した結果

2回トランザクション試行後、成功と出力しました。

I/flutter (11258): Transaction start
I/flutter (11258): Transaction start
I/flutter (11258): ok

エラーをthrowした例

トランザクション中にthrowします。
エラー発生時はトランザクションが失敗します。

await _db.runTransaction((Transaction tx) async {
  print('Transaction start');
  throw 'err';
}).then((value) {
  print('ok');
}).catchError((err) {
  print(err);
});
}

iOS上で実行した結果

エラーをキャッチして終了します。

flutter: Transaction start
flutter: PlatformException(error, err, null)

Android上で実行した結果

アプリが落ちます。

一応エラーログをすべて貼り付けます。

I/flutter (11258): Transaction start
E/CloudFirestorePlugin(11258): java.lang.Exception: Do transaction failed.
E/CloudFirestorePlugin(11258): java.util.concurrent.ExecutionException: java.lang.Exception: Do transaction failed.
E/CloudFirestorePlugin(11258):  at com.google.android.gms.tasks.Tasks.zzb(Unknown Source:61)
E/CloudFirestorePlugin(11258):  at com.google.android.gms.tasks.Tasks.await(Unknown Source:33)
E/CloudFirestorePlugin(11258):  at io.flutter.plugins.firebase.cloudfirestore.CloudFirestorePlugin$4.apply(CloudFirestorePlugin.java:409)
E/CloudFirestorePlugin(11258):  at io.flutter.plugins.firebase.cloudfirestore.CloudFirestorePlugin$4.apply(CloudFirestorePlugin.java:361)
E/CloudFirestorePlugin(11258):  at com.google.firebase.firestore.FirebaseFirestore.lambda$runTransaction$1(com.google.firebase:firebase-firestore@@19.0.0:283)
E/CloudFirestorePlugin(11258):  at com.google.firebase.firestore.FirebaseFirestore$$Lambda$3.call(Unknown Source:6)
E/CloudFirestorePlugin(11258):  at com.google.android.gms.tasks.zzv.run(Unknown Source:2)
E/CloudFirestorePlugin(11258):  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1162)
E/CloudFirestorePlugin(11258):  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:636)
E/CloudFirestorePlugin(11258):  at java.lang.Thread.run(Thread.java:764)
E/CloudFirestorePlugin(11258): Caused by: java.lang.Exception: Do transaction failed.
E/CloudFirestorePlugin(11258):  at io.flutter.plugins.firebase.cloudfirestore.CloudFirestorePlugin$4$1$1.error(CloudFirestorePlugin.java:391)
E/CloudFirestorePlugin(11258):  at io.flutter.plugin.common.MethodChannel$IncomingResultHandler.reply(MethodChannel.java:181)
E/CloudFirestorePlugin(11258):  at io.flutter.embedding.engine.dart.DartMessenger.handlePlatformMessageResponse(DartMessenger.java:103)
E/CloudFirestorePlugin(11258):  at io.flutter.embedding.engine.FlutterJNI.handlePlatformMessageResponse(FlutterJNI.java:228)
E/CloudFirestorePlugin(11258):  at android.os.MessageQueue.nativePollOnce(Native Method)
E/CloudFirestorePlugin(11258):  at android.os.MessageQueue.next(MessageQueue.java:325)
E/CloudFirestorePlugin(11258):  at android.os.Looper.loop(Looper.java:142)
E/CloudFirestorePlugin(11258):  at android.app.ActivityThread.main(ActivityThread.java:6494)
E/CloudFirestorePlugin(11258):  at java.lang.reflect.Method.invoke(Native Method)
E/CloudFirestorePlugin(11258):  at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438)
E/CloudFirestorePlugin(11258):  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:807)
I/flutter (11258): PlatformException(Error performing transaction, java.lang.Exception: Do transaction failed., null)
D/AndroidRuntime(11258): Shutting down VM
E/AndroidRuntime(11258): FATAL EXCEPTION: main
E/AndroidRuntime(11258): Process: com.example.flutter_firestore, PID: 11258
E/AndroidRuntime(11258): java.lang.IllegalStateException: Reply already submitted
E/AndroidRuntime(11258):    at io.flutter.embedding.engine.dart.DartMessenger$Reply.reply(DartMessenger.java:124)
E/AndroidRuntime(11258):    at io.flutter.plugin.common.MethodChannel$IncomingMethodCallHandler$1.success(MethodChannel.java:204)
E/AndroidRuntime(11258):    at io.flutter.plugins.firebase.cloudfirestore.CloudFirestorePlugin$3.onComplete(CloudFirestorePlugin.java:425)
E/AndroidRuntime(11258):    at com.google.android.gms.tasks.zzj.run(Unknown Source:4)
E/AndroidRuntime(11258):    at android.os.Handler.handleCallback(Handler.java:790)
E/AndroidRuntime(11258):    at android.os.Handler.dispatchMessage(Handler.java:99)
E/AndroidRuntime(11258):    at android.os.Looper.loop(Looper.java:164)
E/AndroidRuntime(11258):    at android.app.ActivityThread.main(ActivityThread.java:6494)
E/AndroidRuntime(11258):    at java.lang.reflect.Method.invoke(Native Method)
E/AndroidRuntime(11258):    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438)
E/AndroidRuntime(11258):    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:807)

解決策

これを見る限り、Flutter for Androidかcloud_firestoreのバグかなと思っています。修正待ちですかね。
GitHubのIssueに似たのがなければIssue投げます。

私はずっとAndroidの実機デバッグしか使っていませんでしたので、原因が分かるまで相当な時間を無駄にしました。

Flutter Firestoreで分散カウンタを実装する

分散カウンタを簡単なカウントアップアプリで試してみます。

分散カウンタ

多くのアプリでは、いいね数やフォロワー数のためにカウントアップが必要です。
しかしFirestoreでは、制限上1つのドキュメントにつき1秒に1回しか更新することができません。

そこで分散カウンタを利用します。

1つのドキュメントにつき1秒に1回しかできないなら、
ドキュメントをたくさん作ってしまえばいいのです。

具体的な方法

  1. カウントアップ用のコレクションを作成します。
  2. カウントアップ時はランダムにコレクション内のドキュメントを1つ選び、フィールドの値をインクリメントします。
  3. カウント取得時は、コレクション内のドキュメントの値を合計すればOKです。

簡単なサンプル

Shards数100で100回インクリメントしたところ、96まで上がりました。
4回ほど失敗しているようです。

f:id:hisurga:20190628011028g:plain:w200

Shards数500でも試したら100まで到達しました。

スループットはShards数に比例しますが、
あまりにShards数を増やすとそれだけFirestoreのコストがかかるので難しいところです。

今回のコレクションとドキュメントの構成はこれです。

f:id:hisurga:20190628005307p:plain:w400

f:id:hisurga:20190628005329p:plain:w400

雑で申し訳ないのですが、今回の実験コードはこちらです。

参考にさせていただいたサイト

分散カウンタ  |  Firebase

Firebase Cloud FirestoreのTransactionについて考える - Qiita

Flutter Firestoreのサンプルを動かしてみる

FlutterでFirestoreを扱ってみます。

Firebaseの導入

FlutterのプロジェクトにFirebaseを対応させます。

対応手順はこれ以上ないくらい公式に記載されていますので、割愛します。

ステップ4まで進めましょう。

firebase.google.com

Firestoreの導入

pubspec.yamlへ下記のように指定します。
その際、最新のバージョンはpub.devから確認します。

なお「Firebaseの導入」で使った公式ドキュメントにはfirebase_core: ^0.2.5と記載されていますが、
最新のcloud_firestoreでは0.4.0を要求されますので、書き換える必要があります。(2019/6/24の情報)

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^0.4.0
  cloud_firestore: ^0.12.5+2

Cannot fit requested classes in a single dex fileとエラーが出た場合は下記記事を参考にしてください。

https://blog.hisurga.com/entry/2019/06/23/232923blog.hisurga.com

あとは下記のようにimportすれば使用することができます。

import 'package:cloud_firestore/cloud_firestore.dart';

Firestoreデータベースの作成

プロジェクト毎のFirebaseコンソール > Firestore
からFirestoreデータベースを作成します。中身は空で大丈夫です。

サンプルを試す

公式のサンプルコードほぼそのままで試してみます。

アプリ起動時は何も表示されません。

f:id:hisurga:20190624011608p:plain:w200

Firebaseコンソールからコレクションを追加してみます。

f:id:hisurga:20190624011707p:plain:w500

コレクションIDをbooksにします。

f:id:hisurga:20190624011921p:plain:w500

ドキュメントにtitleauthorを記載します。
IDは特に理由がなければ自動IDで問題ありません。

f:id:hisurga:20190624011946p:plain:w500

データベースにコレクションとドキュメントが追加されました。

f:id:hisurga:20190624012026p:plain:w500

アプリ上では、追加された瞬間にサンプルアプリへ情報が反映されました。

f:id:hisurga:20190624012118p:plain:w200

ドキュメントを追加しても更新されます。

f:id:hisurga:20190624013008p:plain:w200

コードは以下になります。

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Firestore Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Firestore Demo Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: StreamBuilder<QuerySnapshot>(
        stream: Firestore.instance.collection('books').snapshots(),
        builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
          if (snapshot.hasError) return new Text('Error: ${snapshot.error}');
          switch (snapshot.connectionState) {
            case ConnectionState.waiting:
              return new Text('Loading...');
            default:
              return new ListView(
                children:
                    snapshot.data.documents.map((DocumentSnapshot document) {
                  return new ListTile(
                    title: new Text(document['title']),
                    subtitle: new Text(document['author']),
                  );
                }).toList(),
              );
          }
        },
      ),
    );
  }
}

コンソールでは使用状況も容易に把握できます。

f:id:hisurga:20190624013628p:plain:w500

参考

Flutter アプリに Firebase を追加する

cloud_firestore | Flutter Package

Flutter cloud_firestoreプラグインを入れると64kエラーになる時の解決策

cloud_firestoreを試そうとサンプルを実行していたところ、以下の問題にぶつかりました。

現象

cloud_firestoreプラグイン導入後、ビルドが通りませんでした。

環境

  • Flutter/Android
  • firebase_core: ^0.4.0
  • cloud_firestore: ^0.12.5+2

エラーメッセージ

D8: Cannot fit requested classes in a single dex file (# methods: 74144 > 65536)

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:transformDexArchiveWithExternalLibsDexMergerForDebug'.
> com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives:

/省略/

  The number of method references in a .dex file cannot exceed 64K.
  Learn how to resolve this issue at https://developer.android.com/tools/building/multidex.html

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

BUILD FAILED in 3s
Finished with error: Gradle task assembleDebug failed with exit code 1

解決策

minSdkVersionを21にする

これから作るアプリということで、21未満は切りました。
(私の環境ではmultiDexEnabled trueの設定は不要でした)

もし21未満の対応も必要な場合、下記サイトが参考になると思います。

minpro.net

Flutter 画像ロード時にプレースホルダーを表示する

Flutterアプリで画像を表示する際にロード画面を表示する方法です。

画像の表示 with ロード画面

Flutterアプリで画像を表示する方法は以前のブログで紹介しましたが、
インターネットの画像を表示する場合、少なからずダウンロードの時間が発生します。

ユーザーからすると画面にいきなり画像が表示されるので、あまりよろしくありません。

それを解決するために、画像のダウンロード中はローディングを表示(PlaceHolder)して、
ユーザーへ「今はロード中ですよ、ちょっと待ってね」と伝えることにしましょう。

FadeInImageを使う

FadeInImageを使えば簡単に実装することができます。

FadeInImage.assetNetwork(
  placeholder: 'images/loading.gif',
  image: 'https://foo.png',
);

placeholderにはassetsの画像を指定します。
gif画像も可能ですので、ローディングgif画像なら「動いている感」をユーザーに与えられますね。

imageにはダウンロード先のURLを指定します。

FadeInImageにはassetNetworkmemoryNetworkの関数があり、memoryの場合はUint8Listで指定します。

f:id:hisurga:20190619001246g:plain

コード全体

import 'package:flutter/material.dart';

class MyImageFadeinPage extends StatefulWidget {
  MyImageFadeinPage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyImageFadeinPageState createState() => _MyImageFadeinPageState();
}

class _MyImageFadeinPageState extends State<MyImageFadeinPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Column(children: <Widget>[
        Container(
          child: FadeInImage.assetNetwork(
            height: 400,
            placeholder: 'images/loading.gif',
            image:
                'https://cdn-ak.f.st-hatena.com/images/fotolife/h/hisurga/20190616/20190616231036.png',
          ),
        ),
        Center(
          child: Text('Bottom Text'),
        ),
      ]),
    );
  }
}

CachedNetworkImageを使う

FadeInImageは便利ですが、ローディングはassetやmemoryで用意しなければいけません。

CachedNetworkImageならplaceholderにウィジェットを配置することができます。

cached_network_image | Flutter Package

さらには、失敗時画面の用意もキャッシュの利用も簡単にできます。
(あえてキャッシュを利用したくない場合のオプションは用意されていないようです)

外部プラグインですが、公式のcookbookでも紹介されています。

pubspec.yamlの編集

pubspec.yamlにcached_network_imageを追加します。

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^0.1.2
  cached_network_image: "^0.4.0"

placeholderとerrorWidgetの配置

placeholdererrorWidgetにはウィジェットを配置できるので、CircularProgressIndicatorやTextなどを配置することができます。

CachedNetworkImage(
        imageUrl: "http://foo.png",
        placeholder: CircularProgressIndicator(),
        errorWidget: Icon(Icons.error),
     ),

ダウンロード成功時

f:id:hisurga:20190619003338g:plain

失敗時

f:id:hisurga:20190619003440g:plain

コード全体

import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';

class MyImageCachednetworkPage extends StatefulWidget {
  MyImageCachednetworkPage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyImageCachednetworkPageState createState() =>
      _MyImageCachednetworkPageState();
}

class _MyImageCachednetworkPageState extends State<MyImageCachednetworkPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Column(children: <Widget>[
        Container(
          height: 400,
          child: CachedNetworkImage(
            imageUrl:
                "https://cdn-ak.f.st-hatena.com/images/fotolife/h/hisurga/20190616/20190616231036.png",
            placeholder: Center(
              child: CircularProgressIndicator(),
            ),
            errorWidget: Center(
              child: Icon(Icons.error),
            ),
          ),
        ),
        Center(
          child: Text('Bottom Text'),
        ),
      ]),
    );
  }
}

プロジェクトの全体像はGitHubに載せています。

github.com

参考にさせていただいたサイト

Fade in images with a placeholder - Flutter

FadeInImage class - widgets library - Dart API

Work with cached images - Flutter

cached_network_image | Flutter Package

Flutter URL指定で画像を表示する

Flutterアプリでインターネット上の画像を表示する方法です。

画像の表示

インターネットから取ってきた画像を表示するシチュエーションはよくありますが、Flutterなら簡単に対応することができます。

使う画像はもちろんいらすとやです。

f:id:hisurga:20190616231036p:plain:w200

URLの指定

Image.networkでURL指定するだけです。

Image.network(
            'https://cdn-ak.f.st-hatena.com/images/fotolife/h/hisurga/20190616/20190616231036.png'),

f:id:hisurga:20190616232943p:plain:w300

コード全体

import 'package:flutter/material.dart';

class MyImagePage extends StatefulWidget {
  MyImagePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyImagePageState createState() => _MyImagePageState();
}

class _MyImagePageState extends State<MyImagePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Image.network(
            'https://cdn-ak.f.st-hatena.com/images/fotolife/h/hisurga/20190616/20190616231036.png'),
      ),
    );
  }
}

プロジェクトの全体像はGitHubに載せています。

github.com

公式リファレンス

Image class - widgets library - Dart API