Optionally invert oversized images (#61209)

* Optionally invert oversized images
This commit is contained in:
Dan Field 2020-07-13 14:03:23 -07:00 committed by GitHub
parent 8048c0332d
commit eadc35f62b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 361 additions and 51 deletions

View file

@ -127,6 +127,37 @@ class ImageSizeInfo {
/// time.
PaintImageCallback debugOnPaintImage;
/// If true, the framework will color invert and horizontally flip images that
/// have been decoded to a size taking at least [debugImageOverheadAllowance]
/// bytes more than necessary.
///
/// It will also call [FlutterError.reportError] with information about the
/// image's decoded size and its display size, which can be used resize the
/// asset before shipping it, apply `cacheHeight` or `cacheWidth` parameters, or
/// directly use a [ResizeImage]. Whenever possible, resizing the image asset
/// itself should be preferred, to avoid unnecessary network traffic, disk space
/// usage, and other memory overhead incurred during decoding.
///
/// Developers using this flag should test their application on appropriate
/// devices and display sizes for their expected deployment targets when using
/// these parameters. For example, an application that responsively resizes
/// images for a desktop and mobile layout should avoid decoding all images at
/// sizes appropriate for mobile when on desktop. Applications should also avoid
/// animating these parameters, as each change will result in a newly decoded
/// image. For example, an image that always grows into view should decode only
/// at its largest size, whereas an image that normally is a thumbnail and then
/// pops into view should be decoded at its smallest size for the thumbnail and
/// the largest size when needed.
///
/// This has no effect unless asserts are enabled.
bool debugInvertOversizedImages = false;
/// The number of bytes an image must use before it triggers inversion when
/// [debugInvertOversizedImages] is true.
///
/// Default is 1024 (1kb).
int debugImageOverheadAllowance = 1024;
/// Returns true if none of the painting library debug variables have been changed.
///
/// This function is used by the test framework to ensure that debug variables
@ -142,7 +173,9 @@ bool debugAssertAllPaintingVarsUnset(String reason, { bool debugDisableShadowsOv
assert(() {
if (debugDisableShadows != debugDisableShadowsOverride ||
debugNetworkImageHttpClientProvider != null ||
debugOnPaintImage != null) {
debugOnPaintImage != null ||
debugInvertOversizedImages == true ||
debugImageOverheadAllowance != 1024) {
throw FlutterError(reason);
}
return true;

View file

@ -456,42 +456,6 @@ void paintImage({
assert(sourceSize == inputSize, 'centerSlice was used with a BoxFit that does not guarantee that the image is fully visible.');
}
// Output size is fully calculated.
if (!kReleaseMode) {
final ImageSizeInfo sizeInfo = ImageSizeInfo(
// Some ImageProvider implementations may not have given this.
source: debugImageLabel ?? '<Unknown Image(${image.width}×${image.height})>',
imageSize: Size(image.width.toDouble(), image.height.toDouble()),
displaySize: outputSize,
);
// Avoid emitting events that are the same as those emitted in the last frame.
if (!_lastFrameImageSizeInfo.contains(sizeInfo)) {
final ImageSizeInfo existingSizeInfo = _pendingImageSizeInfo[sizeInfo.source];
if (existingSizeInfo == null || existingSizeInfo.displaySizeInBytes < sizeInfo.displaySizeInBytes) {
_pendingImageSizeInfo[sizeInfo.source] = sizeInfo;
}
// _pendingImageSizeInfo.add(sizeInfo);
if (debugOnPaintImage != null) {
debugOnPaintImage(sizeInfo);
}
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
_lastFrameImageSizeInfo = _pendingImageSizeInfo.values.toSet();
if (_pendingImageSizeInfo.isEmpty) {
return;
}
developer.postEvent(
'Flutter.ImageSizesForFrame',
<Object, Object>{
for (ImageSizeInfo imageSizeInfo in _pendingImageSizeInfo.values)
imageSizeInfo.source: imageSizeInfo.toJson()
},
);
_pendingImageSizeInfo = <String, ImageSizeInfo>{};
});
}
}
if (repeat != ImageRepeat.noRepeat && destinationSize == outputSize) {
// There's no need to repeat the image because we're exactly filling the
// output rect with the image.
@ -510,6 +474,79 @@ void paintImage({
final double dy = halfHeightDelta + alignment.y * halfHeightDelta;
final Offset destinationPosition = rect.topLeft.translate(dx, dy);
final Rect destinationRect = destinationPosition & destinationSize;
// Set to true if we added a saveLayer to the canvas to invert/flip the image.
bool invertedCanvas = false;
// Output size and destination rect are fully calculated.
if (!kReleaseMode) {
final ImageSizeInfo sizeInfo = ImageSizeInfo(
// Some ImageProvider implementations may not have given this.
source: debugImageLabel ?? '<Unknown Image(${image.width}×${image.height})>',
imageSize: Size(image.width.toDouble(), image.height.toDouble()),
displaySize: outputSize,
);
assert(() {
if (debugInvertOversizedImages &&
sizeInfo.decodedSizeInBytes > sizeInfo.displaySizeInBytes + debugImageOverheadAllowance) {
final int overheadInKilobytes = (sizeInfo.decodedSizeInBytes - sizeInfo.displaySizeInBytes) ~/ 1024;
final int outputWidth = outputSize.width.toInt();
final int outputHeight = outputSize.height.toInt();
FlutterError.reportError(FlutterErrorDetails(
exception: 'Image $debugImageLabel has a display size of '
'$outputWidth×$outputHeight but a decode size of '
'${image.width}×${image.height}, which uses an additional '
'${overheadInKilobytes}kb.\n\n'
'Consider resizing the asset ahead of time, supplying a cacheWidth '
'parameter of $outputWidth, a cacheHeight parameter of '
'$outputHeight, or using a ResizeImage.',
library: 'painting library',
context: ErrorDescription('while painting an image'),
));
// Invert the colors of the canvas.
canvas.saveLayer(
destinationRect,
Paint()..colorFilter = const ColorFilter.matrix(<double>[
-1, 0, 0, 0, 255,
0, -1, 0, 0, 255,
0, 0, -1, 0, 255,
0, 0, 0, 1, 0,
]),
);
// Flip the canvas vertically.
final double dy = -(rect.top + rect.height / 2.0);
canvas.translate(0.0, -dy);
canvas.scale(1.0, -1.0);
canvas.translate(0.0, dy);
invertedCanvas = true;
}
return true;
}());
// Avoid emitting events that are the same as those emitted in the last frame.
if (!_lastFrameImageSizeInfo.contains(sizeInfo)) {
final ImageSizeInfo existingSizeInfo = _pendingImageSizeInfo[sizeInfo.source];
if (existingSizeInfo == null || existingSizeInfo.displaySizeInBytes < sizeInfo.displaySizeInBytes) {
_pendingImageSizeInfo[sizeInfo.source] = sizeInfo;
}
if (debugOnPaintImage != null) {
debugOnPaintImage(sizeInfo);
}
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
_lastFrameImageSizeInfo = _pendingImageSizeInfo.values.toSet();
if (_pendingImageSizeInfo.isEmpty) {
return;
}
developer.postEvent(
'Flutter.ImageSizesForFrame',
<Object, Object>{
for (ImageSizeInfo imageSizeInfo in _pendingImageSizeInfo.values)
imageSizeInfo.source: imageSizeInfo.toJson()
},
);
_pendingImageSizeInfo = <String, ImageSizeInfo>{};
});
}
}
final bool needSave = repeat != ImageRepeat.noRepeat || flipHorizontally;
if (needSave)
canvas.save();
@ -541,6 +578,10 @@ void paintImage({
}
if (needSave)
canvas.restore();
if (invertedCanvas) {
canvas.restore();
}
}
Iterable<Rect> _generateImageTileRects(Rect outputRect, Rect fundamentalRect, ImageRepeat repeat) sync* {

View file

@ -448,6 +448,18 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB
}
assert(() {
registerBoolServiceExtension(
name: 'invertOversizedImages',
getter: () async => debugInvertOversizedImages,
setter: (bool value) async {
if (debugInvertOversizedImages != value) {
debugInvertOversizedImages = value;
return _forceRebuild();
}
return Future<void>.value();
},
);
registerBoolServiceExtension(
name: 'debugAllowBanner',
getter: () => Future<bool>.value(WidgetsApp.debugAllowBannerOverride),

View file

@ -171,7 +171,7 @@ void main() {
const int disabledExtensions = kIsWeb ? 2 : 0;
// If you add a service extension... TEST IT! :-)
// ...then increment this number.
expect(binding.extensions.length, 28 + widgetInspectorExtensionCount - disabledExtensions);
expect(binding.extensions.length, 29 + widgetInspectorExtensionCount - disabledExtensions);
expect(console, isEmpty);
debugPrint = debugPrintThrottled;
@ -401,6 +401,29 @@ void main() {
expect(binding.frameScheduled, isFalse);
});
test('Service extensions - invertOversizedImages', () async {
Map<String, dynamic> result;
expect(binding.frameScheduled, isFalse);
expect(debugInvertOversizedImages, false);
result = await binding.testExtension('invertOversizedImages', <String, String>{});
expect(result, <String, String>{'enabled': 'false'});
expect(debugInvertOversizedImages, false);
result = await binding.testExtension('invertOversizedImages', <String, String>{'enabled': 'true'});
expect(result, <String, String>{'enabled': 'true'});
expect(debugInvertOversizedImages, true);
result = await binding.testExtension('invertOversizedImages', <String, String>{});
expect(result, <String, String>{'enabled': 'true'});
expect(debugInvertOversizedImages, true);
result = await binding.testExtension('invertOversizedImages', <String, String>{'enabled': 'false'});
expect(result, <String, String>{'enabled': 'false'});
expect(debugInvertOversizedImages, false);
result = await binding.testExtension('invertOversizedImages', <String, String>{});
expect(result, <String, String>{'enabled': 'false'});
expect(debugInvertOversizedImages, false);
expect(binding.frameScheduled, isFalse);
});
test('Service extensions - profileWidgetBuilds', () async {
Map<String, dynamic> result;

View file

@ -8,6 +8,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/painting.dart';
@ -64,6 +65,66 @@ void main() {
expect(command.positionalArguments[2], equals(const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0)));
});
test('debugInvertOversizedImages', () {
debugInvertOversizedImages = true;
final FlutterExceptionHandler oldFlutterError = FlutterError.onError;
final List<String> messages = <String>[];
FlutterError.onError = (FlutterErrorDetails details) {
messages.add(details.exceptionAsString());
};
final TestImage image = TestImage(width: 300, height: 300);
final TestCanvas canvas = TestCanvas();
const Rect rect = Rect.fromLTWH(50.0, 50.0, 200.0, 100.0);
paintImage(
canvas: canvas,
rect: rect,
image: image,
debugImageLabel: 'TestImage',
fit: BoxFit.fill,
);
final List<Invocation> commands = canvas.invocations
.skipWhile((Invocation invocation) => invocation.memberName != #saveLayer)
.take(4)
.toList();
expect(commands[0].positionalArguments[0], rect);
final Paint paint = commands[0].positionalArguments[1] as Paint;
expect(
paint.colorFilter,
const ColorFilter.matrix(<double>[
-1, 0, 0, 0, 255,
0, -1, 0, 0, 255,
0, 0, -1, 0, 255,
0, 0, 0, 1, 0,
]),
);
expect(commands[1].memberName, #translate);
expect(commands[1].positionalArguments[0], 0.0);
expect(commands[1].positionalArguments[1], 100.0);
expect(commands[2].memberName, #scale);
expect(commands[2].positionalArguments[0], 1.0);
expect(commands[2].positionalArguments[1], -1.0);
expect(commands[3].memberName, #translate);
expect(commands[3].positionalArguments[0], 0.0);
expect(commands[3].positionalArguments[1], -100.0);
expect(
messages.single,
'Image TestImage has a display size of 200×100 but a decode size of 300×300, which uses an additional 364kb.\n\n'
'Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 200, a cacheHeight parameter of 100, or using a ResizeImage.',
);
debugInvertOversizedImages = false;
FlutterError.onError = oldFlutterError;
});
testWidgets('Reports Image painting', (WidgetTester tester) async {
ImageSizeInfo imageSizeInfo;
int count = 0;

View file

@ -13,6 +13,7 @@ import 'terminal.dart';
// ignore_for_file: non_constant_identifier_names
const String fire = '🔥';
const String image = '🖼️';
const int maxLineWidth = 84;
/// Encapsulates the help text construction and printing.
@ -35,6 +36,13 @@ class CommandHelp {
final OutputPreferences _outputPreferences;
CommandHelpOption _I;
CommandHelpOption get I => _I ??= _makeOption(
'I',
'Toggle oversized image inversion $image.',
'debugInvertOversizedImages',
);
CommandHelpOption _L;
CommandHelpOption get L => _L ??= _makeOption(
'L',

View file

@ -364,6 +364,7 @@ class WindowsStdoutLogger extends StdoutLogger {
final String windowsMessage = _terminal.supportsEmoji
? message
: message.replaceAll('🔥', '')
.replaceAll('🖼️', '')
.replaceAll('', 'X')
.replaceAll('', '')
.replaceAll('🔨', '');

View file

@ -354,6 +354,18 @@ abstract class ResidentWebRunner extends ResidentRunner {
}
}
@override
Future<void> debugToggleInvertOversizedImages() async {
try {
await _vmService
?.flutterToggleInvertOversizedImages(
isolateId: null,
);
} on vmservice.RPCError {
return;
}
}
@override
Future<void> debugToggleProfileWidgetBuilds() async {
try {

View file

@ -436,6 +436,15 @@ class FlutterDevice {
}
}
Future<void> toggleInvertOversizedImages() async {
final List<FlutterView> views = await vmService.getFlutterViews();
for (final FlutterView view in views) {
await vmService.flutterToggleInvertOversizedImages(
isolateId: view.uiIsolate.id,
);
}
}
Future<void> toggleProfileWidgetBuilds() async {
final List<FlutterView> views = await vmService.getFlutterViews();
for (final FlutterView view in views) {
@ -1009,6 +1018,12 @@ abstract class ResidentRunner {
}
}
Future<void> debugToggleInvertOversizedImages() async {
for (final FlutterDevice device in flutterDevices) {
await device.toggleInvertOversizedImages();
}
}
Future<void> debugToggleProfileWidgetBuilds() async {
for (final FlutterDevice device in flutterDevices) {
await device.toggleProfileWidgetBuilds();
@ -1289,6 +1304,7 @@ abstract class ResidentRunner {
commandHelp.S.print();
commandHelp.U.print();
commandHelp.i.print();
commandHelp.I.print();
commandHelp.p.print();
commandHelp.o.print();
commandHelp.z.print();
@ -1443,12 +1459,17 @@ class TerminalHandler {
residentRunner.printHelp(details: true);
return true;
case 'i':
case 'I':
if (residentRunner.supportsServiceProtocol) {
await residentRunner.debugToggleWidgetInspector();
return true;
}
return false;
case 'I':
if (residentRunner.supportsServiceProtocol && residentRunner.isRunningDebug) {
await residentRunner.debugToggleInvertOversizedImages();
return true;
}
return false;
case 'k':
if (residentRunner.supportsCanvasKit) {
final bool result = await residentRunner.toggleCanvaskit();
@ -1499,13 +1520,6 @@ class TerminalHandler {
// exit
await residentRunner.exit();
return true;
case 's':
for (final FlutterDevice device in residentRunner.flutterDevices) {
if (device.device.supportsScreenshot) {
await residentRunner.screenshot(device);
}
}
return true;
case 'r':
if (!residentRunner.canHotReload) {
return false;
@ -1531,6 +1545,13 @@ class TerminalHandler {
globals.printStatus('Try again after fixing the above error(s).', emphasis: true);
}
return true;
case 's':
for (final FlutterDevice device in residentRunner.flutterDevices) {
if (device.device.supportsScreenshot) {
await residentRunner.screenshot(device);
}
}
return true;
case 'S':
if (residentRunner.supportsServiceProtocol) {
await residentRunner.debugDumpSemanticsTreeInTraversalOrder();

View file

@ -601,6 +601,10 @@ extension FlutterVmService on vm_service.VmService {
@required String isolateId,
}) => _flutterToggle('inspector.show', isolateId: isolateId);
Future<Map<String,dynamic>> flutterToggleInvertOversizedImages({
@required String isolateId,
}) => _flutterToggle('invertOversizedImages', isolateId: isolateId);
Future<Map<String, dynamic>> flutterToggleProfileWidgetBuilds({
@required String isolateId,
}) => _flutterToggle('profileWidgetBuilds', isolateId: isolateId);

View file

@ -50,6 +50,10 @@ void _testMessageLength({
expectedWidth += ansiMetaCharactersLength;
}
expect(
commandHelp.I.toString().length,
lessThanOrEqualTo(expectedWidth),
);
expect(commandHelp.L.toString().length, lessThanOrEqualTo(expectedWidth));
expect(commandHelp.P.toString().length, lessThanOrEqualTo(expectedWidth));
expect(commandHelp.R.toString().length, lessThanOrEqualTo(expectedWidth));

View file

@ -858,6 +858,7 @@ void main() {
commandHelp.S,
commandHelp.U,
commandHelp.i,
commandHelp.I,
commandHelp.p,
commandHelp.o,
commandHelp.z,
@ -1284,6 +1285,36 @@ void main() {
expect(fakeVmServiceHost.hasRemainingExpectations, false);
}));
testUsingContext('ResidentRunner debugToggleInvertOversizedImages calls flutter device', () => testbed.run(() async {
fakeVmServiceHost = FakeVmServiceHost(requests: <VmServiceExpectation>[]);
await residentRunner.debugToggleInvertOversizedImages();
verify(mockFlutterDevice.toggleInvertOversizedImages()).called(1);
}));
testUsingContext('FlutterDevice.toggleInvertOversizedImages invokes correct VM service request', () => testbed.run(() async {
fakeVmServiceHost = FakeVmServiceHost(requests: <VmServiceExpectation>[
listViews,
const FakeVmServiceRequest(
method: 'ext.flutter.invertOversizedImages',
args: <String, Object>{
'isolateId': '1',
},
jsonResponse: <String, Object>{
'value': 'false'
},
),
]);
final FlutterDevice device = FlutterDevice(
mockDevice,
buildInfo: BuildInfo.debug,
);
device.vmService = fakeVmServiceHost.vmService;
await device.toggleInvertOversizedImages();
expect(fakeVmServiceHost.hasRemainingExpectations, false);
}));
testUsingContext('ResidentRunner debugToggleDebugCheckElevationsEnabled calls flutter device', () => testbed.run(() async {
fakeVmServiceHost = FakeVmServiceHost(requests: <VmServiceExpectation>[]);
await residentRunner.debugToggleDebugCheckElevationsEnabled();

View file

@ -1179,6 +1179,47 @@ void main() {
Platform: () => FakePlatform(operatingSystem: 'linux', environment: <String, String>{}),
});
testUsingContext('debugToggleInvertOversizedImagesOverride', () async {
final ResidentRunner residentWebRunner = setUpResidentRunner(mockFlutterDevice);
fakeVmServiceHost = FakeVmServiceHost(requests: <VmServiceExpectation>[
...kAttachExpectations,
const FakeVmServiceRequest(
method: 'ext.flutter.invertOversizedImages',
args: <String, Object>{
'isolateId': null,
},
jsonResponse: <String, Object>{
'enabled': 'false'
},
),
const FakeVmServiceRequest(
method: 'ext.flutter.invertOversizedImages',
args: <String, Object>{
'isolateId': null,
'enabled': 'true',
},
jsonResponse: <String, Object>{
'enabled': 'true'
},
)
]);
_setupMocks();
final Completer<DebugConnectionInfo> connectionInfoCompleter = Completer<DebugConnectionInfo>();
unawaited(residentWebRunner.run(
connectionInfoCompleter: connectionInfoCompleter,
));
await connectionInfoCompleter.future;
await residentWebRunner.debugToggleInvertOversizedImages();
expect(fakeVmServiceHost.hasRemainingExpectations, false);
}, overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => processManager,
Pub: () => MockPub(),
Platform: () => FakePlatform(operatingSystem: 'linux', environment: <String, String>{}),
});
testUsingContext('debugToggleWidgetInspector', () async {
final ResidentRunner residentWebRunner = setUpResidentRunner(mockFlutterDevice);
fakeVmServiceHost = FakeVmServiceHost(requests: <VmServiceExpectation>[

View file

@ -122,21 +122,39 @@ void main() {
verify(mockResidentRunner.toggleCanvaskit()).called(1);
});
testUsingContext('i, I - debugToggleWidgetInspector with service protocol', () async {
testUsingContext('i - debugToggleWidgetInspector with service protocol', () async {
await terminalHandler.processTerminalInput('i');
await terminalHandler.processTerminalInput('I');
verify(mockResidentRunner.debugToggleWidgetInspector()).called(2);
verify(mockResidentRunner.debugToggleWidgetInspector()).called(1);
});
testUsingContext('i, I - debugToggleWidgetInspector without service protocol', () async {
testUsingContext('i - debugToggleWidgetInspector without service protocol', () async {
when(mockResidentRunner.supportsServiceProtocol).thenReturn(false);
await terminalHandler.processTerminalInput('i');
await terminalHandler.processTerminalInput('I');
verifyNever(mockResidentRunner.debugToggleWidgetInspector());
});
testUsingContext('I - debugToggleInvertOversizedImages with service protocol/debug', () async {
when(mockResidentRunner.isRunningDebug).thenReturn(true);
await terminalHandler.processTerminalInput('I');
verify(mockResidentRunner.debugToggleInvertOversizedImages()).called(1);
});
testUsingContext('I - debugToggleInvertOversizedImages with service protocol/ndebug', () async {
when(mockResidentRunner.isRunningDebug).thenReturn(false);
await terminalHandler.processTerminalInput('I');
verifyNever(mockResidentRunner.debugToggleInvertOversizedImages());
});
testUsingContext('I - debugToggleInvertOversizedImages without service protocol', () async {
when(mockResidentRunner.supportsServiceProtocol).thenReturn(false);
await terminalHandler.processTerminalInput('I');
});
testUsingContext('l - list flutter views', () async {
final MockFlutterDevice mockFlutterDevice = MockFlutterDevice();
when(mockResidentRunner.isRunningDebug).thenReturn(true);