Flutter非同步程式設計詳解

chonglingliu發表於2021-05-28

不知道大家有沒有一個疑問:Dart是單執行緒執行,那它是如何實現非同步操作的呢?

本文將對Dart/Flutter提供的Isolate,Event Loop,Future,async/await等進行非同步操作相關的知識點進行分析。

Isolate

什麼是Isolate?

An isolate is what all Dart code runs in. It’s like a little space on the machine with its own, private chunk of memory and a single thread running an event loop.

  • Isolate相當於Dart語言中的執行緒Thread,是Dart/Flutter的執行上下文環境(容器);
  • Isolate有自己獨立的記憶體地址和Event Loop,不存在共享記憶體所以不會出現死鎖,但是比Thread更耗記憶體;

Isolate

  • Isolate間不能直接訪問,需憑藉Port進行通訊;

通訊

Main Isolate

當執行完main()入口函式後,Flutter會建立一個Main Isolate。一般情況下任務都是在這個Main Isolate中執行的。

多執行緒

一般情況下在Main Isolate執行任務是可以接受的,但是把一些耗時操作放在Main Isolate中執行,會造成掉幀的現象,這會對使用者體驗造成嚴重影響。此時,選擇將耗時任務分發到其他的Isolate中就是一個很好的實現方式了。

所有的Dart Code都是在Isolate中執行的,程式碼只能使用同一個Isolate中的內容,不同的 Isolate是記憶體隔離的,因此只能通過 Port 機制傳送訊息通訊,其原理是向不同的 Isolate 佇列中執行寫任務。

案例

案例

我們做了個簡單的Demo,螢幕中間有一個心在不停的動畫(由小變大,再由大變小)。當我們點選右下角對的加號按鈕,會進行一個耗時的運算。如果耗時操作在Main Isolate執行,將會造成介面的丟幀,動畫將會出現卡頓的情況。

掉幀現象

我們目前就是需要解決這個掉幀的問題。

1.compute 方法

Flutter封裝了一個compute這個高階API函式可以讓我們方便的實現多執行緒的功能。

Future<R> compute<Q, R>(isolates.ComputeCallback<Q, R> callback, Q message, { String? debugLabel }) async {
}
複製程式碼

compute接收兩個必傳引數:1,需要執行的方法;2,傳入的引數,這引數最多隻能是1個,所以多個引數需要封裝到Map中;

  • 最開始的程式碼
// 耗時操作的方法:`bigCompute`
Future<int> bigCompute(int initalNumber) async {
    int total = initalNumber;
    for (var i = 0; i < 1000000000; i++) {
      total += i;
    }
    return total;
}

// 點選按鈕呼叫的方法:`calculator`
void calculator() async {
    int result = await bigCompute(0);
    print(result);
}

// FloatingActionButton的點選事件
FloatingActionButton(
    onPressed: calculator,
    tooltip: 'Increment',
    child: Icon(Icons.add),
)
複製程式碼
  • 修改程式碼
  1. 新建一個calculatorByComputeFunction方法,用compute呼叫bigCompute方法:
void calculatorByComputeFunction() async {
    // 使用`compute`呼叫`bigCompute`方法,傳參0
    int result = await compute(bigCompute, 0);
    print(result);
}
複製程式碼
  1. 修改FloatingActionButton的點選事件方法為calculatorByComputeFunction
FloatingActionButton(
    onPressed: calculatorByComputeFunction,
    tooltip: 'Increment',
    child: Icon(Icons.add),
)
複製程式碼

我們點選試試?

[VERBOSE-2:ui_dart_state.cc(186)] Unhandled Exception: Invalid argument(s): Illegal argument in isolate message : (object is a closure - Function 'bigCompute':.)

  1. 解決Error:將bigCompute改為為static方法(改為全域性函式也是可行的)
static Future<int> bigCompute(int initalNumber) async {
    int total = initalNumber;
    for (var i = 0; i < 1000000000; i++) {
      total += i;
    }
    return total;
}
複製程式碼

警告:還有一個需要注意的是所有的Platform-Channel的通訊必須在Main Isolate中執行,譬如在其他Isolate中呼叫rootBundle.loadString("assets/***")就掉坑裡了。

2. 直接使用Isolate

上面我們用compute方法,基本上沒有看到Isolate的身影,因為Flutter幫我們做了很多工作,包括Isolate建立,銷燬,方法的執行等等。一般情況下我們使用這個方法就夠了。

但是這個方法有個缺陷,我們只能執行一個任務,當我們有多個類似的耗時操作時候,如果使用這個compute方法將會出現大量的建立和銷燬,是一個高消耗的過程,如果能複用Isolate那就是最好的實現方式了。

多執行緒Isolate間通訊的原理如下:

  1. 當前Isolate接收其他Isolate訊息的實現邏輯: Isolate之間是通過Port進行通訊的,ReceivePort是接收器,它配套有一個SendPort傳送器, 當前Isolate可以把SendPort傳送器送給其他Isolate,其他Isolate通過這個SendPort傳送器就可以傳送訊息給當前Isolate了。

  2. 當前Isolate給其他Isolate發訊息的實現邏輯: 其他Isolate通過當前IsolateSendPort傳送器傳送一個SendPort2傳送器2過來,其他的Isolate則持有SendPort 2傳送器2對應的接收器ReceivePort2接收器2,當前Isolate通過SendPort 2傳送訊息就可以被其他Isolate收到了。

是不是很繞!我再打個比喻:市面上有一套通訊工具套件,這套通訊工具套件包括一個接電話的工具和一個打電話的工具。A留有接電話的,把打電話的送給B,這樣B就可以隨時隨地給A打電話了(此時是單向通訊)。 如果B也有一套工具,把打電話的送給A,這樣A也能隨時隨地給B打電話了(此時是雙向通訊了)。

上程式碼:

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
    // 1.1 新建的isolate
    Isolate isolate;
    // 1.2 Main Isolate的接收器
    ReceivePort mainIsolaiteReceivePort;    
    // 1.3 Other Isolate的傳送器
    SendPort otherIsolateSendPort;
    
    // 新建(複用)Isolate
    void spawnNewIsolate() async {
        // 2.1 建一個接收Main Isolate的接收器
        if (mainIsolaiteReceivePort == null) {
          mainIsolaiteReceivePort = ReceivePort();
        }
        try {
          if (isolate == null) {
            // 2.2 新建的isolate, 把Main Isolate傳送器傳給新的isolate,calculatorByIsolate是需要執行的任務
            isolate = await Isolate.spawn(
                calculatorByIsolate, mainIsolaiteReceivePort.sendPort);
            // 2.3 Main Isolate 通過接收器接收新建的isolate發來的訊息    
            mainIsolaiteReceivePort.listen((dynamic message) {
              if (message is SendPort) {
                // 2.4 如果新建的isolate發來的是一個傳送器,就通過這個傳送器給新建的isolate傳送值過去(此時雙向通訊建立成功)
                otherIsolateSendPort = message;
                otherIsolateSendPort.send(1);
                print("雙向通訊建立成功,主isolate傳遞初始引數1");
              } else {
                // 2.5 如果新建的isolate發來了一個值,我們知道是耗時操作的計算結果。
                print("新建的isolate計算得到的結果$message");
              }
            });
          } else {
            // 2.6 複用otherIsolateSendPort
            if (otherIsolateSendPort != null) {
              otherIsolateSendPort.send(1);
              print("雙向通訊複用,主isolate傳遞初始引數1");
            }
          }
        } catch (e) {}
    }
    
    // 這個是新的Isolate中執行的任務
    static void calculatorByIsolate(SendPort sendPort) {
        // 3.1 新的Isolate把傳送器發給Main Isolate
        ReceivePort receivePort = new ReceivePort();
        sendPort.send(receivePort.sendPort);
        
        // 3.2 如過Main Isolate發過來了初始資料,就可以進行耗時計算了
        receivePort.listen((val) {
          print("從主isolate傳遞過來的初始引數是$val");
          int total = val;
          for (var i = 0; i < 1000000000; i++) {
            total += i;
          }
          // 3.3 通過Main Isolate的傳送器發給Main Isolate計算結果
          sendPort.send(total);
        });
    } 
    
    @override
    void dispose() {
        // 釋放資源
        mainIsolaiteReceivePort.close();
        isolate.kill();
        super.dispose();
    }
}
複製程式碼

程式碼註釋的很詳細了,就不再解釋了。是不是程式碼好多的感覺,其實如果理解流程了邏輯倒不復雜。

關於Isolate的概念和使用我們就介紹到這裡,接下來我們來介紹Isolate中的一個重要知識點Event Loop.

Event Loop

Loop這個概念絕大部分開發者都應該很熟悉了,iOS中有NSRunLoop,Android中有Looper, js中有Event Loop,名字上類似,其實所做的事情也是類似的。

Event Loop的官方介紹如下圖:

  • 靜態示意圖

Event Loop

執行完main()函式後將會建立一個Main Isolate

  • 動態示意圖

動圖

  • Event Loop會處理兩個佇列MicroTask queueEvent queue中的任務;
  • Event queue主要處理外部的事件任務:I/O,手勢事件,定時器,isolate間的通訊等;
  • MicroTask queue主要處理內部的任務:譬如處理I/O事件的中間過程中可能涉及的一些特殊處理等;
  • 兩個佇列都是先進先出的處理邏輯,優先處理MicroTask queue的任務,當MicroTask queue佇列為空後再執行Event queue中的任務;
  • 當兩個佇列都為空的時候就進行GC操作,或者僅僅是在等待下個任務的到來。

為了比較好的理解 Event Loop 的非同步邏輯,我們來打個比喻:就像我去長沙某網紅奶茶品牌店買杯“幽蘭拿鐵”(由於是現做的茶,比較耗時)的過程。

  1. 我來到前臺給服務員說我要買一杯你們店的“幽蘭拿鐵”,然後服務員遞給了我一個有編號的飛盤(獲取憑證);
  2. 奶茶店的備餐員工就將我的訂單放在訂單列表的最後面,他們按照順序準備訂單上的商品,準備好一個就讓顧客去領取(Event queue 先進先出進行處理),而我就走開了,該幹啥幹啥去了(非同步過程,不等待處理結果);
  3. 突然他們來了個超級VIP會員的訂單,備餐員工就把這個超級VIP訂單放在了其他訂單的最前面,優先安排了這個訂單的商品(MicroTask優先處理)---此場景為虛構;
  4. 當我的訂單完成後,飛盤開始震動(進行結果回撥),我又再次回到了前臺,如果前臺妹子遞給我一杯奶茶(獲得結果),如果前臺妹子說對不起先生,到您的訂單的時候沒水了,訂單沒法完成了給我退錢(獲得異常錯誤錯誤)。

我們常用的非同步操作Future,async,await都是基於Event Loop,我們接下來就來介紹他們非同步操作背後的原理。

Future

我們接下來用程式碼總體說明一下Future背後的邏輯:


final myFuture = http.get('https://my.image.url');
myFuture.then((resp) {
    setImage(resp);
}).catchError((err) {
    print('Caught $err'); // Handle the error.
});
// 繼續其他任務
...
複製程式碼
  1. http.get('https://my.image.url')返回的是一個未完成狀態的Future, 可以理解為一個控制程式碼,同時http.get('https://my.image.url')被丟進了Event queue中等待被執行,然後接著執行當前的其他任務;
  2. Event queue執行完這個get請求成功後會回撥then方法,將結果返回,Future為完成狀態 ,就可以進行接下來的操作了;
  3. Event queue執行完這個get請求失敗後會回撥catchError方法,將錯誤返回,Future為失敗狀態 ,就可以進行錯誤處理了。

我們接下來分別介紹下Future的一些相關函式:

建構函式
  • Future(FutureOr<T> computation())
final future1 = Future(() {
    return 1;
});
複製程式碼

computation被放入了Event queue佇列中

  • Future.value
final future2 = Future.value(2);
複製程式碼

值在MicroTask queue佇列中返回

  • Future.error(Object error, [StackTrace? stackTrace])
final future3 = Future.error(3);
複製程式碼

這個error表示出現了錯誤,其中的值不一定需要給一個Error物件

  • Future.delay
final future4 = Future.delayed(Duration(seconds: 1), () {
    return 4;
});
複製程式碼

延遲一定時間再執行

Future結果回撥then
final future = Future.delayed(Duration(seconds: 1), () {
    print('進行計算');
    return 4;
});
future.then((value) => print(value));
print('繼續進行接下來的任務');

// flutter: 繼續進行接下來的任務
// flutter: 進行計算
// flutter: 4
複製程式碼
Future出現錯誤後的回撥onError
final future = Future.error(3);
future.then((value) => print(value))
.onError((error, stackTrace) => print(error));
    
print('繼續進行接下來的任務');

// flutter: 繼續進行接下來的任務
// flutter: 3
複製程式碼
Future完成的回撥whenComplete
final future = Future.error(3);
future.then((value) => print(value))
.onError((error, stackTrace) => print(error))
.whenComplete(() => print("完成"));
    
print('繼續進行接下來的任務');

// flutter: 繼續進行接下來的任務
// flutter: 3
// flutter: 完成
複製程式碼

async/await

做過前端開發的對這兩個關鍵字應該很熟悉,Flutterasync/await本質上只是Future的語法糖,使用方法也很簡單。

Future<String> createOrderMessage() async {
  var order = await fetchUserOrder();
  return 'Your order is: $order';
}
複製程式碼
  1. await放在返回值為Future的執行任務前面,相當於做了個標記,表示執行到此為止,等有結果後再往下執行;
  2. 使用了await必須在方法後面加上async;
  3. async方法必須在返回值上封裝上Future

FutureBuilder

Flutter為我們封裝了一個FutureBuilder這個Widget,可以方便的構造UI, 以獲取圖片進行展示為例:

FutureBuilder(
    future: 載入圖片的Future
    uilder: (context, snapshot) {
    // 未完成
    if (!snapshot.hasData) {
        // 使用預設的佔點陣圖
    } else if (snapshot.hasError) {
        // 使用載入失敗的圖
    }  else {
        // 使用載入到的圖
    }
},
複製程式碼

總結

通過新建Isolate可以實現多執行緒,每個執行緒Isolate都有Event Loop可以執行非同步操作。

有些移動開發者可能偏愛ReactiveX響應式程式設計,譬如RxJavaRxSwiftReactiveCocoa等。其實他們也是非同步程式設計的一種方式,Flutter為我們提供了一個對應的類---Stream,其也有豐富的中間操作符,還提供了StreamBuilder可以構建UI,接下來我們將會一篇文章來分析它。

相關文章