雜談: MVC/MVP/MVVM(轉)

原文

前言

本文為回答一位朋友關于MVC/MVP/MVVM架構方面的疑問所寫, 旨在介紹iOS下MVC/MVP/MVVM三種架構的設計思路以及各自的優缺點. 全文約五千字, 預計花費閱讀時間20 - 30分鐘.

MVC

MVC的相關概念

MVC最早存在于桌面程序中的, M是指業務數據, V是指用戶界面, C則是控制器. 在具體的業務場景中, C作為M和V之間的連接, 負責獲取輸入的業務數據, 然后將處理后的數據輸出到界面上做相應展示, 另外, 在數據有所更新時, C還需要及時提交相應更新到界面展示. 在上述過程中, 因為M和V之間是完全隔離的, 所以在業務場景切換時, 通常只需要替換相應的C, 復用已有的M和V便可快速搭建新的業務場景. MVC因其復用性, 大大提高了開發效率, 現已被廣泛應用在各端開發中.

概念過完了, 下面來看看, 在具體的業務場景中MVC/MVP/MVVM都是如何表現的.

MVC之消失的C層


上圖中的頁面(業務場景)或者類似頁面相信大家做過不少, 各個程序員的具體實現方式可能各不一樣, 這里說說我所看到的大多數新手程序員的寫法:

//UserVC

-?(void)viewDidLoad?{

[super?viewDidLoad];

[[UserApi?new]?fetchUserInfoWithUserId:132?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{

[self?showToastWithText:@"獲取用戶信息失敗了~"];

?}else{

self.userIconIV.image?=?...

self.userSummaryLabel.text?=?...

...

?}?

?}];

[[userApi?new]?fetchUserBlogsWithUserId:132?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{

[self?showErrorInView:self.tableView?info:...];

}else{

[self.blogs?addObjectsFromArray:result];

[self.tableView?reloadData];

?}?

}];

?}

//...略

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

BlogCell?*cell?=?[tableView?dequeueReusableCellWithIdentifier:@"BlogCell"];

cell.blog?=?self.blogs[indexPath.row];

return?cell;

?}

-?(void)tableView:(UITableView?*)tableView?didSelectRowAtIndexPath:(NSIndexPath?*)indexPath?{

[self.navigationController?pushViewController:[BlogDetailViewController?instanceWithBlog:self.blogs[indexPath.row]]?animated:YES];

}

//...略

//BlogCell

-?(void)setBlog:(Blog)blog?{

_blog?=?blog;

self.authorLabel.text?=?blog.blogAuthor;

self.likeLebel.text?=?[NSString?stringWithFormat:@"贊?%ld",?blog.blogLikeCount];

...

}

新手很快寫完了代碼, Command+R一跑, 沒有問題, 心滿意足的做其他事情去了. 后來有一天, 產品要求這個業務需要改動, 用戶在看他人信息時是上圖中的頁面, 看自己的信息時, 多一個草稿箱的展示, 像這樣:


于是新生將代碼改成這樣:

//UserVC

-?(void)viewDidLoad?{

[super?viewDidLoad];

if(self.userId?!=?LoginUserId)?{

self.switchButton.hidden?=?self.draftTableView.hidden?=?YES;

self.blogTableView.frame?=?...

?}

[[UserApi?new]?fetchUserI......略

[[UserApi?new]?fetchUserBlogsWithUserId:132?completionHandler:^(NSError?*error,?id?result)?{

//if?Error...略

[self.blogs?addObjectsFromArray:result];

[self.blogTableView?reloadData];

?}];

[[userApi?new]?fetchUserDraftsWithUserId:132?completionHandler:^(NSError?*error,?id?result)?{

//if?Error...略

[self.drafts?addObjectsFromArray:result];

[self.draftTableView?reloadData];

?}];

?}

-?(NSInteger)tableView:(UITableView?*)tableView?numberOfRowsInSection:(NSInteger)section?{

return?tableView?==?self.blogTableView???self.blogs.count?:?self.drafts.count;

?}

//...略

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

if(tableView?==?self.blogTableView)?{

BlogCell?*cell?=?[tableView?dequeueReusableCellWithIdentifier:@"BlogCell"];

cell.blog?=?self.blogs[indexPath.row];

return?cell;

}else{

DraftCell?*cell?=?[tableView?dequeueReusableCellWithIdentifier:@"DraftCell"];

cell.draft?=?self.drafts[indexPath.row];

return?cell;

? }

?}

-?(void)tableView:(UITableView?*)tableView?didSelectRowAtIndexPath:(NSIndexPath?*)indexPath?{

if(tableView?==?self.blogTableView)?...

? }

//...略

//DraftCell

-?(void)setDraft:(draft)draft?{

_draft?=?draft;

self.draftEditDate?=?...

?}

//BlogCell

-?(void)setBlog:(Blog)blog?{

...同上

}

后來啊, 產品覺得用戶看自己的頁面再加個回收站什么的會很好, 于是程序員又加上一段代碼邏輯 , 再后來...

隨著需求的變更, UserVC變得越來越臃腫, 越來越難以維護, 拓展性和測試性也極差. 新手也發現好像代碼寫得有些問題, 但是問題具體出在哪里? 難道這不是MVC嗎?

我們將上面的過程用一張圖來表示:


通過這張圖可以發現, 用戶信息頁面作為業務場景Scene需要展示多種數據M(Blog/Draft/UserInfo), 所以對應的有多個View(blogTableView/draftTableView/image...), 但是, 每個MV之間并沒有一個連接層C, 本來應該分散到各個C層處理的邏輯全部被打包丟到了Scene這一個地方處理, 也就是M-C-V變成了MM...-Scene-...VV, C層就這樣莫名其妙的消失了.

另外, 作為V的兩個cell直接耦合了M(blog/draft), 這意味著這兩個V的輸入被綁死到了相應的M上, 復用無從談起.

最后, 針對這個業務場景的測試異常麻煩, 因為業務初始化和銷毀被綁定到了VC的生命周期上, 而相應的邏輯也關聯到了和View的點擊事件, 測試只能Command+R, 點點點...

正確的MVC使用姿勢

也許是UIViewController的類名給新人帶來了迷惑, 讓人誤以為VC就一定是MVC中的C層, 又或許是Button, Label之類的View太過簡單完全不需要一個C層來配合, 總之, 我工作以來經歷的項目中見過太多這樣的"MVC". 那么, 什么才是正確的MVC使用姿勢呢?

仍以上面的業務場景舉例, 正確的MVC應該是這個樣子的:

UserVC作為業務場景, 需要展示三種數據, 對應的就有三個MVC, 這三個MVC負責各自模塊的數據獲取, 數據處理和數據展示, 而UserVC需要做的就是配置好這三個MVC, 并在合適的時機通知各自的C層進行數據獲取, 各個C層拿到數據后進行相應處理, 處理完成后渲染到各自的View上, UserVC最后將已經渲染好的各個View進行布局即可, 具體到代碼中如下:

@interface BlogTableViewHelper : NSObject(UITableViewDelegate, UITableViewDataSource)(識別問題此處圓括號替換尖括號)

+?(instancetype)helperWithTableView:(UITableView?*)tableView?userId:(NSUInteger)userId;

-?(void)fetchDataWithCompletionHandler:(NetworkTaskCompletionHander)completionHander;

-?(void)setVCGenerator:(ViewControllerGenerator)VCGenerator;

@end

@interface BlogTableViewHelper()

@property?(weak,?nonatomic)?UITableView?*tableView;

@property?(copy,?nonatomic)?ViewControllerGenerator?VCGenerator;

@property?(assign,?nonatomic)?NSUInteger?userId;

@property?(strong,?nonatomic)?NSMutableArray?*blogs;

@property?(strong,?nonatomic)?UserAPIManager?*apiManager;

@end

#define?BlogCellReuseIdentifier?@"BlogCell"

@implementation?BlogTableViewHelper

+?(instancetype)helperWithTableView:(UITableView?*)tableView?userId:(NSUInteger)userId?{

return[[BlogTableViewHelper?alloc]?initWithTableView:tableView?userId:userId];

}

-?(instancetype)initWithTableView:(UITableView?*)tableView?userId:(NSUInteger)userId?{

if(self?=?[super?init])?{

self.userId?=?userId;

tableView.delegate?=?self;

tableView.dataSource?=?self;

self.apiManager?=?[UserAPIManagernew];

self.tableView?=?tableView;

__weaktypeof(self)?weakSelf?=?self;

[tableView?registerClass:[BlogCell?class]?forCellReuseIdentifier:BlogCellReuseIdentifier];

tableView.header?=?[MJRefreshAnimationHeader?headerWithRefreshingBlock:^{//下拉刷新

[weakSelf.apiManage?refreshUserBlogsWithUserId:userId?completionHandler:^(NSError?*error,?id?result)?{

//...略

}];

}];

tableView.footer?=?[MJRefreshAnimationFooter?headerWithRefreshingBlock:^{//上拉加載

[weakSelf.apiManage?loadMoreUserBlogsWithUserId:userId?completionHandler:^(NSError?*error,?id?result)?{

//...略

}];

}];

}

return?self;

}

#pragma?mark?-?UITableViewDataSource?&&?Delegate

//...略

-?(NSInteger)tableView:(UITableView?*)tableView?numberOfRowsInSection:(NSInteger)section?{

returnself.blogs.count;

}

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

BlogCell?*cell?=?[tableView?dequeueReusableCellWithIdentifier:BlogCellReuseIdentifier];

BlogCellHelper?*cellHelper?=?self.blogs[indexPath.row];

if(!cell.didLikeHandler)?{

__weaktypeof(cell)?weakCell?=?cell;

[cell?setDidLikeHandler:^{

cellHelper.likeCount?+=?1;

weakCell.likeCountText?=?cellHelper.likeCountText;

}];

}

cell.authorText?=?cellHelper.authorText;

//...各種設置

returncell;

}

-?(void)tableView:(UITableView?*)tableView?didSelectRowAtIndexPath:(NSIndexPath?*)indexPath?{

[self.navigationController?pushViewController:self.VCGenerator(self.blogs[indexPath.row])?animated:YES];

}

#pragma?mark?-?Utils

-?(void)fetchDataWithCompletionHandler:(NetworkTaskCompletionHander)completionHander?{

[[UserAPIManager new]?refreshUserBlogsWithUserId:self.userId?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{

[self?showErrorInView:self.tableView?info:error.domain];

}else{

for(Blog?*bloginresult)?{

[self.blogs?addObject:[BlogCellHelper?helperWithBlog:blog]];

}

[self.tableView?reloadData];

}

completionHandler???completionHandler(error,?result)?:?nil;

}];

}

//...略

@end


@implementation BlogCell

//...略

-?(void)onClickLikeButton:(UIButton?*)sender?{

[[UserAPIManager?new]?likeBlogWithBlogId:self.blogId?userId:self.userId?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{

//do?error

}else{

//do?success

self.didLikeHandler???self.didLikeHandler()?:?nil;

}

}];

}

@end

@implementation BlogCell

//...略

-?(void)onClickLikeButton:(UIButton?*)sender?{

[[UserAPIManagernew]?likeBlogWithBlogId:self.blogId?userId:self.userId?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{

//do?error

}else{

//do?success

self.didLikeHandler???self.didLikeHandler()?:?nil;

}

}];

}

@end

@implementation BlogCellHelper

-?(NSString?*)likeCountText?{

return?[NSString?stringWithFormat:@"贊?%ld",?self.blog.likeCount];

}

//...略

-?(NSString?*)authorText?{

return?[NSString?stringWithFormat:@"作者姓名:?%@",?self.blog.authorName];

}

@end

Blog模塊由BlogTableViewHelper(C), BlogTableView(V), Blogs(C)構成, 這里有點特殊, blogs里面裝的不是M, 而是Cell的C層CellHelper, 這是因為Blog的MVC其實又是由多個更小的MVC組成的. M和V沒什么好說的, 主要說一下作為C的TableVIewHelper做了什么.

實際開發中, 各個模塊的View可能是在Scene對應的Storyboard中新建并布局的, 此時就不用各個模塊自己建立View了(比如這里的BlogTableViewHelper), 讓Scene傳到C層進行管理就行了, 當然, 如果你是純代碼的方式, 那View就需要相應模塊自行建立了(比如下文的UserInfoViewController), 這個看自己的意愿, 無傷大雅.

BlogTableViewHelper對外提供獲取數據和必要的構造方法接口, 內部根據自身情況進行相應的初始化.

當外部調用fetchData的接口后, Helper就會啟動獲取數據邏輯, 因為數據獲取前后可能會涉及到一些頁面展示(HUD之類的), 而具體的展示又是和Scene直接相關的(有的Scene展示的是HUD有的可能展示的又是一種樣式或者根本不展示), 所以這部分會以CompletionHandler的形式交由Scene自己處理.

在Helper內部, 數據獲取失敗會展示相應的錯誤頁面, 成功則建立更小的MVC部分并通知其展示數據(也就是通知CellHelper驅動Cell), 另外, TableView的上拉刷新和下拉加載邏輯也是隸屬于Blog模塊的, 所以也在Helper中處理.

在頁面跳轉的邏輯中, 點擊跳轉的頁面是由Scene通過VCGeneratorBlock直接配置的, 所以也是解耦的(你也可以通過didSelectRowHandler之類的方式傳遞數據到Scene層, 由Scene做跳轉, 是一樣的).

最后, V(Cell)現在只暴露了Set方法供外部進行設置, 所以和M(Blog)之間也是隔離的, 復用沒有問題.

這一系列過程都是自管理的, 將來如果Blog模塊會在另一個SceneX展示, 那么SceneX只需要新建一個BlogTableViewHelper, 然后調用一下helper.fetchData即可.

DraftTableViewHelper和BlogTableViewHelper邏輯類似, 就不貼了, 簡單貼一下UserInfo模塊的邏輯:

@implementation UserInfoViewController

+?(instancetype)instanceUserId:(NSUInteger)userId?{

return?[[UserInfoViewController?alloc]?initWithUserId:userId];

}

-?(instancetype)initWithUserId:(NSUInteger)userId?{

//????...略

[self?addUI];

//????...略

}

#pragma?mark?-?Action

-?(void)onClickIconButton:(UIButton?*)sender?{

[self.navigationController?pushViewController:self.VCGenerator(self.user)?animated:YES];

}

#pragma?mark?-?Utils

-?(void)addUI?{

//各種UI初始化?各種布局

self.userIconIV?=?[[UIImageView?alloc]?initWithFrame:CGRectZero];

self.friendCountLabel?=?...

...

}

-?(void)fetchData?{

[[UserAPIManager?new]?fetchUserInfoWithUserId:self.userId?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{

[self?showErrorInView:self.view?info:error.domain];

}else{

self.user?=?[User?objectWithKeyValues:result];

self.userIconIV.image?=?[UIImage?imageWithURL:[NSURL?URLWithString:self.user.url]];//數據格式化

self.friendCountLabel.text?=?[NSString?stringWithFormat:@"贊?%ld",?self.user.friendCount];//數據格式化

...

}

}];

}

@end

UserInfoViewController除了比兩個TableViewHelper多個addUI的子控件布局方法, 其他邏輯大同小異, 也是自己管理的MVC, 也是只需要初始化即可在任何一個Scene中使用.

現在三個自管理模塊已經建立完成, UserVC需要的只是根據自己的情況做相應的拼裝布局即可, 就和搭積木一樣:

@interface UserViewController ()

@property?(assign,?nonatomic)?NSUInteger?userId;

@property?(strong,?nonatomic)?UserInfoViewController?*userInfoVC;

@property?(strong,?nonatomic)?UITableView?*blogTableView;

@property?(strong,?nonatomic)?BlogTableViewHelper?*blogTableViewHelper;

@end

@interface?SelfViewController?:?UserViewController

@property?(strong,?nonatomic)?UITableView?*draftTableView;

@property?(strong,?nonatomic)?DraftTableViewHelper?*draftTableViewHelper;

@end

#pragma?mark?-?UserViewController

@implementation?UserViewController

+?(instancetype)instanceWithUserId:(NSUInteger)userId?{

if(userId?==?LoginUserId)?{

return?[[SelfViewController?alloc]?initWithUserId:userId];

}else{return [[UserViewController alloc] initWithUserId:userId];

}

}



-?(void)viewDidLoad?{

[super?viewDidLoad];

[self?addUI];

[self?configuration];

[self?fetchData];

}

#pragma?mark?-?Utils(UserViewController)

-?(void)addUI?{

//這里只是表達一下意思?具體的layout邏輯肯定不是這么簡單的

self.userInfoVC?=?[UserInfoViewController?instanceWithUserId:self.userId];

self.userInfoVC.view.frame?=?CGRectZero;

[self.view?addSubview:self.userInfoVC.view];

[self.view?addSubview:self.blogTableView?=?[[UITableView?alloc]?initWithFrame:CGRectZero?style:0]];

}

-?(void)configuration?{

self.title?=?@"用戶詳情";

//????...其他設置

[self.userInfoVC?setVCGenerator:^UIViewController?*(id?params)?{

return?[UserDetailViewController?instanceWithUser:params];

}];

self.blogTableViewHelper?=?[BlogTableViewHelper?helperWithTableView:self.blogTableView?userId:self.userId];

[self.blogTableViewHelper?setVCGenerator:^UIViewController?*(id?params)?{

return?[BlogDetailViewController?instanceWithBlog:params];

?}];?

?}

-?(void)fetchData?{

[self.userInfoVC?fetchData];//userInfo模塊不需要任何頁面加載提示

[HUD?show];//blog模塊可能就需要HUD

[self.blogTableViewHelper?fetchDataWithcompletionHandler:^(NSError?*error,?id?result)?{

[HUD?hide];

?}];

?}

@end

#pragma?mark?-?SelfViewController

@implementation?SelfViewController

-?(void)viewDidLoad?{

?[super?viewDidLoad];

[self?addUI];

[self?configuration];

[self?fetchData];

? }

#pragma?mark?-?Utils(SelfViewController)

-?(void)addUI?{

?[super?addUI];

[self.view?addSubview:switchButton];//特有部分...

//...各種設置

[self.view?addSubview:self.draftTableView?=?[[UITableView?alloc]?initWithFrame:CGRectZero?style:0]];

?}

-?(void)configuration?{

[super?configuration];

self.draftTableViewHelper?=?[DraftTableViewHelper?helperWithTableView:self.draftTableView?userId:self.userId];

[self.draftTableViewHelper?setVCGenerator:^UIViewController?*(id?params)?{

return?[DraftDetailViewController?instanceWithDraft:params];

?}];

?}

-?(void)fetchData?{

[super?fetchData];

[self.draftTableViewHelper?fetchData];

}

@end

作為業務場景的的Scene(UserVC)做的事情很簡單, 根據自身情況對三個模塊進行配置(configuration), 布局(addUI), 然后通知各個模塊啟動(fetchData)就可以了, 因為每個模塊的展示和交互是自管理的, 所以Scene只需要負責和自身業務強相關的部分即可. 另外, 針對自身訪問的情況我們建立一個UserVC子類SelfVC, SelfVC做的也是類似的事情.

MVC到這就說的差不多了, 對比上面錯誤的MVC方式, 我們看看解決了哪些問題:

1.代碼復用: 三個小模塊的V(cell/userInfoView)對外只暴露Set方法, 對M甚至C都是隔離狀態, 復用完全沒有問題. 三個大模塊的MVC也可以用于快速構建相似的業務場景(大模塊的復用比小模塊會差一些, 下文我會說明).

2.代碼臃腫: 因為Scene大部分的邏輯和布局都轉移到了相應的MVC中, 我們僅僅是拼裝MVC的便構建了兩個不同的業務場景, 每個業務場景都能正常的進行相應的數據展示, 也有相應的邏輯交互, 而完成這些東西, 加空格也就100行代碼左右(當然, 這里我忽略了一下Scene的布局代碼).

3.易拓展性: 無論產品未來想加回收站還是防御塔, 我需要的只是新建相應的MVC模塊, 加到對應的Scene即可.

4.可維護性: 各個模塊間職責分離, 哪里出錯改哪里, 完全不影響其他模塊. 另外, 各個模塊的代碼其實并不算多, 哪一天即使寫代碼的人離職了, 接手的人根據錯誤提示也能快速定位出錯模塊.

5.易測試性: 很遺憾, 業務的初始化依然綁定在Scene的生命周期中, 而有些邏輯也仍然需要UI的點擊事件觸發, 我們依然只能Command+R, 點點點...

MVC的缺點

可以看到, 即使是標準的MVC架構也并非完美, 仍然有部分問題難以解決, 那么MVC的缺點何在? 總結如下:

1.過度的注重隔離: 這個其實MV(x)系列都有這缺點, 為了實現V層的完全隔離, V對外只暴露Set方法, 一般情況下沒什么問題, 但是當需要設置的屬性很多時, 大量重復的Set方法寫起來還是很累人的.

2.業務邏輯和業務展示強耦合: 可以看到, 有些業務邏輯(頁面跳轉/點贊/分享...)是直接散落在V層的, 這意味著我們在測試這些邏輯時, 必須首先生成對應的V, 然后才能進行測試. 顯然, 這是不合理的. 因為業務邏輯最終改變的是數據M, 我們的關注點應該在M上, 而不是展示M的V.

MVP

MVC的缺點在于并沒有區分業務邏輯和業務展示, 這對單元測試很不友好. MVP針對以上缺點做了優化, 它將業務邏輯和業務展示也做了一層隔離, 對應的就變成了MVCP. M和V功能不變, 原來的C現在只負責布局, 而所有的邏輯全都轉移到了P層.

對應關系如圖所示:

業務場景沒有變化, 依然是展示三種數據, 只是三個MVC替換成了三個MVP(圖中我只畫了Blog模塊), UserVC負責配置三個MVP(新建各自的VP, 通過VP建立C, C會負責建立VP之間的綁定關系), 并在合適的時機通知各自的P層(之前是通知C層)進行數據獲取, 各個P層在獲取到數據后進行相應處理, 處理完成后會通知綁定的View數據有所更新, V收到更新通知后從P獲取格式化好的數據進行頁面渲染, UserVC最后將已經渲染好的各個View進行布局即可. 另外, V層C層不再處理任何業務邏輯, 所有事件觸發全部調用P層的相應命令, 具體到代碼中如下:

@interface BlogPresenter : NSObject

+?(instancetype)instanceWithUserId:(NSUInteger)userId;

-?(NSArray?*)allDatas;//業務邏輯移到了P層?和業務相關的M也跟著到了P層

-?(void)refreshUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;

-?(void)loadMoreUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;

@end

@interface BlogPresenter()

@property?(assign,?nonatomic)?NSUInteger?userId;

@property?(strong,?nonatomic)?NSMutableArray?*blogs;

@property?(strong,?nonatomic)?UserAPIManager?*apiManager;

@end

@implementation?BlogPresenter

+?(instancetype)instanceWithUserId:(NSUInteger)userId?{

return[[BlogPresenter?alloc]?initWithUserId:userId];

}

-?(instancetype)initWithUserId:(NSUInteger)userId?{

if(self?=?[superinit])?{

self.userId?=?userId;

self.apiManager?=?[UserAPIManagernew];

//...略

}

}

#pragma?mark?-?Interface

-?(NSArray?*)allDatas?{

returnself.blogs;

}

//提供給外層調用的命令

-?(void)refreshUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler?{

[self.apiManager?refreshUserBlogsWithUserId:self.userId?completionHandler:^(NSError?*error,?id?result)?{

if(!error)?{

[self.blogs?removeAllObjects];//清空之前的數據

for(Blog?*bloginresult)?{

[self.blogs?addObject:[BlogCellPresenter?presenterWithBlog:blog]];

}

}

completionHandler???completionHandler(error,?result)?:?nil;

}];

}

//提供給外層調用的命令

-?(void)loadMoreUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler?{

[self.apiManager?loadMoreUserBlogsWithUserId:self.userId?completionHandler...]

}

@end

@interface BlogCellPresenter : NSObject

+?(instancetype)presenterWithBlog:(Blog?*)blog;

-?(NSString?*)authorText;

-?(NSString?*)likeCountText;

-?(void)likeBlogWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;

-?(void)shareBlogWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;

@end

@implementation BlogCellPresenter

-?(NSString?*)likeCountText?{

return[NSString?stringWithFormat:@"贊?%ld",?self.blog.likeCount];

}

-?(NSString?*)authorText?{

return[NSString?stringWithFormat:@"作者姓名:?%@",?self.blog.authorName];

}

//????...略

-?(void)likeBlogWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler?{

[[UserAPIManagernew]?likeBlogWithBlogId:self.blogId?userId:self.userId?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{

//do?fail

}else{

//do?success

self.blog.likeCount?+=?1;

}

completionHandler???completionHandler(error,?result)?:?nil;

}];

}

//????...略

@end

BlogPresenter和BlogCellPresenter分別作為BlogViewController和BlogCell的P層, 其實就是一系列業務邏輯的集合. BlogPresenter負責獲取Blogs原始數據并通過這些原始數據構造BlogCellPresenter, 而BlogCellPresenter提供格式化好的各種數據以供Cell渲染, 另外, 點贊和分享的業務現在也轉移到了這里.

業務邏輯被轉移到了P層, 此時的V層只需要做兩件事:

1.監聽P層的數據更新通知, 刷新頁面展示.

2.在點擊事件觸發時, 調用P層的對應方法, 并對方法執行結果進行展示.

@interface BlogCell : UITableViewCell

@property?(strong,?nonatomic)?BlogCellPresenter?*presenter;

@end

@implementation BlogCell

-?(void)setPresenter:(BlogCellPresenter?*)presenter?{

_presenter?=?presenter;

//從Presenter獲取格式化好的數據進行展示

self.authorLabel.text?=?presenter.authorText;

self.likeCountLebel.text?=?presenter.likeCountText;

//????...略

}

#pragma?mark?-?Action

-?(void)onClickLikeButton:(UIButton?*)sender?{

[self.presenter?likeBlogWithCompletionHandler:^(NSError?*error,?id?result)?{

if(!error)?{//頁面刷新

self.likeCountLebel.text?=?self.presenter.likeCountText;

}

//????????...略

}];

}

@end

而C層做的事情就是布局和PV之間的綁定(這里可能不太明顯, 因為BlogVC里面的布局代碼是TableViewDataSource, PV綁定的話, 因為我偷懶用了Block做通知回調, 所以也不太明顯, 如果是Protocol回調就很明顯了), 代碼如下:


@interface BlogViewController : NSObject

+?(instancetype)instanceWithTableView:(UITableView?*)tableView?presenter:(BlogPresenter)presenter;

-?(void)setDidSelectRowHandler:(void?(^)(Blog?*))didSelectRowHandler;

-?(void)fetchDataWithCompletionHandler:(NetworkCompletionHandler)completionHandler;

@end

@interface BlogViewController ()(UITableViewDataSource, UITabBarDelegate, BlogView)(識別問題此處圓括號替換尖括號使用)

@property?(weak,?nonatomic)?UITableView?*tableView;

@property?(strong,?nonatomic)?BlogPresenter?presenter;

@property?(copy,?nonatomic)?void(^didSelectRowHandler)(Blog?*);

@end

@implementation?BlogViewController

+?(instancetype)instanceWithTableView:(UITableView?*)tableView?presenter:(BlogPresenter)presenter?{

return[[BlogViewController?alloc]?initWithTableView:tableView?presenter:presenter];

}

-?(instancetype)initWithTableView:(UITableView?*)tableView?presenter:(BlogPresenter)presenter?{

if(self?=?[superinit])?{

self.presenter?=?presenter;

self.tableView?=?tableView;

tableView.delegate?=?self;

tableView.dataSource?=?self;

__weaktypeof(self)?weakSelf?=?self;

[tableView?registerClass:[BlogCell?class]?forCellReuseIdentifier:BlogCellReuseIdentifier];

tableView.header?=?[MJRefreshAnimationHeader?headerWithRefreshingBlock:^{//下拉刷新

[weakSelf.presenter?refreshUserBlogsWithCompletionHandler:^(NSError?*error,?id?result)?{

[weakSelf.tableView.header?endRefresh];

if(!error)?{

[weakSelf.tableView?reloadData];

}//...略}];}];

tableView.footer = [MJRefreshAnimationFooter headerWithRefreshingBlock:^{//上拉加載

[weakSelf.presenter loadMoreUserBlogsWithCompletionHandler:^(NSError *error, id result) {

[weakSelf.tableView.footer endRefresh];

if(!error) {

[weakSelf.tableView reloadData];

}

//...略}];

}];}return self; }

#pragma?mark?-?Interface

-?(void)fetchDataWithCompletionHandler:(NetworkCompletionHandler)completionHandler?{

[self.presenter?refreshUserBlogsWithCompletionHandler:^(NSError?*error,?id?result)?{

if(error)?{

//show?error?info

}else{

[self.tableView?reloadData];

}

completionHandler???completionHandler(error,?result)?:?nil;

}];

}

#pragma?mark?-?UITableViewDataSource?&&?Delegate

-?(NSInteger)tableView:(UITableView?*)tableView?numberOfRowsInSection:(NSInteger)section?{

return?self.presenter.allDatas.count;

}

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

BlogCell?*cell?=?[tableView?dequeueReusableCellWithIdentifier:BlogCellReuseIdentifier];

BlogCellPresenter?*cellPresenter?=?self.presenter.allDatas[indexPath.row];

cell.present?=?cellPresenter;

return?cell;

}

-?(void)tableView:(UITableView?*)tableView?didSelectRowAtIndexPath:(NSIndexPath?*)indexPath?{

self.didSelectRowHandler???self.didSelectRowHandler(self.presenter.allDatas[indexPath.row])?:?nil;

}

@end

BlogViewController現在不再負責實際的數據獲取邏輯, 數據獲取直接調用Presenter的相應接口, 另外, 因為業務邏輯也轉移到了Presenter, 所以TableView的布局用的也是Presenter.allDatas. 至于Cell的展示, 我們替換了原來大量的Set方法, 讓Cell自己根據綁定的CellPresenter做展示. 畢竟現在邏輯都移到了P層, V層要做相應的交互也必須依賴對應的P層命令, 好在V和M仍然是隔離的, 只是和P耦合了, P層是可以隨意替換的, M顯然不行, 這是一種折中.

最后是Scene, 它的變動不大, 只是替換配置MVC為配置MVP, 另外數據獲取也是走P層, 不走C層了(然而代碼里面并不是這樣的):

- (void)configuration {

//????...其他設置

BlogPresenter?*blogPresenter?=?[BlogPresenter?instanceWithUserId:self.userId];

self.blogViewController?=?[BlogViewController?instanceWithTableView:self.blogTableView?presenter:blogPresenter];

[self.blogViewController?setDidSelectRowHandler:^(Blog?*blog)?{

[self.navigationController?pushViewController:[BlogDetailViewController?instanceWithBlog:blog]?animated:YES];

}];

//????...略

}

-?(void)fetchData?{

//????????...略

[self.userInfoVC?fetchData];

[HUD?show];

[self.blogViewController?fetchDataWithCompletionHandler:^(NSError?*error,?id?result)?{

[HUD?hide];

}];

//還是因為懶,?用了Block走C層轉發會少寫一些代碼,?如果是Protocol或者KVO方式就會用self.blogViewController.presenter了

//不過沒有關系,?因為我們替換MVC為MVP是為了解決單元測試的問題,?現在的用法完全不影響單元測試,?只是和概念不符罷了.

//????????...略

}

上面的例子中其實有一個問題, 即我們假定: 所有的事件都是由V層主動發起且一次性的. 這其實是不成立的, 舉個簡單的例子: 類似微信語音聊天之類的頁面, 點擊語音Cell開始播放, Cell展示播放動畫, 播放完成動畫停止, 然后播放下一條語音.

在這個播放場景中, 如果CellPresenter還是像上面一樣僅僅提供一個playWithCompletionHandler的接口是行不通的. 因為播放完成后回調肯定是在C層, C層在播放完成后會發現此時執行播放命令的CellPresenter無法通知Cell停止動畫, 即事件的觸發不是一次性的. 另外, 在播放完成后, C層遍歷到下一個待播放CellPresenterX調用播放接口時, CellPresenterX因為并不知道它對應的Cell是誰, 當然也就無法通知Cell開始動畫, 即事件的發起者并不一定是V層.

針對這些非一次性或者其他層發起事件, 處理方法其實很簡單, 在CellPresenter加個Block屬性就行了, 因為是屬性, Block可以多次回調, 另外Block還可以捕獲Cell, 所以也不擔心找不到對應的Cell. 大概這樣:

@interface VoiceCellPresenter : NSObject

@property?(copy,?nonatomic)?void(^didUpdatePlayStateHandler)(NSUInteger);

-?(NSURL?*)playURL;

@end


@implementation?VoiceCell

-?(void)setPresenter:(VoiceCellPresenter?*)presenter?{

_presenter?=?presenter;

if(!presenter.didUpdatePlayStateHandler)?{

__weaktypeof(self)?weakSelf?=?self;

[presenter?setDidUpdatePlayStateHandler:^(NSUInteger?playState)?{

switch(playState)?{

caseBuffering:?weakSelf.playButton...break;

casePlaying:?weakSelf.playButton...break;

casePaused:?weakSelf.playButton...break;

}

}];

}

}

播放的時候, VC只需要保持一下CellPresenter, 然后傳入相應的playState調用didUpdatePlayStateHandler就可以更新Cell的狀態了.

當然, 如果是Protocol的方式進行的VP綁定, 那么做這些事情就很平常了, 就不寫了.

MVP大概就是這個樣子了, 相對于MVC, 它其實只做了一件事情, 即分割業務展示和業務邏輯. 展示和邏輯分開后, 只要我們能保證V在收到P的數據更新通知后能正常刷新頁面, 那么整個業務就沒有問題. 因為V收到的通知其實都是來自于P層的數據獲取/更新操作, 所以我們只要保證P層的這些操作都是正常的就可以了. 即我們只用測試P層的邏輯, 不必關心V層的情況.

MVVM

MVP其實已經是一個很好的架構, 幾乎解決了所有已知的問題, 那么為什么還會有MVVM呢?

仍然是舉例說明, 假設現在有一個Cell, 點擊Cell上面的關注按鈕可以是加關注, 也可以是取消關注, 在取消關注時, SceneA要求先彈窗詢問, 而SceneB則不做彈窗, 那么此時的取消關注操作就和業務場景強關聯, 所以這個接口不可能是V層直接調用, 會上升到Scene層.具體到代碼中, 大概這個樣子:

@interface UserCellPresenter : NSObject

@property?(copy,?nonatomic)?void(^followStateHander)(BOOL?isFollowing);

@property?(assign,?nonatomic)?BOOL?isFollowing;

-?(void)follow;

@end


@implementation UserCellPresenter

-?(void)follow?{

if(!self.isFollowing)?{//未關注?去關注

//????????follow?user

}else{//已關注?則取消關注

self.followStateHander???self.followStateHander(YES)?:?nil;//先通知Cell顯示follow狀態

[[FollowAPIManagernew]?unfollowWithUserId:self.userId?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{

self.followStateHander???self.followStateHander(NO)?:?nil;//follow失敗?狀態回退

}?eles?{

self.isFollowing?=?YES;

}

//...略

}];

}

}

@end

@implementation UserCell

-?(void)setPresenter:(UserCellPresenter?*)presenter?{

_presenter?=?presenter;

if(!_presenter.followStateHander)?{

__weaktypeof(self)?weakSelf?=?self;

[_presenter?setFollowStateHander:^(BOOL?isFollowing)?{

[weakSelf.followStateButton?setImage:isFollowing???:?...];

}];

}

}

-?(void)onClickFollowButton:(UIButton?*)button?{//將關注按鈕點擊事件上傳

[self?routeEvent:@"followEvent"userInfo:@{@"presenter":?self.presenter}];

}

@end

@implementation FollowListViewController

//攔截點擊事件?判斷后確認是否執行事件

-?(void)routeEvent:(NSString?*)eventName?userInfo:(NSDictionary?*)userInfo?{

if([eventName?isEqualToString:@"followEvent"])?{

UserCellPresenter?*presenter?=?userInfo[@"presenter"];

[self?showAlertWithTitle:@"提示"message:@"確認取消對他的關注嗎?"cancelHandler:nil?confirmHandler:?^{

[presenter?follow];

}];

}}@end

@implementation UIResponder (Router)


//沿著響應者鏈將事件上傳?事件最終被攔截處理?或者?無人處理直接丟棄

-?(void)routeEvent:(NSString?*)eventName?userInfo:(NSDictionary?*)userInfo?{

[self.nextResponder?routeEvent:eventName?userInfo:userInfo];

}

@end

@protocol UserCellPresenterCallBack (NSObject)(識別問題此處圓括號替換尖括號)

-?(void)userCellPresenterDidUpdateFollowState:(BOOL)isFollowing;

@end

@interface?UserCellPresenter?:?NSObject

@property?(weak,?nonatomic)?id(UserCellPresenterCallBack)?view(識別問題此處圓括號替換尖括號);

@property?(assign,?nonatomic)?BOOL?isFollowing;

-?(void)follow;

@end

@implementation UserCellPresenter

-?(void)follow?{

if(!self.isFollowing)?{//未關注?去關注

//????????follow?user

}else{//已關注?則取消關注

BOOL?isResponse?=?[self.view?respondsToSelector:@selector(userCellPresenterDidUpdateFollowState)];

isResponse???[self.view?userCellPresenterDidUpdateFollowState:YES]?:?nil;

[[FollowAPIManagernew]?unfollowWithUserId:self.userId?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{

isResponse???[self.view?userCellPresenterDidUpdateFollowState:NO]?:?nil;

}?eles?{

self.isFollowing?=?YES;

}

//...略

}];

}

}

@end

@implementation UserCell

-?(void)setPresenter:(UserCellPresenter?*)presenter?{

_presenter?=?presenter;

_presenter.view?=?self;

}

#pragma?mark?-?UserCellPresenterCallBack

-?(void)userCellPresenterDidUpdateFollowState:(BOOL)isFollowing?{

[self.followStateButton?setImage:isFollowing???:?...];

}

除去Route和VC中Alert之類的代碼, 可以發現無論是Block方式還是Protocol方式因為需要對頁面展示和業務邏輯進行隔離, 代碼上饒了一小圈, 無形中增添了不少的代碼量, 這里僅僅只是一個事件就這樣, 如果是多個呢? 那寫起來真是蠻傷的...

仔細看一下上面的代碼就會發現, 如果我們繼續添加事件, 那么大部分的代碼都是在做一件事情: P層將數據更新通知到V層. Block方式會在P層添加很多屬性, 在V層添加很多設置Block邏輯. 而Protocol方式雖然P層只添加了一個屬性, 但是Protocol里面的方法卻會一直增加, 對應的V層也就需要增加的方法實現.

問題既然找到了, 那就試著去解決一下吧, OC中能夠實現兩個對象間的低耦合通信, 除了Block和Protocol, 一般都會想到KVO. 我們看看KVO在上面的例子有何表現:

@interface UserCellViewModel : NSObject

@property?(assign,?nonatomic)?BOOL?isFollowing;

-?(void)follow;

@end

@implementation UserCellViewModel

-?(void)follow?{

if(!self.isFollowing)?{//未關注?去關注

//????????follow?user

}else{//已關注?則取消關注

self.isFollowing?=?YES;//先通知Cell顯示follow狀態

[[FollowAPIManagernew]?unfollowWithUserId:self.userId?completionHandler:^(NSError?*error,?id?result)?{

if(error)?{?self.isFollowing?=?NO;?}//follow失敗?狀態回退

//...略

}];

}

}

@end

@implementation UserCell

-?(void)awakeFromNib?{

@weakify(self);

[RACObserve(self,?viewModel.isFollowing)?subscribeNext:^(NSNumber?*isFollowing)?{

@strongify(self);

[self.followStateButton?setImage:[isFollowing?boolValue]???:?...];

};

}

代碼大概少了一半左右, 另外, 邏輯讀起來也清晰多了, Cell觀察綁定的ViewModel的isFollowing狀態, 并在狀態改變時, 更新自己的展示.

三種數據通知方式簡單一比對, 相信哪種方式對程序員更加友好, 大家都心里有數, 就不做贅述了.

現在大概一提到MVVM就會想到RAC, 但這兩者其實并沒有什么聯系, 對于MVVM而言RAC只是提供了優雅安全的數據綁定方式, 如果不想學RAC, 自己搞個KVOHelper之類的東西也是可以的. 另外 ,RAC的魅力其實在于函數式響應式編程, 我們不應該僅僅將它局限于MVVM的應用, 日常的開發中也應該多使用使用的.

關于MVVM, 我想說的就是這么多了, 因為MVVM其實只是MVP的綁定進化體, 除去數據綁定方式, 其他的和MVP如出一轍, 只是可能呈現方式是Command/Signal而不是CompletionHandler之類的, 故不做贅述.

最后做個簡單的總結吧:

1.MVC作為老牌架構, 優點在于將業務場景按展示數據類型劃分出多個模塊, 每個模塊中的C層負責業務邏輯和業務展示, 而M和V應該是互相隔離的以做重用, 另外每個模塊處理得當也可以作為重用單元. 拆分在于解耦, 順便做了減負, 隔離在于重用, 提升開發效率. 缺點是沒有區分業務邏輯和業務展示, 對單元測試不友好.

2.MVP作為MVC的進階版, 提出區分業務邏輯和業務展示, 將所有的業務邏輯轉移到P層, V層接受P層的數據更新通知進行頁面展示. 優點在于良好的分層帶來了友好的單元測試, 缺點在于分層會讓代碼邏輯優點繞, 同時也帶來了大量的代碼工作, 對程序員不夠友好.

3.MVVM作為集大成者, 通過數據綁定做數據更新, 減少了大量的代碼工作, 同時優化了代碼邏輯, 只是學習成本有點高, 對新手不夠友好.

4.MVP和MVVM因為分層所以會建立MVC兩倍以上的文件類, 需要良好的代碼管理方式.

5.在MVP和MVVM中, V和P或者VM之間理論上是多對多的關系, 不同的布局在相同的邏輯下只需要替換V層, 而相同的布局不同的邏輯只需要替換P或者VM層. 但實際開發中P或者VM往往因為耦合了V層的展示邏輯退化成了一對一關系(比如SceneA中需要顯示"xxx+Name", VM就將Name格式化為"xxx + Name". 某一天SceneB也用到這個模塊, 所有的點擊事件和頁面展示都一樣, 只是Name展示為"yyy + Name", 此時的VM因為耦合SceneA的展示邏輯, 就顯得比較尷尬), 針對此類情況, 通常有兩種辦法, 一種是在VM層加狀態進而判斷輸出狀態, 一種是在VM層外再加一層FormatHelper. 前者可能因為狀態過多顯得代碼難看, 后者雖然比較優雅且拓展性高, 但是過多的分層在數據還原時就略顯笨拙, 大家應該按需選擇.

這里隨便瞎扯一句, 有些文章上來就說MVVM是為了解決C層臃腫, MVC難以測試的問題, 其實并不是這樣的. 按照架構演進順序來看, C層臃腫大部分是沒有拆分好MVC模塊, 好好拆分就行了, 用不著MVVM. 而MVC難以測試也可以用MVP來解決, 只是MVP也并非完美, 在VP之間的數據交互太繁瑣, 所以才引出了MVVM. 當MVVM這個完全體出現以后, 我們從結果看起源, 發現它做了好多事情, 其實并不是, 它的前輩們付出的努力也并不少!

架構那么多, 日常開發中到底該如何選擇?

不管是MVC, MVP, MVVM還是MVXXX, 最終的目的在于服務于人, 我們注重架構, 注重分層都是為了開發效率, 說到底還是為了開心. 所以, 在實際開發中不應該拘泥于某一種架構, 根據實際項目出發, 一般普通的MVC就能應對大部分的開發需求, 至于MVP和MVVM, 可以嘗試, 但不要強制.

總之, 希望大家能做到: 設計時, 心中有數. 擼碼時, 開心就好.

本文demo地址

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

推薦閱讀更多精彩內容