利用 Protocol Extension 減少重覆的 Code 大大增強 Code 的維護性


對任何程式開發來說,減少重覆的 code,把權責明確分開,讓 code 維護性變好,是非常重要的課題。同樣功能的 code,如果分散在程式的各個角落,不但改功能時很有可能會漏改或改錯,而且要找到某個功能確切的擺放位置也會非常困難;這些都會讓開發成本變得非常高,也會讓開發所需要的時間變得難以估計。

如果我們能把每個小元件的功能定義清楚,就有機會把重覆的部份拉出來,另外找個統一的地方擺放,在需要這些功能的時候,再簡單地連結過去,這樣開發跟維護起來,都會輕鬆很多。而在現今的軟體開發模式中,有許多方法可以做到這點,最為人所知的一個模式,就是利用繼承 (Inheritance),把會重覆利用的部份放在母類別,讓其它子類別去繼承。另外一種做法,則是利用 Composition Pattern,將功能做成組件分出來,讓需要的模組去組合取用。不管那一種方法都能夠有效地減少 code 重覆性,讓權責更加清楚。

在 Swift 2.0 之後,針對 Composition 這個 pattern,我們有了更方便的工具:Protocol Extension。Protocol Extension 指的是利用 extension 的語法,來實作 protocol 中定義的 interface,在 conform protocol 的時候,就算不實作這些 interface,編譯器也會給過,因為這些 interface 已經在 extension 中被實作了。利用 Protocol Extension,我們可以很巧妙地把會重覆利用的功能,包裝在一個 protocol 裡,而任何 conform 這個 protocol 的物件,不用做任何事就可以自動得到這些功能,就像被「裝」上某個擴充功能一樣。

已經有有許多文章詳盡介紹過 Swift 的 protocol,像這篇 Swift 開發指南:Protocols 與 Protocol Extensions 的使用心法,就完整地提到了使用 protocol extension 的方法,以及用 protocol 取代 class 的好處等,非常推薦一看。不過在實務開發中,我們面對的不只有單純的 model(大家愛舉的 Animal、Employee 等例子),還有更多複雜的元件,像是 controller、 service 等。我們要怎樣在架構上利用 protocol extension 簡化這些模組,並提高重用率呢?在這篇文章中,我們將會從一個非常生活化 (?) 的例子:分頁 (Pagination),帶大家了解怎樣利用 Swift protocol 與 extension,創造出各種能夠重覆利用的模組。

在這篇文章裡,你可以學到:

  • 如何整理系統設計上的脈絡,把重覆的部份抽出來
  • 如何在實務上使用 Protocol Extension
  • 怎樣利用 Conditional Protocol Conformance 來幫特定類別擴充功能
  • 基本的 Protocol-Oriented Programming 原則

雖然這篇文章不會提到太過複雜的技術,但是會需要架構上有明確的分工,如果不是很熟悉要怎樣做,歡迎參考拙作(順便廣告?):原來是那個傳說中的 MVVM 阿Table View 太複雜?利用 MVVM 和 Protocol 就可以為它重構瘦身!,裡面有詳細的教學,分享如何把 controller 的邏輯從 massive-view-controller 裡面抽出來!除此之外,你可能還需要看看 Generic Protocols with Associated Type 這篇文章,了解一些 Swift 的概念。但現在不管你有沒有被騙點擊,我們都來開始今天的手把手教學吧!

為了簡化起見,範例程式碼只保留跟概念相關的部份,如果想看完整程式碼,可以在 GitHub – koromiko/SightSurfing 找到。完整的程式碼會比這邊的範例要複雜許多,建議先了解本文內容後再試著參照!

一個簡單的 app

今天我們要來製作一個簡單的相片瀏覽 app,一打開 app 就有滾不完的照片可以看。當然像這樣的 app,絕對不可能把所有在資料庫的相片全部都載下來再顯示,最好的做法是利用分頁把資料切成好幾頁,之後再一頁一頁秀給使用者。經過小弟沒日沒夜的一番努力後,我們先來看成品 ✍️ 👨🏽‍💻 📲:

protocol-extension-1

只要頁面滾到最底部,我們就會根據目前取得的資料,發 requst 給 server,去要下一頁的資料。

目前它的架構長得像這樣:

protocol-extension-2

我們利用一個 controller 來負責邏輯的部份。Controller 一開始會先跟 PhotoService 要照片清單,得到所有照片後,把照片 model 轉成 viewModel 存到 container 這個變數之中,最後通知 PhotoListViewController 去取得資料呈現。透過 container 裡面物件的數量,我們可以計算出目前已經取到第幾筆資料、接下來要從那一頁開始取等資訊。

我們來一步一腳印地解析這個小程式:

  1. 開始跟 PhotoService 要照片
  2. 計算一下目前在第幾頁
  3. 為了要知道接下來要不要繼續讀取下一頁,我們需要 server 回傳的總筆數,並且把總資料筆數存下來
  4. 把 Photo model 轉成 viewModels 並且通知 PhotoListViewController 更新 tableView
  5. 每當滾動 PhotoListViewController 頁面時,都會觸發這個 function。我們會檢查是不是滾到最後一筆了,如果是,就看一下有沒有需要抓下一頁的資料

以上就是一個非常簡單的分頁實作。這個小程式可以用下面的圖理解:

protocol-extension-3

上半部單純接受 server 來的相片資料,轉成 viewModel 後,丟給 view 去呈現;下方的方塊則負責判斷現在是不是已經滾到最後一個 cell,如果是,就通知 service 去抓下一頁的資料。

上面這樣的邏輯完美地躺在 PhotoListController 裡面,就這樣相安無事地活了好幾年,直到 ⋯⋯

分!都分!全部都給我分頁!── 散落在各處的相同邏輯

用過了有分頁的首頁後,PM 覺得相當滿意,並希望讓整個 app 的所有列表頁面都能夠分頁,連使用者設定頁也不放過。在產品設計的食物鏈裡,工程師永遠都是藍綠藻般的存在,所以我們就準備要來幫所有頁面加上分頁功能。最簡單的做法,當然就是在各個 controller 裡面,都建一個 container 收取 service 來的資料,並加上一個 function 判斷現在是不是滾到最後一筆資料了,然後再把更新的資料丟回 view 做呈現,每個 controller 都有自己的分頁器。

但是!這樣的做法很明顯有個問題,就是 Copy/Paste 很累 ⋯⋯ 不是!是一樣的邏輯,會被分散到 Project 各個不同的角落,當某一天需要改動分頁的行為,像是改成提前兩個 index 抓下一頁,或是改成一次抓 100 筆資料,就要把整個 project 翻過,檢查有沒有漏改的,這會提高造成改動程式的成本,而且你知道接手的人正在你後面嗎?他非常火!

這裡我們有個更好的做法:利用繼承的特性,我們可以建立一個叫 PaginationController 的 controller,把所有分頁相關的邏輯,都擺在這裡面:

這樣我們的 PhotoListController 就可以變成:

在上面的 code 裡,我們宣告了一個 PhotoListController,並且讓它繼承 PaginationController,而目標資料的型別是 PhotoListCellViewModel。我們把判斷是否為最後一筆的邏輯擺在 PaginationController 裡,所以在這邊我們就不用實作它,因為不管頁面內容是甚麼,「到最後一筆資料時準備抓下一頁」這個邏輯是不會變的。加上利用泛型 (Generic) T 這個型別,讓 container 裡放的東西,不限於只有一種 PhotoListCellViewModel,而可以是各個頁面的 CellViewModel。

看起來這個解決方法非常實用,也讓 code 重覆率降到很低了,這麼棒的東西,它會有甚麼問題?想像一下,今天這個 PhotoListController,除了處理分頁之外,我們也希望它能夠處理預先下載圖片素材、定時更新、接收廣播 (Notification) 等功能,這些也都是可能會被其它頁面重覆使用的。照剛剛的做法,如果每個功能都做一個類別,因為 Swift 無法多重繼承,變成如果它原本是繼承分頁器,就無法再繼承定時更新器,只能選一個功能讓它的彈性變得超級低。這樣一言難盡的關係可以從下面這張圖看出來:

protocol-extension-4

聰明的你,可能另外也想到,那不然做一個超級母類別,把所有的功能都放在裡面,就不會有上面這個問題了!一個全能的員工在職場上或許很常見(快去照照鏡子 QQ),但是當成一個母類別,就不太適合了。一個超大全能的類別,有很多功能大家不一定都會用到,但所有繼承它的子類別,都同時會繼承這些功能。這些不需要的功能,就成了干擾般的存在,光是在 IDE 上的 auto complete 跳出一堆用不到的 function 就夠讓人困擾了。

延續上面的觀點,在權責上我們也不希望任何一個員工身上掛滿職稱,不然這樣在分配人力時,反而會不知道這個人到底專長是甚麼、擅長怎樣的工作。理想中,一個 controller 只有在掛上 PaginationController 這個頭銜時,才表示它具有分頁器的功能;掛上 PollingController 這個頭銜,才表示它可以定時拉資料。這樣一來,我們只要看這個 controller 定義的第一行,就大概知道它會甚麼、負責甚麼工作,這樣追蹤起來就會輕鬆很多!

讓我們來重新定義 controller 與這些「功能」的關係:

protocol-extension-5

在這個理想中的世界,PhotoListController 的功能,是由原本自己的功能,外加 PaginationController 跟 PollingController 合起來的,等於是我們把後面兩者的功能,「裝」到原本的 controller 上。🛠

Swift Protocol Extension

到這裡,我們已經大概了解怎樣的解決方法會比直接用繼承來的方便,但具體來說,我們要怎樣在 Swift 上實作呢?累了嗎?讓我們先看個影片吧:Protocol-Oriented Programming in Swift – WWDC。在 2015 年的 WWDC 上,Apple 首次簡介了上面段落提到的概念,並介紹了要實現這樣概念最重要的元素:Protocol Extension!

Protocol Extension 這個功能,讓你可以用 extension 的語法,幫 protocol 上的某個 interface 加上實作,不管它是不是一個具體的 class 或 struct。具體的語法,在上面影片跟更上面的教學連結都有,這邊就不越俎代庖了。現在我們要來動手實現上面提到的,把 PaginationController 變成一個可以被自由「掛」到不同 controller 的功能。

首先,我們要先思考一下,一個分頁器,應該要提供那些功能:

  1. 判斷甚麼時候應該抓下一頁的資料、甚麼時候停止抓下一頁
  2. 告訴抓資料的人該從那個點開始抓資料 (offset)

一個分頁器也應該要知道這些資訊:

  1. 資料類型
  2. 每一頁有多少筆資料
  3. 總共要抓多少筆資料
  4. 現在滾動到第幾筆資料

根據上面的這些描述,我們來創建一個 protocol,定義分頁相關的 interface:

利用 protocol,我們可以清楚地把功能定義出來,不過我們的目標,是盡可能地把能夠重覆利用的實作抽出來,如果這些 interface 都要透過每次的 conformance 去實作出來,那就跟最一開始沒有兩樣了。

接著就是我們的重頭戲 ── Protocol Extension。利用這個特性,我們可以幫某些 protocol 的 interface 加上實作,未來 conform 這個 protocol 的物件,就不用再重覆寫一樣的 code 了。現在讓我們來示範一下:

在上面這段 code 中,我們直接把原本寫在 class 裡面的內容搬到這個 extension 裡面。這裡面我們也取用到兩個 protocol 定義的 interface :container 跟 fetchData(at:complete:) 這個 function,雖然它們還沒被實作出來,但是有被定義在 protocol 裡面,我們可以確定未來無論是誰 conform 這個 protocol,都一定會需要實作這兩個 interface,所以在這個時候我們可以自由地取用它們。

再把鏡頭拉遠一點,我們可以發現,一樣是在 protocol 中定義的 interface,我們實作的 func willScrollTo(index:),代表的是能夠被共享的邏輯:「判斷甚麼時候該抓下一頁」;而我們留下來未實作的 fetchData(at:complete:),則是跟特定 controller 相關的抓資料邏輯。每個 controller 抓資料的方式都不一樣,所以我們就留給 controller 自己去決定。對應到下圖,方塊內就是可被共享的邏輯 “is last?”,也就是 protocol extention 可以處理的部份。而跟方塊互動的,就是需要個別被不同 controller 實作的部份:

protocol-extension-6

完成了這個 extension 之後,我們來看一下要怎樣使用它。回到 PhotoListController,底下我們先在定義上加上 protocol PaginationController。這個時候compiler 會顯示錯誤,它會請你完成 protocol 的 conformance。不過,可以不用管 willScrollTo(index:) 這個 function,因為我們已經在上面實作完它了。

在上面的程式碼中,我們來看看 fetchData(at:, complete:) 這個 function。在這個 function 裡面,我們呼叫了這個 controller 專屬的 PhotoService,並且在取得資料後,透過 1 設定好總資料筆數,供 protocol extension 去做判斷,另外也在 2 呼叫 complete([PhotoListCellViewModel]) -\> Void 這個 closure,讓 extension 裡面的 willScrollTo(index:) 能夠取得資料去做進一步的處理。

所有的流程整合起來,就會像下面這樣:

  1. 使用者滾動,觸發 willScrollTo(index:)
  2. PaginationController 的 extension 判斷是否抓取下一頁
  3. PaginationController 的 extension 呼叫 fetchData(at:, complete:) 來取得下一頁的資料
  4. PhotoListController 執行實際抓取資料的工作,並把資料丟回給 PaginationController
  5. PaginationController 把收到的資料 append 到 container 裡,並且呼叫 reloadUI()

未來任何 controller 只要有需要做分頁,都可以直接在宣告時把它宣告為一種 PaginationController,剩下的部份,compiler 會一步一步告訴你該做甚麼,而重覆的部份,則已經被 extension 處理好了,是不是相當輕鬆呢! 🛀

只做該做的工作 簡單提升 tableView 效率

現在,把現場交給我們一直都忽略了的 View 這個部份。現在這個 View ── 也就是 PhotoListViewController ── 應該會長得像這樣:

看起來這個 View 的工作非常單純,就是負責把 tableView 呈現出來,然後等 reloadTableView() 被呼叫的時候,就用 tableView.reloadData() 重整 tableView 的資料。還記得我們甚麼時候會呼叫 reloadTableView() 嗎?就是在 controller 接到通知,新一頁資料已經進來、並 append 到 container 裡的時候,也就是說,在拿到第二頁、第三頁等的時候,都會呼叫 tableView.reloadData()。就在這個時候,你會發現我們每次 append 資料的同時,整個 tableView 都會被 reload。使用上可能不會有甚麼感覺,但我們更希望 View 的更新能夠更有效率,只 reload 需要的 cell,而不用整個 tableView 都 reload。

這個時候,我們需要稍微修改一下 reloadTableView() 這個 function,讓它能夠只重新讀取特定數量的 cell。我們把 reloadTableView 改成 insert,對應它原本該有的行為,並利用 UITableView.insertRows(at:with:) 這個 function 來插入 cell :

在這邊,要能夠更新特定的 cell,我們需要知道插入的資料 index 是從第幾個到第幾個。比方說,現在已經有的資料有 20 筆,第二頁有 15 筆資料進來,需要插入的 index 就是從 20 ~ 34。而 index 的資訊,目前被放在 PaginationController 裡統一做處理,所以我們來看一下要怎樣修改 PaginationController :

首先,當然是先把 reloadUI() 改成可以接受 index 的 insert(at:)。接著,我們在 extension 之中,試著找到即將被插入的資料的資訊:

從程式碼 1,我們先算一下目前有的資料筆數 (fromCount),再把抓到的新資料加上去,算出最後的資料筆數 (toCount)。這樣,我們就有即將被插入的資料的 index 區間了 (2)。

這樣一來,在 PhotoListController 裡的實作就變得非常簡單:

目前上面的關係就像是這張圖:

protocol-extension-7

PaginationController 把資料丟給 controller,controller 再把資料原封不動地丟給 view。在這裡,我們發現對 view 來說,要接受的資訊相對單純,就是接受一個 array 的 index,再針對這些 index 做 view 的更新。所以上圖最左邊的 PhotoListViewController,其實是可以被抽換掉,換成其它任何 view,只要保持固定有個 insert(at:) 這個接口就好:

基於上面的觀察,我們設計一個新的 protocol:ListViewHostProtocol,代表這是一個放有任何一種 list 的 view。而上面的關係圖,就可以進一步簡化成:

protocol-extension-8

PaginationController 直接對 ListViewHostProtocol 做操作,而這個 ListViewHostProtocol 可以是任何的 View,只要我們在實作 PhotoListController 時,指定這個 ListViewHostProtocol 是一個 PhotoListViewController 就好了。切回 code,讓我們在 PaginationController 加上一個 interface,代表上圖中間的小方框:

有了這一個介面,在 extension 裡面就可以直接跟它溝通,不需要再依賴 controller 的實作:

我們就只改了一行,把原本呼叫 self.insert(at:),並且期待 controller 會實作它,改成直接通知 listViewHost,告訴它有 cell 需要被更新了。讓我們整理一下我們的 PaginationController :

假設我們現在要做另外一個頁面,它是一個 UITableViewController,並且也希望它能夠分頁,我們就會先創建一個 controller,並且讓該 controller conform 這個 protocol。然後,compiler 會先報第一個錯誤:

protocol-extension-9

因為這個 protocol 有個 associated type,我們需要先指定資料類型:

接下來,你知道還需要提供每一頁的資料數量、全部的資料數量、實作 fetchData(at:complete:) 來提供資料,創建一個 UITableViewController 並 conform ListViewHostProtocol,再把這個 view 指定給 listViewHost。以上這些步驟,都在 compiler 的幫忙提示下完成,剩下的就是把 code 給填進去就好(當然這一步 compiler 是不會幫你的!)

最後,身為一個只想下班的工程師,偷懶的最後一哩路還沒完成!還記得上一個 section 提到,利用 protocol extension 把重覆的事情先做完嗎?我們還有一個非常重要的地方可以讓我們偷吃步:ListViewHostProtocol 👀

限縮條件讓提前實作變得可行 – Conditional Protocol Conformance

好,這個看起來非常簡單的 protocol,能夠提前實作的就只有 insert(at:) 這個 function,所以我們先試著把原本放在 PhotoListViewController 裡面的 code 挪過來擺看看:

問題來了,這個 ListViewHostProtocol 並沒有 tableView 這個 property。如果把 tableView 設定成一個 protocol 的 interface,會讓這個 protocol 被限定成只能給 UITableViewController 用。如果想要用一樣可以滾動、可以新增刪除的 UICollectionViewController 當成 UI,就要再開新的 protocol,這樣就失去原本統一介面的意義了。

統整一下我們的問題,我們希望幫這個 protocol 加上實作,在不改動 protocol 本身定義的情況下,取得某些類別才有的功能。這時候,Conditional Protocol Conformance 就派上用場了!

一般來說,如果你利用 extension 來實作 protocol 的某個 interface,那任何 conform 這個 protocol 的類別,都可以免費得到這個 extension 的實作。但目前就我們有限的認知,只知道如果對象是 UITableViewController 的時候,我們怎樣做出 ListViewHostProtocol 的 extension,其它的我們都還不知道。在這種狀況下,我們可以這樣寫:

跟原本的 protocol extension 語法比起來,只差了宣告的部份:

這個語法用阿鬼也懂的中文來說,就是:

我要幫 ListViewHostProtocol 加上實作,但限制只有在物件同時也是 UITableViewController 的情況下,才能夠得到這個實作。

這帶來甚麼好處?在上面的實作中,可以看到,我們已經可以自由取用 tableView 這個原本不存在的 property 了!因為限定只有 UITableViewController conform 這個protocol,所以 compiler 在這個宣告範圍內,會知道目前 self 就是一個 UITableViewController,自然就可以操作各種 UITableViewController 的功能。🎉

現在任何的 UITableViewController,都能夠接收來自 PaginationController 的訊息,並操作 insert cell,而不用另外再撰寫 UITableView.insert(at:with:) 了。今天如果我們要做一個 iPad 版,介面上是使用 UICollectionViewController,而不是 UITableViewController,我們也只要提供針對 UICollectionViewController 的實作就好:

這樣不論我們的頁面是拉成 UITableViewController 的型式、或是 UICollectionViewController 的型式,對 PaginationController,還有我們的 controller 來說,都是一樣的,不需要改動任何的 code 就能夠完整支援不同的 UI 實作!

相信眼尖的攻城獅已經發現了,這樣仰賴介面來讓底層能夠自由互換的特性,其實就是一種 Dependency Inversion Principle (DIP) 原則。我們的 PaginationController 操作的,既不是特定的 UITableViewController,也不是特定的 UICollectionViewController,而是只有 interface 的 ListViewHostProtocol。有了這樣的設計,未來如果你想把 TableView 換成一個自己設計的跑馬燈式橫向捲軸 view,也都不需要再改動 PaginationController 的 code,只要幫你的跑馬燈式橫向捲軸 view,加上 insert(at:) 的實作就好。DIP 是非常著名的軟體設計原則,它著名的地方在於實務上很常使用到,Protocol-Oriented Programming 的中心原則之一也是 DIP,更有名的是它是選擇了用 100 分的複雜名詞去解釋 10 分的簡單概念 XD。有興趣的還是會推薦先看 Protocol-Oriented Programming in Swift – WWDC,從實務上了解會比從定義上容易得多!

另外,利用 extension 來「裝」上功能,減少重覆 code 的方法,也正是有名的 Composition over Inheritance 模式,名詞永遠都比實際上看起來嚇人!👻 當然,隨著經驗的增長,你對這些名詞的體悟就會越深,也會慢慢的了解為甚麼它們會這樣被命名,而且定義好名詞也讓工程師之間的溝通更有效率,所以還是值得再給自己兩分鐘,了解並且記憶一下的!

總結

在今天的分享之中,我們了解到了怎樣先把可以重覆利用的邏輯歸納出來,並使用 protocol extension,提供預設好的實作,並且讓 compiler 能夠幫你完成一部份的工作。另外,抽象化物件跟物件之間的關聯,讓物件的抽換變得非常容易。加上 Conditional Conformance 的特性,讓我們能夠預先實作的範圍最大化。種種 Swift 的特性,都讓你的程式更好理解和維護。這些 pattern 除了可以用在 controller 上,其實也可以用在各種不同元件上,當然 PaginationController 隨著產品的功能,也有可能有完全不一樣的設計方法,Protocol-Oriented Programming 也不是軟體工程的完美救世主。系統的設計不是只有唯一解,也永遠不會有最佳解!在了解各種方法之後,還是要靠攻城獅你自己來決定、取捨,盡可能地做出當下最好的產品!

文章中很多部份都簡化了,並且針對修改的內容作解釋。如果你有興趣更深入了解,可以在 GitHub 上參考所有的原始碼。

在這個 repo 裡面,多實作了 loading indicator,也完整地接上了 API – Pexels,實實在在地上網抓取照片真心不騙。歡迎揪錯,或提供更好的做法,一起來 coding 吧!🍻


I’m ShihTing Huang(黃士庭). I brew iOS app, front-end web app, and of course, coffee and beer!

blog comments powered by Disqus
訂閲電子報

訂閲電子報

AppCoda致力於發佈優質iOS程式教學,你不必每天上站,輸入你的電子郵件地址訂閱網站的最新教學文章。每當有新文章發佈,我們會使用電子郵件通知你。

已收你的指示。請你檢查你的電郵,我們已寄出一封認證信,點擊信中鏈結才算完成訂閱。

Shares
Share This