[web] add support for AppLifecycleState changes (flutter/engine#44720)

closes flutter/flutter#53107

This PR introduces support for `AppLifecycleState` on Web, aligning the web's lifecycle events with those of the mobile platforms. This ensures a more consistent developer experience and better lifecycle management across all platforms.

**PR includes:**

- Page Visibility Handling: Integrated the `visibilitychange` DOM event to determine if the app is in a `resumed` or `paused` state based on the visibility state of the document.
- Page Transition Handling: Used `beforeunload` events to better manage the `detached` state.

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
This commit is contained in:
maRci002
2024-01-25 17:31:12 +01:00
committed by GitHub
parent 75bd18f4b2
commit e0a9cc7ce0
8 changed files with 243 additions and 9 deletions

View File

@@ -6020,6 +6020,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/noto_font_encoding.dart + ../
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/onscreen_logging.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/picture.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher/app_lifecycle_state.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_views.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/message_handler.dart + ../../../flutter/LICENSE
@@ -8873,6 +8874,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/noto_font_encoding.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/onscreen_logging.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/picture.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher/app_lifecycle_state.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/message_handler.dart

View File

@@ -1832,13 +1832,13 @@ enum AppLifecycleState {
/// any host views.
///
/// The application defaults to this state before it initializes, and can be
/// in this state (on Android and iOS only) after all views have been
/// in this state (applicable on Android, iOS, and web) after all views have been
/// detached.
///
/// When the application is in this state, the engine is running without a
/// view.
///
/// This state is only entered on iOS and Android, although on all platforms
/// This state is only entered on iOS, Android, and web, although on all platforms
/// it is the default state before the application begins running.
detached,

View File

@@ -123,6 +123,7 @@ export 'engine/noto_font_encoding.dart';
export 'engine/onscreen_logging.dart';
export 'engine/picture.dart';
export 'engine/platform_dispatcher.dart';
export 'engine/platform_dispatcher/app_lifecycle_state.dart';
export 'engine/platform_views.dart';
export 'engine/platform_views/content_manager.dart';
export 'engine/platform_views/message_handler.dart';

View File

@@ -339,6 +339,10 @@ extension DomHTMLDocumentExtension on DomHTMLDocument {
@JS('getElementById')
external DomElement? _getElementById(JSString id);
DomElement? getElementById(String id) => _getElementById(id.toJS);
@JS('visibilityState')
external JSString get _visibilityState;
String get visibilityState => _visibilityState.toDart;
}
@JS('document')

View File

@@ -77,7 +77,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
_addFontSizeObserver();
_addLocaleChangedListener();
registerHotRestartListener(dispose);
_setAppLifecycleState(ui.AppLifecycleState.resumed);
AppLifecycleState.instance.addListener(_setAppLifecycleState);
viewManager.onViewDisposed.listen((_) {
// Send a metrics changed event to the framework when a view is disposed.
// View creation/resize is handled by the `_didResize` handler in the
@@ -111,6 +111,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
_disconnectFontSizeObserver();
_removeLocaleChangedListener();
HighContrastSupport.instance.removeListener(_updateHighContrast);
AppLifecycleState.instance.removeListener(_setAppLifecycleState);
viewManager.dispose();
}
@@ -1033,10 +1034,10 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
}
void _setAppLifecycleState(ui.AppLifecycleState state) {
sendPlatformMessage(
invokeOnPlatformMessage(
'flutter/lifecycle',
ByteData.sublistView(utf8.encode(state.toString())),
null,
const StringCodec().encodeMessage(state.toString()),
(_) {},
);
}

View File

@@ -0,0 +1,106 @@
// 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:meta/meta.dart';
import 'package:ui/ui.dart' as ui;
import '../../engine.dart';
/// Signature of functions added as a listener to [ui.AppLifecycleState] changes
typedef AppLifecycleStateListener = void Function(ui.AppLifecycleState state);
/// Determines the [ui.AppLifecycleState].
abstract class AppLifecycleState {
static final AppLifecycleState instance = _BrowserAppLifecycleState();
ui.AppLifecycleState get appLifecycleState => _appLifecycleState;
ui.AppLifecycleState _appLifecycleState = ui.AppLifecycleState.resumed;
final List<AppLifecycleStateListener> _listeners =
<AppLifecycleStateListener>[];
void addListener(AppLifecycleStateListener listener) {
if (_listeners.isEmpty) {
activate();
}
_listeners.add(listener);
listener(_appLifecycleState);
}
void removeListener(AppLifecycleStateListener listener) {
_listeners.remove(listener);
if (_listeners.isEmpty) {
deactivate();
}
}
@protected
void activate();
@protected
void deactivate();
@visibleForTesting
void onAppLifecycleStateChange(ui.AppLifecycleState newState) {
if (newState != _appLifecycleState) {
_appLifecycleState = newState;
for (final AppLifecycleStateListener listener in _listeners) {
listener(newState);
}
}
}
}
/// Manages [ui.AppLifecycleState] within a web context by monitoring specific
/// 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 {
@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);
}
@override
void deactivate() {
domWindow.removeEventListener('focus', _focusListener);
domWindow.removeEventListener('blur', _blurListener);
domWindow.removeEventListener('beforeunload', _beforeUnloadListener);
domDocument.removeEventListener(
'visibilitychange',
_visibilityChangeListener,
);
}
late final DomEventListener _focusListener =
createDomEventListener((DomEvent event) {
onAppLifecycleStateChange(ui.AppLifecycleState.resumed);
});
late final DomEventListener _blurListener =
createDomEventListener((DomEvent event) {
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') {
onAppLifecycleStateChange(ui.AppLifecycleState.resumed);
} else if (domDocument.visibilityState == 'hidden') {
onAppLifecycleStateChange(ui.AppLifecycleState.hidden);
}
});
}

View File

@@ -37,6 +37,95 @@ void testMain() {
engineDispatcher.dispose();
});
test('AppLifecycleState transitions through all states', () {
final List<ui.AppLifecycleState> states = <ui.AppLifecycleState>[];
void listener(ui.AppLifecycleState state) {
states.add(state);
}
final MockAppLifecycleState mockAppLifecycleState =
MockAppLifecycleState();
expect(mockAppLifecycleState.appLifecycleState,
ui.AppLifecycleState.resumed);
mockAppLifecycleState.addListener(listener);
expect(mockAppLifecycleState.activeCallCount, 1);
expect(
states, equals(<ui.AppLifecycleState>[ui.AppLifecycleState.resumed]));
mockAppLifecycleState.inactive();
expect(mockAppLifecycleState.appLifecycleState,
ui.AppLifecycleState.inactive);
expect(
states,
equals(<ui.AppLifecycleState>[
ui.AppLifecycleState.resumed,
ui.AppLifecycleState.inactive
]));
// consecutive same states are skipped
mockAppLifecycleState.inactive();
expect(
states,
equals(<ui.AppLifecycleState>[
ui.AppLifecycleState.resumed,
ui.AppLifecycleState.inactive
]));
mockAppLifecycleState.hidden();
expect(
mockAppLifecycleState.appLifecycleState, ui.AppLifecycleState.hidden);
expect(
states,
equals(<ui.AppLifecycleState>[
ui.AppLifecycleState.resumed,
ui.AppLifecycleState.inactive,
ui.AppLifecycleState.hidden
]));
mockAppLifecycleState.resume();
expect(mockAppLifecycleState.appLifecycleState,
ui.AppLifecycleState.resumed);
expect(
states,
equals(<ui.AppLifecycleState>[
ui.AppLifecycleState.resumed,
ui.AppLifecycleState.inactive,
ui.AppLifecycleState.hidden,
ui.AppLifecycleState.resumed
]));
mockAppLifecycleState.detach();
expect(mockAppLifecycleState.appLifecycleState,
ui.AppLifecycleState.detached);
expect(
states,
equals(<ui.AppLifecycleState>[
ui.AppLifecycleState.resumed,
ui.AppLifecycleState.inactive,
ui.AppLifecycleState.hidden,
ui.AppLifecycleState.resumed,
ui.AppLifecycleState.detached
]));
mockAppLifecycleState.removeListener(listener);
expect(mockAppLifecycleState.deactivateCallCount, 1);
// No more states should be recorded after the listener is removed.
mockAppLifecycleState.resume();
expect(
states,
equals(<ui.AppLifecycleState>[
ui.AppLifecycleState.resumed,
ui.AppLifecycleState.inactive,
ui.AppLifecycleState.hidden,
ui.AppLifecycleState.resumed,
ui.AppLifecycleState.detached
]));
});
test('responds to flutter/skia Skia.setResourceCacheMaxBytes', () async {
const MethodCodec codec = JSONMethodCodec();
final Completer<ByteData?> completer = Completer<ByteData?>();
@@ -279,3 +368,34 @@ class MockHighContrastSupport implements HighContrastSupport {
_listeners.remove(listener);
}
}
class MockAppLifecycleState extends AppLifecycleState {
int activeCallCount = 0;
int deactivateCallCount = 0;
void detach() {
onAppLifecycleStateChange(ui.AppLifecycleState.detached);
}
void resume() {
onAppLifecycleStateChange(ui.AppLifecycleState.resumed);
}
void inactive() {
onAppLifecycleStateChange(ui.AppLifecycleState.inactive);
}
void hidden() {
onAppLifecycleStateChange(ui.AppLifecycleState.hidden);
}
@override
void activate() {
activeCallCount++;
}
@override
void deactivate() {
deactivateCallCount++;
}
}

View File

@@ -32,9 +32,9 @@ namespace flutter {
enum class AppLifecycleState {
/**
* Corresponds to the Framework's AppLifecycleState.detached: The initial
* state of the state machine. On Android and iOS, also the final state of the
* state machine when all views are detached. Other platforms do not enter
* this state again after initially leaving it.
* state of the state machine. On Android, iOS, and web, also the final state
* of the state machine when all views are detached. Other platforms do not
* re-enter this state after initially leaving it.
*/
kDetached,