Skip to content

Latest commit

 

History

History
219 lines (152 loc) · 9.64 KB

Flutter-DL.md

File metadata and controls

219 lines (152 loc) · 9.64 KB

聊聊 Flutter & Dart 里的内存泄漏和优化,也许没你想的那么复杂

内存泄漏一直以来都是程序员无法回避的话题,但是其实你想在 Flutter 的 Dart 层面里真的制造出「完全无法回收」内存泄漏,其实也并不是那么容易。

我们先聊点八股的,简单来说,应用会创建一个根对象,根对象会直接或间接地引用 App 创建的所有其他对象,一般可以把整个关系视为对象之间的链,如果链中的某个链接断开,那么当它不存在引用时,则会被回收:

root -> A -> B -> C
root -> A -> B -/- C (Signals GC to de-allocate memory of C)

更直观的例子可以参考 Flutter 文档提供的,可以看到 myFunctionchild 最终会不变成“不可达”,然后被回收:

class Child{}

class Parent {
  Child? child;
}

Parent parent1 = Parent();

void myFunction() {

  Child? child = Child();

  // The `child` object was allocated in memory.
  // It's now retained from garbage collection
  // by one retaining path (root …-> myFunction -> child).

  Parent? parent2 = Parent()..child = child;
  parent1.child = child;

  // At this point the `child` object has three retaining paths:
  // root …-> myFunction -> child
  // root …-> myFunction -> parent2 -> child
  // root -> parent1 -> child

  child = null;
  parent1.child = null;
  parent2 = null;

  // At this point, the `child` instance is unreachable
  // and will eventually be garbage collected.

  …
}

而我们常说的内存泄漏,一般是指不再需要的内存被程序占用无法回收时,就可能会导致内存泄漏,而Dart GC 无法阻止所有内存泄漏,因为它只能释放不再引用的对象

一般来说,当使用构造函数创建对象时,相关的内存会由 Dart VM(虚拟机)在堆中分配,Dart VM 负责在创建对象时为对象分配内存,并在不再使用对象时取消分配内存。

在 Dart 里,如果不需要的对象还存在某些引用,例如全局变量或者静态变量,那么垃圾回收器就会无法识别它们,从而导致内存泄漏,常见的场景大概有:

  • 被全局/静态变量持有
  • 被闭包捕获
  • 对象未 dispose
  • ···

而对于这些场景里,最容易出现的泄漏大部份依赖于 BuildContext ,为什么 BuildContext 容易泄漏?因为很多操作都和 BuildContext 相关,例如: Theme.of(context)Provider.of(context)context.readcontext.pop() 等等。

BuildContext 一旦泄漏,基本就代表着整个控件或者页面完全无法回收,因为 BuildContextElement 的抽象,而 Element 又作为“桥梁”管理和沟通着 WidgetRenderObject

因为 Element 里强引用了 WidgetRenderObject ,这两者的 GC 依赖于 Elementunmount ,甚至 StatefulWidget 对应的 State 回收,也同样依赖其 Elementunmount

Element 又等于 BuildContext ,所以, BuildContext 泄漏就约等于大家一起无法回收。

那么我们到这里就知道了,其实要让对象可以被 GC,那么就是让他不被其他对象所持有,简单说就是对需要 GC 的对象赋 null ,事实上 Flutter/Dart 里很多 dispose 操作,也就是给对应 Listener 设置为 null:

那么,为什么我前面又要说 Flutter 在 Dart 层不容易造成“完全”泄漏呢?因为 「可能造成泄漏≠一定会泄漏」,「暂时泄漏≠完全泄漏」

举个例子,这是一个 Flutter 里经常提到内存泄漏案例,因为 Timer 里的闭包,也就是函数对象持有了 context ,所以在闭包生命周期内, context 对应的控件或者页面会无法回收,出现泄漏:

Timer(Duration(seconds: 5), () {
  print(context.size); // 持有 context 的引用
});

但是它又不是「致命泄漏」,因为对于这个函数对象来说,它的生命周期也就是 5s ,5s 后这个闭包其实就没有外部引用,GC 其实就可以顺利清除掉它。

举个类似例子,以下代码里 Future.delayed 持有了 context ,所有在 Navigator.pop 之后,context 所在页面在刚返回时无法被回收,但是,如图所示,在等待 5s 后再手动执行 GC ,看 DevTools 下的 Instances 数量,最终对应的页面还是可以被成功回收:

ElevatedButton(
    onPressed: () {
      Future.delayed(const Duration(seconds: 5), () {
        print(this.context.widget);
      });
      Navigator.pop(context);
    },
    child: const Text("back")),

类似的代码还有这个,例如这里 handler 在闭包里因为 Theme.of 捕获了 context ,但是因为闭包的生命周期没有超过了 Widget 的生命周期,所以其实并不会实际造成回收问题:

@override
Widget build(BuildContext context) {
  final handler = () => print(Theme.of(context));

  return ElevatedButton(
    onPressed: handler,
    child: Text('Apply Theme'),
  );
}

接着我们再看一个例子,我们每个页面都通过 Timer 开了一个定时器,然后我们在页面退出时不主动销毁它,可以看到对应的页面都无法被销毁,因为此时 Timercallback 闭包还被 Engine 里其他对象「外部持有」,而导致 State 无法被正常回收:

int _counter = 0;
Timer? _timer;
@override
void initState() {
  _timer = Timer.periodic(Duration(seconds: 1), (timer) {
    print(this._counter);
    print(timer.tick);
  });
  super.initState();
}

但是,如果你把代码修改为如下所示,Timer 的 callback 捕获了 context ,然后在页面退出后,你认为会发生什么事情?

int _counter = 0;
Timer? _timer;
@override
void initState() {
  _timer = Timer.periodic(Duration(seconds: 1), (timer) {
    print(context.size);
  });
  super.initState();
}

事实上会出现如 「This widget has been unmounted, so the State no longer has a context (and should be considered defunct).」 的报错,但是后续 context 所在页面还是可以被 GC,因为 callback 异常会打断 Timer 的后续定时执行:

所以对于 Timer ,虽然我们没有回收它,但是如果它的 callback 出现异常,循环被打断,那么相关闭包也会变成可被 GC 的情况

如果对于 Timer 机制感兴趣的,可以看到:https://juejin.cn/post/7383281753145475099#heading-5 ,其实异步 Future 和 Timer 也有关系。

还有一种是 AnimationController ,如下代码所示,通过创建 AnimationController 之后,不执行相关 cancel 操作,可以看到在页面退出后,也无法触发 GC :

int _counter = 0;
late AnimationController animationController;

@override
void initState() {
  animationController =
      AnimationController(vsync: this, duration: const Duration(seconds: 60));
  animationController.addListener(() {
    print(this._counter);
  });
  animationController.repeat();
  super.initState();
}

当然,其实 AnimationController 的问题更多是因为全局单例的 SchedulerBinding.instance.scheduleFrameCallback 在 tick 时持有了闭包。

最后就是典型的全局引用闭包导致的泄漏,如下代码所示,将 test 放到全局 closures 列表里,会导致闭包一直被外部持有无法回收,从而让闭包捕获的 context 和 state 都无法回收:

final List<Function> closures = [];

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  @override
  void initState() {
    var test = () {
      _counter++;
       print(context.widget);
    };
    closures.add(test);
    super.initState();
  }

类似的典型例子还有官方修复 UndoHistory 的内存泄漏问题,UndoManager.client 是一个静态变量,在失去焦点和控件销毁时,需要将 UndoManager.client 的引用清空,不然静态变量变量会一直持有 UndoHistoryState ,导致它无法被回收而出现内存泄漏:

UndoHistoryTextField 的内部控件之一。

所以,有没有和你想的不大一样?就是其实一不小心写的闭包就会导致内存泄漏,都是实际上也还兜得住,主要还是看内存泄漏的严重程度,只要不是存在静态和全局引用,一般来说闭包的生命周期不会很长,还是可以在最后被 GC。

当然,良好的代码习惯可以加速内存回收,同时避免内存泄漏的出现,因此正确使用 context 还是很有必要的,比如:

  • 要尽可能不要让 context 出现在异步和闭包里面
  • 要尽可能不让闭包被全局持有

总结起来就是:小心静态和全局变量,注意 BuildContext 和闭包捕获

当然,有的时候,内存泄漏可能更多来自底层问题,比如过去就有在异步操作里的闭包过度捕获#42457,导致出现的内存泄漏问题:

另外有时候还要避免内存膨胀,例如在大量数据操作时,使用 BytesBuilder 代替 Uint8List 频繁计算过程中的频繁 GC ,也是优化的一种方式。

最后,在适当场景使用 WeakReferenceFinalizer ,也可以有效帮助优化内存场景。