[web] Implement AppLifecycleState.detached as documented (flutter/engine#53506)

Currently, we are transitioning to the `AppLifecycleState.detached` incorrectly. This is causing the framework to stop pumping frames when the app is still active and visible.

This PR re-implements the transition to `AppLifecycleState.detached` as documented [here](https://api.flutter.dev/flutter/dart-ui/AppLifecycleState.html#detached) (based on whether the app has any views or not).

Fixes https://github.com/flutter/flutter/issues/150636
Fixes https://github.com/flutter/flutter/issues/149417
This commit is contained in:
Mouad Debbar 2024-06-25 10:14:17 -04:00 committed by GitHub
parent 833ddf5993
commit 2d762001d3
3 changed files with 84 additions and 12 deletions

View File

@ -78,7 +78,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
_addFontSizeObserver();
_addLocaleChangedListener();
registerHotRestartListener(dispose);
AppLifecycleState.instance.addListener(_setAppLifecycleState);
_appLifecycleState.addListener(_setAppLifecycleState);
_viewFocusBinding.init();
domDocument.body?.prepend(accessibilityPlaceholder);
_onViewDisposedListener = viewManager.onViewDisposed.listen((_) {
@ -122,7 +122,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
_disconnectFontSizeObserver();
_removeLocaleChangedListener();
HighContrastSupport.instance.removeListener(_updateHighContrast);
AppLifecycleState.instance.removeListener(_setAppLifecycleState);
_appLifecycleState.removeListener(_setAppLifecycleState);
_viewFocusBinding.dispose();
accessibilityPlaceholder.remove();
_onViewDisposedListener.cancel();
@ -155,6 +155,9 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
late final FlutterViewManager viewManager = FlutterViewManager(this);
late final AppLifecycleState _appLifecycleState =
AppLifecycleState.create(viewManager);
/// The current list of windows.
@override
Iterable<EngineFlutterView> get views => viewManager.views;

View File

@ -2,6 +2,8 @@
// 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:meta/meta.dart';
import 'package:ui/ui.dart' as ui;
@ -12,7 +14,9 @@ typedef AppLifecycleStateListener = void Function(ui.AppLifecycleState state);
/// Determines the [ui.AppLifecycleState].
abstract class AppLifecycleState {
static final AppLifecycleState instance = _BrowserAppLifecycleState();
static AppLifecycleState create(FlutterViewManager viewManager) {
return _BrowserAppLifecycleState(viewManager);
}
ui.AppLifecycleState get appLifecycleState => _appLifecycleState;
ui.AppLifecycleState _appLifecycleState = ui.AppLifecycleState.resumed;
@ -56,28 +60,36 @@ abstract class AppLifecycleState {
/// browser events.
///
/// This class listens to:
/// - 'beforeunload' on [DomWindow] to detect detachment,
/// - 'visibilitychange' on [DomHTMLDocument] to observe visibility changes,
/// - 'focus' and 'blur' on [DomWindow] to track application focus shifts.
class _BrowserAppLifecycleState extends AppLifecycleState {
_BrowserAppLifecycleState(this._viewManager);
final FlutterViewManager _viewManager;
final List<StreamSubscription<void>> _subscriptions = <StreamSubscription<void>>[];
@override
void activate() {
domWindow.addEventListener('focus', _focusListener);
domWindow.addEventListener('blur', _blurListener);
// TODO(web): Register 'beforeunload' only if lifecycle listeners exist, to improve efficiency: https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
domWindow.addEventListener('beforeunload', _beforeUnloadListener);
domDocument.addEventListener('visibilitychange', _visibilityChangeListener);
_subscriptions
..add(_viewManager.onViewCreated.listen(_onViewCountChanged))
..add(_viewManager.onViewDisposed.listen(_onViewCountChanged));
}
@override
void deactivate() {
domWindow.removeEventListener('focus', _focusListener);
domWindow.removeEventListener('blur', _blurListener);
domWindow.removeEventListener('beforeunload', _beforeUnloadListener);
domDocument.removeEventListener(
'visibilitychange',
_visibilityChangeListener,
);
for (final StreamSubscription<void> subscription in _subscriptions) {
subscription.cancel();
}
_subscriptions.clear();
}
late final DomEventListener _focusListener =
@ -90,11 +102,6 @@ class _BrowserAppLifecycleState extends AppLifecycleState {
onAppLifecycleStateChange(ui.AppLifecycleState.inactive);
});
late final DomEventListener _beforeUnloadListener =
createDomEventListener((DomEvent event) {
onAppLifecycleStateChange(ui.AppLifecycleState.detached);
});
late final DomEventListener _visibilityChangeListener =
createDomEventListener((DomEvent event) {
if (domDocument.visibilityState == 'visible') {
@ -103,4 +110,12 @@ class _BrowserAppLifecycleState extends AppLifecycleState {
onAppLifecycleStateChange(ui.AppLifecycleState.hidden);
}
});
void _onViewCountChanged(_) {
if (_viewManager.views.isEmpty) {
onAppLifecycleStateChange(ui.AppLifecycleState.detached);
} else {
onAppLifecycleStateChange(ui.AppLifecycleState.resumed);
}
}
}

View File

@ -0,0 +1,54 @@
// 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(AppLifecycleState, () {
test('listens to changes in view manager', () {
final FlutterViewManager viewManager = FlutterViewManager(EnginePlatformDispatcher.instance);
final AppLifecycleState state = AppLifecycleState.create(viewManager);
ui.AppLifecycleState? currentState;
void listener(ui.AppLifecycleState newState) {
currentState = newState;
}
state.addListener(listener);
final view1 = EngineFlutterView(EnginePlatformDispatcher.instance, createDomHTMLDivElement());
viewManager.registerView(view1);
expect(currentState, ui.AppLifecycleState.resumed);
currentState = null;
final view2 = EngineFlutterView(EnginePlatformDispatcher.instance, createDomHTMLDivElement());
viewManager.registerView(view2);
// The listener should not be called again. The view manager is still not empty.
expect(currentState, isNull);
viewManager.disposeAndUnregisterView(view1.viewId);
// The listener should not be called again. The view manager is still not empty.
expect(currentState, isNull);
viewManager.disposeAndUnregisterView(view2.viewId);
expect(currentState, ui.AppLifecycleState.detached);
currentState = null;
final view3 = EngineFlutterView(EnginePlatformDispatcher.instance, createDomHTMLDivElement());
viewManager.registerView(view3);
// The state should go back to `resumed` after a new view is registered.
expect(currentState, ui.AppLifecycleState.resumed);
viewManager.dispose();
state.removeListener(listener);
});
});
}