建造物件是一件耗時耗力的事。除了需要配置記憶體給它之外,可能還會牽涉到排版、渲染或載入外部資源等耗費大的動作。WKWebView
與 MKMapView
就是這樣的例子,都需要大量的時間去啟動。如果只有一次兩次的話還好,但如果它們經常出現的話,使用者就要常常等它們載入,很影響體驗。
解決的辦法很簡單:重複利用這些物件,省去生成這些物件所需的時間。
說起來簡單,但要怎麼實作呢?如果確定一次只會用一個物件的話,或許可以把該物件宣告成單例,像是這樣:
import UIKit
import MapKit
class MapViewController: UIViewController {
// 定義一個 MKMapView 的單例。
private static let singletonMapView = MKMapView()
override func viewDidLoad() {
super.viewDidLoad()
// 取得 mapView 單例。
let mapView = MapViewController.singletonMapView
// 清理 mapView。
mapView.removeFromSuperview()
// 安裝 mapView。
mapView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
mapView.frame = view.bounds
view.addSubview(mapView)
}
}
也就是說,每一次 MapViewController
出現的時候,它都會去把 singletonMapView
抓取下來,然後加到自己的 view 階層裡面。
但是!這個做法雖然確實有效,卻有一個致命的限制:不能讓 MapViewController
的物件一次出現兩個,否則它們就會去搶 singletonMapView
。沒搶到的,就沒辦法顯示地圖了。
那怎麼辦呢?
「池」的概念
為了同時提供多個物件,我們可以把物件單例改成一個搜集 (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 會好得多。
以下我們就來看看,物件池在這個場景裡可以怎麼實作!
物件池 (object pool) 的架構
首先,讓我們先準備好物件池。
物件池本身也是一個物件,而由於它管理的物件是甚麼類型並不重要,所以可以用通用型別來代替。
// 用 AnyObject 把 Object 限制為物件,排除 enum 與 struct 等值型別的可能性。
class ObjectPool<Object: AnyObject> {
}
它擁有兩個基本的方法:獲取與歸還。注意這裡可以使用通用型別 Object
來當回傳值或參數的型別。
class ObjectPool<Object: AnyObject> {
// 獲取 object。
func acquire() -> Object {
// 待實作...
}
// 歸還 object。
func release(_ object: Object) {
// 待實作...
}
}
物件池的底層通常是一個搜集,包括堆疊 (stack) 或佇列 (queue) 等都是常用於此的搜集型別。為了簡明起見,這裡先用陣列 (array) 來實作。
class ObjectPool<Object: AnyObject> {
// 用 private 防止被客戶直接更動。
private var objects = [Object]()
func acquire() -> Object {
// 取出陣列的最後一個元素的複雜度是 O(1),比較快。
if let object = objects.popLast() {
return object
} else {
// 待實作...
}
}
func release(_ object: Object) {
// 將元素加到陣列的尾巴也是 O(1) 的複雜度
objects.append(object)
}
}
也就是說,當客戶跟物件池獲取物件的時候,實際上是從物件陣列的尾巴拿走一個物件;歸還的時候,則是把物件放回陣列的尾巴去。
問題來了!如果客戶要獲取物件,但物件陣列裡沒物件的話,要怎麼辦呢?
能自己建構物件的物件池
物件池其實也有許多不同的種類,一些物件池一開始就有些物件在池裡面,如果物件用完,就需要等別的客戶歸還才能再借出。不過這裡讓我們向 UITableView
學習。它的 dequeueReusableCell(withIdentifier:for:)
方法不只是會回傳用過的 cell 而已,如果當下沒有可用的 cell 的話,它還會呼叫 cell 類型的建構式來創造新的 cell 並回傳。
這樣的做法其實是物件池加與工廠方法的混合種。它的好處是彈性大,並且有懶載入 (lazy loading) 的性質特徵。簡單來說,就是等到需要時再建構物件,而不是一開始就把數個物件建構好並丟進池裡。
它的流程圖是這樣的:
一開始的時候,物件池的物件數為零,所以客戶拿到的都會是新的物件。但很快的,當有客戶開始歸還物件之後,物件池就會開始提供使用過的物件,以減少建構物件的時間了。
那要怎麼給物件池建構物件的能力呢?閉包是一個好選擇:
class ObjectPool<Object: AnyObject> {
// 定義 ObjectConstructor 為一個會回傳 Object 實例的函數。
typealias ObjectConstructor = () -> Object
private var objects = [Object]()
// 將物件建構式儲存起來。
private let objectConstructor: ObjectConstructor
// 交由客戶來傳入物件建構式。
init(objectConstructor: @escaping ObjectConstructor) {
self.objectConstructor = objectConstructor
}
func acquire() -> Object {
if let object = objects.popLast() {
return object
} else {
// 如果 objects 陣列為空,則呼叫物件建構式來製造一個新的物件並回傳。
let object = objectConstructor()
return object
}
}
func release(_ object: Object) {
objects.append(object)
}
}
在這裡,objectConstructor
就是建構物件用的工廠方法。現在當物件池初始化的時候,除了需要物件的實際類型之外,也會需要這個建構式。
重用前的準備
當你租完車要歸還的時候,最好把車清理到租之前的狀態,下一個租客才有一樣乾淨的車可以開。但是,並不是每個租客都會先清理之後再還,所以租車公司也有責任去清理每部歸還回來的車。
物件池也是一樣。每個物件都可能有它的可變狀態 (mutable state),像 map view 的話,可能就會有顯示區域跟地圖標記等狀態。如果要物件池先清理過物件再借出物件的話,一樣可以用閉包來實作:
class ObjectPool<Object: AnyObject> {
typealias ObjectConstructor = () -> Object
// 因為 Object 是參照型別,所以不需要將清理後的物件回傳回來。
typealias ObjectReusePreparer = (Object) -> Void
private var objects = [Object]()
private let objectConstructor: ObjectConstructor
private let objectReusePreparer: ObjectReusePreparer
init(objectConstructor: @escaping ObjectConstructor, objectReusePreparer: @escaping ObjectReusePreparer) {
self.objectConstructor = objectConstructor
self.objectReusePreparer = objectReusePreparer
}
func acquire() -> Object {
if let object = objects.popLast() {
return object
} else {
let object = objectConstructor()
return object
}
}
func release(_ object: Object) {
// 在把物件放回池裡之前,先把它清理乾淨,以免還沒清乾淨就又被借出去。
objectReusePreparer(object)
objects.append(object)
}
}
執行緒安全性 (Thread safety)
我們的物件池已經可以用了,但是如果有多個執行緒同時存取的話,會容易出問題,因為物件池的底層 —— 陣列 —— 並不是執行緒安全的。
讓我們用一個 DispatchQueue
,來把所有的操作限制在同一個執行緒裡面,以達到執行緒安全:
// Dispatch 就是 Grand Central Dispatch 的框架名稱,也包含在 Foundation 裡面。
import Dispatch
class ObjectPool<Object: AnyObject> {
typealias ObjectConstructor = () -> Object
typealias ObjectReusePreparer = (Object) -> Void
private var objects = [Object]()
private let objectConstructor: ObjectConstructor
private let objectReusePreparer: ObjectReusePreparer
// 宣告一個序列的調度佇列,並根據 Object 的類型來指定標籤名。
private let dispatchQueue = DispatchQueue(label: "TeamName.AppName.\(Object.self)Pool")
init(objectConstructor: @escaping ObjectConstructor, objectReusePreparer: @escaping ObjectReusePreparer) {
self.objectConstructor = objectConstructor
self.objectReusePreparer = objectReusePreparer
}
func acquire() -> Object {
// 使 dispatchQueue 序列地從物件陣列中拿取物件。
return dispatchQueue.sync {
// 這裡簡化了之前的寫法。
return objects.popLast() ?? objectConstructor()
}
}
func release(_ object: Object) {
// 把物件放回物件陣列也是在 dispatchQueue 中序列地進行的。
dispatchQueue.sync {
objectReusePreparer(object)
objects.append(object)
}
}
}
這邊定義了一個專屬於物件池的調度佇列,並將所有的操作都放到佇列裡面順序地執行,以避免競爭狀態 (race condition)。於是,這個物件池就算同時從多個執行緒去使用,也不會有問題啦!
應用物件池
寫好物件池之後,就可以拿到專案裡來用用看了。讓我們再看一次架構圖:
圖中主要有三個類型的物件:UIPageViewController
、MapViewController
與 ObjectPool
。在套用物件池前,讓我們先把整個架構寫好。從 MapViewController
開始:
import UIKit
import MapKit
class MapViewController: UIViewController {
// 用以製造 MKMapView 實例的工廠方法。
private static func makeMapView() -> MKMapView {
let mapView = MKMapView()
// 取消 mapView 的手勢偵測,以使 pageVC 可以左右滑動。
mapView.isUserInteractionEnabled = false
return mapView
}
override func loadView() {
// 製造新的 MKMapView 實例並指派給 self.view。
self.view = MapViewController.makeMapView()
}
}
這樣子,我們就有了一個會自己建構並載入 MKMapView
實例的 MapViewController
了。
接著是 UIPageViewController
。與其透過繼承,不如我們試試另外宣告一個 container view controller 來控制它:
import UIKit
// Page view controller 的母 VC,同時也是它的 data source。
class RootViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 建構並嵌入 pageVC。
let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
addChild(pageVC)
pageVC.view.autoresizingMask = [.flexibleHeight, .flexibleWidth]
pageVC.view.frame = view.bounds
view.addSubview(pageVC.view)
pageVC.didMove(toParent: self)
// 設定自己為 pageVC 的 data source。
pageVC.dataSource = self
// 建構初始的 mapVC 並放到 pageVC 裡面。
let mapVC = MapViewController()
pageVC.setViewControllers([mapVC], direction: .forward, animated: false)
}
}
// 當 pageVC 要求上一頁跟下一頁的 view controller 時,都直接回傳新的 MapViewController 實例給它。
extension RootViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
return MapViewController()
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
return MapViewController()
}
}
現在實際地執行看看。有沒有發現滑得比較快的時候,每次滑到新的頁面時地圖都要重新載入一次呢?
這是因為每次滑到新頁面的時候,MapViewController
都會生成一個新的 MKMapView
物件,所以要重新載入資料。
現在改成用物件池看看:
import UIKit
import MapKit
public class MapViewController: UIViewController {
// 把物件池定義成一個單例。
private static var mapViewPool = ObjectPool(
// 沿用 makeMapView() 做為 mapView 的建構式。
objectConstructor: makeMapView,
objectReusePreparer: { mapView in
// 在此做 mapView 的清理。
})
private static func makeMapView() -> MKMapView {
let mapView = MKMapView()
mapView.isUserInteractionEnabled = false
return mapView
}
public override func loadView() {
// 在 loadView() 裡從物件池獲取 mapView 來用。
view = MapViewController.mapViewPool.acquire()
}
deinit {
// mapVC 要被清除的時候歸還 mapView。
if let mapView = view as? MKMapView {
MapViewController.mapViewPool.release(mapView)
}
}
}
我們把物件池定義成一個單例,所以每一個 MapViewController
實例都會跟同一個物件池索取物件。
索取物件的時間點在 loadView()
裡面,而歸還的時間點則是在 deinit
裡面。換句話說,當一個 mapVC
的 view
被讀取的時候,它會去從物件池借出一個 mapView
來用;當它離開 pageVC
、要被消滅之前,它會把 mapView
還給物件池管理。
再執行看看,會發現在滑過兩頁之後,地圖就沒有再重新載入了,這代表了 mapView
已經開始被重複利用了!
結語
物件池模式是一種簡單但強大的機制。它雖然在一般的 app 開發中不那麼出名(主要是遊戲開發會碰到),但用來解決一些效能問題是十分有效的。而由於它影響的層面小,只要替換掉原本的建構式,並找時間歸還物件即可,所以可以到開發後期需要最佳化執行效率時再實作。