Dutter | 前車之鑑:聊聊釘釘 Flutter 落地桌面端踩過的“坑”

語言: CN / TW / HK

《Dutter 系列文章》將闡述釘釘基於 Flutter 構建的跨四端應用框架(代號 Dutter)的技術實踐與踩坑經驗,共分為上、下兩篇,上篇內容可點選 《釘釘 Flutter 跨四端方案設計與技術實踐》 ,本文為下篇,感謝閱讀。

本文主要介紹一下釘釘 Flutter 業務灰度過程中,在桌面端遇到並處理過的幾個 FlutterEngine 層面的 Bug。具體包含:

  • Mac 端:

  • Windows 端:

下面來為大家分別介紹一下。

FlutterEngine Mac 端問題

1.1 FlutterEngine 退出之後記憶體洩漏問題

1 問題背景

Mac 端 FlutterViewController 在銷燬之後,其開闢的記憶體並未並實際釋放,會出現記憶體洩漏問題。此問題在 Flutter issue 中有一些討論,但一直未有明確定位。在釘釘 Mac 端 Flutter 業務灰度過程中也遇到此問題,如無法處理將直接影響 Dutter 在 Mac 端落地的可行性:

2 定位分析

一句話原因:

Mac 端 FlutterEngine 實現中對 weak property 使用不合理導致。FlutterViewController 強持有 FlutterEngine,後者持有一個指向 FlutterViewController 的 weak property。FlutterViewController 在 dealloc 流程中嘗試釋放 FlutterEngine,但是此時 FlutterEngine 中持有的 weak property 已經無法正確訪問(nil),導致釋放流程未能正常執行,出現洩漏。

下面結合具體實現來為大家做一個簡單說明。

由於設計到 OC 和 C++ 物件生命週期管理問題, FlutterEngine 內部物件持有關係略微特殊一些,大致如下圖所示:

  • FlutterViewController 作為對外暴露的主要 Class,負責建立並持有 FlutterEngine 以及 FlutterView;

  • FluterEngine 在初始化階段會自己強持有自己,並在 shutdown 時自我 Release;

  • FlutterEngine 會建立並持有 FlutterRenderer,FlutterRenderer 會強持有 FlutterView;

  • FlutterEngine 間接強持有 FlutterView;

  • FlutterEngine 有一個指向 FlutterViewController 的弱引用指標。

正常情況下,FlutterViewController 退出之後,會通過呼叫 FlutterEngine 的 setViewController 傳入 nil 的方式,來觸發 FlutterEngine shudown 動作。參考實現如下:

即正常情況下,FlutterViewController dealloc 之後應該觸發 369 行程式碼執行,進而釋放 FlutterEngine 資源。但是實際執行情況缺不是這樣,在程式碼執行到 359 行時,嘗試判斷 if (_viewController != controller) 時並未成立。通過上述程式碼我們知道,controller 是外部傳入的物件此時為 nil;_viewController 作為一個 weak proptry,在 FlutterViewController 進入 dealloc 流程之後也變為 nil。因而在此流程下,我們希望中的 shutDownEngine 方法並未被呼叫。

3 處理方案

問題定位之後處理方式就很簡單了,可以在 FlutterViewController dealloc 的時候手動觸發 FlutterEngine shutDownEngine 方法。並且通過在上層通過 OC 動態特性 hook 實現、或者直接修改重新編譯 FlutterEngine 都可以。

但此處修改一定要謹慎,注意完整還原 FlutterEngine 中的 shutdown 流程,否則可能導致我們遇到的第二個問題:死鎖。

1.2 FlutterEngine shutdown 階段死鎖問題

1 問題背景

釘釘最初在處理上述「FlutterEngine 洩漏」問題時,採用了一種相對比較簡單的方案:在 FlutterViewController dealloc 方法中,手動呼叫 FlutterEngine 提供的 shutDownEngine 方法,手動觸發相關資源釋放。

通過此方案,FlutterViewController 退出之後記憶體確實出現了下降,但是在灰度時發現偶爾會有整個頁面卡死的情況。通過對出現問題的鏈路進行簡單分析以及配合暴力測試,我們在 debug 環境對問題做了還原。最終初確認 UI 執行緒與 Raster 執行緒出現死鎖,死鎖之後的執行緒狀態大致如下。

UI 執行緒狀態:

Raster 執行緒:

2 定位分析

一句話原因:

釘釘側呼叫 FlutterEngine shutDownEngine 方法不合理導致。shutDownEngine 之前,必須先呼叫 FlutterView 的 shutdown 方法來停止渲染流程。待渲染流程正常停止之後,才可進入 FlutterEngine 資源釋放流程,否則即有可能出現上述死鎖問題。

因為此問題為釘釘呼叫不合理導致,具體異常原因不再深入分析,感興趣的同學可以根據上述線索自行查閱。

3 處理方案

在上層補全 FlutterEngine 釋放流程,在呼叫 FlutterEngine shutDownEngine 之前首先呼叫 FlutterView shutdown 停止 Raster 執行緒。

1.3 低版本 macOS OpenGL 析構階段 Crash 問題

1 問題背景

此問題還是接兩個問題,在處理完問題1和問題2之後,參考 FlutterEngine shutdown 流程,釘釘會在 FlutterViewController 析構之後做3件事情:

  1. 將 FlutterRenderer 中繫結的 FlutterView 置為 nil;

  2. 呼叫 FlutterView shutdown 方法;

  3. 呼叫 FlutterEngine shutDownEngine 方法。

經過一系列處理之後,測試發現記憶體洩漏和死鎖問題基本得以根治。但是在內部灰度過程中發現低版本 macOS 上會出現 Crash,堆疊大致如下:

2 定位分析

一句話原因:

與問題2類似,此問題也是因為釘釘處理洩漏問題而引入。其大致由兩方面因素迭代導致。一方面因為重置 FlutterOpenGLRenderer 繫結的 FlutterView,導致在 embedder 層建立的 OpenGL 物件被提前釋放;另外一方面因為低版本 macOS OpenGL 實現不完善析構流程中未能對關鍵鏈路做保護,進而導致異常。

下面對異常相關程式碼做一下簡答分析,避免其他同學再遇到類似問題。

  1. 在 FlutterEngine setViewController 方法中,如果處於釋放流程,會呼叫 FlutterOpenGLRenderer setFlutterView 方法,並傳入 nil:

  1. FlutterOpenGLRenderer setFlutterView 方法在入參為 nil 時,會釋放其內部維護的 NSOpenGLContext 物件:

  1. FlutterEngine 底層實現會在 GrDirectContext 物件析構時執行 flush,如果此時 OpenGL 相關物件已經釋放,在低版本 macOS(10.11, 10.12)會出現 Crash:

3 處理方案

由於出現問題的部分是由釘釘上層程式碼觸發,處理相對比較簡單。最終我們在所有使用 OpenGL 渲染的 Mac 裝置上(macOS 10.14 之前的版本)移除 FlutterView 置空動作。即最終 FlutterViewController 釋放階段只執行以下兩個動作:

  1. 呼叫 FlutterView shutdown 方法;

  2. 呼叫 FlutterEngine shutDownEngine 方法。

FlutterEngine Windows 端問題

2.1 Win7 裝置渲染模組「Crash + 殘影」問題

1 問題背景

此問題背景略微有些複雜,如果細分來看的話,此問題應該可以拆分為兩個子問題。

第一個問題是,在部分 Win7 裝置上(x86 + x64)出現 d3d11 導致的 Crash,堆疊大致如下:

由於遲遲無法定位導致此問題的具體原因、且 Flutter 官方表示他們對 Win7 裝置的覆蓋度並不完善「參考」(https://github.com/flutter/flutter/issues/92650#issuecomment-961341821)。因此我們決定對 FlutterEngine 稍加定製,在 Win7 等陳舊裝置上強制通過「軟解模式」來渲染 Flutter 頁面。

本以為通過此方式可以繞過此問題,但很不幸運的是此方案暴露了 FlutterEngine 裡另外一個 Bug:通過「軟解模式」來渲染頁面時,FlutterViewController 關閉只有有一定概率會導致 Windows 桌面出現殘影。

2 定位分析

一句話原因:

此問題主要是因為 FlutterEngine 內部 shutdown 流程中,未及時修改 FlutterWindowsEngine 指向 FlutterWindowsView 物件的指標,導致多執行緒場景下出現野指標;因為野指標導致raster 執行緒在 FlutterWindowsView 已經銷燬情況下仍向其輸出繪製幀,進而導致異常。

在定位時,我們通過增加輔助 log 的方式來加快問題定位過程。通過對關鍵節點補充日誌,我們很快發現了可疑點:

上圖是出現問題之後關鍵節點輸出的日誌。我們通過日誌可以得到以下關鍵資訊:

  1. OnBitmapSurfaceUpdated 是 FlutterWindowsView 的成員函式。但是在輸出最後兩行 OnBitmapSurfaceUpdated 方法時,FlutterWindowsView 的解構函式已被執行(野指標);

  2. 最後一次執行 OnBitmapSurfaceUpdated 時,渲染使用的 Window 控制代碼為 nullptr,即可供渲染的視窗(與 FlutterWindowsView 繫結)以被釋放。

因為最後渲染所使用 Window 控制代碼為 nullptr,進而導致出現殘影問題。

補充說明:在呼叫 C++ 成員函式時,即使呼叫時 this 已經為野指標,但只要成員函式中並未訪問到 this 物件,則不會出現記憶體訪問異常(Crash)。

3 處理方案

修改 FlutterEngine 內部實現,在 SoftwareRenderer 模式下 FlutterWindowsView 析構時,置空 FlutterWindowsEngine 指向其的指標(因 GPU 模式會有異常輸出,暫未修改):

通過此方式,可以保證在 FlutterWindowsView 銷燬之後 raster 執行緒中的任務不會再回調渲染介面:

2.2 FlutterPlugin 註冊階段野指標 Crash

1 問題背景

在釘釘 Flutter 版本「+面板」業務 Windows 端一灰、二灰階段出現較多例 Crash,客戶端整體 Crash 率高達 x%:

通過簡單分析,還原 Crash 堆疊大致如下:

從堆疊可以達到兩個比較重要的資訊:

  1. Crash 出現在 FlutterEngine 初始化階段,具體是在 Plugin 註冊時出現異常;

  2. 導致 Crash 原因是野指標問題。

2 定位分析

一句話原因:

Flutter 為 Windows 平臺提供 wrapper 層程式碼中,包含一個設計上為單例的物件 PluginRegistrarManager。PluginRegistrarManager 主要服務於 FlutterPlugin 註冊、設計上為一個單例,其內部通過 map 維持了一個 FlutterEngine 指標與 Registrar 的對映關係,保證 Registrar 與 FlutterEngine 生命週期保持一致。但是因為 wrapper 層的程式碼在構建時被編入了 pulgin.dll,導致每一個 plugin.dll 中都包含一份 PluginRegistrarManager 實現副本,即「單例機制」失效。帶來的問題是 FlutterEngine 析構時無法正確清除 PluginRegistrarManager 中的繫結關係,導致其內部維護一個失效的指標地址,再次訪問時出現 Crash。

下面簡單介紹一下分析過程。通過暴力測試,我們可以復現問題:

根據上圖可以確認,出現 Crash 是因為 FlutterEngine 物件野指標導致。進一步定位外掛註冊時 Engine 指標來源,最終可定位到 flutter::PluginRegistrarManager::GetInstance()->GetRegistrar() 方法中:

進一步分析 PluginRegistrarManager 中的實現,可知 GetRegistrar 內部需要 map + emplace 方法來維繫 FlutterEngine 地址與 Registrar 關係:

其內部會通過 FlutterDesktopPluginRegistrarSetDestructionHandler 將方法註冊到底層 Engine 物件中,其會在 FlutterEngine 析構時被呼叫,進而解除繫結關係:

問題即出現在此流程中, 如果 PluginRegistrarManager 並非真正的單例,且 FlutterEngine 只能維護一份有效的 OnRegistrarDestroyed 回撥 ,那麼在 FlutterEngine 析構時,有部分 PluginRegistrarManager 物件中儲存的 FlutterEngine 地址不會被清除,再次使用時即會導致問題。

3 處理方案

修改 FlutterEngine wrapper 層 PluginRegistrarManager 實現,優化「單例」實現方案。將單例生命週期週期管理下層到底層,wrapper 層僅負責提供相關服務。

具體可參考:

2.3 Flutter Window 可見性變化之後頁面白屏

1 問題背景

在 Windows 端 Flutter 頁面中,如果將 Flutter Window:

  • 先通過 ShowWindow(flutter_wnd, SW_HIDE) 隱藏;

  • 再通過 ShowWindow(flutter_wnd, SW_SHOWNORMAL) 顯示出來。

會發現 Flutter 頁面內容無法正常展示,畫布上為空白一片。如果在白屏之後通過 setState 或者 拖拽視窗等方式觸發  Flutter 頁面重新整理,則內容可被正常渲染。

2 定位分析

此問題相對比較明確,Flutter Windows 端實現存在 bug,在 Window 可見性發生變化之後,應重新出發 flush 將最新檢視繪製到對應視窗,但是目前此流程並未實現,導致出現以上問題。

3 處理方案

此問題已經提交issue,暫時釘釘側是通過上層補償的方式來繞過此此問題。我們在 Native Window 可視性變化之後,手動通知 Flutter 側重新整理當前可見頁面,以此觸發重繪、規避問題。

總結

以上即為釘釘 Flutter 落地過程中桌面端處理的幾大主要問題。從我們實際體驗來看,雖然在 Flutter v2.10 版本已經正式釋出對 Windows 的支援。但僅從穩定性角度來看,Flutter 在 Mac 端的表現無疑要優於 WIndows。如果有其它團隊希望在使用 Flutter 在桌面單端做一下嘗試,我們優先推薦選擇 Mac 端,其無論是上手門檻還是效能穩定性表現,相比 Windows 端要更有優勢。

關注我們,阿里前沿移動乾貨&實踐給你思考!