CI/CD and state management for Flutter apps with MobX
Software Engineering Intern at Busha
MobX is a scalable library developed to ease the process of state management in frontend applications. In this tutorial, you will learn how to manage the state of your Flutter applications using MobX, and then set up a continuous integration/continuous deployment (CI/CD) pipeline for your application using CircleCI. You can find the sample project developed for this tutorial in this GitHub repository.
Prerequisites
Before getting started, you will need a working knowledge of Flutter. If you need help getting started, you can follow the codelabs on the Flutter website.
You need these items installed on your machine:
- Flutter SDK, version 1.0 or later. (This comes with a Dart SDK installation).
- A development environment. Choose one of these:
- Android Studio, version 3.0 or later.
- IntelliJ IDEA, version 2017.1 or later.
- Visual Studio Code.
No matter which IDE you choose, you will need an installation of the Dart and Flutter plugins. These plugins are vital for editing and refactoring your Flutter application.
A short history of MobX
According to mobx.js.org, MobX is a battle tested library that makes state management simple and scalable by transparently applying functional reactive programming (TFRP). Developed primarily with React applications in mind, MobX has grown to support applications built with other JavaScript libraries, and in recent times, Flutter applications.
Using MobX for state management in Flutter apps
Managing state with MobX relies on three of the library’s main concepts:
- Observable state
- Actions
- Computed values
Observable states are those properties of an application that are susceptible to change. These states are declared with the @observable
annotation. For example, the observable states in a to-do application include the list of all to-dos. The list also includes every other property whose value may be updated.
Actions are operations that are aimed at changing the value of an observable state. Actions are declared with the @action
annotation. Upon running an action, MobX handles updating parts of the application that use observables modified by the action. An example of an action in a to-do application would be a function to update the list of to-dos with a new to-do.
Computed values are similar to observable states and are declared with the @computed
annotation. Computed values do not depend directly on actions. Instead, computed values depend on the value of observable states. If the observable state that a computed value depends on is modified by an action, the computed value is also updated. In practice, developers often overlook the idea of computed values and instead, often unintentionally, use observables in their place.
Comparing paradigms in MobX, BLoC, Redux, and setState()
MobX was built on the simple philosophy that anything that can be derived from the application state should be derived. This implies that MobX provides coverage for all properties in an application state that have been defined with the likelihood to change. MobX rebuilds the UI only when such properties change. This approach is unlike those used by BLoC, Redux, and setState. BLoC uses streams to propagate changes, while Redux is based on an application possessing a single source of truth from which its widgets inherit. Providing a similar level of simplicity as MobX, setState()
, requires you to handle state propagation yourself. With its ability to abstract state-changing details, MobX provides a smoother learning curve relative to other approaches.
Setting up a Flutter project
To create your new Flutter project, you will use the Flutter CLI tool. Open your terminal and navigate to your project’s directory and run this command:
$ flutter create reviewapp
The CLI tool generates a template project that gets you started in a matter of seconds. After the project is generated, you can open it in your IDE.
Installing project dependencies
Your new project requires five main dependencies:
- mobx is a Dart port of MobX for writing state-modifying logic.
- flutter_mobx is Flutter integration for MobX that provides the
Observer
widget that automatically rebuilds based on changes to observable state. - shared_preferences is a local persistence library.
- mobx_codegen is a code generation library for MobX that allows usage of MobX annotations.
- build_runner is a stand-alone library to run code generation operations.
With the project open in your IDE, navigate to your /pubspec.yaml
file to add the dependencies. Replace the dependencies
section with this snippet:
dependencies:
flutter:
sdk: flutter
mobx: ^0.3.5
flutter_mobx: ^0.3.0+1
shared_preferences: ^0.5.3+4
Then replace the dev_dependencies
section with this snippet of code:
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^1.6.5
mobx_codegen: ^0.3.3+1
Now, run this command in your project’s root directory to download the dependencies:
$ flutter packages get
What you are building
In this tutorial, you are building a simple review application that allows users to add comments and stars as seen in the images below:
How to structure the sample Flutter project
The sample project described in the What you are building section works as follows:
- Start the app
- Get reviews from local preferences
- Update UI with retrieved reviews
- Add review
- Update list of reviews within app state
- Persist updated list of reviews in preferences
Before getting started, create the /widgets
, /screens
, and /models
folder by running this command in your project’s /lib
directory:
$ mkdir widgets screens models
Creating the data model
To start with, define a data model for the reviews by creating a reviewmodel.dart
file in the /lib/models/
directory. Add this code snippet to it:
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,
};
}
}
Creating the user interface
The sample application we are building needs a way for users to interact with it. The app will contain a review form that displays a list of existing reviews, the total number of reviews, and the average number of stars for each review. The form will also let users add a new review.
Start by creating a review.dart
file in the /lib/screens
directory. Add this code snippet:
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),
//contains average stars and total reviews card
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),
//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: Text("No reviews yet"),
),
),
],
),
),
);
}
}
Creating custom widgets
In this code snippet there is a reference to InfoCard
. InfoCard
is a custom widget that displays the total number of reviews and the average number of stars:
To create the InfoCard
widget, create a file named info_card.dart
in the /lib/widgets
directory. Add this code snippet:
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),
),
],
),
),
);
}
}
Although you will not need it until later in the tutorial, create a ReviewWidget
class. This class will be used to display a single review item. Start by creating a review.dart
file in the project’s lib/widgets
directory. Add this code snippet:
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,
)
],
);
}
}
Implementing MobX
To implement MobX in your application you need to define the observables, actions, and computed values in the application state.
At any point in time in the application, the list of reviews, average number of stars, and total number of reviews must be the most-recent value available. This means that they must be declared with annotations so that changes to them can be tracked by MobX.
To do this, create a file reviews.dart
in the /lib/models
directory of your project. Add this code snippet:
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) {
//to update list of reviews
reviews.add(newReview);
// to update the average number of stars
averageStars = _calculateAverageStars(newReview.stars);
// to update the total number of stars
totalStars += newReview.stars;
// to store the reviews using Shared Preferences
_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;
}
}
This code declares two variables:
reviews
is a list of all user reviewsaverageStars
is the average number of stars computed as observables from all reviews. They are computed as observables because their values are expected to change in response to an action. The code then defines theaddReview()
function, which adds a new review to the list of reviews. It also adds aninitReviews()
function to initialize the list of reviews with existing data from shared preferences as actions that update the observable states.
Although the numberOfReviews
variable can also be declared as an observable, a computed value is used instead, because changes to its value are dependent on the result of an action (updated observable state) rather than directly on the action itself. Think of it as an aftermath effect. Finally, a totalStars
variable and the functions _calculateAverageStars()
, _persistReview()
, and _getReviews()
are declared. These have no annotations because they are helper parameters that do not directly update state.
Running CodeGen
As a result of MobX’s focus on abstracting high-level implementation details, the library handles the process of generating a data store. In contrast, Redux requires that even stores are manually written. MobX performs code generation by using its mobx_codegen
library with Dart’s build_runner
library, and takes all annotated properties into consideration when scaffolding a store.
Go to your project’s root directory, and run the command:
$ flutter packages pub run build_runner build
After you generate the store, you will find a review.g.dart
file in your /lib/models
directory.
Using observers
Even with a MobX store implemented, reflecting state changes in your application’s UI requires the use of Observers from the flutter_mobx
library. An observer is a widget that wraps around an observable or a computed value to render changes in their values to the UI.
The values of average stars, number of reviews, and total number of reviews get updated as each new review is added. This implies that the widgets used in rendering the values are being wrapped in an Observer
widget. To use the observer widget, navigate to your /lib/screens/review.dart
file. Modify the ReviewState
class by using this code:
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"),
),
),
)
],
),
),
);
}
}
This code appends the first modification by creating an instance of the Review
class from /lib/models/reviews.dart
as a means of accessing the store. It then wraps the Row
that the average stars and total reviews data are displayed in with an observer widget. Then it uses reviewStore
instance of the Review
class to refer to the data.
Next, the placeholder “no reviews” Text
widget is made to display when the list of reviews in the store is empty. Otherwise a ListView
displays the items of the list. Finally, the onPressed()
function of the “Done” button is modified to add a new review to the store.
At this point, your application is almost complete. Your next step is to import the review screen to the imports section of your main.dart
file. Open the file, and add this snippet:
$ import './screens/review.dart';
In /lib/main.dart
, modify the home
attribute in the build()
method of the MyApp
class. Change the home
attribute from MyHomePage()
to Review()
. Here is the code:
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Review() //previously MyHomePage(),
);
}
Finally, run the app using the flutter run
command.
Writing sample tests
To understand how tests fit into a CI/CD pipeline, you will need to create a simple unit test and widget test.
To write the unit test, create a file called unit_test.dart
in the project’s /test
directory. Add this code snippet:
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);
});
}
Next, add the widget test by totally replacing the contents of the existing widget_test.dart
file in your project’s test
directory with this code snippet:
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);
});
}
Run the tests by executing the flutter test
command in your project’s root directory.
CI/CD with CircleCI
Beyond just providing an environment for you to build your projects, continuous integration (CI) tools provide a reliable and stable environment to run automated tests and auto-upload deploy artifacts. In this section, you will learn how to setup and utilize a CI/CD pipeline for your Flutter project using CircleCI.
To start, initialize a local Git repository in your project by executing the git init
command in your project’s root directory. Add your files to it with git add .
. Commit those files with git commit -m "First commit"
. Then, create an online repository for your project on GitHub. Add the GitHub repository as a remote reference to your local repository, then push changes to the remote repository by running the command below in your project’s root directory:
$ git remote add origin https://link_to_repo && git push -u origin master
Creating a configuration file
Create a folder named .circleci
by running the mkdir .circleci
command in your project’s root directory. Create a configuration file so that the file path is structured like this /your_project_path/.circleci/config.yml
.
Then, populate the /.circleci/config.yml
file with this code snippet:
version: 2
jobs:
build:
docker:
- image: cirrusci/flutter:v1.5.8
branches:
only: master
steps:
- checkout
- run:
name: Run Flutter doctor
command: flutter doctor
- run:
name: Run the application tests
command: flutter test
- run:
name: Build the Android version
command: flutter build apk
- store_artifacts:
path: build/app/outputs/apk/release/app-release.apk
In this configuration file, Docker was used as the executor. There is no official CircleCI Docker image for Flutter, but there is a large list of Flutter images available on DockerHub. The most prominent is the cirrusci/flutter image. This image has a usage frequency of over 1M pulls.
The optional branches
section of the config file is used to filter the branches that the deployment process runs on. When not explicitly defined, CircleCI assumes master
as the branch to work on.
The config defines which Docker image to use. It also pins a version of the image that matches the version of Flutter running the local copy of your project, (v1.5.8 in my case).
In the steps section, the config file defines each process that is to be performed every time the deployment is run on your project’s repository, in their execution order.
Lastly, in the store-artifacts
section of the code snippet above, a path to our build artifact is referenced. This enables automatic upload of the artifact to the Artifacts tab of your CircleCI dashboard. The artifact could be deployed to an AWS S3 bucket or any other hosting service. For production-ready Flutter applications, you could add deployment to an app store to this config.
Setting up CircleCI
To integrate CircleCI with your project, go to the CircleCI dashboard, and click Add Projects. It is on the far left of the dashboard’s page. Next, navigate to the far right of the page and click Set Up Project.
On the next page, click Start Building.
Your build will begin.
Now, every commit of new code will trigger an automated build, test, and deployment pipeline for your Flutter application.
Conclusion
In this post, we covered how to manage the state of a Flutter application with the MobX state management library. We also covered how to setup a CI/CD pipeline for your application using CircleCI.
Although there are trade-offs to consider in deciding on a choice of state management approaches for a Flutter project, especially large ones, MobX offers a solid option for medium-sized projects. The library does the hardest work for you, while giving you the power to take charge where necessary. That is big benefit to consider when you decide on state management for your next Flutter project, and maybe even rewriting a current one!
Development teams continue to adopt MobX for their Flutter projects. Check out the library’s GitHub repository for ways to contribute to its growth.
I do hope that you enjoyed this tutorial. Have fun coding!