Flutter简介
Flutter是谷歌开源的移动端应用开发框架,采用Dart语言作为开发语言,主要的特点是跨平台,高性能,高保真。一套代码同时运行在Android与IOS两端并且可以保持UI的统一性(Web端也可以使用,但是目前性能不佳)。
Flutter如何做到跨平台以及统一UI(高保真)?关键在于谷歌实现了一个跨平台的绘图引擎,我们敲出的页面实际上是这个绘图引擎画出来的一张图片(这个与游戏十分类似,我们看到的游戏画面也是通过游戏引擎渲染出来的。还有一个不太准确的类比,我们都知道Java可以在大部分平台上运行,这都得益于Java是跑在Java虚拟机上,这使得平台的差异消失了)。当然由于这种形式会造成一些缺点,因为我们的页面实际上是绘图引擎画出的一张图片,所以类似于“选中某些文字然后复制”这种功能实现起来就会变得比较困难。
为什么Flutter高性能?因为Dart既支持JIT(即时编译,以JavaScript为代表的语言使用这种方式),又支持AOT(提前编译,以C++为代表的语言使用这种方式)。因为这样Dart可以做到开发时使用JIT避免了每次的改动都要重新编译,发布时使用AOT,提前编译好提高程序运行速度。
有没有类似的开发框架?实际上类似原理的QT mobile在Flutter之前就推出了,但是因为官方推广不给力以及C++极高的上手门槛导致其一直不温不火。
Dart简介
在使用Dart开发后,这门语言给我的感觉就是一个Java与JavaScript的综合产物。在静态语法方面与Java十分相似,包括类型定义,泛型等。而在动态语法方面就和JavaScript很相似,函数式特性,异步的用法等。如果平时只写JS可能会不太习惯,但是如果你已经习惯使用TS开发(实际上TS就有很多Java,C#的影子),我相信上手Dart语言不是一件很困难的事。
环境搭建
参考官网[1],有非常详细的教程。
关键步骤,安装Flutter SDK。
IDE推荐使用Android Studio,当然VS CODE装上对应插件也OK。
入门
从与React对比开始入门。
如果熟悉React的话,你在使用Flutter的时候肯定会充满即视感,其实这一点也不奇怪,实际上Flutter官方就提到在设计Flutter时受到了React的影响。对于熟悉React的前端开发人员来说,从与React对比开始入门想必是相对来说比较轻松的一个方式。
Flutter与React,两者都作为一个声明式UI框架,都遵循UI = f(state)的理念,加之Flutter本身就参考了React,所以两者有大量相似的地方。下面我们从编写一个经典前端入门应用Todo List开始我们的Flutter之路。
简要设计
我们的Todo List主要分为两个页面,“Todo列表页”以及“Todo详情页”。
- Todo列表页主要用于展示Todo列表。
- Todo详情页用于新增Todo/查看Todo详情。
开始编写代码
目录结构
这里没有什么强制规定,基本上代码都在lib目录下就OK了。这里的目录结构主要是我的开发习惯。
Flutter目录结构
├── lib // 相当于React项目的src
│ ├── app.dart // 相当于React项目的App.js
│ ├── main.dart // 相当于React项目的index.js
│ ├── models // MVC模型中的model层类比React项目中的状态管理部分
│ │ ├── todo.dart // Todo类
│ │ └── todo_list.dart // TodoList类
│ └── pages // 页面
│ ├── detail // 详情页
│ │ └── index.dart
│ └── list // 列表页
│ └── index.dart
├── pubspec.yaml // 相当于package.json
对比的React项目目录结构
├── src
│ ├── App.js
│ ├── index.js
│ ├── models
│ │ ├── todo.js
│ │ └── todoList.js
│ ├── pages
│ │ ├── detail
│ │ │ └── index.js
│ │ └── list
│ │ └── index.js
├── package.json
入口文件
main.dart作为入口文件主要有一个主函数main,同时这个主函数也是作为整个应用的入口函数,其中main里面起到关键作用的就是runApp函数,这与React的ReactDOM.render作用类似。
import 'package:flutter/material.dart'; // 谷歌官方组件库,类比antd
import 'app.dart';
void main() {
runApp(App());
}
对比的React代码
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.render(
<App/>,
document.getElementById('root')
)
Model层设计
Todo List应用有列表以及todo详情,因此我们这一块设计两个类,一个TodoList类对应列表,一个Todo类对应todo详情。
至于React项目那边,可能这种设计不是很常见,但是为了方便对比,设计和Flutter保持了一致。
Flutter Todo类代码:
import 'package:uuid/uuid.dart';
class Todo {
bool complete; // todo的完成状态
final String id; // todo的唯一id
final DateTime time; // todo创建时间
String task; // todo的具体任务
Todo({
this.task,
this.complete = false,
DateTime time,
String id
}) : this.time = time ?? DateTime.now(), this.id = id ?? Uuid().v4();
}
React对比代码:
import {v4} from 'uuid';
class Todo {
constructor(task, id = v4(), complete = false, time = new Date().toLocaleString()) {
this.id = id
this.task = task
this.complete = complete
this.time = time
}
}
export default Todo
Flutter TodoList类代码:
import 'package:flutter/foundation.dart';
import 'todo.dart' show Todo;
class TodoList with ChangeNotifier {
Map<String, Todo> _list = new Map(); // 用于保存所有todo
Map<String, Todo> get list => _list; // 私有变量的getter
void add(Todo todo) { // 添加todo
_list[todo.id] = todo;
notifyListeners(); // 通知组件状态改变
}
void remove(String id) { // 删除todo
_list.remove(id);
notifyListeners();
}
void statusChange(Todo todo) { // 改变todo状态
todo.complete = !todo.complete;
_list.update(todo.id, (value) => todo);
notifyListeners();
}
Todo getById(String id) { // 获取单个todo
return _list[id];
}
}
React对比代码:
import React, {createContext} from 'react'
class TodoList {
constructor() {
this._list = new Map()
}
get list() {
return this._list
}
add(todo) {
this._list.set(todo.id, todo)
}
remove(id) {
this._list.delete(id)
}
statusChange(todo) {
this._list.set(todo.id, {...todo, complete: !todo.complete})
}
getById(id) {
return this._list.get(id)
}
}
export const todoList = new TodoList()
const TodoListContext = createContext(todoList)
export default TodoListContext
页面路由
Flutter的路由跳转主要用到Navigator,React那边对应的就是history。
页面路由有几种方式,详细参考官网。这里为了与React对比主要介绍命名路由。
import 'package:flutter/material.dart';
import 'pages/detail/index.dart';
import 'pages/list/index.dart';
import 'models/todo_list.dart';
import 'package:provider/provider.dart';
class App extends StatelessWidget {
const App({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => TodoList()),
],
child: MaterialApp(
title: 'flutter todo list',
initialRoute: '/',
routes: {
'/': (context) => ListPage(),
'/list': (context) => ListPage(),
'/detail': (context) => DetailPage(false),
'/edit': (context) => DetailPage(true),
},
),
);
}
}
React代码
import {BrowserRouter, Switch, Route} from 'react-router-dom'
import TodoListContext, {todoList} from './models/todoList'
import DetailPage from './pages/detail'
import ListPage from './pages/list'
import './App.css'
import 'antd-mobile/dist/antd-mobile.css'
function App() {
return (
<TodoListContext.Provider value={todoList}>
<BrowserRouter>
<Switch>
<Route exact path="/" component={ListPage}/>
<Route exact path="/list" component={ListPage}/>
<Route exact path="/detail" component={DetailPage}/>
<Route exact path="/edit" component={DetailPage}/>
</Switch>
</BrowserRouter>
</TodoListContext.Provider>
)
}
export default App
编写组件
在编写组件之前,先简要介绍一下Flutter里面的Widget。与React页面都由组件组成的理念类似,Flutter的页面都是由Widget组成的,因此我们可以把Widget通俗的理解成我们所熟悉的组件。
Flutter也和React一样有无状态组件与状态组件两种Widget。分别通过继承StatelessWidget,StatefulWidget来实现。
StatelessWidget,根据名字就可以看出来这是无状态组件。顾名思义就是用于不需要组件内部管理状态的场景,相信写过React的前端程序员不难理解。上面介绍路由时贴的代码就是一个StatelessWidget。
StatefulWidget,这个就是状态组件。一个StatefulWidget对应一个State类,State类就是状态组件维护的状态。State中有两个常用属性widget与context。widget是这个状态组件对应的实例,我们一般使用它来获取在StatefulWidget中定义的属性。context就是BuildContext类的一个实例,对应着这个组件所在的组件树的上下文。
正常情况下,我们要编写一个Widget,用其他Widget组合起来就可以了。当然如果现有的Widget都不符合你的需求,你可以实现自己的一个独有的Widget。开头的时候就说过,我们写的页面实际上就是绘图引擎绘制的一张图片。在Flutter上面要实现一个自定义Widget,其实就是用到Flutter提供的CustomPainter类把Widget绘制出来(CustomPainter其实是一个Canvas)。
现在所有的准备工作都做好了,我们开始实现Todo List应用最关键的两个页面。
我们先从列表页开始。列表的主要功能有Todo展示以及添加todo的按钮。列表页主要用于展示,没有必要维护内部状态,因此我们选用StatelessWidget。
首先根据最基本的页面结构来开始写代码:
class ListPage extends StatelessWidget {
const ListPage({Key key}) : super(key: key);
@override
Widget build(BuildContext context) { // 类似React class组件的Render
return Scaffold( // Material组件,页面的骨架
appBar: AppBar( // 页头的导航栏
title: Text('Todo List'),
leading: Container(), // 用于隐藏左侧返回按钮
),
floatingActionButton: FloatingActionButton( // 页面上浮动的按钮
child: Icon(Icons.add), // 按钮展示的icon
onPressed: () {/* todo */}, // 点击事件
),
body: ListView.builder( // 页面主体的列表
itemCount: 0, // 列表包含的列表项总数量
itemBuilder: (context, index) { // 具体列表项组件
/* todo */
return Container();
},
),
);
}
}
React对比代码:
const ListPage = (props) => {
return (
<div>
<NavBar>Todo List</NavBar>
<List>
{/* todo */}
</List>
<Button
onClick={() => {}}
icon={<Icon type="plus"/>}
/>
</div>
)
}
export default ListPage
可能你会觉得React与Flutter对比起来好像也没有那么相似,但是如果你见过在jsx出现以前的React代码肯定就不会这么觉得了。下面贴一下没有jsx的React代码再来对比一下。
const ListPage = (props) => {
return createElement(
'div',
null,
[
createElement(
NavBar,
{key: 'listNavBar'},
'Todo List',
),
createElement(
List,
{key: 'listContent'},
),
createElement(
Button,
{
key: 'listButton',
onClick: () => {},
icon: createElement(
Icon,
{type: 'plus'}
)
}
),
]
)
}
export default ListPage
从这三段代码对比我们就能看出,Flutter的组件用法其实很像在React里面直接使用React.creatElement的形式。从这里我们也能看出一些Flutter的缺点,对于前端人员来说这种写法不太直观。根据jsx的原理,我觉得未来Flutter出现类似于jsx这种写法也不奇怪,期待dart对应的dsx出现。
由上述代码我们就可以得到一个这样的页面:
接下来我们开始实现具体功能,首先是添加按钮。添加按钮是要跳转到新增Todo的页面,因此这个按钮要有一个路由跳转的回调。下面我们来实现代码(限于篇幅,往下只会贴React代码的部分片段,完整代码请移步到todo_list_react[2], todo_list_react_nojsx[3]):
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () => Navigator.of(context).pushNamed('/edit'), // 跳转到新增todo页
),
Navigator.of(context).pushNamed('/edit'),这个和我们熟悉的history.push('/edit')是类似的。但是却多了一个.of(context),这个有什么作用呢?这个of其实和js里面的bind有点相似,是用来绑定上下文的,这个context就是Widget所在Widget树的一个上下文。
接下来我们实现列表的代码。列表项由这么几部分组成,todo状态,todo任务,详情按钮,删除。
ListView.builder( // 官方列表组件
itemCount: list.list.length,
itemBuilder: (context, index) {
var v = list.list.values.toList()[index];
return Card( // 官方卡片组件
child: Dismissible( // 官方手势组件
key: Key(v.id),
onDismissed: (direction) { // 划动回调,用于删除todo
Scaffold.of(context).showSnackBar(SnackBar(content: Text('删除了任务 ${v.task}')));
list.remove(v.id);
},
background: Container( // 左/右划动展示删除icon
color: Colors.red,
child: ListTile(
leading: Icon(
Icons.delete,
color: Colors.white,
),
trailing: Icon(
Icons.delete,
color: Colors.white,
),
),
),
child: ListTile( // 官方列表项组件
title: Row(
children: [
Icon(v.complete ? Icons.check_circle : Icons.access_time, color: v.complete ? Colors.green : Colors.red,),
Container( // todo完成状态
child: Padding(
padding: EdgeInsets.all(8.0),
child: v.complete ?
Text('已完成', style: TextStyle(color: Colors.white)) :
Text('未完成', style: TextStyle(color: Colors.white)),
),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.all(Radius.circular(5)),
),
margin: EdgeInsets.only(right: 10, left: 10),
),
Container( // todo任务
width: 200,
child: Text(v.task, overflow: TextOverflow.ellipsis, maxLines: 1),
),
],
),
trailing: IconButton( // 详情按钮
icon: Icon(Icons.keyboard_arrow_right),
onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (context) => DetailPage(false, curId: v.id))),
),
onTap: () { // 点击回调,todo状态切换
onPressed: list.statusChange(v);
},
),
),
);
},
),
结合上面代码,介绍一些基础Widget。我这里简单分成基础类,容器类,布局类以及功能类来介绍(详细API参考官网)。
- 基础类,Image,Text等。这些对应img,span等html标签
- 容器类,Container,Padding这些就是容器类Widget。为了方便理解,我们可以把它当作我们常用div这种html标签。
- 布局类,Row,Column这些就是布局类Widget。这两个与我们常用的flex布局很相似,由主轴与交叉轴控制布局,Row就是flex的横向,Column就是flex的纵向。
- 功能类,Dismissible,Navigator这些Widget。这些对应某些功能,如手势,路由等。
这里吐槽一下Flutter的样式写法,看一下上面的一些样式代码,再对比我们前端人员熟悉的css,Flutter的样式编写方式明显很繁琐很不直观。不管是原生,css module,css in js还是less这种css预处理器都吊打Flutter这种样式处理。
增加了列表的代码后,可以得到下面的效果:
页面部分已经完成了,但是我们的组件数据从哪里得来呢?这就要回到上面路由的那部分代码。
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => TodoList()),
],
// ...
);
Flutter和React的理念很像,状态的改变会导致页面的改变。这里介绍一下跨组件如何共享状态,Flutter也能使用我们熟悉的一些状态管理库Redux,Mobx。这里使用官方推荐的Provider。
其实我们可以对比这React的Context来看。上面的代码其实就相当于:
<TodoListContext.Provider value={todoList}>
{/* ... */}
</TodoListContext.Provider>
对于被包裹的组件使用状态也很类似。
Flutter:
TodoList list = Provider.of<TodoList>(context);
React:
const list = useContext(TodoListContext)
与React稍有不同的是,Flutter里使用Provider要手动通知组件。
class TodoList with ChangeNotifier { // ChangeNotifier通知的类
Map<String, Todo> _list = new Map(); // 用于保存所有todo
Map<String, Todo> get list => _list; // 私有变量的getter
void add(Todo todo) { // 添加todo
_list[todo.id] = todo;
notifyListeners(); // 通知组件状态改变的方法
}
// ...
}
简单解释一下上面的代码。dart里面有几种复用代码的方式,extends(继承),implements(实现),with(混入)。继承应该大家比较熟悉这里就不展开了。实现的话,其实和Java很类似,子类不可以继承多个父类,但是可以实现多个接口,但是因为dart里没有接口(interface),这个关键字是用来实现多个抽象类使用的。混入的话,就是和vue里面的mixins类似了,这里相信大家也比较熟悉就不展开了。
至此列表页已经完成。我们开始实现新增todo/todo详情页。新增页很简单,我们只需要一个输入框输入任务然后提交就可以了。详情页的话就是展示todo的信息。
因为新增页/详情页是需要状态的,所以我们使用StatefulWidget来实现页面。
class DetailPage extends StatefulWidget {
DetailPage(this._isCreate, {String curId}) {
this._curId = curId;
}
final bool _isCreate; // 是否是新增todo
String _curId; // 查看详情页时对应todo的id
@override
_DetailPageState createState() => _DetailPageState(); // 状态组件都需要实现的方法
}
class _DetailPageState extends State<DetailPage> { // 状态组件对应的状态
final _formKey = GlobalKey<FormState>(); // 对比React Form的ref
bool _isCreate; // 是否新增todo
String _task; // todo任务
String _curId; // 当前todo id
@override
void initState() { // 初始化生命周期
// TODO: implement initState
super.initState();
_isCreate = widget._isCreate; // widget是上面StatefulWidget的实例
_curId = widget._curId; // 用来获取StatefulWidget声明的属性
}
@override
Widget build(BuildContext context) {
TodoList list = Provider.of<TodoList>(context);
return Scaffold( // 页面骨架
appBar: AppBar(
title: _isCreate ? Text('新增Todo') : Text('Todo详情页'),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios),
onPressed: () => Navigator.of(context).pushNamed('/'),
),
),
body: _isCreate ? // 新增页或者详情页
Form( // 表单
key: _formKey, // 类似React ref
child: Column(
children: [
TextFormField( // 输入框
decoration: InputDecoration(
labelText: '任务',
prefixIcon: Icon(Icons.article),
),
validator: (value) { // 校验回调
if (value == null || value.isEmpty) {
return '必须填写';
}
return null;
},
onSaved: (value) { // 表单保存时触发的回调
_task = value;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton( // 表单提交按钮
onPressed: () {
if (_formKey.currentState.validate()) { // 表单校验通过
_formKey.currentState.save(); // 保存field
list.add(new Todo( // 添加todo
task: _task,
time: new DateTime.now(),
complete: false,
));
Navigator.of(context).pushNamed('/'); // 路由回列表页
}
},
child: Text('提交'),
),
)
],
),
) : // 详情页代码
Column(
children: [
Card(
child: Container(
padding: EdgeInsets.all(16),
width: MediaQuery.of(context).size.width,
child: Row(
children: [
Text('任务:', style: TextStyle(fontSize: 16)),
Expanded(
child: Text('${list.getById(_curId).task}', style: TextStyle(fontSize: 16)),
),
],
),
),
),
Card(
child: Container(
padding: EdgeInsets.all(16),
child: Row(
children: [
Text('任务状态:', style: TextStyle(fontSize: 16)),
Text('${list.getById(_curId).complete ? '已完成' : '未完成'}', style: TextStyle(fontSize: 16)),
],
),
),
),
Card(
child: Container(
padding: EdgeInsets.all(16),
child: Row(
children: [
Text('任务创建时间:', style: TextStyle(fontSize: 16), textAlign: TextAlign.left),
Text('${list.getById(_curId).time}', style: TextStyle(fontSize: 16)),
],
),
),
),
],
),
);
}
}
在Flutter里面State是有生命周期的,虽然上面代码只用到了initState,但是我觉得了解具体的生命周期是有必要的。
根据上图介绍一下主要的生命周期。
- initState,Widget首次挂进widget树时调用,对比React的componentDidMount。
- didChangeDependencies,State对象中的依赖变化时调用,上面介绍Provider的时候,状态通知给Widget时就会触发。
- build,构建Widget子树,对比React的render。
- reassemble,开发专用,热重载的时候回调用。
- didUpdateWidget,Widget重新构建时会检测Widget是否要更新。新旧widget的key和runtimeType同时相等时说明这个Widget还是它自己,只需要更新不需要卸载。这时didUpdateWidget就会被调用。Widget的key对比React组件的key,runtimeType对比React.createElement的type(div变span)。
- deactive,Widget从Widget树中移除然后又重新插入widget树中就会被调用。换成我们熟悉的概念就是dom节点在dom树中的位置改变。
- dispose,Widget在Widget树中被移除就会调用。也就是组件卸载。
完成上面代码就得到了如下页面:
至此我们的Todo List应用已完成。