Flutter技术与实战(4)

Flutter技术与实战(4)

八归少年 3,658 2020-08-10

Flutter基础

Widget,构建Flutter界面的基石。

  • Widget 是 Flutter 功能的抽象描述,是视图的配置信息,同样也是数据的映射,是 Flutter 开发框架中最基本的概念。前端框架中常见的名词,比如视图(View)、视图控制器(View Controller)、活动(Activity)、应用(Application)、布局(Layout)等,在 Flutter 中都是 Widget。
Widget渲染过程
  • 通常情况下,不同的 UI 框架中会以不同的方式去处理这一问题,但无一例外地都会用到视图树(View Tree)的概念。而 Flutter 将视图树的概念进行了扩展,把视图数据的组织和渲染抽象为三部分,即 Widget,Element 和 RenderObject。

Widget、Element与RenderObject

Widget
  • Widget 是 Flutter 世界里对视图的一种结构化描述,你可以把它看作是前端中的“控件”或“组件”。Widget 是控件实现的基本逻辑单位,里面存储的是有关视图渲染的配置信息,包括布局、渲染属性、事件响应信息等。
  • Flutter 将 Widget 设计成不可变的,所以当视图渲染的配置信息发生变化时,Flutter 会选择重建 Widget 树的方式进行数据更新,以数据驱动 UI 构建的方式简单高效。
  • 但,这样做的缺点是,因为涉及到大量对象的销毁和重建,所以会对垃圾回收造成压力。不过,Widget 本身并不涉及实际渲染位图,所以它只是一份轻量级的数据结构,重建的成本很低。
  • 另外,由于 Widget 的不可变性,可以以较低成本进行渲染节点复用,因此在一个真实的渲染树中可能存在不同的 Widget 对应同一个渲染节点的情况,这无疑又降低了重建 UI 的成本。
Element
  • Element 是 Widget 的一个实例化对象,它承载了视图构建的上下文数据,是连接结构化的配置信息到完成最终渲染的桥梁。
  • Flutter 渲染过程,可以分为这么三步:
    • 首先,通过 Widget 树生成对应的 Element 树;
    • 然后,创建相应的 RenderObject 并关联到 Element.renderObject 属性上;
    • 最后,构建成 RenderObject 树,以完成最终的渲染。
  • Element 同时持有 Widget 和 RenderObject。而无论是 Widget 还是 Element,其实都不负责最后的渲染,只负责发号施令,真正去干活儿的只有 RenderObject。
  • 增加中间的这层 Element 树,不直接由 Widget 命令 RenderObject,这样做会极大地减少渲染带来的性能损耗。
  • Element 树存在的意义。因为 Widget 具有不可变性,但 Element 却是可变的。实际上,Element 树这一层将 Widget 树的变化(类似 React 虚拟 DOM diff)做了抽象,可以只将真正需要修改的部分同步到真实的 RenderObject 树中,最大程度降低对真实渲染视图的修改,提高渲染效率,而不是销毁整个渲染视图树重建。
RenderObject
  • RenderObject 是主要负责实现视图渲染的对象。
  • Flutter 通过控件树(Widget 树)中的每个控件(Widget)创建不同类型的渲染对象,组成渲染对象树。
  • 而渲染对象树在 Flutter 的展示过程分为四个阶段,即布局、绘制、合成和渲染。 其中,布局和绘制在 RenderObject 中完成,Flutter 采用深度优先机制遍历渲染对象树,确定树中各个对象的位置和尺寸,并把它们绘制到不同的图层上。绘制完毕后,合成和渲染的工作则交给 Skia 搞定。
  • Flutter 通过引入 Widget、Element 与 RenderObject 这三个概念,把原本从视图数据到视图渲染的复杂构建过程拆分得更简单、直接,在易于集中治理的同时,保证了较高的渲染效率。
RenderObjectWidget 介绍
  • StatelessWidget 和 StatefulWidget 只是用来组装控件的容器,并不负责组件最后的布局和绘制。在 Flutter 中,布局和绘制工作实际上是在 Widget 的另一个子类 RenderObjectWidget 内完成的。
//RenderObjectWidget 的源码
abstract class RenderObjectWidget extends Widget {
  @override
  RenderObjectElement createElement();
  @protected
  RenderObject createRenderObject(BuildContext context);
  @protected
  void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { }
  ...
}
  • 实际上,RenderObjectWidget 本身并不负责这些对象的创建与更新。
  • 对于 Element 的创建,Flutter 会在遍历 Widget 树时,调用 createElement 去同步 Widget 自身配置,从而生成对应节点的 Element 对象。而对于 RenderObject 的创建与更新,其实是在 RenderObjectElement 类中完成的。
abstract class RenderObjectElement extends Element {
  RenderObject _renderObject;

  @override
  void mount(Element parent, dynamic newSlot) {
    super.mount(parent, newSlot);
    _renderObject = widget.createRenderObject(this);
    attachRenderObject(newSlot);
    _dirty = false;
  }
   
  @override
  void update(covariant RenderObjectWidget newWidget) {
    super.update(newWidget);
    widget.updateRenderObject(this, renderObject);
    _dirty = false;
  }
  ...
}
  • 在 Element 创建完毕后,Flutter 会调用 Element 的 mount 方法。在这个方法里,会完成与之关联的 RenderObject 对象的创建,以及与渲染树的插入工作,插入到渲染树后的 Element 就可以显示到屏幕中了。
  • 如果 Widget 的配置数据发生了改变,那么持有该 Widget 的 Element 节点也会被标记为 dirty。在下一个周期的绘制时,Flutter 就会触发 Element 树的更新,并使用最新的 Widget 数据更新自身以及关联的 RenderObject 对象,接下来便会进入 Layout 和 Paint 的流程。而真正的绘制和布局过程,则完全交由 RenderObject 完成。
abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
  ...
  void layout(Constraints constraints, { bool parentUsesSize = false }) {...}
  
  void paint(PaintingContext context, Offset offset) { }
}
  • 布局和绘制完成后,接下来的事情就交给 Skia 了。在 VSync 信号同步时直接从渲染树合成 Bitmap,然后提交给 GPU。
案例展示
界面示例
  • 在 Flutter 遍历完 Widget 树,创建了各个子 Widget 对应的 Element 的同时,也创建了与之关联的、负责实际布局和绘制的 RenderObject。

示例界面生成的“三棵树”

Widget中的State到底是什么

  • StatefulWidget 应对有交互、需要动态变化视觉效果的场景,而 StatelessWidget 则用于处理静态的、无状态的视图展示。
UI编程范式
  • 要想理解 StatelessWidget 与 StatefulWidget 的使用场景,我们首先需要了解,在 Flutter 中,如何调整一个控件(Widget)的展示样式,即 UI 编程范式。
  • 对于Android、IOS或原生JavaScript开发者来说,视图开发是命令式的,需要精确地告诉操作系统或浏览器用何种方式去做事情。比如,如果我们想要变更界面的某个文案,则需要找到具体的文本控件并调用它的控件方法命令,才能完成文字变更。
// Android设置某文本控件展示文案为Hello World
TextView textView = (TextView) findViewById(R.id.txt);
textView.setText("Hello World");

// iOS设置某文本控件展示文案为Hello World
UILabel *label = (UILabel *)[self.view viewWithTag:1234];
label.text = @"Hello World";

// 原生JavaScript设置某文本控件展示文案为Hello World
document.querySelector("#demo").innerHTML = "Hello World!";
  • Flutter 的视图开发是声明式的,其核心设计思想就是将视图和数据分离,这与 React 的设计思路完全一致。
  • 总结来说,命令式编程强调精确控制过程细节;而声明式编程强调通过意图输出结果整体。
  • 对应到 Flutter 中,意图是绑定了组件状态的 State,结果则是重新渲染后的组件。在 Widget 的生命周期内,应用到 State 中的任何更改都将强制 Widget 重新构建。
  • 当你所要构建的用户界面不随任何状态信息的变化而变化时,需要选择使用 StatelessWidget,反之则选用 StatefulWidget。前者一般用于静态内容的展示,而后者则用于存在交互反馈的内容呈现中。
StateLessWidget
  • 在 Flutter 中,Widget 采用由父到子、自顶向下的方式进行构建,父 Widget 控制着子 Widget 的显示样式,其样式配置由父 Widget 在构建时提供。
  • 用这种方式构建出的 Widget,有些(比如 Text、Container、Row、Column 等)在创建时,除了这些配置参数之外不依赖于任何其他信息,换句话说,它们一旦创建成功就不再关心、也不响应任何数据变化进行重绘。在 Flutter 中,这样的 Widget 被称为 StatelessWidget(无状态组件)。
StateLessWidget示意图
* 以 Text 的部分源码为例,说明 StatelessWidget 的构建过程。
class Text extends StatelessWidget {     
  //构造方法及属性声明部分
  const Text(this.data, {
    Key key,
    this.textAlign,
    this.textDirection,
    //其他参数
    ...
  }) : assert(data != null),
     textSpan = null,
     super(key: key);
     
  final String data;
  final TextAlign textAlign;
  final TextDirection textDirection;
  //其他属性
  ...
  
  @override
  Widget build(BuildContext context) {
    ...
    Widget result = RichText(
       //初始化配置
       ...
      )
    );
    ...
    return result;
  }
}
  • 什么场景下应该使用 StatelessWidget ?父 Widget 是否能通过初始化参数完全控制其 UI 展示效果?如果能,那么我们就可以使用 StatelessWidget 来设计构造函数接口了。
StatefulWidget
  • 与 StatelessWidget 相对应的,有一些 Widget(比如 Image、Checkbox)的展示,除了父 Widget 初始化时传入的静态配置之外,还需要处理用户的交互(比如,用户点击按钮)或其内部数据的变化(比如,网络数据回包),并体现在 UI 上。
  • 换句话说,这些 Widget 创建完成后,还需要关心和响应数据变化来进行重绘。在 Flutter 中,这一类 Widget 被称为 StatefulWidget(有状态组件)。
StatefulWidget示意图
* StatefulWidget 是以 State 类代理 Widget 构建的设计方式实现的。接下来,以 Image 的部分源码为例,说明 StatefulWidget 的构建过程。
class Image extends StatefulWidget {
  //构造方法及属性声明部分
  const Image({
    Key key,
    @required this.image,
    //其他参数
  }) : assert(image != null),
       super(key: key);

  final ImageProvider image;
  //其他属性
  ...
  
  @override
  _ImageState createState() => _ImageState();
  ...
}

class _ImageState extends State<Image> {
  ImageInfo _imageInfo;
  //其他属性
  ...

  void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) {
    setState(() {
      _imageInfo = imageInfo;
    });
  }
  ...
  @override
  Widget build(BuildContext context) {
    final RawImage image = RawImage(
      image: _imageInfo?.image,
      //其他初始化配置
      ...
    );
    return image;
  }
 ...
}
  • Image 以一种动态的方式运行:监听变化,更新视图。与 StatelessWidget 通过父 Widget 完全控制 UI 展示不同,StatefulWidget 的父 Widget 仅定义了它的初始化状态,而其自身视图运行的状态则需要自己处理,并根据处理情况即时更新 UI 展示。
StatefulWidget 不是万金油,要慎用
  • 对于 UI 框架而言,同样的展示效果一般可以通过多种控件实现。从定义来看,StatefulWidget 仿佛是万能的,替代 StatelessWidget 看起来合情合理。于是 StatefulWidget 的滥用,也容易因此变得顺理成章,难以避免。但事实是,StatefulWidget 的滥用会直接影响 Flutter 应用的渲染性能。

Widget 是不可变的,更新则意味着销毁 + 重建(build)。StatelessWidget 是静态的,一旦创建则无需更新;而对于 StatefulWidget 来说,在 State 类中调用 setState 方法更新数据,会触发视图的销毁和重建,也将间接地触发其每个子 Widget 的销毁和重建。

  • 如果我们的根布局是一个 StatefulWidget,在其 State 中每调用一次更新 UI,都将是一整个页面所有 Widget 的销毁和重建。
  • 正确评估你的视图展示需求,避免无谓的 StatefulWidget 使用,是提高 Flutter 应用渲染性能最简单也是最直接的手段。

需要注意的是,除了我们主动地通过 State 刷新 UI 之外,在一些特殊场景下,Widget 的 build 方法有可能会执行多次。因此,我们不应该在这个方法内部,放置太多有耗时的操作。

反思:build执行多次,通过接口获取表单数据,不要在build里写耗时方法,外部处理传入一个变量即可!

生命周期

  • 从 Widget(的 State)和 App 这两个维度,介绍它们的生命周期。
State生命周期
  • State 的生命周期,指的是在用户参与的情况下,其关联的 Widget 所经历的,从创建到显示再到更新最后到停止,直至销毁等各个过程阶段。
  • 这些不同的阶段涉及到特定的任务处理,因此为了写出一个体验和性能良好的控件,正确理解 State 的生命周期至关重要。
State生命周期图
  • State 的生命周期可以分为 3 个阶段:创建(插入视图树)、更新(在视图树中存在)、销毁(从视图树中移除)。接下来,我们一起看看每一个阶段的具体流程。
创建
  • State 初始化时会依次执行 :构造方法 -> initState -> didChangeDependencies -> build,随后完成页面渲染。看一下初始化过程中每个方法的意义。
    • 构造方法是 State 生命周期的起点,Flutter 会通过调用 StatefulWidget.createState() 来创建一个 State。我们可以通过构造方法,来接收父 Widget 传递的初始化 UI 配置数据。这些配置数据,决定了 Widget 最初的呈现效果。
    • initState,会在 State 对象被插入视图树的时候调用。这个函数在 State 的生命周期中只会被调用一次,所以我们可以在这里做一些初始化工作,比如为状态变量设定默认值。
    • didChangeDependencies 则用来专门处理 State 对象依赖关系变化,会在 initState() 调用结束后,被 Flutter 调用。
    • build,作用是构建视图。经过以上步骤,Framework 认为 State 已经准备好了,于是调用 build。我们需要在这个函数中,根据父 Widget 传递过来的初始化配置数据,以及 State 的当前状态,创建一个 Widget 然后返回。
更新
  • Widget 的状态更新,主要由 3 个方法触发:setState、didchangeDependencies 与 didUpdateWidget。这三个方法分别会在什么场景下调用。
    • setState:我们最熟悉的方法之一。当状态数据发生变化时,我们总是通过调用这个方法告诉 Flutter:“我这儿的数据变啦,请使用更新后的数据重建 UI!”
    • didChangeDependencies:State 对象的依赖关系发生变化后,Flutter 会回调这个方法,随后触发组件构建。哪些情况下 State 对象的依赖关系会发生变化呢?典型的场景是,系统语言 Locale 或应用主题改变时,系统会通知 State 执行 didChangeDependencies 回调方法。
    • didUpdateWidget:当 Widget 的配置发生变化时,比如,父 Widget 触发重建(即父 Widget 的状态发生变化时),热重载时,系统会调用这个函数。
  • 一旦这三个方法被调用,Flutter 随后就会销毁老 Widget,并调用 build 方法重建 Widget。
销毁
  • 组件销毁相对比较简单。比如组件被移除,或是页面销毁的时候,系统会调用 deactivate 和 dispose 这两个方法,来移除或销毁组件。

    • 当组件的可见状态发生变化时,deactivate 函数会被调用,这时 State 会被暂时从视图树中移除。值得注意的是,页面切换时,由于 State 对象在视图树中的位置发生了变化,需要先暂时移除后再重新添加,重新触发组件构建,因此这个函数也会被调用。
    • 当 State 被永久地从视图树中移除时,Flutter 会调用 dispose 函数。而一旦到这个阶段,组件就要被销毁了,所以我们可以在这里进行最终的资源释放、移除监听、清理环境,等等。

    常见场景下State生命周期图

  • 左边部分展示了当父 Widget 状态发生变化时,父子双方共同的生命周期;而中间和右边部分则描述了页面切换时,两个关联的 Widget 的生命周期函数是如何响应的。State生命周期中的方法调用对比如图。

State生命周期中的方法调用对比
##### App生命周期
  • 视图的生命周期,定义了视图的加载到构建的全过程,其回调机制能够确保我们可以根据视图的状态选择合适的时机做恰当的事情。而 App 的生命周期,则定义了 App 从启动到退出的全过程。
  • 在原生 Android、iOS 开发中,有时我们需要在对应的 App 生命周期事件中做相应处理,比如 App 从后台进入前台、从前台退到后台,或是在 UI 绘制完成后做一些处理。在 Flutter 中,我们可以利用 WidgetsBindingObserver 类,来实现同样的需求。
abstract class WidgetsBindingObserver {
  //页面pop
  Future<bool> didPopRoute() => Future<bool>.value(false);
  //页面push
  Future<bool> didPushRoute(String route) => Future<bool>.value(false);
  //系统窗口相关改变回调,如旋转
  void didChangeMetrics() { }
  //文本缩放系数变化
  void didChangeTextScaleFactor() { }
  //系统亮度变化
  void didChangePlatformBrightness() { }
  //本地化语言变化
  void didChangeLocales(List<Locale> locale) { }
  //App生命周期变化
  void didChangeAppLifecycleState(AppLifecycleState state) { }
  //内存警告回调
  void didHaveMemoryPressure() { }
  //Accessibility相关特性回调
  void didChangeAccessibilityFeatures() {}
}
  • App 生命周期的回调 didChangeAppLifecycleState,和帧绘制回调 addPostFrameCallback 与 addPersistentFrameCallback。
生命周期回调
  • didChangeAppLifecycleState 回调函数中,有一个参数类型为 AppLifecycleState 的枚举类,这个枚举类是 Flutter 对 App 生命周期状态的封装。它的常用状态包括 resumed、inactive、paused 这三个。

    • resumed:可见的,并能响应用户的输入。
    • inactive:处在不活动状态,无法处理用户响应。
    • paused:不可见并不能响应用户的输入,但是在后台继续活动中。
    class _MyHomePageState extends State<MyHomePage>  with WidgetsBindingObserver{
    ...
      @override
      @mustCallSuper
      void initState() {
        super.initState();
        WidgetsBinding.instance.addObserver(this);//注册监听器
      }
      @override
      @mustCallSuper
      void dispose(){
        super.dispose();
        WidgetsBinding.instance.removeObserver(this);//移除监听器
      }
      @override
      void didChangeAppLifecycleState(AppLifecycleState state) async {
        print("$state");
        if (state == AppLifecycleState.resumed) {
          //do sth
        }
      }
    }
    
  • 试着切换一下前、后台,观察控制台输出的 App 状态,可以发现:

    • 从后台切入前台,控制台打印的 App 生命周期变化如下: AppLifecycleState.paused->AppLifecycleState.inactive->AppLifecycleState.resumed;
    • 从前台退回后台,控制台打印的 App 生命周期变化则变成了:AppLifecycleState.resumed->AppLifecycleState.inactive->AppLifecycleState.paused。
  • 可以看到,App 前后台切换过程中打印出的状态是完全符合预期的。

App前后台切换变化
###### 帧绘制回调
  • 除了需要监听 App 的生命周期回调做相应的处理之外,有时候我们还需要在组件渲染之后做一些与显示安全相关的操作。在 Android 开发中,我们可以通过 View.post() 插入消息队列,来保证在组件渲染后进行相关操作。

  • 在 Flutter 中实现同样的需求会更简单:依然使用万能的 WidgetsBinding 来实现。

  • WidgetsBinding 提供了单次 Frame 绘制回调,以及实时 Frame 绘制回调两种机制,来分别满足不同的需求。

    • 单次 Frame 绘制回调,通过 addPostFrameCallback 实现。它会在当前 Frame 绘制完成后进行进行回调,并且只会回调一次,如果要再次监听则需要再设置一次。
    WidgetsBinding.instance.addPostFrameCallback((_){
        print("单次Frame绘制回调");//只回调一次
      });
    
    • 实时 Frame 绘制回调,则通过 addPersistentFrameCallback 实现。这个函数会在每次绘制 Frame 结束后进行回调,可以用做 FPS 监测。
    WidgetsBinding.instance.addPersistentFrameCallback((_){
      print("实时Frame绘制回调");//每帧都回调
    });
    

经典控件(一):文本、图片和按钮

文本控件
  • 文本是视图系统中的常见控件,用来显示一段特定样式的字符串,就比如 Android 里的 TextView、iOS 中的 UILabel。而在 Flutter 中,文本展示是通过 Text 控件实现的。
  • Text 支持两种类型的文本展示,一个是默认的展示单一样式的文本 Text,另一个是支持多种混合样式的富文本 Text.rich。
  • 单一样式文本 Text 的初始化,是要传入需要展示的字符串。而这个字符串的具体展示效果,受构造函数中的其他参数控制。这些参数大致可以分为两类:
    • 控制整体文本布局的参数,如文本对齐方式 textAlign、文本排版方向 textDirection,文本显示最大行数 maxLines、文本截断规则 overflow 等等,这些都是构造函数中的参数;
    • 控制文本展示样式的参数,如字体名称 fontFamily、字体大小 fontSize、文本颜色 color、文本阴影 shadows 等等,这些参数被统一封装到了构造函数中的参数 style 中。
Text(
  '文本是视图系统中的常见控件,用来显示一段特定样式的字符串,就比如Android里的TextView,或是iOS中的UILabel。',
  textAlign: TextAlign.center,//居中显示
  style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red),//20号红色粗体展示
);
  • 混合展示样式与单一样式的关键区别在于分片,即如何把一段字符串分为几个片段来管理,给每个片段单独设置样式。面对这样的需求,在 Android 中,我们使用 SpannableString 来实现;在 iOS 中,我们使用 NSAttributedString 来实现;而在 Flutter 中也有类似的概念,即 TextSpan。
  • TextSpan 定义了一个字符串片段该如何控制其展示样式,而将这些有着独立展示样式的字符串组装在一起,则可以支持混合样式的富文本展示。
TextStyle blackStyle = TextStyle(fontWeight: FontWeight.normal, fontSize: 20, color: Colors.black); //黑色样式

TextStyle redStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red); //红色样式

Text.rich(
    TextSpan(
        children: <TextSpan>[
          TextSpan(text:'文本是视图系统中常见的控件,它用来显示一段特定样式的字符串,类似', style: redStyle), //第1个片段,红色样式 
          TextSpan(text:'Android', style: blackStyle), //第1个片段,黑色样式 
          TextSpan(text:'中的', style:redStyle), //第1个片段,红色样式 
          TextSpan(text:'TextView', style: blackStyle) //第1个片段,黑色样式 
        ]),
  textAlign: TextAlign.center,
);
图片
  • 使用 Image,可以让我们向用户展示一张图片。图片的显示方式有很多,比如资源图片、网络图片、文件图片等,图片格式也各不相同,因此在 Flutter 中也有多种方式,用来加载不同形式、支持不同格式的图片。
    • 加载本地资源图片,如 Image.asset(‘images/logo.png’);
    • 加载本地(File 文件)图片,如 Image.file(new File(’/storage/xxx/xxx/test.jpg’));
    • 加载网络图片,如 Image.network('http://xxx/xxx/test.gif') 。
  • 除了可以根据图片的显示方式设置不同的图片源之外,图片的构造方法还提供了填充模式 fit、拉伸模式 centerSlice、重复模式 repeat 等属性,可以针对图片与目标区域的宽高比差异制定排版模式。
  • Flutter 中的 FadeInImage 控件。在加载网络图片的时候,为了提升用户的等待体验,我们往往会加入占位图、加载动画等元素,但是默认的 Image.network 构造方法并不支持这些高级功能,这时候 FadeInImage 控件就派上用场了。
  • FadeInImage 控件提供了图片占位的功能,并且支持在图片加载完成时淡入淡出的视觉效果。此外,由于 Image 支持 gif 格式,我们甚至还可以将一些炫酷的加载动画作为占位图。
FadeInImage.assetNetwork(
  placeholder: 'assets/loading.gif', //gif占位
  image: 'https://xxx/xxx/xxx.jpg',
  fit: BoxFit.cover, //图片拉伸模式
  width: 200,
  height: 200,
)
  • Image 控件需要根据图片资源异步加载的情况,决定自身的显示效果,因此是一个 StatefulWidget。图片加载过程由 ImageProvider 触发,而 ImageProvider 表示异步获取图片数据的操作,可以从资源、文件和网络等不同的渠道获取图片。
  • 首先,ImageProvider 根据 _ImageState 中传递的图片配置生成对应的图片缓存 key;然后,去 ImageCache 中查找是否有对应的图片缓存,如果有,则通知 _ImageState 刷新 UI;如果没有,则启动 ImageStream 开始异步加载,加载完毕后,更新缓存;最后,通知 _ImageState 刷新 UI。
图片加载流程
  • ImageCache 使用 LRU(Least Recently Used,最近最少使用)算法进行缓存更新策略,并且默认最多存储 1000 张图片,最大缓存限制为 100MB,当限定的空间已存满数据时,把最久没有被访问到的图片清除。图片缓存只会在运行期间生效,也就是只缓存在内存中。如果想要支持缓存到文件系统,可以使用第三方的CachedNetworkImage控件。

  • CachedNetworkImage 的使用方法与 Image 类似,除了支持图片缓存外,还提供了比 FadeInImage 更为强大的加载过程占位与加载错误占位,可以支持比用图片占位更灵活的自定义控件占位。

CachedNetworkImage(
        imageUrl: "http://xxx/xxx/jpg",
        placeholder: (context, url) => CircularProgressIndicator(),
    	//错误图兜底,以备图片加载出错
        errorWidget: (context, url, error) => Icon(Icons.error),
     )
按钮
  • 通过按钮,我们可以响应用户的交互事件。Flutter 提供了三个基本的按钮控件,即 FloatingActionButton、FlatButton 和 RaisedButton。
    • FloatingActionButton:一个圆形的按钮,一般出现在屏幕内容的前面,用来处理界面中最常用、最基础的用户动作。
    • RaisedButton:凸起的按钮,默认带有灰色背景,被点击后灰色背景会加深。
    • FlatButton:扁平化的按钮,默认透明背景,被点击后会呈现灰色背景。
  • 这三个按钮控件的使用方法类似,唯一的区别只是默认样式不同而已。
FloatingActionButton(onPressed: () => print('FloatingActionButton pressed'),child: Text('Btn'),);
FlatButton(onPressed: () => print('FlatButton pressed'),child: Text('Btn'),);
RaisedButton(onPressed: () => print('RaisedButton pressed'),child: Text('Btn'),);
  • 既然是按钮,因此除了控制基本样式之外,还需要响应用户点击行为。这就对应着按钮控件中的两个最重要的参数了:
    • onPressed 参数用于设置点击回调,告诉 Flutter 在按钮被点击时通知我们。如果 onPressed 参数为空,则按钮会处于禁用状态,不响应用户点击。
    • child 参数用于设置按钮的内容,告诉 Flutter 控件应该长成什么样,也就是控制着按钮控件的基本样式。child 可以接收任意的 Widget,比如我们在上面的例子中传入的 Text,除此之外我们还可以传入 Image 等控件。
  • 通常情况下,我们还是会进行控件样式定制。与 Text 控件类似,按钮控件也提供了丰富的样式定制功能,比如背景颜色 color、按钮形状 shape、主题颜色 colorBrightness,等等。
FlatButton(
    color: Colors.yellow, //设置背景色为黄色
    shape:BeveledRectangleBorder(borderRadius: BorderRadius.circular(20.0)), //设置斜角矩形边框
    colorBrightness: Brightness.light, //确保文字按钮为深色
    onPressed: () => print('FlatButton pressed'), 
    child: Row(children: <Widget>[Icon(Icons.add), Text("Add")],)
);

经典控件(二):ListView与CustomScrollView

  • 当元素的排列布局超过屏幕显示尺寸(即超过一屏)时,我们就需要引入列表控件来展示视图的完整内容,并根据元素的多少进行自适应滚动展示。
  • 在 Android 中是由 ListView 或 RecyclerView 实现的,在 iOS 中是用 UITableView 实现的;而在 Flutter 中,实现这种需求的则是列表控件 ListView。
ListView
  • 在 Flutter 中,ListView 可以沿一个方向(垂直或水平方向)来排列其所有子 Widget,因此常被用于需要展示一组连续视图元素的场景,比如通信录、优惠券、商家列表等。
  • ListView 提供了一个默认构造函数 ListView,我们可以通过设置它的 children 参数,很方便地将所有的子 Widget 包含到 ListView 中。
  • 不过,这种创建方式要求提前将所有子 Widget 一次性创建好,而不是等到它们真正在屏幕上需要显示时才创建,所以有一个很明显的缺点,就是性能不好。因此,这种方式仅适用于列表中含有少量元素的场景。
ListView(
  children: <Widget>[
    //设置ListTile组件的标题与图标 
    ListTile(leading: Icon(Icons.map),  title: Text('Map')),
    ListTile(leading: Icon(Icons.mail), title: Text('Mail')),
    ListTile(leading: Icon(Icons.message), title: Text('Message')),
  ]);

备注:ListTile 是 Flutter 提供的用于快速构建列表项元素的一个小组件单元,用于 1~3 行(leading、title、subtitle)展示文本、图标等视图元素的场景,通常与 ListView 配合使用。上面这段代码中用到 ListTile,是为了演示 ListView 的能力。

  • 除了默认的垂直方向布局外,ListView 还可以通过设置 scrollDirection 参数支持水平方向布局。如下所示,我定义了一组不同颜色背景的组件,将它们的宽度设置为 140,并包在了水平布局的 ListView 中,让它们可以横向滚动。
ListView(
    scrollDirection: Axis.horizontal,
    itemExtent: 140, //item延展尺寸(宽度)
    children: <Widget>[
      Container(color: Colors.black),
      Container(color: Colors.red),
      Container(color: Colors.blue),
      Container(color: Colors.green),
      Container(color: Colors.yellow),
      Container(color: Colors.orange),
    ]);
  • 考虑到创建子 Widget 产生的性能问题,更好的方法是抽象出创建子 Widget 的方法,交由 ListView 统一管理,在真正需要展示该子 Widget 时再去创建。

  • ListView 的另一个构造函数 ListView.builder,则适用于子 Widget 比较多的场景。这个构造函数有两个关键参数:

    • itemBuilder,是列表项的创建方法。当列表滚动到相应位置时,ListView 会调用该方法创建对应的子 Widget。
    • itemCount,表示列表项的数量,如果为空,则表示 ListView 为无限列表。
ListView.builder(
    itemCount: 100, //元素个数
    itemExtent: 50.0, //列表项高度
    itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index"))
);
  • itemExtent 并不是一个必填参数。但对于定高的列表项元素,建议提前设置好这个参数的值。
  • 但如果提前设置好 itemExtent,ListView 则可以提前计算好每一个列表项元素的相对位置,以及自身的视图高度,省去了无谓的计算。
  • 因此,在 ListView 中,指定 itemExtent 比让子 Widget 自己决定自身高度会更高效。
  • 在 ListView 中,有两种方式支持分割线:
    • 一种是,在 itemBuilder 中,根据 index 的值动态创建分割线,也就是将分割线视为列表项的一部分;
    • 另一种是,使用 ListView 的另一个构造方法 ListView.separated,单独设置分割线的样式。
//使用ListView.separated设置分割线
ListView.separated(
    itemCount: 100,
    separatorBuilder: (BuildContext context, int index) => index %2 ==0? Divider(color: Colors.green) : Divider(color: Colors.red),//index为偶数,创建绿色分割线;index为奇数,则创建红色分割线
    itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index"))//创建子Widget
)
  • ListView常见的构造方法及其适用场景。
ListView常见构造方法及适用场景
##### CustomScrollView
  • 对于某些特殊交互场景,比如多个效果联动、嵌套滚动、精细滑动、视图跟随手势操作等,还需要嵌套多个 ListView 来实现。这时,各自视图的滚动和布局模型就是相互独立、分离的,就很难保证整个页面统一一致的滑动效果。
  • Flutter 是如何解决多 ListView 嵌套时,页面滑动效果不一致的问题的呢?在 Flutter 中有一个专门的控件 CustomScrollView,用来处理多个需要自定义滚动效果的 Widget。在 CustomScrollView 中,这些彼此独立的、可滚动的 Widget 被统称为 Sliver。
  • 视差滚动是指让多层背景以不同的速度移动,在形成立体滚动效果的同时,还能保证良好的视觉体验。 作为移动应用交互设计的热点趋势,越来越多的移动应用使用了这项技术。
CustomScrollView(
  slivers: <Widget>[
    SliverAppBar(//SliverAppBar作为头图控件
      title: Text('CustomScrollView Demo'),//标题
      floating: true,//设置悬浮样式
      flexibleSpace: Image.network("https://xx.jpg",fit:BoxFit.cover),//设置悬浮头图背景
      expandedHeight: 300,//头图控件高度
    ),
    SliverList(//SliverList作为列表控件
      delegate: SliverChildBuilderDelegate(
            (context, index) => ListTile(title: Text('Item #$index')),//列表项创建方法
        childCount: 100,//列表元素个数
      ),
    ),
  ]);
  • 实现效果类似于可折叠式标题栏。例如QQ好友动态头部效果!
ScrollController与ScrollNotification
ScrollController
  • 在某些情况下,我们希望获取视图的滚动信息,并进行相应的控制。比如,列表是否已经滑到底(顶)了?如何快速回到列表顶部?列表滚动是否已经开始,或者是否已经停下来了?对于前两个问题,我们可以使用 ScrollController 进行滚动信息的监听,以及相应的滚动控制;而最后一个问题,则需要接收 ScrollNotification 通知进行滚动事件的获取。
  • 在 Flutter 中,因为 Widget 并不是渲染到屏幕的最终视觉元素(RenderObject 才是),所以我们无法像原生的 Android 或 iOS 系统那样,向持有的 Widget 对象获取或设置最终渲染相关的视觉信息,而必须通过对应的组件控制器才能实现。
  • ListView 的组件控制器则是 ScrollControler,我们可以通过它来获取视图的滚动信息,更新视图的滚动位置。一般而言,获取视图的滚动信息往往是为了进行界面的状态控制,因此 ScrollController 的初始化、监听及销毁需要与 StatefulWidget 的状态保持同步。
/**
 * 声明了一个有着 100 个元素的列表项,当滚动视图到特定位置后,用户可以点击按钮返回列表顶部:
 */
class MyAPPState extends State<MyApp> {
  ScrollController _controller;//ListView控制器
  bool isToTop = false;//标示目前是否需要启用"Top"按钮
  @override
  void initState() {
    _controller = ScrollController();
    _controller.addListener(() {//为控制器注册滚动监听方法
      if(_controller.offset > 1000) {//如果ListView已经向下滚动了1000,则启用Top按钮
        setState(() {isToTop = true;});
      } else if(_controller.offset < 300) {//如果ListView向下滚动距离不足300,则禁用Top按钮
        setState(() {isToTop = false;});
      }
    });
    super.initState();
  }

  Widget build(BuildContext context) {
    return MaterialApp(
        ...
        //顶部Top按钮,根据isToTop变量判断是否需要注册滚动到顶部的方法
        RaisedButton(onPressed: (isToTop ? () {
                  if(isToTop) {
                    _controller.animateTo(.0,
                        duration: Duration(milliseconds: 200),
                        curve: Curves.ease
                    );//做一个滚动到顶部的动画
                  }
                }:null),child: Text("Top"),)
        ...
        ListView.builder(
                controller: _controller,//初始化传入控制器
                itemCount: 100,//列表元素总数
                itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项构造方法
               )      
        ...   
    );

  @override
  void dispose() {
    _controller.dispose(); //销毁控制器
    super.dispose();
  }
}
ScrollNotification
  • 在 Flutter 中,ScrollNotification 通知的获取是通过 NotificationListener 来实现的。与 ScrollController 不同的是,NotificationListener 是一个 Widget,为了监听滚动类型的事件,我们需要将 NotificationListener 添加为 ListView 的父容器,从而捕获 ListView 中的通知。而这些通知,需要通过 onNotification 回调函数实现监听逻辑:
Widget build(BuildContext context) {
  return MaterialApp(
    title: 'ScrollController Demo',
    home: Scaffold(
      appBar: AppBar(title: Text('ScrollController Demo')),
      body: NotificationListener<ScrollNotification>(//添加NotificationListener作为父容器
        onNotification: (scrollNotification) {//注册通知回调
         ScrollMetrics metrics = notification.metrics;
          print(metrics.pixels);// 当前位置
          print(metrics.atEdge);//是否在顶部或底部
          print(metrics.axis);//垂直或水平滚动
          print(metrics.axisDirection);// 滚动方向是down还是up
          print(metrics.extentAfter);//视口底部距离列表底部有多大
          print(metrics.extentBefore);//视口顶部距离列表顶部有多大
          print(metrics.extentInside);//视口范围内的列表长度
          print(metrics.maxScrollExtent);//最大滚动距离,列表长度-视口长度
          print(metrics.minScrollExtent);//最小滚动距离
          print(metrics.viewportDimension);//视口长度
          print(metrics.outOfRange);//是否越过边界
          return true;
        },
        child: ListView.builder(
          itemCount: 30,//列表元素个数
          itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项创建方法
        ),
      )
    )
  );
}
  • 相比于 ScrollController 只能和具体的 ListView 关联后才可以监听到滚动信息;通过 NotificationListener 则可以监听其子 Widget 中的任意 ListView,不仅可以得到这些 ListView 的当前滚动位置信息,还可以获取当前的滚动事件信息 。
问题

在ListView中,如何提前缓存子元素?

答:ListView构造函数中有一个cacheExtent参数,即预渲染区域长度,ListView会在其可视化区域的两边留一个cacheExtent长度的区域作为预渲染区域,相当于提前缓存些元素,这样当滑动时迅速呈现。

经典布局:如何定义子控件在父容器中的排版位置

  • Flutter 提供了 31 种布局 Widget,对布局控件的划分非常详细,一些相同(或相似)的视觉效果可以通过多种布局控件实现。
单子Widget布局:Container、Padding与Center
  • 单子 Widget 布局类容器比较简单,一般用来对其唯一的子 Widget 进行样式包装,比如限制大小、添加背景色样式、内间距、旋转变换等。
  • 在 Flutter 中,Container 本身可以单独作为控件存在(比如单独设置背景色、宽高),也可以作为其他控件的父级存在:Container 可以定义布局过程中子 Widget 如何摆放,以及如何展示。与其他框架不同的是,Flutter 的 Container 仅能包含一个子 Widget。
Container(
  child: Text('Container(容器)在UI框架中是一个很常见的概念,Flutter也不例外。'),
  padding: EdgeInsets.all(18.0), // 内边距
  margin: EdgeInsets.all(44.0), // 外边距
  width: 180.0,
  height:240,
  alignment: Alignment.center, // 子Widget居中对齐
  decoration: BoxDecoration( //Container样式
    color: Colors.red, // 背景色
    borderRadius: BorderRadius.circular(10.0), // 圆角边框
  ),
)
  • 如果我们只需要将子 Widget 设定间距,则可以使用另一个单子容器控件 Padding 进行内容填充。
Padding(
  padding: EdgeInsets.all(44.0),
  child: Text('Container(容器)在UI框架中是一个很常见的概念,Flutter也不例外。'),
);
  • 在需要设置内容间距时,我们可以通过 EdgeInsets 的不同构造函数,分别制定四个方向的不同补白方式,如均使用同样数值留白、只设置左留白或对称方向留白等。
  • Center 会将其子 Widget 居中排列。
Scaffold(
  body: Center(child: Text("Hello")) // This trailing comma makes auto-formatting nicer for build methods.
);
  • 需要注意的是,为了实现居中布局,Center 所占据的空间一定要比其子 Widget 要大才行,这也是显而易见的:如果 Center 和其子 Widget 一样大,自然就不需要居中,也没空间居中了。因此 Center 通常会结合 Container 一起使用。
Container(
  child: Center(child: Text('Container(容器)在UI框架中是一个很常见的概念,Flutter也不例外。')),
  padding: EdgeInsets.all(18.0), // 内边距
  margin: EdgeInsets.all(44.0), // 外边距
  width: 180.0,
  height:240,
  decoration: BoxDecoration( //Container样式
    color: Colors.red, // 背景色
    borderRadius: BorderRadius.circular(10.0), // 圆角边框
  ),
);
  • 我们通过 Center 容器实现了 Container 容器中 alignment: Alignment.center 的效果。
多子Widget布局:Row、Column与Expanded
  • 对于拥有多个子 Widget 的布局类容器而言,其布局行为无非就是两种规则的抽象:水平方向上应该如何布局、垂直方向上应该如何布局。
  • Row 与 Column 的使用方法很简单,我们只需要将各个子 Widget 按序加入到 children 数组即可。
//Row的用法示范
Row(
  children: <Widget>[
    Container(color: Colors.yellow, width: 60, height: 80,),
    Container(color: Colors.red, width: 100, height: 180,),
    Container(color: Colors.black, width: 60, height: 80,),
    Container(color: Colors.green, width: 60, height: 80,),
  ],
);

//Column的用法示范
Column(
  children: <Widget>[
    Container(color: Colors.yellow, width: 60, height: 80,),
    Container(color: Colors.red, width: 100, height: 180,),
    Container(color: Colors.black, width: 60, height: 80,),
    Container(color: Colors.green, width: 60, height: 80,),
  ],
);
  • 单纯使用 Row 和 Column 控件,在子 Widget 的尺寸较小时,无法将容器填满,视觉样式比较难看。对于这样的场景,我们可以通过 Expanded 控件,来制定分配规则填满容器的剩余空间。
  • 我们希望 Row 组件(或 Column 组件)中的绿色容器与黄色容器均分剩下的空间,于是就可以设置它们的弹性系数参数 flex 都为 1,这两个 Expanded 会按照其 flex 的比例(即 1:1)来分割剩余的 Row 横向(Column 纵向)空间。
Row(
  children: <Widget>[
    Expanded(flex: 1, child: Container(color: Colors.yellow, height: 60)), //设置了flex=1,因此宽度由Expanded来分配
    Container(color: Colors.red, width: 100, height: 180,),
    Container(color: Colors.black, width: 60, height: 80,),
    Expanded(flex: 1, child: Container(color: Colors.green,height: 60),)/设置了flex=1,因此宽度由Expanded来分配
  ],
);
  • 于 Row 与 Column 而言,Flutter 提供了依据坐标轴的布局对齐行为,即根据布局方向划分出主轴和纵轴:主轴,表示容器依次摆放子 Widget 的方向;纵轴,则是与主轴垂直的另一个方向。
Row与Column控件的主轴与纵轴
* 我们可以根据主轴与纵轴,设置子 Widget 在这两个方向上的对齐规则 mainAxisAlignment 与 crossAxisAlignment。比如,主轴方向 start 表示靠左对齐、center 表示横向居中对齐、end 表示靠右对齐、spaceEvenly 表示按固定间距对齐;而纵轴方向 start 则表示靠上对齐、center 表示纵向居中对齐、end 表示靠下对齐。 * 需要注意的是,对于主轴而言,Flutter 默认是让父容器决定其长度,即尽可能大,类似 Android 中的 match_parent。 * Row 的宽度为屏幕宽度,Column 的高度为屏幕高度。主轴长度大于所有子 Widget 的总长度,意味着容器在主轴方向的空间比子 Widget 要大,这也是我们能通过主轴对齐方式设置子 Widget 布局效果的原因。 * Row 与 Column 自身的大小由父widget的大小、子widget的大小、以及mainSize设置共同决定(mainAxisSize和crossAxisSize) * 主轴(纵轴)值为max:主轴(纵轴)大小等于屏幕主轴(纵轴)方向大小或者父widget主轴(纵轴)方向大小。 * 主轴(纵轴)值为min: 所有子widget组合在一起的主轴(纵轴)大小。 * 如果想让容器与子 Widget 在主轴上完全匹配,我们可以通过设置 Row 的 mainAxisSize 参数为 MainAxisSize.min,由所有子 Widget 来决定主轴方向的容器长度,即主轴方向的长度尽可能小,类似 Android 中的 wrap_content。
Row(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly, //由于容器与子Widget一样宽,因此这行设置排列间距的代码并未起作用
  mainAxisSize: MainAxisSize.min, //让容器宽度与所有子Widget的宽度一致
  children: <Widget>[
    Container(color: Colors.yellow, width: 60, height: 80,),
    Container(color: Colors.red, width: 100, height: 180,),
    Container(color: Colors.black, width: 60, height: 80,),
    Container(color: Colors.green, width: 60, height: 80,),
  ],
)
层叠Widget布局:Stack与Positioned
  • Stack 容器与前端中的绝对定位、Android 中的 Frame 布局非常类似,子 Widget 之间允许叠加,还可以根据父容器上、下、左、右四个角的位置来确定自己的位置。
  • Stack 提供了层叠布局的容器,而 Positioned 则提供了设置子 Widget 位置的能力。
Stack(
  children: <Widget>[
    Container(color: Colors.yellow, width: 300, height: 300),//黄色容器
    Positioned(
      left: 18.0,
      top: 18.0,
      child: Container(color: Colors.green, width: 50, height: 50),//叠加在黄色容器之上的绿色控件
    ),
    Positioned(
      left: 18.0,
      top:70.0,
      child: Text("Stack提供了层叠布局的容器"),//叠加在黄色容器之上的文本
    )
  ],
)
  • Stack 控件允许其子 Widget 按照创建的先后顺序进行层叠摆放,而 Positioned 控件则用来控制这些子 Widget 的摆放位置。需要注意的是,Positioned 控件只能在 Stack 中使用,在其他容器中使用会报错。

组合与自绘,何种方式定义Widget

  • 在 Flutter 中,自定义 Widget 与其他平台类似:可以使用基本 Widget 组装成一个高级别的 Widget,也可以自己在画板上根据特殊需求来画界面。
组装
  • 使用组合的方式自定义 Widget,即通过我们之前介绍的布局方式,摆放项目所需要的基础 Widget,并在控件内部设置这些基础 Widget 的样式,从而组合成一个更高级的控件。
  • 在 Flutter 中,组合的思想始终贯穿在框架设计之中,这也是 Flutter 提供了如此丰富的控件库的原因之一。比如,在新闻类应用中。
class UpdateItemModel {
  String appIcon;//App图标
  String appName;//App名称
  String appSize;//App大小
  String appDate;//App更新日期
  String appDescription;//App更新文案
  String appVersion;//App版本
  //构造函数语法糖,为属性赋值
  UpdateItemModel({this.appIcon, this.appName, this.appSize, this.appDate, this.appDescription, this.appVersion});
}
Widget buildTopRow(BuildContext context) {
  return Row(//Row控件,用来水平摆放子Widget
    children: <Widget>[
      Padding(//Paddng控件,用来设置Image控件边距
        padding: EdgeInsets.all(10),//上下左右边距均为10
        child: ClipRRect(//圆角矩形裁剪控件
          borderRadius: BorderRadius.circular(8.0),//圆角半径为8
          child: Image.asset(model.appIcon, width: 80,height:80)图片控件//
        )
      ),
      Expanded(//Expanded控件,用来拉伸中间区域
        child: Column(//Column控件,用来垂直摆放子Widget
          mainAxisAlignment: MainAxisAlignment.center,//垂直方向居中对齐
          crossAxisAlignment: CrossAxisAlignment.start,//水平方向居左对齐
          children: <Widget>[
            Text(model.appName,maxLines: 1),//App名字
            Text(model.appDate,maxLines: 1),//App更新日期
          ],
        ),
      ),
      Padding(//Paddng控件,用来设置Widget间边距
        padding: EdgeInsets.fromLTRB(0,0,10,0),//右边距为10,其余均为0
        child: FlatButton(//按钮控件
          child: Text("OPEN"),
          onPressed: onPressed,//点击回调
        )
      )
  ]);
}
Widget buildBottomRow(BuildContext context) {
  return Padding(//Padding控件用来设置整体边距
    padding: EdgeInsets.fromLTRB(15,0,15,0),//左边距和右边距为15
    child: Column(//Column控件用来垂直摆放子Widget
      crossAxisAlignment: CrossAxisAlignment.start,//水平方向距左对齐
      children: <Widget>[
        Text(model.appDescription),//更新文案
        Padding(//Padding控件用来设置边距
          padding: EdgeInsets.fromLTRB(0,10,0,0),//上边距为10
          child: Text("${model.appVersion} • ${model.appSize} MB")
        )
      ]
  ));
}
  • 将上下两部分控件通过 Column 包装起来,这次升级项 UI 定制就完成了。
class UpdatedItem extends StatelessWidget {
  final UpdatedItemModel model;//数据模型
  //构造函数语法糖,用来给model赋值
  UpdatedItem({Key key,this.model, this.onPressed}) : super(key: key);
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return Column(//用Column将上下两部分合体
        children: <Widget>[
          buildTopRow(context),//上半部分
          buildBottomRow(context)//下半部分
        ]);
  }
  Widget buildBottomRow(BuildContext context) {...}
  Widget buildTopRow(BuildContext context) {...}
}
  • 按照从上到下、从左到右去拆解 UI 的布局结构,把复杂的 UI 分解成各个小 UI 元素,在以组装的方式去自定义 UI 中非常有用,请一定记住这样的拆解方法。
自绘
  • Flutter 提供了非常丰富的控件和布局方式,使得我们可以通过组合去构建一个新的视图。但对于一些不规则的视图,用 SDK 提供的现有 Widget 组合可能无法实现,比如饼图,k 线图等,这个时候我们就需要自己用画笔去绘制了。
  • 在原生 iOS 和 Android 开发中,我们可以继承 UIView/View,在 drawRect/onDraw 方法里进行绘制操作。其实,在 Flutter 中也有类似的方案,那就是 CustomPaint。
  • CustomPaint 是用以承接自绘控件的容器,并不负责真正的绘制。既然是绘制,那就需要用到画布与画笔。
  • 在 Flutter 中,画布是 Canvas,画笔则是 Paint,而画成什么样子,则由定义了绘制逻辑的 CustomPainter 来控制。将 CustomPainter 设置给容器 CustomPaint 的 painter 属性,我们就完成了一个自绘控件的封装。
  • 对于画笔 Paint,我们可以配置它的各种属性,比如颜色、样式、粗细等;而画布 Canvas,则提供了各种常见的绘制方法,比如画线 drawLine、画矩形 drawRect、画点 DrawPoint、画路径 drawPath、画圆 drawCircle、画圆弧 drawArc 等。
//用 6 种不同颜色的画笔依次画了 6 个 1/6 圆弧,拼成了一张饼图。
class WheelPainter extends CustomPainter {
 // 设置画笔颜色 
  Paint getColoredPaint(Color color) {//根据颜色返回不同的画笔
    Paint paint = Paint();//生成画笔
    paint.color = color;//设置画笔颜色
    return paint;
  }

  @override
  void paint(Canvas canvas, Size size) {//绘制逻辑
    double wheelSize = min(size.width,size.height)/2;//饼图的尺寸
    double nbElem = 6;//分成6份
    double radius = (2 * pi) / nbElem;//1/6圆
    //包裹饼图这个圆形的矩形框
    Rect boundingRect = Rect.fromCircle(center: Offset(wheelSize, wheelSize), radius: wheelSize);
    // 每次画1/6个圆弧
    canvas.drawArc(boundingRect, 0, radius, true, getColoredPaint(Colors.orange));
    canvas.drawArc(boundingRect, radius, radius, true, getColoredPaint(Colors.black38));
    canvas.drawArc(boundingRect, radius * 2, radius, true, getColoredPaint(Colors.green));
    canvas.drawArc(boundingRect, radius * 3, radius, true, getColoredPaint(Colors.red));
    canvas.drawArc(boundingRect, radius * 4, radius, true, getColoredPaint(Colors.blue));
    canvas.drawArc(boundingRect, radius * 5, radius, true, getColoredPaint(Colors.pink));
  }
  // 判断是否需要重绘,这里我们简单的做下比较即可
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this;
}
//将饼图包装成一个新的控件
class Cake extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
        size: Size(200, 200),
        painter: WheelPainter(),
      );
  }
}
  • 在实现视觉需求上,自绘需要自己亲自处理绘制逻辑,而组合则是通过子 Widget 的拼接来实现绘制意图。因此从渲染逻辑处理上,自绘方案可以进行深度的渲染定制,从而实现少数通过组合很难实现的需求(比如饼图、k 线图)。不过,当视觉效果需要调整时,采用自绘的方案可能需要大量修改绘制代码,而组合方案则相对简单:只要布局拆分设计合理,可以通过更换子 Widget 类型来轻松搞定。

从夜间模式说起,定制不同的App主题

主题定制
  • 主题,又叫皮肤、配色,一般由颜色、图片、字号、字体等组成,我们可以把它看做是视觉效果在不同场景下的可视资源,以及相应的配置集合。比如,App 的按钮,无论在什么场景下都需要背景图片资源、字体颜色、字号大小等,而所谓的主题切换只是在不同主题之间更新这些资源及配置集合而已。
  • 因此在 App 开发中,我们通常不关心资源和配置的视觉效果好不好看,只要关心资源提供的视觉功能能不能用。比如,对于图片类资源,我们并不需要关心它渲染出来的实际效果,只需要确定它渲染出来是一张固定宽高尺寸的区域,不影响页面布局,能把业务流程跑通即可。
  • 视觉效果是易变的,我们将这些变化的部分抽离出来,把提供不同视觉效果的资源和配置按照主题进行归类,整合到一个统一的中间层去管理,这样我们就能实现主题的管理和切换了。
  • 在 iOS 中,我们通常会将主题的配置信息预先写到 plist 文件中,通过一个单例来控制 App 应该使用哪种配置;而 Android 的配置信息则写入各个 style 属性值的 xml 中,通过 activity 的 setTheme 进行切换;前端的处理方式也类似,简单更换 css 就可以实现多套主题 / 配色之间的切换。
  • Flutter 也提供了类似的能力,由 ThemeData 来统一管理主题的配置信息。
  • ThemeData 涵盖了 Material Design 规范的可自定义部分样式,比如应用明暗模式 brightness、应用主色调 primaryColor、应用次级色调 accentColor、文本字体 fontFamily、输入框光标颜色 cursorColor 等。
  • 通过 ThemeData 来自定义应用主题,我们可以实现 App 全局范围,或是 Widget 局部范围的样式切换。
全局统一的视觉风格定制
  • 在 Flutter 中,应用程序类 MaterialApp 的初始化方法,为我们提供了设置主题的能力。我们可以通过参数 theme,选择改变 App 的主题色、字体等,设置界面在 MaterialApp 下的展示样式。
MaterialApp(
  title: 'Flutter Demo',//标题
  theme: ThemeData(//设置主题
      brightness: Brightness.dark,//明暗模式为暗色
      primaryColor: Colors.cyan,//主色调为青色
  ),
  home: MyHomePage(title: 'Flutter Demo Home Page'),
);
  • 虽然我们只修改了主色调和明暗模式两个参数,但按钮、文字颜色都随之调整了。这是因为默认情况下,ThemeData 中很多其他次级视觉属性,都会受到主色调与明暗模式的影响。再细化一下主题配置。
MaterialApp(
  title: 'Flutter Demo',//标题
  theme: ThemeData(//设置主题
      brightness: Brightness.dark,//设置明暗模式为暗色
      accentColor: Colors.black,//(按钮)Widget前景色为黑色
      primaryColor: Colors.cyan,//主色调为青色
      iconTheme:IconThemeData(color: Colors.yellow),//设置icon主题色为黄色
      textTheme: TextTheme(body1: TextStyle(color: Colors.red))//设置文本颜色为红色
  ),
  home: MyHomePage(title: 'Flutter Demo Home Page'),
);
局部独立的视觉风格定制
  • 为整个 App 提供统一的视觉呈现效果固然很有必要,但有时我们希望为某个页面、或是某个区块设置不同于 App 风格的展现样式。以主题切换功能为例,我们希望为不同的主题提供不同的展示预览。
  • 在 Flutter 中,我们可以使用 Theme 来对 App 的主题进行局部覆盖。Theme 是一个单子 Widget 容器,与 MaterialApp 类似的,我们可以通过设置其 data 属性,对其子 Widget 进行样式定制:
    • 如果我们不想继承任何 App 全局的颜色或字体样式,可以直接新建一个 ThemeData 实例,依次设置对应的样式;
    • 而如果我们不想在局部重写所有的样式,则可以继承 App 的主题,使用 copyWith 方法,只更新部分样式。
// 新建主题
Theme(
    data: ThemeData(iconTheme: IconThemeData(color: Colors.red)),
    child: Icon(Icons.favorite)
);

// 继承主题
Theme(
    data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Colors.green)),
    child: Icon(Icons.feedback)
);
  • 除了定义 Material Design 规范中那些可自定义部分样式外,主题的另一个重要用途是样式复用。
  • 比如,如果我们想为一段文字复用 Materia Design 规范中的 title 样式,或是为某个子 Widget 的背景色复用 App 的主题色,我们就可以通过 Theme.of(context) 方法,取出对应的属性,应用到这段文字的样式中。
  • Theme.of(context) 方法将向上查找 Widget 树,并返回 Widget 树中最近的主题 Theme。如果 Widget 的父 Widget 们有一个单独的主题定义,则使用该主题。如果不是,那就使用 App 全局主题。
Container(
    color: Theme.of(context).primaryColor,//容器背景色复用应用主题色
    child: Text(
      'Text with a background color',
      style: Theme.of(context).textTheme.title,//Text组件文本样式复用应用文本样式
    ));
分平台主题定制
  • 有时候,为了满足不同平台的用户需求,我们希望针对特定的平台设置不同的样式。比如,在 iOS 平台上设置浅色主题,在 Android 平台上设置深色主题。面对这样的需求,我们可以根据 defaultTargetPlatform 来判断当前应用所运行的平台,从而根据系统类型来设置对应的主题。
// iOS浅色主题
final ThemeData kIOSTheme = ThemeData(
    brightness: Brightness.light,//亮色主题
    accentColor: Colors.white,//(按钮)Widget前景色为白色
    primaryColor: Colors.blue,//主题色为蓝色
    iconTheme:IconThemeData(color: Colors.grey),//icon主题为灰色
    textTheme: TextTheme(body1: TextStyle(color: Colors.black))//文本主题为黑色
);
// Android深色主题
final ThemeData kAndroidTheme = ThemeData(
    brightness: Brightness.dark,//深色主题
    accentColor: Colors.black,//(按钮)Widget前景色为黑色
    primaryColor: Colors.cyan,//主题色Wie青色
    iconTheme:IconThemeData(color: Colors.blue),//icon主题色为蓝色
    textTheme: TextTheme(body1: TextStyle(color: Colors.red))//文本主题色为红色
);
// 应用初始化
MaterialApp(
  title: 'Flutter Demo',
  theme: defaultTargetPlatform == TargetPlatform.iOS ? kIOSTheme : kAndroidTheme,//根据平台选择不同主题
  home: MyHomePage(title: 'Flutter Demo Home Page'),
);
  • 当然,除了主题之外,你也可以用 defaultTargetPlatform 这个变量去实现一些其他需要判断平台的逻辑,比如在界面上使用更符合 Android 或 iOS 设计风格的组件。

依赖管理(一):图片、配置和字体

  • 一个应用程序主要由两部分内容组成:代码和资源。代码关注逻辑功能,而如图片、字符串、字体、配置文件等资源则关注视觉功能。
  • 资源外部化,即把代码与资源分离,是现代 UI 框架的主流设计理念。因为这样不仅有利于单独维护资源,还可以对特定设备提供更准确的兼容性支持,使得我们的应用程序可以自动根据实际运行环境来组织视觉功能,适应不同的屏幕大小和密度等。
资源管理
  • 在移动开发中,常见的资源类型包括 JSON 文件、配置文件、图标、图片以及字体文件等。它们都会被打包到 App 安装包中,而 App 中的代码可以在运行时访问这些资源。
  • 在 Android、iOS 平台中,为了区分不同分辨率的手机设备,图片和其他原始资源是区别对待的:
    • iOS 使用 Images.xcassets 来管理图片,其他的资源直接拖进工程项目即可;
    • Android 的资源管理粒度则更为细致,使用以 drawable+ 分辨率命名的文件夹来分别存放不同分辨率的图片,其他类型的资源也都有各自的存放方式,比如布局文件放在 res/layout 目录下,资源描述文件放在 res/values 目录下,原始文件放在 assets 目录下等。
  • 而在 Flutter 中,资源管理则简单得多:资源(assets)可以是任意类型的文件,比如 JSON 配置文件或是字体文件等,而不仅仅是图片。
  • 而关于资源的存放位置,Flutter 并没有像 Android 那样预先定义资源的目录结构,所以我们可以把资源存放在项目中的任意目录下,只需要使用根目录下的 pubspec.yaml 文件,对这些资源的所在位置进行显式声明就可以了,以帮助 Flutter 识别出这些资源。
assets
├── background.jpg
├── icons
│   └── food_icon.jpg
├── loading.gif
└── result.json
flutter:
  assets:
    - assets/background.jpg   #挨个指定资源路径
    - assets/loading.gif  #挨个指定资源路径
    - assets/result.json  #挨个指定资源路径
    - assets/icons/    #子目录批量指定
    - assets/ #根目录也是可以批量指定的
  • 需要注意的是,目录批量指定并不递归,只有在该目录下的文件才可以被包括,如果下面还有子目录的话,需要单独声明子目录下的文件。
  • 完成资源的声明后,我们就可以在代码中访问它们了。在 Flutter 中,对不同类型的资源文件处理方式略有差异。
  • 对于图片类资源的访问,我们可以使用 Image.asset 构造方法完成图片资源的加载及显示。
  • 而对于其他资源文件的加载,我们可以通过 Flutter 应用的主资源 Bundle 对象 rootBundle,来直接访问。
  • 对于字符串文件资源,我们使用 loadString 方法;而对于二进制文件资源,则通过 load 方法。
rootBundle.loadString('assets/result.json').then((msg)=>print(msg));
  • 与 Android、iOS 开发类似,Flutter 也遵循了基于像素密度的管理方式,如 1.0x、2.0x、3.0x 或其他任意倍数,Flutter 可以根据当前设备分辨率加载最接近设备像素比例的图片资源。而为了让 Flutter 更好地识别,我们的资源目录应该将 1.0x、2.0x 与 3.0x 的图片资源分开管理。
assets
├── background.jpg    //1.0x图
├── 2.0x
│   └── background.jpg  //2.0x图
└── 3.0x
    └── background.jpg  //3.0x图
  • 而在 pubspec.yaml 文件声明这个图片资源时,仅声明 1.0x 图资源即可。
flutter:
  assets:
    - assets/background.jpg   #1.0x图资源
  • 1.0x 分辨率的图片是资源标识符,而 Flutter 则会根据实际屏幕像素比例加载相应分辨率的图片。这时,如果主资源缺少某个分辨率资源,Flutter 会在剩余的分辨率资源中选择最接近的分辨率资源去加载。
  • 字体则是另外一类较为常用的资源。手机操作系统一般只有默认的几种字体,在大部分情况下可以满足我们的正常需求。但是,在一些特殊的情况下,我们可能需要使用自定义字体来提升视觉体验。
  • 在 Flutter 中,使用自定义字体同样需要在 pubspec.yaml 文件中提前声明。需要注意的是,字体实际上是字符图形的映射。所以,除了正常字体文件外,如果你的应用需要支持粗体和斜体,同样也需要有对应的粗体和斜体字体文件。
  • 在将 RobotoCondensed 字体摆放至 assets 目录下的 fonts 子目录后,下面的代码演示了如何将支持斜体与粗体的 RobotoCondensed 字体加到我们的应用中。
fonts:
  - family: RobotoCondensed  #字体名字
    fonts:
      - asset: assets/fonts/RobotoCondensed-Regular.ttf #普通字体
      - asset: assets/fonts/RobotoCondensed-Italic.ttf 
        style: italic  #斜体
      - asset: assets/fonts/RobotoCondensed-Bold.ttf 
        weight: 700  #粗体
Text("This is RobotoCondensed", style: TextStyle(
    fontFamily: 'RobotoCondensed',//普通字体
));
Text("This is RobotoCondensed", style: TextStyle(
    fontFamily: 'RobotoCondensed',
    fontWeight: FontWeight.w700, //粗体
));
Text("This is RobotoCondensed italic", style: TextStyle(
  fontFamily: 'RobotoCondensed',
  fontStyle: FontStyle.italic, //斜体
));
原生平台的资源设置
  • Flutter 需要原生环境才能运行,但是有些资源我们需要在 Flutter 框架运行之前提前使用,比如要给应用添加图标,或是希望在等待 Flutter 框架启动时添加启动图,我们就需要在对应的原生工程中完成相应的配置,所以下面介绍的操作步骤都是在原生系统中完成的。
更换App图标
  • 对于 Android 平台,启动图标位于根目录 android/app/src/main/res/mipmap 下。我们只需要遵守对应的像素密度标准,保留原始图标名称,将图标更换为目标资源即可。
  • 对于 iOS 平台,启动图位于根目录 ios/Runner/Assets.xcassets/AppIcon.appiconset 下。同样地,我们只需要遵守对应的像素密度标准,将其替换为目标资源并保留原始图标名称即可。
更换启动图
  • 对于 Android 平台,启动图位于根目录 android/app/src/main/res/drawable 下,是一个名为 launch_background 的 XML 界面描述文件。
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 白色背景 -->
    <item android:drawable="@android:color/white" />
    <item>
         <!-- 内嵌一张居中展示的图片 -->
        <bitmap
            android:gravity="center"
            android:src="@mipmap/bitmap_launcher" />
    </item>
</layer-list>
  • 而对于 iOS 平台,启动图位于根目录 ios/Runner/Assets.xcassets/LaunchImage.imageset 下。我们保留原始启动图名称,将图片依次按照对应像素密度标准,更换为目标启动图即可。

依赖管理(二):第三方组件库在FLutter如何管理

  • pubspec.yaml 更为重要的作用是管理 Flutter 工程代码的依赖,比如第三方库、Dart 运行环境、Flutter SDK 版本都可以通过它来进行统一管理。所以,pubspec.yaml 与 iOS 中的 Podfile、Android 中的 build.gradle、前端的 package.json 在功能上是类似的。
Pub
  • Dart 提供了包管理工具 Pub,用来管理代码和资源。从本质上说,包(package)实际上就是一个包含了 pubspec.yaml 文件的目录,其内部可以包含代码、资源、脚本、测试和文档等文件。包中包含了需要被外部依赖的功能抽象,也可以依赖其他包。
  • 与 Android 中的 JCenter/Maven、iOS 中的 CocoaPods、前端中的 npm 库类似,Dart 提供了官方的包仓库 Pub。通过 Pub,我们可以很方便地查找到有用的第三方包
  • 。当然,这并不意味着我们可以简单地拿别人的库来拼凑成一个应用程序。Dart 提供包管理工具 Pub 的真正目的是,让你能够找到真正好用的、经过线上大量验证的库,复用他人的成果来缩短开发周期,提升软件质量。
  • 在 Dart 中,库和应用都属于包。pubspec.yaml 是包的配置文件,包含了包的元数据(比如,包的名称和版本)、运行环境(也就是 Dart SDK 与 Fluter SDK 版本)、外部依赖、内部配置(比如,资源管理)。
  • 下面的例子中,我们声明了一个 flutter_app_example 的应用配置文件,其版本为 1.0,Dart 运行环境支持 2.1 至 3.0 之间,依赖 flutter 和 cupertino_icon。
name: flutter_app_example #应用名称
description: A new Flutter application. #应用描述
version: 1.0.0 
#Dart运行环境区间
environment:
  sdk: ">=2.1.0 <3.0.0"
#Flutter依赖库
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ">0.1.1"
  • 运行环境和依赖库 cupertino_icons 冒号后面的部分是版本约束信息,由一组空格分隔的版本描述组成,可以支持指定版本、版本号区间,以及任意版本这三种版本约束方式。比如上面的例子中,cupertino_icons 引用了大于 0.1.1 的版本。
  • 需要注意的是,由于元数据与名称使用空格分隔,因此版本号中不能出现空格;同时又由于大于符号“>”也是 YAML 语法中的折叠换行符号,因此在指定版本范围的时候,必须使用引号, 比如">=2.1.0 < 3.0.0"。
  • 对于包,我们通常是指定版本区间,而很少直接指定特定版本,因为包升级变化很频繁,如果有其他的包直接或间接依赖这个包的其他版本时,就会经常发生冲突。
  • 而对于运行环境,如果是团队多人协作的工程,建议将 Dart 与 Flutter 的 SDK 环境写死,统一团队的开发环境,避免因为跨 SDK 版本出现的 API 差异进而导致工程问题。
environment:
  sdk: 2.3.0
  flutter: 1.2.1
  • 基于版本的方式引用第三方包,需要在其 Pub 上进行公开发布,我们可以访问https://pub.dev/来获取可用的第三方包。
  • 而对于不对外公开发布,或者目前处于开发调试阶段的包,我们需要设置数据源,使用本地路径或 Git 地址的方式进行包声明。
dependencies:
  package1:
    path: ../package1/  #路径依赖
  date_format:
    git:
      url: https://github.com/xxx/package2.git #git依赖
  • 在开发应用时,我们可以不写明具体的版本号,而是以区间的方式声明包的依赖;但对于一个程序而言,其运行时具体引用哪个版本的依赖包必须要确定下来。因此,除了管理第三方依赖,包管理工具 Pub 的另一个职责是,找出一组同时满足每个包版本约束的包版本。包版本一旦确定,接下来就是下载对应版本的包了。
  • 对于 dependencies 中的不同数据源,Dart 会使用不同的方式进行管理,最终会将远端的包全部下载到本地。比如,对于 Git 声明依赖的方式,Pub 会 clone Git 仓库;对于版本号的方式,Pub 则会从 pub.dartlang.org 下载包。如果包还有其他的依赖包,比如 package1 包还依赖 package3 包,Pub 也会一并下载。
  • 然后,在完成了所有依赖包的下载后,Pub 会在应用的根目录下创建.packages 文件,将依赖的包名与系统缓存中的包文件路径进行映射,方便后续维护。
  • 最后,Pub 会自动创建 pubspec.lock 文件。pubspec.lock 文件的作用类似 iOS 的 Podfile.lock 或前端的 package-lock.json 文件,用于记录当前状态下实际安装的各个直接依赖、间接依赖的包的具体来源和版本号。
  • 除了提供功能和代码维度的依赖之外,包还可以提供资源的依赖。在依赖包中的 pubspec.yaml 文件已经声明了同样资源的情况下,为节省应用程序安装包大小,我们需要复用依赖包中的资源。
pubspec.yaml    
└──assets
    ├──2.0x
    │   └── placeholder.png
    └──3.0x
        └── placeholder.png
Image.asset('assets/placeholder.png', package: 'package4');

AssetImage('assets/placeholder.png', package: 'package4');
举例
  • 在 Flutter 中,提供了表达日期的数据结构DateTime,这个类拥有极大的表示范围,可以表达 1970-01-01 UTC 时间后 100,000,000 天内的任意时刻。不过,如果我们想要格式化显示日期和时间,DateTime 并没有提供非常方便的方法,我们不得不自己取出年、月、日、时、分、秒,来定制显示方式。
  • 值得庆幸的是,我们可以通过 date_format 这个第三方包来实现我们的诉求:date_format 提供了若干常用的日期格式化方法,可以很方便地实现格式化日期的功能。
  • 首先,我们在 Pub 上找到 date_format 这个包,确定其使用说明。
    dart_format使用说明
  • date_format 包最新的版本是 1.0.6,于是接下来我们把 date_format 添加到 pubspec.yaml 中。
dependencies:
  date_format: 1.0.6
  • 随后,IDE(Android Studio)监测到了配置文件的改动,提醒我们进行安装包依赖更新。于是,我们点击 Get dependencies,下载 date_format 。
    下载安装包依赖
  • 下载完成后,我们就可以在工程中使用 date_format 来进行日期的格式化了。
print(formatDate(DateTime.now(), [mm, '月', dd, '日', hh, ':', n]));
//输出2019年06月30日01:56
print(formatDate(DateTime.now(), [m, '月第', w, '周']));
//输出6月第5周
  • 现代编程语言大都自带第依赖管理机制,其核心功能是为工程中所有直接或间接依赖的代码库找到合适的版本,但这并不容易。就比如前端的依赖管理器 npm 的早期版本,就曾因为不太合理的算法设计,导致计算依赖耗时过长,依赖文件夹也高速膨胀,一度被开发者们戏称为“黑洞”。而 Dart 使用的 Pub 依赖管理机制所采用的PubGrub 算法则解决了这些问题,因此被称为下一代版本依赖解决算法,在 2018 年底被苹果公司吸纳,成为 Swift 所采用的依赖管理器算法。
问题
  • .packages 与 pubspec.lock 是否需要做代码版本管理呢?为什么?
    答:pubspec.lock需要做版本管理,因为lock文件把版本锁定,统一工程环境
    .packages不需要版本管理,因为跟本地环境有关,无法做到统一。

用户交互事件如何响应

  • 手势操作在 Flutter 中分为两类:
    • 第一类是原始的指针事件(Pointer Event),即原生开发中常见的触摸事件,表示屏幕上触摸(或鼠标、手写笔)行为触发的位移行为;
    • 第二类则是手势识别(Gesture Detector),表示多个原始指针事件的组合操作,如点击、双击、长按等,是指针事件的语义化封装。
指针事件
  • 指针事件表示用户交互的原始触摸数据,如手指接触屏幕 PointerDownEvent、手指在屏幕上移动 PointerMoveEvent、手指抬起 PointerUpEvent,以及触摸取消 PointerCancelEvent,这与原生系统的底层触摸事件抽象是一致的。
  • 在手指接触屏幕,触摸事件发起时,Flutter 会确定手指与屏幕发生接触的位置上究竟有哪些组件,并将触摸事件交给最内层的组件去响应。与浏览器中的事件冒泡机制类似,事件会从这个最内层的组件开始,沿着组件树向根节点向上冒泡分发。
  • 不过 Flutter 无法像浏览器冒泡那样取消或者停止事件进一步分发,我们只能通过 hitTestBehavior 去调整组件在命中测试期内应该如何表现,比如把触摸事件交给子组件,或者交给其视图层级之下的组件去响应。
  • 关于组件层面的原始指针事件的监听,Flutter 提供了 Listener Widget,可以监听其子 Widget 的原始指针事件。
Listener(
  child: Container(
    color: Colors.red,//背景色红色
    width: 300,
    height: 300,
  ),
  onPointerDown: (event) => print("down $event"),//手势按下回调
  onPointerMove:  (event) => print("move $event"),//手势移动回调
  onPointerUp:  (event) => print("up $event"),//手势抬起回调
);
手势识别
  • 通常情况下,响应用户交互行为的话,我们会使用封装了手势语义操作的 Gesture,如点击 onTap、双击 onDoubleTap、长按 onLongPress、拖拽 onPanUpdate、缩放 onScaleUpdate 等。另外,Gesture 可以支持同时分发多个手势交互行为,意味着我们可以通过 Gesture 同时监听多个事件。
  • Gesture 是手势语义的抽象,而如果我们想从组件层监听手势,则需要使用 GestureDetector。GestureDetector 是一个处理各种高级用户触摸行为的 Widget,与 Listener 一样,也是一个功能性组件。
//红色container坐标
double _top = 0.0;
double _left = 0.0;
Stack(//使用Stack组件去叠加视图,便于直接控制视图坐标
  children: <Widget>[
    Positioned(
      top: _top,
      left: _left,
      child: GestureDetector(//手势识别
        child: Container(color: Colors.red,width: 50,height: 50),//红色子视图
        onTap: ()=>print("Tap"),//点击回调
        onDoubleTap: ()=>print("Double Tap"),//双击回调
        onLongPress: ()=>print("Long Press"),//长按回调
        onPanUpdate: (e) {//拖动回调
          setState(() {
            //更新位置
            _left += e.delta.dx;
            _top += e.delta.dy;
          });
        },
      ),
    )
  ],
);
  • 我们对一个 Widget 同时监听了多个手势事件,但最终只会有一个手势能够得到本次事件的处理权。对于多个手势的识别,Flutter 引入了手势竞技场(Arena)的概念,用来识别究竟哪个手势可以响应用户事件。手势竞技场会考虑用户触摸屏幕的时长、位移以及拖动方向,来确定最终手势。
手势竞技场实现
  • 实际上,GestureDetector 内部对每一个手势都建立了一个工厂类(Gesture Factory)。而工厂类的内部会使用手势识别类(GestureRecognizer),来确定当前处理的手势。
  • 而所有手势的工厂类都会被交给 RawGestureDetector 类,以完成监测手势的大量工作:使用 Listener 监听原始指针事件,并在状态改变时把信息同步给所有的手势识别器,然后这些手势会在竞技场决定最后由谁来响应用户事件。
  • 有些时候我们可能会在应用中给多个视图注册同类型的手势监听器,比如微博的信息流列表中的微博,点击不同区域会有不同的响应:点击头像会进入用户个人主页,点击图片会进入查看大图页面,点击其他部分会进入微博详情页等。
  • 像这样的手势识别发生在多个存在父子关系的视图时,手势竞技场会一并检查父视图和子视图的手势,并且通常最终会确认由子视图来响应事件。而这也是合乎常理的:从视觉效果上看,子视图的视图层级位于父视图之上,相当于对其进行了遮挡,因此从事件处理上看,子视图自然是事件响应的第一责任人。
GestureDetector(
  onTap: () => print('Parent tapped'),//父视图的点击回调
  child: Container(
    color: Colors.pinkAccent,
    child: Center(
      child: GestureDetector(
        onTap: () => print('Child tapped'),//子视图的点击回调
        child: Container(
          color: Colors.blueAccent,
          width: 200.0,
          height: 200.0,
        ),
      ),
    ),
  ),
);
  • 运行这段代码,然后在蓝色区域进行点击,可以发现:尽管父容器也监听了点击事件,但 Flutter 只响应了子容器的点击事件。
  • 为了让父容器也能接收到手势,我们需要同时使用 RawGestureDetector 和 GestureFactory,来改变竞技场决定由谁来响应用户事件的结果。
  • 在此之前,我们还需要自定义一个手势识别器,让这个识别器在竞技场被 PK 失败时,能够再把自己重新添加回来,以便接下来还能继续去响应用户事件。
class MultipleTapGestureRecognizer extends TapGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }
}
  • 接下来,我们需要将手势识别器和其工厂类传递给 RawGestureDetector,以便用户产生手势交互事件时能够立刻找到对应的识别方法。事实上,RawGestureDetector 的初始化函数所做的配置工作,就是定义不同手势识别器和其工厂类的映射关系。
  • 这里,由于我们只需要处理点击事件,所以只配置一个识别器即可。工厂类的初始化采用 GestureRecognizerFactoryWithHandlers 函数完成,这个函数提供了手势识别对象创建,以及对应的初始化入口。
RawGestureDetector(//自己构造父Widget的手势识别映射关系
  gestures: {
    //建立多手势识别器与手势识别工厂类的映射关系,从而返回可以响应该手势的recognizer
    MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<
        MultipleTapGestureRecognizer>(
          () => MultipleTapGestureRecognizer(),
          (MultipleTapGestureRecognizer instance) {
        instance.onTap = () => print('parent tapped ');//点击回调
      },
    )
  },
  child: Container(
    color: Colors.pinkAccent,
    child: Center(
      child: GestureDetector(//子视图可以继续使用GestureDetector
        onTap: () => print('Child tapped'),
        child: Container(...),
      ),
    ),
  ),
);
  • 运行一下这段代码,我们可以看到,当点击蓝色容器时,其父容器也收到了 Tap 事件。

跨组件传递数据

  • 通过组合嵌套的方式,利用数据对基础 Widget 的样式进行视觉属性定制,我们已经实现了多种界面布局,在 Flutter 中实现跨组件数据传递的标准方式是通过属性传值。

  • 对于稍微复杂一点的、尤其视图层级比较深的 UI 样式,一个属性可能需要跨越很多层才能传递给子组件,这种传递方式就会导致中间很多并不需要这个属性的组件也需要接收其子 Widget 的数据,不仅繁琐而且冗余。

  • 所以,对于数据的跨层传递,Flutter 还提供了三种方案:InheritedWidget、Notification 和 EventBus。接下来,我将依次为你讲解这三种方案。

InheritedWidget
  • InheritedWidget 是 Flutter 中的一个功能型 Widget,适用于在 Widget 树中共享数据的场景。通过它,我们可以高效地将数据在 Widget 树中进行跨层传递。
  • 之前通过 Theme 去访问当前界面的样式风格,从而进行样式复用的例子,比如 Theme.of(context).primaryColor。
  • Theme 类是通过 InheritedWidget 实现的典型案例。在子 Widget 中通过 Theme.of 方法找到上层 Theme 的 Widget,获取到其属性的同时,建立子 Widget 和上层父 Widget 的观察者关系,当上层父 Widget 属性修改的时候,子 Widget 也会触发更新。
  • 以 Flutter 工程模板中的计数器为例,说明 InheritedWidget 的使用方法。
    • 首先,为了使用 InheritedWidget,我们定义了一个继承自它的新类 CountContainer。
    • 然后,我们将计数器状态 count 属性放到 CountContainer 中,并提供了一个 of 方法方便其子 Widget 在 Widget 树中找到它。最后,我们重写了 updateShouldNotify 方法,这个方法会在 Flutter 判断 InheritedWidget 是否需要重建,从而通知下层观察者组件更新数据时被调用到。在这里,我们直接判断 count 是否相等即可。
class CountContainer extends InheritedWidget {
  //方便其子Widget在Widget树中找到它
  static CountContainer of(BuildContext context) => context.inheritFromWidgetOfExactType(CountContainer) as CountContainer;
  
  final int count;

  CountContainer({
    Key key,
    @required this.count,
    @required Widget child,
  }): super(key: key, child: child);

  // 判断是否需要更新
  @override
  bool updateShouldNotify(CountContainer oldWidget) => count != oldWidget.count;
}
  • 然后,我们使用 CountContainer 作为根节点,并用 0 初始化 count。随后在其子 Widget Counter 中,我们通过 InheritedCountContainer.of 方法找到它,获取计数状态 count 并展示。
class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
   //将CountContainer作为根节点,并使用0作为初始化count
    return CountContainer(
      count: 0,
      child: Counter()
    );
  }
}

class Counter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //获取InheritedWidget节点
    CountContainer state = CountContainer.of(context);
    return Scaffold(
      appBar: AppBar(title: Text("InheritedWidget demo")),
      body: Text(
        'You have pushed the button this many times: ${state.count}',
      ),
    );
}
  • 可以看到 InheritedWidget 的使用方法还是比较简单的,无论 Counter 在 CountContainer 下层什么位置,都能获取到其父 Widget 的计数属性 count,再也不用手动传递属性了。
  • 不过,InheritedWidget 仅提供了数据读的能力,如果我们想要修改它的数据,则需要把它和 StatefulWidget 中的 State 配套使用。我们需要把 InheritedWidget 中的数据和相关的数据修改方法,全部移到 StatefulWidget 中的 State 上,而 InheritedWidget 只需要保留对它们的引用。
class CountContainer extends InheritedWidget {
  ...
  final _MyHomePageState model;//直接使用MyHomePage中的State获取数据
  final Function() increment;

  CountContainer({
    Key key,
    @required this.model,
    @required this.increment,
    @required Widget child,
  }): super(key: key, child: child);
  ...
}
  • 然后,我们将 count 数据和其对应的修改方法放在了 State 中,仍然使用 CountContainer 作为根节点,完成了数据和修改方法的初始化。
  • 在其子 Widget Counter 中,我们还是通过 InheritedCountContainer.of 方法找到它,将计数状态 count 与 UI 展示同步,将按钮的点击事件与数据修改同步。
class _MyHomePageState extends State<MyHomePage> {
  int count = 0;
  void _incrementCounter() => setState(() {count++;});//修改计数器

  @override
  Widget build(BuildContext context) {
    return CountContainer(
      model: this,//将自身作为model交给CountContainer
      increment: _incrementCounter,//提供修改数据的方法
      child:Counter()
    );
  }
}

class Counter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //获取InheritedWidget节点
    CountContainer state = CountContainer.of(context);
    return Scaffold(
      ...
      body: Text(
        'You have pushed the button this many times: ${state.model.count}', //关联数据读方法
      ),
      floatingActionButton: FloatingActionButton(onPressed: state.increment), //关联数据修改方法
    );
  }
}
Notification
  • Notification 是 Flutter 中进行跨层数据共享的另一个重要的机制。如果说 InheritedWidget 的数据流动方式是从父 Widget 到子 Widget 逐层传递,那 Notificaiton 则恰恰相反,数据流动方式是从子 Widget 向上传递至父 Widget。这样的数据传递机制适用于子 Widget 状态变更,发送通知上报的场景。
  • 在之前的ListView学习中,介绍了 ScrollNotification 的使用方法:ListView 在滚动时会分发通知,我们可以在上层使用 NotificationListener 监听 ScrollNotification,根据其状态做出相应的处理。
  • 自定义通知的监听与 ScrollNotification 并无不同,而如果想要实现自定义通知,我们首先需要继承 Notification 类。Notification 类提供了 dispatch 方法,可以让我们沿着 context 对应的 Element 节点树向上逐层发送通知。
  • 自定义了一个通知和子 Widget。子 Widget 是一个按钮,在点击时会发送通知。
class CustomNotification extends Notification {
  CustomNotification(this.msg);
  final String msg;
}

//抽离出一个子Widget用来发通知
class CustomChild extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return RaisedButton(
      //按钮点击时分发通知
      onPressed: () => CustomNotification("Hi").dispatch(context),
      child: Text("Fire Notification"),
    );
  }
}
  • 在子 Widget 的父 Widget 中,我们监听了这个通知,一旦收到通知,就会触发界面刷新,展示收到的通知信息。
class _MyHomePageState extends State<MyHomePage> {
  String _msg = "通知:";
  @override
  Widget build(BuildContext context) {
    //监听通知
    return NotificationListener<CustomNotification>(
        onNotification: (notification) {
          setState(() {_msg += notification.msg+"  ";});//收到子Widget通知,更新msg
        },
        child:Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[Text(_msg),CustomChild()],//将子Widget加入到视图树中
        )
    );
  }
}
EventBus
  • 无论是 InheritedWidget 还是 Notificaiton,它们的使用场景都需要依靠 Widget 树,也就意味着只能在有父子关系的 Widget 之间进行数据共享。但是,组件间数据传递还有一种常见场景:这些组件间不存在父子关系。这时,事件总线 EventBus 就登场了。
  • 事件总线是在 Flutter 中实现跨组件通信的机制。它遵循发布 / 订阅模式,允许订阅者订阅事件,当发布者触发事件时,订阅者和发布者之间可以通过事件进行交互。发布者和订阅者之间无需有父子关系,甚至非 Widget 对象也可以发布 / 订阅。这些特点与其他平台的事件总线机制是类似的。
  • EventBus 是一个第三方插件,因此我们需要在 pubspec.yaml 文件中声明它。
dependencies:  
  event_bus: 1.1.0
  • EventBus 的使用方式灵活,可以支持任意对象的传递。所以在这里,我们传输数据的载体就选择了一个有字符串属性的自定义事件类 CustomEvent。
class CustomEvent {
  String msg;
  CustomEvent(this.msg);
}
  • 然后定义了一个全局的 eventBus 对象,并在第一个页面监听了 CustomEvent 事件,一旦收到事件,就会刷新 UI。需要注意的是,千万别忘了在 State 被销毁时清理掉事件注册,否则你会发现 State 永远被 EventBus 持有着,无法释放,从而造成内存泄漏。
//建立公共的event bus
EventBus eventBus = new EventBus();
//第一个页面
class _FirstScreenState extends  State<FirstScreen>  {

  String msg = "通知:";
  StreamSubscription subscription;
  @override
  initState() {
   //监听CustomEvent事件,刷新UI
    subscription = eventBus.on<CustomEvent>().listen((event) {
      setState(() {msg+= event.msg;});//更新msg
    });
    super.initState();
  }
  dispose() {
    subscription.cancel();//State销毁时,清理注册
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      body:Text(msg),
      ...
    );
  }
}
  • 最后,我们在第二个页面以按钮点击回调的方式,触发了 CustomEvent 事件。
class SecondScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      ...
      body: RaisedButton(
          child: Text('Fire Event'),
          // 触发CustomEvent事件
          onPressed: ()=> eventBus.fire(CustomEvent("hello"))
      ),
    );
  }
}
  • 属性传值、InheritedWidget、Notification 与 EventBus 数据传递方式对比。
![通过组合嵌套的方式,利用数据对基础 Widget 的样式进行视觉属性定制,我们已经实现了多种界面布局。所以,你应该已经体会到了,在 Flutter 中实现跨组件数据传递的标准方式是通过属性传值。](https://img-blog.csdnimg.cn/20200810225847774.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3lhbmdfc3R1ZHlfZmlyc3Q=,size_16,color_FFFFFF,t_70#pic_center)
#### 路由与导航实现页面切换
  • 如果说 UI 框架的视图元素的基本单位是组件,那应用程序的基本单位就是页面了。对于拥有多个页面的应用程序而言,如何从一个页面平滑地过渡到另一个页面,我们需要有一个统一的机制来管理页面之间的跳转,通常被称为路由管理或导航管理。
  • 我们首先需要知道目标页面对象,在完成目标页面初始化后,用框架提供的方式打开它。比如,在 Android/iOS 中我们通常会初始化一个 Intent 或 ViewController,通过 startActivity 或 pushViewController 来打开一个新的页面;而在 React 中,我们使用 navigation 来管理所有页面,只要知道页面的名称,就可以立即导航到这个页面。
  • 其实,Flutter 的路由管理也借鉴了这两种设计思路。
路由管理
  • 在 Flutter 中,页面之间的跳转是通过 Route 和 Navigator 来管理的。
    • Route 是页面的抽象,主要负责创建对应的界面,接收参数,响应 Navigator 打开和关闭;
    • 而 Navigator 则会维护一个路由栈管理 Route,Route 打开即入栈,Route 关闭即出栈,还可以直接替换栈内的某一个 Route。
  • 而根据是否需要提前注册页面标识符,Flutter 中的路由管理可以分为两种方式。
    • 基本路由。无需提前注册,在页面切换时需要自己构造页面实例。
    • 命名路由。需要提前注册页面标识符,在页面切换时通过标识符直接打开新的路由。
基本路由
  • 在 Flutter 中,基本路由的使用方法和 Android/iOS 打开新页面的方式非常相似。要导航到一个新的页面,我们需要创建一个 MaterialPageRoute 的实例,调用 Navigator.push 方法将新页面压到堆栈的顶部。
  • 其中,MaterialPageRoute 是一种路由模板,定义了路由创建及切换过渡动画的相关配置,可以针对不同平台,实现与平台页面切换动画风格一致的路由切换动画。
  • 而如果我们想返回上一个页面,则需要调用 Navigator.pop 方法从堆栈中删除这个页面。
class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return RaisedButton(
      //打开页面
      onPressed: ()=> Navigator.push(context, MaterialPageRoute(builder: (context) => SecondScreen()));
    );
  }
}

class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return RaisedButton(
      // 回退页面
      onPressed: ()=> Navigator.pop(context)
    );
  }
}
命名路由
  • 基本路由使用方式相对简单灵活,适用于应用中页面不多的场景。而在应用中页面比较多的情况下,再使用基本路由方式,那么每次跳转到一个新的页面,我们都要手动创建 MaterialPageRoute 实例,初始化页面,然后调用 push 方法打开它,还是比较麻烦的。
  • 所以,Flutter 提供了另外一种方式来简化路由管理,即命名路由。我们给页面起一个名字,然后就可以直接通过页面名字打开它了。这种方式简单直观,与 React 中的 navigation 使用方式类似。
  • 要想通过名字来指定页面切换,我们必须先给应用程序 MaterialApp 提供一个页面名称映射规则,即路由表 routes,这样 Flutter 才知道名字与页面 Widget 的对应关系。
  • 路由表实际上是一个 Map,其中 key 值对应页面名字,而 value 值则是一个 WidgetBuilder 回调函数,我们需要在这个函数中创建对应的页面。而一旦在路由表中定义好了页面名字,我们就可以使用 Navigator.pushNamed 来打开页面了。
MaterialApp(
    ...
    //注册路由
    routes:{
      "second_page":(context)=>SecondPage(),
    },
);
//使用名字打开页面
Navigator.pushNamed(context,"second_page");
  • 不过由于路由的注册和使用都采用字符串来标识,这就会带来一个隐患:如果我们打开了一个不存在的路由会怎么办?
    • 我们可以约定使用字符串常量去定义、使用路由,但我们无法避免通过接口数据下发的错误路由标识符场景。面对这种情况,无论是直接报错或是不响应错误路由,都不是一个用户体验良好的解决办法。
    • 更好的办法是,对用户进行友好的错误提示,比如跳转到一个统一的 NotFoundScreen 页面,也方便我们对这类错误进行统一收集、上报。
    • 在注册路由表时,Flutter 提供了 UnknownRoute 属性,我们可以对未知的路由标识符进行统一的页面跳转处理。
MaterialApp(
    ...
    //注册路由
    routes:{
      "second_page":(context)=>SecondPage(),
    },
    //错误路由处理,统一返回UnknownPage
    onUnknownRoute: (RouteSettings setting) => MaterialPageRoute(builder: (context) => UnknownPage()),
);

//使用错误名字打开页面
Navigator.pushNamed(context,"unknown_page");
页面参数
  • 与基本路由能够精确地控制目标页面初始化方式不同,命名路由只能通过字符串名字来初始化固定目标页面。为了解决不同场景下目标页面的初始化需求,Flutter 提供了路由参数的机制,可以在打开路由时传递相关参数,在目标页面通过 RouteSettings 来获取页面参数。
//打开页面时传递字符串参数
Navigator.of(context).pushNamed("second_page", arguments: "Hey");

class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //取出路由参数
    String msg = ModalRoute.of(context).settings.arguments as String;
    return Text(msg);
  }
}
  • 除了页面打开时需要传递参数,对于特定的页面,在其关闭时,也需要传递参数告知页面处理结果。
  • 比如在电商场景下,我们会在用户把商品加入购物车时,打开登录页面让用户登录,而在登录操作完成之后,关闭登录页面返回到当前页面时,登录页面会告诉当前页面新的用户身份,当前页面则会用新的用户身份刷新页面。
  • 与 Android 提供的 startActivityForResult 方法可以监听目标页面的处理结果类似,Flutter 也提供了返回参数的机制。在 push 目标页面时,可以设置目标页面关闭时监听函数,以获取返回参数;而目标页面可以在关闭路由时传递相关参数。
class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: <Widget>[
          Text('Message from first screen: $msg'),
          RaisedButton(
            child: Text('back'),
            //页面关闭时传递参数
            onPressed: ()=> Navigator.pop(context,"Hi")
          )
        ]
      ));
  }
}

class _FirstPageState extends State<FirstPage> {
  String _msg='';
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      body: Column(children: <Widget>[
        RaisedButton(
            child: Text('命名路由(参数&回调)'),
            //打开页面,并监听页面关闭时传递的参数
            onPressed: ()=> Navigator.pushNamed(context, "third_page",arguments: "Hey").then((msg)=>setState(()=>_msg=msg)),
        ),
        Text('Message from Second screen: $_msg'),

      ],),
    );
  }
}
  • 在中大型应用中,我们通常会使用命名路由来管理页面间的切换。命名路由的最重要作用,就是建立了字符串标识符与各个页面之间的映射关系,使得各个页面之间完全解耦,应用内页面的切换只需要通过一个字符串标识符就可以搞定,为后期模块化打好基础。
补充

问题:Navigator.pushA->B->C->D,请问如何 D页面 pop 到 B 呢?

答:Navigator.popUntil(context,ModalRoute.withName('B'));


Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://www.yanghujun.com/archives/fluttertechnologycombat4