Flutter是Google推出的移动端跨平台开发技术。比起大家比较熟悉的原生开发,RN, weex等技术栈,它有以下优势:
- 编译为原生ARM机器代码,执行效率更高。
- 建立在一套声明式API之上,app逻辑更容易理解。(类似于react的setState/build/diff/render的机制,老一代传统UI是命令式API)
- 底层架构在skia绘图层之上,抛弃了OEM widget,因此跨平台行为一致,少有兼容性bug。
- 开发效率高。
使用flutter技术可以提升终端开发效率,得益于它的以下几个特性:
1. hot reload(热更新)能获得更快的界面反馈,不用坐等编译
2. Android IOS一套代码。
3. 框架提供了大量预制组件,方便开发。
Flutter开发语言是dart。但这个不是什么事。因为这个语言设计跟java js很像,完全不用刻意学习。有代码基本直接上手。
好处说了这么多,我们下面通过例子,体验下如何使用flutter开发app。
开始撸码前,首先我们需要根据官方文档按部就班的把开发环境准备好。大致过程就是:安装flutter sdk,安装android studio。(因为是G家的东西,大家最好把梯子准备好)
成功安装之后,可以跑一下 flutter doctor
检查一下还有什么问题。
接下来,我们通过一个简单的日记本项目体验下flutter开发过程。
初始化项目
flutter命令行工具提供了新项目的脚手架工具。输入以下命令新建一个名称为flutter_notebook的项目。
flutter create flutter_notebook
cd flutter_notebook
项目文件夹下有如下一些文件:
- pubspec.yaml: flutter项目的描述信息,版本号,包依赖等
- lib/: flutter项目的dart代码
- android/: android终端原生代码
- ios/: IOS项目终端原生代码
- test/: 单元测试代码
现在在手机上开启USB调试,连上USB,并执行flutter run
。项目就可以在手机上跑起来了。
体验热加载
接下来,我们打开lib/main.dart文件,这个是flutter项目的入口。我们从这里入手。随便改一行代码,返回终端按r键,app迅速的重新渲染了并保存了之前的状态。也可以按R重启app,这样之前的状态就丢了。热加载在项目开发的时候会大大提高开发效率。
项目起点
现在的代码还是有点复杂的,我们把main.dart换成下面的 Hello, world!
代码,从最简单的例子开始研究。
也可以直接使用github上flutter_notebook项目的master分支作为起点。
import 'package:flutter/material.dart';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Flutter Demo',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
home: HomePage()
);
}
}
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('flutter notebook'),
),
body: Center(
child: Text('Hello, world!')
)
);
}
}
这段代码中的main
函数是flutter项目的入口函数。(其实真是的入口函数是仍然是android/IOS终端项目文件夹中的入口。)它把new MyApp()
返回的组件树传给runApp
函数,渲染组件到原生窗口。
MyApp
和HomePage
是两个类似的组件,都继承了StatelessWidget
,并重写了build函数。这里讲的Stateless
类似于react里面Pure component
的概念,即:某些UI组件没有状态,完全是函数式的,传入参数确定,画出来的结果就一样。
build函数返回这个组件下面的组件树,从而确定这个组件长什么样。组件树不同于react里面的XML形式的jsx,而是嵌套的构造函数调用(习惯了就好,并不比jsx差)。
例子中用到如下组件:
- MaterialApp
实现了Material样式的根组件,IOS对应的还有个CupertinoApp
。 - Scaffold
移动应用的框架,实现了最上面的AppBar,下面的Tab导航栏,左右两边的Drawer,底部的BottomSheet等。 - AppBar
应用顶部的标题栏,可以有左上角的返回按钮,和右上角的工具栏。 - Center
样式组件,负责把子组件居中。(对,样式也是通过组件实现的,没有CSS,这样更纯粹)
列表渲染(一)
我们先来实现第一个视图:日记列表。做这个列表我们需要先实现单独一个列表项的渲染。如下NoteBookItem
类
class NoteBookItem extends StatelessWidget {
NoteBookItem({@required this.icon, @required this.text, @required this.timestamp});
final Icon icon;
final String text;
final DateTime timestamp;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 50.0,
child: Row(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: icon,
),
Expanded(
child: Text(text,
overflow: TextOverflow.ellipsis,
maxLines: 2
)
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text('${timestamp.month} - ${timestamp.day}'),
),
],
),
);
}
}
同样继承于StatelessWidget类,这个类多了几个实例属性。表示这条记录的图表,文字和时间展示。
final Icon icon;
final String text;
final DateTime timestamp;
final
类似于java,初始化后不能修改。
这个类的构造函数接受了几个关键字参数。
NoteBookItem({@required this.icon, @required this.text, @required this.timestamp});
@required
是参数的注释,表明这个参数必填,this.icon
这样就直接把参数的值给了实例的属性。
布局中用到如下一些组件:
SizedBox
给这个组件一个固定的高度。Flutter中很多容器型组价宽高默认是占满父组件的。Row
是flutter中的基础布局容器,用于实现flex布局。它接受一个子组件数组,并把子组件排到一排。三个子组件中有一个特别的Expanded
,它是布局中的弹性的部分,占满该行剩下的所有空间。Padding
是另外一个样式组件,给子组件留个白边。
有个这个组件,我们把之前的Scaffold
的body
改一下,显示这个组件。
Scaffold(
appBar: AppBar(
title: Text('flutter notebook'),
),
body: NoteBookItem(
icon: Icon(Icons.star),
text: 'Note message',
timestamp: DateTime.now()
)
)
好了,有了这个组件,我们可以愉快的渲染我们的日记列表了。
完成代码见github上flutter_notebook项目的part1-end分支。
列表渲染(二)
有了上面的单个列表组件,我们接下就可以渲染一个真的列表了。
这部分里,我们将学会
- 使用状态组件
StatefulWidget
保存状态 - 如何绑定事件
- 如何保存状态
- 如何渲染列表
首先,我们我们需要一个日记项目的NoteModel
。如下:
class NoteModel {
NoteModel({this.iconData, this.text, this.timestamp});
final IconData iconData;
final String text;
final DateTime timestamp;
}
然后,我们需要一个数组保存多个NoteModel
。这个数组放到哪里呢?StatelessWidget的属性就不合适了,因为是不可变的。最合适的地方就是组件的状态。Flutter里面是叫StatefulWidget
。
我们把之前的无状态的HomePage组件改成如下具有状态的组件:
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
List<NoteModel> notes = [];
@override
Widget build(BuildContext context) {
// ...
}
}
HomePage
这次继承了StatefulWidget
组件,覆盖createState
方法并返回这个组件的状态。这个状态则有个build方法负责渲染的子组件。
- _HomePageState的
notes
属性是我们保存日记列表模型的状态。 - 下划线开头的变量表明模块私有变量(虽然我不是很喜欢这样表示)。
接下来我们添加一个按钮,绑定一个点击事件,并添加notes
。
class _HomePageState extends State<HomePage> {
List<NoteModel> notes = [];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('flutter notebook'),
),
body: ListView(
children: notes.map((note) => NoteBookItem(
icon: Icon(note.iconData),
text: note.text,
timestamp: note.timestamp
)).toList(),
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
setState(() {
notes.add(NoteModel(
iconData: Icons.star,
text: 'Hello',
timestamp: DateTime.now())
);
});
}
),
);
}
}
注意Scaffold
的floatingActionButton
属性,目前加了一个FloatingActionButton
组件了,它有个onPressed
属性是点击的回调函数。
这个回调函数调用了State
上的setState
方法,往notes
里添加了一个NoteModel
。
注意:
() {}
这种写法是dart的回调匿名函数写法。setState
和react里不一样,flutter里面的参数是个函数,而react是个Object。
接下来,修改body的渲染组件,用ListView
组件把notes全部渲染出来。为了好理解,这里先不考虑性能了,ListView支持动态渲染的!
这样事件就处理好了,点了按钮以后先后触发onPressed -> setState -> 触发组件rebuild -> 自动组件渲染。
完成代码见github上flutter_notebook项目的part2-end分支。
编辑对话框
上面的点击事件完成了,但是数据都是写死的。这节我们加一个对话框,把日记内容给加到NoteModel
上去。
这节我们将学习:
- Flutter的路由系统
- 在路由系统之间传递数据
- 文字编辑
全屏的对话框需要用到Flutter中的路由系统。路由系统方便不同的UI组件之间的跳转。
对话框需要保存两个状态一个是日记的图标,二是日记的文字,因此需要用到一个StatefulWidget
。
见如下代码,(build函数的组件树比较大,😓)
const List<IconData> ICONS = [Icons.star, Icons.favorite, Icons.fastfood, Icons.card_travel];
class NoteEditDialog extends StatefulWidget {
@override
_NoteEditDialogState createState() => _NoteEditDialogState();
}
class _NoteEditDialogState extends State<NoteEditDialog> {
IconData currentIcon;
TextEditingController textController;
@override
void initState() {
super.initState();
currentIcon = ICONS[0];
textController = TextEditingController();
}
@override
Widget build(BuildContext context) {
return Scaffold(
// appBar: ... 暂时省略,
body: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: ICONS.map((iconData) {
var color = Theme.of(context).iconTheme.color;
if (iconData == currentIcon) {
color = Theme.of(context).primaryColor;
}
return IconButton(
icon: Icon(iconData, color: color),
onPressed: () {
setState(() {
currentIcon = iconData;
});
},
);
}).toList()
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: TextField(
autofocus: true,
decoration: new InputDecoration.collapsed(
hintText: 'Note Message'
),
keyboardType: TextInputType.multiline,
controller: textController,
maxLines: 1000,
),
),
)
],
)
);
}
}
这里重载了一个initState
函数,这个是State组件声明周期的方法,在初始化组件的时候调用,这里我们把当前的图表设为第一个,并实例了一个TextEditingController
。这个Controller传给TextField
组件后,可以获取当前输入框的文字。
更详细的生命周期方法,各位可以看下这张图,跟react, vue比较类似。
要把这个组件作为对话框显示出来,在点击触发onPressed事件的时候需要调用Navigator.push
方法。这个函数是个异步方法,返回解决就是这个弹框的处理结果。通过弹框的Navigator.pop
方法返回。返回后,弹框逻辑把返回的NoteModel
加到状态上。代码如下:
NoteModel ret = await Navigator.push(context, MaterialPageRoute<NoteModel>(
builder: (BuildContext context) => NoteEditDialog(),
fullscreenDialog: true,
));
if (ret != null) {
setState(() {
notes.add(ret);
});
}
Navigator.pop(context, NoteModel(
iconData: currentIcon,
text: textController.text,
timestamp: DateTime.now()
));
- 第一段是弹框触发,第二段是弹框返回结果。
- dart使用
await
语法等待异步方法结果。 - 如果直接x掉对话框,返回结果为null,因此要处理null结果。
完成代码见github上flutter_notebook项目的part3-end分支。
最后的优化
这部分是教程的最后一部分,我们把项目进行最后的调整,显得完整一些。
打包并显示图片
目前列表为空的时候什么都没有,不如空的时候显示一张默认图片吧。这需要把资源(图片,音效之类)打包到应用程序中。(暂不讨论从网上拉取)
我们把pubspec.yaml
文件打开,在flutter下面添加assets路径的地址。这样重新打包,图片就嵌入到安装包里了。这里我们把assets/images/
文件夹下的所有内容打包,其实只有note-icon.png
文件。
flutter:
assets:
- assets/images/
接下来,通过Image.asset('assets/images/note-icon.png')
组件就可以展示出这个图片了。
考虑到布局,图片大小等因素,我们把这部分的构造代码放到一个函数里。
_buildDefaultContent(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SizedBox(
width: 100.0,
height: 100.0,
child: Image.asset('assets/images/note-icon.png')
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text('Empty notebook', style: Theme.of(context).textTheme.display1),
)
],
)
);
}
然后把列表渲染代码改为
notes.length == 0 ? _buildDefaultContent(context) : ListView(...)
使用内置组件
之前为了演示组件布局我们开发了一个NoteBookItem。其实有内置组件可以用。事实上flutter提供了大量的预制组件,方便我们完成终端开发。
这里我们用ListTile
替换之前的NoteBookItem
。长相几乎和之前差不多。
ListTile(
leading: Icon(note.iconData),
title: Text(note.text),
trailing: Text('${note.timestamp.month} - ${note.timestamp.day}')
)
使用右滑动作删除单个日记
目前,日记列表的内容是不能修改的。至少得有个删除吧。。。因此,我决定增加右滑删除功能。
在web上完成这个功能需要首先检测右滑事件,右滑的同事需要修改视图状态,完了做个动画什么的,想想都头大。。。
不不不,flutter提供了大量的预制组件。这里我们用Dismissible
就行了。爽吧?
Dismissible(
key: ObjectKey(note),
direction: DismissDirection.startToEnd,
onDismissed: (DismissDirection dir) {
setState(() {
notes.remove(note);
});
},
background: Container(
color: Theme.of(context).primaryColor,
child: ListTile(
leading: Icon(Icons.delete, color: Colors.white),
),
),
child: ListTile(
leading: Icon(note.iconData),
title: Text(note.text),
trailing: Text('${note.timestamp.month} - ${note.timestamp.day}')
),
);
把之前的ListTile
放到Dismissible
的child
里面,onDissmissed
回调就会在右滑时触发。
完成代码见github上flutter_notebook项目的part4-end分支。
本篇Flutter入门教程就到这里了。本文简单介绍了Flutter的布局,组件,状态,声明周期资源打包等。希望能对大家有所帮助!