Keeping image memory under control in a Flutter shopping app
Our crash rate on older Android phones was 10% before the SKIMS x NIKE drop. This is how the app handles product images now, from the CDN URL down to the disk cache.
Before the SKIMS x NIKE drop, our crash rate on older Android phones sat at 10%. After a rework of how the app caches images and assets, it was 1%, in time for launch.
Photos are most of what a shopping app draws. They’re also where the memory goes. A decoded image takes width × height × 4 bytes of RAM, whatever size you paint it at, so a 2000 × 2000 product shot shown in a small grid tile still costs 16 MB once decoded. Put twenty of those in a grid and a low-end phone runs out. Android’s low-memory killer ends the process, and the crash report blames whichever allocation was unlucky, which is rarely the code that spent the memory.
The app now has four layers between a product photo and the screen.
Ask the CDN for the right size
Product image URLs go through a helper that adds a width parameter based on the device’s screen:
String resizedTo(ImageSize size, Size deviceSize) {
final query = {...rawUri.queryParameters, ..._defaultParams()};
if (size != ImageSize.highRes) {
query[_widthParamKey] = size
.adjustedWidth(originalWidth: deviceSize.width)
.toInt()
.toString();
}
return Uri(/* ... */, queryParameters: query).toString();
}
Call sites never pass pixel counts. They pass an intent.
enum ImageSize {
half, full, highRes;
double adjustedWidth({required double originalWidth}) => switch (this) {
ImageSize.half => originalWidth / 2,
ImageSize.full || ImageSize.highRes => originalWidth,
};
}
A two-column grid asks for half. The zoomable product view asks for highRes, which skips the width parameter entirely so a pinch-zoom has real pixels to show. All the arithmetic sits in one function, so a new screen can’t quietly get it wrong.
Cap the decode
A smaller download still gets decoded at its full size unless you tell Flutter otherwise. memCacheWidth tells it. The image decodes straight to the target width and the extra pixels never exist in memory.
int calculateMemCacheWidth(BoxConstraints constraints, MediaQueryData mq) {
final stableWidth = min(constraints.maxWidth, mq.size.width);
final memCacheWidth = stableWidth * mq.devicePixelRatio;
return (memCacheWidth * _memCacheWidthScaleFactor).round();
}
Using constraints.maxWidth alone is a trap. Inside a horizontal list, an overflowing Row or any unbounded parent, it can be enormous or infinite. Taking the smaller of the constraint and the screen width means a layout bug can’t turn into a decode the size of a billboard.
Then there’s the 1.2. Here’s the comment that sits next to it in the codebase:
/// Due to empirical testing, a scale factor of 1.2 provides a good balance
/// between image quality and memory usage across a variety of devices.
/// Without it, some images appear pixelated on 2.0+ pixel density multiplier
/// screens.
static const _memCacheWidthScaleFactor = 1.2;
Decoding at exactly the layout width looks soft on dense screens. The extra 20% of width costs 44% more memory, because the height scales with it, and it’s still a small fraction of what decoding the source would cost. Someone measured that trade once and wrote the reason down beside the number.
Only width is constrained. Nearly every image in the app is sized by its width with the height following the aspect ratio, and pinning both would invite distortion bugs.
Size the memory cache in pages
Flutter’s imageCache defaults to 1,000 images and 100 MB. A product grid on an old phone can die long before it fills that.
static const maxDiskCacheItems = ProductListPageBloc.defaultPaginatedLimit * 10;
static const maxInMemoryCacheItems = ProductListPageBloc.defaultPaginatedLimit * 3;
// set once, when the cache repository initialises
imageCache.maximumSize = SkimsCacheManager.maxInMemoryCacheItems;
The product list loads 20 items per page. Memory holds three pages of images and disk holds ten. Written as 60 and 200, those numbers would be impossible to re-derive, and nobody would dare change them. Written as pages, the reasoning is visible: the page on screen plus one on each side. If pagination moves to 30 items, both caches follow.
Disk, with a remote flush
The disk layer is ordinary flutter_cache_manager configuration, with a seven-day stale period and room for ten pages of objects:
Config(
key,
stalePeriod: const Duration(days: 7),
maxNrOfCacheObjects: maxDiskCacheItems,
repo: JsonCacheInfoRepository(databaseName: key),
fileService: FirebaseFileService(),
);
The piece I’d copy into any app is the flush switch:
int get _cacheFlushVersion => _featureFlagsRepository.cacheFlushVersion;
bool get shouldClearDiskCache =>
_appSharedPreferences.mostRecentCacheFlushVersion < _cacheFlushVersion;
Remote Config holds an integer, and the app stores the last value it acted on. Bump the remote number and every install, on every shipped version, empties its image caches once and records the new value.
Some cache bugs can’t be fixed by shipping code. A bad CDN transform gets cached, or an asset is replaced at the same URL. Without a remote switch you ship a build and wait days for people to update, and every old install keeps showing the broken image in the meantime.
The flush also writes its version and timestamp into the Sentry tags. Every crash report then says which cache generation the device was on, which is the first thing you want to filter by when a crash only shows up on some installs.