AssetImage looks up every variant of the path in the asset manifest, picks the one whose folder ratio (2.0x, 3.0x) is closest to the screen's device pixel ratio, and sets the image's scale, so the picture lays out at the same logical size on every screen.
Layout on disk: assets/images/logo.png is the 1x file, variants sit in 2.0x/ and 3.0x/ subfolders with the same name; in pubspec.yaml you list only the main path or the folder
Only a 1x file: a 3x phone stretches it to three times its pixels, so it looks blurry
Only the 3x export under the plain name: Flutter treats it as 1x: without an explicit width it lays out three times too large, and it always costs the full decoded memory
Variants are an asset feature: Image.network and Image.file get no automatic choice; there you request the right size yourself
Flat icons and illustrations: one SVG often replaces three PNGs
FlutterMiddle
Images, Video & Camera
Image pipeline, ImageCache, decode sizing, video_player, camera, audio, EXIF, thumbnails
Questions closed
0 / 21
- 0Review
Questions
Use the flutter_svg package: SvgPicture.asset, SvgPicture.network or SvgPicture.string. Image cannot show SVG because Flutter's image codecs decode raster formats (PNG, JPEG, WebP, GIF) into pixels, while an SVG is an XML document that has to be parsed into drawing commands.
Why it is not built in: a full SVG renderer (CSS, text, filters, scripting) is large, so the engine ships only raster decoders and leaves vectors to packages
Colouring: colorFilter: ColorFilter.mode(color, BlendMode.srcIn); the old color parameter is deprecated
Not every SVG renders: animations, most filter effects and stylesheet-based styling are unsupported, so ask design to export plain paths
Faster loading: flutter_svg 2 is built on vector_graphics; precompiling with vector_graphics_compiler moves XML parsing from runtime to build time
Use a package such as just_audio or audioplayers: create an AudioPlayer, give it a source (asset, file or URL), call play(), and call dispose() when its owner goes away, because the player holds a native decoder and the audio focus.
Sources: setAsset, setFilePath, setUrl; loading returns the duration, so preload in initState instead of on tap
UI state: listen to playerStateStream and positionStream for the play button and the progress bar
**Do not await play() in just_audio: its future completes only when playback pauses or ends
Forgetting dispose(): the sound keeps playing after the screen is gone and players pile up
Playback that must outlive the screen**: a player belongs in a service object outside the widget tree, not in a State
Image.network creates a NetworkImage, an ImageProvider that is a cache key plus a loader. The widget resolves it to an ImageStream; on a miss in the in-memory ImageCache the provider downloads and decodes the bytes, and the frame arrives through the stream.
**ImageProvider**: obtainKey returns the cache key (for NetworkImage: url and scale, not the headers); loadImage runs only on a miss. Creating a new provider in every build is harmless, because equal keys hit the same entry
**ImageStream**: the widget listens for ImageInfo; one in-flight load is shared by every widget that asks for the same key
**ImageCache**: PaintingBinding.instance.imageCache, an LRU of decoded images in memory, by default 1000 entries and 100 MB
No disk cache: after a cold start every image downloads again; that gap is what cached_network_image fills
Stale pictures: the cache is keyed by URL or path, not by content: when the file behind the same key changes, call provider.evict() and make the widget resolve a new provider (a new key, e.g. a version in the URL); evict alone does not make a mounted Image reload
The cap only controls what the cache may keep for later; an image a widget is showing is held by that widget, so evicting it frees nothing. Each decoded photo costs width × height × 4 bytes, so the fix is to decode smaller with cacheWidth or ResizeImage.
Decoded size, not file size: a 3 MB JPEG at 4000×3000 is 48 MB as a bitmap
Twenty visible tiles: close to a gigabyte of live bitmaps; the OS kills the process without a Dart exception, so crash reports show nothing useful
Decode at the tile size: cacheWidth: (tileWidth * devicePixelRatio).round() makes a 400-pixel tile cost well under 1 MB
**Changing maximumSizeBytes does not help: lowering it only forces re-decoding when you scroll back; raising it keeps even more
Better still**: request a thumbnail from the server, so the download shrinks too
cacheWidth and cacheHeight are physical pixels, not logical ones: on a 3x screen the 96-logical-pixel circle needs 288 physical pixels, so a 96-pixel bitmap is stretched and blurs. Passing both also decodes to exactly 96×96 regardless of the photo's aspect ratio, which squashes non-square photos.
What the parameters do: they wrap the provider in ResizeImage, so the codec decodes straight to that size; the download stays full-size
Multiply by the pixel ratio: (96 * MediaQuery.devicePixelRatioOf(context)).round()
Pass one dimension: the other follows the source aspect ratio; or use ResizeImage with policy: ResizeImagePolicy.fit
The size is part of the cache key: one URL at five sizes is five decoded copies, so round to a few fixed sizes
The precache stored the key of NetworkImage(url), but the page asks for ResizeImage(NetworkImage(url), width: 1080): cacheWidth changes the cache key, so the page misses the cache and starts a fresh download.
**What precacheImage does**: resolves a provider into ImageCache, so a widget asking for the same key gets the image on its first frame
Use the same provider for both: same type and the same size; build it once and pass it to precacheImage and Image(image: ...)
Check instead of guessing: provider.obtainCacheStatus(configuration: ...) reports whether the image is cached, live on screen, still loading or missing
Do not block the tap on the network: put a short timeout on the await, and do not precache dozens of images: they fill the 100 MB cache and push out the images the user has just scrolled past
**cached_network_image**: warm it through its own CacheManager and the same cacheKey
A disk cache: through flutter_cache_manager it stores downloaded files in the app's cache directory, so after a restart images load from disk instead of the network. The in-memory ImageCache still sits on top, and the decoded size is still your problem.
Freshness: a file is valid for the response's Cache-Control: max-age (7 days when the server sends none), then it is revalidated, using ETag when available
Eviction: the default manager keeps up to 200 files and drops those unused for 30 days; a feed with thousands of thumbnails needs its own CacheManager(Config(...))
The key is the URL: signed URLs with rotating query parameters miss every time, so pass a stable cacheKey
RAM is not covered: the disk cache does not shrink bitmaps; set memCacheWidth, and maxWidthDiskCache to store smaller files
Invalidation: CachedNetworkImage.evictFromCache(url) frees the cached file after the user changes an avatar, but a mounted widget keeps its equal provider, so also change the URL or give the widget a new Key; emptyCache() on logout
The URL never changes, and NetworkImage uses the URL (plus scale) as its cache key: after setState the avatar gets an equal provider, so Flutter reuses the old decoded image and never downloads the new file.
Why nothing reloads: CircleAvatar and Image resolve the provider again only when it is no longer == the old one; an equal NetworkImage maps to the same ImageCache entry, so the rebuild repaints the old bitmap without a request. The cache lives in memory, which is why a restart shows the new photo
The fix: the server returns a version with the user (avatarVersion, an updatedAt timestamp or a content hash) and the client puts it in the URL: a new upload is a new key in ImageCache, in the cached_network_image disk cache and in the CDN, while an unchanged avatar still hits all three
Even better: the server returns the versioned URL itself (avatars/42/<hash>.jpg); such a file never changes, so it can be cached for a year
What does not work: NetworkImage(url).evict() alone drops the cache entry, but the mounted avatar still holds an equal provider and does not ask again; ?t=${DateTime.now()} in build turns caching off and downloads the avatar on every rebuild
Catch it: a widget test against a local HttpServer that counts requests: after the upload the avatar must be fetched once more
ImagePicker().pickImage(source: ...) opens the system photo picker or the camera app and returns an XFile pointing at a copy in a temporary directory, or null if the user cancels. The system picker runs outside your app, so picking from the gallery needs no storage permission.
iOS: Info.plist needs NSPhotoLibraryUsageDescription, plus NSCameraUsageDescription for the camera and NSMicrophoneUsageDescription for video; pass requestFullMetadata: false when you do not need the original metadata, so the plugin does not ask for photo-library access
Android: no READ_MEDIA_IMAGES or storage permission: the system Photo Picker returns only what the user chose. The camera opens through an intent, so CAMERA is not needed, unless your manifest declares it (often via another plugin); then it must be granted first
Resize at pick time: maxWidth, maxHeight and imageQuality shrink the file natively before Dart reads it
The file is temporary: copy it to your own directory if an upload may be retried later
Lost results on Android: the OS may kill your activity while the camera app is open; call retrieveLostData() at startup or the photo silently disappears
Cameras store pixels in the sensor's orientation and record the rotation in the EXIF Orientation tag. Flutter and the gallery apply it; a server that re-encodes or strips metadata without applying it (thumbnailers often do) shows them sideways. Bake the rotation in before upload.
**A RotatedBox is not a fix: it only changes your app's view and hides the bug
Bake it in**: flutter_image_compress with autoCorrectionAngle: true, or bakeOrientation from the image package, then re-encode
Strip the rest: keepExif: false also removes GPS coordinates, which you usually should not upload
HEIC: iPhones capture HEIC by default and many servers and browsers cannot read it; convert to JPEG in the same pass
Rule: treat a picked file as untrusted input: decode, fix the orientation, resize, re-encode, upload
Decoding, resizing and re-encoding a 12-megapixel photo in the pure-Dart image package is seconds of CPU work, and an async function still runs it on the UI isolate: await only yields at real asynchronous gaps. Move the work to native code or to another isolate.
Where the time goes: decodeImage allocates about 48 MB and visits every pixel; copyResize and encodeJpg walk the pixels again
Native first: flutter_image_compress uses the platform encoder on a background thread: faster, and off your isolate
**Isolate.run for Dart-side work: custom crops, watermarks, filters; send encoded bytes in, not decoded images, because data sent to an isolate is copied
Plugins inside an isolate**: call BackgroundIsolateBinaryMessenger.ensureInitialized(token) with the RootIsolateToken from the main isolate first
Resize once: about 2048 px is plenty for a phone (note that minWidth/minHeight in flutter_image_compress bound the shorter side); every extra lossy pass adds artefacts
Create a VideoPlayerController (networkUrl, asset or file) in initState, call initialize(), show VideoPlayer(controller) inside an AspectRatio once value.isInitialized is true, and call dispose() in dispose.
**The controller is a ValueNotifier<VideoPlayerValue>**: rebuild with ValueListenableBuilder from isInitialized, isPlaying, isBuffering and hasError
Aspect ratio: value.aspectRatio is meaningless before initialization, so reserve a placeholder box until then
Errors: a failed load sets hasError and errorDescription; a spinner tied only to !isPlaying spins forever
Background: the plugin pauses when the app goes to the background and resumes on return; VideoPlayerOptions(allowBackgroundPlayback: true) keeps it playing
Dispose: otherwise the native player keeps its decoder; devices have only a few hardware decoders, so in a feed keep at most one or two controllers alive
The controller, and with it a native player, is created inside build, so every rebuild makes a new one that starts from zero, and nothing ever calls dispose() on the old ones. Resources a widget owns belong in a State: create them in initState, release them in dispose.
**build runs often**: on every parent rebuild, theme or MediaQuery change, keyboard opening; it must be cheap and side-effect free
The leak: each abandoned controller keeps its decoder and buffers, and may keep playing sound in the background
The flash and restart: a new controller is not initialized yet, so VideoPlayer paints nothing until it is ready, then plays from the start
**Hard-coded 16 / 9**: use value.aspectRatio after initialize() completes
The fix: a StatefulWidget with the controller in initState, dispose() in dispose, and didUpdateWidget that disposes and recreates it only when url changes
Three things: the OS must know you play audio in the background, there must be a media session the system can attach controls to, and the player must live outside the widget tree. In Flutter that usually means just_audio plus audio_service.
iOS: UIBackgroundModes with audio in Info.plist and an AVAudioSession in the playback category (the audio_session package sets it); without them iOS suspends the app when the screen locks
iOS controls: the lock screen reads MPNowPlayingInfoCenter and MPRemoteCommandCenter, which audio_service fills from your MediaItem and PlaybackState
Android: a foreground service of type mediaPlayback (with the FOREGROUND_SERVICE_MEDIA_PLAYBACK permission) and a MediaSession; its notification is mandatory and is what keeps the process alive
Dart: an AudioHandler created with AudioService.init in main() before runApp; it owns the player, so playback survives navigation, and Android can start it with no UI at all (media button, Android Auto)
One simple player: just_audio_background wires all this up for you
On resumed the code calls initialize() on the controller it disposed on inactive. A disposed CameraController cannot be reused: initialize() throws CameraException('Disposed CameraController'), and the preview keeps the released camera's black texture.
**Releasing on inactive is right: the plugin has not handled the app lifecycle itself since 0.5, and its README frees the camera there so the OS and other apps can take it; the bug is only in how the screen gets it back
Why it fails quietly**: dispose() marks the controller dead for good, and nobody awaits the initialize() future in the callback, so the exception lands in the log as an unhandled async error instead of a visible crash
The fix: on resumed create a new CameraController and swap it in with setState, so CameraPreview attaches to the new texture and shows an empty Container until isInitialized is true; the field stops being final for that
The same rule elsewhere: a disposed VideoPlayerController or AnimationController, a closed StreamController: replace the object, do not try to revive it
Catch it: a widget test with a fake CameraPlatform that sends inactive and then resumed through tester.binding.handleAppLifecycleStateChanged and expects a second camera to be created
Before the bytes reach the phone: a CDN image service or the upload pipeline produces sized variants, and the client requests the smallest one that covers its slot. Client-side cacheWidth saves memory and decode time, but not a single downloaded byte.
Client only: fixes RAM and decode time; a 4 MB original still crosses a mobile network for a 100-point thumbnail
CDN image service: a URL such as ?w=640&fm=webp makes the size on demand: one original serves every size, a new size needs no reprocessing, and the format can be chosen per device. This is the usual default
Upload pipeline: derivatives made once at upload are cheapest to serve, but a new size means backfilling the whole library
A fixed ladder of widths: for example 160, 320, 640 and 1280; request the smallest one at or above slot width × pixel ratio. Exact per-device widths fragment the CDN cache
Send dimensions in the API: width, height and a BlurHash per item let a tile reserve its aspect ratio, so the list does not jump while images load
Do the sum first: visible tiles × width × height × 4 bytes is the decoded memory per screen; pick the ladder from that
Adaptive streaming, yes: point it at an HLS manifest and ExoPlayer or AVPlayer switch bitrates on their own. DRM, offline downloads and quality or bitrate selection are not in its API, and that is where you move to a DRM-capable player SDK or your own native plugin.
HLS everywhere, DASH only on Android: AVPlayer does not play DASH, so HLS is the safe default; if you also need DASH, package CMAF (fMP4) segments once and publish both manifests
**formatHint**: tells Android what an extensionless URL is, for example VideoFormat.hls
What is missing: Widevine and FairPlay, a quality picker, a bitrate cap for a data-saver setting, subtitle-track selection (only external caption files), downloads; audio-track selection exists since video_player 2.11 (getAudioTracks(), selectAudioTrack())
Options: a commercial player SDK with a Flutter plugin, or your own plugin over Media3 ExoPlayer with a Widevine DrmConfiguration and AVPlayer with AVContentKeySession for FairPlay
Rendering protected video: hardware-secure decoders write to surfaces Flutter cannot sample, so L1/FairPlay playback usually needs a platform view, not a Texture
Decide early: check DRM and download requirements before choosing the player; swapping it later rewrites the whole playback layer
The stutter comes from heavy per-frame work on the UI isolate: process one frame at a time off that isolate and drop the frames that arrive meanwhile.
Preview stretched? Do not wrap CameraPreview in your own AspectRatio; it already swaps the ratio for portrait. To fill the screen use FittedBox(fit: BoxFit.cover) with the swapped previewSize, and clip it
No back-pressure: frames arrive at about 30 fps and each callback runs on the UI isolate; keep a busy flag and drop frames while one is in flight
No pixel conversion in Dart: YUV to RGB per frame costs more than a frame budget; request nv21 on Android and bgra8888 on iOS and hand the planes to ML Kit, native code or an isolate
Lower resolution for analysis: detection rarely needs ResolutionPreset.high, and every step down cuts per-frame work
Lifecycle: dispose the controller on AppLifecycleState.inactive and create it again on resumed; the OS takes the camera from a backgrounded app
Use a Texture by default: native code renders frames into a GPU surface the engine owns, and Flutter composites it like any layer, with no copy into Dart. Use a platform view only when you need the native view itself, such as for DRM playback, and accept extra compositing work per frame.
How a texture works: the plugin registers a surface (createSurfaceProducer() on Android, a FlutterTexture that vends CVPixelBuffers on iOS) and Dart gets only an integer id for Texture(textureId: ...); CameraPreview and video_player work this way
What a texture allows: clipping, transforms, animation and CustomPaint overlays, like any widget
What a platform view costs: the engine has to merge two UI systems: on iOS every Flutter widget drawn above the view needs an extra overlay layer, and Android hybrid composition adds thread synchronisation; clips, transforms and opacity on it are expensive
When a texture cannot work: hardware-secure DRM (Widevine L1, FairPlay) decodes into a protected surface that cannot be sampled, so in a texture it shows black
The trap: startImageStream into Image.memory every frame means a copy, an encode, a decode and a GPU upload per frame on the UI isolate; fine for an occasional snapshot, never for display
Download one chosen rendition per episode as a whole file you own, write it to an app directory the OS will not purge or back up, and track every file in a small index with a byte budget and least-recently-played eviction.
What to download
video_player has no download API, and offline HLS (AVAssetDownloadTask, Media3 DownloadManager) is native work; the simple path is one progressive MP4 or M4A per episode at a quality chosen when the download starts
Write to name.part and rename only when the size matches Content-Length, otherwise a truncated file plays as corrupt media
Resume with HTTP Range requests; on iOS a background URLSession (for example through background_downloader) keeps going while the app is suspended
Where it lives
Not the cache or temporary directory: iOS purges it under storage pressure, which is how an episode vanishes before the commute
Not Documents on iOS: it is backed up to iCloud, and App Review can reject apps that back up re-downloadable media
getApplicationSupportDirectory(), with the folder excluded from backup: isExcludedFromBackup on iOS (set via a platform channel or a package), backup rules in the manifest on Android
Keeping it bounded
One index row per file: id, path, bytes, last played, pinned
After each download, evict the least recently played unpinned items until the total is under budget, and show that budget in settings
At startup, reconcile the index with the folder: delete orphan files and rows whose file is missing