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


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

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

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

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。沒搶到的,就沒辦法顯示地圖了。

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) 的架構

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

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

// 用 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)。於是,這個物件池就算同時從多個執行緒去使用,也不會有問題啦!

應用物件池

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

map-view-object-pool

圖中主要有三個類型的物件:UIPageViewControllerMapViewControllerObjectPool。在套用物件池前,讓我們先把整個架構寫好。從 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()
    }

}

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

map-view-controller-1

這是因為每次滑到新頁面的時候,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 裡面。換句話說,當一個 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
Shares
Share This