实战检测Flutter内存泄漏

背景

Dart语言有一套自己的内存管理机制,内存泄漏是OOM的元凶为此我们开发者很有必要了解一下Dart的管理以及检测机制,特别是多人协同开发的大型项目如果对内存泄漏不高度重视很容易引发内存溢出。

什么是内存泄漏

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。内存泄漏缺陷具有隐蔽性、积累性的特征,比其他内存非法访问错误更难检测。因为内存泄漏的产生原因是内存块未被释放,属于遗漏型缺陷而不是过错型缺陷。此外,内存泄漏通常不会直接产生可观察的错误症状,而是逐渐积累,降低系统整体性能,极端的情况下可能使系统崩溃。

Flutter垃圾回收

Flutter程序运行在Dart VM中,在VM中Dart 又是以Isolate为单元管理自己的任务以及数据的,每个Isolate都有自己的独立Heap。Heap中有两个垃圾回收器新生代和老年代,大量的小对象会在新生代创建和回收它采用的是复制算法效率非常高。当对象存活很长时间都没新生代回收器回收后就会进入来老年代,老年代采用的是标记清除法,它相对于复制算法更省空间。
image
当活跃区内存满了垃圾回收器进行垃圾回收,Flutter中对垃圾回收进行了优化。为了尽量防止垃圾回收不影响UI渲染性能它选择在空闲时候主动进行垃圾回收。详细可参考: Flutter垃圾收集器
内存泄漏检测
使用弱引用引用待观测对象,并在合适的时机,发起虚拟机 GC 任务,然后检查弱引用的对象是否为 null。如果不为 null,说明发生了内存泄漏
image.png
Dart中的弱引用实现
Dart中也提供了弱引用_WeakProperty 在GC的时候如果对象不可达就会被回收,dart层源码位置在在/engine/src/third_party/dart/sdk/lib/_internal/vm/lib/weak_property.dart
_WeakProperty不直接提供给开发者,只能间接通过Expand 来间接使用弱引用,在Expand中有一个_data数组
Expando具体的实现源码在/engine/src/third_party/dart/sdk/lib/_internal/vm/lib/expando_patch.dart 中
它内部重载了两个操作符就是类似数组的set/get方法,注意Expand中的Key值不支持String,bool,num以及null类型的数据。

示例

var text = Text('expando');
Expando expando = Expando();
expando[text] = true;

set方法

  void operator []=(Object object, T? value) {
	...
      var ephemeron = new _WeakProperty();
      ephemeron.key = object;
      ephemeron.value = value;
      _data[idx] = ephemeron;
      
    ...
  }

get方法

 T? operator [](Object object) {

    var idx = object._identityHashCode & mask;
    var wp = _data[idx];

    while (wp != null) {
      if (identical(wp.key, object)) {
        return unsafeCast<T?>(wp.value);
      } else if (wp.key == null) {
        // This entry has been cleared by the GC.
        _data[idx] = _deletedEntry;
      }
      idx = (idx + 1) & mask;
      wp = _data[idx];
    }

    return null;
  }
怎么获取VM运行时数据和主动触发GC

我们可以借助于VM_Service去获取DartVM运行时的数据,Debug模式下DartVM在初始化Root Isolate的时候会启动一个Dart Service Isolate,这个Service Isolate可以获取VM运行时数据 dev tools中的调试信息正是基于此Isolate实现,参考devtools。Service Isolate中会启动一个WebSocket提供外部服务。
image.png
image.png

通过VM_Service获取运行时数据

VM_Service使用的json协议 ,具体可参考参考接口说明。VM_Service中都是通过id去获取类,对象的信息。首先我们要想办法获取对象运行时id,这里需要借助两个额外的顶级函数通过vmService.invoke获取对象的id。VM_Service中提供的数据结构有ObjRef, Obj两种类型ObjRef 指的是引用类型,数据比较简单,而Obj数据比较完善。
image.png
获取对象id示例代码

int _key = 0;

/// 顶级函数,通过invoke调用获取到key的id
String generateNewKey() {
  return "${++_key}";
}

Map<String, dynamic> _objCache = Map();

/// 顶级函数,通过invoke调用获取到object的id
dynamic key2Obj(String key) {
  return _objCache[key];
}


// dart对象转vm中的id
Future<String> obj2Id(dynamic obj, {sdk.Isolate? sdkIsolate}) async {
  String isolateId = _getIsolateId(sdkIsolate: sdkIsolate);
  VmService vmService = await getVmService();
  Isolate isolate = await vmService.getIsolate(isolateId);

  LibraryRef libraryRef = isolate.libraries!
      .where(
          (element) => element.uri == 'package:flutter_leaks/object_util.dart')
      .first;

  String libraryId = libraryRef.id!;

  // 用 vm service 执行 generateNewKey 函数生成 一个key
  Response keyRef =
      await vmService.invoke(isolateId, libraryId, "generateNewKey", []);
  //获取 generateNewKey 生成的key
  String key = keyRef.json!['valueAsString'];
  //把obj存到map
  _objCache[key] = obj;

  //key在vm中对应的id
  String vmkeyId = keyRef.json!['id'];
  try {
    // 调用 key2Obj 顶级函数,获取obj的在vm中的信息 (ps:使用vmService调用有参数的函数不能直接传参数的值,需要传参数在VM中对应的id)
    Response objRef =
        await vmService.invoke(isolateId, libraryId, "key2Obj", [vmkeyId]);
    // 获取obj在vm中的id
    return objRef.json!['id'];
  } finally {
    //移除map中的值
    _objCache.remove(key);
  }
}
主动触发VM GC

getAllocationProfile可以让VM进行Full GC,当DevTools Memory中出现蓝色小圆点就是VM GC了

vmService.getAllocationProfile(isolateId, gc: true);

对应Engine的代码

void Heap::CollectAllGarbage(GCReason reason) {
  Thread* thread = Thread::Current();

  // New space is evacuated so this GC will collect all dead objects
  // kept alive by a cross-generational pointer.
  EvacuateNewSpace(thread, reason);
  if (thread->is_marking()) {
    // If incremental marking is happening, we need to finish the GC cycle
    // and perform a follow-up GC to purge any "floating garbage" that may be
    // retained by the incremental barrier.
    CollectOldSpaceGarbage(thread, kMarkSweep, reason);
  }
  CollectOldSpaceGarbage(
      thread, reason == kLowMemory ? kMarkCompact : kMarkSweep, reason);
  WaitForSweeperTasks(thread);
}

image.png

获取调用链信息

获取引用链是比较麻烦的 vmService也给我们提供了方法获取,通过getRetainingPath可以简单的获取泄漏的引用链路,如果需要详细的知道代码所属的library、类、方法、行数等信息还需要自己额外的处理。

vmService.getRetainingPath(isolateId, objId, limit);

找到Flutter的泄漏对象

被检测的泄漏对象都是有生命周期管理的,其中的三棵树比较合适作为检测对象。Flutter中都是通过Navigator把一个页面压入和弹出栈的,可定义一个NavigatorObserver获取页面的Router再获取对应的Widget、Element或State 基于此写了一个内存泄漏检测示例,需要注意的地方是页面退出后不会立马GC。这里我们是延时一段时间手动GC一次,当发现对象还未被回收再次手动GC一次。

const int _defaultCheckLeakDelay = 15;

class LeakObserver extends NavigatorObserver {
  final ShouldAddedRoute? shouldCheck;
  final int checkLeakDelay;

  ///[callback] if 'null',the all route can added to LeakDetector.
  ///if not 'null', returns ‘true’, then this route will be added to the LeakDetector.
  LeakObserver(
      {this.checkLeakDelay = _defaultCheckLeakDelay, this.shouldCheck});

  @override
  void didPop(Route route, Route? previousRoute) {
    _remove(route);
  }

  @override
  void didPush(Route route, Route? previousRoute) {
    _add(route);
  }

  @override
  void didRemove(Route route, Route? previousRoute) {
    _remove(route);
  }

  @override
  void didReplace({Route? newRoute, Route? oldRoute}) {
    if (newRoute != null) {
      _add(newRoute);
    }
    if (oldRoute != null) {
      _remove(oldRoute);
    }
  }

  Map<String, Expando> _widgetRefMap = {};
  Map<String, Expando> _stateRefMap = {};

  ///add a object to check
  void _add(Route route) {
    route.didPush().then((value){
      Element? element = _getElementByRoute(route);
      if (element != null) {
     
        Expando expando = Expando('${element.widget}');
      
        _widgetRefMap[_generateKey(route)] = expando;
        if(element is StatefulElement){
          Expando expandoState = Expando('${element.state}');
        
          _stateRefMap[_generateKey(route)] = expandoState;
        }
      }
    });

  }

  ///check and analyze the route
  void _remove(Route route) {
    Element? element = _getElementByRoute(route);
    if (element != null) {
        Future.delayed(Duration(seconds: checkLeakDelay), (){
           LeaksTask(_widgetRefMap.remove(_generateKey(route))).checkLeak();
           if(element is StatefulElement){
             LeaksTask(_stateRefMap.remove(_generateKey(route))).checkLeak();
           }
        });
    }
  }

  String _generateKey(Route route){
    return '${route.hashCode}-${route.runtimeType}';
  }

  ///Get the ‘Element’ of our custom page
  Element? _getElementByRoute(Route route) {
    Element? element;
    if (route is ModalRoute &&
        (shouldCheck == null || shouldCheck!.call(route))) {
      //RepaintBoundary
      route.subtreeContext?.visitChildElements((child) {
        //Builder
        child.visitChildElements((child) {
          //Semantics
          child.visitChildElements((child) {
            //My Page
            element = child;
          });
        });
      });
    }
    return element;
  }
}

处理const 对象误报

const是编译是常量,下面的代码c1,c2都是同一个对象不会被GC回收,c3会重新分配堆内存。所以我们在做内存泄露统计时候应该要排除const对象的统计

main() {

  final c1 =  const ConstClosure();
  final c2 =  const ConstClosure();
  final c3 =   ConstClosure();

  print('c1 == c2 ---->  ${c1 == c2}');
  print('c1 == c3 ---->  ${c1 == c3}');

}

class ConstClosure {
  const ConstClosure();
}

在getRetainingPath中对于const常量对象它的引用是CodeRef,这里我们需要对const进行排除

聚合引用链

这里因为用到了Expando,我们在拿到检测对象后要立马将Expando置为null消除Expando对检测对象的引用避免我们通过getRetainingPath拿到的不是我们想要的泄漏链路,当对象泄漏了这条链路回到GC Root结束。
image.png
这里通过简单的聚合我们找到泄漏的链路,在实际项目中的链路可能会特别的长,需要做一些链路的删减才会直观明了

总结

内存泄露是开发中很常见的问题,如果不加以关注会引发我们的应用崩溃,出现内存泄漏的原因也是由于大家在开发过程中不够细心所以比较难已发现,通过此次实战我们可以开发出一个工具及时的检测出内存泄漏的点

参考

vm_service
Flutter垃圾收集器
Flutter内存泄漏监控

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值