前言
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)
}
}