169 lines
7.7 KiB
Markdown
169 lines
7.7 KiB
Markdown
|
|
---
|
||
|
|
name: "flutter-caching-data"
|
||
|
|
description: "Implements caching strategies for Flutter apps to improve performance and offline support. Use when retaining app data locally to reduce network requests or speed up startup."
|
||
|
|
metadata:
|
||
|
|
model: "models/gemini-3.1-pro-preview"
|
||
|
|
last_modified: "Thu, 12 Mar 2026 22:19:54 GMT"
|
||
|
|
|
||
|
|
---
|
||
|
|
# Implementing Flutter Caching and Offline-First Architectures
|
||
|
|
|
||
|
|
## Contents
|
||
|
|
- [Selecting a Caching Strategy](#selecting-a-caching-strategy)
|
||
|
|
- [Implementing Offline-First Data Synchronization](#implementing-offline-first-data-synchronization)
|
||
|
|
- [Managing File System and SQLite Persistence](#managing-file-system-and-sqlite-persistence)
|
||
|
|
- [Optimizing UI, Scroll, and Image Caching](#optimizing-ui-scroll-and-image-caching)
|
||
|
|
- [Caching the FlutterEngine (Android)](#caching-the-flutterengine-android)
|
||
|
|
- [Workflows](#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.
|
||
|
|
|
||
|
|
```dart
|
||
|
|
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.
|
||
|
|
|
||
|
|
```dart
|
||
|
|
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.
|
||
|
|
|
||
|
|
```dart
|
||
|
|
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_image` package to handle file-system caching of remote images.
|
||
|
|
* **Custom ImageProviders:** If implementing a custom `ImageProvider`, override `createStream()` and `resolveStreamForKey()` instead of the deprecated `resolve()` method.
|
||
|
|
* **Cache Sizing:** The `ImageCache.maxByteSize` no longer automatically expands for large images. If loading images larger than the default cache size, manually increase `ImageCache.maxByteSize` or subclass `ImageCache` to 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.
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// Correct implementation
|
||
|
|
ListView(
|
||
|
|
scrollCacheExtent: const ScrollCacheExtent.pixels(500.0),
|
||
|
|
children: // ...
|
||
|
|
)
|
||
|
|
|
||
|
|
Viewport(
|
||
|
|
scrollCacheExtent: const ScrollCacheExtent.viewport(0.5),
|
||
|
|
slivers: // ...
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Widget Caching
|
||
|
|
* Avoid overriding `operator ==` on `Widget` objects. 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 `const` constructors 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.
|
||
|
|
|
||
|
|
1. Instantiate and pre-warm the engine in the `Application` class.
|
||
|
|
2. Store it in the `FlutterEngineCache`.
|
||
|
|
3. Retrieve it using `withCachedEngine` in the `FlutterActivity` or `FlutterFragment`.
|
||
|
|
|
||
|
|
```kotlin
|
||
|
|
// 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 `synchronized` boolean flag (default `false`).
|
||
|
|
- [ ] Implement the local `DatabaseService` (SQLite/Hive) with CRUD operations.
|
||
|
|
- [ ] Implement the remote `ApiClientService` for network requests.
|
||
|
|
- [ ] Create the `Repository` class 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 `synchronized` flag).
|
||
|
|
- [ ] Implement a background sync function to process records where `synchronized == false`.
|
||
|
|
- [ ] Run validator -> review errors -> fix (Test offline behavior by disabling network).
|
||
|
|
|
||
|
|
### Workflow: Pre-warming the Android FlutterEngine
|
||
|
|
Follow these steps to cache the FlutterEngine for seamless Android integration.
|
||
|
|
|
||
|
|
- [ ] **Task Progress:**
|
||
|
|
- [ ] Locate the Android `Application` class (create one if it doesn't exist and register in `AndroidManifest.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 `FlutterActivity` or `FlutterFragment` to use `.withCachedEngine("id")`.
|
||
|
|
- [ ] Run validator -> review errors -> fix (Verify no blank screen appears during transition).
|