作者 | ELab.lijianye
出品 | ELab團隊(ID:gh_b1857b91fe44)
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詳情。開始編寫代碼這裡沒有什麼強制規定,基本上代碼都在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.jsonmain.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')
)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 TodoFlutter 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 TodoListContextFlutter的路由跳轉主要用到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應用已完成。
學習資料推薦《Flutter實戰》[4]
官網實用教程[5]
參考資料[1]官網: https://flutter.cn/
[2]todo_list_react: https://codesandbox.io/s/0v2vz
[3]todo_list_react_nojsx: https://codesandbox.io/s/t6qtk
[4]《Flutter實戰》: https://book.flutterchina.club/
[5]官網實用教程: https://flutter.cn/docs/cookbook