製作物件池 (Object Pool) 重複利用物件 讓你大大提升開發效能!


建造物件是一件耗時耗力的事。除了需要配置記憶體給它之外,可能還會牽涉到排版、渲染或載入外部資源等耗費大的動作。WKWebViewMKMapView 就是這樣的例子,都需要大量的時間去啟動。如果只有一次兩次的話還好,但如果它們經常出現的話,使用者就要常常等它們載入,很影響體驗。

解決的辦法很簡單:重複利用這些物件,省去生成這些物件所需的時間。

說起來簡單,但要怎麼實作呢?如果確定一次只會用一個物件的話,或許可以把該物件宣告成單例,像是這樣:

也就是說,每一次 MapViewController 出現的時候,它都會去把 singletonMapView 抓取下來,然後加到自己的 view 階層裡面。

但是!這個做法雖然確實有效,卻有一個致命的限制:不能讓 MapViewController 的物件一次出現兩個,否則它們就會去搶 singletonMapView。沒搶到的,就沒辦法顯示地圖了。

object-pool-1

那怎麼辦呢?

「池」的概念

為了同時提供多個物件,我們可以把物件單例改成一個搜集 (collection),裡面再放進多個物件。當需要該物件時,客戶就去從物件搜集裡取出物件,用完之後再放回去。如此一來,就可以解決一般單例資源稀缺的問題了。

拿一個生活中的例子來說。假設有一個文案寫手在他的客戶之間很搶手,他可能會忙到崩潰。所以,他找來許多寫手朋友,成立了一個文案工作室,每次客戶來就指派一個寫手朋友給他。

這,就是的抽象概念。

在 iOS 開發中,池的概念處處可見。初學者最常碰到的,大概就屬 UITableView 所管理的 cell 池了吧!Table view 在捲動的時候,會跟它的 data source 拿 cell 去顯示捲到的列的內容;但是 UIKit 並不允許在 data source 中直接用建構式去創造新的 cell,而必須呼叫 UITableView 實例的 dequeueReusableCell(withIdentifier:for:) 方法,來取得用過的 cell 以重複利用。這個方法會從 cell 池裡面取出符合辨識符的 cell。而結束顯示某列的 cell 時, UITableView 就會把它放回 cell 池裡面去,準備再次取用。

UITableView 把 cell 池的實作都隱藏了起來,以簡化整個使用過程。不過,我們偶爾也會在別的地方碰上重複使用物件的需求,這時就只能自己寫一個物件池 (object pool) 出來用了。

UIPageViewController 就是一個很適合應用物件池的場景。假設它的每一頁都要顯示一個地圖,與其每次都要創造一個新的 MKMapView,實作一個物件池來重用 map view 會好得多。

map-view-object-pool

以下我們就來看看,物件池在這個場景裡可以怎麼實作!

物件池 (object pool) 的架構

首先,讓我們先準備好物件池。

物件池本身也是一個物件,而由於它管理的物件是甚麼類型並不重要,所以可以用通用型別來代替。

它擁有兩個基本的方法:獲取與歸還。注意這裡可以使用通用型別 Object 來當回傳值或參數的型別。

物件池的底層通常是一個搜集,包括堆疊 (stack) 或佇列 (queue) 等都是常用於此的搜集型別。為了簡明起見,這裡先用陣列 (array) 來實作。

也就是說,當客戶跟物件池獲取物件的時候,實際上是從物件陣列的尾巴拿走一個物件;歸還的時候,則是把物件放回陣列的尾巴去。

問題來了!如果客戶要獲取物件,但物件陣列裡沒物件的話,要怎麼辦呢?

能自己建構物件的物件池

物件池其實也有許多不同的種類,一些物件池一開始就有些物件在池裡面,如果物件用完,就需要等別的客戶歸還才能再借出。不過這裡讓我們向 UITableView 學習。它的 dequeueReusableCell(withIdentifier:for:) 方法不只是會回傳用過的 cell 而已,如果當下沒有可用的 cell 的話,它還會呼叫 cell 類型的建構式來創造新的 cell 並回傳。

這樣的做法其實是物件池加與工廠方法的混合種。它的好處是彈性大,並且有懶載入 (lazy loading) 的性質特徵。簡單來說,就是等到需要時再建構物件,而不是一開始就把數個物件建構好並丟進池裡。

它的流程圖是這樣的:

一開始的時候,物件池的物件數為零,所以客戶拿到的都會是新的物件。但很快的,當有客戶開始歸還物件之後,物件池就會開始提供使用過的物件,以減少建構物件的時間了。

那要怎麼給物件池建構物件的能力呢?閉包是一個好選擇:

在這裡,objectConstructor 就是建構物件用的工廠方法。現在當物件池初始化的時候,除了需要物件的實際類型之外,也會需要這個建構式。

重用前的準備

當你租完車要歸還的時候,最好把車清理到租之前的狀態,下一個租客才有一樣乾淨的車可以開。但是,並不是每個租客都會先清理之後再還,所以租車公司也有責任去清理每部歸還回來的車。

物件池也是一樣。每個物件都可能有它的可變狀態 (mutable state),像 map view 的話,可能就會有顯示區域跟地圖標記等狀態。如果要物件池先清理過物件再借出物件的話,一樣可以用閉包來實作:

執行緒安全性 (Thread safety)

我們的物件池已經可以用了,但是如果有多個執行緒同時存取的話,會容易出問題,因為物件池的底層 —— 陣列 —— 並不是執行緒安全的。

讓我們用一個 DispatchQueue,來把所有的操作限制在同一個執行緒裡面,以達到執行緒安全:

這邊定義了一個專屬於物件池的調度佇列,並將所有的操作都放到佇列裡面順序地執行,以避免競爭狀態 (race condition)。於是,這個物件池就算同時從多個執行緒去使用,也不會有問題啦!

應用物件池

寫好物件池之後,就可以拿到專案裡來用用看了。讓我們再看一次架構圖:

map-view-object-pool

圖中主要有三個類型的物件:UIPageViewControllerMapViewControllerObjectPool。在套用物件池前,讓我們先把整個架構寫好。從 MapViewController 開始:

這樣子,我們就有了一個會自己建構並載入 MKMapView 實例的 MapViewController 了。

接著是 UIPageViewController。與其透過繼承,不如我們試試另外宣告一個 container view controller 來控制它:

現在實際地執行看看。有沒有發現滑得比較快的時候,每次滑到新的頁面時地圖都要重新載入一次呢?

map-view-controller-1

這是因為每次滑到新頁面的時候,MapViewController 都會生成一個新的 MKMapView 物件,所以要重新載入資料。

現在改成用物件池看看:

我們把物件池定義成一個單例,所以每一個 MapViewController 實例都會跟同一個物件池索取物件。

索取物件的時間點在 loadView() 裡面,而歸還的時間點則是在 deinit 裡面。換句話說,當一個 mapVCview 被讀取的時候,它會去從物件池借出一個 mapView 來用;當它離開 pageVC、要被消滅之前,它會把 mapView 還給物件池管理。

再執行看看,會發現在滑過兩頁之後,地圖就沒有再重新載入了,這代表了 mapView 已經開始被重複利用了!

map-view-controller-2

結語

物件池模式是一種簡單但強大的機制。它雖然在一般的 app 開發中不那麼出名(主要是遊戲開發會碰到),但用來解決一些效能問題是十分有效的。而由於它影響的層面小,只要替換掉原本的建構式,並找時間歸還物件即可,所以可以到開發後期需要最佳化執行效率時再實作。

參考資料


iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]

blog comments powered by Disqus
訂閲電子報

訂閲電子報

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

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

Shares
Share This