Chuyển đến nội dung chính

Finding a Flutter memory leak: the five objects that never get disposed

A memory leak in Flutter rarely announces itself. The app works, tests pass, and then somebody navigates between two screens forty times and the process is at 900 MB. On a mid-range Android device that ends as an out-of-memory kill, reported to you as “the app closed by itself” with no stack trace.

Dart is garbage collected, so a leak here means exactly one thing: something still holds a reference to an object you are done with. The whole investigation is finding what that something is.

The five patterns

In practice, almost every leak I have chased in a Flutter app was one of these.

1. A controller that is never disposed. AnimationController, TextEditingController, ScrollController, TabController, PageController — every one of them holds listeners and, in the animation case, a ticker registered with the scheduler.

class _EditorState extends State<Editor> with SingleTickerProviderStateMixin {
  late final _text = TextEditingController();
  late final _anim = AnimationController(vsync: this, duration: _kFade);
  late final _scroll = ScrollController();

  @override
  void dispose() {
    _text.dispose();
    _anim.dispose();
    _scroll.dispose();
    super.dispose();
  }
}

An AnimationController without dispose keeps ticking after its widget is gone — that is both a leak and wasted frame work, and Flutter will eventually assert about a disposed TickerProvider.

2. A listener added and never removed. This is the same shape but easier to miss, because the object you leak is not the one you created.

@override
void initState() {
  super.initState();
  widget.model.addListener(_onModelChanged);
}

@override
void dispose() {
  widget.model.removeListener(_onModelChanged);
  super.dispose();
}

The ChangeNotifier here outlives the widget — it is the app’s model. Because it holds _onModelChanged, and that closure holds this, the model holds your State, which holds its Element, which holds a whole subtree. One missing removeListener retains an entire screen.

The variant that breaks in a subtler way: didUpdateWidget when widget.model changes identity. If you only add in initState, you listen to the old model forever.

@override
void didUpdateWidget(covariant MyWidget old) {
  super.didUpdateWidget(old);
  if (old.model != widget.model) {
    old.model.removeListener(_onModelChanged);
    widget.model.addListener(_onModelChanged);
  }
}

3. A stream subscription that is never cancelled. Same mechanism, different API, and worse consequences because the callback keeps running.

StreamSubscription<Position>? _sub;

@override
void initState() {
  super.initState();
  _sub = locationStream.listen(_onPosition);
}

@override
void dispose() {
  _sub?.cancel();
  super.dispose();
}

A setState from a cancelled-too-late subscription is where “setState() called after dispose()” comes from. That error is the friendly version of this bug; the unfriendly version is silent retention.

4. A global or singleton that accumulates. A cache with no eviction, a list of past requests kept “for debugging”, a static Map<String, BuildContext>. Anything reachable from a top-level variable is reachable forever by definition.

The BuildContext case deserves its own warning: storing a context outside the widget that owns it retains the entire element subtree, and the stored context is invalid the moment that widget unmounts. If you find yourself keeping one, the design is wrong.

5. Images. Flutter’s ImageCache holds decoded images, and decoded size is width × height × 4 bytes, not file size. A 4000×3000 photograph is roughly 48 MB in memory regardless of the JPEG being 2 MB on disk. A gallery screen that decodes forty of those at full resolution is not leaking in the strict sense — it is doing exactly what you asked — but the outcome is the same.

Finding it with DevTools

The memory view gives you three tools. Use them in this order.

The chart, to confirm there is a leak at all. Do the suspect action ten times — push a screen, pop it, repeat — with a manual GC between rounds. Memory that steps up and never comes down is a leak. Memory that saws up and down is normal allocation.

Snapshot diffing, to find what class is leaking. This is the part that turns guessing into knowing:

  1. Take a heap snapshot.
  2. Perform the suspect flow (open the screen, close it) several times.
  3. Force a GC.
  4. Take a second snapshot and diff.

Any class whose instance count went up by the number of repetitions is your leak. Seeing _ProfileScreenState at ten instances when only one screen ever exists is the entire diagnosis.

The retaining path, to find who is holding it. Select the leaked instance and DevTools shows the chain of references from a GC root down to it. Read it from the bottom up; the first entry that surprises you is the bug. In pattern 2 above, the path reads roughly: static app model → listener list → closure → _ProfileScreenState.

That path is the answer. Everything before this step is confirming a leak exists; this step says which line to fix.

Making leaks fail loudly instead of quietly

The framework has a built-in leak tracker for exactly these object lifecycles. In tests, flutter_test can be configured to fail when a disposable object outlives its expected scope, which turns a class of production memory bugs into red CI. The available knobs move between releases, so check the version you are on — but if your project does not have this enabled anywhere, enabling it is a higher-value hour than any individual leak hunt.

Beyond tooling, three habits prevent most of the five patterns:

Pair every add with a remove in the same class. Write dispose immediately after initState, before the feature works. It is much harder to add later.

Prefer widgets that own their lifecycle. StreamBuilder cancels its own subscription. AnimatedBuilder with a controller you dispose is fine. A manual listen in initState is the risky shape.

Give caches a bound. An LRU with a size cap that you chose deliberately is a cache; a Map you only ever insert into is a leak with good intentions.

Images specifically

Image.network(
  url,
  cacheWidth: 400,   // decode to what you display, not the source size
  cacheHeight: 300,
)

// Bound the global cache
PaintingBinding.instance.imageCache
  ..maximumSize = 100                 // entries
  ..maximumSizeBytes = 50 << 20;      // 50 MB

// Drop it when the OS is asking for memory back
PaintingBinding.instance.imageCache.clear();

cacheWidth is the single highest-leverage line in a media-heavy app. It changes the decode itself, so a thumbnail costs thumbnail memory instead of full-resolution memory. ResizeImage is the same idea wrapped as an ImageProvider for cases where you control the provider rather than the widget.

Also handle didHaveMemoryPressure if your app holds large caches of its own — the platform tells you, and ignoring it makes your app the one the OS kills.

FAQ

Does setState after dispose leak memory?

The error itself is a symptom, not the leak — but the thing that could still call setState is by definition still holding your State, so it usually is one.

Should I use WeakReference?

Rarely. It exists, and it is the right tool for a cache that should not keep its keys alive, but reaching for it to “fix” a leak usually means the ownership model is unclear. Fix the ownership first.

Do closures capture more than I think?

Yes — this is the mechanism behind patterns 2 and 3. A closure captures the variables it references, and a method tear-off like _onModelChanged captures this, meaning the whole State.

Is a growing heap always a leak?

No. Dart’s GC is generational and does not run on a schedule you can predict. Force a GC before concluding anything, and measure the floor after collection rather than the peak.

Can I profile memory in release mode?

DevTools needs the VM service, so use profile mode. Release-only leaks are possible but rare; the reference graph is the same.


The DevTools workflow, disposal contracts and ImageCache behaviour described here are documented in the Flutter memory tooling guide and the API references linked above. The five-pattern framing, the ordering of the investigation, and the position that an unbounded cache is a leak are my own judgement from debugging apps this way. Leak-tracking APIs in flutter_test have changed across releases — check what your SDK exposes.


Originally published on FlutterCook. Read the latest version there — that copy is the one kept up to date.

Nhận xét

Bài đăng phổ biến từ blog này

5 concepts every Flutter dev should know

  Phụ lục: State management architecture Testing IDE Shortcuts Platform channel Maintaining a project Tôi đã làm việc với Flagship trong một thời gian dài, và đây là những điều mà tôi phát hiện ra là điều cần phải có đối với bất kỳ nhà phát triển Flagship nào, về tổng thể nó sẽ khiến bạn trở thành một nhà phát triển Flagship giỏi trong thời gian dài. 1. State management architecture Đây là một trong những chủ đề quan trọng nhất trong cộng đồng thiết bị rung, nó khá quan trọng nếu bạn muốn duy trì một dự án rung kích thước trung bình hoặc lớn. Nó sẽ giúp tạo một dự án suôn sẻ và thêm các tính năng mới một cách hoàn hảo.  2. Testing Đây là một chủ đề duy nhất mà tôi không hiểu tại sao nó lại quan trọng trước đó trong sự nghiệp của tôi, nhưng khi tôi tiến lên trong sự nghiệp của mình và có kinh nghiệm với nhiều dự án và vấn đề xảy ra trong môi trường sản xuất. Tôi đã nhận ra một cách khó khăn, tại sao điều này lại quan trọng như vậy. Nếu bạn vẫn muốn có thêm lý do để cân nhắc thử...

Thiết kế giao diện với DotNetBar (Phần 1)

Đây là phiên bản DotNetBar hỗ trợ C# và Visual Basic https://www.dropbox.com/s/wx80jpvgnlrmtux/DotNetBar.rar  , phiên bản này hỗ trợ giao diện Metro cực kỳ “dễ thương” Các bạn load về và cài đặt, khi cài đặt xong sẽ có source code mẫu của tất cả các control. Để sử dụng được các control của DotNetBar các bạn nhớ add item vào controls box. Thiết kế giao diện với DotNetBar, giao diện sẽ rất đẹp. Link các video hướng dẫn chi tiết cách sử dụng và coding: http://www.devcomponents.com/dotnetbar/movies.aspx Hiện tại DotNetBar có rất nhiều công cụ cực mạnh, trong đó có 3 công cụ dưới đây: DotNetBar for Windows Forms Requires with Visual Studio 2003, 2005, 2008, 2010 or 2012.   DotNetBar for WPF Requires with Visual Studio 2010 or 2012 and Windows Presentation Foundation.   DotNetBar for Silverlight Requires with Visual Studio 2010 or 2012 and Silverlight. Dưới đây là một số hình ảnh về các control trong DotnetBar.   Metro User Interface  controls with Metro Tiles, toolba...

Announcing Flutter 2

  Phụ lục: Flutter on the web Flutter 2 on desktops, foldables, and embedded devices The growing Flutter ecosystem Dart: The secret sauce behind Flutter Flutter 2: Available now Hôm nay, chúng tôi sẽ công bố Flutter 2: một bản nâng cấp lớn cho Flutter cho phép các nhà phát triển tạo các ứng dụng đẹp, nhanh chóng và di động cho bất kỳ nền tảng nào. Với Flutter 2, bạn có thể sử dụng cùng một cơ sở mã để gửi các ứng dụng gốc cho năm hệ điều hành: IOS, Android, Windows, macOS và Linux; cũng như trải nghiệm web nhắm mục tiêu các trình duyệt như Chrome, Firefox, Safari hoặc Edge. Flutter thậm chí có thể được nhúng vào ô tô, TV và thiết bị gia dụng thông minh, mang đến trải nghiệm di động và lan tỏa nhất cho thế giới điện toán xung quanh. Mục tiêu của chúng tôi là thay đổi cơ bản cách các nhà phát triển nghĩ về việc xây dựng ứng dụng, bắt đầu không phải với nền tảng bạn đang nhắm mục tiêu mà là với trải nghiệm bạn muốn tạo. Flutter cho phép bạn tạo ra những trải nghiệm tuyệt đẹp trong đó ...