“無架構”和“MVP”都救不了業務程式碼,MVVM能力挽狂瀾?(一)

語言: CN / TW / HK

highlight: arduino-light theme: healer-readable


本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!

複雜度

Android 架構演進系列是圍繞著複雜度向前推進的。

軟體的首要技術使命是“管理複雜度” —— 《程式碼大全》

因為低複雜度才能降低理解成本和溝通難度,提升應對變更的靈活性,減少重複勞動,最終提高程式碼質量。

架構的目的在於“將複雜度分層”

複雜度為什麼要被分層?

若不分層,複雜度會在同一層次展開,這樣就太 ... 複雜了。

舉一個複雜度不分層的例子:

小李:“你會做什麼菜?”

小明:“我會做用土雞生的土雞蛋配上切片的番茄,放點油鹽,開火翻炒的番茄炒蛋。”

聽了小明的回答,你還會和他做朋友嗎?

小明把不同層次的複雜度以不恰當的方式揉搓在一起,讓人感覺是一種由“沒有必要的具體”導致的“難以理解的複雜”。

小李其實並不關心土雞蛋的來源、番茄的切法、新增的佐料、以及烹飪方式。

這樣的回答除了難以理解之外,侷限性也很大。因為它太具體了!只要把土雞蛋換成洋雞蛋、或是番茄片換成塊、或是加點糖、或是換成電磁爐,其中任一因素髮生變化,小明就不會做番茄炒蛋了。

再舉個正面的例子,TCP/IP 協議分層模型自下到上定義了五層: 1. 物理層 2. 資料鏈路成 3. 網路層 4. 傳輸層 5. 應用層

其中每一層的功能都獨立且明確,這樣設計的好處是縮小影響面,即單層的變動不會影響其他層。

這樣設計的另一個好處是當專注於一層協議時,其餘層的技術細節可以不予關注,同一時間只需要關注有限的複雜度,比如傳輸層不需要知道自己傳輸的是 HTTP 還是 FTP,傳輸層只需要專注於端到端的傳輸方式,是建立連線,還是無連線。

有限複雜度的另一面是“下層的可重用性”。當應用層的協議從 HTTP 換成 FTP 時,其下層的內容不需要做任何更改。

引子

該系列的前三篇結合“搜尋”這個業務場景,講述了不使用架構寫業務程式碼會產生的痛點: 1. 低內聚高耦合的繪製:控制元件的繪製邏輯散落在各處,散落在各種 Activity 的子程式中(子程式間相互耦合),分散在現在和將來的邏輯中。這樣的設計增加了介面重新整理的複雜度,導致程式碼難以理解、容易改出 Bug、難排查問題、無法複用。 2. 耦合的非粘性通訊:Activity 和 Fragment 通過獲取對方引用並互調方法的方式完成通訊。這種通訊方式使得 Fragment 和 Activity 耦合,從而降低了介面的複用度。並且沒有一種內建的機制來輕鬆的實現粘性通訊。 3. 上帝類:所有細節都在介面被鋪開。比如資料存取,網路訪問這些和介面無關的細節都在 Activity 被鋪開。導致 Activity 程式碼不單純、高耦合、程式碼量大、複雜度高、變化源不單一、改動影響範圍大。 4. 介面 & 業務:介面展示和業務邏輯耦合在一起。“介面該長什麼樣?”和“哪些事件會觸發介面重繪?”這兩個獨立的變化源沒有做到關注點分離。導致 Activity 程式碼不單純、高耦合、程式碼量大、複雜度高、變化源不單一、改動影響範圍大、易改出 Bug、介面和業務無法單獨被複用。

詳細分析過程可以點選下面的連結:

  1. 寫業務不用架構會怎麼樣?(一)

  2. 寫業務不用架構會怎麼樣?(二)

  3. 寫業務不用架構會怎麼樣?(三)

緊接著又用了三篇講述瞭如何使用 MVP 架構對該業務場景的重構過程。MVP 的確解決了一些問題,但也引入了新問題: 1. 分層:MVP 最大的貢獻在於將介面繪製與業務邏輯分層,前者是 MVP 中的 V(View),後者是 MVP 中的 P(Presenter)。分層實現了業務邏輯和介面繪製的解耦,讓各自更加單純,降低了程式碼複雜度。 2. 面向介面通訊:MVP 將業務和介面分層之後,各層之間就需要通訊。通訊通過介面實現,介面把做什麼和怎麼做分離,使得關注點分離成為可能:介面的持有者只關心做什麼,而怎麼做留給介面的實現者關心。介面通過業務介面向 Presenter 發出請求以觸發業務邏輯,這使得它不需要關心業務邏輯的實現細節。Presenter 通過 view 層介面返回響應以指導介面重新整理,這使得它不需要關心介面繪製的細節。 3. 有限的解耦:因為 View 層介面的存在,迫使 Presenter 得了解該把哪個資料塞給哪個 View 層介面。這是一種耦合,Presenter 和這個具體的 View 層介面耦合,較難複用於其他業務。 4. 有限內聚的介面繪製:MVP 並未向介面提供唯一 Model,而是將描述一個完整介面的 Model 分散在若干 View 層介面回撥中。這使得介面的繪製無法內聚到一點,增加了介面繪製邏輯維護的複雜度。 5. 困難重重的複用:理論上,介面和業務分層之後,各自都更加單純,為複用提供了可能性。但不管是業務介面的複用,還是View層介面的複用都相當彆扭。 6. Presenter 與介面共存亡:這個特性使得 MVP 無法應對橫豎屏切換的場景。 7. 無內建跨介面(粘性)通訊機制:MVP 無法優雅地實現跨介面通訊,也未內建粘性通訊機制,得藉助第三方庫實現。 8. 生命週期不友好:MVP 並未內建生命週期管理機制,易造成記憶體洩漏、crash、資源浪費。

詳細分析過程可以點選下面的連結:

  1. MVP 架構最終審判 —— MVP 解決了哪些痛點,又引入了哪些坑?(一)

  2. MVP 架構最終審判 —— MVP 解決了哪些痛點,又引入了哪些坑?(二)

  3. MVP 架構最終審判 —— MVP 解決了哪些痛點,又引入了哪些坑?(三)

從這一篇開始,試著引入 MVVM 架構的思想進行搜尋業務場景的重構,看看是否能解決一些痛點。

在重構之前,再介紹下搜尋的業務場景,該功能示意圖如下:

1662106805162.gif

業務流程如下:在搜尋條中輸入關鍵詞並同步展示聯想詞,點聯想詞跳轉搜尋結果頁,若無匹配結果則展示推薦流,返回時搜尋歷史以標籤形式橫向鋪開。點選歷史可直接發起搜尋跳轉到結果頁。

將搜尋業務場景的介面做了如下設計:

微信截圖_20220902171024.png

搜尋頁用Activity來承載,它被分成兩個部分,頭部是常駐在 Activity 的搜尋條。下面的“搜尋體”用Fragment承載,它可能出現三種狀態 1.搜尋歷史頁 2.搜尋聯想頁 3.搜尋結果頁。

Fragment 之間的切換採用 Jetpack 的Navigation。關於 Navigation 詳細的介紹可以點選關於 Navigation 更詳細的介紹可以點選Navigation 元件使用入門  |  Android 開發者  |  Android Developers

更長生命週期的業務層

在使用 MVP 重構搜尋業務時,存在“Presenter 與介面共存亡”的問題,即 Presenter 在 Activity 例項內部構建,遂其生命週期與 Activity 同步。當 Activity 銷燬重建時,Presenter 也跟著一起銷燬重建。當 Presenter 初始化時存在耗時操作時,這樣的從頭來過就很浪費資源。(詳細分析可以點選MVP 架構最終審判 —— MVP 解決了哪些痛點,又引入了哪些坑?

MVVM 中的 VM 即ViewModel,它是與 MVP 中 Presenter 相對應的概念,即業務邏輯層(它在此基礎上又拓展出新的作用),它的引入解決了這個痛點。

ViewModel 是 JetPack 提供的一個類: java public abstract class ViewModel { /** * Construct a new ViewModel instance. * You should never manually construct a ViewModel outside of a * {@link ViewModelProvider.Factory}. */ public ViewModel() {} } ViewModel 雖然提供了公有的構造方法,但註解提示說“永遠不要手動構建 ViewModel 例項,而是得通過ViewModelProvider.Factory

java public interface Factory { <T extends ViewModel> T create(@NonNull Class<T> modelClass); } Factory 是一個介面,是對如何構建 ViewModel 的一個抽象。

之所以不允許直接構建而是必須通過 Factory,是因為系統希望掌控 ViewModel 的例項構建,在內部幫助開發者構建 ViewModel 例項。若把 ViewModel 的構建方法放開,則上層可能出現各種各樣自定義的構建方法(比如在構造方法中出入不同的引數)。

那為啥系統要掌控 ViewModel 例項的構建?

因為系統對 ViewModel 例項的存取做了特殊處理。

ViewModel 通常是這樣宣告的: ```kotlin class SearchViewModel( private val repository: SearchRepository ): ViewModel() { }

class SearchFactory(val repository: SearchRepository): ViewModelProvider.Factory { override fun create(modelClass: Class): T { return SearchViewModel(searchRepository) as T } } ``` 其中的 Repository 是對訪問資料的封裝,比如網路請求,關於它的詳細解釋可以點選MVP 架構最終審判 —— MVP 解決了哪些痛點,又引入了哪些坑?

而 ViewModel 通常在 Activity 中這樣被構建: kotlin class TemplateSearchActivity : AppCompatActivity() { private val searchViewModel by lazy { ViewModelProvider( this, SearchFactory(SearchRepository())).get(SearchViewModel::class.java) } } 構建 Presenter 是直接在 Activity 中 new,而構建 ViewModel 是通過ViewModelProvider().get(): ```java public class ViewModelProvider { // ViewModel 例項商店 private final ViewModelStore mViewModelStore; private Factory mFactory;

public <T extends ViewModel> T get(String key, Class<T> modelClass) {
    // 從商店獲取 ViewModel例項
    ViewModel viewModel = mViewModelStore.get(key);
    // 若 ViewModel 匹配指定型別則直接返回
    if (modelClass.isInstance(viewModel)) {
        return (T) viewModel;
    } 
    ...
    // 若商店無 ViewModel 例項 則通過 Factory 構建
    if (mFactory instanceof KeyedFactory) {
        viewModel = ((KeyedFactory) (mFactory)).create(key, modelClass);
    } else {
        viewModel = (mFactory).create(modelClass);
    }
    // 將 ViewModel 例項存入商店
    mViewModelStore.put(key, viewModel);
    return (T) viewModel;
}

} ``ViewModelProvider是一個獲取 ViewModel 例項的工具類,它遮蔽了通過訪問ViewModelStore`獲取 ViewModel 例項的細節。

ViewModelStore 是真正存在 ViewModel 例項的地方: ```java // ViewModel 例項商店 public class ViewModelStore { // 儲存 ViewModel 例項的 Map private final HashMap mMap = new HashMap<>();

// 存
final void put(String key, ViewModel viewModel) {
    ViewModel oldViewModel = mMap.put(key, viewModel);
    if (oldViewModel != null) {
        oldViewModel.onCleared();
    }
}

// 取
final ViewModel get(String key) {
    return mMap.get(key);
}
...

} ``ViewModelStore`內部持有一個 HashMap,這是 ViewModel 例項的最終存放點。

而 ViewModelStore 的例項是通過ViewModelStoreOwner獲取: ```java public class ViewModelProvider { // ViewModel 例項商店 private final ViewModelStore mViewModelStore;

// 構造 ViewModelProvider 時需傳入 ViewModelStoreOwner 例項
public ViewModelProvider(@NonNull ViewModelStoreOwner owner, @NonNull Factory factory) {
    // 通過 ViewModelStoreOwner 獲取 ViewModelStore 
    this(owner.getViewModelStore(), factory);
}

public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) {
    mFactory = factory;
    mViewModelStore = store;
}

} 那`ViewModelStoreOwner`例項又儲存在哪?java // Activity 基類實現了 ViewModelStoreOwner 介面 public class ComponentActivity extends androidx.core.app.ComponentActivity implements LifecycleOwner, ViewModelStoreOwner{ // Activity 持有 ViewModelStore 例項 private ViewModelStore mViewModelStore;

    public ViewModelStore getViewModelStore() {
        if (mViewModelStore == null) {
            // 獲取配置無關例項
            NonConfigurationInstances nc =
                (NonConfigurationInstances) getLastNonConfigurationInstance();
            if (nc != null) {
                // 從配置無關例項中恢復 ViewModel商店
                mViewModelStore = nc.viewModelStore;
            }
            if (mViewModelStore == null) {
                mViewModelStore = new ViewModelStore();
            }
        }
        return mViewModelStore;
    }

    // 靜態的配置無關例項
    static final class NonConfigurationInstances {
        // 持有 ViewModel 商店例項
        ViewModelStore viewModelStore;
        ...
    }

} `` Activity 就是ViewModelStoreOwner例項,且持有ViewModelStore`例項。

但當橫豎屏切換 Activity 銷燬重建時,作為成員變數的 ViewModelStore 依然會被銷燬,為了避免它被重建,ViewModelStore 例項還會被寄存在一個靜態類NonConfigurationInstances中,以保證橫豎屏切換時可以從中恢復。

最終的持有鏈如下: Activity { ViewModelStore { HashMap<String, ViewModel> } NonConfigurationInstances { ViewModelStore } } Activity 和 NonConfigurationInstances 共同持有 ViewModelStore 例項,ViewModelStore 持有 HashMap,ViewModel 作為鍵值對中的值存在。

這套儲存機制使得 ViewModel 生命週期比 Activity 更長。這樣 Activity 銷燬重建時,就不會重新觸發業務邏輯。

資料持有者 & 資料驅動

假設 Presenter 也套用 ViewModel 這套構建機制,是否就能解決橫豎屏場景下的所有問題?

不能!

解決橫豎屏問題需要做到兩點: 1. 比 Activity 生命週期更長的業務邏輯層。 2. 業務邏輯層持有資料並且具備資料重放能力。

即使 Presenter 做到了更長的生命週期也只是解決了第一個問題。因為 Presenter 它不是一個數據持有者,更別提資料重放了。

引用上一篇關於 MVP 資料流動的示意圖:

微信截圖_20220930213304.png

Presenter 只持有了 Repository,它並不持有資料,即不存在一個叫 data 的成員變數。從 Repository 獲取的資料是直接在業務介面中傳遞給了 View 層介面的。

也就是說,當觸發了一個業務動作後,資料發生了一次從 Repository 到 Presenter 再到介面的流動。整個流動的過程中並沒有一個地方把資料存下來。所以 Presenter 不是一個資料持有者

既然 Presenter 不持有資料,那它也無法把上次流過的資料進行重放,即重新發送給介面。那在 MVP 架構中,當 Activity 銷燬重建時,如何恢復介面剛才的樣子?答案是“無法恢復!”,只能重新觸發一遍業務動作,比如重新請求網路,一切從頭再來!

ViewModel 的出現同時把上述兩個問題都解決了,總結為一句話即是“ViewModel 是生命週期更長的資料持有者。

ViewModel 藉助於LiveData的幫助實現了資料持有者的效果。

LiveData 也是 JetPack 的一員。它是能感知生命週期的,可觀察的,粘性的,資料持有者。LiveData 用於以“資料驅動”方式更新介面。

關於 LiveData 的詳細講解可以點選LiveData 面試題庫、解答、原始碼分析

在 MVP 架構中介面的重新整理是指令式程式設計,即介面重新整理是通過手動呼叫方法實現的。

指令式程式設計會產生耦合。

手動呼叫某個方法的前提是得先獲取對應的物件,在 MVP 架構中,描述介面如何繪製的物件叫“View 層介面”,Presenter 得先持有 View 層介面的例項,然後在內部根據業務邏輯手動呼叫相應的 View 層介面,即 Presenter 得知道要把哪個資料塞給哪個 View 層介面。這使得 Presenter 和 View 層介面耦合,或者說業務和介面耦合。耦合導致複用困難。(詳細分析可以點選MVP 架構最終審判 —— MVP 解決了哪些痛點,又引入了哪些坑?(三)

更加解耦的方式是資料驅動:讓業務層只操縱資料,介面通過觀察資料的方式實現重新整理。

這裡的資料指的是 MXX 架構中的 M,即 Model。

如此一來業務層不再持有任何和介面相關的東西,只和資料有關。不同的介面可以以任何喜歡的方式組合使用業務層提供的資料。(MVP 做不到這點,因為資料是通過 View 層介面給出去,組合使用略困難)

面向業務抽象Model

下面就以搜尋條為例,看看用 MVVM 架構重構之後會是什麼樣子。

搜尋條的業務場景如下:

1664442211986.gif

當輸入框鍵入內容後,顯示X按鈕並高亮搜尋按鈕。點選搜尋跳轉到搜尋結果頁,同時搜尋條拉長並隱藏搜尋按鈕。點選X時清空輸入框並從搜尋結果頁返回,搜尋條還原。

根據業務邏輯為 ViewModel 新增一系列動作及資料: kotlin class SearchViewModel(private val searchRepository: SearchRepository) : ViewModel() { // 業務資料持有者 val initLiveData = MutableLiveData<Boolean>() val keywordLiveData = MutableLiveData<String>() val searchLiveData = MutableLiveData<String>() val clearLiveData = MutableLiveData<Boolean>() val backToHistoryLiveData = MutableLiveData<Boolean>() // 業務動作 fun init() { initLiveData.value = true } fun search(keyword: String) { searchLiveData.value = keyword } fun clear() { clearLiveData.value = true } fun input(keyword: String) { keywordLiveData.value = keyword } fun backToHistory(){ backToHistoryLiveData.value = true } } 每一個函式代表著一個業務邏輯,並有與之對應的一個業務資料(以 LiveData 形式表達)

介面通過觀察資料來更新檢視: ```kotlin class TemplateSearchActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(contentView) searchViewModel.init() observerData() initView() }

// 觀察資料並重新整理介面
private fun observerData() {
    searchViewModel.initLiveData.observe(this, Observer {
        if (it) {
            tvSearch.apply {
                isEnabled = false
                textColor = "#484951"
            }

            ivClear.visibility = gone
            KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
        }
    })
    searchViewModel.searchLiveData.observe(this, Observer {
       searchAndHideKeyboard(it)
    })
    searchViewModel.keywordLiveData.observe(this, Observer {
        if (it.isNotEmpty()) {
            tvSearch.textColor = "#F2F4FF"
            tvSearch.isEnabled = true
            ivClear.visibility = visible
        } else {
            tvSearch.textColor = "#484951"
            tvSearch.isEnabled = false
            ivClear.visibility = gone
        }
    })

    searchViewModel.clearLiveData.observe(this, Observer {
        if(it){
            etSearch.text = null
            etSearch.requestFocus()
            KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
            backToHistory()
        }
    })

    searchViewModel.backToHistoryLiveData.observe(this, Observer {
        backToHistory()
    })
}

private fun initView() {
    etSearch.addTextChangedListener(object : TextWatcher {
        override fun beforeTextChanged(s: CharSequence?,s: Int, c: Int, a: Int) {}

        override fun onTextChanged(char: CharSequence?, s: Int, b: Int, c: Int) {
            val input = char?.toString() ?: ""
            // 向 ViewModel 發起業務動作
            searchViewModel.input(input) 
        }

        override fun afterTextChanged(s: Editable?) {}
    })
    etSearch.setOnEditorActionListener { v, actionId, event ->
        if (actionId == EditorInfo.IME_ACTION_SEARCH) {
            val input = etSearch?.text?.toString() ?: ""
            if (input.isNotEmpty()) {
                // 向 ViewModel 發起業務動作
                searchViewModel.search(input) 
            }
            true
        } else false
    }
    tvSearch.setOnClickListener {
        // 向 ViewModel 發起業務動作
        searchViewModel.search(etSearch.text.toString()) 
    }
    ivClear.setOnClickListener {
        // 向 ViewModel 發起業務動作
        searchViewModel.clear() 
    }
    etSearch.setOnTouchListener { v, event ->
        if (event.action == MotionEvent.ACTION_DOWN) {
            // 向 ViewModel 發起業務動作
            searchViewModel.backToHistory() 
        }
        false
    }
}

} ``` 這樣的寫法頗有脫褲子放屁的感覺,還引入了額外的複雜度 ViewModel。但這褲子脫得還是有價值的:

  1. 業務邏輯和介面展示分離:這使得介面展示和業務邏輯可以獨立的變化而不會相互影響。(這一點MVP也可以做到)
  2. 更新檢視的邏輯不再散落各處:介面通過觀察資料較為集中地進行更新。但遺憾的是,同一個檢視的更新邏輯還是會散落在不同資料的觀察者中。

現在看來和上一篇用 MVP 重構的效果沒任何兩樣,反而因為引入了 ViewModel 和 LiveData 增加了複雜度。

MVVM 的好處當然不止於此,後續章節會慢慢展開。(這一小節只是先展示 MVVM 的概貌)

面向介面抽象Model

用一張圖來表達下上一小節 MVVM 的複雜度:

微信截圖_20220903215347.png

它完成了介面展示與業務邏輯分離,但控制元件的重新整理邏輯散落在不同資料的觀察者中,依然無法將“介面應該長什麼樣子?”這個問題內聚於一點。

之所以會這樣是因為“錯誤的 Model 抽象”。

上述程式碼是以業務邏輯作為抽象 Model 的依據。比如與“返回歷史頁”對應的資料是一個布林值,用來表示是否觸發了返回。這使得 Model 和業務強繫結,業務一變,原先的資料就沒用了。Model 應該和業務無關,Model 應該只表達介面該長成什麼樣子

按照這個思路,MVVM 的 Model 應該做如下改造: ```kotlin class SearchViewModel : ViewModel() { // 搜尋按鈕顏色 val searchButtonColorLiveData = MutableLiveData() // 搜尋按鈕是否可點選 val searchButtonClickableLiveData = MutableLiveData() // 搜尋按鈕是否可見 val searchButtonVisibilityLiveData = MutableLiveData() // 清除按鈕是否顯示 val clearButtonVisibilityLiveData = MutableLiveData() // 搜尋條是否拉深 val searchBarStretchLiveData = MutableLiveData() // 鍵盤是否展示 val keyboardLiveData = MutableLiveData() // 跳轉到搜尋結果頁 val gotoSearchLiveData = MutableLiveData() // 關鍵詞 val keywordLiveData = MutableLiveData() // 從結果頁返回 val popupLiveData = MutableLiveData()

fun init() {
    keyboardLiveData.value = true
    searchButtonColorLiveData.value = "#484951"
    searchButtonClickableLiveData.value = false
    searchButtonVisibilityLiveData.value = true
    searchBarStretchLiveData.value = false
}

fun search(keyword: String) {
    gotoSearchLiveData.value = keyword
    keyboardLiveData.value = false
    searchBarStretchLiveData.value = true
}

fun clear() {
    clearButtonVisibilityLiveData.value = true
    searchButtonClickableLiveData.value = false
    searchButtonColorLiveData.value = "#484951"
    keywordLiveData.value = ""
}

fun input(keyword: String) {
    if (keyword.isNullOrEmpty()) {
        searchButtonColorLiveData.value = "#484951"
        searchButtonVisibilityLiveData.value = true
        searchButtonClickableLiveData.value = false
        clearButtonVisibilityLiveData.value = false
    } else {
        searchButtonColorLiveData.value = "#F2F4FF"
        searchButtonVisibilityLiveData.value = true
        searchButtonClickableLiveData.value = true
        clearButtonVisibilityLiveData.value = true
    }
}

fun popUp() {
    searchButtonClickableLiveData.value = false
    searchButtonColorLiveData.value = "#484951"
    searchButtonVisibilityLiveData.value = true
    clearButtonVisibilityLiveData.value = false
    keywordLiveData.value = ""
    searchBarStretchLiveData.value = false
    popupLiveData.value = true
}

} ``` 以控制元件的某個屬性作為抽象 Model 的依據,不同的業務邏輯函式會修改相應的控制元件屬性 Model,介面再觀察 Model。

繪製介面邏輯也相應地做如下修改: kotlin // TemplateSearchActivity.kt private fun observeData() { searchViewModel.keyboardLiveData.observe(this){ if(it) KeyboardUtils.showSoftInputWithDelay(etSearch, 300) else KeyboardUtils.hideSoftInput(etSearch) } searchViewModel.clearButtonVisibilityLiveData.observe(this){ ivClear.visibility = if(it) visible else gone } searchViewModel.searchButtonVisibilityLiveData.observe(this){ tvSearch.visibility = if(it) visible else gone } searchViewModel.searchButtonColorLiveData.observe(this) { tvSearch.textColor = it } searchViewModel.searchButtonClickableLiveData.observe(this){ tvSearch.isEnabled = it } searchViewModel.searchBarStretchLiveData.observe(this){ vInputBg.apply { if (it) end_toEndOf = parent_id else end_toStartOf = ID_SEARCH } } searchViewModel.gotoSearchLiveData.observe(this){ findNavController(NAV_HOST_ID.toLayoutId()).navigate( R.id.action_to_result, bundleOf("keywords" to it) ) } searchViewModel.keywordLiveData.observe(this){ if(it.isNullOrEmpty()) { etSearch.text = null etSearch.requestFocus() KeyboardUtils.showSoftInputWithDelay(etSearch, 300) } } searchViewModel.popupLiveData.observe(this){ if(it){ findNavController(NAV_HOST_ID.toLayoutId()).popBackStack() } } }

更內聚的Model

上面的這次重構解決了 Model 和業務強耦合的問題,但那個老問題依然沒有得到解決,甚至還加重了,即重新整理介面的邏輯散落在更多的資料觀察者中,無法形成對介面繪製統一的認知。

用一張圖表達下此時 MVVM 的複雜度:

微信截圖_20220903215933.png

看上去挺複雜的。之所以會這樣是因為資料來源不單一。比如搜尋按鈕應該長什麼樣用了三個 Model 來表示: 1. searchButtonColorLiveData 2. searchButtonClickableLiveData 3. searchButtonVisibilityLiveData

當 UI 發生變更,搜尋按鈕要新增一個漸變的背景色時,是不是還要新增一個 Model?

這樣設計的話複雜度就會陡增。

當前按鈕有2種顏色,2種點選狀態,2種可見狀態。當把這三個維度分別用三個 Model 來表達時,意味著它們可以不受控制地獨立變化,進而形成 2 * 2 * 2 = 8 種排列組合。但其中只有 3 種組合是符合預期的。如何保證在改這塊程式碼時不生成錯誤的排列組合(介面狀態)?

進一步,搜尋按鈕的可見狀態是和搜尋條的長度聯動的,即只有當搜尋條拉長時按鈕才不可見。如果處理不好就會產生如下的介面狀態不一致:

微信圖片_20221030144051.jpg

另外,清空按鈕也會和搜尋按鈕的顏色聯動。

更好的設計應該是用一個 Model 表達所有相關的介面狀態kotlin data class SearchBarModel( val searchButtonColor: String,// 搜尋按鈕顏色 val isSearchButtonClickable: Boolean, // 搜尋按鈕是否可點選 val isSearchBarStretch: Boolean, // 搜尋條是否拉昇 val isClearShow: Boolean, // 是否展示清空按鈕 ) 對應的 ViewModel 做相應的修改: ```kotlin class SearchViewModel : ViewModel() { // 搜尋按鈕顏色 val searchBarLiveData = MutableLiveData() // 鍵盤是否展示 val keyboardLiveData = MutableLiveData() // 跳轉到搜尋結果頁 val gotoSearchLiveData = MutableLiveData() // 關鍵詞 val keywordLiveData = MutableLiveData() // 從結果頁返回 val popupLiveData = MutableLiveData()

fun init() {
    keyboardLiveData.value = true
    searchBarLiveData.value = SearchBarModel(
        "#484951",
        false,
        false,
        false
    )
}

fun search(keyword: String) {
    gotoSearchLiveData.value = keyword
    keyboardLiveData.value = false
    searchBarLiveData.value = SearchBarModel(
        "#484951",
        false,
        true,
        true
    )
}

fun clear() {
    keywordLiveData.value = ""
    searchBarLiveData.value = SearchBarModel(
        "#484951",
        false,
        false,
        false
    )
}

fun input(keyword: String) {
    if (keyword.isNullOrEmpty()) {
        searchBarLiveData.value = SearchBarModel(
            "#484951",
            false,
            false,
            false
        )
    } else {
        searchBarLiveData.value = SearchBarModel(
            "#F2F4FF",
            true,
            false,
            true
        )
    }
}

fun popUp() {
    keywordLiveData.value = ""
    popupLiveData.value = true
    searchBarLiveData.value = SearchBarModel(
        "#484951",
        false,
        false,
        false
    )
}

} ``` 當任何一個影響搜尋條狀態變化的事件發生時,你都得構建一個 SearchBarModel 併為其中的四個引數賦值。這迫使你將所有的狀態都考慮在內,避免遺留。這樣的設計極大的降低了程式碼的複雜度。

介面繪製程式碼也得做相應修改: kotlin // TemplateSearchActivity.kt private fun observeData() { searchViewModel.searchBarLiveData.observe(this) { model-> ivClear.visibility = if(model.isClearShow) visible else gone tvSearch.apply { textColor = model.searchButtonColor visibility = if(model.isSearchBarStretch) gone else visible isEnable = model.isSearchButtonClickable } vInputBg.apply { if (model.isSearchBarStretch) end_toEndOf = parent_id else end_toStartOf = ID_SEARCH } } }

如此一來繪製介面的程式碼也更加內聚了,所有關於搜尋按鈕長什麼樣的程式碼都內聚在一個數據觀察者回調中,後期修改搜尋按鈕樣式的時候,不至於要滿 Activity 地找控制元件。

再用一張圖看下簡化後的複雜度:

微信截圖_20221030151202.png

LiveData 數量少了,業務邏輯和 LiveData 互動的邏輯也少了。

總結

這一篇主要引入了 MVVM 架構的兩個重要概念 ViewModel 以及 LiveData。前者使得“更長生命週期的業務層”成為可能,後者使得業務層成為資料持有者,並以資料驅動重新整理介面。

下一篇會繼續講述 MVVM 並用實戰程式碼展示它的痛點。盡請期待~

推薦閱讀

寫業務不用架構會怎麼樣?(一)

寫業務不用架構會怎麼樣?(二)

寫業務不用架構會怎麼樣?(三)

MVP 架構最終審判 —— MVP 解決了哪些痛點,又引入了哪些坑?(一)

MVP 架構最終審判 —— MVP 解決了哪些痛點,又引入了哪些坑?(二)

MVP 架構最終審判 —— MVP 解決了哪些痛點,又引入了哪些坑?(三)

“無架構”和“MVP”都救不了業務程式碼,MVVM能力挽狂瀾?(一)