Flutter 学习笔记
Introduction
Flutter 采用 Dart 作为主要编程语言,所以应用的入口同样是顶层的 main()
方法,main()
方法中调用了 runApp()
方法,它接收一个 Widget 作为参数。
1 |
|
UI
Flutter 中的组件分为非容器类组件和容器类组件,非容器类组件一般直接或间接继承自 LeafRenderObjectWidget
,而容器类组件则继承自 SingleChildRenderObjectWidget
和 MultiChildRenderObjectWidget
,所以,所有组件的继承关系为 Widget > RenderObjectWidget > (Leaf/SingleChild/MultiChild)RenderObjectWidget,其中,RenderObjectWidget
类中定义了创建、更新 RenderObject
的方法,所有子类必须实现它。
Layout 基础
Flutter 中的 Widget 借鉴了 React 的思想。所有的 UI 都是基于 Widget 的,Widget 定义了视图如何显示(实际对应的是 Element)。如果使用的是 StatefulWidget,当 State 发生变化时 (通过 setState()
) 会触发重绘,当然,这种重绘是经过框架计算的,框架会尽量减少重绘的范围。
Flutter 中布局模型有两种,一种是基于 RenderBox 的模型,其约束被称为 BoxContraints,另一种是基于 RenderSliver 的可滚动列表模型,其约束来自于 SliverConstraints。由于开发过程中涉及到的大部分约束相关的问题都来自于 Box 约束,因此这里主要介绍它。
Box 约束
在 Flutter 中,组件是基于 RenderBox 被渲染出来的,而 RenderBox 的约束 (constraints) 来自于父组件,并且会根据约束来调整自身的*大小 (size)*。约束包含最大/最小的宽和高,大小则由具体的宽和高组成。
通常来说,依据组件如何处理它们的约束,Flutter 中可以分为三种 Boxes:
- 想要尽可能大的 Box,比如 Center、ListView 和未指定大小的 Container
- 和子组件一样大的 Box,比如 Transform 和 Opacity
- 固定大小的 Box,比如 Image 和 Text
不受限的约束
在某些场景下,Box 的约束可能是不受限制的,也就是 unbounded,它们的宽或高被设置为 double.INIFINITY。
一个尽可能大的 Box 如果被给予了不受限的约束的话,就会在运行时报错,比如如果你把一个垂直滚动的 ListView 嵌套进一个水平滚动的 ListView,那么,由于 ListView 会在它的交叉方向上寻求最大边界,内部的 ListView 会尝试尽可能填充自己的宽度,而此时宽度又是无限宽,所以会报异常。
Flex Boxes
Flex boxes (比如 Row 或 Column) 在不同的约束下表现不同。在受限的约束下,Flex box 会在该方向上尽可能的大。在不受限的约束下,它们会将子组件尽可能合适地进行排列,但是前提是子组件不能使用大于 0 的 Flex,也就是说使用了 Flex 的 box 不能相互嵌套。比如对于 Column 来说,它的 width 不能是 unbounded;对于 Row 来说,它的高度也不能是 unbounded。
理解约束
Flutter 中有一个基本原则:约束向下传递,大小向上传递,父布局设置位置。
- 组件的约束 (constraints) 来自父组件,约束由 4 个 double 组成:最小/最大宽度,最小/最大高度。
- 组件会依次测量子组件,得到子组件的大小,并且告诉每个子组件它们各自的约束。
- 组件会根据子组件的大小和排列方向依次放置子组件。
- 最后,组件告诉父组件它自身的大小。
约束的限制
- 组件的大小必须在父组件的约束范围之内,也就是说子组件并不能拥有任何想要的大小。
- 组件无法知晓也决定不了自身在屏幕上的位置,因为它的位置是由父组件决定的。
- 因为所有组件都在一个组件树之中,所以任何组件都无法在脱离组件树的情况下知道自身的大小和尺寸。
- 当子组件需要一个不同的大小并且父组件没有足够的关于如何排列它的信息时,子组件的大小会被忽略。所以,定义对齐方式时必须足够明确。
严密约束和宽松约束
约束也分为 tight 和 loose 的。宽松约束指组件的最大宽高确定,但是最小宽高为零。严密约束是指确定大小的约束,也可以理解为最大宽高和最小宽高相等。比如下面这个例子:
1 |
|
同样作为根布局,布局 A 会占满整个屏幕,因为 Container 传递的是 tight 约束,它告诉同样为 Container 的子组件,它的大小是整个屏幕,所以,即使子组件 Container 设置了大小也不会有效果。
而布局 B 中的 Center 作为根布局,它传递的是 loose 约束,所以只要子组件 Contaienr 的大小不超过屏幕大小就可以了,最小多小并不会有限制,所以它设置的宽高可以传递到父布局并设置生效。
内部 Container 的约束分别可以表示为:
1 |
|
因此,固定大小的组件 (比如 SizedBox) 也是严密约束的,其约束可以表示为:
1 |
|
「去除」父级约束
既然布局约束是向下传递的,有没有什么办法可以去除掉父布局的约束呢?理论上是不可以的,但是实际使用中,我们可以使用 UnconstraintedBox
,它可以使得子布局忽略父布局的约束:
1 |
|
上面的例子中,最外层的约束 A 不会传递给 B,最终 redBox 的高度会是 20。但这只是表面现象,其实 UnconstrainedBox
只是阻断了约束的传递,自身还是会受到父级约束的限制,因此最终整体布局的高度还是 100。
实际使用中,我们可以利用 UncontrainedBox
去除父组件的约束,比如如果使用 SizedBox
时指定了宽度或者高度,子组件就会有相同的宽度或高度,而如果想要去除这种限制,我们就可以使用 UnconstrainedBox
来包裹子组件。
如何学习组件的布局规则
Flutter 中组件众多,我们很难一下子记住所有的组件到底是如何布局的,所以最好的做法是阅读文档和源码。比如,如果你想阅读 Column
组件的源码,首先,我们通过 IDE 跳转到 basic.dart
文件,发现原来 Column
继承的是 Flex
类,在 Flex
的 createRenderObject()
方法中发现返回的是 RenderFlex
对象,最后在 flex.dart
文件中找到 performLayout()
方法,这里才是实际测量并计算子组件位置的地方。
常用组件
Basic Widget
MaterialApp, Scaffold, AppBar (material library)
CupertinoApp, CupertinoPageScaffold, CupertinoTabScaffold (cupertino library)
TextButton, ElevatedButton, OutlinedButton, IconButton
GestureDetector, InkWell/InkResponse
Layout Widget
Row, Column, Stack/Positioned, ListView, GridView, Scrollbar
SingleChildScrollView, CustomScrollView, SliverList, SliverGrid
DecoratedBox/BoxDecoration, ClipRect, ClipRRect, Transform
Expanded, Flexible, ConstrainedBox, SizedBox, Spacer
Flex, Wrap, Flow, Align/Alignment/FractionalOffset
Animation Widget
Animation, Tween, AnimationController, Curves, Transition, ImplicitlyAnimatedWidget
Others
其他组件的介绍见:Widget Catalog, Widget of the Week
自定义组件
大多数情况下,我们都可以通过组合创建出所需要的组件,但是对于一些特别复杂的组件,无法通过组合获得,此时就需要通过自定义的方式创造出我们想要的组件。
Flutter 中自定义组件的方式主要依赖于 CustomPaint
组件和自定义画笔 CustomerPainter
。
CustomPaint
用于使用自定义的画笔,其构造方法如下:
1 |
|
其中,painter
最先被绘制,然后是 child
,最后是 foregroudnPainter
。默认会以 child
作为可绘制区域,如果没有指定 child
则以 size
指定的大小作为可绘制区域,默认值为 Size.zero
。painter
默认情况下是可以超出可绘制区域的,如果要改变这种行为,可以使用 ClipRect
组件包裹 CustomerPaint
组件。
注意
为了防止不必要的重绘,可以使用 RepaintBoundary
包裹 CustomPaint
组件。
CustomPainter
实现自定义画笔的内容。主要通过在 Canvas
上绘制一系列图形,通过 Paint
指定画笔风格。
比如画一些常见的几何图形和文字:
1 |
|
Paint
画笔的使用与其它平台中类似,我们可以设置画笔风格(填充 or 描边)、描边宽度、颜色、反锯齿等。
1 |
|
Animation
Flutter 中对动画进行了抽象,主要涉及 Animation、Curve、Controller、Tween 四个部分。
Animation
Animation 是一个抽象类,主要的功能是保存动画的插值和状态。在动画的每一帧中,我们可以通过 Animation
对象的 value
属性获取动画的当前状态值,也可以在其上添加每一帧的监听器 (addListener) 和状态变化监听器 (addStatusListener)。
AnimationController
AnimationController
用于控制动画,它包含动画的启动 forward()
、停止 stop()
、反向播放 reverse()
、重复播放 repeat()
等方法。AnimationController
会在动画的每一帧生成一个新的值,范围默认是 [0, 1],可以通过 lowerBound
和 upperBound
进行指定。
创建 AnimationController
时需要传入一个 vsync
参数,它接收一个 TickerProvider
对象,它负责创建 Ticker
,用于在动画的每一帧提供回调。一般可以使用 mixin 类 SingleTickerProviderStateMixin
或者 TickerProviderStateMixin
来实现 TickerProvider
的功能。
Tween
默认情况下,AnimationController
控制的值的范围是数字区间 [0.0, 1.0],如果我们需要构建 UI 的动画值在不同的范围或者使用不同的数据类型,就可以通过 Tween
添加映射来生成不同范围或数据类型的值。
Curve
我们通过 Curve
(曲线)来描述动画过程,把匀速动画称为线性 (Curves.linear) 的,非匀速动画称为非线性的。
核心原理
Widget/Element/RenderObject
Flutter 中一切都是 Widget,但是所有 widget 最终的 layout 和渲染都是通过 RenderObject
来完成的,从创建到渲染大致的流程是:根据 Widget 树生成 Element 树,然后创建 RenderObject
并关联到 Element.renderObject
属性上,最后再通过 RenderObject
来完成布局和绘制。^注1
Element 是 Widget 在 UI 树中对应的实例对象,大多数 Element 只有一个 renderObject
,除了少部分有多个子节点。最终,所有 Element 的 renderObject
构成了一棵 Render Tree,即渲染树。
即在 Flutter 中主要有三棵树:Widget Tree => Element Tree => RenderObejct Tree(其实 Widget Tree 并不存在,只是我们看到的结果,只有 Element 和 RenderObject 才有上下级相互关联的关系)。
RenderObejct/RenderBox
每一个 Element 对应一个 RenderObject,它负责布局和绘制,但是它并没有定义子节点模型以及坐标系统,实际完成布局的(确定大小和位置)是通过 RenderBox。
BuildContext
即 Element
创建这个接口是为了避免开发者直接操作 Element 对象。
Flutter 应用是如何运行的
我们知道一个 Flutter 应用的入口是 main() 方法中的 runApp(),那么 runApp() 中主要做了哪些操作呢?
首先,通过 WidgetsFlutterBinding
绑定 Flutter 框架(widgets、services、gesture、painting 等)和 Flutter engine(sky_engine),WidgetsFlutterBinding
就像胶水一样,而绑定发生的位置是在 BindingBase
中的 window
。window
实际是一个 SingletonFlutterWindow
对象,它作为应用的主窗口,继承自 FlutterView
,并且提供了设备屏幕相关的信息以及设备设置的回调方法(比如语言、屏幕亮度等)。
1 |
|
紧接着会调用 WidgetsBinding
中的 attachRootWidget
方法,将根 Widget 添加到 renderViewElement
上,这是一个 RenderView
对象,它作为整个 RenderObejct Tree 的根 RenderObject
。
1 |
|
attachRootWidget
之后,会继续调用 scheduleWarmUpFrame
方法,调用该方法之后会通知框架尽早进行一次绘制,而不是等待 flutter 引擎接收到一次 Vsync
信号之后(正常情况下,每次接受到一次信号会触发一次重绘,也就是一帧,此外还会调用 FrameCallback
,Flutter 中一共有 3 种这样的帧回调)。另外,这个方法只会被调用一次,第二次调用无效。
1 |
|
在创建完 RenderObject
以及首帧绘制任务之后,接下来真正执行渲染和绘制任务的地方是在 RendererBinding
中的 drawFrame()
方法:
1 |
|
另外,在首帧绘制结束后,之后的绘制只有被标记为 dirty 的 Element 才会被重新绘制:
1 |
|
State Management
Provider
Provider 是基于 InheritedWidget 的状态管理工具,它的优点是便于理解且使用简单,主要由三个部分组成:
- ChangeNotifier: 提供数据,更新数据并发送通知
- ChangeNotifierProvider: 连接数据和组件
- Consumer: 消费事件,监听数据变化
ChangeNotifier
ChangeNotifier 封装了应用状态,使用观察者-被观察者模式,会通知注册处发生的变化。
1 |
|
ChangeNotifierProvider
ChangeNotifierProvider 用于给子组件提供一个 ChangeNotifier 实例。
1 |
|
Consumer
我们通过 Consumer 组件来消费 ChangeNotifier 产生的事件。
1 |
|
Consumer 的 build 函数一共接收三个参数,第一个参数是 context,第二个参数是 ChangeNotifier 对象,也就是我们需要监听的目标对象,第三个是 child,它的作用是优化性能,比如当一个组件不是每次状态发生更新时都要更新 UI 时,此时就可以根据状态来决定是否需要重新创建。其次,最好的做法是将 Consumer 组件放在组件树底部,这样可以尽可能减少因状态发生变化而需要的组件更新。
另外,我们也可以使用 Provider
提供的扩展方法 context.watch<T>()
来获得 ChangeNotifier 并触发 rebuild,不过,该方法只能在 build 方法中使用。
Provider.of<T>()
当你只需要访问 ChangeNotifier 中的方法或者属性而不需要更新 UI 时,可以使用 Provider.of
:
1 |
|
通过这种方式可以访问到 CartModel
中的方法,但是在数据发生变化时不会收到通知。
另外,也可以使用 context.read<T>()
,它也能获取到 ChangeNotifier,不过只能在 build 方法之外才能被调用(比如在 onPressed 方法中),所以也不会触发 rebuild,而使用 Provider.of<T>(context, listen: false)
则没有这样的限制。
select<T, R>(R Function(T value) selector)
当我们只需要监听 ChangeNotifier 中部分发生变化的内容时,就可以使用 select 方法。与 watch 方法类似,select 只有在 build 方法中才能被使用。
1 |
|
完整例子
使用 Provider 实现一个 Counter 的例子:
1 |
|
Others
调试
Flutter 提供了丰富的调试工具,比如 Flutter Inspector, Flutter Outline, Flutter Performance 等。
异常捕获
Flutter 中,发生异常时不会导致程序退出,只会导致当前任务的后续代码不再继续执行,也就是说一个任务中的异常是不会影响其它任务的执行的。下面是在 Flutter 中捕获异常的简单演示:
1 |
|
具体的异常上报的例子见:Report errors to a service
平台相关
Platform Channel
当我们需要执行平台相关的代码时,就需要借助于 platform channels。工作流程如下:
首先需要在项目中创建 MethodChannel:
1 |
|
接下来,在目标平台创建对应的 MethodChannel,并且对方法调用进行处理。以安卓平台为例:
1 |
|
假如我们需要监听方法调用结果,则需要通过 EventChannel 和 EventSink,前者用于注册事件监听,后者用于发送事件。
同样的,我们需要先在 dart 部分创建 EventChannel:
1 |
|
然后在平台部分创建 EventChannel 并对事件流进行监听并处理,以安卓为例:
1 |
|
向 dart 端传递事件调用结果:
1 |
|
安卓插件安装流程
安卓项目下的 app/build.gradle
中有一行:
1 |
|
该文件的位置在:$FLUTTER_HOME/packages/flutter_tools/gradle/flutter.gradle
,其中添加项目中的依赖的方法调用路径是:apply() -> addFlutterTasks() -> configurePlugins() -> getPluginList() -> configurePluginProject() { api pluginProject }
。
关键的一步是在 getPluginList()
中,通过读取项目根目录下生成的 .flutter-plugins
文件获取插件列表,然后把它们添加到 android 项目中。