チュートリアルAug 30, 202216 分 READ

MobX を使用して Flutter アプリの CI/CD と状態管理を実現する方法

Fabusuyi Ayodeji

Busha 社、ソフトウェア エンジニアリング インターン

Developer B sits at a desk working on a beginner-level project.

MobX は、フロントエンドアプリケーションの状態管理プロセスを容易にするために開発された、スケーラブルなライブラリです。 このチュートリアルでは、MobX を使用して Flutter アプリケーションの状態を管理する方法を説明します。さらに、CircleCI でアプリケーションの 継続的インテグレーション/継続的デプロイ (CI/CD) パイプラインをセットアップする方法についても解説します。 なお、このチュートリアルでは、こちらの GitHub リポジトリ にあるサンプルプロジェクトを使用します。

前提条件

このチュートリアルを進めるには、Flutter の実務知識が必要になります。 Flutter の基本については、Flutter の Web サイト (英語) にある Codelab をご覧ください。

お使いのマシンに以下のものをインストールしてください。

どの IDE を選ぶ場合も、Dart と Flutter のプラグインをインストールしてください。 これらのプラグインは、Flutter アプリケーションを編集、リファクタリングするために必須です。

MobX の簡単な歴史

MobX について、mobx.js.org では、実地テスト済みのライブラリであり、関数型リアクティブプログラミング (TFRP) が透過的に適用されているので状態管理をシンプルかつスケーラブルに行えると紹介されています。 当初は React アプリケーションを念頭に開発されましたが、その後サポート範囲は他の JavaScript ライブラリ製アプリケーションにまで広がり、最近では Flutter アプリケーションもサポートされるようになりました。

MobX を使って Flutter アプリの状態を管理する

MobX では、以下 3 つの主要コンセプトを利用して状態管理を行います。

  • 監視対象の状態 (オブザーバブル)
  • アクション
  • 計算値

監視対象の状態は、変更の影響を受けるアプリケーションのプロパティです。 この状態は、アノテーション @observable を付けて宣言します。 たとえば、To Do アプリケーションの場合、To Do の全リストが監視対象の状態に該当します。 このリストには、値が更新される可能性のある他のプロパティもすべて含まれます。

アクションとは、監視対象の状態の値を変更する操作のことです。 アクションは、アノテーション @action を付けて宣言します。 アクションが実行された場合、MobX では、そのアクションによって変更されるオブザーバブルを利用しているアプリケーションの更新を処理します。 To Do アプリケーションの例で言えば、アクションには、新しい To Do で To Do リストを更新する関数が該当します。

計算値は、監視対象の状態に似ており、アノテーション @computed を付けて宣言します。 計算値は、アクションには直接依存しない代わりに、 監視対象の状態の値に依存します。 計算値が依存する監視対象の状態がアクションによって変更されると、計算値も更新されます。 実際には、多くの開発現場では計算値の概念を見過ごしており、意図せずオブザーバブルを計算値の代わりとして使用するケースがよく見られます。

MobX、BLoC、Redux、setState() のパラダイムの比較

MobX は、アプリケーションの状態から派生できるものはすべて派生するべきというシンプルな考えに基づいています。 したがって、MobX では、変更される可能性があると定義されたアプリケーションの状態に含まれるすべてのプロパティをカバーし、 そのようなプロパティが変更された場合にのみ UI を再ビルドします。 このアプローチは、BLoC や Redux、setState で使用されているアプローチとは異なります。 BLoC ではストリームを使用して変更を伝播させます。Redux ではアプリケーションに所有される唯一の情報源をベースとしており、この情報源をウィジェットが継承します。 setState() は、シンプルさの点では MobX と同程度ですが、状態の伝播をユーザー自身が処理する必要があります。 MobX には状態変化の詳細を抽象化する機能があるため、他のアプローチに比べてスムーズに習得できます。

Flutter プロジェクトをセットアップする

新しい Flutter プロジェクトを作成するために、Flutter CLI ツールを使用します。 ターミナルを開いてプロジェクトのディレクトリに移動し、以下のコマンドを実行します。

$ flutter create reviewapp

CLI ツールにより、作業開始用のテンプレートプロジェクトが数秒で生成されます。 生成されたプロジェクトを IDE で開きましょう。

プロジェクトの依存関係をインストールする

今回のプロジェクトでは、次の 5 つの依存関係が必要です。

  • mobx: 状態変更ロジックを記述するための MobX の Dart ポート
  • flutter_mobx: 監視対象の状態に対する変更に基づいて自動的に再ビルドを行う Observer ウィジェットを提供する、MobX 用 Flutter インテグレーション
  • shared_preferences: ローカル永続ライブラリ
  • mobx_codegen: MobX のアノテーションを使うための MobX 用コード生成ライブラリ
  • build_runner: コード生成操作を実行するスタンドアロンライブラリ

先ほど作成したプロジェクトを IDE で開き、/pubspec.yaml ファイルに移動して、依存関係を追加します。 dependencies セクションの内容を以下のスニペットに置き換えます。

dependencies:
  flutter:
    sdk: flutter
  mobx: ^0.3.5
  flutter_mobx: ^0.3.0+1
  shared_preferences: ^0.5.3+4

次に、dev_dependencies セクションを以下のコードスニペットに置き換えます。

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^1.6.5
  mobx_codegen: ^0.3.3+1

最後に、プロジェクトのルートディレクトリで以下のコマンドを実行して、依存関係をダウンロードします。

$ flutter packages get

今回開発するアプリケーション

このチュートリアルでは、以下の画像に示すような、ユーザーがコメントや評価 (星) を追加できる簡単なレビューアプリケーションを作成します。

レビューアプリ アプリの作成

サンプル Flutter プロジェクトの構築方法

今回開発するアプリケーション」セクションに示したサンプルプロジェクトには、以下のような動作を設定します。

  • アプリが起動する
  • ローカルの設定からレビューを取得する
  • 取得したレビューで UI を更新する
  • レビューを追加する
  • アプリ状態内のレビューのリストを更新する
  • 更新後のレビューのリストを設定に永続化する

具体的な作業に入る前に、プロジェクトの /lib ディレクトリに移動して以下のコマンドを実行し、/widgets/screens/models フォルダーを作成します。

$ mkdir widgets screens models

データモデルを作成する

まず、レビューのデータモデルを定義しましょう。/lib/models/ ディレクトリ内に reviewmodel.dart ファイルを作成して、 このファイルに以下のコードスニペットを追加します。

import 'package:meta/meta.dart';
class ReviewModel {
  final String comment;
  final int stars;
  const ReviewModel({@required this.comment, @required this.stars});

  factory ReviewModel.fromJson(Map<String, dynamic> parsedJson) {
    return ReviewModel(
      comment: parsedJson['comment'],
      stars: parsedJson['stars'],
    );
  }

  Map<String, dynamic> toJson(){
    return {
      'comment': this.comment,
      'stars': this.stars,
    };
  }
}

ユーザーインターフェースを作成する

今回開発するサンプルアプリケーションには、ユーザーが操作するための手段を用意する必要があります。 既存レビューのリスト、レビューの合計数、各レビューの星の平均数を表示するレビューフォームを用意しましょう。 また、ユーザーが新しいレビューを追加するための機能も追加しましょう。

/lib/screens ディレクトリに review.dart ファイルを作成して、 以下のコードスニペットを貼り付けます。

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import '../widgets/info_card.dart';

class Review extends StatefulWidget {
  @override
  ReviewState createState() {
    return new ReviewState();
  }
}
class ReviewState extends State<Review> {
  final List<int> _stars = [1, 2, 3, 4, 5];
  final TextEditingController _commentController = TextEditingController();
  int _selectedStar;

  @override
  void initState() {
    super.initState();
  }
  @override
  Widget build(BuildContext context) {
    Size screenSize = MediaQuery.of(context).size;
    double screenWidth = screenSize.width;
    return Scaffold(
      appBar: AppBar(
        title: Text('Review App'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: <Widget>[
            SizedBox(height: 12.0),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: <Widget>[
                Container(
                  width: screenWidth * 0.6,
                  child: TextField(
                    controller: _commentController,
                    decoration: InputDecoration(
                      contentPadding: EdgeInsets.all(10),
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(10.0),
                      ),
                      hintText: "Write a review",
                      labelText: "Write a review",
                    ),
                  ),
                ),
                Container(
                  child: DropdownButton(
                    hint: Text("Stars"),
                    elevation: 0,
                    value: _selectedStar,
                    items: _stars.map((star) {
                      return DropdownMenuItem<int>(
                        child: Text(star.toString()),
                        value: star,
                      );
                    }).toList(),
                    onChanged: (item) {
                      setState(() {
                        _selectedStar = item;
                      });
                    },
                  ),
                ),
                Container(
                  child: Builder(
                    builder: (BuildContext context) {
                      return IconButton(
                        icon: Icon(Icons.done),
                        onPressed: () {},
                      );
                    },
                  ),
                ),
              ],
            ),
            SizedBox(height: 12.0),
            //星の平均数と合計レビュー数のカードを含む
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: <Widget>[
                InfoCard(
                    infoValue: '2',
                    infoLabel: "reviews",
                    cardColor: Colors.green,
                    iconData: Icons.comment),
                InfoCard(
                  infoValue: '2',
                  infoLabel: "average stars",
                  cardColor: Colors.lightBlue,
                  iconData: Icons.star,
                  key: Key('avgStar'),
                ),
              ],
            ),
            SizedBox(height: 24.0),
            //レビュー メニュー ラベル
            Container(
              color: Colors.grey[200],
              padding: EdgeInsets.all(10),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.start,
                children: <Widget>[
                  Icon(Icons.comment),
                  SizedBox(width: 10.0),
                  Text(
                    "Reviews",
                    style: TextStyle(fontSize: 18),
                  ),
                ],
              ),
            ),
            //レビュー リストを含む
            Expanded(
              child: Container(
                child: Text("No reviews yet"),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

カスタムウィジェットを作成する

上記のコードスニペットでは、InfoCard を参照しています。 InfoCard は、以下のような、レビューの合計数と星の平均数を表示するカスタムウィジェットです。

レビューアプリの InfoCard カスタムウィジェット

InfoCard ウィジェットを作成するために、/lib/widgets ディレクトリに info_card.dart という名前のファイルを作成し、 以下のコードスニペットを貼り付けます。

import 'package:flutter/material.dart';

class InfoCard extends StatelessWidget {
  final String infoValue;
  final String infoLabel;
  final Color cardColor;
  final IconData iconData;
  const InfoCard(
      {Key key,
      @required this.infoValue,
      @required this.infoLabel,
      @required this.cardColor,
      @required this.iconData,
      })
      : super(key: key);
  @override
  Widget build(BuildContext context) {
    Size screenSize = MediaQuery.of(context).size;
    double screenWidth = screenSize.width;
    return Container(
      height: 100,
      width: screenWidth / 2,
      child: Card(
        color: cardColor,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(5.0),
        ),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: <Widget>[
            Icon(
              iconData,
              size: 28.0,
              color: Colors.white,
            ),
            Text(
              "$infoValue $infoLabel",
              style: TextStyle(color: Colors.white),
            ),
          ],
        ),
      ),
    );
  }
}

今はまだ必要ではありませんが、ReviewWidget クラスも作成しておきましょう。 このクラスは、レビュー項目を 1 つ表示するために使用します。 プロジェクトの lib/widgets ディレクトリに review.dart ファイルを作成して、 以下のコードスニペットを貼り付けます。

import 'package:flutter/material.dart';
import '../models/reviewmodel.dart';
import '../models/reviews.dart';
import '../widgets/review.dart';
import '../widgets/info_card.dart';

class ReviewWidget extends StatelessWidget {
  final ReviewModel reviewItem;

  const ReviewWidget({Key key, @required this.reviewItem}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Padding(
          padding: EdgeInsets.all(10.0),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: <Widget>[
              Expanded(
                child: Text(
                  reviewItem.comment,
                ),
              ),
              Row(
                children: List(reviewItem.stars).map((listItem) {
                  return Icon(Icons.star);
                }).toList(),
              ),
            ],
          ),
        ),
        Divider(
          color: Colors.grey,
        )
      ],
    );
  }
}

MobX を実装する

アプリケーションに MobX を実装するには、オブザーバブル、アクション、アプリケーションの状態に応じた計算値を定義する必要があります。

今回のアプリケーションでは、どの時点においても、レビューリスト、星の平均数、レビューの合計数がその時点における最新の値でなければなりません。 つまり、レビューリスト、星の平均数、レビューの合計数に対する変更を MobX で追跡できるように、それらにアノテーションを付けて宣言する必要があります。

そのために、プロジェクトの /lib/models ディレクトリに reviews.dart ファイルを作成して、 以下のコードスニペットを貼り付けます。

import 'dart:async';
import 'dart:convert';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import './reviewmodel.dart';
part 'reviews.g.dart';
class Reviews = ReviewsBase with _$Reviews;
abstract class ReviewsBase with Store {
  @observable
  ObservableList<ReviewModel> reviews = ObservableList.of([]);

  @observable
  double averageStars = 0;

  @computed
  int get numberOfReviews => reviews.length;

  int totalStars = 0;

  @action
  void addReview(ReviewModel newReview) {
    //レビュー リストを更新
    reviews.add(newReview);
    // 星の平均数を更新
    averageStars = _calculateAverageStars(newReview.stars);
    // 星の合計数を更新
    totalStars += newReview.stars;
    // 共有設定を使用してレビューを保存
    _persistReview(reviews);
  }

  @action
  Future<void> initReviews() async {
    await _getReviews().then((onValue) {
      reviews = ObservableList.of(onValue);
      for (ReviewModel review in reviews) {
        totalStars += review.stars;
      }
    });
    averageStars = totalStars / reviews.length;
  }

  double _calculateAverageStars(int newStars) {
    return (newStars + totalStars) / numberOfReviews;
  }

  void _persistReview(List<ReviewModel> updatedReviews) async {
    List<String> reviewsStringList = [];
    SharedPreferences _preferences = await SharedPreferences.getInstance();
    for (ReviewModel review in updatedReviews) {
      Map<String, dynamic> reviewMap = review.toJson();
      String reviewString = jsonEncode(ReviewModel.fromJson(reviewMap));
      reviewsStringList.add(reviewString);
    }
    _preferences.setStringList('userReviews', reviewsStringList);
  }

  Future<List<ReviewModel>> _getReviews() async {
    final SharedPreferences _preferences =
        await SharedPreferences.getInstance();
    final List<String> reviewsStringList =
        _preferences.getStringList('userReviews') ?? [];
    final List<ReviewModel> retrievedReviews = [];
    for (String reviewString in reviewsStringList) {
      Map<String, dynamic> reviewMap = jsonDecode(reviewString);
      ReviewModel review = ReviewModel.fromJson(reviewMap);
      retrievedReviews.add(review);
    }
    return retrievedReviews;
  }
}

上記のコードでは、2 つの変数を宣言しています。

  1. reviews: すべてのユーザーレビューのリスト
  2. averageStars: すべてのレビューに基づき、オブザーバブルとして計算する星の平均数。 この値はアクションに応じて変化すると想定されるため、オブザーバブルとして計算します。 上記のコードでは、変数宣言の後で addReview() 関数を定義しています。この関数は、レビューのリストに新しいレビューを追加します。 また、監視対象の状態を更新するアクションとして、共有設定の既存のデータでレビューのリストを初期化する initReviews() 関数も定義しています。

なお、numberOfReviews 変数はオブザーバブルとして宣言することもできますが、ここでは計算値として宣言しています。この値に対する変更は、アクション自体に直接依存するのではなく、アクションの結果 (更新された監視対象の状態) に依存するからです。 これは、二次的な効果と考えてください。 他に、totalStars 変数、_calculateAverageStars() 関数、_persistReview() 関数、_getReviews() 関数も宣言しています。 これらの変数や関数は、状態を直接更新することのないヘルパーパラメーターであるため、アノテーションを付けていません。

CodeGen を実行する

MobX では上位レベルの実装詳細を抽象化することに重点が置かれているため、データストア生成プロセスは MobX のライブラリにより処理されます。 一方、Redux では、ストアであっても手動で記述する必要があります。 MobX では、mobx_codegen ライブラリと Dart の build_runner ライブラリを使用してコードを生成します。ストアのスキャフォールディング時には、アノテーションが付けられているプロパティすべてが考慮されます。

プロジェクトのルートディレクトリに移動し、以下のコマンドを実行します。

$ flutter packages pub run build_runner build

ストアの生成が終わると、/lib/models ディレクトリに review.g.dart ファイルが作成されます。

オブザーバーを使用する

MobX ストアが実装されている場合でも、アプリケーションの UI に状態の変化を反映するには、flutter_mobx ライブラリのオブザーバーを使用する必要があります。 オブザーバーとは、オブザーバブルまたは計算値をラップして、それらの値の変化を UI にレンダリングするウィジェットです。

星の平均数、レビュー数、レビューの合計数の値は、新しいレビューが追加されるたびに更新されます。 つまり、上記の値のレンダリングに使用するウィジェットを、Observer ウィジェットでラップする必要があります。 Observer ウィジェットを使用するために、/lib/screens/review.dart ファイルに移動して、 ReviewState クラスの中身を以下のコードに置き換えます。

class ReviewState extends State<Review> {
  final Reviews _reviewsStore = Reviews();
  final TextEditingController _commentController = TextEditingController();
  final List<int> _stars = [1, 2, 3, 4, 5];
  int _selectedStar;
  @override
  void initState() {
    _selectedStar = null;
    _reviewsStore.initReviews();
    super.initState();
  }
  @override
  Widget build(BuildContext context) {
    Size screenSize = MediaQuery.of(context).size;
    double screenWidth = screenSize.width;
    return Scaffold(
      appBar: AppBar(
        title: Text('Review App'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: <Widget>[
            SizedBox(height: 12.0),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: <Widget>[
                Container(
                  width: screenWidth * 0.6,
                  child: TextField(
                    controller: _commentController,
                    decoration: InputDecoration(
                      contentPadding: EdgeInsets.all(10),
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(10.0),
                      ),
                      hintText: "Write a review",
                      labelText: "Write a review",
                    ),
                  ),
                ),
                Container(
                  child: DropdownButton(
                    hint: Text("Stars"),
                    elevation: 0,
                    value: _selectedStar,
                    items: _stars.map((star) {
                      return DropdownMenuItem<int>(
                        child: Text(star.toString()),
                        value: star,
                      );
                    }).toList(),
                    onChanged: (item) {
                      setState(() {
                        _selectedStar = item;
                      });
                    },
                  ),
                ),
                Container(
                  child: Builder(
                    builder: (BuildContext context) {
                      return IconButton(
                        icon: Icon(Icons.done),
                        onPressed: () {
                          if (_selectedStar == null) {
                            Scaffold.of(context).showSnackBar(SnackBar(
                              content:
                                  Text("You can't add a review without star"),
                              duration: Duration(milliseconds: 500),
                            ));
                          } else if (_commentController.text.isEmpty) {
                            Scaffold.of(context).showSnackBar(SnackBar(
                              content: Text("Review comment cannot be empty"),
                              duration: Duration(milliseconds: 500),
                            ));
                          } else {
                            _reviewsStore.addReview(ReviewModel(
                                comment: _commentController.text,
                                stars: _selectedStar));
                          }
                        },
                      );
                    },
                  ),
                ),
              ],
            ),
            SizedBox(height: 12.0),
            //contains average stars and total reviews card
            Observer(
              builder: (_) {
                return Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: <Widget>[
                    InfoCard(
                      infoValue: _reviewsStore.numberOfReviews.toString(),
                      infoLabel: "reviews",
                      cardColor: Colors.green,
                      iconData: Icons.comment
                    ),
                    InfoCard(
                      infoValue: _reviewsStore.averageStars.toStringAsFixed(2),
                      infoLabel: "average stars",
                      cardColor: Colors.lightBlue,
                      iconData: Icons.star,
                      key: Key('avgStar'),
                    ),
                  ],
                );
              },
            ),
            SizedBox(height: 24.0),
            //the review menu label
            Container(
              color: Colors.grey[200],
              padding: EdgeInsets.all(10),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.start,
                children: <Widget>[
                  Icon(Icons.comment),
                  SizedBox(width: 10.0),
                  Text(
                    "Reviews",
                    style: TextStyle(fontSize: 18),
                  ),
                ],
              ),
            ),
            //contains list of reviews
            Expanded(
              child: Container(
                child: Observer(
                  builder: (_) => _reviewsStore.reviews.isNotEmpty
                      ? ListView(
                          children:
                              _reviewsStore.reviews.reversed.map((reviewItem) {
                            return ReviewWidget(
                              reviewItem: reviewItem,
                            );
                          }).toList(),
                        )
                      : Text("No reviews yet"),
                ),
              ),
            )
          ],
        ),
      ),
    );
  }
}

上記のコードでは、ストアへのアクセス手段として /lib/models/reviews.dart から Review クラスのインスタンスを作成し、最初の変更を追加します。 次に、星の平均数と合計レビューデータが表示される Row を Observer ウィジェットでラップします。 その後、Review クラスの reviewsStore インスタンスを使用してデータを参照します。

次に、ストア内にあるレビューリストを表示する Text ウィジェットを作成しています。このウィジェットは、ストアが空の場合は “no reviews yet” と表示し、 空ではない場合は ListView によってリストの項目を表示します。 最後に、完了ボタンの onPressed() 関数について、ストアに新しいレビューを追加するように変更しています。

アプリケーションの完成まで後わずかです。 次は、main.dart ファイルの import セクションにレビュー画面をインポートします。 ファイルを開き、以下のスニペットを貼り付けます。

$ import './screens/review.dart';

/lib/main.dart で、MyApp クラスの build() メソッドの home 属性を変更します。 home 属性を MyHomePage() から Review() に変更します。 変更後のコードは以下のようになります。

@override
Widget build(BuildContext context) {
  return MaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
      primarySwatch: Colors.blue,
    ),
    home: Review() //変更前は MyHomePage()
  );
}

最後に、flutter run コマンドでアプリケーションを実行しましょう。

サンプルテストを作成する

CI/CD パイプラインへのテストの組み込み方を知るために、シンプルな単体テストとウィジェットテストを作成してみましょう。

単体テストを作成するには、プロジェクトの /test ディレクトリに unit_test.dart という名前のファイルを作成し、 以下のコードスニペットを貼り付けます。

import 'package:flutter_test/flutter_test.dart';
import '../lib/models/reviewmodel.dart';
import '../lib/models/reviews.dart';

void main() {
  test('Test MobX state class', () async {
    final Reviews _reviewsStore = Reviews();

    _reviewsStore.initReviews();

    expect(_reviewsStore.totalStars, 0);

    expect(_reviewsStore.averageStars, 0);
    _reviewsStore.addReview(ReviewModel(
      comment: 'This is a test review',
      stars: 3,
    ));

    expect(_reviewsStore.totalStars, 3);
    _reviewsStore.addReview(ReviewModel(
      comment: 'This is a second test review',
      stars: 5,
    ));

    expect(_reviewsStore.averageStars, 4);
  });
}

次に、ウィジェットテストを追加しましょう。プロジェクトの test ディレクトリにある既存の widget_test.dart ファイルの内容全体を、以下のコードスニペットに置き換えます。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../lib/main.dart';

void main() {
  testWidgets('Test for rendered UI', (WidgetTester tester) async {
    await tester.pumpWidget(MyApp());
    Finder starCardFinder = find.byKey(Key('avgStar'));

    expect(starCardFinder, findsOneWidget);
  });
}

プロジェクトのルートディレクトリで flutter test コマンドを実行して、テストを実行してみましょう。

CircleCI を使用した CI/CD

継続的インテグレーション (CI) ツールを利用すると、プロジェクトをビルドするための環境だけでなく、自動テスト実行やデプロイアーティファクトの自動アップロードを確実かつ安定して行える環境も手に入ります。 このセクションでは、CircleCI を使用して Flutter プロジェクト用の CI/CD パイプラインを設定し、利用する方法について説明します。

まず、プロジェクトのルートディレクトリで git init コマンドを実行し、プロジェクトのローカル Git リポジトリを初期化します。 git add . を実行して、リポジトリにファイルを追加します。 追加したファイルを git commit -m "First commit" でコミットします。 その後、GitHub でプロジェクトのオンラインリポジトリを作成します。 その GitHub リポジトリをローカル リポジトリのリモートリファレンスとして追加した後、プロジェクトのルートディレクトリで以下のコマンドを実行して、リモートリポジトリに変更をプッシュします。

$ git remote add origin https://link_to_repo && git push -u origin master

設定ファイルを作成する

プロジェクトのルートディレクトリで mkdir .circleci コマンドを実行して、.circleci という名前のフォルダーを作成します。 ファイルパスが /実際のプロジェクトのパス/.circleci/config.yml となるように、設定ファイルを作成します。

次に、/.circleci/config.yml ファイルに以下のコードスニペットを入力します。

version: 2
jobs:
  build:
    docker:
      - image: cirrusci/flutter:v1.5.8

    branches:
      only: master

    steps:
      - checkout

      - run:
          name: flutter doctor の実行
          command: flutter doctor

      - run:
          name: アプリケーションテストの実行
          command: flutter test

      - run:
          name: Android バージョンのビルド
          command: flutter build apk

      - store_artifacts:
          path: build/app/outputs/apk/release/app-release.apk

この設定ファイルでは、DockerExecutor として使用しています。 Flutter 向けの CircleCI 公式の Docker イメージはありませんが、DockerHub では多数の Flutter イメージが公開されています。 最も利用されているのは cirrusci/flutter イメージで、 そのプル回数は 100 万回を超えています。

また、この設定ファイルでは、オプションの branches セクションを使用して、デプロイプロセスを実行するブランチをフィルタリングしています。 このブランチを明示的に定義しない場合、CircleCI では master が作業ブランチとみなされます。

この設定ファイルでは、使用する Docker イメージを定義しているのに加え、 イメージのバージョンを、プロジェクトのローカルコピーを実行するための Flutter バージョンと同じにもの (この場合は v1.5.8) に固定しています。

steps セクションでは、プロジェクトのリポジトリでのデプロイの実行時に毎回実行するプロセスとその実行順序を定義しています。

最後に、上記コードスニペットの store-artifacts セクションでは、ビルドアーティファクトへのパスを参照しています。 こうすることで、CircleCI ダッシュボードの [Artifacts (アーティファクト)] タブにアーティファクトを自動アップロードできます。 アーティファクトは、AWS S3 バケットなど、任意のホスティングサービスにデプロイできます。 本番環境用の Flutter アプリケーションであれば、この設定ファイルにアプリストアへのデプロイを追加してもよいでしょう。

CircleCI をセットアップする

今回のプロジェクトを CircleCI と連携するために、CircleCI ダッシュボードに移動して [Add Projects (プロジェクトの追加)] をクリックします。 このボタンは、ダッシュボードのページの左端にあります。 次に、ページ右端にある [Set Up Project (プロジェクトのセットアップ)] をクリックします。

[Add Projects (プロジェクトの追加)] 画面

次のページで [Start Building (ビルドを開始)] をクリックします。

プロジェクトのセットアップ用インターフェース

ビルドが始まります。

プロジェクトのビルド

これで、新しいコードをコミットするたびに、Flutter アプリケーションのビルド、テスト、デプロイのパイプラインが自動でトリガーされるようになりました

おわりに

この記事では、MobX 状態管理ライブラリを使用して Flutter アプリケーションの状態を管理する方法を説明しました。 また、CircleCI を使用してアプリケーションの CI/CD パイプラインをセットアップする方法も説明しました。

Flutter プロジェクト (特に大規模なプロジェクト) の状態管理手法を検討する場合、考慮すべきトレードオフは複数ありますが、中規模プロジェクトなら MobX が堅実な選択肢です。 このライブラリは、困難な処理をユーザーに代わって実行してくれるうえに、必要なときに必要な機能を提供してくれます。 今後 Flutter プロジェクトの状態管理手法を決めるときや、現在のプロジェクトを書き直す際には、こうしたメリットが大きな検討材料となるでしょう。

MobX は、Flutter プロジェクトの開発で採用が進んでいます。 MobX の成長に協力したいとお考えであれば、ぜひこのライブラリの GitHub リポジトリをご覧ください。

このチュートリアルがお役に立ったなら幸いです。 コーディングを楽しみましょう!

クリップボードにコピー