构架(一)—— 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之间一定要各尽其职才能使代码更容易测试,更容易维护

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