Add web startup analyzer to material 3 demo (#2144)

This adds a tool to measure web app startup for the Material 3 demo.

Demo:
- [Example app](https://flutter-web-perf-experiments.web.app/)
- [Material
3](https://flutter-web-perf-experiments--material3-vswzldcy.web.app/)
(open console)

---------

Co-authored-by: Brett Morgan <brett.morgan@gmail.com>
Co-authored-by: Kevin Moore <kevmoo@google.com>
pull/2150/head
John Ryan 11 months ago committed by GitHub
parent dc37f37b5c
commit d96bb336b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -2,12 +2,31 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:web_startup_analyzer/web_startup_analyzer.dart';
import 'constants.dart'; import 'constants.dart';
import 'home.dart'; import 'home.dart';
void main() { void main() async {
var analyzer = WebStartupAnalyzer(additionalFrameCount: 10);
debugPrint(json.encode(analyzer.startupTiming));
analyzer.onFirstFrame.addListener(() {
debugPrint(json.encode({'firstFrame': analyzer.onFirstFrame.value}));
});
analyzer.onFirstPaint.addListener(() {
debugPrint(json.encode({
'firstPaint': analyzer.onFirstPaint.value?.$1,
'firstContentfulPaint': analyzer.onFirstPaint.value?.$2,
}));
});
analyzer.onAdditionalFrames.addListener(() {
debugPrint(json.encode({
'additionalFrames': analyzer.onAdditionalFrames.value,
}));
});
runApp( runApp(
const App(), const App(),
); );

@ -9,6 +9,7 @@ version: 1.0.0+1
environment: environment:
sdk: ^3.2.0 sdk: ^3.2.0
flutter: ^3.16.0
dependencies: dependencies:
flutter: flutter:
@ -16,6 +17,8 @@ dependencies:
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2
url_launcher: ^6.1.8 url_launcher: ^6.1.8
web_startup_analyzer:
path: ../../web/_packages/web_startup_analyzer
dev_dependencies: dev_dependencies:
analysis_defaults: analysis_defaults:

@ -38,22 +38,30 @@
</script> </script>
<!-- This script adds the flutter initialization JS code --> <!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script> <script src="flutter.js" defer></script>
<script type="text/javascript" src="assets/packages/web_startup_analyzer/lib/web_startup_analyzer.js"></script>
</head> </head>
<body> <body>
<script> <script>
var flutterWebStartupAnalyzer = new FlutterWebStartupAnalyzer();
var analyzer = flutterWebStartupAnalyzer;
window.addEventListener('load', function(ev) { window.addEventListener('load', function(ev) {
// Download main.dart.js analyzer.markStart("loadEntrypoint");
_flutter.loader.loadEntrypoint({ _flutter.loader.loadEntrypoint({
serviceWorker: { serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion, serviceWorkerVersion: serviceWorkerVersion,
}, },
onEntrypointLoaded: function(engineInitializer) { onEntrypointLoaded: function(engineInitializer) {
analyzer.markFinished("loadEntrypoint");
analyzer.markStart("initializeEngine");
engineInitializer.initializeEngine().then(function(appRunner) { engineInitializer.initializeEngine().then(function(appRunner) {
analyzer.markFinished("initializeEngine");
analyzer.markStart("appRunnerRunApp");
appRunner.runApp(); appRunner.runApp();
}); });
} }
}); });
}); });
</script> </script>
</body> </body>
</html> </html>

@ -32,7 +32,8 @@ declare -ar PROJECT_NAMES=(
"experimental/federated_plugin/federated_plugin_web" "experimental/federated_plugin/federated_plugin_web"
"experimental/federated_plugin/federated_plugin_windows" "experimental/federated_plugin/federated_plugin_windows"
"experimental/linting_tool" "experimental/linting_tool"
"experimental/material_3_demo" # TODO(DomesticMouse): re-enable once deps allow
# "experimental/material_3_demo"
"experimental/pedometer" "experimental/pedometer"
"experimental/pedometer/example" "experimental/pedometer/example"
# TODO(DomesticMouse): Dart formatting required # TODO(DomesticMouse): Dart formatting required

@ -32,7 +32,8 @@ declare -ar PROJECT_NAMES=(
"experimental/federated_plugin/federated_plugin_web" "experimental/federated_plugin/federated_plugin_web"
"experimental/federated_plugin/federated_plugin_windows" "experimental/federated_plugin/federated_plugin_windows"
"experimental/linting_tool" "experimental/linting_tool"
"experimental/material_3_demo" # TODO(DomesticMouse): re-enable once deps allow
# "experimental/material_3_demo"
"experimental/pedometer" "experimental/pedometer"
"experimental/pedometer/example" "experimental/pedometer/example"
"experimental/varfont_shader_puzzle" "experimental/varfont_shader_puzzle"

@ -0,0 +1,29 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
build/

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "f5fb61b953a631f47191124a31169701911ee1f4"
channel: "main"
project_type: package

@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

@ -0,0 +1,43 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "f5fb61b953a631f47191124a31169701911ee1f4"
channel: "main"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f5fb61b953a631f47191124a31169701911ee1f4
base_revision: f5fb61b953a631f47191124a31169701911ee1f4
- platform: android
create_revision: f5fb61b953a631f47191124a31169701911ee1f4
base_revision: f5fb61b953a631f47191124a31169701911ee1f4
- platform: ios
create_revision: f5fb61b953a631f47191124a31169701911ee1f4
base_revision: f5fb61b953a631f47191124a31169701911ee1f4
- platform: linux
create_revision: f5fb61b953a631f47191124a31169701911ee1f4
base_revision: f5fb61b953a631f47191124a31169701911ee1f4
- platform: macos
create_revision: f5fb61b953a631f47191124a31169701911ee1f4
base_revision: f5fb61b953a631f47191124a31169701911ee1f4
- platform: web
create_revision: f5fb61b953a631f47191124a31169701911ee1f4
base_revision: f5fb61b953a631f47191124a31169701911ee1f4
- platform: windows
create_revision: f5fb61b953a631f47191124a31169701911ee1f4
base_revision: f5fb61b953a631f47191124a31169701911ee1f4
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

@ -0,0 +1,3 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:

@ -0,0 +1,152 @@
// Copyright 2021 The Flutter team. 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:convert';
import 'package:flutter/material.dart';
import 'package:web_startup_analyzer/web_startup_analyzer.dart';
main() async {
var analyzer = WebStartupAnalyzer(additionalFrameCount: 10);
print(json.encode(analyzer.startupTiming));
analyzer.onFirstFrame.addListener(() {
print(json.encode({'firstFrame': analyzer.onFirstFrame.value}));
});
analyzer.onFirstPaint.addListener(() {
print(json.encode({
'firstPaint': analyzer.onFirstPaint.value?.$1,
'firstContentfulPaint': analyzer.onFirstPaint.value?.$2,
}));
});
analyzer.onAdditionalFrames.addListener(() {
print(json.encode({
'additionalFrames': analyzer.onAdditionalFrames.value,
}));
});
runApp(
WebStartupAnalyzerSample(
analyzer: analyzer,
),
);
}
class WebStartupAnalyzerSample extends StatelessWidget {
final WebStartupAnalyzer analyzer;
const WebStartupAnalyzerSample({super.key, required this.analyzer});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter web app timing',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green.shade100),
useMaterial3: true,
),
home: WebStartupAnalyzerScreen(analyzer: analyzer),
);
}
}
class WebStartupAnalyzerScreen extends StatefulWidget {
final WebStartupAnalyzer analyzer;
const WebStartupAnalyzerScreen({super.key, required this.analyzer});
@override
State<WebStartupAnalyzerScreen> createState() =>
_WebStartupAnalyzerScreenState();
}
class _WebStartupAnalyzerScreenState extends State<WebStartupAnalyzerScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.amber.shade50,
body: Align(
alignment: Alignment.topCenter,
child: Container(
margin: const EdgeInsets.all(8.0),
constraints: const BoxConstraints(maxWidth: 400),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.0),
),
child: ListenableBuilder(
listenable: widget.analyzer.onChange,
builder: (BuildContext context, child) {
return ListView(
shrinkWrap: true,
children: [
TimingWidget(
name: 'DCL',
timingMs: widget.analyzer.domContentLoaded,
),
TimingWidget(
name: 'Load entrypoint',
timingMs: widget.analyzer.loadEntrypoint,
),
TimingWidget(
name: 'Initialize engine',
timingMs: widget.analyzer.initializeEngine,
),
TimingWidget(
name: 'Run app',
timingMs: widget.analyzer.appRunnerRunApp,
),
if (widget.analyzer.firstFrame != null)
TimingWidget(
name: 'First frame',
timingMs: widget.analyzer.firstFrame!,
),
if (widget.analyzer.firstPaint != null)
TimingWidget(
name: 'First paint',
timingMs: widget.analyzer.firstPaint!),
if (widget.analyzer.firstContentfulPaint != null)
TimingWidget(
name: 'First contentful paint',
timingMs: widget.analyzer.firstContentfulPaint!),
if (widget.analyzer.additionalFrames != null) ...[
for (var i in widget.analyzer.additionalFrames!)
TimingWidget(name: 'Frame', timingMs: i.toDouble()),
] else
TextButton(
child: const Text('Trigger frames'),
onPressed: () {},
),
],
);
},
),
),
),
);
}
}
class TimingWidget extends StatelessWidget {
final String name;
final double timingMs;
const TimingWidget({
super.key,
required this.name,
required this.timingMs,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
name,
style: const TextStyle(fontSize: 18),
overflow: TextOverflow.ellipsis,
),
trailing: Text(
'${timingMs.truncate()}ms',
style: const TextStyle(fontSize: 18),
),
);
}
}

@ -0,0 +1,18 @@
name: example
description: "flutter_web_startup_analyzer example"
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: '>=3.4.0-16.0.dev <4.0.0'
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
web_startup_analyzer:
path: ../
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="web_startup_analyzer example">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="example">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<link rel="icon" type="image/png" href="favicon.png"/>
<title>web_perf_metrics example</title>
<link rel="manifest" href="manifest.json">
<script>
const serviceWorkerVersion = null;
</script>
<script src="flutter.js" defer></script>
</head>
<body>
<script type="text/javascript" src="assets/packages/web_startup_analyzer/lib/web_startup_analyzer.js"></script>
<script>
var flutterWebStartupAnalyzer = new FlutterWebStartupAnalyzer();
var analyzer = flutterWebStartupAnalyzer;
window.addEventListener('load', function(ev) {
analyzer.markStart("loadEntrypoint");
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: function(engineInitializer) {
analyzer.markFinished("loadEntrypoint");
analyzer.markStart("initializeEngine");
engineInitializer.initializeEngine().then(function(appRunner) {
analyzer.markFinished("initializeEngine");
analyzer.markStart("appRunnerRunApp");
appRunner.runApp();
});
}
});
});
</script>
</body>
</html>

@ -0,0 +1,35 @@
{
"name": "example",
"short_name": "example",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

@ -0,0 +1,43 @@
// Copyright 2021 The Flutter team. 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:async';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
class FrameAnalyzer {
final WidgetsBinding _binding;
final Completer _onDone = Completer();
int _remainingFrames;
final int additionalFrames;
List<int> additionalFrameTimes = [];
FrameAnalyzer(this._binding, {this.additionalFrames = 10})
: _remainingFrames = additionalFrames;
Future captureAdditionalFrames() {
_binding.addTimingsCallback(_timingsCallback);
return _onDone.future;
}
_reportFrame(FrameTiming frameTiming) {
additionalFrameTimes.add(frameTiming.totalSpan.inMilliseconds);
}
_timingsCallback(timings) {
int i = 0;
while (_remainingFrames > 0 && i < timings.length) {
_reportFrame(timings[i]);
i++;
_remainingFrames--;
}
if (_remainingFrames <= 0) {
_binding.removeTimingsCallback(_timingsCallback);
_onDone.complete();
}
}
}

@ -0,0 +1,25 @@
// Copyright 2021 The Flutter team. 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:js_interop';
@JS()
@staticInterop
external FlutterWebStartupAnalyzer get flutterWebStartupAnalyzer;
@JS()
@staticInterop
class FlutterWebStartupAnalyzer {
external factory FlutterWebStartupAnalyzer();
}
extension FlutterWebStartupAnalyzerExtensions on FlutterWebStartupAnalyzer {
external JSObject get timings;
external void markStart(String name);
external void markFinished(String name);
external void capture(String name);
external void captureAll();
external void capturePaint();
external void report();
}

@ -0,0 +1,97 @@
// Copyright 2021 The Flutter team. 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:async';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:flutter/widgets.dart';
import 'src/frame_analyzer.dart';
import 'src/startup_analyzer.dart';
class WebStartupAnalyzer {
final WidgetsBinding _widgetsBinding;
late final FrameAnalyzer _frameAnalyzer;
List<int>? _additionalFrames;
late final Listenable onChange;
Map<String, dynamic> startupTiming = {};
ValueNotifier<double?> onFirstFrame = ValueNotifier(null);
ValueNotifier<(double, double)?> onFirstPaint = ValueNotifier(null);
ValueNotifier<List<int>?> onAdditionalFrames = ValueNotifier(null);
double get domContentLoaded =>
(flutterWebStartupAnalyzer.timings['domContentLoaded'] as JSNumber)
.toDartDouble;
double get loadEntrypoint =>
(flutterWebStartupAnalyzer.timings['loadEntrypoint'] as JSNumber)
.toDartDouble;
double get initializeEngine =>
(flutterWebStartupAnalyzer.timings['initializeEngine'] as JSNumber)
.toDartDouble;
double get appRunnerRunApp =>
(flutterWebStartupAnalyzer.timings['appRunnerRunApp'] as JSNumber)
.toDartDouble;
double? get firstFrame =>
(flutterWebStartupAnalyzer.timings['firstFrame'] as JSNumber?)
?.toDartDouble;
double? get firstPaint =>
(flutterWebStartupAnalyzer.timings['first-paint'] as JSNumber?)
?.toDartDouble;
double? get firstContentfulPaint =>
(flutterWebStartupAnalyzer.timings['first-contentful-paint'] as JSNumber?)
?.toDartDouble;
List<int>? get additionalFrames => _additionalFrames;
WebStartupAnalyzer({int additionalFrameCount = 5})
: _widgetsBinding = WidgetsFlutterBinding.ensureInitialized() {
_frameAnalyzer =
FrameAnalyzer(_widgetsBinding, additionalFrames: additionalFrameCount);
_captureStartupMetrics();
startupTiming = {
'domContentLoaded': domContentLoaded,
'loadEntrypoint': loadEntrypoint,
'initializeEngine': initializeEngine,
'appRunnerRunApp': appRunnerRunApp,
};
_captureFirstFrame().then((value) {
flutterWebStartupAnalyzer.captureAll();
onFirstFrame.value = firstFrame;
// Capture first-paint and first-contentful-paint
Future.delayed(const Duration(milliseconds: 200)).then((_) {
flutterWebStartupAnalyzer.capturePaint();
onFirstPaint.value = (firstPaint!, firstContentfulPaint!);
});
});
captureFlutterFrameData().then((value) {
_additionalFrames = value;
onAdditionalFrames.value = value;
});
onChange =
Listenable.merge([onFirstFrame, onFirstPaint, onAdditionalFrames]);
}
_captureStartupMetrics() {
flutterWebStartupAnalyzer.markFinished('appRunnerRunApp');
flutterWebStartupAnalyzer.captureAll();
}
Future<void> _captureFirstFrame() {
final completer = Completer();
flutterWebStartupAnalyzer.markStart('firstFrame');
_widgetsBinding.addPostFrameCallback((timeStamp) {
flutterWebStartupAnalyzer.markFinished('firstFrame');
flutterWebStartupAnalyzer.capture('firstFrame');
completer.complete();
});
return completer.future;
}
Future<List<int>> captureFlutterFrameData() async {
await _frameAnalyzer.captureAdditionalFrames();
return _frameAnalyzer.additionalFrameTimes;
}
}

@ -0,0 +1,47 @@
// Copyright 2021 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Helper class to capture Flutter web app startup timing information
class FlutterWebStartupAnalyzer {
timings;
constructor() {
this.timings = {};
}
markStart(name) {
this.timings[name] = null;
performance.mark('flt-' + name + '-started');
}
markFinished(name) {
performance.mark('flt-' + name + '-finished');
}
capture(name) {
var timingName = 'flt-' + name;
var started = 'flt-' + name + 'started';
try {
var measurement = performance.measure('flt-' + name, 'flt-' + name + '-started', 'flt-' + name + '-finished');
} catch(e) {
// ignore errors if the mark doesn't exist
return;
}
this.timings[name] = measurement.duration;
}
captureAll() {
for (var [key, value] of Object.entries(this.timings)) {
this.capture(key);
}
// Capture
this.timings['load'] = performance.timing.loadEventEnd - performance.timing.domContentLoadedEventEnd;
this.timings['domContentLoaded'] = performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart;
}
capturePaint() {
const entries = performance.getEntriesByType("paint");
// Collect first-paint and first-contentful-paint entries
entries.forEach((entry) => {
this.timings[entry.name] = entry.startTime;
});
}
}

@ -0,0 +1,19 @@
name: web_startup_analyzer
description: "Captures web startup timing data in a Flutter web app"
version: 0.1.0-wip
environment:
sdk: ^3.2.0
flutter: ^3.16.0
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
assets:
- lib/web_startup_analyzer.js

@ -11,6 +11,7 @@ import 'common.dart';
void main() async { void main() async {
final packageDirs = listPackageDirs(Directory.current) final packageDirs = listPackageDirs(Directory.current)
.map((path) => p.relative(path, from: Directory.current.path)) .map((path) => p.relative(path, from: Directory.current.path))
.where((path) => !p.dirname(path).startsWith('_'))
.toList(); .toList();
print('Package dirs:\n${packageDirs.map((path) => ' $path').join('\n')}'); print('Package dirs:\n${packageDirs.map((path) => ' $path').join('\n')}');

Loading…
Cancel
Save