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


試想像這樣的一個情境:你的 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() 的調用次數,當次數超過門檻時就印出日誌。聽起來很簡單,對吧?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

這裡所做的,不是用一般的方式來呼叫一個方法(也就是執行將在 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

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

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

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

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

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

最終的程式碼:

總結

完成了!現在你可以為所有特定的視圖設定分析事件、發佈 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編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
訂閲電子報

訂閲電子報

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

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

Shares
Share This