一個常規(guī)軟件或者 APP 一般都是服務于某種商業(yè)或者非商業(yè)述求,我們平常稱為“業(yè)務需求”。隨著業(yè)務需求的擴張、一個軟件會變得越來越龐大,越來越復雜。所以一般都會有一套完整的架構設計、研發(fā)流程以及質量管理體系來保證整個研發(fā)過程。關于“架構設計”,這是一個很大的話題,伴隨著我們的業(yè)務需求,它會涉及到方方面面,我們今天來談一談其中的一個基礎環(huán)節(jié)——MVVM模式。
經典的 MVC 模式
MVC 是最常見的客戶端軟件架構之一,它歷史悠久,簡單好用,易于理解,而且目前常見的 iOS 和 Android 開發(fā),SDK 和與其搭配的 IDE 工具都是默認以 MVC 的方式來使用的。但是我個人更喜歡 MVVM 模式,也一直堅持使用 MVVM 模式來工作了很多年。
最常見的客戶端架構有三種:
- MVC: Model-View-Controller
- MVP: Model-View-Presenter
- MVVM: Model-View-ViewModel
在 MVC 里面,Model 是數據模型;View 是視圖或者說就是我們的軟件界面需要去展示的東西;Controller 是用來控制Model的讀取、存儲,以及如何在 View上 展示數據,更新數據的邏輯控制器。
- 對應到 iOS 開發(fā)中,View 約等于 Storyboard,Controller 就是 ViewController,Model 的話是需要我們自己去創(chuàng)建的一些實體(Entity)Class。
- 對應到 Android 開發(fā)中,View 約等于 Layout 中的 xml ,Controller 就是 Activity,Model 同上。
- 對應到 React-Native 開發(fā)中,View 約等于 Component 中的 render 函數部分,Controller 就是整個 Component,Model 同上。
這里為什么要說 View 約等于 Storyboard 或者 Layout 呢?因為 Storyboard 和 Layout 都被設計為一種界面(View)的描述語言,它描述了整個 View 應該長成什么樣子,應該具有哪些控件。程序啟動時,系統會首先讀取 Storyboard 或者 Layout 文件,通過渲染引擎去把這種 View 的描述語言渲染成一個真正的 View,此時此刻的 View 才是 MVC 中的那個真正的 V。網絡上還有一些文章論調,說 Activity 并不能算真正的 Controller 等等,在這里我們就不糾結這些細節(jié)了。
VC 到 VM 的轉變
我們前面有提到一個常見的 Controller(下稱 VC )中會包含 Model 的讀取、存儲等功能,除此之外還會有一些控件的事件綁定和響應,甚至還有網絡請求等等。
一個 VC 在包含了大量的業(yè)務邏輯后,代碼就會變得特別的臃腫、不易閱讀和修改。于是后來就慢慢延伸出了 MVP 和 MVVM 模式。MVP 這里我們就跳過了,直接講 MVVM 模式。MVVM 模式顧名思義是由 Model、View 和 ViewModel(下稱 VM )組成,之前的 VC 變成了 VM。怎么來理解 VM 呢?
對于一個 Model ,比如我們要存儲和顯示一個人的信息,這個人具有姓名、年齡、性別這三個屬性。這個Model的偽代碼如下:
class Person {
String name;
int age;
int gender;
}
Model 的數據模型,和我們的業(yè)務需求或者說業(yè)務實體(Entity)是一一映射關系。而 ViewModel 顧名思義,就是一個 Model of View,它是一個 View 信息的存儲結構,ViewModel 和 View 上的信息是一一映射關系。
以一個軟件的登陸場景為例子,假設這個登錄界面上有如下邏輯:
- 用戶名輸入框
- 密碼輸入框
- 登陸按鈕,點擊登陸按鈕按鈕置灰,顯示 loading 框
- 登陸成功,頁面觸發(fā)跳轉
- 登陸失敗,loading 框消失,在界面上顯示錯誤信息
- 錯誤信息可以分為兩種情況: 1、密碼錯誤;2、沒有網絡
那么我們下面來定義這樣一個 ViewModel:
class LoginViewModel {
String userId;
String password;
bool isLoading;
bool isShowErrorMessage;
String errorMessage;
}
界面初始化
由于 LoginView 和 LoginViewModel 是映射關系,也稱為綁定關系,那么 LoginViewModel 是怎樣的數據,View 就按照怎樣的數據來進行顯示。界面第一次打開時,整個 LoginViewModel 的初始值為:
{
userId: '',
password: '',
isLoading: false,
isShowErrorMessage: false,
errorMessage: ''
}
那么此時界面為:
- 用戶名輸入框顯示為空白字符串
- 密碼輸入框顯示為空白字符串
- loading 框因為 isLoading = false,所以不顯示
- 錯誤信息框 因為 isShowErrorMessage = false,所以不顯示
- 錯誤信息框里面的文字為空白字符串
觸發(fā)登陸
接下來,用戶輸入用戶名和密碼,點擊登錄按鈕,在登陸事件里面觸發(fā)網絡通信邏輯,同時設定 isLoading = true,偽代碼如下:
function onLoginButtonClick() {
request(url, ...);
loginViewModel.isLoading = true;
}
此時 LoginViewModel 的值為:
{
userId: 'this is user id',
password: 'this is password',
isLoading: true,
isShowErrorMessage: false,
errorMessage: ''
}
隨著 isLoading 值的變化,因為 ViewModel 和 View 存在綁定關系,那么此時界面動態(tài)變化為:
- 用戶名輸入框顯示為剛剛輸入的字符串
- 密碼輸入框顯示為剛剛輸入的字符串
- 因為isLoading = true,所以顯示 loading 框
- 因為isLoading = true,登陸按鈕置灰,不可點擊
- 錯誤信息框 因為 isShowErrorMessage = false,所以不顯示
- 錯誤信息框里面的文字為空白字符串
登錄失敗
接下來我們假設登陸失敗,服務器返回密碼錯誤,那么此時在服務器邏輯的相應代碼里面我們去設定 isLoading = false,isShowErrorMessage = true,以及對應的errorMessage,偽代碼如下:
request(url, {
success: function() {
...
},
fail: function(err) {
if(err.code == 1000) { // 假設1000表示密碼錯誤
loginViewModel.isLoading = false;
loginViewModel.isShowErrorMessage = true;
loginViewModel.errorMessage = '密碼錯誤';
}
}
})
此時 LoginViewModel 的值為:
{
userId: 'this is user id',
password: 'this is password',
isLoading: false,
isShowErrorMessage: ture,
errorMessage: '密碼錯誤'
}
接下來依然是觸發(fā)界面變化,根據綁定關系重新更新顯示內容:
- 用戶名輸入框顯示為剛剛輸入的字符串
- 密碼輸入框顯示為剛剛輸入的字符串
- 因為isLoading = false,隱藏 loading 框
- 因為isLoading = false,登陸按鈕置為正常,可以點擊
- 因為 isShowErrorMessage = true,顯示錯誤信息框
- 錯誤信息框里面的文字為“密碼錯誤”
重新登錄
用戶修改密碼后,重新點擊登陸按鈕:
function onLoginButtonClick() {
loginViewModel.isLoading = true;
loginViewModel.isShowErrorMessage: false,
request(url, ...);
}
到這里相信大家都會知道了,錯誤提示框消失,顯示 loading 框,登陸按鈕置灰。
所以這就是 MVVM 模式的神奇之處,讓你不要去關心如何去顯示登錄框,如何去置灰一個按鈕,如何去顯示錯誤提示框又如何去隱藏它等等。當然,這里說的“不關心”并不代表不需要知道這些,完全不處理這些邏輯,只是在架構上給你一種更清晰,更簡單的原則,那就是:
“當任何外部事件發(fā)生時,永遠只操作 ViewModel 中的數據”
這里外部事件主要指界面點擊、文字輸入、網絡通信等等事件。因為綁定關系的存在,ViewModel 變成啥樣,界面就會自動變成啥樣。
單向綁定與雙向綁定
隨著 ViewModel 的變化,View 會自動變化,那么 View 變化后,ViewModel 會自動變化么?比如用戶在“用戶名輸入框”輸入文字后,LoginViewModel 中的 userId 會自動存儲輸入值么?然后用戶又刪掉部分輸入的內容, userId 再次立即變化么?
這里就需要引入一個新的概念了:單向綁定與雙向綁定。
- 所謂“單向綁定”就是 ViewModel 變化時,自動更新 View
- 所謂“雙向綁定”就是在單向綁定的基礎上 View 變化時,自動更新 ViewModel
我們把前面登陸按鈕點擊后這一過程的代碼再來梳理下,單向綁定模式下的偽代碼如下:
function onUserIdTextViewChanged(textView) {
loginViewModel.userId = textView.text;
}
function onPasswordTextViewChanged(textView) {
loginViewModel.password = textView.text;
}
function onLoginButtonClick() {
loginViewModel.isLoading = true;
loginViewModel.isShowErrorMessage: false,
login(loginViewModel.userId, loginViewModel.password);
}
大家可以看到,我們需要非常明確的在 TexView 變化事件里面去重新設定 LoginViewModel 中的值,而雙向綁定模式下,根據綁定關系,這一過程就隱藏性的自動完成了。既然“雙向綁定”那么智能、簡單,為什么還需要“單向綁定”呢?因為在真實的“業(yè)務需求”下,實際情況是非常復雜的,雖然 ViewModel 可以和 View 形成映射關系,但是它們之間的值卻不一定能直接劃等號。
比如在界面上要填寫性別,我們通常會提供一個下拉列表框,讓用戶選擇。這個選擇框里面至少有“未知”、“男”和“女”三種字符串值,而我們的 ViewModel 一般情況下并不直接存儲這些字符串。因為 ViewModel 中的數據很大一部分情況下是來自于數據庫、來自于服務器,而數據庫和服務器中幾乎是不可能直接把性別字符串存儲在數據模型中的。一般會建立一個 int 類型的字段,用 0 表示未知;用 1 表示男人;用 2 表示女人。
那么問題來了,在 ViewModel 中一個 gender 屬性類型為 int,值為 0 或者 1 或者 2 時,與其綁定的 View 怎么知道該如何來顯示為“未知”、“男”或者“女”呢?
所以“屬性轉換器”應運而生,在給 View 綁定 ViewModel 時,發(fā)現屬性值不匹配,那么就需要設定一個屬性轉換器。反之亦然,當性別選擇下拉列表框被用戶改變時,用戶選擇了“男”,在雙向綁定模式下,那么 View 依然需要在一個屬性轉換器的幫助下,把“男”轉換為 1,然后設定到 ViewModel 中。
上面只是最簡單的一種在綁定不匹配時涉及到屬性轉換的情況,但是真實的世界往往會更加的錯綜復雜,雙向綁定下的屬性轉換器隨著業(yè)務需求的迭代常常會變得越來越龐大,而且因為綁定關系觸發(fā) ViewModel 和 View 的動態(tài)變化過程是隱藏不可見的,也給調試帶來了極大的麻煩。
所以后來大家在長年累月的使用過程中,發(fā)現單向綁定可能會是更合適的一種做法。
把數據的請求與處理放在 ViewModel 中
針對前面的登陸代碼,我們再來做一次優(yōu)化,得到一個更加合理的版本:
class LoginViewModel {
String userId;
String password;
bool isLoading;
bool loginStatus;
String errorMessage;
Login() {
request(url, this.userId, this.password, {
success: function() {
...
},
failed: function() {
this.isLoading = false; //觸發(fā)綁定關系,隱藏登陸 loading 框
this.isShowErrorMessage = true; //觸發(fā)綁定關系,顯示錯誤提示框
this.errorMessage = '密碼錯誤'; //觸發(fā)綁定關系,設置錯誤提示文字內容
}
});
}
}
可以看到,我們把整個登陸過程放在了 LoginViewModel 中,那么登陸按鈕點擊后這一套響應過程也相應的有所調整:
function onUserIdTextViewChanged(textView) {
loginViewModel.userId = textView.text;
}
function onPasswordTextViewChanged(textView) {
loginViewModel.password = textView.text;
}
function onLoginButtonClick() {
loginViewModel.isLoading = true; //觸發(fā)綁定關系,顯示登陸 loading 框
loginViewModel.isShowErrorMessage: false; //觸發(fā)綁定關系,隱藏錯誤提示框
loginViewModel.login(); //開始登陸
}
大家看到沒有,上面這段代碼再也不處理任何的數據邏輯,不關心數據庫、不關心網絡調用,也完全不關心界面隨著數據和邏輯的變化應該如何去設置控件屬性狀態(tài)等等。讓我們再來復習一下 MVVM 的核心原則:
“當任何外部事件發(fā)生時,永遠只操作 ViewModel 中的數據”
上面這段代碼它不屬于 Model,不屬于 View,也不屬于 ViewModel,那它應該寫在哪里呢?
- iOS 下依然寫在 ViewController 中
- Android 下依然寫在 Activity 或者 Fragment 中
- ReactNative 下依然寫在 Component 中
- 微信小程序 下依然寫在 Page 中
所以 MVC 中的 C,其實一直都默默的存在著,只是變得弱化了,一定要完整的講的話,那就是 Model-View-Controler-ViewModel 模式。只有在理想的雙向綁定模式下,Controller 才會完全的消失。
上帝之手
講到這里,我們已經講解完了整個 MVVM 模式的核心原理和使用原則,不涉及任何平臺、任何語言?;蛘哒f只要你遵循以上的原則,寫出來的代碼都是 MVVM 模式的。但是還有一個最大的疑問,我們一直沒有去探討。那就是:
ViewModel 是如何與 View 形成的綁定關系,憑什么 ViewModel 中的數據變化了,View 就會動態(tài)變化?
我們之所以要把這個問題放在最后講是因為數據綁定和動態(tài)變化這都是由具體的 MVVM 平臺或者框架來實現的,跟 MVVM 模式沒有直接關系?;蛘哒f為了達成 MVVM 模式,不同的平臺、不同的框架提供了不同的實現版本來完成這一目標。當然,他們也都大同小異,核心原理差不多。
先來說說第二個疑問:“為什么會動態(tài)變化”?其實這里是有一只“上帝之手”在幫你實現這個過程,當“上帝”發(fā)現某個數據變化了,然后根據“映射”關系去幫你修改那個控件的屬性。但是,我們的登陸界面是否要顯示 loading 框,是否要顯示錯誤提示,這些都是我們的業(yè)務需求,“上帝”也并不知道你到底想要怎樣。所以,所謂的綁定或則映射關系是需要開發(fā)者明確來告訴“上帝”的。怎么告訴“上帝”呢?這就需要你或通過配置文件或通過代碼語句來設定。
我們來總結一下這兩個問題的答案:
- 開發(fā)者在代碼或者配置文件中設定 ViewModel 和 View 的映射關系
- “上帝之手”在整個軟件的運行過程中監(jiān)控 ViewModel,自動變化這一切
設定映射關系
目前在幾大手機 APP 開發(fā)平臺下,Android 的 MVVM 框架是做得最完善、最智能、最方便的。讓我們來看一看 Android 是怎么實現 MVVM 的。
假設我們要實現一個“時鐘”界面,這個界面可以顯示“當前時間”,如果用戶開啟了鬧鐘功能,且同時顯示“鬧鐘時間”。這個界面的簡化描述文件( Layout 文件)如下:
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View" />
<variable
name="mainVM"
type="com.company.app.viewModel.MainViewModel" />
</data>
<RelativeLayout>
<TextView
android:id="@+id/timeTextView"
android:text="@{mainVM.timeText}" />
<TextView
android:id="@+id/alarmTimeTextView"
android:visibility='@{mainVM.alarmEnable ? View.VISIBLE : View.GONE }'
android:text='@{mainVM.alarmTimeText}' >
</RelativeLayout>
</layout>
它具有以下幾部分內容:
- 該 Layout 具有哪些控件(布局屬性這里就隱去了),可以看到這里有兩個 TextView,也就是文本展示控件
- 該 Layout 和哪個 ViewModel 具有映射關系
- 該 Layout 中的哪些控件的哪些屬性和 ViewModel 中的哪些屬性是映射關系
<variable
name="mainVM"
type="com.company.app.viewModel.MainViewModel" />
這幾行指定了 Layout 和 MainViewModel 類具有映射關系,且 MainViewModel 在整個綁定關系中的對象名字叫做 mainVM。
<TextView
android:id="@+id/timeTextView"
android:text="@{mainVM.timeText}" />
這幾行指定了控件 timeTextView 的 text 屬性 和 mainVM 中的 timeText 屬性具有映射關系
<TextView
android:id="@+id/alarmTimeTextView"
android:visibility='@{mainVM.alarmEnable ? View.VISIBLE : View.GONE }'
android:text='@{mainVM.alarmTimeText}' >
這幾行中,和上面 timeTextView 控件的綁定關系類似,稍微不一樣的是第三行。這一行描述了 alarmTimeTextView 控件的 visibility 屬性和 mainVM 中的 alarmEnable 屬性綁定在一起。
visibility 屬性用來控制 alarmTimeTextView 控件是顯示還是隱藏。即 alarmEnable 為 true 則顯示,為 false 則隱藏。但是有一個小問題就是在 Android 平臺上,一個控件的 visibility 屬性并不是 bool 值,而是一個enum值,分別是 VISIBLE、INVISIBLE 和 GONE。在映射時,這里就需要一個屬性轉換器了。
Android 的屬性轉換器比較直接,就是讓你直接在綁定語句里面寫上一些簡單的邏輯代碼。如果你的屬性轉換器過于復雜,還允許把這些邏輯寫在一個正常的代碼文件中,然后通過 Layout 頂部的 Import 語句引入。
@{mainVM.alarmEnable ? View.VISIBLE : View.GONE }
這行代碼大家看字面意思肯定就能理解:如果 alarmEnable 為 true,則 visibility 的值 為 VISIBLE, 否則為 GONE。
生成上帝之手
Android 平臺的 MVVM 映射關系基本上就是這樣來指定,那它的上帝之手又在哪里呢?特別的簡單,Android 的 IDE 在編譯代碼的時候,會根據 Layout 文件中的綁定關系,自動生成一批 Java 代碼插入到你的源代碼工程中,這個過程你完全不用關心。你的 Layout 變化后,下一次編譯時也會自動更新這些隱藏代碼。
所以 Android 平臺下的 MVVM 模式是在框架代碼和 IDE 工具的輔助下,來實現了整個工作機制。想了解細節(jié)的朋友看一看這篇官方文檔。
https://developer.android.com/topic/libraries/data-binding/index.html
其他平臺的 MVVM 模式
而 iOS 平臺下想實現 MVVM 就沒有那么輕松了,有一個叫做 ReactiveCocoa 的第三方庫實現了大部分 MVVM 工作機制,但是還沒有像 Android 那么傻瓜化,還需要你手動寫代碼來指定綁定關系。有興趣的朋友可以看這篇文章:
- https://www.raywenderlich.com/74106/mvvm-tutorial-with-reactivecocoa-part-1
- https://www.raywenderlich.com/74131/mvvm-tutorial-with-reactivecocoa-part-2
如何在 React-Native 下來實現 MVVM,大家可以研究一下一個叫做 MobX 的庫。