前言
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的问题,是因为,现在这三个都会有问题,如果你是新手,也许你会问,这不是每天都在写的代码吗,能有什么问题呢
- Model: Model的行为被暴露在外面,很多地方要对其直接进行操作,VC要直接调用系统方法来删除Item,会产生很多不必要的方法,比如你要先查找Model里有没有一个item,要使用,firstIndex(of:),然后使用Optional Binding解除绑定,这也许只有几行,但当很多地方都有类似的操作,就会有许多行
- ViewController:先说ViewController的一部分问题,Section,当需要给titleSection和todo的Section之间添加一个新的Section,这里有点绕,总而言之,就是添加新的的时候,需要把DataSource中的每个地方都改一遍,当这样的操作多了起来,先不说团队合作,你自己也会很麻烦
- View:DataSource不该知道Cell的实现,这里的Cell的实现应该内联到自己的View当中去
- 第一个问题的解决方案
- 在前面的文章里,我有提到这样的方法,但在这里写出来,代入场景会更容易理解,也更细致的解释一下,定义一个储存类,在这里单独提取的Model将会易于测试,在网络和持久化的时候,可以有更多的选择方案,Model的各种操作都不需要写在Controller里,因为这样会将各种操作分离在各个地方,ViewController将会变得难以测试
- 在上面Model更多的是扮演了一个属性的角色(简单的储存了变量),只不过是一个储存了几个变量的属性而已,没有额外的操作,在我反思第一次做作品的时候,我记得很清楚,那时候我的Model就无事可做,导致Controller里的代码越来越多
- 在这里有两个小提示,是我的个人见解,你也可以采纳但不能保证正确性,items应该使用private,存储类里的Model不需要让外界知道,只通过方法来进行操作即可
- 并定义一个private init方法,来让这个存储类除了Share以外无法从外部创建
- 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")
}
}
}
- 在这里,使用一个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散落各处
- DeleteButton和AddButton同时控制着AddButton的状态,UI的操作散落在各处,在只有两个按钮的时候,就已经这样难以控制,有很多时候VC都要同时维护很多UI事件,当事件一多,测试变得几乎不可能
- UI的改变直接影响着Model的改变,和自身的改变
- 通常的MVC的图片都是三角形,但我认为MVC的通信应该是直线
- 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)
}
}