[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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()),
|
||||
(_) {},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user