回顾我们共同解决的 Flutter 开发难题

发表时间: 2024-04-28 10:56

Flutter 技术并非新技术,近年以来备受各类型公司追捧,其中不乏头部公司参与其中。Soul 2020年开始逐步实践并推进,随着覆盖的业务场景越来越广,跌跌撞撞过程中经历过非常多大大小小的坑,在此分享部分血泪史,希望能够给到大家一些参考。

1. 一个表情竟然需要消耗120M内存


1.1 问题背景

在 iOS 上显示一个 Apple Emoji,居然要消耗120M以上内存,初次看到这个现象,倍感震惊啊!一个表情至于耗那么多内存吗,瞬间感觉 Flutter 推不下去的感觉。在实际业务研发过程中,我们经常会在文本中显示表情,特别是展示昵称时,普通显示一个表情内存会上涨约120M,对于内存比较敏感的场景显然这是难以被接受的。同时在官方的 Issue 中能够看到之前也有反馈类似的现象,可以参考下图关于内存的前后对比:

1.1.1 无表情

1.1.2 有表情

1.2问题原因

Flutter 在绘制表情时,并没有复用原生系统的表情内存 Buffer,哪怕是一个表情在首次绘制时会 Malloc 一份包含所有苹果表情数据,整个表情的 Sbix Table Zize 非常大,加在一起大约120M以上。

// font_skia.ccconst size_t table_size = typeface->getTableSize(tag);  if (table_size == 0)    return nullptr;  void* buffer = malloc(table_size);  if (buffer == nullptr)    return nullptr;

1.3问题处理

目前官方也在解决,但是我们遇到此问题时,使用的是2.x版本。另外为了后续能够跟进官方演进并没有采用修改引擎的方式解决此类问题。最终我们决定采用外接纹理(Texture)的方式规避问题,将真正绘制表情的动作放在原生端,如此一来就能完全复用原生表情已有的内存 Buffer,避免产生额外的内存增量。然后在 Flutter 端通过封装好的 SoText 组件调用原生端返回的 TextureId 所对应的内存进行展示。目前外接纹理使用相对成熟,大家可以参考官方提供的 Demo。

2. iOS 14真机启动 Debug 包别忘了连接 XCode

2.1 问题背景

当你在开发一个功能时,跑 Android 手机上打开丝滑,你感觉再简单试试 iOS 就可以收工的时候,信心满满打开 App 时,你会被泼一盆冷水,非常抱歉你的 App 无法启动并且百思不得其解,没有任何提示!收起短暂的悲伤情绪,各种尝试各种查阅资料你会发现,只有 iOS 14真机运行 Debug 包才存在这个情况,模拟器和 Release 一切正常。

2.2问题原因

在 iOS上,FlutterEngine 实例化需要 Dart 的初始化虚拟机。在Debug Flutter Runtime 模式下,会创建一个 JIT 模式的 Dart VM,Flutter 依赖于在进程中启用跟踪来启用JIT。随着最近 iOS 的版本(iOS 14+),这种机制不再支持,无奈啊,这就是系统规定。

2.3问题处理

各路大神已经给出了解决方案,你挑一个吧?

3. Lottie 动画 CPU 竟比原生高30%以上

3.1 问题背景

在业务研发中经常会有动画的需求,在 Soul App 中有大量 Lottie 的动画需要展示。社区也提供了 Flutter 的三方版本,在视频派对业务中使用就是三方的 Lottie 组件,在日常回归时发现 Flutter 播放动画时 CPU 比原生稳定要高30%以上(Flutter 使用的 Release 版本)。而视频派对本身对性能比较敏感,高出的 CPU 消耗意味着将会导致更多的发烫情况发生。

3.2 问题原因

开源提供的 Flutter 组件并没有复用原生的能力,完全编写的全新框架,实现相对比较复杂,目前并没有完全去深究其造成 CPU 上涨的原因,但确实是一个比较容易忽视的坑,日常开发中可以多注意下。

3.3 问题处理

问题解决也比较直接,通过 PlatformView 复用原生提供的 Lottie 组件。但对于 iOS 端需要特别注意需要设置成
LottieRendeingEngineCoreAnimation 类型。最终经过测试 CPU 能够对齐原生的消耗。

图中纵坐标为 CPU 消耗的百分比,虽然只有3%的差距,但是对于性能敏感的场景,持续的消耗会更容易发烫和耗电,可以看出优化之后与原生无差别。

4. Android 端从后台切回前台时,界面闪瞎眼

4.1 问题背景

从视频中可以看出界面中发生了闪烁行为,非必现问题。通过复现以后发现一般是发生在后台切回前台时大概率产生闪烁的行为,另外并没有发现明确的规律,在界面中多次都有可能会发生类似的限制。在以往的经验中即使多次刷新界面最多是产生卡顿行为,并没有出现闪烁的情况,虽然是偶现但是一旦出现严重影响用户体验

4.2问题原因

Flutter 规定进行后台时需要停止帧调度,进入前台时恢复帧调度行为。如果发送给 Flutter 框架的前后台事件与实际情况无法匹配上就会产生视频中的闪烁现象。

1. 目前在我们混合开发的工程中,目前同样采用了单引擎的架构,通过引入的 FlutterBoost 管理整体的路由和生命周期状态,那大家一定会熟悉以下的集成代码:

/// main.dart /// 添加全局生命周期监听类PageVisibilityBinding.instance.addGlobalObserver(AppLifecycleObserver());SoulBinding();WidgetsFlutterBinding.ensureInitialized();class SoulBinding extends WidgetsFlutterBinding with BoostFlutterBinding {}

我们可以大胆猜想 Flutter 对生命周期的处理方式将被 BoostFlutterBinding 所代理, 接下来我们看看是如何重写的:

/// BoostFlutterBinding.dart /// This class is to hook the Binding,to handle lifecycle eventsmixin BoostFlutterBinding on WidgetsFlutterBinding {  bool _appLifecycleStateLocked = true;  static BoostFlutterBinding? get instance => _instance;  static BoostFlutterBinding? _instance;  @override  void handleAppLifecycleStateChanged(AppLifecycleState state) {    if (_appLifecycleStateLocked) {      return;    }    Logger.log('boost_flutter_binding: '        'handleAppLifecycleStateChanged ${state.toString()}');    super.handleAppLifecycleStateChanged(state);  }}

FlutterBoost 对生命周期处理事件进行了拦截,但最终还是交还给 Flutter 框架处理,继续看下框架接受生命周期做了哪些事情:

///scheduler/binding.dartvoid handleAppLifecycleStateChanged(AppLifecycleState state) {    assert(state != null);    _lifecycleState = state;    switch (state) {      case AppLifecycleState.resumed:      case AppLifecycleState.inactive:        ///可见时进行帧率调度        _setFramesEnabledState(true);        break;      case AppLifecycleState.paused:      case AppLifecycleState.detached:        ///不可见时进行帧率调度        _setFramesEnabledState(false);        break;    } }bool _framesEnabled = true;  void _setFramesEnabledState(bool enabled) {    if (_framesEnabled == enabled)      return;    _framesEnabled = enabled;    if (enabled)      ///熟悉的帧绘制流程,此函数会请求vSync信号,等到信号到来时进行绘制系列流程      scheduleFrame(); }

篇幅原因,并没有对更具体的过程展开分析。至此我们知道了系统对于帧调度是有可见性要求的,Flutter 框架接收的前后台事件应该与容器实际的生命周期保持一致。

2. 查看过 FlutterBoost 源码的同学应该清楚,FlutterBoost 本身已经处理了前后台事件,但处理的方式很容易引起坑。我们知道 Android 端通常在识别是否为前后台时,采用的是 Activity 创建和销毁计数的方式,倘若因为冷启动首个 Activity 与前后台注册顺序发生错乱,那将导致计数发生偏差引发前后台发生错乱。此时刚好切后台并存在绘制行为再回前台将发生视频中的闪烁现象。

4.3问题处理

处理方式,可以修改 FlutterBoost 的源码解决问题,或者也可以通过 FlutterBoost 提供的属性屏蔽默认的前后台处理行为,自行控制前后台发送事件(采用的方式,无侵入性更灵活):

//屏蔽默认的前后台处理行为FlutterBoostSetupOptions.Builder().shouldOverrideBackForegroundEvent(true).build()//发送前后台变化事件,重点是业务上我们能够控制前后台的准确性app.registerActivityLifecycleCallbacks(object :Application.ActivityLifecycleCallbacks {     //前台时     FlutterBoost.instance().plugin.onForeground()     //后台时     FlutterBoost.instance().plugin.onBackground()  }}

整个过程原理比较简单,处理方式也比较简单,一旦发生类似的现象比较容易懵,比较隐秘的一个坑。

5. Android 使用 PlatformView 导致 Activity 泄漏

5.1 问题背景

Android 端在 Debug 环境中集成了 LeakCanary。每次关闭 Activity 时都会提示发生了内存泄漏无法正常释放,泄漏提示信息指示。

5.2问题原因

Activity 关闭时 PlatformView 的 dispose 未正确执行,进而导致 PlatformView 被PlatformViewsController(在一个引擎只有一个实例,对于单引擎模式即为单例)持有无法正常释放。

上图中为容器关闭时的简单时序图,重点在于粉色线条,执行容器 detach 函数时,将会执
platformViewsChannel.setPlatformViewsHandler(null) 逻辑。接着 AndroidView 执行 dispose 时(时机晚于 setPlatformViewHandler ),因 hander 为 null 直接返回,导致不能正常调用释放的函数,可参考如下代码:

private final MethodChannel.MethodCallHandler parsingHandler =      new MethodChannel.MethodCallHandler() {        @Override        public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {          // platformViewsChannel.setPlatformViewsHandler(null)导致handler为空,dispose不被执行,进而产生泄漏问题          if (handler == null) {            return;          }          Log.v(TAG, "Received '" + call.method + "' message.");          switch (call.method) {            case "create":              create(call, result);              break;            case "dispose":              dispose(call, result);              break;            default:              result.notImplemented();          }        }

5.3问题处理

依然保持不修改 Flutter 框架和三分库的原则,采用别的方式进行规避。使用过 PlatformView 同学应该知道,每次创建完 PlatformView 都会得到一个 ID,这样可以在 Activity 关闭时通过 ID 主动移除被持有的 PlatformView 实例,具体如下:

fun dispose(engine: FlutterEngine, id: Int){try {// 修复内存泄漏,经测试 dispose 方法不会主动触发,因此通过反射底层手动触发val platformViewsController: PlatformViewsController = engine.platformViewsControllerval field1 = platformViewsController.javaClass.getDeclaredField("channelHandler")       field1.isAccessible = trueval `object` = field1[platformViewsController]if (`object` is PlatformViewsChannel.PlatformViewsHandler) {val method = `object`.javaClass.getDeclaredMethod("dispose", Int::class.java)          method.isAccessible = true          method.invoke(`object`, id)        }   } catch (e: Exception) {        e.printStackTrace()   }}

6. 总结

以上为我们在实践所遇到的部分问题,大大小小的坑多如牛毛,虽然称为坑,但作为成年人我们都应该明白任何技术都会存在两面性。相对如此优秀的跨平台解决方案带给我们的人效提升收益,我们愿意孜孜不倦地去解决各种问题,后续我们也会更多分享交流实际过程中遇到的问题,共同促进。Flutter 官方依然发展速度非常快,支持力度大,现在的问题,将来都不是问题,总之未来可期。


作者:Soul 客户端

来源-微信公众号:Soul技术团队

出处
:https://mp.weixin.qq.com/s/pD8DlysfjzfYdNB2dNM8fQ