構架(一)—— MVC的誤用的總結

前言

MVC是每個iOS開發者都要接觸到的話題,輕量級的MVC也一直是每個開發者需要關注的,筆者因爲以前寫過一個自己的作品,但作品的一個VC的數量來到了1.8k行,但很大程度上是因爲那時候自己水平有限,對很多都不瞭解就開始製作作品,當VC到了500行以上,就已經很難維護,因爲UI的操作散落各處Model出現在VC的很多地方,導致測試變得不可能,前一部作品給了我很多提示和想法,我也一直不斷的總結,MVC的輕量和構建一直是我在關注的話題,自己的作品2.0已經在製作中,我的新作品裏,VC暫時都在200行左右(基本完成的),所以這這裏把這些的一些想法寫出來,和大家分享,文章很基礎

內容有很多也來源於iOS社區的優秀開發者,我在他們的文章裏學到了很多

本文章屬於原創,轉載請註明出處

參考文章:王巍的博客
還有很多零散的博客,我記不太清了,但絕大多數的學習都來源於這裏


一個錯誤的例子

Model

先創建一個代辦清單的VC,代碼很簡單,創建一個Model,然後使用CollectionView對其進行展示,然後第一個Section只有一個Cell,用來增加新的Cell

Model

var dataManagers: [LNTodoItem] = [
    LNTodoItem(name: "Buy Some Eggs"),
    LNTodoItem(name: "Buy Some Fruits"),
    LNTodoItem(name: "Buy Some Food"),
    LNTodoItem(name: "Go Shop")
]

struct LNTodoItem
{
    let id: UUID
    var name: String
    var date: Date?
    
    init(name: String) {
        self.init()
        self.name = name
    }
    
    init() {
        self.id = UUID()
        self.name = ""
    }
}

先創建一個Model,使用一個已定的[String]來模擬數據庫,值得一提的是,這裏應該提供一個萬能init方法,使別的init方法都能通過它來進行初始化,別的沒什麼可以提的,僅僅是一個簡單的Model

View 和 ViewController

下面是一個很常規的VC,使用使用線程來加載數據並讓數據顯示在Cell上,然後有一個點擊添加的按鈕在第一個Section,當Cell到了一定數量時,就不能再添加數據了,這裏需要設置AddButton.isEnable = false,代碼有點多,但很簡單


final class LNViewController: UIViewController
{
    private var items = [LNTodoItem]()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        DispatchQueue.main.async {
            self.items = dataManagers
            self.collectionView.reloadData()
        }
    }
    
    @objc func tapToAdd(_ recognizer: UITapGestureRecognizer) {
        let indexPath = IndexPath(item: 0, section: LNSection.add.rawValue)
        guard let addCell = collectionView.cellForItem(at: indexPath) as? LNCustomNormalCollectionViewCell else {
            return
        }
        if items.count < 10 {
            let item = LNTodoItem()
            addCell.label.text = "\(items.count)"
            items.insert(item, at: 0)
            collectionView.reloadData()
        } else {
            addCell.label.text = ""
        }
    }
    
	@objc func tapToDelete(_ sender: UIButton) {
        guard items.count > 0 else { return }
        collectionView.performBatchUpdates({
            let item = 0
            let indexPath = IndexPath(item: item, section: 1)
            items.removeFirst()
            collectionView.deleteItems(at: [indexPath])
        })
        if items.count < 10 {
            addButton.isEnabled = true
        }
    }
}

extension LNViewController: UICollectionViewDelegate, UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return items.count
    }
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 2
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    if indexPath.section = 0 {
    	let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "LNTitleCollectionViewCell", for: indexPath)
            
        return cell
    } else {
    	let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "LNCustomNormalCollectionViewCell", for: indexPath) as! LNCustomNormalCollectionViewCell 
    	let item = indexPath.item
    	cell.label.text = items[item].name
        let date = items[item]
        let day = date.day
        let month = date.month
        let year = date.year
        let text = "\(year)/\(month)/\(day))"
        cell.dateLabel.text = text
        
        return cell
    	}
    }
}

class LNCustomNormalCollectionViewCell: UICollectionViewCell {
    var label = UILabel()
    var dateLabel = UILabel()
}

上面的MVC的第一個問題

先拋開Model和View的更新不談,來看看上面的MVC的問題,之所以是MVC的問題,是因爲,現在這三個都會有問題,如果你是新手,也許你會問,這不是每天都在寫的代碼嗎,能有什麼問題呢

  1. Model: Model的行爲被暴露在外面,很多地方要對其直接進行操作,VC要直接調用系統方法來刪除Item,會產生很多不必要的方法,比如你要先查找Model裏有沒有一個item,要使用,firstIndex(of:),然後使用Optional Binding解除綁定,這也許只有幾行,但當很多地方都有類似的操作,就會有許多行
  2. ViewController:先說ViewController的一部分問題,Section,當需要給titleSection和todo的Section之間添加一個新的Section,這裏有點繞,總而言之,就是添加新的的時候,需要把DataSource中的每個地方都改一遍,當這樣的操作多了起來,先不說團隊合作,你自己也會很麻煩
  3. View:DataSource不該知道Cell的實現,這裏的Cell的實現應該內聯到自己的View當中去
  • 第一個問題的解決方案
  1. 在前面的文章裏,我有提到這樣的方法,但在這裏寫出來,代入場景會更容易理解,也更細緻的解釋一下,定義一個儲存類,在這裏單獨提取的Model將會易於測試,在網絡和持久化的時候,可以有更多的選擇方案,Model的各種操作都不需要寫在Controller裏,因爲這樣會將各種操作分離在各個地方,ViewController將會變得難以測試
  2. 在上面Model更多的是扮演了一個屬性的角色(簡單的儲存了變量),只不過是一個儲存了幾個變量的屬性而已,沒有額外的操作,在我反思第一次做作品的時候,我記得很清楚,那時候我的Model就無事可做,導致Controller裏的代碼越來越多
  3. 在這裏有兩個小提示,是我的個人見解,你也可以採納但不能保證正確性,items應該使用private,存儲類裏的Model不需要讓外界知道,只通過方法來進行操作即可
  4. 並定義一個private init方法,來讓這個存儲類除了Share以外無法從外部創建
  5. class應該被定義爲final,除非你的類創建來就是爲了讓別的類繼承的
extension LNTodoItem: Equatable {
    static func ==(lhs: LNTodoItem, rhs: LNTodoItem) -> Bool {
        return lhs.id == rhs.id
    }
}

public final class LNStore
{
    static let shared = LNStore()
    
    private var items = [LNTodoItem]()
    
    private init() {  }
    
    var count: Int {
        return items.count
    }
    
    func append(_ item: LNTodoItem) {
        items.append(item)
    }
    
    func insertItem(_ item: LNTodoItem, at index: Int) {
        items.insert(item, at: index)
    }
    
    func removeItem(at index: Int) {
        items.remove(at: index)
    }
    
    func removeItem(_ item: LNTodoItem) {
        guard let index = items.firstIndex(of: item) else { return }
        removeItem(at: index)
    }
}

  • 第二個問題的解決方案
final class LNViewController: UIViewController
{
	// ViewController
    private enum LNSection: Int {
        case todos, add
        case max
    }

	// UICollectionViewDataSource
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return LNSection.max.rawValue
    }

	func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        guard let currentSection = LNSection(rawValue: section) else {
            fatalError("Invaild Section")
        }
        switch currentSection {
        case .add: return 1
        case .todos: return LNStore.shared.count
        case .max: fatalError()
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let section = LNSection(rawValue: indexPath.section) else {
            fatalError("Invaild Section")
        }
        switch section {
        case .add:
           // 當前Cell的代碼,前面寫過
        case .todos:
            // 當前Cell的代碼
        case .max:
            fatalError("Invaild Section")
        }
    }


}
  1. 在這裏,使用一個Enumeration來保存Section的狀態,這樣DataSource的Section操作就很容易實現和維護了,在需要增加新的Section時,只要簡單的在這個Enum裏修改即可
  • 第三個問題的解決方法
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "LNCustomNormalCollectionViewCell", for: indexPath) as! LNCustomNormalCollectionViewCell
let item = items[indexPath.item]
cell.configureCell(with: item)
            
return cell

extension LNCustomNormalCollectionViewCell {
    func configureCell(with item: LNTodoItem) {
        label.text = item.name
        let date = item.date
        let day = date.day
        let month = date.month
        let year = date.year
        let text = "\(year)/\(month)/\(day))"
        dateLabel.text = text
    }


}
  • 這裏的日期處理是假設使用了開源框架SwiftDate,直接這樣使用Date會報錯

改進後的MVC的問題

上述的一些是對MVC的三項書寫上的錯誤,並沒有涉及多少關於MVC的職能的問題,但已經改進了很多了,當前還有一個大問題,即UI散落各處

  1. DeleteButton和AddButton同時控制着AddButton的狀態,UI的操作散落在各處,在只有兩個按鈕的時候,就已經這樣難以控制,有很多時候VC都要同時維護很多UI事件,當事件一多,測試變得幾乎不可能
  2. UI的改變直接影響着Model的改變,和自身的改變
  3. 通常的MVC的圖片都是三角形,但我認爲MVC的通信應該是直線
  4. UI事件 -> Controller根據事件改變Model -> Model的改變通知Controller -> Controller根據Model的改變更新UI -> 繼續等待事件
  • Model的改變,UI事件發生後Controller通知Model改變,Model根據所改變的情況反饋給Controller,使用Notification
public final class LNStore
{
    private var items = [LNTodoItem]() {
        didSet {
            let behavior = LNStore.diff(original: oldValue, now: items)
            NotificationCenter.default.post(
                name: .ln_todoStoreDidChangeBehavior,
                object: self,
                typedUserInfo: [.todoStoreDidChangedChangeBehaviorKey : behavior]
            )
        }
    }
    
    enum LNChangeBehavior {
        case add([Int])
        case remove([Int])
        case reload
    }
    
    static func diff(original: [LNTodoItem], now: [LNTodoItem]) -> LNChangeBehavior {
        let originalSet = Set(original)
        let nowSet = Set(now)
        
        if originalSet.isSubset(of: nowSet) {
            let added = nowSet.subtracting(originalSet)
            let indexes = added.compactMap { now.firstIndex(of: $0) }
            return .add(indexes)
        } else if nowSet.isSubset(of: originalSet) {
            let removed = originalSet.subtracting(nowSet)
            let indexes = removed.compactMap { original.firstIndex(of: $0) }
            return .remove(indexes)
        } else {
            return .reload
        }
    }
}
  • UI操作,通過Controller操作告知Model數據改變了,從下面代碼可以看到,對AddButton的操作不再散落在各處,數據也變成的單向流動的,當Model改變,通知Controller,隨即Controller就根據Model的改變情況對View進行更新,然後進入等待狀態,等待事件的發生
final class LNViewController: UIViewController
{
    override func viewDidLoad() {
        super.viewDidLoad()
        NotificationCenter.default.addObserver(self, selector: #selector(todoItemsDidChange), name: .ln_todoStoreDidChangeBehavior, object: nil)
    }
    
    private func syncTableView(for behavior: LNStore.LNChangeBehavior) {
        switch behavior {
        case .add(let indexes):
            let indexPathes = indexes.map { IndexPath(row: $0, section: 0) }
            collectionView.insertItems(at: indexPathes)
        case .remove(let indexes):
            let indexPathes = indexes.map { IndexPath(row: $0, section: 0) }
            collectionView.deleteItems(at: indexPathes)
        case .reload:
            collectionView.reloadData()
        }
    }
    
    @objc func todoItemsDidChange(_ notification: Notification) {
        let behavior = notification.getUserInfo(for: .todoStoreDidChangedChangeBehaviorKey)
        syncTableView(for: behavior)
        addButton.isEnabled = LNStore.shared.count > 10
    }
    
    @objc func tapToAdd(_ sender: UIButton) {
        let shared = LNStore.shared
        sender.setTitle("\(shared.count)", for: .normal)
        shared.append(LNTodoItem())
    }
    
    @objc func tapToDelete(_ sender: UIButton) {
        LNStore.shared.removeItem(at: 0)
    }
}

總結

一定要使用單向數據流動的MVC,經典的MVC圖意在表達各個不同區域如何通信,而不是通信狀態,MVC之間一定要各盡其職才能使代碼更容易測試,更容易維護

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章