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函数,渲染组件到原生窗口。

MyAppHomePage是两个类似的组件,都继承了StatelessWidget,并重写了build函数。这里讲的Stateless类似于react里面Pure component的概念,即:某些UI组件没有状态,完全是函数式的,传入参数确定,画出来的结果就一样。

build函数返回这个组件下面的组件树,从而确定这个组件长什么样。组件树不同于react里面的XML形式的jsx,而是嵌套的构造函数调用(习惯了就好,并不比jsx差)。

例子中用到如下组件:

  • MaterialApp
    实现了Material样式的根组件,IOS对应的还有个CupertinoApp
  • Scaffold
    移动应用的框架,实现了最上面的AppBar,下面的Tab导航栏,左右两边的Drawer,底部的BottomSheet等。
  • AppBar
    应用顶部的标题栏,可以有左上角的返回按钮,和右上角的工具栏。
  • Center
    样式组件,负责把子组件居中。(对,样式也是通过组件实现的,没有CSS,这样更纯粹)

flutter_01

列表渲染(一)

我们先来实现第一个视图:日记列表。做这个列表我们需要先实现单独一个列表项的渲染。如下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是另外一个样式组件,给子组件留个白边。

有个这个组件,我们把之前的Scaffoldbody改一下,显示这个组件。

Scaffold(
  appBar: AppBar(
    title: Text('flutter notebook'),
  ),
  body: NoteBookItem(
    icon: Icon(Icons.star),
    text: 'Note message',
    timestamp: DateTime.now()
  )
)

好了,有了这个组件,我们可以愉快的渲染我们的日记列表了。

完成代码见github上flutter_notebook项目part1-end分支。
flutter_02

列表渲染(二)

有了上面的单个列表组件,我们接下就可以渲染一个真的列表了。
这部分里,我们将学会

  • 使用状态组件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())
            );
          });
        }
      ),
    );
  }
}

注意ScaffoldfloatingActionButton属性,目前加了一个FloatingActionButton组件了,它有个onPressed属性是点击的回调函数。
这个回调函数调用了State上的setState方法,往notes里添加了一个NoteModel

注意:

  • () {}这种写法是dart的回调匿名函数写法。
  • setState和react里不一样,flutter里面的参数是个函数,而react是个Object。

接下来,修改body的渲染组件,用ListView组件把notes全部渲染出来。为了好理解,这里先不考虑性能了,ListView支持动态渲染的!

这样事件就处理好了,点了按钮以后先后触发onPressed -> setState -> 触发组件rebuild -> 自动组件渲染。

完成代码见github上flutter_notebook项目part2-end分支。

flutter_03-1

编辑对话框

上面的点击事件完成了,但是数据都是写死的。这节我们加一个对话框,把日记内容给加到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比较类似。
lifecycle

要把这个组件作为对话框显示出来,在点击触发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分支。

flutter_04

最后的优化

这部分是教程的最后一部分,我们把项目进行最后的调整,显得完整一些。

打包并显示图片

目前列表为空的时候什么都没有,不如空的时候显示一张默认图片吧。这需要把资源(图片,音效之类)打包到应用程序中。(暂不讨论从网上拉取)

我们把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放到Dismissiblechild里面,onDissmissed回调就会在右滑时触发。

完成代码见github上flutter_notebook项目part4-end分支。

flutter_05

本篇Flutter入门教程就到这里了。本文简单介绍了Flutter的布局,组件,状态,声明周期资源打包等。希望能对大家有所帮助!