mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
perf(mornye): prepare overlay GPU pipeline before first opening
This commit is contained in:
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
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user