iOS MVP模式重構實踐

興興點燈發表於2018-04-21

最近完成了我司iOS專案的重構,把整體的程式碼架構都梳理了一遍,主要按照MVP的架構模式,並綜合考慮了重構的難度和效果。在這個過程中也積累了一些程式碼重構方面的經驗,在這裡總結一下。

專案簡介和MVP模式重構

專案簡介

首先簡單介紹一下專案情況。我們原有專案的架構是比較標準的MVC模式,也是蘋果官方推薦的架構模式。Model層用來表示實體類,View層負責介面展示和傳遞UI事件,Controller層負責大部分的業務邏輯。除此之外,對一部分公共的可複用的邏輯,我們抽象出Service層,提供給Controller使用,另外網路層也獨立出來。下圖比較清楚地展示了整體架構

整體架構

MVC模式的問題

MVC架構作為蘋果官方推薦的架構模式,把資料Model和展現View通過Controller層隔離開,在專案規模較小的時候是一個不錯的選擇。隨著專案複雜性的提高,我們也漸漸感覺到MVC模式的弊端,主要體現在下面幾個方面

  • Controller層職責過多,Model和View層太簡單

Controller處理業務邏輯,處理UI更新,處理UI事件,同步Model層,我們幾乎所有的程式碼都寫在了Controller層。設計模式裡有單一模式原則,你看這裡的Controller層已經至少有四種職責了。

  • 業務邏輯和UI混雜在一起,難以編寫單元測試

這一點一方面是因為Cocoa框架裡的Controller層,就是我們最熟悉的UIViewController和View是天然耦合的,很多view的生命週期方法如viewWillAppear都存在於VC,另一方面我們很多時候也習慣於把UI操作甚至初始化操作放在VC裡,導致UI和業務邏輯混雜在一起。當你想對業務邏輯編寫單元測試的時候,看著業務邏輯程式碼裡混雜的UI操作,就知道什麼叫舉步維艱——資料可以Mock,UI是不可能被Mock的。

  • 業務邏輯程式碼大量存在於Controller層,維護困難

當一個介面功能比較複雜的時候,我們所有的邏輯程式碼都會堆積在Controller中,比如我們原有的WebViewController的程式碼就多達5000行,在這種情況下維護程式碼簡直是如履薄冰。

MVP模式的重構

對於Controller層過於臃腫的問題,MVP模式則能較好地解決這個問題——既然UIViewControllerUIView是耦合的,索性把這兩者都歸為View層,業務邏輯則獨立存在於Presenter層,Model層保持不變。下圖比較清除得展示了MVP模式的結構

MVP模式簡介
我們來看一下MVP模式能否解決MVC模式存在的問題

  • Controller層職責過多,Model和View層太簡單

在MVP模式下,Controller層和View層已經合併為View層,專門負責處理UI更新和事件傳遞,Model層還是作為實體類。原本寫在ViewController層的業務邏輯已經遷移到Presenter中。MVP模式較好地解決了Controller層職責過多的問題。

  • 業務邏輯和UI混雜在一起,難以編寫單元測試

Presenter層主要處理業務邏輯,ViewController層實現Presenter提供的介面,Presenter通過介面去更新View,這樣就實現了業務邏輯和UI解耦。如果我們要編寫單元測試的話,只需要Mock一個物件實現Presenter提供的介面就好了。MVP模式較好地解決了UI和邏輯的解耦。

  • 業務邏輯程式碼大量存在於Controller層,維護困難

通過把業務邏輯遷移到Presenter層,Controller層的困境似乎得到了解決,但是如果某個需求邏輯較為複雜,單純的把業務邏輯遷移解決不了根本的問題,Presenter層也會存在大量業務邏輯程式碼,維護困難。這個問題,我們下面會討論如何解決。

MVC模式改進——Router模式

這裡主要是考慮介面間跳轉的程式碼如何重構,這一點我在之前的文章裡已經有提到了,這裡給個連結iOS重構之面向協議程式設計實踐,另外附圖一張

Router模式

例項分析

前面我們提到,MVP模式雖然能解決許多MVC模式下存在的問題,但對於比較複雜的需求,還是會存在邏輯過於複雜,Presenter層也出現難以維護的問題。下面我們就通過一個實際的例子,來看看面對複雜的業務邏輯,我們應該如何去設計和實現。

很多複雜的需求,在最初都是從一個簡單的場景,一步步往上增加功能。在這個過程中,如果不持續的進行優化和重構,到最後就成了所謂的"只有上帝能看懂的程式碼"。說了這麼多,進入正題,來看這個需求。

V1.0 單檔案上傳

實現一個簡單的單檔案上傳,檔案的索引儲存在資料庫中,檔案儲存在App的沙箱裡面。這個應該對於有經驗的客戶端開發者來說是小菜一碟,比較簡單也容易實現。我們可以把這個需求大致拆分成以下幾個子需求

  1. 初始化上傳View
  2. 更新上傳View
  3. 點選上傳按鈕事件
  4. 資料庫中獲取上傳模型
  5. 發起HTTP請求上傳檔案
  6. 檢查網路狀態

以上幾項如果使用傳統的MVC模式,實現起來如下圖所示

MVC
我們可以看到上述需求基本都直接在UploadViewController中實現,目前需求還是比較簡單的情形下面,還是勉強能夠接受,也不需要更多的思考。如果使用MVP的模式進行優化,如下圖所示

MVP.png

現在UploadPresenter負責處理上傳邏輯了,而UploadViewController專注於UI更新和事件傳遞,整體的結構更加清晰,以後維護程式碼也會比較方便。

V2.0 多檔案上傳

需求來了!需要在原來的基礎上支援多檔案上傳,意味著我們多了一個子需求

  1. 維護上傳模型佇列

很顯然,我們需要在UploadPresenter中增加一個維護上傳佇列的功能,最初我也確實是這樣實現的,但是由於檔案上傳需要監聽的事件比較多,回撥也比較頻繁,直接在Presenter中繼續寫這樣的邏輯程式碼,已經成倍增加了程式碼的複雜性。

所以經過一番思考,我考慮把檔案上傳這部分的邏輯單獨提取出一層FileUploader,而UploadPresenter只負責維護FileUploader的佇列以及檢查網路狀態。具體的實現如下所示。

MVP2.png
我們可以看到,分層之後的結構又更加清晰了,每一層的職責都比較單一,目前看起來一切OK!

V3.0 多來源上傳

原來我們的上傳檔案的來源是存在於App沙箱裡的,我們通過資料庫查詢可以找到這個檔案的索引和路徑,進而獲取到這個檔案進行上傳。現在萬惡的需求又來了,需要支援上傳系統相簿中獲取的圖片/視訊。

  1. 支援系統相簿和App沙箱中獲取檔案

到這裡可能有些讀者已經有點頭大了,如果沒有仔細思考,很可能從這裡就走向了程式碼質量崩潰的道路。

這個時候,我們就要思考,他們是多來源,但是對於FileUploader來說,它其實不關心模型的來源,它只需要獲取到模型的二進位制流。於是,我們可以抽象出一個BaseModel,提供一個stream只讀屬性,兩種來源分別繼承BaseModel,各自過載stream只讀屬性,實現自己的構造檔案stream的方法。對於FileUploader來說,它只持有BaseModel即可,這就是繼承和多型的一個典型的使用場景

如果後續還有更多來源的檔案,比如網路檔案(先下載再上傳?),也只需要繼續繼承BaseModel,過載stream即可,對於FileUploader和它的所有上層來說,一切都是透明的,無需進行修改。經過這樣的設計,我們的程式碼的可維護性和可擴充套件性又好了。下面是架構圖。

MVP3.png

V4.0 多方式上傳

在HTTP檔案上傳中,我們可以直接上傳檔案的二進位制流,這種就需要服務端做特定的支援。但更為常用和支援廣泛的做法是使用HTTP表單檔案傳輸,即組裝HTTP請求的body時採用multipart/form-data的標準組裝,傳輸資料。於是,我們又多了一個需求:

  1. 支援表單傳輸和流傳輸

思路和剛才的多來源上傳差不多,我們把上面的兩種來源的模型,即FSBaseMABaseM抽象為父類,父類含有各自的檔案二進位制資料的抽象,子類分別實現二進位制直接組裝流,和按multipart/form-data格式組裝流,實現如下圖。

MVP4.png

V5.0 支援FTP/Socket上傳

剛才我們的檔案上傳,底層的協議是基於Http,此時我們需要支援FTP/Socket協議的傳輸,應該怎麼辦?

  1. 支援HTTP/FTP/Socket

經過上面的思考,相信你一定知道該怎麼做了。這裡留個思考,答案請戳這裡MVP_V5架構

對比

最後,我們把目前的需求全都整理一下

  1. 初始化上傳View
  2. 更新上傳View
  3. 點選上傳按鈕事件
  4. 資料庫中獲取上傳模型
  5. 發起HTTP請求上傳檔案
  6. 檢查網路狀態
  7. 維護上傳模型佇列
  8. 支援系統相簿和App沙箱中獲取檔案
  9. 支援表單傳輸和流傳輸
  10. 支援HTTP/FTP/Socket

我們看看,如果分別採用MVC、MVP_V1、MVP_V2、MVP_V3、MVP_V4、MVP_V5,來實現目前的十個需求,我們的程式碼大致會分佈在哪幾層。

優化後的架構模式之間的比較

孰優孰劣一目瞭然。如果採用最原始的MVC模式的話,保守估計ViewController程式碼量至少3K行以上。

總結

  • 運用MVP的設計模式,邏輯和UI操作解耦
  • 分層模式,上層擁有下層,下層通過介面與上層通訊,達到解耦。
  • 利用繼承和多型,遮蔽底層實現的細節,達到職責分離和高擴充套件性

程式碼優化和重構的技巧

在這次的專案重構中,我也總結了一些重構方面的技巧和貼士,希望能幫助到想開始進行程式碼重構的同學

事不過三

  • 大段重複的程式碼出現了三次或以上 ——提取成一個公共的方法 這一點是最常見也最容易做到的,只要在平時的編碼過程中養成這種習慣,對於出現過三次以上重複程式碼段,提取成一個公共方法。

  • 一個類的職責有三種或以上 ——通過合理分層的方式,減少職責 這一點在上面的例子中已經闡述地比較清楚了,通過職責的分層,上層持有下層,下層通過介面與上層通訊。其實這也是MVP模式的本質。

  • 同類的if/else出現了三次或以上 ——考慮使用抽象類和多型代替if/else 如果相同的if/else判斷在你的程式碼中出現了很多次的話,則應該考慮設計一個抽象類去替代這些判斷。這裡可能有點難以理解,舉個例子就好懂很多 比如,現在我們有一個水果類,有三種水果,水果有顏色、價錢和品種

class Fruit {
    
    var name:String = ""
    
    func getColor() -> UIColor? {
        if name == "apple" {
            return UIColor.red
        } else if name == "banana" {
            return UIColor.yellow
        } else if name == "orange" {
            return UIColor.orange
        }
        return nil
    }

    func getPrice() -> Float? {
        if name == "apple" {
            return 10
        } else if name == "banana" {
            return 20
        } else if name == "orange" {
            return 30
        }
        return nil
    }
    
    func getType() -> String? {
        if name == "apple" {
            return "紅富士"
        } else if name == "banana" {
            return "芭蕉"
        } else if name == "orange" {
            return "皇帝"
        }
        return nil
    }
}
複製程式碼

這裡的對名稱name的相同的if/else判斷出現了三次,如果此時我們多了一種水果梨,我們得修改上述所有的if/else判斷,這樣就會非常難維護。

這種場景我們可以考慮抽象出一個Fruit的抽象類/介面/協議,通過實現水果類/介面/協議的方式,此時如果多了一種水果,讓這種水果繼續實現Fruit協議就行,這樣我們就通過新增的方式替代修改,提高了程式碼的可維護性。


protocol Fruit {
    func getPrice() -> Float?
    func getType() -> String?
    func getColor() -> UIColor?
    var name:String { get }
}

class Apple:Fruit {
    var name:String = "apple"
    func getColor() -> UIColor? {
        return UIColor.red
    }
    
    func getPrice() -> Float? {
        return 10
    }
    
    func getType() -> String? {
        return "紅富士"
    }
}

class Banana:Fruit {
    var name:String = "banana"
    func getColor() -> UIColor? {
        return UIColor.yellow
    }
    
    func getPrice() -> Float? {
        return 20
    }
    
    func getType() -> String? {
        return "芭蕉"
    }
}

class Orange:Fruit {
    var name:String = "orange"
    func getColor() -> UIColor? {
        return UIColor.orange
    }
    
    func getPrice() -> Float? {
        return 30
    }
    
    func getType() -> String? {
        return "皇帝柑"
    }
}
複製程式碼

合理分層

  • 縱向分層——層級之間有關聯 上層持有下層,下層通過介面與上層通訊。這裡為什麼不讓下層也持有上層呢?主要還是為了能夠解耦,下層設計的目的是為上層服務的,它不應該依賴上層。這種設計模式在電腦科學中是很常見的,比如計算機網路中的網路分層設計。
  • 橫向分層——層級之間無關聯 適用於功能相對獨立的模組,簡單劃分即可。我們的iOS專案的首頁就是由好幾個部分組成,這個部分之間無太多的關聯,我們簡單劃分成幾個模組就行。如果出現了少數需要通訊的場景,使用Notification即可。

避免過度設計

  • 越簡單的越是有效的 複雜的架構設計往往在客戶端高速迭代開發中意義不大(相比服務端)
  • 沒有銀彈! 軟體開發是工程化的,沒有完美的架構模式,很多時候需要具體問題具體分析,靈活運用設計模式,得到區域性的最優解。比如前面提到的MVP模式,如果生搬硬套,同樣無法解決Presenter層複雜的問題。
  • 如何判斷過度設計? 膠水程式碼過多 大量檔案的行數小於100 想了一天,沒寫出程式碼,也沒寫出架構方案

重構的時機和物件

  • 時機 單檔案程式碼行數開始超過500行的時候 Code Review是重構的好幫手
  • 物件 需求經常變化或增加的功能,一定要注意設計,避免走向質量不可控 穩定且不變的功能,不重構

總結

最後我想談談設計模式。其實重構的過程其實也就是靈活運用設計模式對程式碼進行優化和改進。很多人設計模式也看了很多,學習了很多,但真正在工作中能合理使用的卻很少。所以關鍵還在靈活運用四個字上,能做到這一點,你的水平就會上一個臺階。

所以在平時的工作中,我們要有對程式碼的Taste,知道什麼樣的是好程式碼,什麼樣的是髒程式碼,儘早發現可優化可改進的地方,持續產出高質量程式碼,而不是實現功能就萬事大吉,否則遲早要為你以前偷的懶買單。 以上就是我在我司專案重構過程中的的一些總結和分享,水平有限,希望對大家有所幫助。

相關文章