[web] Add frame number support. (#171592)

Fixes https://github.com/flutter/flutter/issues/170972

### Description
- Adds `frameData` with `frameNumber` value to `FrameService`
- Adds non-mock `frameData` to `EngineFlutterWindow` and
`EnginePlatformDispatcher`
- Adds `frameNumber` value to `FrameTimingRecorder`'s recorded timings

## Pre-launch Checklist

- [X] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [X] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [X] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [X] I signed the [CLA].
- [X] I listed at least one issue that this PR fixes in the description
above.
- [X] I updated/added relevant documentation (doc comments with `///`).
- [X] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [X] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [X] All existing and new tests are passing.

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

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/frame_service.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/frame_service.dart
index 2e2dbf1..613e3a6 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/frame_service.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/frame_service.dart
@@ -36,18 +36,16 @@
     _instance = mock;
   }
 
-  /// A monotonically increasing frame number being rendered.
-  ///
-  /// This is intended for tests only.
-  int get debugFrameNumber => _debugFrameNumber;
-  int _debugFrameNumber = 0;
+  /// The [ui.FrameData] object for the current frame.
+  ui.FrameData get frameData => _frameData;
+  ui.FrameData _frameData = const ui.FrameData();
 
-  /// Resets [debugFrameNumber] back to zero.
+  /// Resets [frameData] back to the initial value.
   ///
   /// This is intended for tests only.
   @visibleForTesting
-  void debugResetFrameNumber() {
-    _debugFrameNumber = 0;
+  void debugResetFrameData() {
+    _frameData = const ui.FrameData();
   }
 
   /// Whether a frame has already been scheduled.
@@ -102,7 +100,7 @@
 
       try {
         _isRenderingFrame = true;
-        _debugFrameNumber += 1;
+        _frameData = ui.FrameData(frameNumber: _frameData.frameNumber + 1);
         _renderFrame(highResTime.toDartDouble);
       } finally {
         _isRenderingFrame = false;
@@ -132,7 +130,7 @@
 
     Timer.run(() {
       _isRenderingFrame = true;
-      _debugFrameNumber += 1;
+      _frameData = ui.FrameData(frameNumber: _frameData.frameNumber + 1);
       // TODO(yjbanov): it's funky that if beginFrame crashes, the drawFrame
       //                fires anyway. We should clean this up, or better explain
       //                what the expectations are for various situations. The
@@ -158,6 +156,7 @@
   }
 
   void _renderFrame(double highResTime) {
+    FrameTimingRecorder.recordCurrentFrameNumber(_frameData.frameNumber);
     FrameTimingRecorder.recordCurrentFrameVsync();
 
     // In Flutter terminology "building a frame" consists of "beginning
@@ -181,6 +180,10 @@
       );
     }
 
+    if (EnginePlatformDispatcher.instance.onFrameDataChanged != null) {
+      EnginePlatformDispatcher.instance.invokeOnFrameDataChanged();
+    }
+
     if (EnginePlatformDispatcher.instance.onDrawFrame != null) {
       // On mobile Flutter flushes microtasks between onBeginFrame and
       // onDrawFrame. The web doesn't because there's no way to hook into the
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/frame_timing_recorder.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/frame_timing_recorder.dart
index 0f329ab..0833dd3 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/frame_timing_recorder.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/frame_timing_recorder.dart
@@ -2,10 +2,12 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'package:meta/meta.dart';
 import 'package:ui/src/engine.dart';
 import 'package:ui/ui.dart' as ui;
 
 class FrameTimingRecorder {
+  final int _frameNumber = _currentFrameNumber;
   final int _vsyncStartMicros = _currentFrameVsyncStart;
   final int _buildStartMicros = _currentFrameBuildStart;
 
@@ -18,12 +20,30 @@
   /// This list is periodically reported to the framework (see [_kFrameTimingsSubmitInterval]).
   static List<ui.FrameTiming> _frameTimings = <ui.FrameTiming>[];
 
-  /// These two metrics are collected early in the process, before the respective
+  /// List of the collected frame timings that are not yet reported.
+  ///
+  /// This is intended for tests only.
+  @visibleForTesting
+  static List<ui.FrameTiming> get debugFrameTimings => _frameTimings;
+
+  @visibleForTesting
+  static void debugResetFrameTimings() {
+    _frameTimings = <ui.FrameTiming>[];
+  }
+
+  /// These three metrics are collected early in the process, before the respective
   /// scene builders are created. These are instead treated as global state, which
   /// are used to initialize any recorders that are created by the scene builders.
+  static int _currentFrameNumber = 0;
   static int _currentFrameVsyncStart = 0;
   static int _currentFrameBuildStart = 0;
 
+  static void recordCurrentFrameNumber(int frameNumber) {
+    if (frameTimingsEnabled) {
+      _currentFrameNumber = frameNumber;
+    }
+  }
+
   static void recordCurrentFrameVsync() {
     if (frameTimingsEnabled) {
       _currentFrameVsyncStart = _nowMicros();
@@ -36,6 +56,21 @@
     }
   }
 
+  @visibleForTesting
+  static void debugResetCurrentFrameNumber() {
+    _currentFrameNumber = 0;
+  }
+
+  @visibleForTesting
+  static void debugResetCurrentFrameVsync() {
+    _currentFrameVsyncStart = 0;
+  }
+
+  @visibleForTesting
+  static void debugResetCurrentFrameBuildStart() {
+    _currentFrameBuildStart = 0;
+  }
+
   /// The last time (in microseconds) we submitted frame timings.
   static int _frameTimingsLastSubmitTime = _nowMicros();
 
@@ -87,6 +122,7 @@
       rasterStart: _rasterStartMicros!,
       rasterFinish: _rasterFinishMicros!,
       rasterFinishWallTime: _rasterFinishMicros!,
+      frameNumber: _frameNumber,
     );
     _frameTimings.add(timing);
     final int now = _nowMicros();
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart
index d7f87a3..73db507 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart
@@ -1292,8 +1292,26 @@
     });
   }
 
+  /// The [ui.FrameData] object for the current frame.
   @override
-  ui.FrameData get frameData => const ui.FrameData.webOnly();
+  ui.FrameData get frameData => FrameService.instance.frameData;
+
+  /// A callback that is invoked when the window updates the [ui.FrameData].
+  @override
+  ui.VoidCallback? get onFrameDataChanged => _onFrameDataChanged;
+  ui.VoidCallback? _onFrameDataChanged;
+  Zone _onFrameDataChangedZone = Zone.root;
+  @override
+  set onFrameDataChanged(ui.VoidCallback? callback) {
+    _onFrameDataChanged = callback;
+    _onFrameDataChangedZone = Zone.current;
+  }
+
+  /// Engine code should use this method instead of the callback directly.
+  /// Otherwise zones won't work properly.
+  void invokeOnFrameDataChanged() {
+    invoke(onFrameDataChanged, _onFrameDataChangedZone);
+  }
 
   @override
   double scaleFontSize(double unscaledFontSize) => unscaledFontSize * textScaleFactor;
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart
index badd6dc..efb9ae4 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart
@@ -475,12 +475,14 @@
   }
 
   @override
-  ui.FrameData get frameData => const ui.FrameData.webOnly();
+  ui.FrameData get frameData => platformDispatcher.frameData;
 
   @override
-  ui.VoidCallback? get onFrameDataChanged => null;
+  ui.VoidCallback? get onFrameDataChanged => platformDispatcher.onFrameDataChanged;
   @override
-  set onFrameDataChanged(ui.VoidCallback? callback) {}
+  set onFrameDataChanged(ui.VoidCallback? callback) {
+    platformDispatcher.onFrameDataChanged = callback;
+  }
 
   @override
   ui.AccessibilityFeatures get accessibilityFeatures => platformDispatcher.accessibilityFeatures;
diff --git a/engine/src/flutter/lib/web_ui/lib/window.dart b/engine/src/flutter/lib/web_ui/lib/window.dart
index 5564cb5..3412e6a 100644
--- a/engine/src/flutter/lib/web_ui/lib/window.dart
+++ b/engine/src/flutter/lib/web_ui/lib/window.dart
@@ -165,9 +165,15 @@
 SingletonFlutterWindow get window => engine.window;
 
 class FrameData {
-  const FrameData.webOnly();
+  const FrameData({this.frameNumber = 0});
 
-  int get frameNumber => -1;
+  /// The number of the current frame.
+  ///
+  /// This number monotonically increases, but doesn't necessarily
+  /// start at a particular value.
+  ///
+  /// If not provided, defaults to 0.
+  final int frameNumber;
 }
 
 class GestureSettings {
diff --git a/engine/src/flutter/lib/web_ui/test/engine/frame_service_test.dart b/engine/src/flutter/lib/web_ui/test/engine/frame_service_test.dart
index 261bd43..e99e0e2 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/frame_service_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/frame_service_test.dart
@@ -34,22 +34,22 @@
 
     test('counts frames', () async {
       final instance = FrameService.instance;
-      instance.debugResetFrameNumber();
+      instance.debugResetFrameData();
 
       final frameCompleter = Completer<void>();
       instance.onFinishedRenderingFrame = () {
         frameCompleter.complete();
       };
 
-      expect(instance.debugFrameNumber, 0);
+      expect(instance.frameData.frameNumber, 0);
       instance.scheduleFrame();
       await frameCompleter.future;
-      expect(instance.debugFrameNumber, 1);
+      expect(instance.frameData.frameNumber, 1);
     });
 
-    test('isFrameScheduled is true iff the frame is scheduled', () async {
+    test('isFrameScheduled is true if the frame is scheduled', () async {
       final instance = FrameService.instance;
-      instance.debugResetFrameNumber();
+      instance.debugResetFrameData();
 
       var frameCompleter = Completer<void>();
       instance.onFinishedRenderingFrame = () {
@@ -62,10 +62,10 @@
       expect(instance.isFrameScheduled, isTrue);
       await frameCompleter.future;
       expect(instance.isFrameScheduled, isFalse);
-      expect(instance.debugFrameNumber, 1);
+      expect(instance.frameData.frameNumber, 1);
 
       // Test idempotency
-      instance.debugResetFrameNumber();
+      instance.debugResetFrameData();
       frameCompleter = Completer<void>();
       instance.scheduleFrame();
       instance.scheduleFrame();
@@ -75,7 +75,7 @@
       expect(instance.isFrameScheduled, isTrue);
       await frameCompleter.future;
       expect(instance.isFrameScheduled, isFalse);
-      expect(instance.debugFrameNumber, 1);
+      expect(instance.frameData.frameNumber, 1);
     });
 
     test('onBeginFrame and onDrawFrame are called with isRenderingFrame set to true', () async {
diff --git a/engine/src/flutter/lib/web_ui/test/engine/frame_timing_recorder_test.dart b/engine/src/flutter/lib/web_ui/test/engine/frame_timing_recorder_test.dart
new file mode 100644
index 0000000..10c1d42
--- /dev/null
+++ b/engine/src/flutter/lib/web_ui/test/engine/frame_timing_recorder_test.dart
@@ -0,0 +1,74 @@
+// Copyright 2013 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 'package:test/bootstrap/browser.dart';
+import 'package:test/test.dart';
+import 'package:ui/src/engine.dart';
+import 'package:ui/ui.dart' as ui;
+
+void main() {
+  internalBootstrapBrowserTest(() => testMain);
+}
+
+void testMain() {
+  group('FrameTimingRecorder', () {
+    setUp(() {
+      EnginePlatformDispatcher.instance.onReportTimings = null;
+      FrameTimingRecorder.debugResetCurrentFrameNumber();
+      FrameTimingRecorder.debugResetCurrentFrameVsync();
+      FrameTimingRecorder.debugResetCurrentFrameBuildStart();
+      FrameTimingRecorder.debugResetFrameTimings();
+    });
+
+    tearDown(() {
+      EnginePlatformDispatcher.instance.onReportTimings = null;
+      FrameTimingRecorder.debugResetCurrentFrameNumber();
+      FrameTimingRecorder.debugResetCurrentFrameVsync();
+      FrameTimingRecorder.debugResetCurrentFrameBuildStart();
+      FrameTimingRecorder.debugResetFrameTimings();
+    });
+
+    test('frameTimingsEnabled is false when onReportTimings is not set', () {
+      expect(EnginePlatformDispatcher.instance.onReportTimings, isNull);
+      expect(FrameTimingRecorder.frameTimingsEnabled, isFalse);
+    });
+
+    test('frameTimingsEnabled is true when onReportTimings is set', () {
+      EnginePlatformDispatcher.instance.onReportTimings = (_) {};
+      expect(EnginePlatformDispatcher.instance.onReportTimings, isNotNull);
+      expect(FrameTimingRecorder.frameTimingsEnabled, isTrue);
+    });
+
+    test('uses recorded frame number', () {
+      EnginePlatformDispatcher.instance.onReportTimings = (_) {};
+      expect(FrameTimingRecorder.frameTimingsEnabled, isTrue);
+
+      const int frameNumber1 = 333;
+      FrameTimingRecorder.recordCurrentFrameNumber(frameNumber1);
+
+      FrameTimingRecorder()
+        ..recordBuildFinish()
+        ..recordRasterStart()
+        ..recordRasterFinish()
+        ..submitTimings();
+
+      expect(FrameTimingRecorder.debugFrameTimings.length, 1);
+      final ui.FrameTiming timing1 = FrameTimingRecorder.debugFrameTimings.first;
+      expect(timing1.frameNumber, frameNumber1);
+
+      const int frameNumber2 = 334;
+      FrameTimingRecorder.recordCurrentFrameNumber(frameNumber2);
+
+      FrameTimingRecorder()
+        ..recordBuildFinish()
+        ..recordRasterStart()
+        ..recordRasterFinish()
+        ..submitTimings();
+
+      expect(FrameTimingRecorder.debugFrameTimings.length, 2);
+      final ui.FrameTiming timing2 = FrameTimingRecorder.debugFrameTimings.last;
+      expect(timing2.frameNumber, frameNumber2);
+    });
+  });
+}
diff --git a/engine/src/flutter/lib/web_ui/test/engine/window_test.dart b/engine/src/flutter/lib/web_ui/test/engine/window_test.dart
index bef2e09..33c8c4c 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/window_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/window_test.dart
@@ -396,6 +396,23 @@
     expect(responded, isTrue);
   });
 
+  test('onFrameDataChanged preserves the zone', () {
+    final Zone innerZone = Zone.current.fork();
+
+    innerZone.runGuarded(() {
+      void callback() {
+        expect(Zone.current, innerZone);
+      }
+
+      myWindow.onFrameDataChanged = callback;
+
+      // Test that the getter returns the exact same callback, e.g. it doesn't wrap it.
+      expect(myWindow.onFrameDataChanged, same(callback));
+    });
+
+    EnginePlatformDispatcher.instance.invokeOnFrameDataChanged();
+  });
+
   // Emulates the framework sending a request for screen orientation lock.
   Future<bool> sendSetPreferredOrientations(List<dynamic> orientations) {
     final Completer<bool> completer = Completer<bool>();