看腾讯企鹅辅导如何解决Flutter内存泄漏

辅导团队接入Flutter,上线核心页面已有半年有余。目前积累的主要经验包括架构设计、页面栈管理、跨平台接入层MJFlutter SDK、减包、首屏和内存优化、行为流跟踪等等。这篇文章分享下辅导团队在Flutter内存泄漏上的处理。

一. 起因

大家应该都注意到,目前业界接入Flutter,尤其在iOS端,都是使用单例的方式,也就是让FlutterViewController独立出来。那本文以iOS为例,分享下辅导团队的单例方式。我们在接入的时候,当时业界并没有很多案例分享自己的接入方式。而且我们的应用场景包括多个独立入口、以及多个二级和三级页面。那简单的处理,当然就是每进入一个入口,或者每次进入二级页面,分别开启一个FlutterVC。

开启一个FlutterVC,set initial route,哇,这种方式很香,好使。结果,当然是很致命,内存真的是暴增,低端机已经卡的不行了。老大们要是看到这个效果,肯定劈了我们。平复下心情,分下下原因:

1.每次启动一个FlutterVC,引擎就会帮我们开启一个dart vm,gpu、ui和io线程。显然,我们之前的处理方式太消耗资源了

  if (shell::IsIosEmbeddedViewsPreviewEnabled()) {
    blink::TaskRunners task_runners(threadLabel.UTF8String, // label
           fml::MessageLoop::GetCurrent().GetTaskRunner(),  // platform
           fml::MessageLoop::GetCurrent().GetTaskRunner(),  // gpu
           fml::MessageLoop::GetCurrent().GetTaskRunner(),  // ui
           fml::MessageLoop::GetCurrent().GetTaskRunner()   // io
    );
    // Create the shell. This is a blocking operation.
    _shell = shell::Shell::Create(std::move(task_runners),  // task runners
                                  std::move(settings),      // settings
                                  on_create_platform_view,  // platform view creation
                                  on_create_rasterizer      // rasterzier creation
    );
  } 

2. 从Flutter页面退出后,即退回入口 处的Native页面,Flutter占有的内存并不会释放掉。 我们在FlutterViewController的dealloc处加上断点,发现明明有被断住,这就很奇怪了

经过分析,以上两个问题是导致内存暴增的主要原因。有这样的严重问题,那我们就需要优先处理。对于第一问题,单例会是一个很好的方式,后面我们也发现业界普遍也都是这种方式。我们在AppDelegate中声明了Flutter,并进行相关的初始化。

二.Flutter Engine

为了解决第二个问题,我们注意到了Flutter Engine这个关键人物。

Flutter从1.0版本后引入了FlutterEngine,目的是解决早先版本中FlutterVC循环引用的问题。但最终的结果是FlutterVC可以正常释放(dealloc函数可以被断点断住),但FlutterEngine却hold住了。

看下官方给的定义,这个类主要用来管理Shell(TaskRunner),开启DartVM,基本的Channel(settng、lifecircle、platform)等等。可以看得出来,这是一个非常重要的对外接口。

为什么我们会注意到FlutterEngine这个关键人物呢,因为官方的issue也表示他们暂时也无能为力。

Google的技术讲道理不应该会有这样的问题,而且Flutter Stable已经发出几个版本了,一直没有得到合适的解决。那我们的理解主要包括两个原因:

  • 引擎仍然使用MRC的方式实现,有Retain的地方就需要有Release,这么复杂的功能内存管理相对会非常困难

  • Flutter从设计之初就希望整个App使用它的外壳,全局一个FlutterVC实现所有UI。这样也就不存在内存泄不泄露的问题了,所以官方能够这个问题没有得到解决的前提下,继续发布新版本

可能Google也没有想到,确实很多科技公司都在尝试Flutter,但大多数都是采用混合模式。所以Google暂时没有着重解决这个问题,那我们就需要自力更生了。

三. 动手实践

仔细阅读源码,我们发现整个链路,从FlutterEngine开始,内存引用就是一个大循环。

FlutterVC和FlutterEngine是一种弱引用的关系,所以FlutterVC可以正常释放。所以这两个之前我们不用太在意循环引用的问题,正常释放FlutterVC就可以了。

那么如何正确使用FlutterVC呢,FlutterEngine是从1.0后引入的。之前的版本,我们初始化一个FlutterVC,可以直接使用alloc,init的方式。如果升级到1.0以后的版本,这种方式仍然是ok的,FlutterVC内部会自动帮我们创建一个FlutterEngine来管理内部的Channel,dartVM等。

FlutterViewController *FlutterVC = [[FlutterViewController alloc] init];

//源码
- (instancetype)init {
  return [self initWithProject:nil nibName:nil bundle:nil];
}

- (instancetype)initWithProject:(FlutterDartProject*)projectOrNil
                        nibName:(NSString*)nibNameOrNil
                         bundle:(NSBundle*)nibBundleOrNil {
  self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
  if (self) {
    _viewOpaque = YES;
    _weakFactory = std::make_unique<fml::WeakPtrFactory<FlutterViewController>>(self);
    _engine.reset([[FlutterEngine alloc] initWithName:@"io.flutter" project:projectOrNil]);
    _flutterView.reset([[FlutterView alloc] initWithDelegate:_engine opaque:self.isViewOpaque]);
    [_engine.get() createShell:nil libraryURI:nil];
    _engineNeedsLaunch = YES;
    [self loadDefaultSplashScreenView];
    [self performCommonViewControllerInitialization];
  }

  return self;
}

既然引入了FlutterEngine,官方推荐的方式或者说正确的方式,应该是自己初始化一个FlutterEngine,用它来手动管理内部的channel等等。好处就是我们可以手动释放FlutterEngine,解决内存引用。这里有两个注意点:

1. [engine runWithEntrypoint:nil]: 这段代码表示FlutterEngine跑在主线程,不加会crash。Flutter是一个ui层面上的操作,所有的指定都需要在主线程

2. [flutterViewController setInitialRoute:@"route"]:  设置初始化路由,你会发现可能会不生效,真的很难用(Google自己说的)

- (void)initFlutter {    
    FlutterDartProject * dart = [[FlutterDartProject alloc] init];
    if (!self.engine) {
        FlutterEngine * engine = [[FlutterEngine alloc] initWithName:@"k12" project:dart];
        [engine runWithEntrypoint:nil]; //必需
        self.engine = engine;
    }
    FlutterViewController* flutterViewController = [[FlutterViewController alloc] initWithEngine:self.engine nibName:nil bundle:nil];    
    [GeneratedPluginRegistrant registerWithRegistry:flutterViewController];    

     [flutterViewController setInitialRoute:@"route"];
}

//FlutterViewController源码
- (instancetype)initWithEngine:(FlutterEngine*)engine
                       nibName:(NSString*)nibNameOrNil
                        bundle:(NSBundle*)nibBundleOrNil {
  NSAssert(engine != nil, @"Engine is required");
  self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
  if (self) {
    _viewOpaque = YES;
    _engine.reset([engine retain]);
    _engineNeedsLaunch = NO;
    _flutterView.reset([[FlutterView alloc] initWithDelegate:_engine opaque:self.isViewOpaque]);
    _weakFactory = std::make_unique<fml::WeakPtrFactory<FlutterViewController>>(self);

    [self performCommonViewControllerInitialization];
    [engine setViewController:self];
  }

  return self;
}

内存泄漏的主要原因在于FlutterEngine中定义的一些methodChannel、basicMethodChannel、plugin,这些channel是一种retain的关系。我们看下面的源码FlutterEngine持有methodChannel,methodChannel中又持有FlutterEngine。关键点在于methodChannel中真正对FlutterEngine释放放到了dealloc,但实际上methodChannel因为一直持有FlutterEngine,所以一直无法走到dealloc中,因此造成了循环引用。

那为什么官方一直不修改这样的引用方式呢,我们的猜想是这里整体工程量大,MRC实现起来改动会很多,影响整体稳定性。而且如果使用方没有处理好FlutterEngine,提前释放,后面再调用methodChannel,很有可能会crash。

另外还有FlutterEngine、FlutterChannel、FlutterDartProject中block的使用,需要认真修复。我们看下面的代码,问题很明显。

大体上我们找到了FlutterEngine内存泄漏的主要原因。修改完后,我们需要一个入口,释放FlutterEngine中持有的channel们,这里建议放到FlutterViewController dealloc中调用,释放Flutter占有的内存。

四. 使用

以上方式,释放内存后。我们发现其实并没有完全释放干净,这里留存下的内存实际上是Dart VM占有的内存。官方这里也给出了解释,如果释放了Dart VM,就无法再重新启动了,因为它也是单例的方式实现。不过相比与之前的内存暴增而无法释放已经好太多了。

修改完源码,我们需要创建自己的Engine,替换Flutter自身的引擎。网上讲述构建自己的Engine的文章很多,大家可以自己尝试下。

以上内容,不当之处欢迎指正、交流。

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章