--- 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 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 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 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` (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).