[譯]Flutter for Android Developers - Async UI

catsuo發表於2018-03-03

寫在前面

為了幫助理解本篇的內容,先簡單介紹下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也是各自獨享的。 為什麼需要兩個佇列呢?我們看一張圖就明白了:

[譯]Flutter for Android Developers - Async UI
這張圖以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,在耗時任務執行完成之後更新介面的效果。

英文原版傳送

相關文章