MobX是一个可扩展的库,是为了简化前端应用程序的状态管理过程而开发的。在本教程中,您将学习如何使用MobX管理您的Flutter应用程序的状态,然后使用CircleCI为您的应用程序建立一个 持续集成/持续部署(CI/CD)管道。您可以在 这个GitHub仓库中找到为本教程开发的示例项目。
前提条件
在开始之前,您需要对Flutter有一定的了解。如果你需要帮助,你可以关注 Flutter网站上的代码实验。
您需要在您的机器上安装这些项目。
- Flutter SDK,1.0或更高版本。(这是与 Dart SDK安装一起的)。
- 一个开发环境。选择其中一个。
- Android Studio,3.0版或更高版本。
- IntelliJ IDEA,2017.1版或更高版本。
- Visual Studio Code。
无论你选择哪个IDE,你都需要安装Dart和Flutter插件。这些插件对于编辑和重构您的Flutter应用程序至关重要。
MobX的简短历史
根据 mobx.js.org,MobX是一个经过战斗考验的库,通过透明地应用功能反应式编程 (TFRP)使状态管理变得简单和可扩展。MobX主要是针对React应用而开发的,它已经发展到支持用其他JavaScript库构建的应用,最近还支持Flutter应用。
在Flutter应用程序中使用MobX进行状态管理
使用MobX管理状态依赖于该库的三个主要概念。
- 可观察的状态
- 行动
- 计算值
可观察的状态是指应用程序中那些容易被改变的属性。这些状态用@observable
注释来声明。例如,待办事项应用程序中的可观察状态包括所有待办事项的列表。该列表还包括其值可能被更新的其他所有属性。
行动是旨在改变可观察状态的值的操作。行动是用@action
注解来声明的。在运行一个动作时,MobX会处理更新应用程序中使用被该动作修改的可观察对象的部分。在一个待办事项应用程序中,动作的一个例子是用一个新的待办事项更新待办事项列表的函数。
计算值类似于可观察的状态,用@computed
注释来声明。计算值不直接依赖于动作。相反,计算值依赖于可观察状态的值。如果一个计算值所依赖的可观察状态被一个动作修改,那么计算值也会被更新。在实践中,开发者常常忽略了计算值的概念,而往往是无意中使用可观察值来代替它们。
比较MobX、BLoC、Redux和setState()的范式
MobX建立在一个简单的理念上,即任何可以从应用状态中导出的东西都应该被导出。这意味着MobX为应用状态中的所有属性提供了覆盖,这些属性已经被定义为有可能发生变化。只有当这些属性发生变化时,MobX才会重建用户界面。这种方法与BLoC、Redux和setState使用的方法不同。BLoC使用流来传播变化,而Redux则是基于一个应用程序拥有一个单一的真理源,它的小部件继承于此。setState()
,提供与MobX类似的简单程度,要求你自己处理状态传播。由于它能够抽象出状态变化的细节,MobX提供了一个相对于其他方法更平滑的学习曲线。
设置一个Flutter项目
为了创建您的新Flutter项目,您将使用Flutter CLI工具。打开你的终端,导航到你的项目目录,运行这个命令。
$ flutter create reviewapp
CLI工具会生成一个模板项目,让您在几秒钟内开始。项目生成后,你可以在你的IDE中打开它。
安装项目的依赖性
你的新项目需要五个主要的依赖项。
- mobx是MobX的一个Dart移植,用于编写状态修改逻辑。
- flutter_mobx是MobX的Flutter集成,它提供了
Observer
widget,可以根据可观察状态的变化自动重建。 - 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 示例项目
你要构建的部分中描述的示例项目的工作方式如下。
- 启动应用程序
- 从本地偏好中获取评论
- 用检索到的评论更新用户界面
- 添加评论
- 在应用程序状态中更新评论列表
- 将更新的评论列表保留在偏好设置中
在开始之前,通过在你的项目的/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),
//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"),
),
),
],
),
),
);
}
}
创建自定义小部件
在这个代码片断中,有一个对InfoCard
的引用。InfoCard
是一个自定义小组件,显示评论总数和平均星数。
要创建InfoCard
widget,在/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
类。这个类将被用来显示一个单一的评论项目。首先在项目的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) {
//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;
}
}
这段代码声明了两个变量。
reviews
是一个所有用户评论的列表averageStars
是所有评论中作为观察变量计算的平均星数。它们被计算为观察变量,因为它们的值预计会随着行动的进行而改变。然后,代码定义了 函数,该函数将一个新的评论添加到评论列表中。它还添加了一个 函数,用来自共享偏好的现有数据初始化评论列表,作为更新可观察状态的行动。addReview()
initReviews()
尽管numberOfReviews
变量也可以被声明为可观察变量,但我们使用了一个计算值来代替,因为它的值的变化取决于一个动作的结果(更新的可观察状态),而不是直接取决于动作本身。可以把它看作是一种后遗症。最后,我们声明了一个totalStars
变量和函数_calculateAverageStars()
,_persistReview()
, 和_getReviews()
。这些没有注释,因为它们是不直接更新状态的辅助参数。
运行CodeGen
由于MobX专注于抽象高层实现的细节,该库处理了生成数据存储的过程。相比之下,Redux甚至需要手动编写存储。MobX通过使用其mobx_codegen
库与Dart的build_runner
库来执行代码生成,并在构建存储的脚手架时考虑到所有注释的属性。
进入你的项目的根目录,并运行该命令。
$ flutter packages pub run build_runner build
在你生成商店后,你会在你的/lib/models
目录中发现一个review.g.dart
文件。
使用观察者
即使实现了MobX存储,在你的应用程序的UI中反映状态变化也需要使用flutter_mobx
库中的观察者。观察者是一个小部件,它围绕着观察者或计算值,将其数值的变化呈现在用户界面上。
平均星数、评论数和评论总数的值会随着每个新评论的加入而被更新。这意味着用于呈现这些值的部件被包裹在一个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
,用一个观察者小部件进行包装。然后,它使用Review
类的reviewStore
实例来引用这些数据。
接下来,占位符 "无评论 "Text
widget在商店里的评论列表为空时显示。否则,一个ListView
显示列表中的项目。最后,"完成 "按钮的onPressed()
功能被修改为向商店添加一个新的评论。
在这一点上,你的应用程序几乎已经完成。你的下一步是将评论屏幕导入到你的main.dart
文件的导入部分。打开该文件,并添加这个片段。
$ 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() //previously 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
的文件夹。创建一个 配置文件,使文件路径的结构像这样/your_project_path/.circleci/config.yml
。
然后,用这个代码片断填充/.circleci/config.yml
文件。
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
在这个配置文件中, Docker被用来作为 执行器。没有用于Flutter的官方CircleCI Docker镜像,但在DockerHub上有一大串Flutter镜像。最突出的是 cirrusci/flutter镜像。这个镜像的使用频率超过100万次拉取。
配置文件中可选的branches
部分用于过滤部署过程中运行的分支。当没有明确定义时,CircleCI假设master
作为工作的分支。
配置文件定义了要使用的Docker镜像。它还钉了一个与运行你的项目本地副本的Flutter版本相匹配的镜像, (在我的例子中是v1.5.8)。
在步骤部分,配置文件定义了每次在你的项目资源库上运行部署时要执行的每个过程,以及它们的执行顺序。
最后,在上述代码片断的store-artifacts
部分,引用了我们 构建工件的路径。这可以使工件自动上传到你的CircleCI仪表板的Artifacts标签。该工件可以被部署到AWS S3桶或任何其他托管服务。对于生产就绪的Flutter应用程序,您可以在此配置中添加部署到应用程序商店。
设置CircleCI
要将CircleCI与您的项目集成,请进入CircleCI仪表板,并点击添加项目。它在仪表板页面的最左边。接下来,导航到页面的最右边,点击设置项目。
在下一个页面,点击开始构建。
你的构建将开始。
现在,每次提交新的代码都会触发您的Flutter应用程序的自动构建、测试和部署管道。
总结
在这篇文章中,我们介绍了如何用MobX状态管理库管理Flutter应用程序的状态。我们还介绍了如何使用CircleCI为您的应用程序设置CI/CD流水线。
虽然在决定选择Flutter项目的状态管理方法时需要考虑权衡,尤其是大型项目,但MobX为中等规模的项目提供了一个可靠的选择。该库为你做最难的工作,同时给你权力在必要时负责。当您决定为您的下一个Flutter项目进行状态管理时,甚至是重写一个当前的项目时,这是一个很大的好处。
开发团队继续为他们的Flutter项目采用MobX。请查看 该库的GitHub仓库,了解如何为其发展做出贡献。
我确实希望你喜欢这个教程。编码愉快!