理解Dart 異步事件流 Stream

轉載:http://blog.sina.com.cn/s/blog_12d64892f0102vtk9.html

基本概念

顧名思義,Stream 就是流的意思,表示發出的一系列的異步數據。可以簡單地認為 Stream 是一個異步數據源。它是 Dart 中處理異步事件流的統一 API。

集合與Stream

Dart 中,集合(Iterable或Collection)表示一系列的對象。而 Stream (也就是“流”)也表示一系列的對象,但區別在于 Stream 是異步的事件流。比如文件、套接字這種 IO 數據的非阻塞輸入流(input data),或者用戶界面上用戶觸發的動作(UI事件)。

集合可以理解為“拉”模式,比如你有一個 List ,你可以主動地通過迭代獲得其中的每個元素,想要就能拿出來。而 Stream 可以理解為“推”模式,這些異步產生的事件或數據會推送給你(并不是你想要就能立刻拿到)。這種模式下,你要做的是用一個 listener (即callback)做好數據接收的準備,數據可用時就通知你。

推和拉就是別人給你還是你自己去拿的區別。但是不管如何獲取數據,二者的本質都可以認為是數據的集合(數據可能無限多)。所以,二者有很多相同的方法,稍后介紹。

怎么理解 Stream 中的數據?

數據(data)是個非常抽象的概念,可以認為一切皆數據。在程序的世界里,其實只有兩種東西:數據和對數據的操作。對數據的操作就是對輸入的數據經過一些計算,之后輸出一些新數據。事件(event,如UI上的事件)、計算結果(value,如函數/方法的返回值)以及從文件或網絡獲得的純數據都可以認為是數據(data)。另外,Dart 中的所有事物都是對象,所以數據也一定是某種對象(object)。在本文中,可以認為事件、結果、數據、對象都是一樣的,不用特意區分。

Stream 與 Future

Stream 和 Future 是 Dart 異步處理的核心 API。Future 表示稍后獲得的一個數據,所有異步的操作的返回值都用 Future 來表示。但是 Future 只能表示一次異步獲得的數據。而 Stream 表示多次異步獲得的數據。比如界面上的按鈕可能會被用戶點擊多次,所以按鈕上的點擊事件(onClick)就是一個 Stream 。簡單地說,Future將返回一個值,而Stream將返回多次值。

另外一點, Stream 是流式處理,比如 IO 處理的時候,一般情況是每次只會讀取一部分數據(具體取決于實現)。和一次性讀取整個文件的內容相比,Stream 的好處是處理過程中內存占用較小。而 File 的 readAsString(異步讀,返回 Future)或 readAsStringSync(同步讀,返回 String)等方法都是一次性讀取整個文件的內容進來,雖然獲得完整內容處理起來比較方便,但是如果文件很大的話就會導致內存占用過大的問題。


基本使用

獲得 Stream

Dart 中統一使用 Stream 處理異步事件流,所以可以獲得 Stream 的地方很多。為了方便演示,這里先介紹2種獲取 Stream 的方式。

  1. 將集合(Iterable)包裝為 Stream
    Stream 有3個工廠構造函數:fromFuture、fromIterable 和 periodic,分別可以通過一個 Future、Iterable或定時觸發動作作為 Stream 的事件源構造 Stream。下面的代碼就是通過一個 List 構造的 Stream。
var data = [1, 2, 3, 4];
var stream = new Stream.fromIterable(data);

對集合的包裝只是簡單地模擬異步,定時觸發、IO輸入、UI事件等現實情況才是真正的異步事件。

  1. 使用 Stream 讀文件
    讀文件的方式有多種,其中一種是使用 Stream 獲得文件內容。File 的方法 openRead() 返回一個 Stream>,List 可以理解為一個 byte array,因為 Dart 中沒有 byte 類型。下面的代碼將打開當前程序的源代碼的 Stream 輸入流。
var stream = new File(new Options().script).openRead();

訂閱 Stream

當你有了一個 Stream 時,最常用的功能就是通過 listen() 方法訂閱 Stream 上發出的數據(即事件)。有事件發出時就會通知訂閱者。如果在發出事件的同時添加訂閱者,那么要在訂閱者在該事件發出后才會生效。如果訂閱者取消了訂閱,那么它會立即停止接收事件。

我們在接收一個輸入流的時候要面臨幾種不同的情況和狀態,最基本的是處理收到數據,此外上游還可能出現錯誤,以及出現錯誤時是否繼續后續數據的處理,最后在輸入完成的時候還有一個結束狀態。所以 listen 方法的幾個參數分別對應這些情況和狀態:

onData,處理收到的數據的 callback
onError,處理遇到錯誤時的 callback
onDone,結束時的通知 callback
unsubscribeOnError,遇到第一個錯誤時是否停止(也就是取消訂閱),默認為false

onData 是唯一必填參數,也是用的最多的,后面3個是可選的命名參數。

下面我們訂閱一個 Stream 的數據,收到數據時只是簡單地打印出來:

var data = [1, 2, 3, 4];
var stream = new Stream.fromIterable(data);

stream.listen((e)=>print(e), onDone: () => print('Done'));
// => 1, 2, 3, 4
// => Done

上面的代碼會先打印出從 Stream 收到的每個數字,最后打印一個‘Done’。

當 Stream 中的所有數據發送完時,就會觸發 onDone 的調用,但提前取消訂閱不會觸發 onDone 。在結束的同時(收到 onDone 事件之前),所有的訂閱者都被取消了訂閱,此時 Stream 上便沒有訂閱者了。允許對一個已經結束了的 Stream 再添加訂閱者(盡管沒什么意義),此時只會立刻收到一個 onDone 事件。

stream.listen(print, onDone: () {
print('first done');
//listen again
stream.listen(print, onDone:() => print('second done'));
});
// => data: 1,2,3,4,
// => first done
// => no data, because stream is done
// => second done

上面的代碼中,首先我們在 onDone 的回調中打印了 ‘first done’ 表示第一次結束。此時 stream 上已經沒有訂閱者了,但接著我們又再次訂閱了這個 stream。這一次沒有再收到數據,而是馬上打印出了 ‘second done’ 表示第二次訂閱的結束。

高級訂閱管理

前面的示例代碼會處理 Stream 發出的所有數據,直到 Stream 結束。如果想提前取消處理怎么辦?listen() 方法會返回一個 StreamSubscription 對象,用于提供對訂閱的管理控制。onData、onError和onDone 這3個方法分別用于設置(如果listen方法中的參數為null)或覆蓋對應的 callback。cancel、pause和resume分別用于取消訂閱、暫停和繼續。比如,可以在 listen 方法中參數置為 null,接著通過 subscription 對象設置 callback 。此外,cancel 方法也重要,要么一直處理數據直到 stream 結束,要么提前取消訂閱結束處理。比如使用 Stream 讀文件,為了使資源得到釋放,要么讀完整個文件,要么使用 subscription 的 cancel 方法取消訂閱(即終止后續數據的讀取)。可以看出,這里的 cancel 相當于傳統意義上的 close 方法。最后,pause和resume方法是嘗試向數據源發出暫停和繼續的請求,其意義取決于實際情況,并且不保證一定能生效。比如數據源能夠支持,或者是帶緩沖實現的 stream 才能做到暫停。

var sub = stream.listen(null);
sub.onData(print);
sub.onError((e)=>print('error $e'));
sub.onDone(()=>print('done'));
// => 1, 2, 3, 4, done

上面的代碼與前面的 listen 示例代碼作用相同。

var sub = stream.listen(null);
sub.onData((e){
if(e > 2)
sub.cancel();
else
print(e);
});
sub.onDone(()=>print('done'));
// => 1, 2
// no 'done', because stream is cancel.

上面的代碼最后會打印出1和2,但不會打印出‘done’ 。首先,listen 中的參數為 null,也就是沒有訂閱者。然后,通過 listen 的返回者 subscription 對象設置了 onData 和 onDone 的處理,這時才有了訂閱者。在 onData 中,如果收到的數字大于2就取消后續處理,因此到數字 3 的時候就沒有打印 3,而是立即結束了處理,這樣后面的 4 也不會出現了。既然是提前退出,所以 onDone 也是不會觸發的。

Stream 兩種訂閱模式

Stream有兩種訂閱模式:單訂閱(single)和多訂閱(broadcast)。單訂閱就是只能有一個訂閱者,而廣播是可以有多個訂閱者。這就有點類似于消息服務(Message Service)的處理模式。單訂閱類似于點對點,在訂閱者出現之前會持有數據,在訂閱者出現之后就才轉交給它。而廣播類似于發布訂閱模式,可以同時有多個訂閱者,當有數據時就會傳遞給所有的訂閱者,而不管當前是否已有訂閱者存在。

Stream 默認處于單訂閱模式,所以同一個 stream 上的 listen 和其它大多數方法只能調用一次,調用第二次就會報錯。但 Stream 可以通過 transform() 方法(返回另一個 Stream)進行連續調用。通過 Stream.asBroadcastStream() 可以將一個單訂閱模式的 Stream 轉換成一個多訂閱模式的 Stream,isBroadcast 屬性可以判斷當前 Stream 所處的模式。

assert(stream.isBroadcast == false);
stream.first.then(print);
stream.last.then(print);// Bad state: Stream already has subscriber.

上的代碼需要分別打印出 stream 的第一個數據和最后一個數據,但是單模式 Stream 只能訂閱一次,所以直接出錯了。當然,Stream 是異步的,所以 first 也沒有打印出來。

var bs = stream.asBroadcastStream();
assert(bs.isBroadcast == true);
bs.first.then(print);
bs.last.then(print);
// OK => 1, 4

上面的代碼,我們把單模式 Stream 轉成了多訂閱的 Stream,所以可以 first 和 last 都打印出來了。

按前面說的,單訂閱模式會持有數據,多訂閱模式如果沒有及時添加訂閱者則可能丟數據。不過具體取決于 stream 的實現。

new Timer(new Duration(seconds:5), ()=>stream.listen(print));
// after 5 second, it output 1,2,3,4

上面的代碼利用 Timer 延遲了5秒才訂閱 stream,但仍然輸出了數據。因為我們這里的這個 stream 是單訂閱模式,它在有訂閱者后才會發出事件。那么多訂閱模式就一定會漏掉數據嗎?

var bs = stream.asBroadcastStream();
new Timer(new Duration(seconds:5), ()=>bs.listen(print));
// after 5 second, it also output 1,2,3,4
// because asBroadcastStream() is a simple wrap,
// it don't change the source stream's feature

上面我們把原始的單訂閱模式轉成了多訂閱模式的 Stream,此時可以添加多個訂閱者。我們5秒后才在 broadcast stream 上添加了訂閱者,但它依然輸出了 1,2,3,4 ,并沒有漏掉數據。這其實是因為 asBroadcastStream() 只是對原始 stream 的封裝,并不改變原始 stream 的實現特性。所以這個 broadcast stream 同樣在等待有訂閱者之后才發出數據。但是如果一旦有了第一個訂閱者,然后再延遲添加第二個訂閱者就會漏數據了。

var bs = stream.asBroadcastStream();
// add first listener
new Timer(new Duration(seconds:5), ()=>bs.listen(print));
// after 5 second, it output 1,2,3,4

// add second listener
new Timer(new Duration(seconds:10), ()=>bs.listen(print));
// after 10 second, nothing output, because stream is done

再來看另外一個例子,我們自己來創建一個 Stream。StreamController 用于創建 Stream,它有兩個構造函數,分別用于創建單訂閱模式 Stream 和 多訂閱模式 Stream。然后可以利用 add()、addError() 和 close() 方法發送事件、發送錯誤和結束,這三個方法來自 EventSink,是各種 Sink 上的通用方法。

// build single stream
//var controller = new StreamController();

// build broadcast stream
var controller = new StreamController.broadcast();
//send event
controller..add(1)
..add(2)
..add(3)
..add(4);
//send done
controller.close();

var myStream = controller.stream;
new Timer(new Duration(seconds:5), ()=>myStream.listen(print));
//if myStream is single stream, it output 1,2,3,4
//if myStream is broadcast stream, it output nothing, because stream is done.

Stream 的集合特性

前面說過,Stream 和一般的集合類似,都是一組數據,只不過一個是異步推送,一個是同步拉取。所以他們都很多共同的方法。例如:

stream.any((e) => e > 2).then(print);// stream.any()
print([1,2,3,4].any((e) => e > 2));// iterable.any()
// => true, true

比如 Stream 和 集合 都有 any() 方法,集合是同步的(但是惰性執行,這里因為有 print 調用,所以立刻執行了)并直接返回結果, Stream 上的 any() 方法是異步的,返回的是 Future 。方法本身的含義都是一樣的。上面的代碼雖然 stream 的 any 方法在前,但因為是異步的,所以的輸出在后。

在列舉其它 Stream 和 Iterable 通用的方法:

//常見集合方法
stream.first.then(print);
stream.firstWhere((e)=>e>3, defaultValue:()=>0).then(print);
stream.last.then(print);
stream.lastWhere((e)=>e>3, defaultValue:()=>0).then(print);
stream.length.then(print);
stream.isEmpty.then(print);

stream.any((e) => e > 2).then(print);
stream.every((e) => e > 2).then(print);
stream.contains(3).then(print);
stream.elementAt(2).then(print);
stream.where((e) => e >2).listen(print);

stream.skip(2).listen(print);
stream.skipWhile((e) => e < 2).listen(print);
stream.take(2).listen(print);
stream.takeWhile((e)=>e<3).listen(print);

stream.map((e) => e*2).listen(print);
stream.reduce(0, (p, c) => p + c).then(print);
stream.expand((e) => [e, e]).listen(print);

stream.toList().then(print);
stream.toSet().then(print);

注意以上方法同時只能使用一次,因為是單訂閱模式。此外,如果方法只有一個返回值,即數據收斂類型的方法,那么返回就是一個 Future。如果是只是數據轉換的方法,如 map ,返回的還是一個 Stream,只是數據數據的類型和數量變了。看到這么多 Stream 與 Iterable 相同的方法,大家應該更清楚 Stream 其實也是個數據集合。

通用數據收斂方法

集合中有很多方法只返回一個值,多個數據作為輸入、一個數據作為輸出的方法就是數據收斂的方法。Stream 有一個更通用的收斂方法 pipe() 。pipe() 方法的參數要求是一個 StreamConsumer 接口的實現,該接口只有一個方法: Future consume(Stream stream)

class DataConsumer implements StreamConsumer{
Future consume(Stream stream){
return stream.reduce(0, (c,p)=>c+p);
}
}

stream.pipe(new DataConsumer()).then(print);
// => 10

// equivalent below
stream.reduce(0, (p, c) => p + c).then(print);

上面我們自己實現了一個 StreamConsumer ,它只是對 Stream 的數據求和,并返回該結果。這個簡單的例子實際意義不大。但這里只是為了演示這個通用 pipe() 方法和 StreamConsumer 接口的意義。

通用數據轉換方法

除了數據收斂方法,Stream 也有自己通用的數據轉換方法 transform() 。類似于 Future 的連續調用,Stream 也可以連續調用。 transform 方法就是把一個 Stream 作為輸入,然后經過計算或數據轉換,輸出為另一個 Stream。另一個 Stream 中的數據類型可以不同于原類型,數據多少也可以不同(比如實現一個數據的 buffer )。

transform 的方法簽名是:
Stream transform(StreamTransformer streamTransformer)

下面我們構造一個 StreamTransformer ,然后使用 Stream 的 transform() 進行轉換:

var transformer = new StreamTransformer(
handleData: (e, sink){
sink.add(e*2);
}
);
stream.transform(transformer).listen(print);

// equivalent below
stream.map((e) => e*2).listen(print);

class MyTransformer extends StreamEventTransformer {
handleData(e, sink){
sink.add(e*2);
}
}

stream.transform(new MyTransformer()).listen(print);

使用 StreamTransformer 接口的工廠構造函數 或者 繼承 StreamEventTransformer 都可以構造一個 transformer 。其本質和我們處理一個 Stream 是一樣的,就要要處理 handleData、handleError 和 handleDone 這三件事。上面的 transform 和 map 方法類似,但是 transform 方法比 map 方法更靈活。map 只能做1對1的轉換,而 transform 并沒有這個要求,因為它是利用 sink 來添加數據,而不是返回轉換結果。transform 方法和 StreamTransformer 接口是一種更通用的設計。

舉個更實用點例子,Dart 中的 StringDecoder 和 StringEncoder 就是一個 StreamTransformer,負責實現 byte stream 和 String stream 之間的轉換。LineTransformer 是切分行的 transformer。比如,使用 Stream 讀文件需要先將字節轉換為字符,然后還可以按行讀取。

file.openRead()
.transform(new StringDecoder())
.transform(new LineTransformer()) .listen(your_process);

注意,不管是 Stream.map() 還是 Stream.transform() ,他們都是在做轉換,而非訂閱。對于單模式 Stream ,如果沒有添加訂閱者,那么轉換方法根本不會執行(可能是由于是惰性執行的緣故)。

stream.map((e){
print(e);
return e*2;
});
// nothing output, because lazy evaluate

class MyTransformer extends StreamEventTransformer {
handleData(e, sink){
print(e);
sink.add(e*2);
}
}
stream.transform(new MyTransformer());
// nothing output, because no subscription

上面的示例中,都在轉換過程中做了輸出,但實際不會輸出內容,因為沒有用 listen 添加訂閱者。

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容