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

Tìm rò rỉ bộ nhớ trong Flutter: năm loại đối tượng không bao giờ được dispose

Rò rỉ bộ nhớ trong Flutter hiếm khi tự khai báo. Ứng dụng chạy được, test xanh, rồi ai đó chuyển qua lại giữa hai màn hình bốn mươi lần và tiến trình đứng ở mức 900 MB. Trên một máy Android tầm trung, kết cục là bị hệ thống giết vì hết bộ nhớ, và bạn nhận được báo cáo “app tự đóng” mà không có stack trace nào.

Dart có bộ thu gom rác, nên rò rỉ ở đây chỉ có đúng một nghĩa: vẫn còn thứ gì đó giữ tham chiếu tới một đối tượng bạn đã dùng xong. Toàn bộ cuộc điều tra là đi tìm cái “thứ gì đó” ấy.

Năm mô-típ

Trên thực tế, gần như mọi vụ rò rỉ tôi từng truy trong ứng dụng Flutter đều thuộc một trong số này.

1. Controller không bao giờ được dispose. AnimationController, TextEditingController, ScrollController, TabController, PageController — cái nào cũng giữ listener và, với trường hợp animation, còn giữ một ticker đã đăng ký với bộ lập lịch.

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();
  }
}

Một AnimationController không dispose sẽ tiếp tục tick sau khi widget của nó biến mất — vừa là rò rỉ vừa là công việc frame lãng phí, và cuối cùng Flutter sẽ assert về một TickerProvider đã bị huỷ.

2. Listener được thêm mà không bao giờ gỡ. Cùng hình dạng nhưng dễ bỏ sót hơn, vì đối tượng bạn làm rò rỉ không phải cái bạn tạo ra.

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

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

ChangeNotifier ở đây sống lâu hơn widget — nó là model của ứng dụng. Vì nó giữ _onModelChanged, và closure đó giữ this, nên model giữ State của bạn, cái này giữ Element của nó, cái này giữ cả một nhánh cây. Một lần thiếu removeListener giữ lại nguyên một màn hình.

Biến thể hỏng theo cách tinh vi hơn: didUpdateWidget khi widget.model đổi danh tính. Nếu bạn chỉ thêm trong initState, bạn sẽ nghe cái model cũ mãi mãi.

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

3. Stream subscription không bao giờ được huỷ. Cùng cơ chế, khác API, và hậu quả tệ hơn vì callback vẫn tiếp tục chạy.

StreamSubscription<Position>? _sub;

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

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

Một lệnh setState từ subscription huỷ quá muộn chính là nguồn gốc của “setState() called after dispose()”. Lỗi đó là phiên bản thân thiện của con bug này; phiên bản không thân thiện là giữ lại bộ nhớ trong im lặng.

4. Biến toàn cục hoặc singleton tích tụ dần. Một cache không có cơ chế loại bỏ, một danh sách các request cũ giữ lại “để gỡ lỗi”, một static Map<String, BuildContext>. Bất cứ thứ gì với tới được từ biến cấp cao nhất, theo định nghĩa, sẽ với tới được mãi mãi.

Trường hợp BuildContext đáng được cảnh báo riêng: lưu một context ra ngoài widget sở hữu nó sẽ giữ lại nguyên nhánh element, và context đã lưu trở nên vô hiệu ngay khoảnh khắc widget đó bị gỡ. Nếu bạn thấy mình đang giữ một cái, thì thiết kế đã sai.

5. Ảnh. ImageCache của Flutter giữ ảnh đã giải mã, và kích thước sau giải mã là rộng × cao × 4 byte, không phải kích thước file. Một tấm ảnh 4000×3000 chiếm khoảng 48 MB bộ nhớ bất kể file JPEG chỉ 2 MB trên đĩa. Một màn hình thư viện ảnh giải mã bốn mươi tấm như thế ở độ phân giải gốc không rò rỉ theo nghĩa chặt — nó đang làm đúng điều bạn yêu cầu — nhưng kết cục thì như nhau.

Tìm nó bằng DevTools

Memory view cho bạn ba công cụ. Hãy dùng theo thứ tự này.

Biểu đồ, để xác nhận có rò rỉ hay không. Thực hiện thao tác bị nghi ngờ mười lần — đẩy một màn hình vào, pop ra, lặp lại — với một lần GC thủ công giữa các vòng. Bộ nhớ bậc thang đi lên và không bao giờ xuống là rò rỉ. Bộ nhớ răng cưa lên xuống là cấp phát bình thường.

So sánh snapshot, để tìm class nào đang rò. Đây là phần biến việc đoán thành việc biết:

  1. Chụp một heap snapshot.
  2. Thực hiện luồng bị nghi ngờ (mở màn hình, đóng lại) vài lần.
  3. Ép GC.
  4. Chụp snapshot thứ hai và so sánh.

Class nào có số thể hiện tăng đúng bằng số lần lặp chính là chỗ rò của bạn. Thấy _ProfileScreenState có mười thể hiện trong khi chỉ tồn tại một màn hình duy nhất — đó là toàn bộ chẩn đoán.

Đường tham chiếu giữ lại, để tìm ai đang giữ nó. Chọn thể hiện bị rò và DevTools hiện chuỗi tham chiếu từ một gốc GC xuống tới nó. Hãy đọc từ dưới lên; mục đầu tiên khiến bạn ngạc nhiên chính là con bug. Ở mô-típ 2 phía trên, đường đó đọc đại khái: model tĩnh của ứng dụng → danh sách listener → closure → _ProfileScreenState.

Đường đó chính là câu trả lời. Mọi thứ trước bước này chỉ để xác nhận có rò rỉ; bước này nói cho bạn phải sửa dòng nào.

Làm cho rò rỉ ồn ào lên thay vì im lặng

Framework có sẵn một bộ theo dõi rò rỉ dành đúng cho các vòng đời đối tượng này. Trong test, flutter_test có thể được cấu hình để fail khi một đối tượng cần huỷ sống lâu hơn phạm vi mong đợi, biến cả một lớp lỗi bộ nhớ trên production thành CI màu đỏ. Các nút điều chỉnh có sẵn thay đổi giữa các bản phát hành, nên hãy kiểm tra phiên bản bạn đang dùng — nhưng nếu dự án của bạn chưa bật nó ở đâu cả, thì bật nó là một giờ đáng giá hơn bất kỳ cuộc săn rò rỉ đơn lẻ nào.

Ngoài công cụ, ba thói quen ngăn được phần lớn năm mô-típ trên:

Mỗi add phải có một remove trong cùng class. Hãy viết dispose ngay sau initState, trước cả khi tính năng chạy được. Thêm sau sẽ khó hơn nhiều.

Ưu tiên widget tự quản vòng đời của nó. StreamBuilder tự huỷ subscription của mình. AnimatedBuilder với một controller mà bạn dispose thì ổn. Một lệnh listen thủ công trong initState mới là hình dạng rủi ro.

Hãy đặt giới hạn cho cache. Một LRU có ngưỡng kích thước do bạn chọn có chủ ý là một cache; một Map mà bạn chỉ có chèn vào là một chỗ rò rỉ với ý định tốt.

Riêng về ảnh

Image.network(
  url,
  cacheWidth: 400,   // giải mã theo kích thước hiển thị, không theo kích thước gốc
  cacheHeight: 300,
)

// Giới hạn cache toàn cục
PaintingBinding.instance.imageCache
  ..maximumSize = 100                 // số mục
  ..maximumSizeBytes = 50 << 20;      // 50 MB

// Xả nó khi hệ điều hành đòi bộ nhớ về
PaintingBinding.instance.imageCache.clear();

cacheWidth là dòng có đòn bẩy cao nhất trong một ứng dụng nhiều media. Nó thay đổi chính quá trình giải mã, nên một thumbnail tốn bộ nhớ của thumbnail thay vì bộ nhớ của ảnh gốc. ResizeImage là cùng ý tưởng nhưng gói dưới dạng ImageProvider, cho những trường hợp bạn kiểm soát provider chứ không phải widget.

Cũng hãy xử lý didHaveMemoryPressure nếu ứng dụng của bạn tự giữ cache lớn — nền tảng có báo cho bạn, và phớt lờ nó khiến ứng dụng của bạn thành cái bị hệ điều hành giết.

Câu hỏi thường gặp

setState sau dispose có làm rò rỉ bộ nhớ không?

Bản thân lỗi đó là triệu chứng, không phải chỗ rò — nhưng thứ vẫn còn có thể gọi setState thì theo định nghĩa vẫn đang giữ State của bạn, nên nó thường đúng là một chỗ rò.

Tôi có nên dùng WeakReference không?

Hiếm khi. Nó tồn tại, và là công cụ đúng cho một cache không nên giữ sống các khoá của nó, nhưng dùng nó để “sửa” một chỗ rò thường có nghĩa là mô hình sở hữu chưa rõ ràng. Hãy sửa quyền sở hữu trước.

Closure có bắt nhiều hơn tôi tưởng không?

Có — đó chính là cơ chế đứng sau mô-típ 2 và 3. Closure bắt các biến nó tham chiếu, và một tear-off phương thức như _onModelChanged bắt this, nghĩa là bắt cả State.

Heap phình lên có luôn là rò rỉ không?

Không. GC của Dart theo thế hệ và không chạy theo một lịch mà bạn đoán trước được. Hãy ép GC trước khi kết luận bất cứ điều gì, và đo cái đáy sau khi thu gom thay vì đo đỉnh.

Tôi phân tích bộ nhớ ở chế độ release được không?

DevTools cần VM service, nên hãy dùng chế độ profile. Rò rỉ chỉ xảy ra ở release là có thể nhưng hiếm; đồ thị tham chiếu vẫn như nhau.


Quy trình DevTools, hợp đồng huỷ đối tượng và hành vi ImageCache mô tả ở đây được ghi trong hướng dẫn công cụ bộ nhớ của Flutter và các trang API đã dẫn. Cách chia năm mô-típ, thứ tự điều tra, và lập trường rằng một cache không giới hạn là một chỗ rò là đánh giá riêng của tôi từ việc gỡ lỗi ứng dụng theo cách này. Các API theo dõi rò rỉ trong flutter_test đã đổi qua các bản phát hành — hãy kiểm tra SDK của bạn cung cấp những gì.


Bài viết gốc đăng tại FlutterCook. Bản trên đó là bản được cập nhật mới nhất.

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...

2026 👏 Google miễn phí một năm gói AI Plus cho sinh viên Việt Nam 🇻🇳🇻🇳🇻🇳

Chúc mừng anh em 👏 Google miễn phí một năm gói AI Plus cho sinh viên Việt Nam 🇻🇳🇻🇳🇻🇳 Đối tượng là sinh viên đại học từ 18–24 tuổi (bao gồm sinh viên mới và sinh viên từng dùng gói AI Pro 2025). Lưu ý là cần xác minh tư cách sinh viên hàng năm. Khi nhận gói, anh em có được các ưu đãi sau: 🔖 Nâng cấp bộ nhớ đám mây lên 400GB. 🔖 Nhân đôi hạn mức truy cập mô hình AI. 🔖 Mở quyền trải nghiệm tính năng tạo video bằng Gemini Omni. Lưu ý chương trình chỉ dành cho sinh viên đủ điều kiện. Hạn nhận ưu đãi: 31 tháng 12, 2026. Các bạn cần có phương thức thanh toán hợp lệ khi đăng ký. Google AI Plus sẽ tự động tính phí 132.000 ₫/tháng sau khi thời gian dùng thử kết thúc, trừ phi bạn đã huỷ trước đó. Huỷ bất cứ lúc nào. CÁC BƯỚC THỰC HIỆN CHI TIẾT 🔹Bước 1: Truy cập cổng đăng ký chương trình 🔖 Mở trình duyệt và truy cập vào trang chính thức: gemini.google/students/ 🔖 Đăng nhập vào Tài khoản Google cá nhân của bạn. 🔖 Nhấn vào nút "Claim your student plan at no cost" (hoặc Nhận gó...