Skip to content

Images ​

Since 0.9.0. package:fespalier_image shows a network image at the size its layout needs. A ResponsiveImage measures its box, multiplies by the device pixel ratio, rounds up to one of a few widths (a bucket) and asks the app's image CDN for that one: a 40-point avatar downloads 128 pixels, not the original.

Which CDN, and which URL it writes, is the app's choice: imgproxy and EmgR, Cloudinary, imgix, Thumbor, a URL template, or a srcset that the backend already signed. There is no fespalier: key for it, because a CDN's address differs per flavour and per test, so it is Dart, not pubspec.yaml.

  • imageCdnProvider is the app's ImageCdn: the URL builder, the width buckets, the format and the quality, set once in startup().
  • ResponsiveImage('products/3.jpg', aspectRatio: 1) is the widget: a placeholder while it loads (or the widest variant of the same picture that is already loaded), an error view with a retry, and no new download when the layout animates.
  • The URL builders write the URL for one CDN: const classes whose output is pinned by exact-string tests.
  • No key. The package has no parameter for a signing key and ships no HMAC code. A key in an app is public: Signed image URLs says what to do instead.
  • package:fespalier_image/testing.dart has FakeImages: image loads in a widget test, without a network.

Installing fespalier_image ​

Add it next to fespalier, with the same url and the same ref (Companion packages says why):

yaml
dependencies:
  fespalier:
    git:
      url: https://github.com/fespalier/fespalier
      path: packages/fespalier
      ref: v0.25.0
  fespalier_image:
    git:
      url: https://github.com/fespalier/fespalier
      path: packages/fespalier_image
      ref: v0.25.0

It has no plugin and no dependency of its own beyond fespalier. It does not wrap cached_network_image or any CDN's SDK: it chooses a width and writes a URL, and Flutter's Image does the rest (Caching images says how to plug a disk cache in).

The image CDN: imageCdnProvider ​

The app's choice lives in a provider, overridden in startup() like everything else a fespalier app configures, so a test swaps it with one line:

dart
// lib/app/startup.dart
Future<List<Override>> startup() async => [
  imageCdnProvider.overrideWithValue(
    ImageCdn(
      transport: transport, // the app's HttpTransport chain (fespalier_transport)
      builder: const ImgproxyUrlBuilder.emgr(
        baseUrl: String.fromEnvironment('IMAGES', defaultValue: 'https://img.example.com'),
        sourceBase: 'https://images.example.com/',
      ),
      quality: 80,
    ),
  ),
];

Images load through ImageCdn.transport (since 0.21.0): a TransportImage, which is NetworkImage over your HttpTransport chain (HTTP transport), so an image request is signed, traced and counted like the app's others. ImageCdn is therefore no longer const when it has a transport (a transport is a runtime object; const ImageCdn(builder: ...) was the example until 0.20.0). A TransportImage reads the bytes whole, decodes them with the image cache's own decoder (so the ResizeImage round it still decodes at the width the box needs, off the web) and reports a bad status as a NetworkImageLoadException.

Without an override the provider holds const ImageCdn(), which is no CDN and has no transport: a source is a URL and is meant to be fetched as it is, but with neither transport nor providerFactory there is nothing to fetch it with, so the image shows its error view and reports an ImageUrlError that says so (a NetworkImage was the default until 0.20.0). A source that is not a URL (it has no scheme) prints this once in a debug build: fespalier_image: "products/3.jpg" is not a URL and no image CDN is configured, so it is fetched as it is. Override imageCdnProvider in startup() (docs/responsive-images.md, "Images").

ImageCdn fieldWhat it is
builderThe URL builder (DirectUrlBuilder by default)
bucketsThe widths an image may be asked for (ImageBuckets.standard)
formatImageFormat.webp (default), jpeg, png, avif or auto (see URL builders: name a format)
quality1 to 100, or null to leave it to the CDN
maxPixelRatioThe device pixel ratio is capped at this (3 by default): a 3.5× phone asks for 3× widths
transportThe HttpTransport images are fetched through, as a TransportImage (since 0.21.0)
providerFactoryImageProvider<Object> Function(String url): the provider of a URL, instead of a TransportImage; plug a disk cache in here
placeholderWhat shows while an image loads (a box in the theme's surfaceContainerHighest by default)
errorBuilderWhat a failed image shows: (context, error, retry); that box with a broken-image icon by default
fadeInHow long a loaded image fades in; zero (the default) shows it at once

Every argument of ResponsiveImage that has a counterpart overrides the CDN's for that one image.

  • The provider is scoped (dependencies: const []), so a subtree, a feature or a test can override it in a nested ProviderScope. A provider of yours that reads it must list it in its own dependencies.
  • cdn.copyWith(...) makes a variant.
  • cdn.resolve(source, logicalWidth: ..., devicePixelRatio: ...) is the pure function the widget calls, which answers with the request and its URL: how a test (or a precache) knows what a box asks for without building one.

Buckets: the widths an image is fetched at ​

A CDN caches by URL, so asking for 187 pixels here and 191 there misses twice. The widget rounds up to one of a short list instead (Next.js 16's imageSizes and deviceSizes, in physical pixels):

text
32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840

The width needed is (logical × min(devicePixelRatio, maxPixelRatio) − 0.5).ceil(), and the request gets the smallest bucket at least that wide (the largest when none is).

  • The half pixel is a tolerance: a 411.43-point box at 2.625× is 1079.99… pixels, and asks for 1080, not 1200.
  • maxPixelRatio is 3 so a phone at 3.5× neither downloads nor decodes a third more pixels than it can show (a 640×640 image decodes to 1.6 MB and a 3840×3840 one to 59 MB, which is also why the list stops at 3840).

ImageBuckets([...]) replaces the list, for a CDN that only has presets (ImageBuckets([128, 640]), with the presets named w128 and w640). The widths must be positive and strictly increasing, or the first use throws ImageBuckets: the widths must be positive and increasing, got [64, 32].

Because the widths are few, the URL that a precache computes is the one the page later asks for. A srcset has its own widths, and those replace the buckets (Already sized: a srcset).

ResponsiveImage ​

dart
ResponsiveImage('products/3.jpg', width: 160, aspectRatio: 1)   // a 160-point square: the 640 bucket at 3×
ResponsiveImage(product.photo, aspectRatio: 16 / 9)             // as wide as the layout gives it
ArgumentWhat it does
sourceA path or public id the CDN knows, a URL, or a srcset
width, heightThe box in logical pixels. A width is not measured, so the widget also works inside IntrinsicWidth
aspectRatioThe shape. The CDN crops to it (resize) and the box takes it
resize, fit, alignmentImageResize.fill (cover and crop, the default) or fit, on the server; BoxFit and Alignment on the device
quality, format, extra, builder, bucketsOverride the CDN's; extra is provider-specific options (URL builders)
growWithBoxAsks for a wider bucket when the box grows past the one shown
placeholder, errorBuilder, fadeInOverride the CDN's
semanticLabel, excludeFromSemanticsImage's

The box. width and height make a SizedBox. An aspectRatio (or both width and height, which are one) makes an AspectRatio, in a SizedBox when there is a width. Otherwise the parent decides.

  • The image, the placeholder and the error view fill the box, and a side the parent leaves unbounded collapses instead of throwing.
  • Without a width, a LayoutBuilder measures: the maximum width when it is bounded, else the maximum height times the aspect ratio, else the view's width. A LayoutBuilder cannot be asked for its intrinsic size, so inside IntrinsicHeight give the widget a width.

The shape is the app's, not the box's. The server crops only when you say the shape: aspectRatio: 1 (or a width and a height) asks for rs:fill:640:640, and without one the request is width-only (rs:fit:640:0) and fit crops on the device. A ratio measured from the box would make a new URL, and a new cache entry, for every pixel of padding; a design constant (1, 4/3, 16/9) does not.

It chooses once, and only grows. The bucket is picked at the first layout of an element, again when its inputs change (the source, the options, the builder, the CDN), and when the view's size or the device pixel ratio changes, but then only upward; with growWithBox: true also when the box grows. It is never smaller than what is on screen, so an animation of the box (a hero flight, an AnimatedSize) asks for one width and not one per frame, and a window that gets narrower downloads nothing. Browser zoom changes the device pixel ratio, so the bucket grows and does not shrink.

It is never blank when something is loaded. While its URL loads, the widget shows the widest variant of the same picture (the same source, builder, shape, quality, format and options, at another width) that is already in the image cache, instead of the placeholder: a thumbnail already on screen stands in for the large image, with no flicker on a detail page. "Loaded" is read synchronously from Flutter's ImageCache. The widget builds no Future and no microtask of its own and starts no timer.

Placeholders and errors.

  • placeholder is a WidgetBuilder; the default is a box in the theme's surfaceContainerHighest.
  • A failed load shows errorBuilder(context, error, retry), and retry evicts the provider and loads again.
  • fadeIn: Duration(milliseconds: 200) fades a load that arrives later in over what was showing (a load that was ready in the first frame does not fade).
  • A misconfigured builder or bucket is a bug in the app, not a network failure: it shows the error view and reports an ImageUrlError once (FlutterError.reportError, library fespalier_image, while building the URL of the image). Every message of the package is quoted in the fespalier-troubleshooting skill.

URL builders ​

An ImageUrlBuilder turns an ImageRequest (the source, a width, an optional height, the resize mode, the quality, the format and extra) into a URL. All of them are const, compare equal by their fields, throw an ImageUrlError when misconfigured, and are unsigned unless you give them a signer: (Signed image URLs).

ImageFormat defaults to webp, which Flutter decodes on every platform. ImageFormat.auto asks the CDN to choose from the request's Accept header, which Flutter cannot steer (dart:io sends none, a browser's XHR sends */*), so it can answer an AVIF the platform does not decode: name a format instead.

BuilderForname (telemetry)
ImgproxyUrlBuilder, ImgproxyUrlBuilder.emgrimgproxy, and EmgR (vaam-apps/image-resizer)imgproxy, emgr
CloudinaryUrlBuilderCloudinary delivery URLscloudinary
ImgixUrlBuilderimgiximgix
ThumborUrlBuilderThumbor and imagorthumbor
TemplateUrlBuilderany CDN whose URLs are a path and a querytemplate (or the name:)
SrcsetUrlBuilderimages that arrive already sized, signed by the backendsrcset
DirectUrlBuilderno CDN: the source is the URL (the default)direct

Subclass ImageUrlBuilder for another CDN: url(request) and name are all it needs. resizes says whether the CDN returns the width asked for (when false, the widget decodes at that width), and widthsOf(source) gives the widths a source exists at.

imgproxy and EmgR ​

{baseUrl}/{signature}/{options}/{source}. The options are rs:fill:W:H (or rs:fit:W:H; rs:fit:W:0 without a height), then q:Q when there is a quality, then extra. The source is URL-safe base64 without padding and the extension (.webp), or, with encoding: ImgproxySourceEncoding.plain, plain/ and the percent-encoded URL. A source without a scheme gets sourceBase in front of it.

dart
const ImgproxyUrlBuilder(baseUrl: 'https://imgproxy.example.com')
// https://imgproxy.example.com/insecure/rs:fill:640:640/aHR0cHM6Ly9pbWFnZXMuZXhhbXBsZS5jb20vcGhvdG8uanBn.webp

const ImgproxyUrlBuilder.emgr(baseUrl: 'http://localhost:13001', sourceBase: 'https://images.example.com/')
// photo.jpg at 1080: http://localhost:13001/unsigned/rs:fit:1080:0/aHR0cHM6Ly9pbWFnZXMuZXhhbXBsZS5jb20vcGhvdG8uanBn.webp

.emgr follows EmgR, which differs from imgproxy:

  • an unsigned URL says unsigned (imgproxy's docs say insecure);
  • a plain source takes its extension after a dot (….jpg.png, imgproxy writes @png);
  • ImageFormat.auto is the extension .auto (imgproxy: none);
  • g: is grayscale and gravity is gr: (gr:ce; pass either through extra).

processing: (r) => ['pr:w${r.width}'] replaces the options, for a server that takes presets only. A baseUrl that is not an absolute http or https URL is an ImageUrlError. imgproxy's source URL encryption (enc/, Pro) is not built.

Cloudinary ​

{baseUrl}/{cloudName}/image/{upload|fetch}/{transformation}/{source}, with the transformation's qualifiers sorted by name as Cloudinary's SDKs write them: c_fill (with a height) or c_limit, f_webp, h_, q_ (q_auto without a quality) and w_; then each extra as a component of its own. An uploaded asset's public id that has a / and no version gets v1/ (forceVersion: false turns it off), and a fetched URL is escaped the way Cloudinary expects.

dart
const CloudinaryUrlBuilder(cloudName: 'demo')
// docs/shoes.jpg at 640×480: https://res.cloudinary.com/demo/image/upload/c_fill,f_webp,h_480,q_auto,w_640/v1/docs/shoes.jpg

const CloudinaryUrlBuilder(cloudName: 'demo', delivery: CloudinaryDelivery.fetch)
// a URL at 256×256, quality 80, jpeg: https://res.cloudinary.com/demo/image/fetch/c_fill,f_jpg,h_256,q_80,w_256/https://images.example.com/photo.jpg

There is no signer:: a signed Cloudinary URL needs the account's API secret, which also authorises uploads and deletes. Let the backend send signed URLs and use a srcset.

imgix ​

https://{domain}/{path}?{parameters}, the parameters sorted by name: fm (or auto=format), fit=crop (with a height) or fit=max, h, q, w, then each extra as key=value (it replaces a built-in parameter of its name). A source with a scheme is one encoded path segment, for imgix's web-proxy sources.

dart
const ImgixUrlBuilder(domain: 'demos.imgix.net')
// bridge.png at 640×480: https://demos.imgix.net/bridge.png?fit=crop&fm=webp&h=480&w=640
// with extra: ['crop=faces', 'sat=-100']: …?crop=faces&fit=crop&fm=webp&h=480&sat=-100&w=640

domain is a host name, without a scheme or a path. A signer: gets /path?query (the parameters sorted, without s) and returns the s value.

Thumbor ​

{baseUrl}/{signature}/{path}, with the path fit-in/ (a fit with a height), WxH, smart/ (smart: true), filters:quality(Q):format(webp):… and the source as written. imagor reads the same. Unsigned URLs say unsafe, which Thumbor refuses unless ALLOW_UNSAFE_URL is on.

dart
const ThumborUrlBuilder(baseUrl: 'https://thumbor.example.com')
// images.example.com/photo.jpg at 640×480: https://thumbor.example.com/unsafe/640x480/filters:format(webp)/images.example.com/photo.jpg

A signer: gets the path without a leading / (this is where Thumbor and imgproxy differ) and returns the signature segment. Encode a query string in the source yourself.

A URL template ​

For a CDN whose URLs are a path and a query. {source}, {width}, {height} (0 when not set), {quality} (the request's, else the builder's quality, 80) and {format} are replaced; anything else in braces is an error naming it.

dart
const TemplateUrlBuilder('https://cdn.example.com/{source}?w={width}&h={height}&q={quality}&fm={format}')
// products/1.jpg at 640: https://cdn.example.com/products/1.jpg?w=640&h=0&q=80&fm=webp

resizes is true when the template has {width}; without it the CDN does not size the image, and the widget decodes it at the width the box needs.

Already sized: a srcset ​

When the backend sends the photo's URLs, one per width, signed if they need to be, the source is a srcset and the URLs are used as they are:

dart
ResponsiveImage(
  product.photo, // "https://a.example/p-256.webp 256w, https://a.example/p-640.webp 640w"
  builder: const SrcsetUrlBuilder(),
  aspectRatio: 1,
)

The widths of the srcset replace the buckets: a 100-point image at 3× needs 300 pixels and gets the 640 candidate; a width wider than the widest candidate gets the widest.

  • SrcsetUrlBuilder.parse reads a srcset (a candidate is a URL and a 640w descriptor; a URL may contain commas).
  • SrcsetUrlBuilder.of({256: a, 640: b}) writes one.
  • An empty srcset, or a candidate without a w descriptor (2x, or none), is an ImageUrlError.

Signed image URLs ​

The package never takes a key. A key in an app is public: the Android and iOS binaries are unpacked with standard tools, a Flutter web app's main.dart.js is downloaded by every visitor, --dart-define values are compiled in, and obfuscation renames symbols, not string constants. With the key anyone signs any URL, and the resizer becomes an open image proxy (any allowed source, in every size), a CPU amplifier (a new size per request defeats its result cache) and a bandwidth bill.

So no parameter named or typed like a key or a salt exists in fespalier_image, no HMAC, SHA or MD5 code ships in it, and every builder is unsigned by default. Three ways to get signed URLs, in the order to prefer them:

  1. The backend signs. The API that returns a product returns its photo's URLs, signed, one per width the app uses, as a srcset. The page shows ResponsiveImage(product.photo, builder: const SrcsetUrlBuilder(), aspectRatio: 1). The backend owns the key, the widths and the options; it works for every CDN, Cloudinary's and imgix's secure URLs included; and the URLs arrive with data.dart, which RouteLink preloads, so the widget stays synchronous. A signing endpoint is the same thing called from data.dart (GET /images/sign?src=…&w=256,640): the asynchronous part lives in the data layer, never in the widget.

    json
    {
      "id": 3,
      "photo": "https://img.example.com/Kx…/rs:fill:256:256/aHR0….webp 256w, https://img.example.com/Q9…/rs:fill:640:640/aHR0….webp 640w"
    }
  2. Unsigned, with the server's allowlists. Fine for development, and for production when the server takes presets only, so the URL space is finite (sources × presets) and every result is cached once.

    • EmgR: ALLOW_UNSIGNED_REQUESTS=true, ALLOWED_SOURCES=https://images.example.com/, ALLOWED_PROCESSING_OPTIONS=pr and PRESETS=w128=rs:fill:128:128,w640=rs:fill:640:640; the app writes processing: (r) => ['pr:w${r.width}'] with buckets: ImageBuckets([128, 640]).
    • imgproxy: IMGPROXY_ALLOWED_SOURCES and IMGPROXY_ONLY_PRESETS=true.
    • EmgR documents ALLOW_UNSIGNED_REQUESTS as a local-development escape hatch, and without the allowlists an unsigned server is the open proxy above.
  3. A signer: that looks signatures up. String Function(String payload), called synchronously while the widget builds, so it must return at once: for a backend that sends {payload: signature} pairs, for server-side Dart, and for tests. The payload is documented per builder and pinned by the tests against each provider's published examples, so a backend that signs the same string gets the same signature.

ProviderUnsignedSignedIn the package
imgproxy / EmgRinsecure / unsigned (the server must allow it)HMAC-SHA256 over the salt and the pathsigner: gets the path, leading / included
Thumbor / imagorunsafe (ALLOW_UNSAFE_URL)HMAC-SHA1 over the path without /, padded base64urlsigner: gets the path without a leading /
imgixa plain sources = MD5 of the token, the path, ? and the querysigner: gets /path?query and returns the s value
Cloudinarystrict transformations offs--8 chars-- from the account's API secretno signer: backend URLs through SrcsetUrlBuilder

The HMAC belongs in your backend, never in the app. For an imgproxy or EmgR backend written in Dart:

dart
// On your server, never in the app: an imgproxy or EmgR signature.
import 'dart:convert';
import 'package:crypto/crypto.dart';

String signImgproxy(List<int> key, List<int> salt, String path) => base64Url
    .encode(Hmac(sha256, key).convert([...salt, ...utf8.encode(path)]).bytes)
    .replaceAll('=', '');

Since 0.9.0. A page that shows an image at one size, behind a link that shows it at another, should have the page's image warm when the link is followed. ResponsiveImage.precache(context, source, width:, aspectRatio:) computes the very URL a ResponsiveImage of that size will ask for, with the CDN of context (imageCdnProvider), and loads it into Flutter's image cache. RouteLink(onPreload:) calls it when the link starts a preload (Links: RouteLink):

dart
// lib/app/products/page.dart: in the row of each product
RouteLink(
  to: ProductRoute(id: p.id),
  preload: Preload.intent,
  // The page shows the photo pagePhotoSize wide: warm that size, not the row's.
  onPreload: (context) => ResponsiveImage.precache(context, p.image, width: pagePhotoSize, aspectRatio: 1),
  builder: (context, follow) => ListTile(...),
)

Imperative uses need nothing new: call ResponsiveImage.precache(context, ...) before route.go(context).

  • It completes when the image is loaded or has failed (a failure is dropped: the widget shows its own error), and without a width it uses the view's width.
  • The page's size is known only where there is a BuildContext (MediaQuery), not in route.preload(ref).
  • Buckets absorb a few points of padding, so the precache's URL equals the page's whenever both use the same constant for the photo's size, as examples/shop does (pagePhotoSize). A precache at another bucket than the page's is a wasted download.
  • A misconfigured builder is reported as ImageUrlError with the context while precaching the image "products/3.jpg", and the call returns at once.

Images in heroes ​

A hero flight rebuilds the destination's child at every rectangle of the flight. A widget that measures its box would ask for a new URL at each, and an image in a hero would download a large variant of the list's shape just to fly.

ResponsiveImage.flightShuttle is a Hero.flightShuttleBuilder that marks the flight: an image in flight never starts a load and shows the widest variant of its source that is already loaded, of any shape, else its placeholder. route.imageHero(name, child:) is route.hero(name, shuttle: ResponsiveImage.flightShuttle, child:), so it takes the shared element on both pages in one line each:

dart
// lib/app/products/page.dart: in the row of each product
leading: ProductRoute(id: p.id).imageHero('photo', child: ResponsiveImage(p.image, width: 40, height: 40)),

// lib/app/products/$id/page.dart
ProductRoute(id: product.id).imageHero('photo', child: ResponsiveImage(product.image, width: 160, height: 160)),

Heroes(shuttle: ResponsiveImage.flightShuttle) in the root transition.dart sets it for every hero (it is harmless for a hero without an image).

  • With the page's size precached the shuttle shows that variant at every size of the flight and neither the push nor the pop makes a request.
  • Without a precache it shows the row's thumbnail, and the page shows the thumbnail (the same picture) until its own image arrives.
  • Different shapes on the two sides (1:1 in the row, 4:3 on the page) still fly the loaded variant; at rest the page shows its placeholder until its own shape loads, because a stand-in of another shape would jump.

There is no automatic detection of a flight: an image in a tooltip or an OverlayPortal is also outside any route, so "no ModalRoute" would freeze it. Without the shuttle, Flutter's default measures the image again inside the overlay and it asks for a URL for the rectangle of the moment.

Images on the web ​

Flutter 3.35 and later (every Flutter fespalier supports) have no HTML renderer: CanvasKit and skwasm fetch an image's bytes with XHR, so the image server must send CORS headers on the image and on the redirect target. A blank image on the web and nothing on the other platforms is this.

  • EmgR sends Access-Control-Allow-Origin: * on every route; its CDN_BASE_URL host must as well, since it answers the redirect to the stored file.
  • imgproxy: IMGPROXY_ALLOW_ORIGIN.

A transport fetch needs CORS on the image host (since 0.21.0). TransportImage fetches through the transport, which on the web is fetch or XMLHttpRequest (package:http's browser client, or Dio's adapter), and the browser refuses a response without CORS headers. The <img> element fallback that NetworkImage could fall back to did not need them, but ImageCdn.webHtmlElementStrategy is gone (it defaulted to never until 0.20.0). For a host you do not control that sends no CORS, give the CDN a provider of your own and give up the transport chain for its images: providerFactory: (url) => NetworkImage(url, webHtmlElementStrategy: WebHtmlElementStrategy.fallback) shows an <img> platform view when the fetch fails (a platform view per image, which is costly in a long list, and no request headers); prefer always uses <img>.

Also on the web: the browser's HTTP cache works for XHR, so EmgR's immutable downloads are cached across reloads; ResizeImage does not shrink a web NetworkImage, so a DirectUrlBuilder saves no memory there; ImageFormat.auto can answer AVIF, so do not use it; and the package's code is part of main.dart.js when an eager page uses it (Web chunk sizes has the budgets).

Caching images ​

By default Flutter's in-memory ImageCache (1000 images, 100 MB). On Android, iOS and desktop there is no disk cache: a transport over dart:io's HttpClient has no HTTP cache, so a restart downloads again. Plug one in with providerFactory (instead of transport: it gives up the transport chain for images), a one-line recipe on cached_network_image (the app adds the dependency; it needs Flutter 3.44 and Dart 3.12, above fespalier's own floor, which is why the package does not depend on it):

dart
// lib/app/startup.dart
import 'package:cached_network_image/cached_network_image.dart';

List<Override> startup() => [
  imageCdnProvider.overrideWithValue(
    ImageCdn(
      builder: const ImgproxyUrlBuilder.emgr(baseUrl: 'https://img.example.com'),
      providerFactory: (url) => CachedNetworkImageProvider(url),
    ),
  ),
];

extended_image's ExtendedNetworkImageProvider(url, cache: true) is the alternative without sqflite. A provider from a factory takes part in everything above (the stand-in, the precache, the flight) as long as its key is available synchronously, as NetworkImage's is. On the web the browser's cache does it.

Testing images ​

package:fespalier_image/testing.dart has FakeImages: a providerFactory whose providers record each URL and complete when the test says, or at once, with no network, no timer and no HttpClient (flutter_test's fake one answers every request with a 400 and a warning).

dart
// test/images_test.dart
final fakes = FakeImages(image: await createTestImage()); // made once, in setUpAll
// every load completes in the frame that asks for it; without `image:`, call fakes.complete(url, image) or fakes.fail(url)
await pumpRouter(tester, overrides: [imageCdnProvider.overrideWithValue(fakes.cdn(shopImages))]);
expect(fakes.requested, ['http://localhost:13001/unsigned/rs:fill:128:128/aHR0….webp']);

fakes.cdn(cdn) is cdn loading through the fakes, so the test asserts the exact URLs of the app's real builder.

  • Put the override in pumpRouter(overrides:) and in test/routes/setup.dart's overrides(pattern) (Route smoke tests), or every test that reaches a page with an image goes through the fake HttpClient.
  • A FakeImages' providers are equal for one URL of one instance and never equal to another's, so an image cached by an earlier test is not reused.
  • cdn.resolve(...) tests a size rule without a widget.

Image loads in telemetry ​

Since 0.9.0. With a telemetry sink installed, each network load is an image operation: TelemetryOp.image through FespalierTelemetry.begin and finish, and a span image {cdn} (for example image emgr) with these attributes (Telemetry conventions): fespalier.image.cdn; fespalier.image.width (the bucket); fespalier.image.preload (a precache started it); fespalier.image.result; and fespalier.image.status, for a failed load that carries one.

There is one span per load that starts: a cache hit and a load already in flight make none, and an image in a hero flight starts no load. A load that starts while a page is being reached is a child of that navigation. The URL, the source, the signature and the error's text are never recorded (the exception's message holds the URL): only the builder's name, the bucket, the HTTP status and the outcome.

Image spans need no telemetry: true: they follow the installed sink, like the auth spans. RecordingTelemetry writes them as #4 start image emgr w=640 preload and #4 end image ok async, or #5 end image error async status=404. A load that fails (an offline phone) is a failure like any other, so the Errors dashboard of fsp telemetry lists it under the kind "Image load"; filter on fespalier_operation <> 'image' if they drown the rest.

An exhaustive switch over TelemetryOp in a sink of your own needs a case for image (since 0.9.0, next to auth): the new value is a source break for such a switch.

What images cost ​

Used, the widget builds no Future and no microtask of its own while building, starts no timer, adds no listener of its own (except a fadeIn's animation, only when you ask for one), and reads Flutter's ImageCache synchronously for the stand-in and the "loaded" check. The one process-wide state is a list of the providers it made, by source (256 sources, least recently used first out), so a thumbnail can stand in for the large image. It is keyed by the builder and the provider factory, so two CDNs and the fakes of two tests never see each other's entries.

Released under the MIT License.