Add benchmark reproducing large static scrolling content (#53686)

diff --git a/dev/benchmarks/macrobenchmarks/lib/src/web/bench_dynamic_clip_on_static_picture.dart b/dev/benchmarks/macrobenchmarks/lib/src/web/bench_dynamic_clip_on_static_picture.dart
new file mode 100644
index 0000000..4f319bc
--- /dev/null
+++ b/dev/benchmarks/macrobenchmarks/lib/src/web/bench_dynamic_clip_on_static_picture.dart
@@ -0,0 +1,115 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:ui';
+
+import 'recorder.dart';
+import 'test_data.dart';
+
+/// The height of each row.
+const double kRowHeight = 20.0;
+
+/// Number of rows.
+const int kRows = 100;
+
+/// Number of columns.
+const int kColumns = 10;
+
+/// The amount the picture is scrolled on every iteration of the benchmark.
+const double kScrollDelta = 2.0;
+
+/// Draws one complex picture, then moves a clip around it simulating scrolling
+/// large static content.
+///
+/// This benchmark measures how efficient we are at taking advantage of the
+/// static picture when all that changes is the clip.
+///
+/// See also:
+///
+/// * `bench_text_out_of_picture_bounds.dart`, which measures a volatile
+///   picture with a static clip.
+/// * https://github.com/flutter/flutter/issues/42987, which this benchmark is
+///   based on.
+class BenchDynamicClipOnStaticPicture extends SceneBuilderRecorder {
+  BenchDynamicClipOnStaticPicture() : super(name: benchmarkName) {
+    // If the scrollable extent is too small, the benchmark may end up
+    // scrolling the picture out of the clip area entirely, resulting in
+    // bogus metric vaules.
+    const double maxScrollExtent = kMaxSampleCount * kScrollDelta;
+    const double pictureHeight = kRows * kRowHeight;
+    if (maxScrollExtent > pictureHeight) {
+      throw Exception(
+        'Bad combination of constant values kRowHeight, kRows, and '
+        'kScrollData. With these numbers there is risk that the picture '
+        'will scroll out of the clip entirely. To fix the issue reduce '
+        'kScrollDelta, or increase either kRows or kRowHeight.'
+      );
+    }
+
+    // Create one static picture, then never change it again.
+    const Color black = Color.fromARGB(255, 0, 0, 0);
+    final PictureRecorder pictureRecorder = PictureRecorder();
+    final Canvas canvas = Canvas(pictureRecorder);
+    screenSize = window.physicalSize / window.devicePixelRatio;
+    clipSize = Size(
+      screenSize.width / 2,
+      screenSize.height / 5,
+    );
+    final double cellWidth = screenSize.width / kColumns;
+
+    final List<Paragraph> paragraphs = generateLaidOutParagraphs(
+      paragraphCount: 500,
+      minWordCountPerParagraph: 3,
+      maxWordCountPerParagraph: 3,
+      widthConstraint: cellWidth,
+      color: black,
+    );
+
+    int paragraphCounter = 0;
+    double yOffset = 0.0;
+    for (int row = 0; row < kRows; row += 1) {
+      for (int column = 0; column < kColumns; column += 1) {
+        final double left = cellWidth * column;
+        canvas.save();
+        canvas.clipRect(Rect.fromLTWH(
+          left,
+          yOffset,
+          cellWidth,
+          20.0,
+        ));
+        canvas.drawParagraph(
+          paragraphs[paragraphCounter % paragraphs.length],
+          Offset(left, yOffset),
+        );
+        canvas.restore();
+        paragraphCounter += 1;
+      }
+      yOffset += kRowHeight;
+    }
+
+    picture = pictureRecorder.endRecording();
+  }
+
+  static const String benchmarkName = 'dynamic_clip_on_static_picture';
+
+  Size screenSize;
+  Size clipSize;
+  Picture picture;
+  double pictureVerticalOffset = 0.0;
+
+  @override
+  void onDrawFrame(SceneBuilder sceneBuilder) {
+    // Render the exact same picture, but offset it as if it's being scrolled.
+    // This will move the clip along the Y axis in picture's local coordinates
+    // causing a repaint. If we're not efficient at managing clips and/or
+    // repaints this will jank (see https://github.com/flutter/flutter/issues/42987).
+    final Rect clip = Rect.fromLTWH(0.0, 0.0, clipSize.width, clipSize.height);
+    sceneBuilder.pushClipRect(clip);
+    sceneBuilder.pushOffset(0.0, pictureVerticalOffset);
+    sceneBuilder.addPicture(Offset.zero, picture);
+    sceneBuilder.pop();
+    sceneBuilder.pop();
+    pictureVerticalOffset -= kScrollDelta;
+  }
+}
diff --git a/dev/benchmarks/macrobenchmarks/lib/src/web/bench_text_out_of_picture_bounds.dart b/dev/benchmarks/macrobenchmarks/lib/src/web/bench_text_out_of_picture_bounds.dart
index dfa7d67..d1a8c3d 100644
--- a/dev/benchmarks/macrobenchmarks/lib/src/web/bench_text_out_of_picture_bounds.dart
+++ b/dev/benchmarks/macrobenchmarks/lib/src/web/bench_text_out_of_picture_bounds.dart
@@ -33,16 +33,18 @@
     const Color green = Color.fromARGB(255, 0, 255, 0);
 
     // We don't want paragraph generation and layout to pollute benchmark numbers.
-    singleLineParagraphs = _generateParagraphs(
+    singleLineParagraphs = generateLaidOutParagraphs(
       paragraphCount: 500,
       minWordCountPerParagraph: 2,
-      maxWordCountPerParagraph: 5,
+      maxWordCountPerParagraph: 4,
+      widthConstraint: window.physicalSize.width / 2,
       color: red,
     );
-    multiLineParagraphs = _generateParagraphs(
+    multiLineParagraphs = generateLaidOutParagraphs(
       paragraphCount: 50,
       minWordCountPerParagraph: 30,
-      maxWordCountPerParagraph: 50,
+      maxWordCountPerParagraph: 49,
+      widthConstraint: window.physicalSize.width / 2,
       color: green,
     );
   }
@@ -116,38 +118,4 @@
     sceneBuilder.addPicture(Offset.zero, picture);
     sceneBuilder.pop();
   }
-
-  /// Generates strings and builds pre-laid out paragraphs to be used by the
-  /// benchmark.
-  List<Paragraph> _generateParagraphs({
-    int paragraphCount,
-    int minWordCountPerParagraph,
-    int maxWordCountPerParagraph,
-    Color color,
-  }) {
-    final List<Paragraph> strings = <Paragraph>[];
-    int wordPointer = 0; // points to the next word in lipsum to extract
-    for (int i = 0; i < paragraphCount; i++) {
-      final int wordCount = minWordCountPerParagraph +
-          _random.nextInt(maxWordCountPerParagraph - minWordCountPerParagraph);
-      final List<String> string = <String>[];
-      for (int j = 0; j < wordCount; j++) {
-        string.add(lipsum[wordPointer]);
-        wordPointer = (wordPointer + 1) % lipsum.length;
-      }
-
-      final ParagraphBuilder builder =
-          ParagraphBuilder(ParagraphStyle(fontFamily: 'sans-serif'))
-            ..pushStyle(TextStyle(color: color, fontSize: 18.0))
-            ..addText(string.join(' '))
-            ..pop();
-      final Paragraph paragraph = builder.build();
-
-      // Fill half the screen.
-      paragraph
-          .layout(ParagraphConstraints(width: window.physicalSize.width / 2));
-      strings.add(paragraph);
-    }
-    return strings;
-  }
 }
diff --git a/dev/benchmarks/macrobenchmarks/lib/src/web/recorder.dart b/dev/benchmarks/macrobenchmarks/lib/src/web/recorder.dart
index b7165d9..5170fa7 100644
--- a/dev/benchmarks/macrobenchmarks/lib/src/web/recorder.dart
+++ b/dev/benchmarks/macrobenchmarks/lib/src/web/recorder.dart
@@ -17,14 +17,14 @@
 
 /// Minimum number of samples collected by a benchmark irrespective of noise
 /// levels.
-const int _kMinSampleCount = 50;
+const int kMinSampleCount = 50;
 
 /// Maximum number of samples collected by a benchmark irrespective of noise
 /// levels.
 ///
 /// If the noise doesn't settle down before we reach the max we'll report noisy
 /// results assuming the benchmarks is simply always noisy.
-const int _kMaxSampleCount = 10 * _kMinSampleCount;
+const int kMaxSampleCount = 10 * kMinSampleCount;
 
 /// The number of samples used to extract metrics, such as noise, means,
 /// max/min values.
@@ -513,7 +513,7 @@
       final Timeseries timeseries = scoreData[key];
 
       // Collect enough data points before considering to stop.
-      if (timeseries.count < _kMinSampleCount) {
+      if (timeseries.count < kMinSampleCount) {
         return true;
       }
 
@@ -522,11 +522,11 @@
         // If the timeseries has enough data, stop it, even if it's noisy under
         // the assumption that this benchmark is always noisy and there's nothing
         // we can do about it.
-        if (timeseries.count > _kMaxSampleCount) {
+        if (timeseries.count > kMaxSampleCount) {
           buffer.writeln(
             'WARNING: Noise of benchmark "$name.$key" did not converge below '
             '${_ratioToPercent(_kNoiseThreshold)}. Stopping because it reached the '
-            'maximum number of samples $_kMaxSampleCount. Noise level is '
+            'maximum number of samples $kMaxSampleCount. Noise level is '
             '${_ratioToPercent(timeseries.noise)}.',
           );
           return false;
diff --git a/dev/benchmarks/macrobenchmarks/lib/src/web/test_data.dart b/dev/benchmarks/macrobenchmarks/lib/src/web/test_data.dart
index 2a173e0..688dbca 100644
--- a/dev/benchmarks/macrobenchmarks/lib/src/web/test_data.dart
+++ b/dev/benchmarks/macrobenchmarks/lib/src/web/test_data.dart
@@ -2,6 +2,16 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'dart:math' as math;
+import 'dart:ui';
+
+import 'package:meta/meta.dart';
+
+// Used to randomize data.
+//
+// Using constant seed for reproducibility.
+final math.Random _random = math.Random(0);
+
 /// Random words used by benchmarks that contain text.
 final List<String> lipsum = 'Lorem ipsum dolor sit amet, consectetur adipiscing '
   'elit. Vivamus ut ligula a neque mattis posuere. Sed suscipit lobortis '
@@ -11,7 +21,7 @@
   'odio vestibulum ultricies. Nunc dolor libero, hendrerit eu urna sit '
   'amet, pretium iaculis nulla. Ut porttitor nisl et leo iaculis, vel '
   'fringilla odio pulvinar. Ut eget ligula id odio auctor egestas nec a '
-  'nisl. Aliquam luctus dolor et magna posuere mattis.'
+  'nisl. Aliquam luctus dolor et magna posuere mattis. '
   'Suspendisse fringilla nisl et massa congue, eget '
   'imperdiet lectus porta. Vestibulum sed dui sed dui porta imperdiet ut in risus. '
   'Fusce diam purus, faucibus id accumsan sit amet, semper a sem. Sed aliquam '
@@ -20,3 +30,37 @@
   'pulvinar rhoncus tellus. Nullam vel mauris semper, volutpat tellus at, sagittis '
   'lectus. Donec vitae nibh mauris. Morbi posuere sem id eros tristique tempus. '
   'Vivamus lacinia sapien neque, eu semper purus gravida ut.'.split(' ');
+
+/// Generates strings and builds pre-laid out paragraphs to be used by
+/// benchmarks.
+List<Paragraph> generateLaidOutParagraphs({
+  @required int paragraphCount,
+  @required int minWordCountPerParagraph,
+  @required int maxWordCountPerParagraph,
+  @required double widthConstraint,
+  @required Color color,
+}) {
+  final List<Paragraph> strings = <Paragraph>[];
+  int wordPointer = 0; // points to the next word in lipsum to extract
+  for (int i = 0; i < paragraphCount; i++) {
+    final int wordCount = minWordCountPerParagraph +
+        _random.nextInt(maxWordCountPerParagraph - minWordCountPerParagraph + 1);
+    final List<String> string = <String>[];
+    for (int j = 0; j < wordCount; j++) {
+      string.add(lipsum[wordPointer]);
+      wordPointer = (wordPointer + 1) % lipsum.length;
+    }
+
+    final ParagraphBuilder builder =
+        ParagraphBuilder(ParagraphStyle(fontFamily: 'sans-serif'))
+          ..pushStyle(TextStyle(color: color, fontSize: 18.0))
+          ..addText(string.join(' '))
+          ..pop();
+    final Paragraph paragraph = builder.build();
+
+    // Fill half the screen.
+    paragraph.layout(ParagraphConstraints(width: widthConstraint));
+    strings.add(paragraph);
+  }
+  return strings;
+}
diff --git a/dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart b/dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart
index 6866ea4..434a7ca 100644
--- a/dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart
+++ b/dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart
@@ -12,6 +12,7 @@
 import 'src/web/bench_build_material_checkbox.dart';
 import 'src/web/bench_card_infinite_scroll.dart';
 import 'src/web/bench_draw_rect.dart';
+import 'src/web/bench_dynamic_clip_on_static_picture.dart';
 import 'src/web/bench_simple_lazy_text_scroll.dart';
 import 'src/web/bench_text_out_of_picture_bounds.dart';
 import 'src/web/recorder.dart';
@@ -30,6 +31,7 @@
   BenchTextOutOfPictureBounds.benchmarkName: () => BenchTextOutOfPictureBounds(),
   BenchSimpleLazyTextScroll.benchmarkName: () => BenchSimpleLazyTextScroll(),
   BenchBuildMaterialCheckbox.benchmarkName: () => BenchBuildMaterialCheckbox(),
+  BenchDynamicClipOnStaticPicture.benchmarkName: () => BenchDynamicClipOnStaticPicture(),
 
   // Benchmarks that we don't want to run using CanvasKit.
   if (!isCanvasKit) ...<String, RecorderFactory>{