前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

时间:2022-07-27 00:34:04

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

作者:刘太举(驽良)

《Dutter 系列文章》将阐述钉钉基于 Flutter 构建的跨四端应用框架(代号 Dutter)的技术实践与踩坑经验,共分为上、下两篇,上篇内容可点击 Dutter | 钉钉 Flutter 跨四端方案设计与技术实践,本文为下篇,感谢阅读。

本文主要介绍一下钉钉 Flutter 业务灰度过程中,在桌面端遇到并处理过的几个 FlutterEngine 层面的 Bug。具体包含:

  • Mac 端:
  1. FlutterEngine 退出之后内存泄漏问题;
  2. FlutterEngine shutdown 阶段死锁问题;
  3. 低版本 macOS OpenGL 析构阶段 Crash 问题;
  • Windows 端:
  1. Win7 设备渲染模块「Crash + 残影」问题;
  2. FlutterPlugin 注册阶段野指针 Crash;
  3. Flutter Window 可见性变化之后页面白屏。

下面来为大家分别介绍一下。

FlutterEngine Mac 端问题

1.1 FlutterEngine 退出之后内存泄漏问题

问题背景

Mac 端 FlutterViewController 在销毁之后,其开辟的内存并未并实际释放,会出现内存泄漏问题。此问题在 Flutter issue 中有一些讨论,但一直未有明确定位。在钉钉 Mac 端 Flutter 业务灰度过程中也遇到此问题,如无法处理将直接影响 Dutter 在 Mac 端落地的可行性:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

定位分析

一句话原因:

Mac 端 FlutterEngine 实现中对 weak property 使用不合理导致。FlutterViewController 强持有 FlutterEngine,后者持有一个指向 FlutterViewController 的 weak property。FlutterViewController 在 dealloc 流程中尝试释放 FlutterEngine,但是此时 FlutterEngine 中持有的 weak property 已经无法正确访问(nil),导致释放流程未能正常执行,出现泄漏。

下面结合具体实现来为大家做一个简单说明。

由于设计到 OC 和 C++ 对象生命周期管理问题, FlutterEngine 内部对象持有关系略微特殊一些,大致如下图所示:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

  • FlutterViewController 作为对外暴露的主要 Class,负责创建并持有 FlutterEngine 以及 FlutterView;
  • FluterEngine 在初始化阶段会自己强持有自己,并在 shutdown 时自我 Release;
  • FlutterEngine 会创建并持有 FlutterRenderer,FlutterRenderer 会强持有 FlutterView;
  • FlutterEngine 间接强持有 FlutterView;
  • FlutterEngine 有一个指向 FlutterViewController 的弱引用指针。

正常情况下,FlutterViewController 退出之后,会通过调用 FlutterEngine 的 setViewController 传入 nil 的方式,来触发 FlutterEngine shudown 动作。参考实现如下:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

即正常情况下,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 线程状态:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

Raster 线程:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

定位分析

一句话原因:

钉钉侧调用 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,堆栈大致如下:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

定位分析

一句话原因:

与问题2类似,此问题也是因为钉钉处理泄漏问题而引入。其大致由两方面因素迭代导致。一方面因为重置 FlutterOpenGLRenderer 绑定的 FlutterView,导致在 embedder 层创建的 OpenGL 对象被提前释放;另外一方面因为低版本 macOS OpenGL 实现不完善析构流程中未能对关键链路做保护,进而导致异常。

下面对异常相关代码做一下简答分析,避免其他同学再遇到类似问题。

1、在 FlutterEngine setViewController 方法中,如果处于释放流程,会调用 FlutterOpenGLRenderer setFlutterView 方法,并传入 nil:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

2、FlutterOpenGLRenderer setFlutterView 方法在入参为 nil 时,会释放其内部维护的 NSOpenGLContext 对象:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

3、FlutterEngine 底层实现会在 GrDirectContext 对象析构时执行 flush,如果此时 OpenGL 相关对象已经释放,在低版本 macOS(10.11, 10.12)会出现 Crash:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

处理方案

由于出现问题的部分是由钉钉上层代码触发,处理相对比较简单。最终我们在所有使用 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 落地桌面端踩过的“坑” | Dutter

由于迟迟无法定位导致此问题的具体原因、且 Flutter 官方表示他们对 Win7 设备的覆盖度并不完善「参考」。因此我们决定对 FlutterEngine 稍加定制,在 Win7 等陈旧设备上强制通过「软解模式」来渲染 Flutter 页面。

本以为通过此方式可以绕过此问题,但很不幸运的是此方案暴露了 FlutterEngine 里另外一个 Bug:通过「软解模式」来渲染页面时,FlutterViewController 关闭只有有一定概率会导致 Windows 桌面出现残影。

定位分析

一句话原因:

此问题主要是因为 FlutterEngine 内部 shutdown 流程中,未及时修改 FlutterWindowsEngine 指向 FlutterWindowsView 对象的指针,导致多线程场景下出现野指针;因为野指针导致raster 线程在 FlutterWindowsView 已经销毁情况下仍向其输出绘制帧,进而导致异常。

在定位时,我们通过增加辅助 log 的方式来加快问题定位过程。通过对关键节点补充日志,我们很快发现了可疑点:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

上图是出现问题之后关键节点输出的日志。我们通过日志可以得到以下关键信息:

  1. OnBitmapSurfaceUpdated 是 FlutterWindowsView 的成员函数。但是在输出最后两行 OnBitmapSurfaceUpdated 方法时,FlutterWindowsView 的析构函数已被执行(野指针);
  2. 最后一次执行 OnBitmapSurfaceUpdated 时,渲染使用的 Window 句柄为 nullptr,即可供渲染的窗口(与 FlutterWindowsView 绑定)以被释放。

因为最后渲染所使用 Window 句柄为 nullptr,进而导致出现残影问题。

补充说明:在调用 C++ 成员函数时,即使调用时 this 已经为野指针,但只要成员函数中并未访问到 this 对象,则不会出现内存访问异常(Crash)。

处理方案

修改 FlutterEngine 内部实现,在 SoftwareRenderer 模式下 FlutterWindowsView 析构时,置空 FlutterWindowsEngine 指向其的指针(因 GPU 模式会有异常输出,暂未修改):

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

通过此方式,可以保证在 FlutterWindowsView 销毁之后 raster 线程中的任务不会再回调渲染接口:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

2.2 FlutterPlugin 注册阶段野指针 Crash

问题背景

在钉钉 Flutter 版本「+面板」业务 Windows 端一灰、二灰阶段出现较多例 Crash,客户端整体 Crash 率高达 x%:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

通过简单分析,还原 Crash 堆栈大致如下:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

从堆栈可以达到两个比较重要的信息:

  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。

下面简单介绍一下分析过程。通过暴力测试,我们可以复现问题:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

根据上图可以确认,出现 Crash 是因为 FlutterEngine 对象野指针导致。进一步定位插件注册时 Engine 指针来源,最终可定位到 flutter::PluginRegistrarManager::GetInstance()->GetRegistrar() 方法中:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

进一步分析 PluginRegistrarManager 中的实现,可知 GetRegistrar 内部需要 map + emplace 方法来维系 FlutterEngine 地址与 Registrar 关系:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

其内部会通过 FlutterDesktopPluginRegistrarSetDestructionHandler 将方法注册到底层 Engine 对象中,其会在 FlutterEngine 析构时被调用,进而解除绑定关系:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

问题即出现在此流程中,如果 PluginRegistrarManager 并非真正的单例,且 FlutterEngine 只能维护一份有效的 OnRegistrarDestroyed 回调,那么在 FlutterEngine 析构时,有部分 PluginRegistrarManager 对象中保存的 FlutterEngine 地址不会被清除,再次使用时即会导致问题。

处理方案

修改 FlutterEngine wrapper 层 PluginRegistrarManager 实现,优化「单例」实现方案。将单例生命周期周期管理下层到底层,wrapper 层仅负责提供相关服务。

具体可参考:

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑” | Dutter

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 端要更有优势。

关注【阿里巴巴移动技术】,阿里前沿移动干货&实践给你思考!