[譯]ReactiveCocoa and MVVM, an Introduction

原文鏈接:http://www.sprynthesis.com/2014/12/06/reactivecocoa-mvvm-introduction/

MVC

每一個進行過一段時間軟件開發(fā)的人員都會熟悉MVC。它代表Model View Controller逮刨,是一種在復(fù)雜的軟件應(yīng)用設(shè)計中有效的編碼模式市怎。然而,在iOS開發(fā)中揩瞪,它似乎有第二種含義:Massive View Controller楞艾。它讓許多iOS軟件開發(fā)者糾結(jié)于如何保持代碼整潔清晰逢防,開發(fā)者們意識到,必須為他們的view controller瘦身摇天。但粹湃,怎么做呢?

MVVM

這就是MVVM出現(xiàn)的原因——它代表Model View View-Model泉坐,它能夠幫我們寫出更加可控再芋,結(jié)構(gòu)更加合理的代碼。
有時坚冀,編寫app的時候不遵守Apple的推薦規(guī)范并不是一個好主意济赎。我并不是不贊成,我只是說记某,這有可能得不償失司训。比如,我不會推薦你去編寫你自己的view controller基類液南,并且自己去管理視圖的生命周期壳猜。
因此,這里我需要回答一個問題:用一個并非Apple推薦的設(shè)計模式(MVC)是否是不明智的呢滑凉?
统扳!原因有兩點:

  1. Apple并沒有指導(dǎo)我們?nèi)绾稳ソ鉀QMassive View Controller的問題。它讓我們自己去解決畅姊,去為我們的代碼添加更有效的修改(原注1:今年的WWDC上咒钟,一些Apple的示例代碼中也出現(xiàn)了view model)。MVVM是一個很好的解決的途徑若未。
  2. MVVM朱嘴,或者說我接下來將要展示的MVVM的編碼模式,是很符合MVC模式規(guī)范的,就好像是我們將MVC向前自然地推進了一步萍嬉。

MVVM的定義

  1. Model——MVVM中的model的含義并沒有變化乌昔。你的model有可能包含一些業(yè)務(wù)邏輯,這取決于你自己[1]壤追。我傾向于用它來作為一個保存數(shù)據(jù)模型對象的結(jié)構(gòu)[2]磕道,而將創(chuàng)建或管理model的邏輯放到一個單獨的manager類型的類中癌蓖。
  2. View——view包含了UI(不管是UIView代碼枪孩,storyboard還是xibs),view的邏輯爷耀,以及用戶輸入響應(yīng)资柔。在iOS開發(fā)中焙贷,這其中很多是UIViewController所做的事情撵割,而不僅僅是UIView贿堰。
  3. View-Model——這個詞組本身會使人誤解,雖然它是由兩個我們已經(jīng)了解的詞語構(gòu)成啡彬,但卻代表了完全不同的一種東西羹与。它不是傳統(tǒng)數(shù)據(jù)模型結(jié)構(gòu)意義上的model(再次表明,這也只是我的個人傾向)庶灿。它的職能之一是作為一個靜態(tài)模型纵搁,代表view展示所需要的數(shù)據(jù)。但此外往踢,它也負責收集腾誉、解釋、轉(zhuǎn)換這些數(shù)據(jù)峻呕。這就使得view (controller)有了更清晰專一的職責:將view-model提供的數(shù)據(jù)展示出來利职。

更多關(guān)于view-model

view-model這個詞組的確不能表達我們的意思。一個更好的表述應(yīng)當是"view coordinator(視圖協(xié)調(diào)器)"瘦癌。你可以把它想象成電視新聞節(jié)目幕后的調(diào)查者和撰稿人猪贪。它從信息源搜集原始數(shù)據(jù)(可能是數(shù)據(jù)庫,網(wǎng)絡(luò)協(xié)議讯私,等等)热押,進行邏輯演繹,將數(shù)據(jù)轉(zhuǎn)換成view (controller)可展示的數(shù)據(jù)斤寇。它僅僅暴露(往往通過屬性property)view controller展示view所必需的信息(理想情況下你不應(yīng)該暴露你的數(shù)據(jù)模型對象)桶癣。它也負責對上游數(shù)據(jù)進行修改(例如,更新model/數(shù)據(jù)庫娘锁,POST數(shù)據(jù)等等)鬼廓。

MVVM in a MVC world

正如詞組view-model一樣,我覺得MVVM這個縮寫詞一定程度上也不能清楚代表我們在iOS開發(fā)中的使用方法致盟。讓我們再看一下這個縮寫詞碎税,看看它是怎么融入MVC的尤慰。
為了畫出示意圖,讓我們把MVC中的V和C調(diào)換一下雷蹂,這樣得到的縮寫詞伟端,MCV,能夠更準確反映各個部分之間的關(guān)系匪煌。對于MVVM责蝠,我們采取一樣的做法,把V(View)移動到VM的右邊萎庭,得到MVMV(我確定最初不采取這種更直觀的命名是有原因的)霜医。
下圖描述了這兩種模式在iOS中是如何融為一體的:


  • 我盡量將各個方塊的大小與各部分所承擔的任務(wù)量對應(yīng)起來。
  • 注意到驳规,view controller的方塊有多大肴敛!
  • 可以看到,龐大的view controller與view-model之間有很大一部分工作是重疊的吗购。
  • 你也可以看到医男,view controller的一部分與MVVM中的view是重合的。

你也許會感到寬慰的是:實際上我們并沒有拋棄view controller的概念捻勉。我們只是將其中那一大塊重疊的部分放進view-model中镀梭,讓view controller更輕松。
最終踱启,我們得到的其實是MVMCV报账,Model View-Model Controller View。


我們得到的結(jié)果是:

現(xiàn)在埠偿,view controller唯一要做的就是用來自view-model的數(shù)據(jù)來調(diào)度透罢,管理不同的view,并在用戶輸入需要改變上游數(shù)據(jù)的時候告訴view-model胚想。view controller并不需要知道網(wǎng)絡(luò)請求琐凭,core data,model對象(原注3.1:但實踐中浊服,有時候通過view-model的頭文件來暴露一些model是很有效的方法统屈,而不是再去復(fù)制大量的屬性,稍后詳談)[3]牙躺,等等愁憔。
view-model將作為view controller的屬性property存在。view controller了解view-model及其公共屬性孽拷,但view-model對view controller毫不知情吨掌。你應(yīng)該已經(jīng)感覺到這種分離的好處了。
另一種幫助你理解這些組成部分之間的關(guān)系,以及各部分的職能的方法就是膜宋,看下面這張新的應(yīng)用層級結(jié)構(gòu)圖:

View-Model和View Controller:和而不同

讓我們看一個簡單的view-model頭文件來更深入了解我們的新模式長啥樣窿侈。簡單起見,讓我們編寫一個假冒的twitter客戶端秋茫,它能讓我們查找任何twitter用戶最近的回復(fù)史简,只需要輸入用戶名,然后點擊"Go"肛著。我們的界面長這樣:

  • 有一個UITextField用來輸入用戶名圆兵,一個“Go”按鈕UIButton
  • 有一個UIImageView和一個UILable,展示當前查找的用戶的頭像和名字枢贿。
  • 下方有一個UITableView用來展示最近的回復(fù)(推特)殉农。
  • 可以無限滾動。

示例View-Model

我們view-model的頭文件可能會長這樣:

@interface MYTwitterLookupViewModel: NSObject

@property (nonatomic, assign, readonly, getter=isUsernameValid) BOOL usernameValid;

@property (nonatomic, strong, readonly) NSString *userFullName;

@property (nonatomic, strong, readonly) UIImage *userAvatarImage;

@property (nonatomic, strong, readonly) NSArray *tweets;

@property (nonatomic, assign, readonly) BOOL allTweetsLoaded;

@property (nonatomic, strong, readwrite) NSString *username;

- (void) getTweetsForCurrentUsername;

- (void) loadMoreTweets;

十分明了清晰局荚。注意到這些壯觀的readonly屬性了嗎超凳?View-model會暴露盡量少的信息給view controller,view controller也不關(guān)心view-model是怎么獲得這些信息的(現(xiàn)在我們也不需要關(guān)心危队,只需要想像成我們常用的網(wǎng)絡(luò)請求得到的數(shù)據(jù)聪建,偽造的數(shù)據(jù)钙畔,持久化存儲的數(shù)據(jù)等等)茫陆。

view-model不會做的事情:

  • 以任何方式直接操作view controller,或有變化時直接通知到view controller擎析。

View controller

View controller會使用從view-model獲得的數(shù)據(jù)來:

  • 根據(jù)usernameValid屬性的變化來改變“GO”按鈕的enabled屬性
  • usernameValid為NO時將按鈕的alpha值設(shè)為.5(當usernameValid為YES時為1.0)
  • userFullName里的字符串來更新UILabel的text屬性
  • 使用userAvatarImage的值來更新UIImageView的Image
  • 使用tweets里的對象來設(shè)置tableview cell
  • 當tableview 滑到底時簿盅,如果allTweetsLoaded的值為NO,需要添加一個"loading" cell

View controller可能以下列方式來操作view-model:

  • 當UITextField里的text改變時揍魂,更新我們view-model中唯一的readwrite屬性桨醋,username
  • 當用戶點擊"Go"按鈕時,調(diào)用view-model的getTweetsForCurrentUsername方法
  • 當tableview 滑動到“l(fā)oading cell”時现斋,調(diào)用view-model的loadMoreTweets方法

View controller不做的事情:

  • 發(fā)起網(wǎng)絡(luò)請求
  • 管理tweets數(shù)組
  • 判斷username是否是有效名字
  • 將用戶的first name和last name拼成full name
  • 下載用戶頭像喜最,并將其轉(zhuǎn)換成UIImage(原注4:如果你習慣使用一些UIImageView的category來加載網(wǎng)絡(luò)圖片的話,你可以不暴露一個UIImage庄蹋,而是暴露一個URL瞬内,這確實能夠?qū)iew-model和UIKit分隔得更清楚。但就我自己而言限书,我更將UIIMage看作一種數(shù)據(jù)虫蝶,而不是用來表現(xiàn)數(shù)據(jù)的視圖。這里并沒有明顯的界線)
  • 做許多費力的活兒

再一次倦西,注意到對于view-model的變化的作出響應(yīng)的責任在view controller中能真。

Child View-Model

之前提到,使用tweets里的對象來設(shè)置tableview cell。通常你希望這些對象是代表一條條推特的數(shù)據(jù)模型對象粉铐。你可能會感到疑惑:誒不是說MVVM中我們盡量不暴露數(shù)據(jù)模型的嗎疼约?(原注3)

并不是一個view-model代表了屏幕上所有的東西。我們可以使用child view-model來代表屏幕上更小的蝙泼,更模塊化的元素忆谓。當這種元素可以被重用(比如tableview cell),或者代表了多個data-model對象的時候踱承,這種方法尤其有效倡缠。

我們并不總是需要child view-model。例如茎活,我們可以使用一個table header view來實現(xiàn)我們的“tweetboat plus” APP的頂部昙沦。這部分是不可重用的,所以我們可以直接傳我們在view controller中所使用的view-model到這個自定義的header view载荔。Header view從view-model中挑選自己需要的信息盾饮,忽略其他的信息。這也很有利于保持各個subview的同步懒熙,因為它們都是使用的同一套信息丘损,并監(jiān)聽同樣的屬性變化。

在我們的demo中工扎,tweets數(shù)組會裝滿child view-model徘钥,child view-model也許長這樣:

@interface MYTweetCellViewModel: NSObject

@property (nonatomic, strong, readonly) NSString *tweetAuthorFullName;

@property (nonatomic, strong, readonly) UIImage *tweetAuthorAvatarImage;

@property (nonatomic, strong, readonly) NSString *tweetContent;

你可能會覺得,這長得很像我們正常使用的“Tweet”數(shù)據(jù)模型啊肢娘。那為什么要把它轉(zhuǎn)換成一個view-model呢呈础?因為即使很相似,view-model能讓我們將對外暴露的信息盡量壓縮到我們需要的范圍橱健,并提供一些可能是經(jīng)過加工的屬性而钞,或者計算一些為這個視圖所獨有的數(shù)據(jù)。(再次聲明拘荡,盡量不暴露可變的數(shù)據(jù)模型對象臼节,因為我們希望是由view-model自身來改變這些數(shù)據(jù)模型對象,而不是view或view controller)

那珊皿,在哪里創(chuàng)建view-model呢

我們的View-Model會在何時何地被創(chuàng)建呢网缝?是由view controller自己創(chuàng)建自己的view-model嗎?

View-Model 創(chuàng)造 View-Model

嚴格來講亮隙,你應(yīng)當在app delegate中為你的top view controller創(chuàng)建一個view-model途凫。當present一個新的view controller,或者一個新的由view-model代表的view的時候溢吻,你請求當前的view-model來創(chuàng)建一個child view-model维费。


例如果元,我們想增加一個資料頁(profile view controller),用以當用戶點擊APP頂部區(qū)域中的頭像的時候跳轉(zhuǎn)犀盟。我們可以為我們的主view-model增加一個方法:
- (MYTwitterUserProfileViewModel *) viewModelForCurrentUser;
在我們的主view controller中這樣調(diào)用它:

- (IBAction) didTapPrimaryUserAvatar

{

MYTwitterUserProfileViewModel *userProfileViewModel = [self.viewModel viewModelForCurrentUser];

MYTwitterUserProfileViewController *profileViewController =

[[MYTwitterUserProfileViewController alloc] initWithViewModel: userProfileViewModel];

[self.navigationController pushViewController: profileViewController animated:YES];

}

在這個例子中而晒,我想要present一個當前用戶的profile view controller,但這個profile view controller需要一個view-model阅畴。我們這里的view controller并不知道創(chuàng)建這個profile view controller所需要的全部信息(當然倡怎,它也不應(yīng)該知道),因此贱枣,view controller會讓自己的view-model來做這個工作监署。

View-Models 列表

在這個demo中,當我們收到數(shù)據(jù)的時候(比如可能是通過一個網(wǎng)絡(luò)請求)我們會提前創(chuàng)建好所有cell對應(yīng)的child view-model纽哥。在這種情況下钠乏,主view-model中的tweets數(shù)組中會裝滿了MYTweetCellViewModel對象。在我們的tableview的cellForRowAtIndexPath中春塌,我們只需要找到index對應(yīng)的那個view-model晓避,然后把它賦給cell。

Functional Core, Imperative Shell (函數(shù)式內(nèi)核只壳,命令式外殼)

這種view-model的軟件設(shè)計方式可以看成一種近似于由Gary Bernhardt所提出的 “Functional Core, Imperative Shell”軟件設(shè)計模式

Functional Core(函數(shù)式內(nèi)核)

View-Model 是我們的"函數(shù)式內(nèi)核"俏拱,盡管在iOS/OC中,很難達到純函數(shù)式的程度(Swift給我們提供了更多的函數(shù)性)吼句。通常的思想是锅必,讓我們的view-models盡可能少地依賴于/影響到應(yīng)用的其它部分。什么意思呢命辖?回想一下剛開始學習編程的時候?qū)戇^的最簡單的函數(shù)况毅。它們可能會接受1到2個參數(shù)分蓖,然后輸出一個結(jié)果尔艇。Data in, Data out。函數(shù)中或許進行了一些簡單的計算么鹤,或者諸如拼合first name和last name之類终娃。不管程序其它部分怎么運作,相同的輸入總是會產(chǎn)生相同的輸出蒸甜。這就是函數(shù)的思想棠耕。

這正是我們使用view-models所要取得的結(jié)果。View-model內(nèi)部包含了轉(zhuǎn)換數(shù)據(jù)的邏輯柠新,并將結(jié)果作為property保存下來窍荧。理想情況下,相同的輸入(例如網(wǎng)絡(luò)響應(yīng))總會產(chǎn)生相同的輸出(property的值)恨憎。這就是說蕊退,會盡可能消除外部對于結(jié)果的影響郊楣,比如 使用大量的狀態(tài)值我們要做的第一步就是在你的view-model的頭文件中不要包含UIKit.h瓤荔。(原注6:這是一個很好的原則净蚤,但也有一些灰色區(qū)域:比如,你可能會將UIImage看作數(shù)據(jù)输硝,而不是視圖(我喜歡這樣)今瀑。在這種情況下,你需要UIKit.h來獲得UIImage類)UIKit天生就會影響到APP的很多地方点把,它包含了許多副作用橘荠,因此改變一個值,或者調(diào)用某個方法都可能會產(chǎn)生不直接的變化郎逃。
更新:剛剛看了Andy在Functional Swift Conference上的另一個很棒的演講砾医,因此對此有了更多的思考。我們的view-model說到底還是一個對象衣厘,還是需要保持一些狀態(tài)變量(否則不會成為一個很實用的對象)如蚜。但我們?nèi)匀恍枰驯M可能多的邏輯寫到無狀態(tài)的函數(shù)中。在這方面Swift又一次做的比OC更好影暴。

Imperative (Declarative?) Shell(命令式(聲明式错邦?)外殼)[4]

我們將view-model數(shù)據(jù)轉(zhuǎn)換成屏幕所顯示的東西,需要做一系列工作型宙,比如所有的狀態(tài)改變撬呢,應(yīng)用內(nèi)其它部分的改變,命令式外殼就是我們做這些臟活兒累活兒的地方妆兑。這就是我們的view (controller)魂拦,我們處理UIKit的地方。我依然特別注意盡可能的減少狀態(tài)變量搁嗓,將這一系列工作用聲明式的方式完成芯勘,例如使用ReactiveCocoa。但本質(zhì)上腺逛,iOS和UIKit是命令式的荷愕。(原注7:table data source是一個很好的示例:它的這種代理模式會使得delegate使用狀態(tài)變量來在tableview請求數(shù)據(jù)的時候提供信息。事實上棍矛,一般情況下代理模式都會使用大量的狀態(tài)變量)

可測試的內(nèi)核

iOS中的單元測試是一項糟糕的安疗,變態(tài)的,令人討厭的工作(够委。荐类。。)至少這是我開始接觸做這些時的感想茁帽。我甚至讀過一兩本這方面的書玉罐,但當他們開始偽造(mocking)真竖,偷換(swizzling) view controllers來使得其中一些邏輯可以測試的時候,我就開始打瞌睡厌小。最終恢共,我選擇了退而求其次,只對models和一些相關(guān)的model manager類進行單元測試璧亚。

除了因為減少狀態(tài)變量而減少的bug之外讨韭,View-model這種函數(shù)式內(nèi)核最大的優(yōu)點之一就是,它能夠很好地支持單元測試癣蟋。如果對于一些方法透硝,相同的輸入總會產(chǎn)生相同的輸出,那么這些方法就很適合做單元測試疯搅。我們現(xiàn)在將數(shù)據(jù)的收集/邏輯/轉(zhuǎn)化濒生,與復(fù)雜的view controller分離開了,這就意味著不需要偽造幔欧,偷換等等瘋狂的舉動就可以寫出很好的測試了罪治。

連接一切

那么,當view-model中的公共屬性發(fā)生變化的時候礁蔗,我們怎么去更新view呢觉义?

大部分時候,我們會用相應(yīng)的view-model來初始化view controller浴井,正如我們上面所看到的:

MYTwitterUserProfileViewController *profileViewController = [[MYTwitterUserProfileViewController alloc] initWithViewModel: userProfileViewModel];

有時晒骇,在初始化的時候不能傳入view-model,比如使用storyboard segue或者cell重用的時候磺浙。這時洪囤,可以讓view (controller)對外暴露一個readwrite的view-model屬性。

MYTwitterUserCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"MYTwitterUserCell" forIndexPath:indexPath];// grab the cell view-model from the vc view-model and assign it 
cell.viewModel = self.viewModel.tweets[indexPath.row];

若我們可以在init或者viewDidLoad之前傳入view-model的話撕氧,我們就可以利用view-model的屬性來初始化一些UI元素的狀態(tài)瘤缩。

- (id) initWithViewModel:(MYTwitterLookupViewModel *) viewModel {

self = [super init];

if (!self) return nil;

_viewModel = viewModel;

return self;

}

- (void) viewDidLoad {

[super viewDidLoad];

_goButton.enabled = viewModel.isUsernameValid;

_goButton.alpha = viewModel.isUsernameValid ? 1 : 0.5;

// etc

}

太棒了!我們搞定了我們的初始化呵曹。但若view-model中的值發(fā)生變化呢款咖?GO按鈕怎么才能變成enabled的呢?我們的user label和頭像怎么才能利用網(wǎng)絡(luò)請求響應(yīng)來填充呢奄喂?

我們可以將view controller暴露給view-model,這樣view-model在相關(guān)數(shù)據(jù)發(fā)生變化的時候海洼,就能調(diào)用view controller的“updateUI”方法(千萬別這么做我說著玩的)跨新。將view controller作為view-model的一個delegate?當view-model中屬性發(fā)生變化的時候拋出一個通知坏逢?Noooooooooo域帐!
我們的view controller確實會知道view-model中的一些變化赘被。我們可以利用UITextField代理方法,每當text發(fā)生變化的時候肖揣,檢查view-model來更新按鈕的狀態(tài)民假。

- (void)textFieldDidChange:(UITextField *)sender {

// update the view-model

self.viewModel.username = sender.text;

// check if things are now valid

self.goButton.enabled = self.viewModel.isUsernameValid;

self.goButton.alpha = self.viewModel.isUsernameValid ? 1.0 : 0.5;

}

如果UITextField的text變化是導(dǎo)致view-model的isUsernameValid屬性變化的唯一原因的話,這樣確實可以解決問題龙优。但如果還有其它變量/方法會改變isUsernameValid的值呢羊异?如果view-model內(nèi)部的網(wǎng)絡(luò)請求會改變這個值呢?也許我們可以為view-model中的方法添加completion handlers來更新UI彤断?或許我們可以使用鄭重其事野舶,繁瑣笨重的KVO?

也許宰衙,不管怎么樣我們最終都可以利用我們熟悉的種種機制將view-model和view controller的接觸點連接起來平道。但你已經(jīng)知道了,這不是我們寫這篇文章的原因供炼。這些方法都為我們的代碼增加了大量分散的修改UI邏輯的入口一屋。

進入ReactiveCocoa的世界

ReactiveCocoa (RAC)為我們提供了一種清楚的解決方案。現(xiàn)在讓我們看看吧袋哼!
若有一個表單陆淀,當表單判斷為有效時,更新一個提交按鈕的狀態(tài)先嬉≡唬考慮如何控制這些信息的流動。現(xiàn)在的你可能是這么做的:


最終疫蔓,你小心地使用這些狀態(tài)變量含懊,在代碼里各個不相關(guān)的地方處理這個簡單的邏輯⌒普停看到這個信息流的許多不同入口了嗎岔乔?(這還只是一個UI元素的一套邏輯而已!)我們所使用的這種抽象并不聰明滚躯,不能為我們追蹤這些事情之間的關(guān)系雏门,所以我們只好可憐兮兮地自己來做這些事情。

讓我們來看看聲明式的做法


這看起來有點像以前學校里CS課上使用的APPLICATION流程圖掸掏。利用聲明式編程方法茁影,我們使用一種更高的抽象,使得我們編程的方法更接近我們腦海中設(shè)計應(yīng)用流程圖的過程丧凤。我們把更多的事情丟給計算機去做募闲。實際的代碼現(xiàn)在更接近這張圖了。

RACSignal

RACSignal(signal愿待,信號量)是RAC所有內(nèi)容的基石浩螺。它是一個代表我們最終會收到的信息的對象靴患。當你能夠?qū)⒃谖磥砟硞€時刻接收到的信息用一個具體的對象來表示的時候,你就可以提前寫好所有的邏輯要出,并提前建立起完整的信息流(聲明式)鸳君,而不是等那個事件發(fā)生的時候再去做這些(命令式)。

一個信號量將app中所有控制這個信息流動的異步的方法(delegates, callback blocks, notifications, KVO, target/action event observers, 等等)整合在一個地方患蹂。這是很有意義的一件事或颊。此外,它還能讓你在該信息流動的過程中輕松地轉(zhuǎn)換/分離/整合/過濾信息况脆。


那么饭宾,什么是signal信號量呢?這是一個信號量:



一個信號量是一個會輸出一連串值流的對象格了。但此處的這個信號量并沒有任何作用看铆,因為它沒有任何訂閱者(subscriber)。一個信號量只有在擁有訂閱者聆聽它的時候才會傳出信息盛末。信號量會向它的訂閱者傳出大于等于0個"next"事件弹惦,其中包含了所需要的值。隨后悄但,它會再傳出一個"complete"事件或是一個"error"事件棠隐。一個信號量有點類似于其它語言或框架中的"promise",但信號量的作用更強大檐嚣,不僅僅是只傳遞一次返回值而已助泽。


A signal with a subscriber
A signal with a subscriber

上文提過,我們可以根據(jù)需要對信號量傳遞出的值進行過濾嚎京,轉(zhuǎn)換嗡贺,分離,整合等鞍帝。不同的訂閱者可能會以不同的方式來使用信號量傳出的這些值诫睬。


A signal with two subscribers
A signal with two subscribers

信號量是從哪兒獲得它們所傳遞的這些值的呢?

信號量是一段異步的代碼帕涌,等待某些事情發(fā)生之后摄凡,將結(jié)果值傳給它的訂閱者。你可以使用RACSignal類的類方法createSignal:手動創(chuàng)建一個信號量:

RACSignal *networkSignal = [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {

NetworkOperation *operation = [NetworkOperation getJSONOperationForURL:@"http://someurl"];

[operation setCompletionBlockWithSuccess:^(NetworkOperation *theOperation, id *result) {

[subscriber sendNext:result];

[subscriber sendCompleted];

} failure:^(NetworkOperation *theOperation, NSError *error) {

[subscriber sendError:error];

}];

這里我在一個(假的)帶有success block 和 failure block的網(wǎng)絡(luò)請求操作中創(chuàng)建了一個信號量(原注8:如果想等到有訂閱者的時候才真正地發(fā)起網(wǎng)絡(luò)請求蚓曼,可以使用RACSignal類的類方法defer亲澡。在success block中,我對參數(shù)subscriber對象調(diào)用了sendNext:方法和sendCompleted方法辟躏,在failure block中谷扣,則調(diào)用了sendError:方法。現(xiàn)在捎琐,我可以訂閱這個信號量了会涎,每當有網(wǎng)絡(luò)請求響應(yīng)返回的時候,我都能收到一個json值或是一個error瑞凑。

幸運的是末秃,RAC框架的創(chuàng)造者們在實際項目中也運用著該框架(我猜的),因此籽御,他們很清楚我們的代碼中最需要的是什么练慕。他們提供給我們一套很好的機制,來將我們以前習慣使用的異步模式轉(zhuǎn)換成信號量技掏。只是铃将,萬一你有一個異步的任務(wù),用內(nèi)置的信號量類型無法完成的時候哑梳,不要忘了還可以使用createSignal:或類似方法方便地自己創(chuàng)建一個劲阎。

他們所提供的機制之一就是RACObserve()宏(如果你不喜歡用宏,你可以很容易地使用更底層的方法鸠真。這會稍微繁瑣點兒悯仙,但依舊很好。對于swift吠卷,這里也有一份教程 using the RAC library with swift锡垄,以及swift版本 swifty replacement)。RACObserver()宏是RAC對復(fù)雜的KVO的替代方案祭隔。你只需要將所觀察的對象以及想要觀察的該對象的屬性的keypath作為參數(shù)傳入货岭,RACObserver會生成一個信號量,并立刻將該屬性當前的值傳出(如果有訂閱者的話)疾渴,并且將來該屬性的值變化的話千贯,該變化值也會由此傳出。

RACSignal *usernameValidSignal = RACObserve(self.viewModel, usernameIsValid);
A signal created with RACObserve
A signal created with RACObserve

這僅僅是RAC所提供的一種創(chuàng)建信號量的方式。還有其它多種創(chuàng)建信號量的方式:

RACSignal *controlUpdate = [myButton rac_signalForControlEvents:UIControlEventTouchUpInside];

// signals for UIControl events send the control event value (UITextField, UIButton, UISlider, etc)

// subscribeNext:^(UIButton *button) { NSLog(@"%@", button); // UIButton instance }

RACSignal *textChange = [myTextField rac_textSignal];

// some special methods are provided for commonly needed control event values off certain controls

// subscribeNext:^(UITextField *textfield) { NSLog(@"%@", textfield.text); // "Hello!" }

RACSignal *alertButtonClicked = [myAlertView rac_buttonClickedSignal];

// signals for some delegate methods send the delegate params as the value

// e.g. UIAlertView, UIActionSheet, UIImagePickerControl, etc

// (limited to methods that return void)

// subscribeNext:^(NSNumber *buttonIndex) { NSLog(@"%@", buttonIndex); // "1" }

RACSignal *viewAppeared = [self rac_signalForSelector:@selector(viewDidAppear:)];

// signals for arbitrary selectors that return void, send the method params as the value

// works for built in or your own methods

// subscribeNext:^(NSNumber *animated) { NSLog(@"viewDidAppear %@", animated); // "viewDidAppear 1" }

要記住,你也可以輕松創(chuàng)建自己的信號量抵代,包括替換其他未被支持的代理模式墨坚。想象一下吧!我們現(xiàn)在能從這所有不相關(guān)的信息流動的異步事件中抽取出信號量竹祷,并將它們?nèi)掀饋恚】幔∷鼈儠蔀樯衔穆暶魇搅鞒虉D中的節(jié)點申尼。這是多么讓人雞凍啊垫桂!

什么是訂閱者(subscriber)师幕?

簡單來說,一個訂閱者就是一段代碼,它等待信號量傳來的值霹粥,并用這些值來做一些事情(當然灭将,也可以用“complete”和"error"來做一些事情)。

這里后控,通過將一個block作為參數(shù)傳到一個信號量的subscribeNext實例方法中庙曙,我們就創(chuàng)建了一個簡單的訂閱者。此時浩淘,我們通過RACObserve()宏創(chuàng)建了一個信號量捌朴,并以此來監(jiān)聽一個對象的一個屬性,并將其值賦給本身的一個屬性张抄。

- (void) viewDidLoad {

// ...

// create and get a reference to the signal

RACSignal *usernameValidSignal = RACObserve(self.viewModel, isUsernameValid);

// update the local property when this value changes

[usernameValidSignal subscribeNext:^(NSNumber *isValidNumber) {

self.usernameIsValid = isValidNumber.boolValue

}];

}

注意到砂蔽,RAC處理的都是對象,不是如BOOL的原始值類型署惯。不過不用擔心左驾,RAC大部分情況下都會為你自動轉(zhuǎn)換好。

更棒的是泽台,RAC的創(chuàng)造者認為什荣,像這種將一個屬性的值綁定到另一個屬性上,并監(jiān)聽其變化的行為是一種很常見的需求怀酷,因此他們提供了另一個宏RAC()稻爬。類似于RACObserve(),你只要傳入監(jiān)聽的對象蜕依,以及你想要綁定的參數(shù)桅锄,剩下的工作(創(chuàng)建一個訂閱者,更新參數(shù)等)就交給底層去做吧样眠!這樣友瘤,我們上面的例子就變成了這樣:

- (void) viewDidLoad { 
        //... 
        RAC(self, usernameIsValid) = RACObserve(self.viewModel, isUsernameValid)
;}

但這里,我們的目的并不在于此檐束。我們并不想用另一個屬性來保存信號量傳過來的值(因為這樣又會產(chǎn)生狀態(tài)變量了)辫秧。我們真正想做的事情是利用信號量傳過來的信息來更新UI。

轉(zhuǎn)化接收到的值流

現(xiàn)在我們來看看被丧,RAC提供給我們什么方法來轉(zhuǎn)化接收到的值流的盟戏。這里我們要使用的是RACSignal的實例方法map

- (void) viewDidLoad {

//...

RACSignal *usernameIsValidSignal = RACObserve(self.viewModel, isUsernameValid);

RAC(self.goButton, enabled) = usernameIsValidSignal;

RAC(self.goButton, alpha) = [usernameIsValidSignal

map:^id(NSNumber *usernameIsValid) {

return usernameIsValid.boolValue ? @1.0 : @0.5;

}];

}

現(xiàn)在甥桂,我們將view-model的isUsernameValid屬性的變化直接與goButton按鈕的enabled綁定起來啦柿究!這是多棒!與alpha值的綁定就更酷了黄选,我們將BOOL值通過map方法轉(zhuǎn)化成了alpha屬性的數(shù)據(jù)類型(注意我們這里返回的是一個NSNumber類型蝇摸,而不是普通數(shù)據(jù)類型)。

多個訂閱者,副作用貌夕,以及復(fù)雜的操作

當訂閱一個信號量鏈的時候律歼,有一點很重要的事情要注意:每次有一個新的值通過信號量鏈[5]傳遞的時候,每有一個訂閱者蜂嗽,值就會被傳遞一次苗膝,這些值不會在任何地方被存儲起來(除了RAC內(nèi)部實現(xiàn)的時候)殃恒。當一個信號量想傳遞出去一個新值的時候植旧,它會遍歷其所有訂閱者,對每一個訂閱者都傳一次离唐。(這是信號量鏈的一個簡化了的解釋病附,但基本思想是對的)

了解這點為什么重要呢?這意味著亥鬓,在這一信號量鏈中某處產(chǎn)生的副作用完沪,任何影響應(yīng)用的轉(zhuǎn)換,都會重復(fù)發(fā)生多次嵌戈。這通常是剛接觸RAC的使用者所不希望的覆积。(這也違背了函數(shù)式data in, data out的原則)

舉一個略顯刻意的例子:有一個按鈕點擊事件的信號量,在信號量鏈中的某處熟呛,會增加self的counter屬性宽档。如果有多個訂閱者訂閱了這個信號量鏈,counter屬性會比你預(yù)期的增加得更多庵朝。你必須盡量消除信號量鏈中產(chǎn)生的副作用吗冤。如果實在不能避免中間的副作用,你也可以使用一些方法來防止副作用的影響[6]九府。我將在另一篇文章中講解椎瘟。

除了副作用,你也要當心那些包含耗時操作或可變數(shù)據(jù)的信號量鏈侄旬。網(wǎng)絡(luò)請求包含了以上所說的三個注意點:

  1. 網(wǎng)絡(luò)請求影響了你app的網(wǎng)絡(luò)層(副作用)
  2. 網(wǎng)絡(luò)請求為你的信號量鏈引入了可變數(shù)據(jù)(兩個完全相同的網(wǎng)絡(luò)請求可能會返回不同的數(shù)據(jù))
  3. 網(wǎng)絡(luò)請求速度較慢肺蔚。

舉例:我們有一個信號量,每當一個按鈕被點擊的時候儡羔,會傳出一個值宣羊。我們想將這個值通過網(wǎng)絡(luò)請求轉(zhuǎn)換為另一個結(jié)果。如果這個信號量鏈有多個訂閱者要使用最后的值笔链,這中間就會產(chǎn)生多次的網(wǎng)絡(luò)請求段只。


A signal with side effects happening twice
A signal with side effects happening twice

顯然,網(wǎng)絡(luò)請求是一個很常見的需求鉴扫。正如你預(yù)料赞枕,RAC為這些情況提供了解決方案,即RACCommand和multicasting。我將在我的下篇文章中詳細講解炕婶。

Tweetboat Plus

好了姐赡,經(jīng)過了簡單的介紹(哈?)柠掂,讓我們看一下项滑,如何利用ReactiveCocoa連接我們的view model和view controller。

//

// View Controller

//

- (void) viewDidLoad {

        [super viewDidLoad];

        RAC(self.viewModel, username) = [myTextfield rac_textSignal];

        RACSignal *usernameIsValidSignal = RACObserve(self.viewModel, usernameValid);

        RAC(self.goButton, alpha) = [usernameIsValidSignal

                map: ^(NSNumber *valid) {

                        return valid.boolValue ? @1 : @0.5;

        }];

        RAC(self.goButton, enabled) = usernameIsValidSignal;

        RAC(self.avatarImageView, image) = RACObserve(self.viewModel, userAvatarImage);

        RAC(self.userNameLabel, text) = RACObserve(self.viewModel, userFullName);

        @weakify(self);

        [[[RACSignal merge:@[RACObserve(self.viewModel, tweets),

                RACObserve(self.viewModel, allTweetsLoaded)]]

                bufferWithTime:0 onScheduler:[RACScheduler mainThreadScheduler]]

                subscribeNext:^(id value) {
                
                        @strongify(self);

                        [self.tableView reloadData];

        }];

        [[self.goButton rac_signalForControlEvents:UIControlEventTouchUpInside]

                subscribeNext: ^(id value) {

                @strongify(self);

                [self.viewModel getTweetsForCurrentUsername];

        }];

}

-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

// if table section is the tweets section

        if (indexPath.section == 0) {

                MYTwitterUserCell *cell =

                [self.tableView dequeueReusableCellWithIdentifier:@"MYTwitterUserCell" forIndexPath:indexPath];

// grab the cell view model from the vc view model and assign it

                cell.viewModel = self.viewModel.tweets[indexPath.row];

                return cell;

        } else {

// else if the section is our loading cell

                MYLoadingCell *cell =

                [self.tableView dequeueReusableCellWithIdentifier:@"MYLoadingCell" forIndexPath:indexPath];

                [self.viewModel loadMoreTweets];

                return cell;

        }

}

//

// MYTwitterUserCell

//

// this could also be in cell init

- (void) awakeFromNib {

        [super awakeFromNib];

        RAC(self.avatarImageView, image) = RACObserve(self, viewModel.tweetAuthorAvatarImage);

        RAC(self.userNameLabel, text) = RACObserve(self, viewModel.tweetAuthorFullName);

        RAC(self.tweetTextLabel, text) = RACObserve(self, viewModel.tweetContent);

}

讓我們審視一下這段代碼涯贞。
RAC(self.viewModel, username) = [myTextfield rac_textSignal];
這里我們用RAC的庫方法從UITextField中抽取出一個信號量枪狂,這一行代碼將view-model的readwrite屬性username與用戶產(chǎn)生輸入時textfield的更新綁定起來。

RACSignal *usernameIsValidSignal = RACObserve(self.viewModel, usernameValid);
RAC(self.goButton, alpha) = [usernameIsValidSignal 
  map: ^(NSNumber *valid) { 
    return valid.boolValue ? @1 : @0.5; 
  }
];
RAC(self.goButton, enabled) = usernameIsValidSignal;

這里我們使用RACObserve在view-model的usernameValid屬性上創(chuàng)建了一個信號量usernameIsValidSignal宋渔。每當這個屬性發(fā)生變化的時候州疾,該信號量會傳出一個@YES@NO的值。我們將這個值與goButton的兩個屬性綁定起來皇拣。首先严蓖,我們根據(jù)值是YES或NO,分別將alpha設(shè)置成1或者0.5(記住我們要傳的是一個NSNumber類型)氧急。接著我們將該值直接與enabled屬性綁定起來颗胡,因為該屬性剛好是一個BOOL類型,所以不需要做任何轉(zhuǎn)換吩坝。

RAC(self.avatarImageView, image) = RACObserve(self.viewModel, userAvatarImage);
RAC(self.userNameLabel, text) = RACObserve(self.viewModel, userFullName);

接下來我們還是用宏RACObserve,將imageView和Label分別綁定到view-model對應(yīng)的屬性上去毒姨。

@weakify(self);
[[[RACSignal merge:@[RACObserve(self.viewModel, tweets), RACObserve(self.viewModel, allTweetsLoaded)]] 
      bufferWithTime:0 onScheduler:[RACScheduler mainThreadScheduler]] 
      subscribeNext:^(id value) { 
          @strongify(self);
         [self.tableView reloadData]; 
}];

這一段代碼可能有點復(fù)雜。我們希望當view-model的tweets數(shù)組和allTweetsLoaded屬性改變時更新tableview(在這個例子中钾恢,我們簡單地更新了整個tableview)手素。所以我們用RACObserve創(chuàng)建了這兩個屬性的信號量,并合并成一個更大的信號量:當這兩個屬性任意一個發(fā)生變化的時候瘩蚪,合并后的信號量會傳出一個值(通常你會希望一個信號量傳出的值都是同一類型的泉懦,而不是像這個例子中混合的類型。這個用RAC Swift是會強迫保證的疹瘦。但這里我們并不關(guān)心實際傳出的值崩哩,我們只是用它來觸發(fā)tableview的刷新)。

這里看起來有點復(fù)雜的是接在后面的bufferWithTime:onScheduler:方法言沐。這是為了解決UIKit中的一個問題邓嘹。我們需要追蹤這兩個屬性,tweetsallTweetsLoaded的變化险胰,并在其中任意一個發(fā)生變化時刷新tableview汹押。有時,這兩個屬性會在同一時間發(fā)生變化起便,這意味著棚贾,合并的信號量中的兩個單獨的信號量會同時傳出一個值窖维,reloadData方法會在同一個run loop中調(diào)用兩次。UIKit并不允許這樣的做法妙痹。bufferWithTime:方法將一定時間內(nèi)所有待傳遞的值存起來铸史,并在這段時間之后打包發(fā)送給訂閱者。如果傳入的參數(shù)為0怯伊,bufferWithTime:將會保存我們合并后的信號量在一個特定run loop中傳遞的所有的值琳轿,然后將它們一并發(fā)出(原注10:NSTimer的工作方法是相同的。這也不是巧合啦哈哈耿芹,因為bufferWithTime:就是用NSTimer實現(xiàn)的)≌复郏現(xiàn)在不用去想scheduler,就把它想象成指定了這些值必須是在主線程傳遞⌒上担現(xiàn)在媚送,我們保證了reloadData方法每一個run loop都只執(zhí)行一次。

注意我這里使用的strong weak dance寇甸,就是@weakify/@strongify這些宏。當我們使用這些block的時候疗涉,這是非常重要的拿霉!當在RAC block中使用self的時候,如果不仔細咱扣,很容易會使得self被block所持有绽淘,從而產(chǎn)生循環(huán)引用。

[[self.goButton rac_signalForControlEvents:UIControlEventTouchUpInside] 
      subscribeNext: ^(id value) { 
        @strongify(self); 
        [self.viewModel getTweetsForCurrentUsername]; 
}];

這是我的下一篇文章中會講到的RACCommand使用的地方闹伪。但這里沪铭,我們只是當按鈕被點擊時,手動調(diào)用了view-model的getTweetsForCurrentUsername方法偏瓤。

我們已經(jīng)講過了cellForRowAtIndexPath的第一個部分杀怠,現(xiàn)在看一下loading cell的部分:

MYLoadingCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"MYLoadingCell" forIndexPath:indexPath];
[self.tableView loadMoreTweets];return cell;

這是另一個將來會用RACCommand的地方,不過現(xiàn)在我們也是手動調(diào)用了view-model的loadMoreTweets方法厅克。我們默認在cell重用的時候赔退,view-model內(nèi)部會有機制防止該方法重復(fù)調(diào)用。

- (void) awakeFromNib { 
  [super awakeFromNib]; RAC(self.avatarImageView, image) = RACObserve(self, viewModel.userAvatarImage);
   RAC(self.userNameLabel, text) = RACObserve(self, viewModel.tweetAuthorFullName);
   RAC(self.tweetTextLabel, text) = RACObserve(self, viewModel.tweetContent);
}

這段代碼意思很明確证舟,但我還要指出一點:我們將一個image和一些string綁定在UI的相關(guān)屬性上硕旗。但注意到,viewModel是處于RACObserve()宏的右邊參數(shù)位置女责。這些cell將會被重用漆枚,新的view-model會被賦給它們。如果將viewModel放在左邊參數(shù)的位置抵知,就相當于監(jiān)聽viewModel屬性的變化墙基,并每次都重新進行綁定昔榴;相反,將viewModel放在右邊參數(shù)的位置碘橘,RACObserve會為我們做好這些工作互订。因此,我們只需要做一次這些綁定的工作痘拆,剩下的工作交給Reactive Cocoa吧仰禽!在綁定cell的時候要記住這一點。實際使用中我從沒碰到過坑纺蛆,即使是在大量cell復(fù)用的時候吐葵。

譯者注:


  1. 胖model和瘦model ?

  2. 是否是說,從原始數(shù)據(jù)轉(zhuǎn)化成的model(也許item更合適)稱為數(shù)據(jù)模型桥氏,model保存了若干這樣的數(shù)據(jù)模型温峭,這種結(jié)構(gòu) ?

  3. 什么情況下呢? ?

  4. 參考Imperative and Declarative Programming一文:http://theproactiveprogrammer.com/design/imperative-and-declarative-programming/?utm_source=tuicool&utm_medium=referral ?

  5. 個人理解字支,即由一個信號量出發(fā)凤藏,經(jīng)過一系列整合,分離堕伪,轉(zhuǎn)化的過程揖庄,最后止于訂閱者的一條鏈 ?

  6. RACMulticastConnection ?

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市欠雌,隨后出現(xiàn)的幾起案子蹄梢,更是在濱河造成了極大的恐慌,老刑警劉巖富俄,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件禁炒,死亡現(xiàn)場離奇詭異,居然都是意外死亡霍比,警方通過查閱死者的電腦和手機幕袱,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來桂塞,“玉大人凹蜂,你說我怎么就攤上這事「笪#” “怎么了玛痊?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長狂打。 經(jīng)常有香客問我擂煞,道長,這世上最難降的妖魔是什么趴乡? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任对省,我火速辦了婚禮蝗拿,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘蒿涎。我一直安慰自己哀托,他們只是感情好,可當我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布劳秋。 她就那樣靜靜地躺著仓手,像睡著了一般。 火紅的嫁衣襯著肌膚如雪玻淑。 梳的紋絲不亂的頭發(fā)上嗽冒,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天,我揣著相機與錄音补履,去河邊找鬼添坊。 笑死,一個胖子當著我的面吹牛箫锤,可吹牛的內(nèi)容都是我干的贬蛙。 我是一名探鬼主播,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼麻汰,長吁一口氣:“原來是場噩夢啊……” “哼速客!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起五鲫,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎岔擂,沒想到半個月后位喂,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡乱灵,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年塑崖,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片痛倚。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡规婆,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出蝉稳,到底是詐尸還是另有隱情抒蚜,我是刑警寧澤,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布耘戚,位于F島的核電站嗡髓,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏收津。R本人自食惡果不足惜饿这,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一浊伙、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧长捧,春花似錦嚣鄙、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至奉芦,卻和暖如春赵抢,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背声功。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工烦却, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人先巴。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓其爵,卻偏偏與公主長得像,于是被迫代替她去往敵國和親伸蚯。 傳聞我的和親對象是個殘疾皇子摩渺,可洞房花燭夜當晚...
    茶點故事閱讀 44,979評論 2 355

推薦閱讀更多精彩內(nèi)容