Table View 太複雜?利用 MVVM 和 Protocol 就可以為它重構瘦身!

Table View 太複雜?利用 MVVM 和 Protocol 就可以為它重構瘦身!
Table View 太複雜?利用 MVVM 和 Protocol 就可以為它重構瘦身!
In:

UITableView 或者 UICollectionView 絕對是 iOS 工程師一定會使用到的 UI 元件,甚至可以說大多數 app 的 UI 都是圍繞著 tableView 來設計。隨著手機介面體驗的進步,tableView 也變得越來越複雜,尤其是像 news feed、photo wall 等,一個 cell 塞滿各種功能的設計,近幾年來已經成為手機開發的基本款。基本上一張 tableView 裡就有很多種 cell,如果把所有的 code 都塞在 UITableViewController 或任何 viewController 裡面,一個檔案幾千行 code 應該跑不掉了,這樣要修改或新增 code 都相當困難。如果你曾經參與過 app 整個開發流程,就一定會發現修改規格已經成為工作上不可或缺的一部分。一開始新增一個 cell 類型可能只需要半天,隨著 tableView 越來越肥大,新增一個 cell 類型可能需要一週,還有可能會因為新增一個功能而把其它的功能搞壞!

在這篇文章,我們會利用 MVVM pattern,加上一點 protocol 的技巧,來簡化 datasource 的工作,把 UI 跟邏輯解耦合,並且最大化這個 tableView 模組的擴充性。

你將會學到:

  1. 如何把 dataSource 的 UI 邏輯拆分出去
  2. 如何用 MVVM 技巧簡化 tableView 的撰寫
  3. 如何把商業邏輯跟 UI 清楚地分開

讓我們來開始吧!🎬

Newsfeed App

今天我們要來做一個 newsfeed app,這個 app 一打開就是一個牆 (Wall),牆上總共有兩種 feed,一種是照片的 feed,一種是其它使用者的 feed。介面如下:

newsfeed

我們來看看兩種 feed:

  1. MemberCell
    針對 member 的 cell,我們希望讓使用者可以在 cell 上面直接 follow 其它人,所以這個 cell 會有以下這幾種狀態:

    • 預設狀態 (normal)
      state_normal
    • 讀取中 (loading)
      state_loading
    • 成功 follow (checked)
      state_checked
  2. PhotoCell
    這個 cell 會顯示照片跟它的標題與介紹,使用者點擊了這個 cell 就會打開照片的詳細資訊

    photocell

而從 server 回傳的資料也會有兩種類別,在這個 app 裡 ,我們分別用 Member 跟 Photo 這兩種 model 來代表。

你可以在我的 Github 上找到這個 app 完整的 code:GitHub – koromiko/TheGreatWall: Using MVVM to tackle complicated feed view

要注意的是,Github 上的 code 多實作了很多不同東西,如果有興趣的話可以看 source code 喔。

來開始吧!

一般來說,針對這種狀況,我們會新增一個 FeedListViewController,並且將它設定成 tableView 的 dataSource,這個 viewController 大概會長這個樣子:

 

var feeds: [Feed] = [Feed]()

var tableView: UITableView

func viewDidLoad() {
        self.service.fetchFeeds { [weak self] feeds in 
            self?.feeds = feeds
            self?.tableView.reloadData()
        }
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return feeds.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let feed = feeds[indexPath.row]

    if let memberFeed = feed as? Member, let cell = tableView.dequeueReusableCell(withIdentifier: MemberCell.cellIdentifier(), for: indexPath) as? MemberCell {
        cell.nameLabel.text = memberFeed.name
        cell.profileImageView.image = memberFeed.image
        cell.addBtn.isSelected = memberFeed.isFollwing
        return cell
    } else if let photoFeed = feed as? Photo, let cell = tableView.dequeueReusableCell(withIdentifier: PhotoCell.cellIdentifier(), for: indexPath) as? PhotoCell {
        cell.titleLabel.text = photoFeed.captial
        cell.descriptionLabel.text = photoFeed.description
        cell.coverImageView.image = photoFeed.image
        return cell
    } else {
            fatalError("Unhandled feed \(feed)")
    }
}

從上面這段 code 可以看得出有兩個 if-else 區塊,分別處理不一樣的 feed 類型。利用 feeds 這個 array,我們讓 tableView 的 row number 等於這個 array 的長度,然後取出某個 row 對應的 feed 來設定 cell UI,非常簡單!

搞定了基本 UI 顯示後,我們要來幫 MemberCell 加上讀取中的狀態。為了要讓 cell 能夠記錄 loading 的狀態,我們需要用這樣的 array 來記錄 cell 是不是正在讀取中:

cellLoadingStatus: [Bool]

然後在上面的 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell 裡更新 cell 的 loading status:

if cellLoadingStatus[indexPath.row] {
        cell.loadingIndicator.startAnimation()
} else {
        cell.loadingIndicator.stopAnimation()
}

看起來沒甚麼問題,但這樣子寫有甚麼壞處呢?最大的問題,就在於如果我們要新增 feed 的種類、或是增加 cell 上的互動,都需要在這個 dataSource 裡面多寫非常多 code。比方說,如果我們需要增加照片讀取中的狀態,就需要再多加一個 array,這樣很不方便。仔細分析,這個其貌不揚的 dataSource,總共負責了以下這些事情:

  1. 設定 cell 的 UI code,如 nameLabel、imageView 等
  2. 記錄 cell 的狀態,如 loading、checked 等
  3. 取得與處理 Feed 的資料

我們每新增一個 cell 類別、修改互動,就需要在 dataSource 內做上文對應的改變,但是這些新增和修改其實都跟 cell 本身比較有關,我們希望 dataSource 像通用的插座,可以接收許多不同類型的 cell,而不需要一直修改插座的規格。就好像我們不會因為換一隻滑鼠,就把整個 USB 接頭拆掉重裝一樣。下圖是原本的模式的示意圖:

tableview-model

這是一個全知全能的 ViewController。

理想中,一個比較好的管理方式,應該是要簡化 ViewController 的工作量,把權責分給其它該負責的人,並且盡可能在擴充功能時,不需要修改既存物件的實作,而是像組積木一樣,把元件一個一個組上去就好。

就讓我們先從小地方開始,一步一步地改造我們的 FeedList 模組吧!🔨

分拆 cell UI 設定

回到剛剛的 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell,我們其實可以把 cell UI 的設定,直接讓 Cell 自己去處理,也就是說 dataSource 不需要知道 cell 裡面有那些 label、imageView。

以 MemberCell 為例,它就會像這樣:

// In MemberCell.swift
func setup(name: String, profileImage: UIImage, isFollowing: Bool, isLoading: Bool) {
        self.nameLabel.text = name
        self.profileImageView.image = profileImage
        self.addBtn.isSelected = isFollowing
        if isLoading { 
            self.loadingIndicator.startAnimation()
        } else {
            self.loadingIndicator.stopAnimation()
        }
}

而 dataSource 就可以簡化成這樣:

if let memberFeed = feed as? Member, let cell = tableView.dequeueReusableCell(withIdentifier: MemberCell.cellIdentifier(), for: indexPath) as? MemberCell {
    cell.setup(name: memberFeed.name, 
                    profileImage: memberFeed.image,
                    isFollowing: memberFeed.isFollowing,
                    isLoading: self.loadingStatus[indexPath.row])
    return cell
} else if let photoFeed = feed as? Photo, let cell = tableView.dequeueReusableCell(withIdentifier: PhotoCell.cellIdentifier(), for: indexPath) as? PhotoCell {
    cell.setup(title: photoFeed.title, 
                    description: photoFeed.description,
                    image: photoFeed.image)
    return cell
} else {
        fatalError("Unhandled feed \(feed)")
}

這個時候,看到一個 function 有四個參數的你,是不是有種很痛苦的感覺!在這邊,我們有個技巧可以很好地包裝這些跟 UI 相關的設定:MVVM (Model-View-ViewModel)!還不熟悉 MVVM 的話,可以參考小弟的前作,裡面有完整的 MVVM 觀念和實作範例。簡單的說,MVVM 就是利用一個物件來抽象化 UI 操作的一個技巧,它讓 UI 操作在邏輯端看起來,就只是一堆物件的操作,可以大幅減少在 coding 時的心理負擔,也提升了這個模組的可測試性。

為 Cell 設計 ViewModels

在這邊我們也可以用相同的概念,設計一個 MemberViewModel 與一個 PhotoViewModel,來代表這兩種 Cell 的 UI 與 state。先來看看我們希望透過 MVVM 做到怎樣的架構:

mvvm

利用不同的 ViewModel 代表不同的 Cell,ViewModel 的每一個 property 都代表 UI 上的某個元件。假如我們想要修改 MemberCell 裡的 nameLabel,只要修改 MemberViewModel 物件的 name property 就好。從 dataSource 來看,對 UI 操作就變成了對物件的操作,這樣 code 看起來就會簡潔許多,相當方便呢!

現在來看看實作上的部分,以 MemberViewModel 為例,我們的 MemberViewModel 就像這樣:

class MemberCellViewModel {
    let name: String
    let avatar: UIImage
    let isLoading: Observable
}

而 MemberCell 的 setup function 就會像這樣:

// In MemberCell.swift
func setup(viewModel: MemberViewModel) {
    profileImageView.image = viewModel.image
    nameLabel.text = viewModel.name
        
    viewModel.isLoading.valueChanged { [weak self] (isLoading) in
        if isLoading {
            self?.loadingIndicator.startAnimating()
        } else {
            self?.loadingIndicator.stopAnimating()
        }
    }
}

這段 code 主要負責收到 ViewModel 之後,設定 Cell 上面的 UI 元件。從上面的 code 可以看到,isLoading 是比較特別的變數,因為 isLoading 是一個 UI 初始化之後,還會變化的值,也就是一個 state。所以,我們會用一個 Observer 的 wrapper 來包裝,讓 Cell 可以觀察 isLoading 的變化,並且針對這些變化,設定對應的 UI 元件,這部分我們通常稱為 binding。以上面的 code 來說,當 controller 改變 ViewModel 的 isLoading 值之後,valueChanged 這個 closure 就會被觸發,Cell 就會知道是時候要改變 loadingIndicator 的狀態了。

請注意,Observable 是一個簡單的物件,實作如下:

class Observable {
    var value: T {
        didSet {
            DispatchQueue.main.async {
                self.valueChanged?(self.value)
            }
        }
    }
    var valueChanged: ((T) -> Void)?
}

Value 就是我們想要儲存的值,當 value 被修改之後,就會觸發 valueChanged 這個 closure,達到 binding 效果。詳細介紹可以參考 Solving the binding problem with Swift • Five 這篇文章。當然,在這邊想用不同的 binding 技巧像是 Bond、或 RxSwift 提供的 binding 也是可以的。

還有一點很重要,在 setup 這個 function 裡面,我們 assign 一個 closure 給 valueChanged;也就是說,這個 closure 被儲存在某個 ViewModel 物件裡面,如下圖所示:

model_1

當使用者不斷地往下滾,離開螢幕的 MemberCellA 原本是跟 MemberViewModel 1 綁定的,現在被 reuse 後,拿去給 MemberViewModel 3 使用。但是因為 MemberViewModel 1 的 valueChanged closure 還沒被 release,所以當 MemberViewModel 的 isLoading 值被修改了,就算這時候 MemberCell A 代表的已經是 MemberViewModel 3 而不是 MemberViewModel 1,MemberCell A 還是一樣會做出反應。

所以在設定綁定的同時,也要記得在 reuse 時把 closure 清空:

override func prepareForReuse() {
    super.prepareForReuse()
    viewModel?.isLoading.valueChanged = nil
}

這樣就可以確保 cell 只針對正確的 ViewModel 做反應,而不會因為 reuse 造成錯誤的更新。

回到 tableView 的 dataSource 上,所以我們的 dataSource,就可以再進一步簡化成:

let viewModel = viewModels[indexPath.row]
if let memberViewModel = viewModel as? MemberViewModel, let cell = tableView.dequeueReusableCell(withIdentifier: MemberCell.cellIdentifier(), for: indexPath) as? MemberCell {
    cell.setup(viewModel: memberViewModel)
    return cell
} else if let photoViewModel = viewModel as? PhotoViewModel, let cell = tableView.dequeueReusableCell(withIdentifier: PhotoCell.cellIdentifier(), for: indexPath) as? PhotoCell {
    cell.setup(viewModel: photoViewModel)
    return cell
} else {
        fatalError("Unhandled feed \(feed)")
}

未來在 dataSource 一從 server 收到 feeds 之後,馬上就把 feeds 轉成 viewModels,讓 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell 去取用這些 viewModels,而不是直接取用 feeds。這樣就可以把所有 state 都劃分給 ViewModel 去記錄,未來要新增各種 state 如 isButtonHidden 或 isButtonEnabled 就會非常容易。

不過這個 dataSource 還有改進的空間,因為看起來 setup(viewModel:) 這個 function,是所有 cell 都一樣的。所以,我們可以在這裡利用 Protocol 來減少重覆、統一接口。

利用 Protocol 統一 Interface

只要是能夠透過 ViewModel 設定的 Cell,都需要有一個 setup(viewModel:),所以我們新增一個 Protocol 叫 CellConfigurable:

protocol CellConfiguraable {
    func setup(viewModel: RowViewModel) // Provide a generic function
}

這個 RowViewModel 是另一個 protocol,指的是某個可以拿來代表 cell 的 View Model,像是我們原本的 MemberViewModel 跟 PhotoViewModel:

protocol RowViewModel {}

// make view models conform the RowViewModel protocol
class MemberViewModel: RowViewModel {...}
class PhotoViewModel: RowViewModel {...}

這樣一來,我們的 dataSource 就可以更進一步簡化成:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let rowViewModel = self.viewModels[indexPath.row]

    let cell = tableView.dequeueReusableCell(withIdentifier: self.cellIdentifier(for: rowViewModel), for: indexPath)

    if let cell = cell as? CellConfiguraable {
        cell.setup(viewModel: rowViewModel)
    }
    return cell
}

/// Map the view model with the cell identifier
private func cellIdentifier(for viewModel: RowViewModel) {
    switch viewModel {
    case is PhotoCellViewModel:
        return PhotoCell.cellIdentifier()
    case is MemberCellViewModel:
        return MemberCell.cellIdentifier()
    default:
        fatalError("Unexpected view model type: \(viewModel)")
    }
}

未來要擴充 cell type 的話,只需要修改 cellIdentifier(for:) 這個 function 就好,為我們在擴充功能時,最小化了在這個 dataSource 中要修改的部分。從另外一個角度來看,這些多樣的 cell 對 dataSource 來說,就只是一個一個的擴充模組,新增一個 cell 類別就像加上一個模組一樣,只要模組準備好接口,就隨時都可以接上,不需要再針對這個 dataSource 做大量修改了。

利用 ViewModel 傳遞使用者互動

除了上面的應用之外,protocol 也可以簡化其它 cell 的操作。現在讓我們再新增一個 protocol:

protocol ViewModelPressible {
    var cellPressed: (()->Void)? { get set }
}

class PhotoCellViewModel: RowViewModel, ViewModelPressible {
    let title: String
    let desc: String
    var image: AsyncImage

    var cellPressed: (() -> Void)? // Conform the ViewModelPressible protocol
}

ViewModelPressible 是一個 protocol,代表某個 ViewModel 能夠傳遞 Cell 被點擊這個動作。有了這個 protocol,我們就可以在 tableView 的 delegate 做以下的設定:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let viewModel = self.viewModels[indexPath.row]
    if let viewModel = viewModel as? ViewModelPressible {
        rowViewModel.cellPressed?()
    }
}

現在所有的 didSelectRow 事件,都會被傳遞到 ViewModel 的 cellPressed 這個 closure 上。在產生 ViewModel 的時候,我們就可以用這樣的方式來處理使用者點擊:

let photoCellViewModel = PhotoCellViewModel()
photoCellViewModel.cellPressed = {
                    print("Open a photo viewer!")
                }

這樣子,使用者互動也可以被抽象化成為物件的一部分。之後我們在處理點擊時,就可以把跟 UI 相關的部分完全抽離,只專注在邏輯的部分。

處理完了 cell 的重構,我們稍微把鏡頭拉遠一點,回頭看一下我們的 FeedListViewController。

久違的 Controller

在移除了 cell 設定的 code 之後,目前我們的 FeedListViewController 主要的任務還剩以下幾種:

  1. 從 server 抓取資料
  2. 產生 viewModels
  3. 當成 tableView 的 dataSource 與 delegate

可以看出來這裡面有不少商業邏輯,為了讓權責分得更清楚,我們希望把 FeedListViewController 當成一個純粹的 View,把其它的邏輯部分,都拉出來給真正的 Controller 去處理。最後的架構圖會如下:

FeedListViewController

最後的 FeedListViewController,管理的就是自己本身 View 的部分、以及當作 tableView 的 dataSource;但實際上設定 UI 元件的工作已經交給了 Cell,而將 Feed 轉換成可供 UI 呈現的資料、還有跟 Service 溝通等等的商業邏輯,則是交給了 Controller。

以下是我們新增 Controller 的 code:

// FeedListController.swift
let viewModels = Observable<[RowViewModel]>(value: [])

func start() {
        service.fetchFeed { [weak self] feeds in
            self?.buildViewModels(feeds: feeds)
        }
}

func buildViewModels(feeds: [Feed]) {
        var viewModels = [RowViewModel]()
        for feed in feeds {
            if let feed = feed as? Member {
                viewModels.append( MemberViewModel.from(feed) )
            } else if let feed = feed as? Photo {
                var photoViewModel = PhotoViewModel.from(feed)
                photoViewModel.cellPressed = { [weak self] in  
                    self?.handleCellPressed(feed)
                }
                viewModels.append(photoViewModel)
            }
        }
        self.viewModels.value = viewModels
}

func handleCellPressed(_ feed: Feed) {
        // Send analytics, fetch detail data, etc
        // Open detail photo view
}

這邊可以看到,我們完全不會處理 UI,只專注在邏輯部分。另外也因為 MVVM 的關係,我們可以用非常直觀的方式,建立一個 array,把代表 cell 的 viewModel 一個一個放進 array,與此同時我們甚至不需要知道 cell 有那些 UI 元件、分別叫甚麼名字。另外,我們也把 cell 點擊的邏輯分出來,另外用一個 function 去處理,透過 closure 會複制區域變數的特性,把對應的 feed 當成參數傳遞到 handleCellPressed(_ feed:) 裡,這樣一來,就算 cell 上面有很多按鈕或其它互動,我們也都可以透過這樣的方式去處理,成功把這個模組的擴充性達到最大。

另一方面,我們的 FeedListViewController 就變成這樣:

var viewModels: Observable<[RowViewModel]> {
        return controller.viewModels
}

override func viewDidLoad() {
        viewModels.valueUpdate { [weak self] (_) in
            self?.tableView.reloadData()
        }
    controller.start()
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return rowViewModels.value.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let rowViewModel = self.rowViewModels.value[indexPath.row]
    let cell = tableView.dequeueReusableCell(withIdentifier: controller.cellIdentifier(for: rowViewModel), for: indexPath)
    if let cell = cell as? CellConfiguraable {
        cell.setup(viewModel: rowViewModel)
    }
    return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if let rowViewModel = rowViewModels.value[indexPath.row] as? ViewModelPressible {
        rowViewModel.cellPressed?()
    }
}

可以看得出來,這個 ViewController 在未來新增 cell 類型,或者修改 cell 元素的時候,都不太需要去動到它,我們也就可以更放心地放置與頁面相關的 UI code 如動畫、轉場等。

總結

在這篇文章中,我們分享了如何把一個複雜的 ViewController,一步一步地瘦身,變成明確分工、擴充性佳的模組。還記得我們做了那些處理嗎?針對這個 ViewController,我們做了:

  1. 把設定 cell UI 的工作交給 cell 去執行
  2. 利用 MVVM 抽象化 UI 的設定與互動
  3. 使用 Protocol 單純化 dataSource 的工作
  4. 把商業邏輯從 ViewController 中分開給 Controller 去處理

Massive View Controller 已經是一個所有 iOS 開發者都知道的 anti-pattern,而許多系統架構也因此被提出來,就是為了要解決這個神秘難解的問題。但不管是甚麼架構,最後的本質其實都是一樣的,就是希望模組容易被擴充、容易被測試、有著明確的權責等。也可以說這些架構提供了設計精良的規則,讓你的 code 可以滿足 SOLID 的要求 SOLID – Wikipedia。當然這篇文章分享的方法絕對不是完美的架構,像是 reuse 的處理、大量使用 closure 造成的記憶體負擔(雖然不是很顯著)、還有尚未定義的 view controller transition 等等,都是需要被解決的問題,但是我們可以從這些小地方開始,慢慢加強軟體的架構,一步一步地讓你的 code 更紮實更漂亮!

參考資料

這幾篇文章都寫得非常好,如果看完本文還覺得不過癮,可以看看下面這幾篇文章:

作者
Huang ShihTing
I’m ShihTing Huang(黃士庭). I brew iOS app, front-end web app, and of course, coffee and beer!
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。