Skip to content

Add useLogicalPixels to ResizeImage for logical pixel cache sizing - #184549

Merged
auto-submit[bot] merged 22 commits into
flutter:masterfrom
koji-1009:feat/image_device_pixel_ratio_cache
Aug 19, 2026
Merged

auto-submit[bot] merged 22 commits into
flutter:masterfrom
koji-1009:feat/image_device_pixel_ratio_cache

Conversation

@koji-1009

@koji-1009 koji-1009 commented Apr 2, 2026 •

Copy link
Copy Markdown
Contributor

fix #56239

Add useLogicalPixels to ResizeImage and useLogicalCacheSize to Image.{network, file, asset, memory} and FadeInImage.{memoryNetwork, assetNetwork} constructors.

When true, the cache dimensions are interpreted as logical pixels rather than physical pixels, so the cached resolution follows the surrounding device pixel ratio without requiring BuildContext at the call site. Default is false; existing callers are unaffected.

example
app(lib/main.dart)
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: const MyWidget());
  }
}

class MyWidget extends StatelessWidget {
  const MyWidget({super.key});

  @override
  Widget build(BuildContext context) {
    final dpr = MediaQuery.devicePixelRatioOf(context);
    const url =
        'https://images.unsplash.com/photo-1550613097-fe6c2c321cd3'
        '?auto=format&fit=crop&w=1200&q=80';

    return Scaffold(
      appBar: AppBar(title: Text('devicePixelRatio: $dpr')),
      body: Center(
        child: Column(
          spacing: 8,
          children: [
            const Text('No cacheWidth (reference)'),
            Image.network(url, width: 300, fit: BoxFit.cover),
            const Text('cacheWidth: 300, useLogicalCacheSize: false'),
            Image.network(url, width: 300, fit: BoxFit.cover, cacheWidth: 300),
            const Text('cacheWidth: 300, useLogicalCacheSize: true'),
            Image.network(
              url,
              width: 300,
              fit: BoxFit.cover,
              cacheWidth: 300,
              useLogicalCacheSize: true,
            ),
          ],
        ),
      ),
    );
  }
}

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

@github-actions github-actions Bot added the framework flutter/packages/flutter repository. See also f: labels. label Apr 2, 2026
@koji-1009
koji-1009 marked this pull request as ready for review April 3, 2026 00:39

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds useLogicalPixels to ResizeImage and useLogicalCacheSize to the Image widget, enabling image decoding at logical pixel sizes scaled by the device pixel ratio. Changes include updates to ResizeImageKey, ResizeImage, and Image constructors, along with new regression tests. Feedback identifies a null safety compilation error in image_provider.dart regarding the assignment of effectiveWidth and notes that the new public parameter in resizeIfNeeded requires documentation to comply with the style guide.

@justinmc
justinmc requested review from justinmc and victorsanni April 7, 2026 22:32

@victorsanni victorsanni left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in the ideal world users should not have to turn on this flag to get the correct behavior. It is scary though as developers are probably already implementing workarounds (most notably multiplying by the device pixel ratio) for this issue.

I wonder if it might be better here to break (by always multiplying with the DPR) even though it will be disruptive for users already adopting the workaround.

/// These values also set the image's width & height in logical pixels
/// if it is unconstrained.
///
/// If [useLogicalPixels] is true, [width] and [height] are multiplied by

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the docs should recognize the bug. When/why should useLogicalPixels be set?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an option addition (default off), not a bug fix — wouldn't bug framing be excessive?

this.filterQuality = FilterQuality.medium,
int? cacheWidth,
int? cacheHeight,
bool useLogicalCacheSize = false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These (and other constructors) will need tests as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is covered through image_provider_resize_image_test.dart.

The Image.* constructors forward useLogicalCacheSize to ResizeImage.resizeIfNeeded via a named parameter; image_test.dart doesn't carry similar forwarding tests for cacheWidth/cacheHeight either. Happy to add explicit forwarding tests if you'd still prefer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should still test, even if it is only the forwarding that is tested (i.e the underlying ResizeImage has the expected value). For every public API we are adding here.

/// These parameters are primarily intended to reduce the memory usage of
/// [ImageCache].
///
/// If `useLogicalCacheSize` is true, `cacheWidth` and `cacheHeight` are

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why even set this to true? The documentation should explain this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a use-case hint to the field/param doc. 33d73e2

/// These parameters are primarily intended to reduce the memory usage of
/// [ImageCache].
///
/// If `useLogicalCacheSize` is true, `cacheWidth` and `cacheHeight` are

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

/// These parameters are primarily intended to reduce the memory usage of
/// [ImageCache].
///
/// If `useLogicalCacheSize` is true, `cacheWidth` and `cacheHeight` are

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here

// so we can directly create the synchronous result to return.
result = SynchronousFuture<ResizeImageKey>(
ResizeImageKey._(key, policy, width, height, allowUpscaling),
ResizeImageKey._(key, policy, width, height, allowUpscaling, devicePixelRatio),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of storing only the devicepixelratio in the key, can we store the effective size? That way if the devicepixelratio is different but the effective size (which is the downstream quantity we need) is the same we can just cache that. What do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted, thanks.

ResizeImageKey now stores effective decode dimensions; two ResizeImages whose post-DPR size matches share a cache entry.

@koji-1009

Copy link
Copy Markdown
Contributor Author

Thank you for the review, @victorsanni. I agree — users should get the correct behavior without needing to opt in.

I originally proposed the flag out of caution for backward compatibility, but I'd prefer to always multiply width/height by devicePixelRatio (DPR) internally, removing useLogicalPixels and useLogicalCacheSize.

I looked into cached_network_image, which is the most widely used library wrapping ResizeImage. It passes memCacheWidth/memCacheHeight directly through ResizeImage.resizeIfNeeded without any DPR multiplication of its own, so the library itself requires no changes.

https://github.com/Baseflow/flutter_cached_network_image/blob/v3.4.1/cached_network_image/lib/src/cached_image_widget.dart#L272-L295
https://github.com/Baseflow/octo_image/blob/v2.1.0/lib/src/image/image.dart#L205-L209

The impact is limited to app-level code that manually multiplies by DPR as a workaround. Does this direction work for you?

@koji-1009
koji-1009 force-pushed the feat/image_device_pixel_ratio_cache branch from 9bdc90e to b25a17f Compare April 26, 2026 13:58
@koji-1009 koji-1009 changed the title Add useLogicalPixels to ResizeImage for logical pixel cache sizing Always scale ResizeImage dimensions by devicePixelRatio Apr 26, 2026
@koji-1009
koji-1009 force-pushed the feat/image_device_pixel_ratio_cache branch from b25a17f to d5a26bb Compare April 26, 2026 14:20
@koji-1009
koji-1009 requested a review from victorsanni April 27, 2026 07:03

@victorsanni victorsanni left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think changing the behavior of width and height under the hood is probably not good. If we want to solve this problem this way, we should deprecate width and height, and introduce new similar properties that internally multiply by the devicePixelRatio.

That way users (who may have been using these workarounds) don't get the rug pulled from under them. By migrating to these new properties users are basically signing up for this change in behavior.

/// These values also set the image's width & height in logical pixels
/// if it is unconstrained.
///
/// [width] and [height] are specified in logical pixels and multiplied by

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
/// [width] and [height] are specified in logical pixels and multiplied by
/// The [width] and [height] are specified in logical pixels and multiplied by

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — no longer applies after the shift to opt-in default.

@koji-1009

Copy link
Copy Markdown
Contributor Author

Thanks for the follow-up, @victorsanni.

Coming back to the flag idea I had originally - let me re-propose it with useLogicalPixels defaulting to true, so the observable behavior matches what this PR currently does, and useLogicalPixels: false is available as an explicit escape hatch. I think this addresses the rug-pull concern at lower cost than deprecating width/height.

Concretely:

ResizeImage(provider, width: 300, height: 200);

// Opt-out
ResizeImage(provider, width: 300, height: 200, useLogicalPixels: false);

Comparing the two routes from a maintenance standpoint, deprecating width/height and adding parallel "logical" variants would touch:

  • ResizeImage.{width, height} and ResizeImage.resizeIfNeeded, plus Image.{network,file,asset,memory}.cacheWidth/.cacheHeight (4 constructors x 2 parameters = 8 public APIs).
  • Many internal call sites (e.g. widgets/image.dart and painting/image_provider.dart, plus tests and samples).
  • Downstream wrappers, most notably octo_image / cached_network_image (~2.17M monthly downloads each on pub.dev). Both forward memCacheWidth/memCacheHeight straight through to ResizeImage.resizeIfNeeded with no DPR handling of their own, so they inherit whatever ResizeImage does. New properties would require their own deprecation cycle and a major release before users on that path get the fix.

Eventually removing the deprecated public API is also a larger break than retiring an internal flag.

By contrast, the flag approach:

  • Keeps the same default behavior as this PR, so octo_image / cached_network_image users get the fix automatically without any public-API change in those packages.
  • Gives users on the manual-DPR workaround useLogicalPixels: false to migrate at their own pace instead of being broken without an opt-out.
  • Once the ecosystem has migrated, the flag itself can be deprecated and eventually removed - a much smaller cleanup than removing public properties on Image.*.

Thanks for taking the time to review - please let me know if there are concerns I haven't addressed here.

@koji-1009
koji-1009 requested a review from victorsanni April 28, 2026 04:04
@victorsanni victorsanni added the CICD Run CI/CD label Apr 28, 2026
justinmc
justinmc previously approved these changes Apr 29, 2026

@justinmc justinmc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to throw an approval on here so we can see if the change as-is breaks Google tests or not. That will add some more information to this decision.

Also FYI I think the before and after images in the top PR description are reversed if I'm not mistaken.

@victorsanni

Copy link
Copy Markdown
Contributor

Hi @koji-1009, thanks for the detailed explanation. I understand better now. While it sucks that the intended behavior will be opt-in, it allows us to nudge users towards it allowing for a gentler migration instead of suddenly breaking two APIs.

Feels like we've come full circle :))

@github-actions github-actions Bot removed the CICD Run CI/CD label Apr 29, 2026
@justinmc justinmc added the CICD Run CI/CD label Apr 29, 2026
@victorsanni

Copy link
Copy Markdown
Contributor

Google testing failure is legitimate - FlutterLogo getting scaled by devicePixelRatio.

@github-actions github-actions Bot removed the CICD Run CI/CD label May 1, 2026
///
/// At least one of `width` and `height` must be non-null.
///
/// Set [useLogicalSize] to specify `width` and `height` in logical pixels.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is enough discoverability-wise. We need to explain what the default should be, what it is, why this might cause issues, and how to correct it. This line only explains the "how", and introduces no new information that isn't immediately obvious from the name of the flag.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to make sure I'm reading your comment correctly. Are you suggesting the doc should state what the default should be? For example, "logical pixels would be the appropriate default; physical is the current default."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More than that - a developer doing their due diligence before adopting this widget for use in their project should be able to know from the documentation that specifying cacheWidth/Height may result in lower quality images, why that happens, and how this flag helps resolve this mismatch.

While we don't conventionally link to issues in public-facing API docs, the docs should contain enough information for new adopters to foresee and solve the problem, and the flag should specify the correct default is logical, but is set to physical for backwards compatibility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would one of these match what you have in mind?

A — full inline explanation

/// Whether [width] and [height] are interpreted as logical pixels.
///
/// When `false` (the default), [width] and [height] are treated as physical
/// pixels. On high-density displays, this can result in images being decoded
/// at fewer pixels than the screen can render, producing lower-quality output.
/// When `true`, [width] and [height] are multiplied by the device pixel ratio
/// from the current [ImageConfiguration], so the image is decoded at the
/// resolution that matches the rendered display.
///
/// The correct default is logical, but it is set to physical for backwards
/// compatibility.

B — link to the originating issue

/// Whether [width] and [height] are interpreted as logical pixels.
///
/// When `false` (the default), [width] and [height] are treated as physical
/// pixels. When `true`, they are multiplied by the device pixel ratio from
/// the current [ImageConfiguration] before decoding.
///
/// See https://github.com/flutter/flutter/issues/56239 for background on why
/// physical pixels can produce lower-quality output on high-density displays.
///
/// The correct default is logical, but it is set to physical for backwards
/// compatibility.

Personally, when I encounter a new flag in the Flutter SDK, I search GitHub for the PR that introduced it — so for me, issue or PR links inside the field doc haven't been necessary.

Could you let me know which of these is appropriate as Flutter documentation? If neither matches your intent, please share the concrete wording you have in mind.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A is better, we try not to link to issues etc in widget API docs.

Slight tweaks:

  • Replace the last paragraph with "Typically in the framework, pixel dimensions are interpreted as logical rather than physical. However, this defaults to physical for backwards compatibility". A behavior being correct or incorrect is a matter of perspective.

@koji-1009 koji-1009 May 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Applied in 735b911.


/// Whether [width] and [height] are interpreted as logical pixels rather
/// than physical pixels.
///

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done here as well in 735b911.

@koji-1009
koji-1009 requested a review from victorsanni May 25, 2026 20:52

@victorsanni victorsanni left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@github-actions github-actions Bot removed the CICD Run CI/CD label May 30, 2026
@koji-1009
koji-1009 requested a review from victorsanni May 30, 2026 05:56
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
bool useLogicalCacheSize = false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if width is unset but cacheWidth is set and useLogicalCacheSize is true?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test for this as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

width (layout) and cacheWidth / useLogicalCacheSize (decode) are independent paths — an unset width doesn't feed into the resize path, so cacheWidth is still DPR-multiplied. That multiplication is already asserted in image_provider_resize_image_test.dart ("useLogicalSize accounts for devicePixelRatio when decoding"), and the constructors just forward the flag. width being unset doesn't reach a distinct path, so there's no separate case to add here.

@justinmc justinmc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

@flutter-dashboard flutter-dashboard Bot removed the CICD Run CI/CD label Aug 12, 2026
@koji-1009

Copy link
Copy Markdown
Contributor Author

Thank you for the review. I've merged the master branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Specifying cacheWidth / cacheHeight can degrade the image quality

3 participants