重構與泛型 Generic

Generic 在重構上能幫什麼忙呢?

Posted by willsbor Kang on 2021-09-15

2020 年初我在一個專案中,要做一件麻煩的事情。
需求是

要把原本一頁式的頁面 (Single UINavigationController) 換成 TabBarController 的樣式。

看似平凡無奇的描述中,因為 Legacy code 和 跨區域開發者 的組成,產生了不少惡魔。但是也因此,讓我對於 Generic 有了新解。

表象問題

這種「頁面架構」的需求變換,通常伴隨著一個最麻煩的問題

頁面跳轉 (超連結) 的邏輯需要大幅度變動

比如說一個需求是:

當意圖 A 發生時,要離開目前的頁面,從一般 Account ViewController 的進入點顯示 Account ViewController。
(意圖 A 可能會是由一個 Universal Link 產生,或是某個頁面的動作。)

在 Single UINavigationController 時,只需要 popToRoot 後,接著 push Account ViewController。
但是在 TabBarController 時,因為 Account 也自成一個 Tab 了,則會直接切換過去。

如果是一個有結構性的設計,這個程度的轉換應該還好。
但可以理解過去沒有這樣的需求,所以通常會是直接使用:

把所有的邏輯,都寫在 UINavigationController 的 sub-class 下。
包含 1. 意圖的解析, 2. 解析後的動作實踐

因此會看到這樣的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ProjectNavigationController: UINavigationController {
func navigateToAccountViewController() {
self.popToRootViewController()
let vc = makeAccountViewController()
self.push(vc)
}
}

class DeepLinkManager {
weak var navigationController: ProjectNavigationController?

func receiveEvent() {
navigationController?.navigateToAccountViewController()
}
}

到目前為止,如果轉換成 protocol,然後將實作換成 TabBarController 都還算簡單。
但因為多人維護的關係,這件事有了變化…
當有一個新的需求:

當收到 Deep Link 時,要在 navigationController 顯示一個 Login Flow

Login Flow 由另一個 Manager 管理很正常,通常只要告訴他要顯示在哪個 ViewController 之上就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ProjectNavigationController: UINavigationController {
func navigateToAccountViewController() {
self.popToRootViewController()
let vc = makeAccountViewController()
self.push(vc)
}
}

class LoginManager {
func startFlow(at baseVC: UIViewController) {
...
}
}

class DeepLinkManager {
weak var navigationController: ProjectNavigationController?

func receiveEvent2() {
LoginManager().startFlow(at: navigationController)
}
}

由於這個需求看起來很簡單,加上 ProjectNavigationController 也將 UIViewController 的屬性暴露在外面。所以被這寫出來的機會就很大。

因此,就無法直接將 ProjectNavigationController 的實體物件,換成虛擬的 protocol 了。

為何以及如何使用 Generic

因此無法單純改成介面來使用。如果要修改這類的使用方式,勢必對更多關聯的物件產生影響,不僅僅是程式碼的改動,也要修改到其他開發者的開發思維。
因此可以借用 Generic 來幫忙。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protocol ProjectNavigationControlling: UIViewController {
func navigateToAccountViewController()
}

class ProjectNavigationController: UINavigationController, ProjectNavigationControlling {
func navigateToAccountViewController() {
self.popToRootViewController()
let vc = makeAccountViewController()
self.push(vc)
}
}

class DeepLinkManager<T: ProjectNavigationControlling> {
weak var navigationController: T?

func receiveEvent() {
navigationController?.navigateToAccountViewController()
}

func receiveEvent2() {
LoginManager().startFlow(at: navigationController)
}
}

這樣改寫,會發現 DeepLinkManager 基本上沒有什麼變動。因此如果要改成 TabBarController 的形式(也正好 TabBarController 是繼承 UIViewController)也需要只考慮實作 ProjectNavigationControlling 的問題就好了,例如:

1
2
3
4
5
class ProjectNavigationController: TabBarController, ProjectNavigationControlling {
func navigateToAccountViewController() {
/// select to Account Tab
}
}

這樣對短期的轉換風險會比較少,而且可以分成「重構」和「轉接 TabBarController」兩個階段。但仍然會有被直接使用 UIViewController 而增加相依性的風險。

通常公司有成本考量,所以先以低風險為主應該會比較好。
不改變其他開發者的開發思維,通常他們也比較能接受。

小結

(這篇拖了一年多才寫完XD)

這一年多的時間裡,我又寫了不少的 Generic。
回過頭來看,覺得當時做的決策還是相當有用。
目前這個 Legacy 架構還是躺在那裡好好的,哈!

抽象化在程式中,是蠻重要的一個課題。
不要拘泥於實作物件能做什麼,而是原本的功能、需求要的是什麼。