flutter-caching-data
Implementing Flutter Caching and Offline-First Architectures
Contents
- Selecting a Caching Strategy
- Implementing Offline-First Data Synchronization
- Managing File System and SQLite Persistence
- Optimizing UI, Scroll, and Image Caching
- Caching the FlutterEngine (Android)
- Workflows
Selecting a Caching Strategy
Apply the appropriate caching mechanism based on the data lifecycle and size requirements.
- If storing small, non-critical UI states or preferences: Use
shared_preferences. - If storing large, structured datasets: Use on-device databases (SQLite via
sqflite, Drift, Hive CE, or Isar). - If storing binary data or large media: Use file system caching via
path_provider. - If retaining user session state (navigation, scroll positions): Implement Flutter's built-in state restoration to sync the Element tree with the engine.
- If optimizing Android initialization: Pre-warm and cache the
FlutterEngine.
Implementing Offline-First Data Synchronization
Design repositories as the single source of truth, combining local databases and remote API clients.
Read Operations (Stream Approach)
Yield local data immediately for fast UI rendering, then fetch remote data, update the local cache, and yield the fresh data.
Stream<UserProfile> getUserProfile() async* {
// 1. Yield local cache first
final localProfile = await _databaseService.fetchUserProfile();
if (localProfile != null) yield localProfile;
// 2. Fetch remote, update cache, yield fresh data
try {
final remoteProfile = await _apiClientService.getUserProfile();
await _databaseService.updateUserProfile(remoteProfile);
yield remoteProfile;
} catch (e) {
// Handle network failure; UI already has local data
}
}
Write Operations
Determine the write strategy based on data criticality:
- If strict server synchronization is required (Online-only): Attempt the API call first. Only update the local database if the API call succeeds.
- If offline availability is prioritized (Offline-first): Write to the local database immediately. Attempt the API call. If the API call fails, flag the local record for background synchronization.
Background Synchronization
Add a synchronized boolean flag to your data models. Run a periodic background task (e.g., via workmanager or a Timer) to push unsynchronized local changes to the server.
Managing File System and SQLite Persistence
File System Caching
Use path_provider to locate the correct directory.
- Use
getApplicationDocumentsDirectory()for persistent data. - Use
getTemporaryDirectory()for cache data the OS can clear.
Future<File> get _localFile async {
final directory = await getApplicationDocumentsDirectory();
return File('${directory.path}/cache.txt');
}
SQLite Persistence
Use sqflite for relational data caching. Always use whereArgs to prevent SQL injection.
Future<void> updateCachedRecord(Record record) async {
final db = await database;
await db.update(
'records',
record.toMap(),
where: 'id = ?',
whereArgs: [record.id], // NEVER use string interpolation here
);
}
Optimizing UI, Scroll, and Image Caching
Image Caching
Image I/O and decompression are expensive.
- Use the
cached_network_imagepackage to handle file-system caching of remote images. - Custom ImageProviders: If implementing a custom
ImageProvider, overridecreateStream()andresolveStreamForKey()instead of the deprecatedresolve()method. - Cache Sizing: The
ImageCache.maxByteSizeno longer automatically expands for large images. If loading images larger than the default cache size, manually increaseImageCache.maxByteSizeor subclassImageCacheto implement custom eviction logic.
Scroll Caching
When configuring caching for scrollable widgets (ListView, GridView, Viewport), use the scrollCacheExtent property with a ScrollCacheExtent object. Do not use the deprecated cacheExtent and cacheExtentStyle properties.
// Correct implementation
ListView(
scrollCacheExtent: const ScrollCacheExtent.pixels(500.0),
children: // ...
)
Viewport(
scrollCacheExtent: const ScrollCacheExtent.viewport(0.5),
slivers: // ...
)
Widget Caching
- Avoid overriding
operator ==onWidgetobjects. It causes O(N²) behavior during rebuilds. - Exception: You may override
operator ==only on leaf widgets (no children) where comparing properties is significantly faster than rebuilding, and the properties rarely change. - Prefer using
constconstructors to allow the framework to short-circuit rebuilds automatically.
Caching the FlutterEngine (Android)
To eliminate the non-trivial warm-up time of a FlutterEngine when adding Flutter to an existing Android app, pre-warm and cache the engine.
- Instantiate and pre-warm the engine in the
Applicationclass. - Store it in the
FlutterEngineCache. - Retrieve it using
withCachedEnginein theFlutterActivityorFlutterFragment.
// 1. Pre-warm in Application class
val flutterEngine = FlutterEngine(this)
flutterEngine.navigationChannel.setInitialRoute("/cached_route")
flutterEngine.dartExecutor.executeDartEntrypoint(DartEntrypoint.createDefault())
// 2. Cache the engine
FlutterEngineCache.getInstance().put("my_engine_id", flutterEngine)
// 3. Use in Activity/Fragment
startActivity(
FlutterActivity.withCachedEngine("my_engine_id").build(this)
)
Note: You cannot set an initial route via the Activity/Fragment builder when using a cached engine. Set the initial route on the engine's navigation channel before executing the Dart entrypoint.
Workflows
Workflow: Implementing an Offline-First Repository
Follow these steps to implement a robust offline-first data layer.
- Task Progress:
- Define the data model with a
synchronizedboolean flag (defaultfalse). - Implement the local
DatabaseService(SQLite/Hive) with CRUD operations. - Implement the remote
ApiClientServicefor network requests. - Create the
Repositoryclass combining both services. - Implement the read method returning a
Stream<T>(yield local, fetch remote, update local, yield remote). - Implement the write method (write local, attempt remote, update
synchronizedflag). - Implement a background sync function to process records where
synchronized == false. - Run validator -> review errors -> fix (Test offline behavior by disabling network).
- Define the data model with a
Workflow: Pre-warming the Android FlutterEngine
Follow these steps to cache the FlutterEngine for seamless Android integration.
- Task Progress:
- Locate the Android
Applicationclass (create one if it doesn't exist and register inAndroidManifest.xml). - Instantiate a new
FlutterEngine. - (Optional) Set the initial route via
navigationChannel.setInitialRoute(). - Execute the Dart entrypoint via
dartExecutor.executeDartEntrypoint(). - Store the engine in
FlutterEngineCache.getInstance().put(). - Update the target
FlutterActivityorFlutterFragmentto use.withCachedEngine("id"). - Run validator -> review errors -> fix (Verify no blank screen appears during transition).
- Locate the Android