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

語言: CN / TW / HK

作者:劉太舉(駑良)

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

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

  • Mac 端:

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

  • FlutterEngine shutdown 階段死鎖問題;
  • 低版本 macOS OpenGL 析構階段 Crash 問題;

  • Windows 端:

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

  • FlutterPlugin 註冊階段野指標 Crash;
  • Flutter Window 可見性變化之後頁面白屏。

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

FlutterEngine Mac 端問題

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

問題背景

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

定位分析

一句話原因:

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 方法並未被呼叫。

處理方案

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

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

1.2 FlutterEngine shutdown 階段死鎖問題

問題背景

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

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

UI 執行緒狀態:

Raster 執行緒:

定位分析

一句話原因:

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

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

處理方案

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

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

問題背景

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

  1. 將 FlutterRenderer 中繫結的 FlutterView 置為 nil;
  2. 呼叫 FlutterView shutdown 方法;
  3. 呼叫 FlutterEngine shutDownEngine 方法。

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

定位分析

一句話原因:

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

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

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

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

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

處理方案

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

  1. 呼叫 FlutterView shutdown 方法;
  2. 呼叫 FlutterEngine shutDownEngine 方法。

FlutterEngine Windows 端問題

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

問題背景

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

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

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

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

定位分析

一句話原因:

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

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

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

  1. OnBitmapSurfaceUpdated 是 FlutterWindowsView 的成員函式。但是在輸出最後兩行 OnBitmapSurfaceUpdated 方法時,FlutterWindowsView 的解構函式已被執行(野指標);
  2. 最後一次執行 OnBitmapSurfaceUpdated 時,渲染使用的 Window 控制代碼為 nullptr,即可供渲染的視窗(與 FlutterWindowsView 繫結)以被釋放。

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

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

處理方案

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

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

2.2 FlutterPlugin 註冊階段野指標 Crash

問題背景

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

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

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

  1. Crash 出現在 FlutterEngine 初始化階段,具體是在 Plugin 註冊時出現異常;
  2. 導致 Crash 原因是野指標問題。

定位分析

一句話原因:

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 地址不會被清除,再次使用時即會導致問題。

處理方案

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

具體可參考:

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

問題背景

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

  • 先通過 ShowWindow(flutter_wnd, SW_HIDE) 隱藏;
  • 再通過 ShowWindow(flutter_wnd, SW_SHOWNORMAL) 顯示出來。

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

定位分析

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

處理方案

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

總結

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

關注【阿里巴巴移動技術】,阿里前沿移動乾貨&實踐給你思考!