perf(mornye): prepare overlay GPU pipeline before first opening

This commit is contained in:
zarzet committed 2026-10-07 01:54:22 +07:00
1 parent 1c9c901528
commit 76b843b2bf
4 files changed
+266 -7

No files matched your search

@@ -5,17 +5,20 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/mornye_glass_warmup.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
import 'package:spotiflac_android/widgets/mornye_glass_preparation.dart';
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
import 'performance_probe.dart';
@@ -65,8 +68,20 @@ class _Extensions extends ExtensionNotifier {
void main() {
const glassOverride = String.fromEnvironment('DOWNLOAD_GLASS_LEVEL');
const brightnessOverride = String.fromEnvironment('DOWNLOAD_BRIGHTNESS');
const preloadGlass = bool.fromEnvironment('DOWNLOAD_PRELOAD_GLASS');
const warmupGlass = bool.fromEnvironment(
'DOWNLOAD_WARMUP_GLASS',
defaultValue: true,
);
const shellGlass = bool.fromEnvironment('DOWNLOAD_SHELL_GLASS');
const traceFirstOpen = bool.fromEnvironment('DOWNLOAD_TRACE_FIRST_OPEN');
final binding = PerformanceTestBinding.ensureInitialized();
for (final brightness in Brightness.values) {
if (brightnessOverride.isNotEmpty &&
brightness.name != brightnessOverride) {
continue;
}
testWidgets(
'download glass, source changes and queue feedback in $brightness',
(tester) async {
@@ -105,7 +120,11 @@ void main() {
supportedLocales: AppLocalizations.supportedLocales,
builder: (_, child) => RepaintBoundary(
key: capture,
child: AppScaffoldMessenger(child: child!),
child: warmupGlass
? MornyeGlassPreparation(
child: AppScaffoldMessenger(child: child!),
)
: AppScaffoldMessenger(child: child!),
),
home: Scaffold(
body: Builder(
@@ -131,6 +150,32 @@ void main() {
child: const Text('Open download'),
),
),
if (shellGlass)
Positioned(
left: 12,
right: 12,
bottom: 24,
child: MornyeTabBar(
destinations: const [
NavigationDestination(
icon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.library_music),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.search),
label: 'Search',
),
],
selectedIndex: 0,
onSelected: (_) {},
blurEnabled: true,
liquidGlass: true,
),
),
],
);
},
@@ -139,7 +184,29 @@ void main() {
),
),
);
await probe.wait(const Duration(milliseconds: 600));
if (preloadGlass) {
await tester.runAsync(() => LiquidGlassShaders.ensureLoaded());
}
if (traceFirstOpen) {
await tester.runAsync(() => binding.enableTimeline());
}
// Give both baseline and prepared processes the same idle interval;
// opening the route never waits for preparation to complete.
await probe.wait(const Duration(milliseconds: 1500));
if (warmupGlass &&
(glassOverride.isEmpty || glassOverride == 'liquid')) {
expect(MornyeGlassWarmup.isReady, isTrue);
final view = binding.platformDispatcher.views.first;
expect(
identical(
MornyeGlassWarmup.prepare(view),
MornyeGlassWarmup.prepare(view),
),
isTrue,
reason: 'Multiple callers must share one preparation',
);
}
final shadersLoadedBeforeOpen = LiquidGlassShaders.isLoaded;
Future<void> open() async {
await tester.tap(find.text('Open download'));
await probe.wait(const Duration(milliseconds: 500));
@@ -156,7 +223,12 @@ void main() {
await probe.measure(
'first_open',
open,
traceFirstOpen
? () => binding.traceAction(
open,
reportKey: 'first_open_timeline_${brightness.name}',
)
: open,
warmup: () async {},
repetitions: 1,
);
@@ -166,6 +238,23 @@ void main() {
warmup: () async {},
repetitions: 1,
);
if (const bool.fromEnvironment('DOWNLOAD_FIRST_OPEN_ONLY')) {
await probe.finish(
metadata: {
'scope':
'first opening of production picker with fixture providers',
'brightness': brightness.name,
'shaders_loaded_before_open': shadersLoadedBeforeOpen,
'preload_glass': preloadGlass,
'trace_first_open': traceFirstOpen,
'warmup_glass': warmupGlass,
'warmup_ready': MornyeGlassWarmup.isReady,
'shell_glass': shellGlass,
},
);
await tester.pumpWidget(const SizedBox.shrink());
return;
}
await probe.measure('open_close', () async {
await open();
await close();
@@ -287,6 +376,12 @@ void main() {
'scope':
'production download picker and queue feedback with fixture providers; no downloads',
'brightness': brightness.name,
'shaders_loaded_before_open': shadersLoadedBeforeOpen,
'preload_glass': preloadGlass,
'trace_first_open': traceFirstOpen,
'warmup_glass': warmupGlass,
'warmup_ready': MornyeGlassWarmup.isReady,
'shell_glass': shellGlass,
'pose_layers': poses,
'glass_level_override': glassOverride,
'quality_count': const int.fromEnvironment(
+7 -4
View File
@@ -15,6 +15,7 @@ import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/l10n/supported_locales.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/mornye_glass_preparation.dart';
String initialLocationForAppState({
required bool isFirstLaunch,
@@ -214,10 +215,12 @@ class SpotiFLACApp extends ConsumerWidget {
// level it sits above the router's Navigator, so the controller
// never encounters it while collecting heroes. Removing the
// inherited controller disables flights on the root Navigator.
child: AdaptiveUiScaler(
child: heroAnimationsEnabled
? appContent
: HeroControllerScope.none(child: appContent),
child: MornyeGlassPreparation(
child: AdaptiveUiScaler(
child: heroAnimationsEnabled
? appContent
: HeroControllerScope.none(child: appContent),
),
),
);
},
+135
View File
@@ -0,0 +1,135 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/utils/logger.dart';
/// Prepares the actual backdrop pipeline, not just the fragment asset. The
/// detached scene is rasterized once and discarded without presenting a frame.
class MornyeGlassWarmup {
static Future<void>? _pending;
static bool _ready = false;
static final _log = AppLogger('MornyeGlass');
@visibleForTesting
static bool get isReady => _ready;
static Future<void> prepare(ui.FlutterView view) {
if (!ui.ImageFilter.isShaderFilterSupported) return Future<void>.value();
return _pending ??= _prepare(view).catchError((Object error) {
// Preparation is optional; keep the package's existing lazy fallback.
_log.w('Glass preparation failed: $error');
});
}
static Future<void> _prepare(ui.FlutterView view) async {
// GLES needs the view's initialized graphics context. A first-frame
// callback only means painting was submitted, not that rasterization ran.
await Future.wait<void>([
LiquidGlassShaders.ensureLoaded(),
WidgetsBinding.instance.waitUntilFirstFrameRasterized,
]);
// Do not compete with an entrance, theme switch or scrolling animation.
await SchedulerBinding.instance.scheduleTask(
() async {
final binding = WidgetsBinding.instance;
if (binding.lifecycleState != AppLifecycleState.resumed ||
!binding.platformDispatcher.views.contains(view)) {
_pending = null;
return;
}
await _rasterize(view);
_ready = true;
},
Priority.idle,
debugLabel: 'Prepare Mornye glass backdrop',
);
}
}
Future<void> _rasterize(ui.FlutterView view) async {
const size = Size(240, 180);
final boundary = RenderRepaintBoundary();
final pipeline = PipelineOwner();
final focus = FocusManager();
final owner = BuildOwner(focusManager: focus);
final renderView = RenderView(
view: view,
configuration: ViewConfiguration(
logicalConstraints: BoxConstraints.tight(size),
physicalConstraints: BoxConstraints.tight(size * view.devicePixelRatio),
devicePixelRatio: view.devicePixelRatio,
),
child: boundary,
);
pipeline.rootNode = renderView;
renderView.prepareInitialFrame();
RenderObjectToWidgetElement<RenderBox>? root;
try {
root = RenderObjectToWidgetAdapter<RenderBox>(
container: boundary,
child: ProviderScope(
overrides: [
mornyeGlassLevelProvider.overrideWithValue(MornyeGlassLevel.liquid),
],
child: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(
size: size,
devicePixelRatio: view.devicePixelRatio,
),
child: Theme(
data: MornyeTheme.build(Brightness.dark),
child: const Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xff76534b), Color(0xff385763)],
),
),
),
Padding(
padding: EdgeInsets.all(12),
child: MornyeGlassPanel.overlay(
child: Center(child: Text('Download')),
),
),
],
),
),
),
),
),
).attachToRenderTree(owner);
owner.buildScope(root);
owner.finalizeTree();
pipeline.flushLayout();
pipeline.flushCompositingBits();
pipeline.flushPaint();
final image = await boundary.toImage(pixelRatio: view.devicePixelRatio);
image.dispose();
} finally {
if (root != null) {
RenderObjectToWidgetAdapter<RenderBox>(
container: boundary,
).attachToRenderTree(owner, root);
owner.buildScope(root);
owner.finalizeTree();
}
pipeline.rootNode = null;
renderView.child = null;
renderView.dispose();
boundary.dispose();
pipeline.dispose();
focus.dispose();
}
}
+26
View File
@@ -0,0 +1,26 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/services/mornye_glass_warmup.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
/// Starts the one-time preparation when the full glass theme is enabled,
/// including switching to Mornye or enabling blur after the app has started.
class MornyeGlassPreparation extends ConsumerWidget {
const MornyeGlassPreparation({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (context.isMornye &&
ref.watch(mornyeGlassLevelProvider) == MornyeGlassLevel.liquid &&
MornyeTheme.glassClarityOf(context) > 0.2 &&
!MediaQuery.highContrastOf(context)) {
unawaited(MornyeGlassWarmup.prepare(View.of(context)));
}
return child;
}
}