Auto Layout

擷取佈局回饋循環 (Layout Feedback Loop) 解決記憶體耗盡問題

擷取佈局回饋循環 (Layout Feedback Loop) 解決記憶體耗盡問題
擷取佈局回饋循環 (Layout Feedback Loop) 解決記憶體耗盡問題
In: Auto Layout, Swift 程式語言

試想像這樣的一個情境:你的 App 非常成功,不但有許多使用者、並有 100% 未當機率 (Crash-free rate)。你非常開心,生活也棒極了。但在某個時間點,你開始在 App Store 上看到負評,說你的 App 經常閃退;但查閱 Fabric 卻沒有新的閃退訊息出現。哪是甚麼情況呢?

答案是記憶體用盡 (OOM, Out of Memory) 而終止。

當你用完使用者裝置上的 RAM 時,作業系統可以決定為了其他處理流程,而回收記憶體並關閉你的 App。我們稱其為「記憶體用盡而終止」,有幾個原因會導致這樣的情況發生:

  • 循環引用 (Retain Cycles)
  • 競爭危害 (Race Conditions)
  • 廢棄執行緒 (Abandoned Threads)
  • 死鎖 (Deadlocks)
  • 佈局回饋循環 (Layout Feedback Loop)

Apple 提供了很多方法來解決這些問題:

佈局回饋循環 (Layout Feedback Loop)

我們接著來看看佈局回饋循環。它並不是常見的問題,但一旦遇上了,就會讓你頭痛萬分。

當你的視圖正在執行佈局程式碼,卻因為某種方式導致它們再次開始傳送佈局,佈局回饋循環就會發生。這可能是因為一個視圖改變它其中一個父視圖的大小,或是因為佈局設定含糊。無論是哪種原因,這個問題會導致 CPU 使用率極高、RAM 用量一直上升,這全都是因為視圖不斷重複執行佈局程式碼而不停止。

- Paul Hudson 在 HackingWithSwift 上所撰寫的文章

幸運的是,Apple 在 WWDC 2016 上花了整整 15 分鐘去介紹「佈局回饋循環除錯器 (Layout Feedback Loop Debugger)」,這個工具可以確認在除錯期間發生循環的時間點。這只是一個象徵式的斷點,它的運作方式非常簡單:它會計算每個視圖上的 layoutSubviews() 方法在單一執行循環週期裡被呼叫的次數,次數一旦超過某個門檻(像是 100 次),App 就會停在這個斷點,並且印出訊息日誌來幫助你找出原因所在。這篇文章簡單介紹了這個除錯器的運作。

如果你可以重現問題的話,這個除錯器就能順利運作。但是如果你有數十個畫面、數百張視圖,但 App Store 上的評論只是說:「這 App 爛透了,總是閃退,絕不再用!!」,那你該怎麼辦呢?你當然希望幫這些人帶到辦公室,並為他們設定佈局回饋循環除錯器。雖然你不可能這樣做,不過你可以在產品程式碼中嘗試複製 UIViewLayoutFeedbackLoopDebuggingThreshold

我們來回想一下這個斷點是怎樣運作的:它在單一執行循環週期裡計算 layoutSubviews() 的調用次數,當次數超過門檻時就印出日誌。聽起來很簡單,對吧?

class TrackableView: UIView {
    var counter: Int = 0

    override func layoutSubviews() {
        super.layoutSubviews()
    
        counter += 1;
        if (counter == 100) {
            YourAnalyticsFramework.event(name: "loop")
        }
    }
}

這段程式碼在你的視圖裡運作正常,但現在你希望在其他的視圖中實作它。當然,你可以創建一個 UIView 的子類別並在裡頭實作,然後讓所有專案裡的視圖都繼承它。接著也對 UITableViewUIScrollViewUIStackView 等做同樣的事情。

你希望在不需要撰寫大量重複程式碼的情況下,都能夠將這個邏輯注入任何類別 ── 這正是 Runtime Programming 允許你做的事情。

我們將重新再做相同的事情 ── 創建子類別、覆寫 layoutSubviews() 方法、並計算調用次數。唯一與之前不同的地方,就是所有東西都會在運行時完成,而不是在專案中建立重複的類別。

那我們先從簡單的步驟開始:我們會創建客製的子類別,然後將原本視圖的類別更改為新的子類別

struct LayoutLoopHunter {

    struct RuntimeConstants {
        static let Prefix = “runtime”
    }
    
    static func setUp(for view: UIView, threshold: Int = 100, onLoop: @escaping () -> ()) {
        // 我們以功能及原類別名稱作為前綴字,為新類別命名
        let classFullName = “\(RuntimeConstants.Prefix)_\(String(describing: view.self))”
        let originalClass = type(of: view)
    
        if let trackableClass = objc_allocateClassPair(originalClass, classFullName, 0) {
            // 這個類別並未在現在的運行區塊中被建立
            // 我們需要註冊我們的類別,並與原本視圖的類別交換
            objc_registerClassPair(trackableClass)
            object_setClass(view, trackableClass)
        } else if let trackableClass = NSClassFromString(classFullName) {
            // 我們之前在這個運行區塊中分配了一個相同名字的類別
            // 我們可以從原始字串去取得它,並以相同的方式與視圖類別交換
            object_setClass(view, trackableClass)
        }
    }
}

當這個方法出錯時,objc_allocateClassPair() 的文件就會告訴我們:

新類別如果無法被創建就會是 nil(例如取的名稱已被使用)

這表示你不能有兩個相同名字的類別,所以我們的策略是為單個視圖類別創建一個單獨的 Runtime 類別。這就解釋了我們以原本類別的名稱作為前綴字,來為新類別命名的原因。

現在讓我們加入一個計數器到我們的子類別。理論上,你有兩種方式來加入:

  1. 加入一個持有計數器的屬性。
  2. 為這個類別建立一個關聯對象 (Associated Object)。

但事實上,只有一個方法是可行的。你可以視屬性為儲存在已經分配到類別的記憶體內的東西,而一個關聯對象則會被儲存在一個完全不同的地方。因為分配給一個已存在物件的記憶體是固定的,所以新加到客製子類別裡的屬性會從其他資源中「偷走」記憶體,這可能會導致預料之外的行為、及難以除錯的閃退(查閱 這篇文章以了解更多)。但如果使用關聯對象的話,關聯對象會儲存在一個執行時建立的雜湊表格 (hash table) 內,而這個表格是完全安全的:

static var CounterKey = "_counter"

...

objc_setAssociatedObject(trackableClass, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)

新的子類別已經創建好了,計數器也被設定為 0。接下來,讓我們來實作新的 layoutSubviews() 方法,並把它加到類別裡吧:

let layoutSubviews: @convention(block) (Any?) -> () = { nullableSelf in
    guard let _self = nullableSelf else { return }

    if let counter = objc_getAssociatedObject(_self, &RuntimeConstants.CounterKey) as? Int {
        if counter == threshold {
            onLoop()
        }
    
        objc_setAssociatedObject(trackableClass, &RuntimeConstants.CounterKey, counter+1, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }
}
let implementation = imp_implementationWithBlock(layoutSubviews)
class_addMethod(trackableClass, #selector(originalClass.layoutSubviews), implementation, "v@:")

為了理解上面的程式碼做了甚麼,讓我們先看一下 <objc/runtime.h> 的結構 (Struct):

struct objc_method {
    SEL method_name;
    char *method_types;
    IMP method_imp;
}

雖然我們不會再在 Swift 裡直接使用這個結構,但它清楚地解釋了一個方法實際上包括了甚麼:

  • 實作 (Implementation, IMP),是當你的方法被呼叫時所執行的確切函式。它總是將接受器 (Receiver) 與訊息選擇器 (Message Selector) 作為頭兩個參數。
  • 方法型別字串 (char) 包括了你的方法的名稱。如果想了解更多關於它的格式,你可以看看這個網站。不過在我們的例子中,我們需要指定的字串是 "v@:"v 代表 void,是我們回傳的型別。而 @: 則分別表示接收器和訊息選擇器。
  • 選擇器 (selector, SEL),是你在執行期間尋找方法實作的鍵 (Key)。

你可以視 Witness Table(在其他程式語言中也稱為 Dispatch Table)為一個簡單的字典資料結構、選擇器就是你的鍵、而實作則是值 (Value)。我們在這一行:

class_addMethod(trackableClass, #selector(originalClass.layoutSubviews), implementation, "v@:")

做的就是為與 layoutSubviews() 方法對應的鍵分配一個新的值。

簡單地說,我們拿到計數器後給它加一。如果它超過了我們的門檻,我們就把分析與類別名稱和任何想要的資訊傳送出去。

讓我們回看一下如何實作及使用關聯對象的鍵:

static var CounterKey = “_counter”

...

objc_setAssociatedObject(trackableClass, &RuntimeConstants.CounterKey, counter+1, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)

為什麼我們使用 var 來做計數器變數的靜態鍵屬性,並利用參照 (reference) 來傳送它到所有地方呢?答案就隱藏在 Swift 的基礎中:就像所有其他的數值型別一樣,字串是依據值來傳遞的。所以當你傳送它到閉包時,字串會被複製到不同的位址,這會造成在關聯對象表格內產生一個完全不同的鍵;而 & 符號則會確保將相同的位址作為鍵參數的值。試試以下的程式碼:

func printAddress(_ string: UnsafeRawPointer) {
    print("\(string)")
}

let str = "test"

printAddress(str)
printAddress(str)
let closure = {
    printAddress(str)
    printAddress(str)
}
closure()
// 最後兩個函式呼叫的位址一定不會相同

利用參照傳送鍵是個很好的點子,因為有時候即使你沒有使用閉包,變數的位址仍可以因記憶體管理而改變。看看我們的例子,如果你執行上面的程式碼到一個特定次數的話,你就可能在 printAddress() 看到不同的位址。

讓我們回到 Runtime 的魔法吧!在新的 layoutSubviews() 實作中,有個重要的東西我們還沒有完成,那就是我們每次從父類別重寫方法時,通常都會執行的操作 ── 呼叫父類別實作。layoutSubviews() 的文件說:

這個方法的預設實作在 iOS 5.1 及更早的版本中不執行任何操作。此外,預設實作會使用任何你所設定的約束條件,來決定子視圖的大小及位置。

為了避免任何預料之外的佈局行為,我們必須呼叫父類別的實作。這不會像平常那樣直接了當:

let selector = #selector(originalClass.layoutSubviews)
let originalImpl = class_getMethodImplementation(originalClass, selector)
// @convention(c) 告訴 Swift 這是一個空的函式指標(沒有內容物件)
// 所有 Objective-C 方法函式都會有接收器及訊息作為它們頭兩個參數
// 因此這表示 () -> Void 型別的方法與 layoutSubviews 相符
typealias ObjCVoidVoidFn = @convention(c) (Any, Selector) -> Void
let originalLayoutSubviews = unsafeBitCast(originalImpl, to: ObjCVoidVoidFn.self)
originalLayoutSubviews(view, selector)

這裡所做的,不是用一般的方式來呼叫一個方法(也就是執行將在 Witness Table 中查找實作的選擇器),而是我們自己尋找所需的實作,並直接從我們的程式碼呼叫它。

一起來看看我們的實作:
// This class hasn’t been created during the current runtime session
// We need to register our class and swap is with the original view’s class

static func setUp(for view: UIView, threshold: Int = 100, onLoop: @escaping () -> ()) {
    // 我們以功能及原本的類別名稱為前綴字來為新類別命名
    let classFullName = “\(RuntimeConstants.Prefix)_\(String(describing: view.self))”
    let originalClass = type(of: view)

    if let trackableClass = objc_allocateClassPair(originalClass, classFullName, 0) {
            // 這個類別並未在現在的運行區塊中被建立
            // 我們需要註冊我們的類別,並與原本視圖的類別交換
        objc_registerClassPair(trackableClass)
        object_setClass(view, trackableClass)
    
        // 現在我們可以建立關聯對象
        objc_setAssociatedObject(view, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    
        // 加入我們 layoutSubviews  的實作
        let layoutSubviews: @convention(block) (Any?) -> () = { nullableSelf in
            guard let _self = nullableSelf else { return }
    
            let selector = #selector(originalClass.layoutSubviews)
            let originalImpl = class_getMethodImplementation(originalClass, selector)
    
            // @convention(c) 告訴 Swift 這是一個空的函式指標(沒有內容物件)
        // 所有 Objective-C 方法函式都會有接收器及訊息作為它們頭兩個參數
        // 因此這表示 () -> Void 型別的方法與 layoutSubviews 相符
            typealias ObjCVoidVoidFn = @convention(c) (Any, Selector) -> Void
            let originalLayoutSubviews = unsafeBitCast(originalImpl, to: ObjCVoidVoidFn.self)
            originalLayoutSubviews(view, selector)
    
            if let counter = objc_getAssociatedObject(_self, &RuntimeConstants.CounterKey) as? Int {
                if counter == threshold {
                    onLoop()
                }
    
                objc_setAssociatedObject(view, &RuntimeConstants.CounterKey, counter+1, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            }
        }
        let implementation = imp_implementationWithBlock(layoutSubviews)
        class_addMethod(trackableClass, #selector(originalClass.layoutSubviews), implementation, “v@:“)
    } else if let trackableClass = NSClassFromString(classFullName) {
            // 我們之前在這個運行區塊中分配了一個相同名字的類別
            // 我們可以從原始字串去取得它,並以相同的方式與視圖類別交換
        object_setClass(view, trackableClass)
    }
}

讓我們建立一個模擬的視圖佈局循環並設定計數器,來測試程式碼:

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        LayoutLoopHunter.setUp(for: view) {
            print("Hello, world")
        }
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        view.setNeedsLayout() // 建立循環
    }
}

我們還有沒有遺漏甚麼事情?讓我們來回顧一下 UIViewLayoutFeedbackLoopDebuggingThreshold 斷點是如何運作的吧:

定義一個視圖在被視為一個回饋循環 (Feedback Loop) 之前,必須在單運行循環 (Run Loop) 中佈局其子視圖的次數。

我們從來沒有考慮單運行循環的情況。如果我們的視圖停留在畫面上一定時間,並經常被反覆佈局,那麼我們的計數器遲早會超過門檻。但這並不是因為記憶體問題。

那這個問題如何解決呢?我們只需要在每次運行循環迭代時,重置計數器就行了。為了要這麼做,我們可以建立一個 DispatchWorkItem 來重置計數器,並非同步地將它傳送到主佇列 (main queue)。這麼一來,當下一次運行循環進入到主執行緒時,它將會被呼叫:

static var ResetWorkItemKey = “_resetWorkItem”


...

if let previousResetWorkItem = objc_getAssociatedObject(view, &RuntimeConstants.ResetWorkItemKey) as? DispatchWorkItem {
    previousResetWorkItem.cancel()
}
let currentResetWorkItem = DispatchWorkItem { [weak view] in
    guard let strongView = view else { return }
    objc_setAssociatedObject(strongView, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
DispatchQueue.main.async(execute: currentResetWorkItem)
objc_setAssociatedObject(view, &RuntimeConstants.ResetWorkItemKey, currentResetWorkItem, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)

最終的程式碼:

struct LayoutLoopHunter {

    struct RuntimeConstants {
        static let Prefix = “runtime”
    
        // 關聯對象鍵
        static var CounterKey = “_counter”
        static var ResetWorkItemKey = “_resetWorkItem”
    }
    
    static func setUp(for view: UIView, threshold: Int = 100, onLoop: @escaping () -> ()) {
    // 我們以功能及原本的類別名稱為前綴字來為新類別命名
        let classFullName = “\(RuntimeConstants.Prefix)_\(String(describing: view.self))”
        let originalClass = type(of: view)
    
        if let trackableClass = objc_allocateClassPair(originalClass, classFullName, 0) {
            // 這個類別並未在現在的運行區塊中被建立
            // 我們需要註冊我們的類別,並與原本視圖的類別交換
            objc_registerClassPair(trackableClass)
            object_setClass(view, trackableClass)
    
            // 現在我們可以建立關聯對象
            objc_setAssociatedObject(view, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    
            // 加入我們的 layoutSubviews 實作
            let layoutSubviews: @convention(block) (Any?) -> () = { nullableSelf in
                guard let _self = nullableSelf else { return }
    
                let selector = #selector(originalClass.layoutSubviews)
                let originalImpl = class_getMethodImplementation(originalClass, selector)
    
            // @convention(c) 告訴 Swift 這是一個空的函式指標(沒有內容物件)
        // 所有 Objective-C 方法函式都會有接收器及訊息作為它們頭兩個參數
        // 因此這表示 () -> Void 型別的方法與 layoutSubviews 相符
                typealias ObjCVoidVoidFn = @convention(c) (Any, Selector) -> Void
                let originalLayoutSubviews = unsafeBitCast(originalImpl, to: ObjCVoidVoidFn.self)
                originalLayoutSubviews(view, selector)
    
                if let counter = objc_getAssociatedObject(_self, &RuntimeConstants.CounterKey) as? Int {
                    if counter == threshold {
                        onLoop()
                    }
    
                    objc_setAssociatedObject(view, &RuntimeConstants.CounterKey, counter+1, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
                }
    
                // 調度 Work Item 以在每次新的運行循環迭代時重置計數器
                if let previousResetWorkItem = objc_getAssociatedObject(view, &RuntimeConstants.ResetWorkItemKey) as? DispatchWorkItem {
                    previousResetWorkItem.cancel()
                }
                let counterResetWorkItem = DispatchWorkItem { [weak view] in
                    guard let strongView = view else { return }
                    objc_setAssociatedObject(strongView, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
                }
                DispatchQueue.main.async(execute: counterResetWorkItem)
                objc_setAssociatedObject(view, &RuntimeConstants.ResetWorkItemKey, counterResetWorkItem, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            }
            let implementation = imp_implementationWithBlock(layoutSubviews)
            class_addMethod(trackableClass, #selector(originalClass.layoutSubviews), implementation, “v@:“)
        } else if let trackableClass = NSClassFromString(classFullName) {
            // 我們之前在這個運行區塊中分配了一個相同名字的類別
            // 我們可以從原始字串去取得它,並以相同的方式與視圖類別交換
            object_setClass(view, trackableClass)
        }
    }
}

總結

完成了!現在你可以為所有特定的視圖設定分析事件、發佈 App、並找出問題所在。你可以將範圍縮小到特定的視圖,並不必讓使用者知道,就可以在他們的幫助下解決問題。

最後,我想提醒各位:能力越強,責任越大。Runtime Programming 非常容易發生錯誤,因此很容易在不知情的狀況下,讓你的 App 發生其他嚴重問題。因此,我總是建議大定,將 App 中所有危險的程式碼包裹在某種 KillSwitch 中,你可以從後端出發,並在發現它引起的問題時禁用該功能。有興趣的話,你可以看 Firebase 上的關於 Feature Flags 的這篇文章

你可以在 GitHub 上取得完整的程式碼,同時也發佈在 CocoaPods 上供你的專案來追蹤視圖循環。

P.S. 我想在此特別鳴謝 Aleksandr Gusev,感激他對本篇文章提出的許多想法及幫忙審閱。

作者簡介:Ruslan Serebriakov 現年 21 歲,是一名 Booking.com 的 iOS 開發人員,現居於上海。
譯者簡介:楊敦凱-目前於科技公司擔任 iOS Developer,工作之餘開發自有 iOS App 同時關注網路上有趣的新玩意、話題及科技資訊。平時的興趣則是與自身專業無關的歷史、地理、棒球。來信請寄到:[email protected]

原文Debugging Out of Memory Issues: Catching Layout Feedback Loop with the Runtime Magic

作者
AppCoda 編輯團隊
此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。