通过与React的简单对比来入门Flutter

前端Sharing

共 16624字,需浏览 34分钟

 · 2021-11-08

    

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详情页”。

  1. Todo列表页主要用于展示Todo列表。
  2. Todo详情页用于新增Todo/查看Todo详情。

开始编写代码

  1. 目录结构

这里没有什么强制规定,基本上代码都在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
  1. 入口文件

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')

)
  1. 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
  1. 页面路由

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
  1. 编写组件

在编写组件之前,先简要介绍一下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 ListNavBar>

            <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: 10left10),
              ),

              Container( // todo任务
                width: 200,
                child: Text(v.task, overflow: TextOverflow.ellipsis, maxLines1),
              ),
            ],
          ),

          trailing: IconButton( // 详情按钮
            icon: Icon(Icons.keyboard_arrow_right),

            onPressed() => Navigator.of(context).push(MaterialPageRoute(builder: (context) => DetailPage(falsecurId: v.id))),
          ),

          onTap: () { // 点击回调,todo状态切换
            onPressed: list.statusChange(v);
          },
        ),
      ),
    );
  },
),

结合上面代码,介绍一些基础Widget。我这里简单分成基础类,容器类,布局类以及功能类来介绍(详细API参考官网)。

  1. 基础类,Image,Text等。这些对应img,span等html标签
  2. 容器类,Container,Padding这些就是容器类Widget。为了方便理解,我们可以把它当作我们常用div这种html标签。
  3. 布局类,Row,Column这些就是布局类Widget。这两个与我们常用的flex布局很相似,由主轴与交叉轴控制布局,Row就是flex的横向,Column就是flex的纵向。
  4. 功能类,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>

对于被包裹的组件使用状态也很类似。

Flutter:

TodoList list = Provider.of(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(); // 对比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(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,
                      timenew DateTime.now(),
                      completefalse,
                    ));

                    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,但是我觉得了解具体的生命周期是有必要的。

根据上图介绍一下主要的生命周期。

  1. initState,Widget首次挂进widget树时调用,对比React的componentDidMount。
  2. didChangeDependencies,State对象中的依赖变化时调用,上面介绍Provider的时候,状态通知给Widget时就会触发。
  3. build,构建Widget子树,对比React的render。
  4. reassemble,开发专用,热重载的时候回调用。
  5. didUpdateWidget,Widget重新构建时会检测Widget是否要更新。新旧widget的key和runtimeType同时相等时说明这个Widget还是它自己,只需要更新不需要卸载。这时didUpdateWidget就会被调用。Widget的key对比React组件的key,runtimeType对比React.createElement的type(div变span)。
  6. deactive,Widget从Widget树中移除然后又重新插入widget树中就会被调用。换成我们熟悉的概念就是dom节点在dom树中的位置改变。
  7. 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

❤️ 

便^_^

  ~

 前端Sharing ~


浏览 11
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报