How to Find Memory Leaks in Flutter Apps?

Mastering memory management with DevTools to discover memory leaks in Flutter

Ujas Majithiya
Simform Engineering

--

In a large code base, identifying memory-related issues can occasionally prove challenging. To address this, we can leverage DevTools offered by Flutter. In this article, we will explore different tool sets to find memory leaks in a Flutter app using DevTools.

Basics of memory in Dart

When an object is created using a constructor, its memory is allocated in the heap by the Dart VM (Virtual Machine). Dart VM takes care of allocating memory to objects when they are created and de-allocating memory when they are no longer being used.

A Dart application creates a root object, which references all other objects the application creates, directly or indirectly.

We can think of it as a chain between the objects, ultimately reaching the root object. If a link in the chain is broken, it signals the garbage collector (GC) to de-allocate the memory of the object.

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

Using Memory View in DevTools to find memory leaks

We will be using the below code to check memory leaks in DevTools.

Step — 1 Run the app in profile mode

First, to run the app in profile mode, use the --profile flag; this will get the most accurate memory allocations for your app in DevTools.

flutter run --profile

Step — 2 Open DevTools

You can open DevTools in two ways:

  1. By clicking on Open DevTools in the Performance tab

2. By clicking on the DevTools icon in the Run tab

Step — 3 Open the memory tab

Click on the Memory tab in DevTools and enable Refresh on GC. Enabling refresh on the garbage collector will update the list of referenced objects as they are referenced or dereferenced.

Step — 4 Navigate to Heavy Task Screen

Navigating HeavyTaskScreen, HeavyObj gets registered in the memory.

Here is the code snippet:

class HeavyTaskScreen extends StatefulWidget {
const HeavyTaskScreen({super.key});

@override
State<HeavyTaskScreen> createState() => _HeavyTaskScreenState();
}

class _HeavyTaskScreenState extends State<HeavyTaskScreen> {
@override
void initState() {
super.initState();
Future.delayed(const Duration(seconds: 2), () {
for (var i = 0; i < 10000; i++) {
Singleton.instance.objectList.add(HeavyObj());
}
});
}

@override
Widget build(BuildContext context) {
return const Scaffold();
}
}

In HeavyTaskScreen, we are adding 10,000 elements to the list, which is in a Singleton. We can think of it as an API call where it deserializes a large JSON object or performs some other heavy task.

Now, if you notice in GIF, we have 10,000 objects of HeavyObj that aren’t getting dereferenced even after closing that screen or if we navigate to another screen. We can call this a memory leak because HeavyTaskScreen is no longer on the screen, but operations done on that screen are still consuming memory.

Now, you may ask, why does this happen?

As we discussed above, until the reference chain between the root object and the other object isn’t broken, GC will not be signaled to remove the object from memory. As HeavyObjs can still be reached from the Singleton, their reference is retained to the root object, so GC will not be signaled to remove the object even after HeavyTaskScreen is closed.

How can we fix this?

We can fix this in two ways:

  1. Make Singleton nullable

By making Singleton nullable, we can dereference the whole Singleton using a disposal method when we do not need it.
Like this:

class Singleton {
Singleton._();

static Singleton instance;

factory Singleton() => instance ??= Singleton._();

List<HeavyObj> objectList = [];

void dispose() {
instance = null;
}
}
// Calling in dispose method of stateful widget when we do not need it.
@override
void dispose() {
Singleton.instance.dispose();
super.dispose();
}

2. Use packages like flutter_modular or get_it

Using packages like these, we can create singletons or dependencies, module-wise or scope-wise. When a module/scope is removed from memory, all the registered classes are automatically disposed of, so we do not have to take care of it manually as we did above.

Compare different time frames

We can use DevTools to compare the app's two time frames directly. For example, first, we are at the HomeScreen, and we capture a snapshot, and then we navigate to Page1 and capture another snapshot, and now we can compare those snapshots.

HomeScreen:

class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});

@override
State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: ElevatedButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(
builder: (context) {
return const Page1();
},
));
},
child: const Text('Page1'),
),
),
Center(
child: ElevatedButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(
builder: (context) {
return const HeavyTaskScreen();
},
));
},
child: const Text('Heavy task screen'),
),
),
],
),
);
}
}

Now, in DevTools, head over to the Diff snapshot tab and click the record snapshot button.

When it completes loading the data, we will navigate to Page1.

class Page1 extends StatefulWidget {
const Page1({super.key});

@override
State<Page1> createState() => _Page1State();
}

class _Page1State extends State<Page1> {
late ScrollController controller;

@override
void initState() {
super.initState();
controller = ScrollController()..addListener(fn);
}

void fn() {}

@override
void dispose() {
controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const Page2(),
),
);
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

Now, capture the snapshot again. Now, you can compare both time frames.

Here, we can check how many instances of classes are there or how much size they are consuming by clicking on each main method.

Now, we can directly compare these two snapshots by clicking on the Diff button (Here, the comparison is between main-1 and main-2).

It will show the difference between those two snapshots. For example, how many new instances are there, how many instances are released and differences in memory occupied.

After navigating back to HomeScreen, I took another snapshot and compared it with the second snapshot using the dropdown button.

As you can see, it shows the number of released instances and the amount of memory released.

A common example

Now, let’s take an example where memory leak is very common.

Step — 1 Create leak-prone code

We will create two screens, Page 1 and Page 2. On both screens, we will initialize a ScrollController and attach a listener to it. On both screens, there will be a button that will allow users to navigate to the other screen.

late ScrollController controller;
@override
void initState() {
super.initState();
controller = ScrollController()..addListener(fn);
}
...
onPressed: () {
// Page1 -> Page2 & Page2 -> Page1
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const Page2(),
);
}

Step — 2 Push screen multiple times

Let’s push these screens 30 times and check their memory trace in DevTools.

As you can see, there are 30 instances of each page, and they occupy 11.7 MB of memory.

Step — 3 Remove listener

Now, we will just make a small change. We will remove the listener before pushing the screen.

onPressed: () {
controller.removeListener(fn);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const Page2(),
),
);
},

Now, let’s check the memory trace.

As we can see, there are still 30 instances of each page, but the occupied memory is 11.0 MB, which is slightly less than before.

For example, if we push a screen with heavy resources being used and then push another screen, the previous screen resources will still be occupied and won’t be released (which should not happen). Instead, we can release them from the memory and start reallocating them again as and when required.

If we don’t handle this scenario properly, it can cause memory to bloat. This means more memory is being used than is required, and if this issue gets bigger and piles up, the app can crash due to an out-of-memory issue.

The above scenario can be caused by an app that allows very deep nested navigation.

Conclusion

In this blog, we explored various strategies and methodologies for detecting memory leaks, including monitoring memory usage, when an object is retained, and utilizing the memory view. Armed with this knowledge, you can proactively address memory-related issues, leading to more robust and reliable Flutter applications.

For more updates on the latest tools and technologies, follow the Simform Engineering blog.

Follow us: Twitter | LinkedIn

--

--