由于近期時間相對寬裕,以及很多朋友詢問關于RAC的學習路徑以及資料,故而整理一下之前自己學習RAC的學習筆記,供大家查閱練習。該文章只講解了RAC的詳細用法,有關其內部原理以及實現,請參考ReactiveCocoa深入理解 。
一. ReactiveCocoa簡介
1.解決的問題
作為一個iOS開發者,你寫的每一行代碼幾乎都是在相應某個事件,例如按鈕的點擊,收到網絡消息,屬性的變化(通過KVO)或者用戶位置的變化(通過CoreLocation)。但是這些事件都用不同的方式來處理,比如action、delegate、KVO、callback等。ReactiveCocoa為事件定義了一個標準接口,從而可以使用一些基本工具來更容易的連接、過濾和組合。
2.編程思想
ReactiveCocoa結合了幾種編程風格:
函數式編程(Functional Programming)
響應式編程(Reactive Programming)
所以,你可能聽說過ReactiveCocoa被描述為函數響應式編程(FRP)框架。以后使用RAC解決問題,就不需要考慮調用順序,直接考慮結果,把每一次操作都寫成一系列嵌套的方法中,使代碼高聚合,方便管理。
3.常見的五個宏
- RAC(TARGET, [KEYPATH, [NIL_VALUE]])
作用: 用于給某個對象的某個屬性綁定
實例: 只要文本框的文字改變,就會修改label的文字
RAC(self.labelView,text) = _textField.rac_textSignal;
- RACObserve(self, name)
作用: 監聽某個對象的某個屬性,返回的是信號
實例: 監聽self.view的center變化
[RACObserve(self.view, center) subscribeNext:^(id x) {
NSLog(@"%@",x);
}];
注意事項: 當RACObserve放在block里面使用時一定要加上weakify,不管里面有沒有使用到self;否則會內存泄漏,因為RACObserve宏里面就有一個self。
@weakify(self);
RACSignal *signal3 = [anotherSignal flattenMap:^(NSArrayController *arrayController) {
Avoids a retain cycle because of RACObserve implicitly referencing self
@strongify(self);
return RACObserve(arrayController, items);
}];
- @weakify(Obj)和@strongify(Obj)
一般兩個都是配套使用,在主頭文件(ReactiveCocoa.h)中并沒有導入,需要自己手動導入,RACEXTScope.h才可以使用。但是每次導入都非常麻煩,只需要在主頭文件自己導入就好了
- RACTuplePack
作用: 把數據包裝成RACTuple(元組類)
實例: 把參數中的數據包裝成元組
RACTuple *tuple = RACTuplePack(@10,@20);
- RACTupleUnpack
作用: 把RACTuple(元組類)解包成對應的數據
實例: 把參數中的數據包裝成元組
RACTuple *tuple = RACTuplePack(@"xmg",@20);
注意事項: 解包元組,會把元組的值,按順序給參數里面的變量賦值
二. RACSignal基礎知識點
1.信號類(RACSignal) 簡介
信號類(RACSiganl),只是表示當數據改變時,信號內部會發出數據,它本身不具備發送信號的能力,而是交給內部一個訂閱者去發出.默認一個信號都是冷信號,也就是值改變了,也不會觸發,只有訂閱了這個信號,這個信號才會變為熱信號,值改變了才會觸發。
如何訂閱信號?
調用信號RACSignal的subscribeNext就能訂閱
2.信號類(RACSignal)實踐
1. RACSignal的簡單使用
完整的創建RACSignal 包含三部分sendError(不一定要有) sendNext(可多個) sendCompleted(不一定要有)。 下面代碼中的RACSubscriber表示訂閱者的意思,用于發送信號,這是一個協議,不是一個類,只要遵守這個協議,并且實現方法才能成為訂閱者。通過create創建的信號,都有一個訂閱者,幫助他發送數據。
RACSignal *signal=[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
@strongify(self)
NSError *error;
if ([self.curTag isEqualToString:@"error"]) {
error=[[NSError alloc]initWithDomain:@"myError" code:2001 userInfo:nil];
[subscriber sendError:error];
}
else
{
[subscriber sendNext:@"1"];
[subscriber sendNext:@"3"];
[subscriber sendNext:@"5"];
[subscriber sendCompleted];
}
return [RACDisposable disposableWithBlock:^{
NSLog(@"執行清理");
//RACDisposable:用于取消訂閱或者清理資源,當信號發送完成或者發送錯誤的時候,就會自動觸發它
//使用場景:不想監聽某個信號時,可以通過它主動取消訂閱信號
}];
}];
[signal subscribeNext:^(id x) {
NSLog(@"當前的值為:%@",x);
}];
[signal subscribeError:^(NSError *error) {
NSLog(@"當前出現錯誤%@",error);
}];
[signal subscribeNext:^(id x) {
NSLog(@"2當前的值為:%@",x);
}];
以上代碼的輸出為:
執行清理
當前出現錯誤Error Domain=myError Code=2001 "(null)"
執行清理
執行清理
2. filter、map以及flattenMap的使用
首先我們創建簡單的signal對象,然后通過該對象對filter、map以及flattenMap進行講解。
RACSignal *signal=[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
[subscriber sendNext:@"1"];
[subscriber sendNext:@"3"];
[subscriber sendNext:@"15"];
[subscriber sendNext:@"wujy"];
[subscriber sendCompleted];
return [RACDisposable disposableWithBlock:^{
NSLog(@"執行清理");
}];
}];
然后,我們看一下filter的作用,filter顧名思義就是過濾的意思,我們來看以下代碼:
[[signal filter:^BOOL(id value) {
if ([value isEqualToString:@"wujy"]) {
return YES;
}
return NO;
}] subscribeNext:^(id x) {
NSLog(@"當前的值為:%@",x);
}];
//輸出為: 當前的值為:wujy 執行清理
以上的代碼邏輯中,對signal進行過濾,只有傳入的字符串為“wujy”,才發送信號。
接下來,我們看一下先filter過濾后又用map進行轉換的效果,請看以下代碼:
[[[signal filter:^BOOL(id value) {
if ([value isEqualToString:@"wujy"]) {
return NO;
}
return YES;
}] map:^id(NSString *value) {
return @(value.length);
}] subscribeNext:^(NSNumber *x) {
NSLog(@"當前的位數為:%zd",[x integerValue]);
}];
//輸出為: 當前的位數為:1
當前的位數為:1
當前的位數為:2
執行清理
用map后原來的值就被轉化,上述代碼中,將value的值轉化成了長度。
接下來,我們再看一下flattenMap的使用效果,代碼如下:
[[signal flattenMap:^RACStream *(id value) {
return [RACSignal return:[NSString stringWithFormat:@"當前輸出為:%@",value]];
}] subscribeNext:^(id x) {
NSLog(@"flattenMap中執行:%@",x);
}];
flattenMap的作用是 根據前一個信號的參數創建一個新的信號,以上代碼的輸出為:
flattenMap中執行:當前輸出為:1
flattenMap中執行:當前輸出為:3
flattenMap中執行:當前輸出為:15
flattenMap中執行:當前輸出為:wujy
那么map跟flattenMap有什么區別呢?
1. FlatternMap中的Block返回信號
2. Map中的Block返回對象
3. 如果信號發出的值不是信號,映射一般使用Map
4. 如果信號發出的值是信號,映射一般使用FlatternMap
3. ignore、ignoreValues、take、takeUntilBlock以及takeLast的使用
之后,我們再繼續介紹RAC的其他基本知識點,同樣的方式,我們通過代碼的方式呈現。
首先,看一下ignore的使用,該用法相對簡單,我就直接上代碼了。
//ignore 忽略某個值
[[signal ignore:@"3"] subscribeNext:^(id x) {
NSLog(@"當前的值為:%@",x);
}];
//輸出:當前的值為:1 當前的值為:15 當前的值為:wujy 執行清理
接下來,我們看一下ignoreValues的使用效果,ignoreValues 這個比較極端,忽略所有值,只關心Signal結束,也就是只取Comletion和Error兩個消息,中間所有值都丟棄。
[[signal ignoreValues] subscribeNext:^(id x) {
//它是沒機會執行 因為ignoreValues已經忽略所有的next值
NSLog(@"ignoreValues當前值:%@",x);
} error:^(NSError *error) {
NSLog(@"ignoreValues error");
} completed:^{
NSLog(@"ignoreValues completed");
}];
// 輸出
// ignoreValues completed
然后,我們看一下take的相關用法。首先,我們先登場的是take的簡單使用,take的意思是:從開始一共取N次的信號。
[[signal take:1] subscribeNext:^(id x) {
NSLog(@"take 獲取的值:%@",x);
}];
//輸出:take 獲取的值:1
用法相對簡單,不做過多介紹,我們馬上來看一下takeUntilBlock的用法,takeUntilBlock的意思是:對于每個next值,運行block,當block返回YES時停止取值
[[signal takeUntilBlock:^BOOL(NSString *x) {
if ([x isEqualToString:@"15"]) {
return YES;
}
return NO;
}] subscribeNext:^(id x) {
NSLog(@"takeUntilBlock 獲取的值:%@",x);
}];
// 輸出
// takeUntilBlock 獲取的值:1
// takeUntilBlock 獲取的值:3
最后,我們看一下takeLast的使用,takeLast的意思是: 取最后N次的信號,但是它有一個前提條件,訂閱者必須調用完成,因為只有完成,才知道總共有多少信號。
[[signal takeLast:1] subscribeNext:^(id x) {
NSLog(@"takeLast 獲取的值:%@",x);
}];
//輸出:takeLast 獲取的值:wujy
4. skip、skipUntilBlock、skipWhileBlock、startWith以及reduceEach的使用
RAC的基礎知識真是眾多且難記,喝杯小茶,我們接著來。啦啦啦,我們再看一下關于剩余的知識點。首先,skip登場,skip的字面意思即跳躍,他的使用就是跳過幾個信號,不接受。skipUntilBlock的意思類似,skipUntilBlock是一直跳,直到block為YES。
//skip
[[signal skip:2] subscribeNext:^(id x) {
NSLog(@"skip 獲取的值:%@",x);
}];
//輸出:skip 獲取的值:15 skip 獲取的值:wujy
//skipUntilBlock
[[signal skipUntilBlock:^BOOL(NSString *x) {
if ([x isEqualToString:@"15"]) {
return YES;
}
return NO;
}] subscribeNext:^(id x) {
NSLog(@"skipUntilBlock 獲取的值:%@",x);
}];
// 輸出
// skipUntilBlock 獲取的值:15
// skipUntilBlock 獲取的值:wujy
skipWhileBlock跟skipUntilBlock是相反的意思,skipWhileBlock的意思是一直跳,直到block為NO
[[signal skipWhileBlock:^BOOL(NSString *x) {
if ([x isEqualToString:@"15"]) {
return NO;
}
return YES;
}] subscribeNext:^(id x) {
NSLog(@"skipWhileBlock 獲取的值:%@",x);
}];
// 輸出
// skipWhileBlock 獲取的值:15
// skipWhileBlock 獲取的值:wujy
接下來,我們看一下startWith以及reduceEach的用法,startWith表示起始位置增加相應的元素,不要跟字符串的拼接混用,跟字符串的拼接還是有區別的。
RACSignal *addStartSignal=[RACSignal return:@"123"];
[[addStartSignal startWith:@"345"] subscribeNext:^(id x) {
NSLog(@"startWith增加的值操作 %@",x);
}];
// 輸出
// startWith增加的值操作 345
// startWith增加的值操作 123
reduceEach的意思是聚合,用于信號發出的內容是元組,把信號發出元組的值聚合成一個值。
RACSignal *aSignal=[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
[subscriber sendNext:RACTuplePack(@1,@4)];
[subscriber sendNext:RACTuplePack(@2,@3)];
[subscriber sendNext:RACTuplePack(@5,@2)];
return nil;
}];
[[aSignal reduceEach:^id(NSNumber *first,NSNumber *secnod){
return @([first integerValue]+[secnod integerValue]);
}] subscribeNext:^(NSNumber *x) {
NSLog(@"reduceEach當前的值:%zd",[x integerValue]);
}];
// 輸出
// reduceEach當前的值:5
// reduceEach當前的值:5
// reduceEach當前的值:7
5. 關于時間以及流程相關的RAC語法操作
說到時間,首先想到的就是計時器的使用場景,在RAC中也有對應的計時器操作API,即: (RACSignal *)interval:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler的用法。模仿定時器用,我們設置take方式,每隔段時間發出一個信號。
[[[RACSignal interval:1 onScheduler:[RACScheduler mainThreadScheduler]] take:5]subscribeNext:^(id x) {
NSLog(@"interval-take :吃藥");
}];
// 輸出(每隔一秒執行一句)
// interval-take :吃藥
// interval-take :吃藥
// interval-take :吃藥
// interval-take :吃藥
// interval-take :吃藥
接下來,我們看一下有關超時的操作流程,請看以下代碼:
[[[RACSignal createSignal:^RACDisposable *(id subscriber) {
[[[RACSignal createSignal:^RACDisposable *(id subscriber) {
NSLog(@"我快到了");
[subscriber sendNext:nil];
[subscriber sendCompleted];
return nil;
//延遲2秒后執行next事件
}] delay:2] subscribeNext:^(id x) {
NSLog(@"我到了");
[subscriber sendNext:nil];
[subscriber sendCompleted];
}];
return nil;
}] timeout:1 onScheduler:[RACScheduler mainThreadScheduler]] subscribeError:^(NSError *error) {
NSLog(@"你再不來,我走了");
}];
//輸出
//我快到了
//你再不來,我走了
//我到了
在我們的實際開發中,有很多場景需要我們重新執行某個操作,比如網絡請求中的再次刷新等,類似的場景我們就可以使用retry語法來實現。若發送的是error則可以使用retry來嘗試重新刺激信號 retry重試 :只要失敗,就會重新執行創建信號中的block,直到成功.
[[[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
NSLog(@"i = %d",i);
if (i == 5) {
[subscriber sendNext:@"i == 2"];
}else{
i ++;
[subscriber sendError:nil];
}
return nil;
//當發送的是error時可以retry重新執行
}] retry] subscribeNext:^(id x) {
NSLog(@"%@",x);
}];
// 輸出:
// i = 0
// i = 1
// i = 2
// i = 3
// i = 4
// i = 5
// i == 2
還有一種我們常用的場景,即我們需要達到某個條件時,停止某些操作。如通知的注銷,計時器的銷毀等,我們可以使用takeUntil更加方便快捷的實現該需求。
//創建一個信號
[[[RACSignal createSignal:^RACDisposable *(id subscriber) {
//創建一個定時信號,每隔1秒刺激一次信號
[[RACSignal interval:1 onScheduler:[RACScheduler mainThreadScheduler]] subscribeNext:^(id x) {
[subscriber sendNext:@"直到世界的盡頭才能把我們分開"];
}];
return nil;
//直到此情況下停止刺激信號
}] takeUntil:[RACSignal createSignal:^RACDisposable *(id subscriber) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"世界的盡頭到了");
[subscriber sendNext:@"世界的盡頭到了"];
});
return nil;
}]] subscribeNext:^(id x) {
NSLog(@"%@", x);
}];
//輸出:
// 直到世界的盡頭才能把我們分開
// 直到世界的盡頭才能把我們分開
// 直到世界的盡頭才能把我們分開
// 世界的盡頭到了
最后,再介紹一下doNext跟doCompleted以及throttle的使用,doNext: 執行Next之前,會先執行這個Block; doCompleted: 執行sendCompleted之前,會先執行這個Block。先看一下doNext跟doCompleted的使用:
[[[[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
[subscriber sendNext:@"執行sendNext"];
NSLog(@"執行sendNext");
[subscriber sendCompleted];
return nil;
}] doNext:^(id x) {
NSLog(@"執行doNext");
}] doCompleted:^{
NSLog(@"執行doCompleted");
}] subscribeNext:^(id x) {
NSLog(@"執行subscribeNext");
}];
// 輸出
// 執行doNext
// 執行subscribeNext
// 執行sendNext
// 執行doCompleted
throttle是節流的意思,用來處理當某個信號發送比較頻繁的情況。該情況下可以使用節流,在某一段時間不發送信號內容,過了一段時間獲取信號的最新內容發出。
RACSubject *throttleSignal = [RACSubject subject];
[throttleSignal sendNext:@"throttle a"];
// 節流,在一定時間(4秒)內,不接收任何信號內容,過了這個時間(1秒)獲取最后發送的信號內容發出。
[[throttleSignal throttle:4] subscribeNext:^(id x) {
NSLog(@"throttleSignal:%@",x);
}];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"世界的盡頭到了");
[throttleSignal sendNext:@"throttle b"];
[throttleSignal sendNext:@"throttle c"];
});
//輸出:throttleSignal:throttle c
6. combineLatest、reduce、then、aggregateWithStart、scanWithStart的使用
在此之前,我們大多介紹的是基于單個信號的操作,接下來,我們講解一下關于信號與信號之間的操作與處理問題。我們需要創建兩個簡單的信號,然后基于這兩個信號,我們進行接下來的講解工作。
RACSignal *aSignal=[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
[subscriber sendNext:@"1"];
[subscriber sendNext:@"3"];
[subscriber sendCompleted];
return [RACDisposable disposableWithBlock:^{
NSLog(@"aSignal清理了");
}];
}];
RACSignal *bSignal=[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
[subscriber sendNext:@"7"];
[subscriber sendNext:@"9"];
[subscriber sendCompleted];
return [RACDisposable disposableWithBlock:^{
NSLog(@"bSignal清理了");
}];
}];
首先,看一下combineLatest的用法,他的作用是將多個信號合并起來,并且拿到各個信號的最新的值, combineLatest有一個前提條件,即:必須每個合并的signal至少都有過一次sendNext,才會觸發合并的信號。
RACSignal *combineSignal = [aSignal combineLatestWith:bSignal];
[combineSignal subscribeNext:^(id x) {
NSLog(@"combineSignal為:%@",x);
}];
//輸出
// combineSignal為:<RACTuple: 0x600000015c30> (
// 3,
// 7
// )
// combineSignal為:<RACTuple: 0x600000015cb0> (
// 3,
// 9
// )
combineLatest一般結合reduce聚合一起使用,將產生的最新的值聚合在一起,并生成一個新的信號。
RACSignal *combineReduceSignal=[RACSignal combineLatest:@[aSignal,bSignal] reduce:^id(NSString *aItem,NSString *bItem){
return [NSString stringWithFormat:@"%@-%@",aItem,bItem];
}];
[combineReduceSignal subscribeNext:^(id x) {
NSLog(@"合并后combineSignal的值:%@",x);
}];
//輸出:aSignal清理了 合并后combineSignal的值:3-7 合并后combineSignal的值:3-9 bSignal清理了
從結果可以看出此種合并會將第一個信號中最后一個sendnext與后面信號的所有sendnext結合起來作為一個數組,而next觸發次數以bSignal中的next次數為主。
然后,介紹一下then的用法,用于連接兩個信號,當第一個信號完成,才會連接then返回的信號。
RACSignal *thenSignal=[aSignal then:^RACSignal *{
return bSignal;
}];
[thenSignal subscribeNext:^(id x) {
NSLog(@"thenSignal的值:%@",x);
}];
//輸出 thenSignal的值:7 thenSignal的值:9 bSignal清理了 aSignal清理了
then可以用來處理串行的需求,就像一下實例:
[[[[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
NSLog(@"第一步");
[subscriber sendCompleted];
return nil;
}] then:^RACSignal *{
return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
NSLog(@"第二步");
[subscriber sendCompleted];
return nil;
}];
}] then:^RACSignal *{
return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
NSLog(@"第三步");
return nil;
}];
}] subscribeCompleted:^{
NSLog(@"完成");
}];
//輸出:第一步 第二步 第三步
最后,我們看一下aggregateWithStart跟scanWithStart的使用,aggregateWithStart的意思是 從哪個位置開始 進行順序兩值進行操作 最后只有一個被操作后的值,而scanWithStart的意思是 從哪個位置開始 然后每個位置跟前面的值進行操作 它會有根據NEXT的個數來顯示對應的值。我們通過具體的代碼來看:
RACSignal *operateSignal=[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
[subscriber sendNext:@2];
[subscriber sendNext:@12];
[subscriber sendNext:@15];
[subscriber sendCompleted];
return nil;
}];
//aggregateWithStar
[[operateSignal aggregateWithStart:@0 reduce:^id(NSNumber *running, NSNumber *next) {
return @([running integerValue]+[next integerValue]);
}] subscribeNext:^(id x) {
NSLog(@"aggregateWithStart 當前值:%@",x);
}];
//輸出
//aggregateWithStart 當前值:29
//scanWithStart
[[operateSignal scanWithStart:@0 reduce:^id(NSNumber *running, NSNumber *next) {
return @([running integerValue]+[next integerValue]);
}] subscribeNext:^(id x) {
NSLog(@"scanWithStart 當前值:%@",x);
}];
//輸出
//scanWithStart 當前值:2
//scanWithStart 當前值:14
//scanWithStart 當前值:29
7. 信號隊列的使用
信號隊列顧名思義就是將一組信號排成隊列,挨個調用。Talk is cheap, show you the code .
//創建3個信號來模擬隊列
RACSignal *signalB = [RACSignal createSignal:^RACDisposable *(id subscriber) {
[subscriber sendNext:@"喜歡一個人"];
[subscriber sendCompleted];
return nil;
}];
RACSignal *signalC = [RACSignal createSignal:^RACDisposable *(id subscriber) {
[subscriber sendNext:@"直接去表白"];
[subscriber sendCompleted];
return nil;
}];
RACSignal *signalD = [RACSignal createSignal:^RACDisposable *(id subscriber) {
[subscriber sendNext:@"成功在一起"];
[subscriber sendCompleted];
return nil;
}];
RACSignal *signalGroup = [[signalB concat:signalC] concat:signalD];
[signalGroup subscribeNext:^(id x) {
NSLog(@"%@",x);
}];
//輸出:喜歡一個人 直接去表白 成功在一起
以上的代碼中,我們使用了concat來連接組隊列,concat將幾個信號放進一個組里面,按順序連接每個,每個信號必須執行sendCompleted方法后才能執行下一個信號。此處,我們也可以用merge來處理,merge用來合并隊列。
[[RACSignal merge:@[signalB,signalC,signalD]] subscribeNext:^(id x) {
NSLog(@"merge:%@",x);
}];
//輸出:merge:喜歡一個人 merge:直接去表白 merge:成功在一起
concat跟merge的區別是什么呢?
concat每個信號必須執行sendCompleted方法后才能執行下一個信號,而merge不用。
最后的最后,我們看一下信號的壓縮zipWith的用法,壓縮具有一一對應關系,以2個信號中 消息發送數量少的為主對應。
RACSignal *signalA = [RACSignal createSignal:^RACDisposable *(id subscriber) {
[subscriber sendNext:@"我想你"];
[subscriber sendNext:@"我不想你"];
[subscriber sendNext:@"Test"];
return nil;
}];
RACSignal *signalB = [RACSignal createSignal:^RACDisposable *(id subscriber) {
[subscriber sendNext:@"嗯"];
[subscriber sendNext:@"你豁我"];
return nil;
}];
[[signalA zipWith:signalB] subscribeNext:^(RACTuple* x) {
//解包RACTuple中的對象
RACTupleUnpack(NSString *stringA, NSString *stringB) = x;
NSLog(@"%@%@", stringA, stringB);
}];
//輸出:我想你 嗯 我不想你 你豁我
若將此結果于合并作對比,我們可以發現他們只是觸發next事件的次數所關聯對象不一樣,是以信號中next事件數量較少的為主。
發現已經不知不覺介紹了很多了,那就先這樣吧,剩下的內容我們下篇文章再聊,歡迎你前來圍觀。