三国杀武将|手游三国杀边锋版

首頁 >iOS開發

說說MVVM

2019-05-23 10:46 編輯: 米米狗 分類:iOS開發 來源:一線搬磚工人

寫在前面

關于軟件架構模式(確切的說是一種軟件編碼規范或者軟件開發模式),這幾年罵戰不斷。爭論的焦點主要是在MVC、MVVM、MVP哪種架構最好,哪種架構才是最牛逼的、擴展性更強的、可維護性更高的。筆者不才,在實際項目中很少用過MVP架構,對于MVP的掌握也是只停留在寫寫Demo階段。本篇文章主要著重介紹下MVVM架構在真實項目當中的應用,以及拋開RAC,我們如何自己動手寫一個View和ViewModel之間的綁定框架。

MVVM掃盲

MVVM(Model–View–Viewmodel)是一種軟件架構模式。

MVVM有助于將圖形用戶界面的開發與業務邏輯或后端邏輯(數據模型)的開發分離開來,這是通過置標語言或GUI代碼實現的。MVVM的視圖模型是一個值轉換器, 這意味著視圖模型負責從模型中暴露(轉換)數據對象,以便輕松管理和呈現對象。在這方面,視圖模型比視圖做得更多,并且處理大部分視圖的顯示邏輯。 視圖模型可以實現中介者模式,組織對視圖所支持的用例集的后端邏輯的訪問。

MVVM是馬丁·福勒的PM(Presentation Model)設計模式的變體。 MVVM以相同的方式抽象出視圖的狀態和行為,但PM以不依賴于特定用戶界面平臺的方式抽象出視圖(創建了視圖模型)。

MVVM和PM都來自MVC模式。

MVVM由微軟架構師Ken Cooper和Ted Peters開發,通過利用WPF(微軟.NET圖形系統)和Silverlight(WPF的互聯網應用派生品)的特性來簡化用戶界面的事件驅動程序設計。 微軟的WPF和Silverlight架構師之一John Gossman于2005年在他的博客上發表了MVVM。

MVVM也被稱為model-view-binder,特別是在不涉及.NET平臺的實現中。ZK(Java寫的一個Web應用框架)和KnockoutJS(一個JavaScript庫)使用model-view-binder。

以上內容均來自維基百科。MVVM wikipedia

簡單的講,MVVM是MVC的改進版。我們都知道MVC軟件架構模式是蘋果推薦的開發模式。

MVC中的M就是單純的從網絡獲取回來的數據模型,V指的我們的視圖界面,而C就是我們的ViewController。

在其中,ViewController負責View和Model之間調度,View發生交互事件會通過target-action或者delegate方式回調給ViewController,與此同時ViewController還要承擔把Model通過KVO、Notification方式傳來的數據傳輸給View用于展示的責任。隨著業務越來越復雜,視圖交互越復雜,導致Controller越來越臃腫,負重前行。臟活累活都它干了,到頭來還一點不討好。福報修多了的結果就是,不行了就重構你,重構不了就換掉你。

來一張斯坦福老頭經典的MVC架構圖。

image.png

所以為了解決這個問題,MVVM就閃亮登場了。他把View和Contrller都放在了View層(相當于把Controller一部分邏輯抽離了出來),Model層依然是服務端返回的數據模型。而ViewModel充當了一個UI適配器的角色,也就是說View中每個UI元素都應該在ViewModel找到與之對應的屬性。除此之外,從Controller抽離出來的與UI有關的邏輯都放在了ViewModel中,這樣就減輕了Controller的負擔。

我簡單的畫了下MVVM的架構圖。

image.png

mvvm-arch.png

從以上的架構圖中,我們可以很清晰的梳理出各自的分工。

View層:視圖展示。包含UIView以及UIViewController,View層是可以持有ViewModel的。

ViewModel層:視圖適配器。暴露屬性與View元素顯示內容或者元素狀態一一對應。一般情況下ViewModel暴露的屬性建議是readOnly的,至于為什么,我們在實戰中會去解釋。還有一點,ViewModel層是可以持有Model的。

Model層:數據模型與持久化抽象模型。數據模型很好理解,就是從服務器拉回來的JSON數據。而持久化抽象模型暫時放在Model層,是因為MVVM誕生之初就沒有對這塊進行很細致的描述。按照經驗,我們通常把數據庫、文件操作封裝成Model,并對外提供操作接口。(有些公司把數據存取操作單拎出來一層,稱之為DataAdapter層,所以在業內會有很多MVVM的變種,但其本質上都是MVVM)。

Binder:MVVM的靈魂。可惜在MVVM這幾個英文單詞中并沒有它的一席之地,它的最主要作用是在View和ViewModel之間做了雙向數據綁定。如果MVVM沒有Binder,那么它與MVC的差異不是很大。

我們發現,正是因為View、ViewModel以及Model間的清晰的持有關系,所以在三個模塊間的數據流轉有了很好的控制。

這里給大家推薦一篇博文猿題庫iOS客戶端架構設計,其架構圖如下。

image.png

猿題庫Arch.png

猿題庫的架構本質上不是MVC也不是MVVM,它是兩種架構演進的一種架構模式。博文中對于MVC和MVVM的優缺點做了簡單的介紹。

  • MVC缺點:Massive View Controller,也就是胖VC。

  • MVVM缺點:1.學習成本高。2.DEBUG困難。

但博文中關于MVVM的闡述有兩處筆者不太贊同。

  • MVVM絕不等于RAC,所以MVVM并不存在DEBUG難的問題。

  • MVVM正是因為跟RAC不對等,所以博文中“MVVM一個首要的缺點是,MVVM的學習成本和開發成本都很高”這句話也是不成立的。

MVVM架構本身并不復雜,而且不用RAC我們依然可以通過KVO、類KVO的方式來幫我們實現View和ViewModel綁定器功能。

關于猿題庫iOS客戶端架構設計是否合理,因為筆者不了解其具體業務,所以不能妄下結論。但是有一點可以肯定的是,MVVM ≠ RAC。

一年一度的QA環節來了。

Q:View和ViewModel之間是否一定要解耦?

A:View持有ViewModel,ViewModel不能持有View(即ViewModel不能依賴UIKit中任何東西)。說明白了吧?  解耦是有一定成本的,不管是通過Category或者中間件,消息鏈條都會無形之中變長,會有一定的DEBUG成本。

Q:為什么ViewModel不能持有View?

A:這個很好理解啊兄dei,主要有兩方面原因:1.ViewModel可測性,即單元測試方便進行。2.團隊人員可分離開發(View和ViewModel開發可以是兩個人同時進行)。

MVVM結合RAC

ReativeCocoa相信大家并不陌生,這個函數響應式框架在Github中已經有將近2w star 。RAC是個非常優秀的框架,它可以獨立于MVVM而存在。如果只是把它理解成MVVM中View和ViewModel Binder角色的話,那就有點大材小用了。本文不會對RAC進行展開分析,感興趣的可以自行實踐一下。

RAC特點:

  • 語法怪異,雜交種。(函數式+響應式編程組合)

  • 萬物皆可盤。(事件信號RACSignal貫穿整個框架)

  • 把離散的函數調用攛成一坨。(個人感覺跟Promise很像)

總結:RAC是一種編程思維的改變,所以其缺點很明顯,學習成本很大!!!

具體RAC的使用,可以參考官方文檔,自行實踐一下,這里不再展開。

MVVM結合非RAC(IQDataBinding

通過MVVM掃盲部分,我們了解到,Binder在MVVM中扮演了View和ViewModel數據通信者的角色。

了解過Android開發的同學都知道,Java有個好東西,那就是注解(Annotation)。在開發Android App的時候,可以在XML中通過注解的方式標記View和ViewModel的綁定關系。編譯器在編譯過程中,會自動生成XML和ViewModel的綁定類(Binder)。

注解功能很強大,但是不幸的是,我們iOS(Objective-C)沒有!!!Swift有沒有注解筆者不太清楚,有知道的童鞋可以告訴我一下。

接下來我們將一步步實現一個View和ViewModel雙向綁定的框架。

方案一:“躺爽法”

名次解釋:所謂“躺爽法”(實在想不出用什么詞描述這種最基礎的方法了)和KVO,是相對于ViewModel >>> View而言的。

1.ViewModel >>> View:View不需要關心ViewModel屬性的改變,View只需要提供更新視圖的接口即可,ViewModel屬性改變之后調用View提供的API更新視圖。所以View這里沒有做過多的事情,一切都是被動觸發,所以我稱作是“躺爽法”。

2.View >>> ViewModel:用戶操作視圖,比如一個開關按鈕,這時候要同步給ViewModel。我們知道View是可以持有ViewModel的,所以在View中我們可以直接拿到ViewModel指針,進而通過ViewModel暴露的方法而更新值。

高能預警:這種最基礎的方法,實際上是MVC!!!他本身沒有解決“Massive View Controller”問題。也就是說為了ViewModel中不依賴于View,必須通過Controller中轉,依然會有一堆膠水代碼。所以這種解決方案并不是MVVM!!!不是故意給大家挖坑,只是意在提醒大家,閱讀文章的時候要舉一反三,更不要被一些臟亂差的文章混淆視聽。

方案一:KVO

1.ViewModel >>> View:ViewModel屬性改變之后,通知View進行視圖布局。這種最熟悉不過,通過KVO即可實現。

2.View >>> ViewModel:用戶操作視圖,通過ViewModel暴露的更新方法而更新值(設置屬性值時要避開觸發KVO監聽,否則會出現死循環)。

Talk is cheap,show me the code!  

我們以大家最熟悉的Cell舉例子。

ViewModel

//
//  IQMVVMDemoViewModel.h
//
#import NS_ASSUME_NONNULL_BEGIN

@interface IQMVVMDemoViewModel : NSObject

@property (nonatomic, copy, readonly) NSString *userName;
@property (nonatomic, copy, readonly) NSString *userPwd;

+ (IQMVVMDemoViewModel *)demoViewWithName:(NSString *)userName withPwd:(NSString *)userPwd;
- (void)updateViewModelWithName:(NSString *)userName withPwd:(NSString *)userPwd;

@end

NS_ASSUME_NONNULL_END
//
//  IQMVVMDemoViewModel.m
//
  
#import "IQMVVMDemoViewModel.h"

@interface IQMVVMDemoViewModel ()

@property (nonatomic, copy, readwrite) NSString *userName;
@property (nonatomic, copy, readwrite) NSString *userPwd;

@end

@implementation IQMVVMDemoViewModel

+ (IQMVVMDemoViewModel *)demoViewWithName:(NSString *)userName withPwd:(NSString *)userPwd {
    IQMVVMDemoViewModel *viewModel = [[IQMVVMDemoViewModel alloc]init];
    viewModel.userName  = userName;
    viewModel.userPwd   = userPwd;
    return viewModel;
}

- (void)updateViewModelWithName:(NSString *)userName withPwd:(NSString *)userPwd {
    _userName   = userName;
    _userPwd    = userPwd;
}

@end

View

//
//  IQMVVMDemoView.h
//  
#import NS_ASSUME_NONNULL_BEGIN

@class IQMVVMDemoViewModel;

@interface IQMVVMDemoView : UITableViewCell

- (void)updateViewWithViewModel:(IQMVVMDemoViewModel *)viewModel;

@end

NS_ASSUME_NONNULL_END
//
//  IQMVVMDemoView.m
//
  
#import "IQMVVMDemoView.h"
#import "IQMVVMDemoViewModel.h"

@interface IQMVVMDemoView ()@property (nonatomic, strong) UITextField *userNameField;
@property (nonatomic, strong) UITextField *userPwdField;
@property (nonatomic, strong) IQMVVMDemoViewModel *viewModel;

@end

@implementation IQMVVMDemoView

#pragma mark--Life Cycle--
- (void)dealloc {
    [self.viewModel removeObserver:self forKeyPath:@"userName"];
    [self.viewModel removeObserver:self forKeyPath:@"userPwd"];
}

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
        [self setupSubviews];
    }
    return self;
}
#pragma Public & Private Methods--
- (void)setupSubviews {
    [self.contentView addSubview:self.userNameField];
    [self.contentView addSubview:self.userPwdField];
    /*
     這里做布局,不寫了啊
     */
}

- (void)updateViewWithViewModel:(IQMVVMDemoViewModel *)viewModel {
    self.viewModel = viewModel;
    [self.viewModel addObserver:self forKeyPath:@"userName" options:NSKeyValueObservingOptionNew context:NULL];
    [self.viewModel addObserver:self forKeyPath:@"userPwd" options:NSKeyValueObservingOptionNew context:NULL];
}
#pragma mark--Delegates & KVO--
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if ([keyPath isEqualToString:@"userName"]) {
        self.userNameField.text = change[NSKeyValueChangeNewKey];
    } else if([keyPath isEqualToString:@"userPwd"]) {
        self.userPwdField.text = change[NSKeyValueChangeNewKey];
    }
}

- (void)textFieldDidEndEditing:(UITextField *)textField {
    /*更新ViewModel*/
    if (textField == self.userNameField) {
        self.userNameField.text = textField.text;
    } else {
        self.userPwdField.text = textField.text;
    }
    [self.viewModel updateViewModelWithName:self.userNameField.text withPwd:self.userPwdField.text];
}

#pragma mark--Getters & Setters--
- (UITextField *)userNameField {
    if (!_userNameField) {
        _userNameField = [[UITextField alloc]init];
        _userNameField.delegate = self;
    }
    return _userNameField;
}

- (UITextField *)userPwdField {
    if (!_userPwdField) {
        _userPwdField = [[UITextField alloc]init];
        _userPwdField.delegate = self;
    }
    return _userPwdField;
}

@end

至此,我們大致把View和ViewModel之間數據通信方式給理清了。但是大家都知道KVO存在各種問題,而且每次監聽一個屬性都要寫大量的代碼(注冊、移除、收到監聽的處理)。所以方案一存在以下問題:

  • 直接使用KVO方式,每次都要寫大量的注冊、移除等代碼,沒有做到自動移除。

  • 如果沒有移除監聽可能直接導致Crash,使用姿勢不方便。

方案二:類KVO(IQDataBinding)

名詞解釋:之所以稱之為類KVO,是因為方案二本質上是通過KVO來實現的。不過IQDataBinding實現了自動移除,且支持函數式、鏈式調用,使用姿勢比較優雅。

空說無憑,我們來看看IQDataBinding如何使用

Controller

/*引入NSObject+IQDataBinding頭文件*/
- (void)configData {
    self.contentModel = [[ContentModel alloc]init];
    self.contentModel.title = @"lobster";
    self.contentModel.content = @"123456";
    
    /*View和ViewModel之間綁定*/
    [self.contentView bindModel:self.contentModel];
    
}

View

/*ViewModel >>> View*/
- (void)setUpSubviews {
    
    [self addSubview:self.loginTextField];
    [self addSubview:self.pwdTextField];
    
    self.loginTextField.frame = CGRectMake(0, 0, self.bounds.size.width, 30);
    self.pwdTextField.frame = CGRectMake(0, 40, self.bounds.size.width, 30);
    
    /*綁定ViewModel中title和content屬性,發生改變自動觸發View更新操作*/
    __weak typeof(self)weakSelf = self;
    self.bind(@"title",^(id value){
        weakSelf.loginTextField.text = value;
    }).bind(@"content",^(id value){
        weakSelf.pwdTextField.text = value;
    });
    
}


/*View >>> ViewModel*/
- (BOOL)textFieldShouldReturn:(UITextField *)textField {
    [textField resignFirstResponder];
    if (textField.text) {
        /*函數式調用*/
        self.update(@"content",textField.text).update(@"title",@"lobster");
    }
    return YES;
}

IQDataBinding踩坑記:

  • View更新ViewModel屬性時,如何讓一個函數支持傳輸不同的參數類型?

  • View更新ViewModel時,如何避免觸發KVO而導致死循環?

  • 如何自動移除KVO?

View更新ViewModel屬性時,如何讓一個函數支持傳輸不同的參數類型?

筆者借鑒了Masonry框架的解決方案,通過宏定義+不定參數解決了傳輸不同參數類型的問題。感興趣的可以了解下Masonry中_MASBoxValue這個函數。

View更新ViewModel時,如何避免觸發KVO而導致死循環?

很顯然,通過setValue:forKey:函數會觸發KVO回調,所以我的解決方案是獲取到IVar,直接設置實例變量的值。但是object_setIvar(id _Nullable obj, Ivar _Nonnull ivar, id _Nullable value) 函數,只接收id類型的值。Stack Overflow查詢之后,發現可以通過函數類型強轉的方式來解決。

如何自動移除KVO?

這個問題就比較簡單了,為了監控View的dealloc函數調用時機,我們可以通過Hook的方式,但是Hook不太推薦。尤其使用類似于Aspects(通過消息轉發來實現,代價很高)進行Hook時,對于那種一秒鐘調用超過1000次的業務場景會嚴重影響性能。所以我采用的方案是,通過給View添加一個關聯對象來解決。因為我們知道對象釋放時會先釋放成員變量,然后再釋放關聯對象,所以我們可以在關聯對象的dealloc方法里對觀察者進行自動移除。

 /*給view添加一個關聯對象IQWatchDog,IQWatchDog職責如下
     1.存儲@{綁定的Key,回調Block}對應關系。
     2.根據@{綁定的Key,回調Block}中的Key,進行KVO監聽。
     3.監聽view Dealloc事件,自動移除KVO監聽。
     */
    IQWatchDog *viewAssociatedModel = objc_getAssociatedObject(self, &kViewAssociatedModelKey);
    if (!viewAssociatedModel) {
        viewAssociatedModel = [[IQWatchDog alloc]init];
        viewAssociatedModel.target = model;
        objc_setAssociatedObject(self, &kViewAssociatedModelKey, viewAssociatedModel, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
@interface IQWatchDog : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, strong) NSMutableDictionary *keyPathsAndCallBacks;
@end
@implementation IQWatchDog
- (void)dealloc {
    [self.keyPathsAndCallBacks enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
        [self.target removeObserver:self forKeyPath:key];
    }];
}
- (void)observeKeyPath:(NSString *)keyPath callBack:(observerCallBack)callBack {
    NSAssert(keyPath.length, @"keyPath不合法");
    /*加載默認值*/
    id value = [self.target valueForKeyPath:keyPath];
    if (value) {
        callBack(value);
    }
    /*添加觀察者*/
    [self.keyPathsAndCallBacks setObject:callBack forKey:keyPath];
    [self.target addObserver:self forKeyPath:keyPath options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    observerCallBack callBack = self.keyPathsAndCallBacks[keyPath];
    if (callBack) {
        callBack(change[NSKeyValueChangeNewKey]);
    }
}
- (void)removeAllObservers {
    [self.keyPathsAndCallBacks enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
        [self.target removeObserver:self forKeyPath:key];
    }];
}
- (NSMutableDictionary *)keyPathsAndCallBacks {
    if (!_keyPathsAndCallBacks) {
        _keyPathsAndCallBacks = [NSMutableDictionary dictionary];
    }
    return _keyPathsAndCallBacks;
}
@end

再回憶下對象的釋放過程

/*對象在釋放時,最終都會走到這個函數*/
void *objc_destructInstance(id obj) 
{
    if (obj) {
        // Read all of the flags at once for performance.
        bool cxx = obj->hasCxxDtor();
        bool assoc = obj->hasAssociatedObjects();
        
        // This order is important.
        if (cxx) object_cxxDestruct(obj);/*如果有成員變量,則先釋放成員變量*/
        if (assoc) _object_remove_assocations(obj);/*如果有關聯對象,則釋放關聯對象*/
        obj->clearDeallocating();/*清除SideTable中weak引用表,并把指向該對象的指針置為nil*/
    }
    
    return obj;
}

GitHub地址:IQDataBinding,一個View和ViewModel雙向綁定的框架

除此之外,再推薦一個比較好用的框架KVOController  Simple, modern, thread-safe key-value observing for iOS and OS X.

對于開發者的建議

  • 不管是新、老團隊,還是新、老項目,我都強烈建議大家嘗試MVVM架構,再次強調的是:MVVM ≠ RAC。

  • 對于團隊成員眾多,項目遺留問題多的團隊來說,我建議大家嘗試MVVM+KVO+數據存取放到Model層的架構方案。

  • 不可否認的是,RAC是個特別優秀的框架,但是落地比較難,尤其在中國。

  • 不管哪種架構方式,執行落地都不是一件容易的事情。對于MVVM,我建議采用分步走的策略,即新功能用MVVM開發,老舊代碼分步重構。而且要引入一些手段對代碼進行靜態檢查,然后一步步把MVVM落到實處。再推薦點干貨使用 OCLint 自定義 MVVM 規則

擼主已參與掘金翻譯計劃,架構和組件化系列文章的更新節奏會慢下來。后續會不斷把一些優秀的文章翻譯出來并分享給大家,感謝關注。

文章首發GitHub https://github.com/Lobster-King/AppArticles

作者:一線搬磚工人

鏈接:https://juejin.im/post/5ce5687de51d45109725fdd1 

搜索CocoaChina微信公眾號:CocoaChina
微信掃一掃
訂閱每日移動開發及APP推廣熱點資訊
公眾號:
CocoaChina
我要投稿   收藏文章
上一篇:iOS——隱形水印的實現和『顏色加深』算法
下一篇:iOS組件化開發架構設計思考(初版)

相關資訊

我來說兩句
發表評論
您還沒有登錄!請登錄注冊
所有評論(0

綜合評論

相關帖子

sina weixin mail 回到頂部
三国杀武将