Chiến lược caching trong Flutter: lưu gì, khi nào invalidate, và cách giữ màn hình nhất quán giữa các điều hướng.

Caching trong ứng dụng di động là giữ một bản sao dữ liệu gần bên (trong bộ nhớ hoặc trên thiết bị) để màn hình tiếp theo có thể hiển thị ngay thay vì chờ mạng. Dữ liệu đó có thể là danh sách mục, hồ sơ người dùng, hoặc kết quả tìm kiếm.
Vấn đề khó là dữ liệu cache thường hơi sai. Người dùng nhận ra nhanh: giá không cập nhật, số thông báo dường như đứng yên, hoặc màn hình chi tiết hiện thông tin cũ ngay sau khi họ thay đổi. Điều khó gỡ là thời điểm. Cùng một endpoint có thể trông ổn sau pull-to-refresh, nhưng lại sai sau khi quay lại, khi ứng dụng resume, hoặc khi chuyển tài khoản.
Có một đánh đổi thực sự. Nếu bạn luôn lấy dữ liệu tươi, màn hình sẽ chậm và nhấp nháy, tốn pin và băng thông. Nếu cache quá mạnh tay, ứng dụng nhanh nhưng người dùng mất niềm tin vào dữ liệu.
Một mục tiêu đơn giản giúp: làm cho độ tươi trở nên dự đoán được. Quyết định mỗi màn hình được phép hiển thị gì (tươi, hơi cũ, hoặc offline), dữ liệu sống được bao lâu trước khi làm mới, và sự kiện nào bắt buộc phải invalidation.
Hãy tưởng tượng một luồng phổ biến: người dùng mở một đơn hàng, rồi quay lại danh sách đơn. Nếu danh sách lấy từ cache, nó có thể vẫn hiển thị trạng thái cũ. Nếu bạn làm mới mỗi lần, danh sách có thể nhấp nháy và cảm thấy chậm. Những quy tắc rõ ràng như “hiển thị cache ngay, làm mới nền, và cập nhật cả hai màn hình khi phản hồi về” làm trải nghiệm nhất quán khi điều hướng.
Cache không chỉ là “dữ liệu lưu trữ.” Đó là một bản sao được lưu kèm một quy tắc khi bản sao đó còn hợp lệ. Nếu bạn lưu payload mà bỏ qua quy tắc, bạn sẽ có hai thực tại: một màn hình hiển thị thông tin mới, màn kia hiển thị thông tin hôm qua.
Một mô hình thực tế là đặt mỗi mục cache vào một trong ba trạng thái:
Khung này giữ UI dự đoán được vì nó có thể phản ứng cùng một cách mỗi lần thấy một trạng thái nhất định.
Quy tắc độ tươi nên dựa trên tín hiệu bạn có thể giải thích cho đồng đội. Lựa chọn phổ biến là hết hạn theo thời gian (ví dụ 5 phút), thay đổi phiên bản (schema hoặc phiên bản app), hành động người dùng (pull to refresh, submit, delete), hoặc gợi ý từ server (ETag, last-updated, hoặc phản hồi “cache invalid” rõ ràng).
Ví dụ: màn hình hồ sơ tải dữ liệu người dùng từ cache ngay lập tức. Nếu nó là cũ nhưng dùng được, nó hiển thị tên và avatar đã cache, sau đó im lặng làm mới. Nếu người dùng vừa sửa hồ sơ, đó là lúc phải làm mới. Ứng dụng nên cập nhật cache ngay để mọi màn hình đồng nhất.
Quyết định ai sở hữu những quy tắc này. Trong hầu hết app, mặc định tốt nhất là: lớp dữ liệu (data layer) sở hữu độ tươi và invalidation, UI chỉ phản ứng (hiển thị cache, hiển thị loading, hiển thị lỗi), và backend cung cấp gợi ý khi có thể. Điều này ngăn mỗi màn hình phát minh ra quy tắc riêng.
Caching tốt bắt đầu với một câu hỏi: nếu dữ liệu này hơi cũ, có làm hại người dùng không? Nếu câu trả lời là “không sao,” thường hợp để cache cục bộ.
Dữ liệu được đọc nhiều và thay đổi chậm thường đáng cache: feed và danh sách người dùng cuộn thường xuyên, nội dung dạng catalog (sản phẩm, bài viết, mẫu), và dữ liệu tham khảo như danh mục hoặc quốc gia. Cài đặt và sở thích cũng phù hợp ở đây, cùng với thông tin hồ sơ cơ bản như tên và URL avatar.
Mặt rủi ro là bất cứ thứ gì liên quan đến tiền hoặc thời gian. Số dư, trạng thái thanh toán, tồn kho, slot đặt hẹn, ETA giao hàng, và “last seen online” có thể gây vấn đề nếu quá cũ. Bạn vẫn có thể cache để tăng tốc, nhưng coi cache như placeholder tạm thời và ép làm mới ở các điểm quyết định (ví dụ trước khi xác nhận đơn hàng).
Trạng thái UI dẫn xuất là một loại riêng. Lưu tab đã chọn, bộ lọc, truy vấn tìm kiếm, thứ tự sắp xếp, hoặc vị trí cuộn có thể làm điều hướng mượt mà. Nó cũng có thể gây khó chịu khi lựa chọn cũ xuất hiện lại bất ngờ. Một quy tắc đơn giản: giữ trạng thái UI trong bộ nhớ khi người dùng vẫn ở trong luồng đó, nhưng đặt lại khi họ thực sự “bắt đầu lại” (ví dụ về home).
Tránh cache dữ liệu tạo rủi ro bảo mật hoặc riêng tư: bí mật (mật khẩu, API keys), mã một lần (OTP, token reset), và dữ liệu cá nhân nhạy cảm trừ khi bạn thực sự cần truy cập offline. Không bao giờ cache thông tin thẻ đầy đủ hoặc thứ gì làm tăng rủi ro gian lận.
Trong app mua sắm, cache danh sách sản phẩm là lợi thế lớn. Màn hình thanh toán thì nên luôn làm mới tổng tiền và khả năng sẵn có ngay trước khi mua.
Hầu hết app Flutter cần cache cục bộ để màn hình tải nhanh và không nhấp nháy trống khi mạng chậm. Quyết định chính là dữ liệu cache nằm ở đâu, vì mỗi lớp có tốc độ, giới hạn kích thước và hành vi dọn dẹp khác nhau.
Cache trong bộ nhớ là nhanh nhất. Thích hợp cho dữ liệu vừa lấy và sẽ dùng lại trong khi app mở, như profile hiện tại, kết quả tìm kiếm gần đây, hoặc sản phẩm vừa xem. Đổi lại: nó biến mất khi app bị kill, nên không giúp khởi động lạnh hoặc dùng offline.
Lưu trữ key-value trên đĩa phù hợp cho mục nhỏ bạn muốn giữ qua khởi động lại. Nghĩ đến preferences và blob nhỏ: feature flags, “tab được chọn gần nhất,” và JSON nhỏ hiếm khi thay đổi. Giữ cho nó có chủ đích nhỏ gọn. Khi bạn bắt đầu bỏ danh sách lớn vào key-value, cập nhật trở nên rối và dễ bị phồng.
Database cục bộ phù hợp khi dữ liệu lớn, có cấu trúc, hoặc cần hành vi offline. Nó cũng hữu ích khi bạn cần truy vấn (“tất cả tin nhắn chưa đọc,” “mục trong giỏ,” “đơn hàng trong tháng trước”) thay vì nạp một blob to và lọc trong bộ nhớ.
Để cache dự đoán được, chọn một nơi lưu chính cho mỗi loại dữ liệu và tránh giữ cùng một dataset ở ba chỗ.
Quy tắc nhanh:
Cũng lên kế hoạch dung lượng. Quyết định thế nào là “quá lớn”, giữ item bao lâu, và dọn dẹp ra sao. Ví dụ: giới hạn kết quả tìm kiếm cached trong 20 truy vấn gần nhất, và xóa bản ghi cũ hơn 30 ngày để cache không tăng dần vô hạn.
Quy tắc làm mới nên đủ đơn giản để bạn giải thích trong một câu cho mỗi màn hình. Đó là nơi caching có ích: người dùng có màn hình nhanh và app vẫn đáng tin.
Quy tắc đơn giản nhất là TTL (time to live). Lưu dữ liệu kèm timestamp và coi nó tươi trong ví dụ 5 phút. Sau đó nó trở nên cũ. TTL hoạt động tốt với dữ liệu “không bắt buộc phải tươi” như feed, danh mục, hay đề xuất.
Một tinh chỉnh hữu ích là tách TTL thành soft TTL và hard TTL.
Với soft TTL, bạn hiển thị cache ngay, rồi làm mới nền và cập nhật UI nếu có thay đổi. Với hard TTL, bạn ngừng hiển thị dữ liệu cũ sau khi nó hết hạn. Bạn hoặc block bằng loader hoặc hiển thị trạng thái “offline/try again.” Hard TTL phù hợp nơi sai lệch tệ hơn chậm, như số dư, trạng thái đơn, hoặc quyền truy cập.
Nếu backend hỗ trợ, ưu tiên “làm mới chỉ khi thay đổi” bằng ETag, updatedAt, hoặc trường version. Ứng dụng có thể hỏi “cái này có thay đổi không?” và bỏ qua tải lại payload đầy đủ khi không có gì mới.
Mặc định dễ dùng cho nhiều màn hình là stale-while-revalidate: hiển thị ngay, làm mới im lặng, và vẽ lại chỉ khi kết quả khác. Nó mang lại tốc độ mà không gây nhấp nháy ngẫu nhiên.
Độ tươi theo mỗi màn hình thường trông như sau:
Chọn quy tắc dựa trên chi phí khi sai, chứ không chỉ chi phí fetch.
Invalidate cache bắt đầu bằng một câu hỏi: sự kiện nào làm dữ liệu cache kém tin cậy hơn so với chi phí tải lại? Nếu bạn chọn một tập trigger nhỏ và tuân thủ, hành vi sẽ dự đoán được và UI ổn định.
Các trigger quan trọng trong app thực tế:
Ví dụ: người dùng sửa avatar, rồi quay lại. Nếu bạn chỉ dựa vào làm mới theo thời gian, màn trước có thể vẫn hiện ảnh cũ cho tới lần fetch sau. Thay vào đó, coi việc chỉnh sửa là trigger: cập nhật đối tượng profile cached ngay và đặt timestamp mới tươi.
Giữ quy tắc invalidation nhỏ và rõ ràng. Nếu bạn không chỉ ra được chính xác sự kiện invalidate một mục cache, bạn sẽ làm mới quá thường (UI chậm, nhấp nháy) hoặc không đủ (màn hình cũ).
Bắt đầu bằng cách liệt kê các màn hình chính và dữ liệu mỗi màn cần. Đừng nghĩ theo endpoint. Hãy nghĩ theo đối tượng nhìn thấy bởi người dùng: profile, giỏ hàng, danh sách đơn, mục catalog, số lượng chưa đọc.
Tiếp theo, chọn một nguồn sự thật cho mỗi loại dữ liệu. Trong Flutter, thường là một repository che giấu nơi dữ liệu đến (memory, disk, network). Màn hình không nên quyết định khi nào gọi network. Chúng chỉ hỏi repository và phản ứng với trạng thái trả về.
Luồng thực tế:
Metadata là thứ khiến quy tắc thực thi được. Nếu ownerUserId thay đổi (logout/login), bạn có thể bỏ hoặc bỏ qua các hàng cache cũ ngay thay vì hiện dữ liệu của user trước đó trong tích tắc.
Về hành vi UI, quyết định trước “stale” nghĩa là gì. Quy tắc phổ biến: hiển thị dữ liệu cũ ngay để màn hình không trống, khởi chạy refresh nền, và cập nhật khi dữ liệu mới về. Nếu refresh thất bại, giữ dữ liệu cũ và hiển thị lỗi nhỏ, rõ ràng.
Rồi cố định quy tắc bằng vài test buồn tẻ:
Đó là sự khác biệt giữa “chúng ta có caching” và “ứng dụng hành xử giống nhau mọi lần”.
Không gì làm mất niềm tin nhanh hơn thấy một giá trị ở màn list, nhấn vào chi tiết, sửa nó, rồi quay lại thấy giá trị cũ. Tính nhất quán khi điều hướng đến từ việc mọi màn đọc từ cùng một nguồn.
Quy tắc tốt là: lấy một lần, lưu một lần, render nhiều lần. Màn không nên gọi cùng một endpoint độc lập và giữ bản sao riêng. Đặt dữ liệu cached trong store chia sẻ (lớp quản lý state), và để cả list lẫn detail theo dõi cùng một dữ liệu.
Giữ một nơi duy nhất sở hữu giá trị hiện tại và độ tươi. Màn có thể yêu cầu làm mới, nhưng không nên tự quản thời gian, retry và parsing.
Thói quen thực tế tránh “hai thực tại”:
Dù có quy tắc tốt, người dùng đôi khi vẫn thấy dữ liệu cũ (offline, mạng chậm, app ở nền). Hiển thị điều đó bằng tín hiệu nhỏ, bình tĩnh: timestamp “Cập nhật vừa xong”, chỉ báo “Đang làm mới…”, hoặc huy hiệu “Offline”.
Với chỉnh sửa, optimistic updates thường cho cảm giác tốt nhất. Ví dụ: người dùng đổi giá sản phẩm trên màn chi tiết. Cập nhật store chia sẻ ngay để list hiển thị giá mới khi họ quay lại. Nếu lưu thất bại, rollback về giá cũ và hiển thị lỗi ngắn.
Hầu hết lỗi cache đều nhàm chán: cache hoạt động, nhưng không ai giải thích khi nào dùng nó, khi nào hết hạn, và ai sở hữu nó.
Cạm bẫy đầu tiên là cache mà không có metadata. Nếu bạn chỉ lưu payload, bạn không biết nó đã cũ, phiên bản app nào tạo ra nó, hoặc nó thuộc user nào. Ít nhất hãy lưu savedAt, số phiên bản đơn giản, và userId. Thói quen nhỏ này tránh nhiều bug “tại sao màn này sai?”.
Vấn đề hay gặp khác là nhiều cache cho cùng dữ liệu mà không có owner. Màn list giữ danh sách trong memory, repository ghi lên đĩa, và màn chi tiết fetch lại rồi lưu chỗ khác. Chọn một nguồn sự thật (thường là repository) và ép mọi màn đọc qua nó.
Thay đổi tài khoản là mỏ chân thường gặp. Nếu ai đó logout hoặc đổi tài khoản, xóa bảng và khóa theo user. Nếu không, bạn có thể thấy ảnh profile hoặc đơn hàng của user trước đó trong tích tắc, cảm giác giống vi phạm riêng tư.
Sửa chữa thực tế:
Ví dụ: danh sách sản phẩm của bạn load ngay từ cache, rồi làm mới lặng lẽ. Nếu làm mới thất bại, vẫn hiển thị cache nhưng báo rõ có thể lỗi thời và cung cấp Retry. Đừng block UI khi cache đủ tốt.
Trước khi release, biến caching từ “có vẻ ổn” thành các quy tắc có thể test. Người dùng nên thấy dữ liệu hợp lý ngay cả sau khi điều hướng qua lại, offline, hoặc đăng nhập với tài khoản khác.
Với mỗi màn, quyết định dữ liệu được coi là tươi bao lâu. Có thể là vài phút cho dữ liệu thay đổi nhanh (tin nhắn, số dư) hoặc vài giờ cho dữ liệu chậm (cài đặt, danh mục). Rồi xác nhận chuyện xảy ra khi không còn tươi: làm mới nền, làm mới khi mở, hay pull-to-refresh thủ công.
Với mỗi loại dữ liệu, quyết định sự kiện nào phải wipe hoặc bypass cache. Triggers phổ biến: logout, chỉnh sửa item, chuyển tài khoản, cập nhật app làm thay đổi shape dữ liệu.
Đảm bảo mục cache lưu một bộ metadata nhỏ kèm payload:
Giữ ownership rõ ràng: dùng một repository cho mỗi loại dữ liệu (ví dụ ProductsRepository), không phải cho từng widget. Widget chỉ nên hỏi dữ liệu, không tự quyết quy tắc cache.
Cũng quyết định và test hành vi offline. Xác nhận màn nào hiển thị từ cache, hành động nào bị vô hiệu, và copy hiển thị (“Hiển thị dữ liệu đã lưu”, kèm control làm mới rõ ràng). Làm mới thủ công nên tồn tại trên mọi màn dựa trên cache và dễ tìm.
Hãy tưởng tượng app shop đơn giản với ba màn: catalog (list), chi tiết sản phẩm, và tab Favorites. Người dùng cuộn catalog, mở sản phẩm, và nhấn tim để thêm yêu thích. Mục tiêu là cảm thấy nhanh ngay cả mạng chậm mà không hiển thị sai lệch khó hiểu.
Cache cục bộ những thứ giúp render tức thì: trang catalog (ID, tiêu đề, giá, thumbnail URL, flag yêu thích), chi tiết sản phẩm (mô tả, thông số, tồn kho, lastUpdated), metadata ảnh (URL, kích thước, cache keys), và danh sách yêu thích của người dùng (tập ID sản phẩm, có thể kèm timestamp).
Khi mở catalog, hiển thị kết quả cache ngay, rồi revalidate nền. Nếu có dữ liệu tươi, chỉ cập nhật phần thay đổi và giữ vị trí cuộn.
Với toggle yêu thích, coi đó là hành động “phải nhất quán”. Cập nhật danh sách favorites cục bộ ngay (optimistic), rồi cập nhật các hàng sản phẩm cached và chi tiết sản phẩm cho ID đó. Nếu call mạng thất bại, rollback và báo nhỏ.
Để điều hướng nhất quán, dẫn dữ liệu cả list và icon tim trên chi tiết từ cùng một nguồn sự thật (cache cục bộ hoặc store), không phải trạng thái riêng từng màn. Tim trong list cập nhật ngay khi quay lại từ chi tiết, màn chi tiết phản ánh thay đổi từ list, và số lượng trong tab Favorites khớp mọi nơi mà không chờ refetch.
Thêm quy tắc làm mới đơn giản: cache catalog hết hạn nhanh (phút), chi tiết sản phẩm lâu hơn một chút, và favorites gần như không hết hạn nhưng luôn reconcile sau login/logout.
Caching thôi không còn bí ẩn khi team bạn có một trang quy tắc và đồng ý về hành vi mong đợi. Mục tiêu không phải hoàn hảo mà là hành vi dự đoán được qua các release.
Viết một bảng nhỏ cho mỗi màn và giữ vừa đủ ngắn để review khi thay đổi: tên màn và dữ liệu chính, vị trí lưu cache và khóa, quy tắc độ tươi (TTL, event-based, hoặc thủ công), triggers invalidation, và người dùng thấy gì khi làm mới.
Thêm logging nhẹ khi tinh chỉnh. Ghi cache hits, misses, và lý do một refresh xảy ra (TTL expired, user pulled to refresh, app resumed, mutation completed). Khi ai đó báo “danh sách này trông sai”, những log đó làm bug dễ sửa hơn.
Bắt đầu với TTL đơn giản, rồi tinh chỉnh dựa trên những gì người dùng nhận ra. Feed tin tức có thể chấp nhận 5–10 phút cũ, trong khi màn trạng thái đơn có thể cần làm mới khi resume và sau mọi hành động thanh toán.
Nếu bạn xây app Flutter nhanh, việc phác thảo data layer và quy tắc cache trước khi triển khai sẽ hữu ích. Với các đội dùng Koder.ai (koder.ai), chế độ planning là nơi thực hành viết các quy tắc per-screen trước, rồi xây theo.
Khi tinh chỉnh hành vi làm mới, bảo vệ các màn ổn định trong khi thử nghiệm. Snapshot và rollback tiết kiệm thời gian khi quy tắc mới vô tình gây nhấp nháy, trạng thái trống hoặc đếm không nhất quán khi điều hướng.
Bắt đầu với một quy tắc rõ ràng cho mỗi màn hình: nó có thể hiển thị gì ngay lập tức (cache), khi nào bắt buộc phải làm mới, và người dùng thấy gì trong lúc làm mới. Nếu bạn không thể giải thích quy tắc đó trong một câu, ứng dụng cuối cùng sẽ cảm thấy không nhất quán.
Đối xử với dữ liệu cache như có trạng thái độ tươi mới. Nếu nó là tươi (fresh), hiển thị. Nếu nó là cũ nhưng dùng được (stale but usable), hiển thị ngay và làm mới lặng lẽ. Nếu nó là phải làm mới (must refresh), lấy dữ liệu trước khi hiển thị (hoặc hiển thị trạng thái loading/offline). Cách này giữ hành vi UI nhất quán thay vì “lúc cập nhật, lúc không.”
Cache những thứ được đọc nhiều và có thể hơi cũ mà không gây hại cho người dùng, như feed, catalog, dữ liệu tham khảo, và thông tin hồ sơ cơ bản. Tránh hoặc thận trọng với dữ liệu liên quan tới tiền hoặc thời gian thực như số dư, trạng thái thanh toán, tồn kho, ETA giao hàng; có thể cache để tăng tốc nhưng luôn làm mới ngay trước khi người dùng quyết định hoặc xác nhận.
Dùng memory để tái sử dụng nhanh trong phiên hiện tại, như profile đang dùng hoặc mục vừa xem. Dùng lưu trữ key-value trên disk cho mục nhỏ, đơn giản, sống qua khởi động lại, như preferences. Dùng database khi dữ liệu lớn, có cấu trúc, cần truy vấn hoặc hoạt động offline, như tin nhắn, đơn hàng hoặc danh mục tồn kho.
TTL là mặc định tốt: coi dữ liệu tươi trong một khoảng thời gian cố định, rồi làm mới. Với nhiều màn hình, trải nghiệm tốt hơn là “hiển thị cache ngay, làm mới ở nền, rồi cập nhật nếu thay đổi” (stale-while-revalidate) để tránh màn hình trống và nhấp nháy.
Invalidate khi có sự kiện làm giảm độ tin cậy của cache: chỉnh sửa do người dùng (create/update/delete), đăng nhập/đăng xuất hoặc chuyển tài khoản, resume ứng dụng nếu dữ liệu đã già hơn TTL, và làm mới thủ công. Giữ triggers nhỏ và rõ ràng để không làm mới liên tục hoặc không làm mới khi cần.
Cho cả hai màn hình đọc từ cùng một nguồn sự thật (shared source of truth), không phải bản sao riêng. Khi người dùng chỉnh sửa trên màn hình chi tiết, cập nhật đối tượng cache chung ngay lập tức để list hiển thị giá trị mới khi quay lại, sau đó đồng bộ với server và rollback nếu lưu thất bại.
Luôn lưu metadata kèm theo payload, đặc biệt là timestamp và user identifier. Khi logout hoặc đổi tài khoản, xóa hoặc cô lập ngay cache theo người dùng và hủy các request đang chạy liên quan tới người dùng cũ để không hiển thị dữ liệu của người dùng trước đó trong chốc lát.
Mặc định giữ dữ liệu cũ hiển thị và hiển thị lỗi nhỏ, rõ ràng cùng nút Retry, thay vì làm trắng màn hình. Nếu màn hình không an toàn khi hiển thị dữ liệu cũ, chuyển sang quy tắc must-refresh và hiển thị thông báo loading hoặc offline thay vì giả vờ dữ liệu cũ là đáng tin cậy.
Đặt logic cache trong data layer (ví dụ: repository) để mọi màn hình tuân theo cùng hành vi. Widgets chỉ phản ứng với trạng thái (cached, loading, error) thay vì quyết định quy tắc làm mới. Nếu bạn dùng Koder.ai, viết quy tắc per-screen trong chế độ Planning trước rồi triển khai để UI chỉ cần phản ứng.