我做了一個 Option Sheet!

謝宛軒 Sherry Hsieh
Dcard Tech Blog
Published in
31 min readNov 25, 2022

摳尼吉哇!大家好!我是 Junior iOS 工程師 Sherry,平時除了在 delivery team 內開發、維護 Dcard App 功能外,每兩個月會有約一週的時間回到 function team 自己運用,做對團隊有益處的事。(想更了解 Dcard iOS Engineer 的日常,可以右轉 Dcard 的 iOS 團隊在做什麼?)

本篇文章要分享的是我加入 Dcard 後的第一個 function team week 專案 — 重構 Option Sheet 元件的設計想法和實作架構。

Introduction 🏃‍♀️

在 Dcard App 中到處都可以看到如下圖所示的選項列表(Option Sheet)。

在 Dcard App 中隨處可見的選項列表們

隨著開發需求增加,原有架構已不夠滿足且難以復用,因此決定進行重構,希望幫助團隊未來開發相關功能時省下更多時間,畢竟程式寫得再快,都比不上直接用來得快呀!

Our Goal 🥅

此次專案的目標是讓 Option Sheet 能夠

  • 客製化選項樣式,並在使用者點選選項後,指定一個 closure 來處理動作
  • 指定不同資料來源,可能是固定的靜態資料,也可能是需要從 API 獲取的動態資料
  • 處理 pagination 類型內容,在往下滑動時主動獲取下一批資料
  • 有載入中、錯誤重試等不同狀態的畫面
  • 支援選項搜尋功能

然而,作為一個共用元件,我們不只希望它「能用」,還要「好用」。因此除了滿足上面盤點的目標外,還有一個更遠大的終極目標 — 設計一個易於理解和重複被使用的元件

在開始前,我們試著列出一些方向來幫助達成這個目標:

  • 拆分資料提供和 UI 呈現、抽出重複邏輯,讓其他人能在最小的改動下,達成想要的修改
  • 使用好的命名或關鍵字,讓其他人更清楚理解、也更容易記得使用方式,降低學習曲線
  • 考慮日後新增類似功能的實作,保有調整和擴充彈性
  • 避免隱晦的邏輯判斷,舉例:若 function 和 property 之間的使用是有順序性的,就可能造成預期外的錯誤

Data Flow & Design Structure ✏️

想像一下若將所有邏輯塞在一個 ViewController 中,這個 ViewController 會變得非常複雜、難以閱讀,改動起來也特別麻煩。為了讓元件擁有更多彈性來滿足各種需求,我們依照功能拆分出 Tool 和 Data Provider 兩個部分。

Tool 表示一個能夠提供 keyword 的元件,如搜尋框。Data Provider 則專注在資料處理,負責將資料轉成選項內容或錯誤資訊。至於資料改變後 UI 需要做什麼更動,就交給 ViewController。

整體流程如下:

Data Flow Diagram

當 Tool 的 keyword 發生改變時,會通知 ViewController 向 Data Provider 請求資料,Data Provider 在獲取資料成功後,會通知 ViewController 渲染畫面,如此一來,就成功將資料提供和 UI 拆開了!

這樣明確分工的好處是每個部分都互相獨立,可以被單獨抽換。如果今天天外飛來一個「將選項列表的 TableView 樣式改成 CollectionView 樣式」的需求,我可以快速地抽換 ViewController,而不需要動到另外兩個部分來完成這個需求。

Implementation 👩‍💻

在正式進入實作部分前,先用一張圖來幫助更好的理解整個元件架構。

Option Sheet 元件整體設計架構

大概了解資料流和架構後,就來看看實作吧!

嘗試設計 Option 物件來描述一個選項要如何被呈現

我們先在 ViewController 中放入一個 TableView,用來顯示列表。

因為觀察到大部分的選項樣式都非常類似,決定設計一個帶預設樣式的 default cell,提供一個較方便的使用方式,只需直接給定 title、icon 等資料,不需要每次都實作一個 cell。但同時保留使用客製化 cell 的空間。

最直覺的方式就是將可能需要的資訊都存在 Option 物件中,並在 ViewController cellForRowAt 中判斷要使用哪個 cell。

public struct Option {
public var title: String? // default cell 標題
public var icon: UIImage? // default cell 圖示
public var action: (@MainActor () -> Void)? // 點擊選項時觸發的動作
public var customCell: UITableViewCell? // 客製化 cell
}
public class ViewController: UIViewController {
...
open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let option = option(at: indexPath) // 取得 option

// 有指定 customCell 時使用 custom cell,若無則使用 default cell
if let customCell = option.customCell {
return customCell
} else {
let defaultCell = tableView.dequeueReusableCell(withIdentifier: DefalutCell.cellIdentifier, for: indexPath) as! DefalutCell
defaultCell.titileLabel.text = option.title
defaultCell.iconImageView.image = option.icon
return defaultCell
}
}

open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let option = option(at: indexPath)
option.action?()
}
}

但是,當考慮到實際使用狀況和未來擴充實作,就會發現上面程式碼隱藏了一些問題。

首先,並不是每個開發者都會先詳細了解過元件背後的實作。當他想要使用 default cell,卻不小心設定了 custom cell,結果就可能與他預期不同。

再者,未來想要提供另一種預設樣式的 cell 時,我們只能繼續在 Option 上開更多 property 來儲存資訊。可以想像隨著時間和需求增加,Option 會變得冗長又雜亂,且很難直接看出每個 property 對應的用途和邏輯關係,ViewController cellForRowAt 的判斷也會變得混亂又複雜。

為了減少以上會讓人困惑的情形,可以適當使用 enum 幫助做內容的分類。

新增一個 CellStyle enum,根據支援的樣式分成 使用 default cell 的 .classic 和 使用 custom cell 的 .custom,並新增一個 OptionData struct 將 default cell 所需要的資訊包起來。

如此一來,便可以很簡單的根據 cellStyle 來決定要使用哪個 cell。

public struct OptionData {
public var title: String
public var icon: UIImag?
}

public struct Option {
public enum CellStyle {
case classic(optionData: OptionData) // 使用 default cell
case custom(customCell: UITableViewCell) // 使用 custom cell
}

public private(set) var cellStyle: CellStyle
public var action: (@MainActor () -> Void)?
}
public class ViewController: UIViewController {
...
open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let option = option(at: indexPath)

// 根據 cellStyle 決定要使用哪個 cell
switch option.cellStyle {
case let .classic(optionData):
let defaultCell = tableView.dequeueReusableCell(withIdentifier: DefalutCell.cellIdentifier, for: indexPath) as! DefalutCell
defaultCell.titileLabel.text = optionData.title
defaultCell.iconImageView.image = optionData.icon
return cell
case let .custom(customCell):
return customCell
}
}
}

另外,還可以利用 constructor overloading 提供更直觀的使用方法,讓使用的人更清楚知道需要給哪些資料。

public struct Option {
...
public init(title: String, icon: UIImage?, action: Action) {
self.cellStyle = .classic(OptionData(title: title, icon: icon))
self.action = action
}

public init(customCell: UITabelViewCell) {
self.cellStyle = .custom(customCell: customCell)
self.action = action
}
}

改成上述寫法後,不僅可以避免因隱晦邏輯造成的使用錯誤,也讓功能擴充變得容易許多!

在預設樣式 Cell 中新增 Accessory

根據設計需求,有時會需要在 default cell 右邊放一個 switch 或 checkmark,我們將這些放在 cell accessoryView 位置的元件定義為一個 Accessory

實作 Accessory 做法很多,最終使用 protocol 搭配 generic 的原因如下:

  • 使用 protocol 讓每個 Accessory 實作各自需要的東西,集中邏輯、方便管理。
  • 使用 generic 讓使用的人明確知道 accessory view 是什麼,實作起來比較方便。
public protocol Accessory {
associatedtype ViewType: UIView
var view: ViewType { get } // Accessory 實際類型
func willPresent(in cell: DefaultCell) // Accessory 即將被加到 DefaultCell 前的動作
}

未來新增新的 accessory 時,只需新增一個實作 Accessory protocol 的 class 即可,而所有關於這個 accessory 的邏輯,都會集中在這個 class 中。

新增看看一個 UISwitch 樣式的 accessory:

public class SwitchAccessory: Accessory {
public typealias ViewType = UISwitch
public private(set) view = UISwitch()

private let setupImp: ((SwitchAccessory) -> Void)?
private let switchValueChangedImp: ((Bool) -> Void)?

public init(setup: ((SwitchAccessory) -> Void)?, switchValueChanged: ((Bool) -> Void)?) {
self.setupImp = setup
self.switchValueChangedImp = switchValueChanged
view.addTarget(self, action: #selector(handleSwitchValueChange(_:)), for: .valueChanged)
}

public func willPresent(in cell: DefaultCell) {
setupImp?(self)
}

@objc private func handleSwitchValueChange(_ sender: UISwitch) {
switchValueChangedImp?(sender.isOn)
}
}

當我們將 accessory 參數放到 OptionData 時,會得到下面這個錯誤:

這是因為 Accessory protocol 是 abstract type,沒有辦法直接在 OptionData 中使用,需要加上 anyAccessory protocol 當作 existential container 來使用(詳細用法可以參考 SE 335)。

public struct OptionData {
public var title: String
public var icon: UIImag?
public var accessory: (any Accessory)?
}

在 swift 5.6 之前還沒有 any 用法,這裡介紹另一種常見的解決方式 — type erasure,將 Accessory 轉換成 concrete type 的 AnyAccessoryOptionData 使用,實作方法如下:

public struct AnyAccessory {
public let accessory: Any
public let view: UIView
private let willPresentImp: ((DefaultCell) -> Void)?

// 1. init 時用 Generic
public init<Accessory>(accessory: Accessory) where Accessory: Accessory {
self.accessory = accessory // 2. 將原本的實體存起來,考慮之後有人需要把它還原回來的情況
view = accessory.view
willPresentImp = { accessory.willPresent(in: $0) } // 3. 把 willPresent 實作存起來。
}

public func willPresent(in cell: DefaultCell) { // 4. 開一個 willPresent function 執行 willPresent 實作
willPresentImp?(cell)
}
}
public struct OptionData {
public var title: String
public var icon: UIImag?
public var accessory: AnyAccessory
}

解決 type erasure 後,一樣來考慮看看實際使用狀況。

想像一個第一次使用 Option 的人,在輸入到 let option = Option(title: "Foo Title", icon: nil, accessory: ??? 時很可能會不曉得要如何使用而卡住,他必須先去了解 AnyAccessory 的實作以及 code base 中有哪些已經實作好的 accessory。雖然不影響使用,但這就不夠符合前面設定「讓使用的人方便」的目標。

幸運的是,我們可以利用 static factory method 讓 Xcode 提供更完整的 autocomplete。

extension AnyAccessory {
public static func uiswitch(isOn: Bool, valueChanged: ((Bool) -> Void)?) -> AnyAccessory {
.init(accessory: SwitchAccessory(setup: { $0.view.isOn = isOn }, switchValueChanged: valueChanged))
}
}

試著新增 checkmark、tag label 等其他 accessory,並套用相同方法。這樣當我們需要給定 accessory 時,只需要打 . ,Xcode 就會自動列出所有正確回傳 AnyAccessory 型別的 method:

除了使用起來更友善之外,看起來是不是也更乾淨直覺了呢!

負責呈現選項列表和錯誤資訊的 ViewController

有了 Option 後,我們在 ViewController 中定義一個 Content enum 來表示不同的列表顯示樣式。另外,再定義一個 State enum 表示 ViewController 當前狀態,並讓 ViewController 根據狀態更新對應 UI。

public class ViewController: UIViewController {
public enum Content {
case options([Option]) // 單純列表
case sections([Section]) // 選項根據 section title 分成不同 section
}

public enum State {
case loading
case empty
case normal(Content)
case error(NSError?)
}

public var state: State = .empty {
didSet { updateUI(with: state) }
}

...
}

public struct Section {
public var title: String?
public var options: [Option]
}

到目前為止,我們已經完成 ✅ 客製化選項樣式、✅ 載入中和錯誤重試等不同狀態,真是太讚啦!

利用 Data Provider 處理資料

還記得 Data Provider 負責的工作是什麼嗎?它負責處理資料、提供 ContentErrorViewController 顯示。

讓我們先新增一個 DataProvider class 實作最基本的資料處理邏輯。

在下面程式碼中,ViewController 會透過 isFetchEnded()DataProvider 詢問資料是否已經載完,再透過 fetch()DataProvider 請求內容,並在收到 DataProvider 處理好的內容後更新畫面。

// DataProvider 使用 delegate 與 ViewController 溝通。
public protocol DataProviderDelegate {
func dataProvider(_ dataProvider: DataProvider, didLoad content: Content?) // 成功載入內容
func dataProvider(_ dataProvider: DataProvider, didFail error: NSError) // 載入失敗
}

open class DataProvider {
public weak var delegate: DataProviderDelegate?
private var keyword: String?
private var content: Content?

open func isFetchEnded(keyword: Keyword?) -> Bool { // 資料是否已經載完
keyword == self.keyword
}
open func fetch(keyword: Keyword?, refresh: Bool) { // 請求內容
self.keyword = keyword
self.delegate?.dataProvider(self, didLoad: self.content)
}
}
public class ViewController: UIViewController {
...
public var dataProvider: DataProvider? {
didSet {
dataProvider?.delegate = self
fetchContentIfNeeded(refresh: true)
}
}

override open func viewDidLoad() {
super.viewDidLoad()
...
fetchContentIfNeeded(refresh: true)
}

private func fetchContentIfNeeded(refresh: Bool) {
guard let dataProvider = dataProvider else { return }
guard refresh || !dataProvider.isFetchEnded(keyword: keyword) else { return } // 是否需要重新或繼續請求資料
state = .loading
dataProvider.fetch(keyword: keyword, refresh: refresh) // 向 DataProvider 請求資料
}

open func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
var shouldFetchMore = ... // 快滑到列表底部時,嘗試去拉下一批資料
if shouldFetchMore {
fetchContentIfNeeded(refresh: false)
}
}

}

extension ViewController: DataProviderDelegate {
public func dataProvider(_ dataProvider: DataProvider, didLoad content: Content?) {
switch content {
case let .options(options):
state = options.isEmpty ? .empty : .normal(content)
case let .sections(sections):
state = sections.isEmpty ? .empty : .normal(content)
}
}

public func dataProvider(_ dataProvider: DataProvider, didFail error: NSError) {
state = .error(error)
}
}

有了 DataProvider 後,未來不同的資料處理邏輯需求,我們可以透過繼承它來實作。

新增 DynamicDataProvider + DynamicDataLoadable 實作動態資料載入邏輯

什麼是動態的資料來源呢?動態來源是指會變化的資料,例如:從 API 獲取的非同步資料、pagination 類型內容、搜尋、過濾等等。

由於在 Dcard App 中大部分動態資料邏輯是類似的,因此,新增一個 DynamicDataProvider 將這些共用邏輯抽出來,這樣就不需要每次都做一個新的 Data Provider,幫助團隊在開發時少寫許多重複的 code。

不過,不同類型的 API,在實際資料處理邏輯上還是有些許不同,例如在 Dcard 內 pagination 類型 API 有 offset-based 和 nextKey-based 兩種邏輯。不同的部分,則在 DynamicDataProvider 中新增 Loader 來處理。

Loader 實作 DynamicDataLoadable protocol,可以把 Loader 想像成一個真正負責拉取資料的人, Loader 拉回來的資料會交給 DynamicDataProvider 去與 ViewController 溝通。

public protocol DynamicDataLoadable {
associatedtype Object // 實際資料類型

typealias Completion = (Result<[Object], NSError>, _ isLoadEnded: Bool) -> Void

var objects: [Object] { get set } // 拉回來的資料
var isFetching: Bool { get } // 是否正在拉資料

func fetch(keyword: String?, refresh: Bool, completion: @escaping Completion) // 獲取資料,非同步告知結果
}
// DynamicDataProvider 繼承 DataProvider
public class DynamicDataProvider: DataProvider {
public let loader: (any DynamicDataLoadable)?
private var keyword: Keyword?
private var fetchImp: ((Keyword?) -> Void)?
private var isFetchEnded = false

public init<Loader>(
loader: Loader,
contentParser: @escaping ([Loader.Object], _ keyword: Keyword?) -> Content // 定義拉回來的資料要如何轉成 Content 顯示
) where Loader: DynamicDataLoadable {

self.loader = loader
super.init()

// 實際資料獲取交由 loader 去做
self.fetchImp = { [weak self] keyword in
guard keyword != self.keyword || refresh || !loader.isFetching else { return }

loader.fetch(keyword: keyword) { result, isLoadEnded in
self.isFetchEnded = isLoadEnded

switch result {
case let .success(objects):
let options = contentParser(objects, keyword)
self.delegate?.dataProvider(self, didLoad: options)

case let .failure(error):
self.delegate?.dataProvider(self, didFail: error)
}

}
}

}

override func isFetchEnded(keyword: Keyword?) -> Bool {
if keyword != self.keyword { isFetchEnded = false }
return isFetchEnded
}

override func fetch(keyword: Keyword?, refresh: Bool) {
self.keyword = keyword
fetchImp?(keyword)
}

}

前面提到的 offset-based 和 nextKey-based 兩套邏輯,就可以各自實作一個 Loader 來拉取資料,使用起來感覺如下:

// nextKey-based
let nextKeyBasedLoader = NextKeyListDataLoader(store: FooStore, action: FooAction) // 告訴 loader 要觸發哪個 action 和訂閱哪個 store
let nextKeyBasedProvider = DynamicDataProvider(loader: nextKeyBasedLoader)

// offset-based
let offsetBasedLoader = OffsetListDataLoader(store: FooStore, action: FooAction)
let offsetBasedProvider = DynamicDataProvider(loader: offsetBasedLoader)

你可能會好奇 NextKeyListDataLoaderOffsetListDataLoader內的實作是什麼? 上面程式碼中的 FooStore 和 FooAction 又是什麼?

這裡稍微介紹 Dcard iOS App 的開發架構。

Dcard iOS App 是以 Flux 架構為基礎設計,在 Flux 架構下,當需要獲取資料時,會觸發特定 Action 到 Dispatcher 中排程,輪到該 Action 時,Dispatcher 會請相關的 Store 做真正獲取資料的行為,而 View 或 ViewController 可以透過訂閱該 Store 來取得結果。

因為這並不是此次文章主軸,此處就不詳述實作細節,簡單來說:當要獲取/更新資料時,我們通常需要調用特定 Action 和訂閱相應的 Store,NextKeyListDataLoader 內做的事情,就是幫忙調用給定的 Action 和處理從 Store 收到的資料。

幫元件加上搜尋功能

在下面程式碼中,我們新增 Tool class,並透過繼承它來實作搜尋框工具。

Tool 是定義像 searchFieldUISegmentedControl 這類,能夠掛在 ViewController 上提供 keyword 的元件,並在 keyword 改變時透過 delegate 通知 ViewController

// Tool 使用 delegate 與 ViewController 溝通
public protocol ToolDelegate: AnyObject {
func tool(_ tool: Tool, didChange keyword: String?)
}

public class Tool: NSObject {
public var view = UIView()
public var keyword: String?
public weak var delegate: ToolDelegate?
}
public class SearchFieldTool: Tool {
public let searchField = UITextField()

public init(placeHolder: String?) {
super.init()
self.searchField.placeHolder = placeHolder
view.addSubview(searchField)
searchField.addTarget(self, action: #selector(searchFieldEditingChanged(_ :)), for: .editingChanged)
...
}

@objc private func searchFieldEditingChanged(_ textfield: UITextField) {
guard textfield.markedTextRange == nil else { return }
delegate?.tool(self, didChange: textfield.text)
}
}

// 利用 static function 提供 autocomplete
extension Tool {
public static func searchField(placeHolder: String? = "search") -> Tool {
SearchFieldTool(placeHolder: placeHolder)
}
}
public class ViewController: UIViewController {
public var tool: Tool? {
tool?.delegate = self
configureTool()
}

public func configureTool() {
guard let tool = tool else { return }
addSubView(tool.view)
...
}
}

extension ViewController: ToolDelegate {
func tool(_ tool: Tool, didChange keyword: String?) {
self.keyword = keyword
fetchContentIfNeeded(refresh: true)
}
}

這裡選擇用 class + 繼承來實作 Tool,而不是像 Accessory 使用 protocol + 泛形的原因,主要是希望避免之後新增其他 Tool 的人忘記將 delegate 加上 weak,另外,也不太會有使用的人需要知道該元件真正類型去設定或改動它的需求,因此權衡後選擇使用 class 和繼承。

Usage Example 🤩

最後附上一個簡單的使用範例,如何快速的做出學校選擇列表:

func presentSchoolList() {
let loader = NextKeyListDataLoader(
store: SchoolListStore.shared,
action: { keyword, nextKey in
SchoolListAcion(keyword: keyword, nextKey: nextKey)
})
let dataProvider = DynamicDataProvider(
loader: loader,
contentParser: { schools, keyword in
var sections: [ViewController.Section] = []
for school in schools {
let option = Option(title: school.name, accessory: .checkmark(isSelected: school.id == selectedSchoolId), action: { selectedSchoolId = school.id })
if let index = sections.firstIndex(where: { $0.title == school.region }) { // 將學校資料照地區分群
sections[index].options.append(option)
} else {
let newSection = Section(title: school.region, options: [option])
sections.append(newSection)
}
}
return .sections(sections)
})
let controller = ViewController(title: "select school", tool: .searchField(), dataProvider: dataProvider)
presentOptionSheet(controller)
}
學校選擇列表

The End 👋

今天的分享就到這啦!

自己覺得其實要完成這個專案並不難,達成目的的寫法也有很多,困難的是要去評估什麼才是目前最合適的實作方式,既要考慮使用便利性、保留近期未來擴充的可能性,又不希望 over engineering,來來回回修改了許多遍才終於完成。在這裡也要謝謝我的 mentor,過程中給了非常多建議及方向,我在這個專案中獲益非常非常多。

因為篇幅的關係,無法將所有程式碼貼上來,如果你對文章內容有興趣或是和我一樣喜歡接受挑戰,趕快一起來加入 Dcard 吧!!!

--

--