MobX を使用して Flutter アプリの CI/CD と状態管理を実現する方法
Busha 社、ソフトウェア エンジニアリング インターン
MobX は、フロントエンドアプリケーションの状態管理プロセスを容易にするために開発された、スケーラブルなライブラリです。 このチュートリアルでは、MobX を使用して Flutter アプリケーションの状態を管理する方法を説明します。さらに、CircleCI でアプリケーションの 継続的インテグレーション/継続的デプロイ (CI/CD) パイプラインをセットアップする方法についても解説します。 なお、このチュートリアルでは、こちらの GitHub リポジトリ にあるサンプルプロジェクトを使用します。
前提条件
このチュートリアルを進めるには、Flutter の実務知識が必要になります。 Flutter の基本については、Flutter の Web サイト (英語) にある Codelab をご覧ください。
お使いのマシンに以下のものをインストールしてください。
- Flutter SDK バージョン 1.0 以降 (Dart SDK も付属)
- 以下のいずれかの開発環境 :
- Android Studio バージョン 3.0 以降
- IntelliJ IDEA バージョン 2017.1 以降
- Visual Studio Code
どの 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
ウィジェットを作成するために、/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 つの変数を宣言しています。
reviews
: すべてのユーザーレビューのリスト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
この設定ファイルでは、Docker を Executor として使用しています。 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 (プロジェクトのセットアップ)] をクリックします。
次のページで [Start Building (ビルドを開始)] をクリックします。
ビルドが始まります。
これで、新しいコードをコミットするたびに、Flutter アプリケーションのビルド、テスト、デプロイのパイプラインが自動でトリガーされるようになりました
おわりに
この記事では、MobX 状態管理ライブラリを使用して Flutter アプリケーションの状態を管理する方法を説明しました。 また、CircleCI を使用してアプリケーションの CI/CD パイプラインをセットアップする方法も説明しました。
Flutter プロジェクト (特に大規模なプロジェクト) の状態管理手法を検討する場合、考慮すべきトレードオフは複数ありますが、中規模プロジェクトなら MobX が堅実な選択肢です。 このライブラリは、困難な処理をユーザーに代わって実行してくれるうえに、必要なときに必要な機能を提供してくれます。 今後 Flutter プロジェクトの状態管理手法を決めるときや、現在のプロジェクトを書き直す際には、こうしたメリットが大きな検討材料となるでしょう。
MobX は、Flutter プロジェクトの開発で採用が進んでいます。 MobX の成長に協力したいとお考えであれば、ぜひこのライブラリの GitHub リポジトリをご覧ください。
このチュートリアルがお役に立ったなら幸いです。 コーディングを楽しみましょう!