[译]Flutter for Android Developers - Async UI

6,786 阅读10分钟

写在前面

为了帮助理解本篇的内容,先简单介绍下Dart的运行机制。

isolate

Dart是基于单线程模型的语言。在Dart中有一个很重要的概念叫isolate,它其实就是一个线程或者进程的实现,具体取决于Dart的实现。默认情况下,我们用Dart写的应用都是运行在main isolate中的(可以对应理解为Android中的main thread)。当然我们在必要的时候也可以通过isolate API创建新的isolate,多个isolate可以更好的利用多核CPU的特性来提高效率。但是要注意的是在Dart中isolate之间是无法直接共享内存的,不同的isolate之间只能通过isolate API进行通信。关于isolate更多详情可以参阅官方文档

event loop

同Android类似的是在Dart运行环境中也是靠事件驱动的,通过event loop不停的从队列中获取消息或者事件来驱动整个应用的运行。但是不同点在于一个Dart编写的app中一般有两个队列,一个叫做event queue,另一个叫做microtask queue。而在Android中通常只有一个message queue。另外,由于isolate之间不能直接共享内存,所以每个isolate内的event loop,event queue和microtask queue也是各自独享的。 为什么需要两个队列呢?我们看一张图就明白了:

这张图以main isolate为例,描述了app运行时一个isolate中的正常运行流程。

  1. 启动app。
  2. 首先执行main方法。
  3. 在main方法执行完后,开始处理microtask queue,从中取出microtask执行,直到microtask queue为空。这里可以看到event loop在运行时是优先处理microtask queue的。
  4. 当microtask queue为空才会开始处理event queue,如果event queue不为空则从中取出一个event执行。这里要注意的是event queue并不会一直遍历完,而是一次取出一个event执行,执行完后就回到前面去重新判断microtask queue是否为空。所以这里可以看到microtask queue存在的一个重要意义是由它的运行时机决定的,当我们想要在处理当前的event之后,并且在处理下一个event之前做一些事情,或者我们想要在处理所有event之前做一些事情,这时候可以将这些事情放到microtask queue中。
  5. 当microtask queue和event queue都为空时,app可以正常退出。

Note: 当event loop在处理microtask queue时,会阻塞住event queue。绘制和交互等任务是作为event存放在event queue中的,所以当microtask queue中任务太多或处理时长太长,将会导致应用的绘制和交互等行为被卡住。

关于Dart中event loop的更多详情可以参阅官方文档

future

Future是Dart中提供的一个类,它用于封装一段在将来会被执行的代码逻辑。构造一个Future就会向event queue中添加一条记录。如果把event queue类比Android中的message queue的话,那么可以简单的把Future类比为Android中的Message。只不过Future中包含了需要完成的整个操作。并且利用Future的then和whenComplete方法可以指定在完成Future包含的操作后立马执行另一段逻辑。 关于Future的更多详情可以参阅官方文档

async and await

在Android中我们可以利用Java API自己来管理线程,通过创建新的线程完成异步的操作。 在Flutter中,虽然Dart是基于单线程模型的,但是这并不意味着我们没法完成异步操作。在Dart中我们可以通过async关键字来声明一个异步方法,异步方法会在调用后立即返回给调用者一个Future对象(但这个逻辑存在一些漏洞,在Dart2中有一些改变,详见synchronous async start discussion),而异步方法的方法体将会在后续被执行(应该也是通过协程的方式实现)。在异步方法中可以使用await表达式挂起该异步方法中的某些步骤从而实现等待某步骤完成的目的,await表达式的表达式部分通常是一个Future类型,即在await处挂起后交出代码的执行权限直到该Future完成。在Future完成后将包含在Future内部的数据类型作为整个await表达式的返回值,接着异步方法继续从await表达式挂起点后继续执行。 在后面开始介绍Async UI in Flutter时会看到很多使用async和await的例子。

Note:

  1. async修饰的异步方法需要声明返回一个Future类型,如果方法体内没有主动的返回一个Future类型,系统会将返回值包含到一个Future中返回。
  2. await表达式的表达式部分需要返回一个Future对象。
  3. await表达式需要在一个async修饰的方法中使用才会生效。 关于async和await的更多详情可以参阅官方文档

在Flutter中runOnUiThread等价于什么

  • in Android

    • 基于Java,线程的管理完全由开发者决定,我们无法在非main thread更新UI,所以可以通过在非main thread中利用Activity.runOnUiThread方法向main thread的message queue中post一个更新界面的消息实现界面刷新。
  • in Flutter

    • Dart是基于单线程模型的,所以除非我们主动创建一个isolate,否则我们的Dart代码都是运行在main isolate(类比Android的main thread)并且由event loop来驱动的。

通过协程实现的异步调用其实也是运行在main isolate的,所以其实在Flutter中并不需要runOnUiThread类似方法的存在,我们下面看一个例子,我们可以直接在main isolate执行网络请求而不卡住界面和交互:

loadData() async {
  String dataURL = "https://jsonplaceholder.typicode.com/posts";
  http.Response response = await http.get(dataURL);
  setState(() {
    widgets = JSON.decode(response.body);
  });
}

这里首先将loadData方法声明为异步方法,然后用await表达式在http.get(dataURL)处挂起等待,http是Dart提供的一个网络请求库。在请求完成时会返回一个Future<http.Response>对象,所以await表达式的表达式部分返回的是一个Future<http.Response>类型,整个await表达式返回的就是一个http.Response类型。接下来就如FFAD-Views中说的那样,通过setState改变一个StatefulWidget的State来触发系统重新调用其build方法更新Widget。

下面是一个在ListView中展示异步加载的数据的例子:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(new SampleApp());
}

class SampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Sample App',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => new _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();

    loadData();
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text("Sample App"),
        ),
        body: new ListView.builder(
            itemCount: widgets.length,
            itemBuilder: (BuildContext context, int position) {
              return getRow(position);
            }));
  }

  Widget getRow(int i) {
    return new Padding(
        padding: new EdgeInsets.all(10.0),
        child: new Text("Row ${widgets[i]["title"]}")
    );
  }

  loadData() async {
    String dataURL = "https://jsonplaceholder.typicode.com/posts";
    http.Response response = await http.get(dataURL);
    setState(() {
      widgets = JSON.decode(response.body);
    });
  }
}

界面展示的部分与FFAD-Views中介绍的StatefulWidget的展示没有太大区别,我们声明的loadData异步方法在_SampleAppPageState的initState方法中调用,于是触发异步加载数据,await表达式挂起整个异步操作,直到http.get(dataURL)返回时通过setState更新widgets成员变量,进而触发build方法重新调用以更新ListView中的item。

通过协程实现的异步方法通常能够帮助我们在main isolate去执行一些耗时操作并且不会阻塞界面更新。但是有时候我们需要处理大量的数据,就算我们将该操作声明为异步方法依然可能会导致阻塞界面更新,因为通过协程来实现的异步方法说到底还是运行于一个线程之上,在一个线程上去调度执行毕竟算力有限。

这时候我们可以利用多核CPU的优势去完成这些耗时的或CPU密集型的操作。这正是通过前面介绍的isolate来实现。下面的例子展示了如何创建一个isolate,并且如何在创建的isolate和main isolate之间通信来将数据传递回main isolate进而更新界面:

loadData() async {
    ReceivePort receivePort = new ReceivePort();
    await Isolate.spawn(dataLoader, receivePort.sendPort);

    // The 'echo' isolate sends it's SendPort as the first message
    SendPort sendPort = await receivePort.first;

    List msg = await sendReceive(sendPort, "https://jsonplaceholder.typicode.com/posts");

    setState(() {
      widgets = msg;
    });
  }

// the entry point for the isolate
  static dataLoader(SendPort sendPort) async {
    // Open the ReceivePort for incoming messages.
    ReceivePort port = new ReceivePort();

    // Notify any other isolates what port this isolate listens to.
    sendPort.send(port.sendPort);

    await for (var msg in port) {
      String data = msg[0];
      SendPort replyTo = msg[1];

      String dataURL = data;
      http.Response response = await http.get(dataURL);
      // Lots of JSON to parse
      replyTo.send(JSON.decode(response.body));
    }
  }

  Future sendReceive(SendPort port, msg) {
    ReceivePort response = new ReceivePort();
    port.send([msg, response.sendPort]);
    return response.first;
  }

简单解释一下,这段代码主要声明了三个方法。

loadData被声明为一个异步方法,其内部的代码运行于main isolate中。该方法首先声明了一个用于main isolate从其他isolate接受消息的ReceivePort。接着通过spawn命名构造方法生成了一个isolate,为了后续描述简单这里姑且叫它x isolate。该isolate将会以构造时传入的第一个参数dataLoader方法作为运行的入口函数。即生成x isolate后,在x isolate中会开始执行dataLoader方法。构造x isolate时传入的第二个参数是通过main isolate中的ReceivePort获得的一个SendPort,这个SendPort会在dataLoader被执行时传递给它。在x isolate中可以用该SendPort向main isolate发送消息进行通信。 接下来通过receivePort.first获取x isolate发送过来的消息,这里获取到的其实是一个x isolate的SendPort对象,在main isolate中可以利用这个SendPort对象向x isolate中发送消息。 接下来调用sendReceive方法并传入刚刚获得的x isolate的SendPort对象和一个字符串作为参数。 最后调用setState方法触发界面更新。

dataLoader也被声明为一个异步方法,其内部的代码运行于x isolate中。在构建了x isolate后该方法开始在x isolate中执行,要注意的是dataLoader方法的参数是一个SendPort类型的对象,这正是前面构造x isolate时传入的第二个参数,也就是说,前面通过Isolate.spawn命名构造方法构造一个isolate时,传入的第二个参数的用途就是将其传递给第一个参数所表示的入口函数。在这里该参数表示的是main isolate对应的SendPort,通过它就可以在x isolate中向main isolate发送消息。 在dataLoader方法中首先生成了一个x isolate的ReceivePort对象,然后就用main isolate对应的SendPort向main isolate发送了一个消息,该消息其实就是x isolate对应的SendPort对象,所以回过头去看loadData方法中通过receivePort.first获取到的一个SendPort就是这里发送出去的。在main isolate中接收到这个SendPort后,就可以利用该SendPort向x isolate发送消息了。 接下来dataLoader方法则挂起等待x isolate的ReceivePort接受到消息。

sendReceive被声明为一个普通方法,该方法运行于main isolate中,它是在loadData中被调用的。调用sendReceive时传入的第一个参数就是在main isolate中从x isolate接收到的其对应的SendPort对象,所以在sendReceive方法中利用x isolate对应的这个SendPort对象就可以在main isolate中向x isolate发送消息。在这里发送的消息是一个数组[msg, response.sendPort]。消息发送后在dataLoader方法中await挂起的代码就会开始唤醒继续执行,取出传递过来的参数,于是在x isolate中开始执行网络请求的逻辑。 接着将请求结果再通过main isolate对应的SendPort传递给main isolate。于是在sendReceive方法中通过response.first获取到x isolate传递过来的网络请求结果。 最终在setState方法中使用网络请求回来的结果更新数据集触发界面更新。

完整的例子代码:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:async';
import 'dart:isolate';

void main() {
  runApp(new SampleApp());
}

class SampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Sample App',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => new _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  showLoadingDialog() {
    if (widgets.length == 0) {
      return true;
    }

    return false;
  }

  getBody() {
    if (showLoadingDialog()) {
      return getProgressDialog();
    } else {
      return getListView();
    }
  }

  getProgressDialog() {
    return new Center(child: new CircularProgressIndicator());
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text("Sample App"),
        ),
        body: getBody());
  }

  ListView getListView() => new ListView.builder(
      itemCount: widgets.length,
      itemBuilder: (BuildContext context, int position) {
        return getRow(position);
      });

  Widget getRow(int i) {
    return new Padding(padding: new EdgeInsets.all(10.0), child: new Text("Row ${widgets[i]["title"]}"));
  }

  loadData() async {
    ReceivePort receivePort = new ReceivePort();
    await Isolate.spawn(dataLoader, receivePort.sendPort);

    // The 'echo' isolate sends it's SendPort as the first message
    SendPort sendPort = await receivePort.first;

    List msg = await sendReceive(sendPort, "https://jsonplaceholder.typicode.com/posts");

    setState(() {
      widgets = msg;
    });
  }

// the entry point for the isolate
  static dataLoader(SendPort sendPort) async {
    // Open the ReceivePort for incoming messages.
    ReceivePort port = new ReceivePort();

    // Notify any other isolates what port this isolate listens to.
    sendPort.send(port.sendPort);

    await for (var msg in port) {
      String data = msg[0];
      SendPort replyTo = msg[1];

      String dataURL = data;
      http.Response response = await http.get(dataURL);
      // Lots of JSON to parse
      replyTo.send(JSON.decode(response.body));
    }
  }

  Future sendReceive(SendPort port, msg) {
    ReceivePort response = new ReceivePort();
    port.send([msg, response.sendPort]);
    return response.first;
  }

}

小结:

  1. 在Flutter中一般情况下不需要runOnUiThread,AsyncTask,IntentService等类似的概念,因为Dart是基于单线程模型的。异步方法的执行也是通过协程实现的,其实际也还是运行于main isolate中。
  2. Dart中的代码都是运行在isolate中的,各个isolate之间的内存是没法直接共享的。但是可以通过ReceivePort和SendPort来实现isolate之间的通信。每个isolate都有自己对应的ReceivePort和SendPort,ReceivePort用于接受其他isolate发送过来的消息,SendPort则用于向其他isolate发送消息。关于ReceivePort和SendPort更多详情可以参阅官方文档

在Flutter中OkHttp等价于什么

  • in Android

    • 我们有很多类似OkHttp之类的网络库使用。
  • in Flutter

    • 我们使用http package来简单的完成一个网络请求调用。

虽然http package没有实现OkHttp已经实现的所有功能,但是它实现了很多常用的网络请求功能,帮助我们更简单的完成一个网络请求调用。关于http package的更多信息可以参阅官方文档。 在使用http package之前我们需要先在pubspec.yaml文件中配置依赖:

dependencies:
  ...
  http: '>=0.11.3+12'

然后就可以简单的发起一个网络请求调用:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
[...]
  loadData() async {
    String dataURL = "https://jsonplaceholder.typicode.com/posts";
    http.Response response = await http.get(dataURL);
    setState(() {
      widgets = JSON.decode(response.body);
    });
  }
}

代码很简单,其实前面的分析中我们就已经看到了http package的身影。在这里是直接调用http.get(dataURL)方法发起一个get请求,参数是一个url,该方法返回的是一个Future<http.Response>类型,所以最终整个await表达式返回的就是一个http.Response类型。一旦请求完成获取到了数据我们就可以调用setState方法来触发系统更新界面。

小结:

在Flutter中我们使用http package来帮助我们更简单的实现网络请求调用。

在Flutter中怎样在一个任务正在运行时显示一个Loading Dialog

  • in Android

    • 我们可以在运行一个耗时任务时展示一个Loading Dialog,可以使用Dialog或者其他自定义的View来实现。
  • in Flutter

    • 我们可以用一个Progress Indicator Widget来实现一个Loading Dialog。

下面我们可以看一个例子:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(new SampleApp());
}

class SampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Sample App',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => new _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  showLoadingDialog() {
    if (widgets.length == 0) {
      return true;
    }

    return false;
  }

  getBody() {
    if (showLoadingDialog()) {
      return getProgressDialog();
    } else {
      return getListView();
    }
  }

  getProgressDialog() {
    return new Center(child: new CircularProgressIndicator());
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text("Sample App"),
        ),
        body: getBody());
  }

  ListView getListView() => new ListView.builder(
      itemCount: widgets.length,
      itemBuilder: (BuildContext context, int position) {
        return getRow(position);
      });

  Widget getRow(int i) {
    return new Padding(padding: new EdgeInsets.all(10.0), child: new Text("Row ${widgets[i]["title"]}"));
  }

  loadData() async {
    String dataURL = "https://jsonplaceholder.typicode.com/posts";
    http.Response response = await http.get(dataURL);
    setState(() {
      widgets = JSON.decode(response.body);
    });
  }
}

上面的例子其实跟FFAD-Views中介绍过的一些例子套路很像,我们主要需要关注的是_SampleAppPageState类的build方法。这里看到构造Scaffold时的body参数传递的是一个方法getBody,这个方法内部又根据成员变量widgets的数量是否为0来判断返回的Widget具体是一个怎样的Widget。当widgets的数量为0时返回一个由Center包裹的CircularProgressIndicator Widget。否则返回一个ListView Widget。成员变量widgets同时又作为一个State在数据加载完成时通过setState方法来更新。所以我们看到的效果就是应用刚启动时由于数据未加载完成显示CircularProgressIndicator的Loading过程,当异步函数loadData加载完成数据后通过setState触发界面更新,此时显示ListView展示的数据界面。

小结:

在Flutter中有几个内置的ProgressIndicator供我们用来实现Loading Dialog的效果,本例中使用的是CircularProgressIndicator。结合StatefulWidget就可以实现在耗时任务执行完成前显示Loading Dialog,在耗时任务执行完成之后更新界面的效果。

英文原版传送