From e0a9cc7ce0a43ef0a2b2bbd9bf1546fbb0fe3beb Mon Sep 17 00:00:00 2001 From: maRci002 Date: Thu, 25 Jan 2024 17:31:12 +0100 Subject: [PATCH] [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 --- .../ci/licenses_golden/licenses_flutter | 2 + .../flutter/lib/ui/platform_dispatcher.dart | 4 +- .../flutter/lib/web_ui/lib/src/engine.dart | 1 + .../lib/web_ui/lib/src/engine/dom.dart | 4 + .../lib/src/engine/platform_dispatcher.dart | 9 +- .../app_lifecycle_state.dart | 106 ++++++++++++++++ .../platform_dispatcher_test.dart | 120 ++++++++++++++++++ .../platform/common/app_lifecycle_state.h | 6 +- 8 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher/app_lifecycle_state.dart diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter index b6888198f4..9836b81f49 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_flutter +++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter @@ -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 diff --git a/engine/src/flutter/lib/ui/platform_dispatcher.dart b/engine/src/flutter/lib/ui/platform_dispatcher.dart index 6d059da2be..19e8444d0b 100644 --- a/engine/src/flutter/lib/ui/platform_dispatcher.dart +++ b/engine/src/flutter/lib/ui/platform_dispatcher.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, diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index 500392ac9a..dc0aea3753 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -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'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart index 367f876ff3..4baa48cd3d 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.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') diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart index 1453da94e0..6c1506c2c6 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -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()), + (_) {}, ); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher/app_lifecycle_state.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher/app_lifecycle_state.dart new file mode 100644 index 0000000000..138fcccab4 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher/app_lifecycle_state.dart @@ -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 _listeners = + []; + + 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); + } + }); +} diff --git a/engine/src/flutter/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart b/engine/src/flutter/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart index 6dd81ad7fc..dba58d338c 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart @@ -37,6 +37,95 @@ void testMain() { engineDispatcher.dispose(); }); + test('AppLifecycleState transitions through all states', () { + final List states = []; + 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.resumed])); + + mockAppLifecycleState.inactive(); + expect(mockAppLifecycleState.appLifecycleState, + ui.AppLifecycleState.inactive); + expect( + states, + equals([ + ui.AppLifecycleState.resumed, + ui.AppLifecycleState.inactive + ])); + + // consecutive same states are skipped + mockAppLifecycleState.inactive(); + expect( + states, + equals([ + ui.AppLifecycleState.resumed, + ui.AppLifecycleState.inactive + ])); + + mockAppLifecycleState.hidden(); + expect( + mockAppLifecycleState.appLifecycleState, ui.AppLifecycleState.hidden); + expect( + states, + equals([ + ui.AppLifecycleState.resumed, + ui.AppLifecycleState.inactive, + ui.AppLifecycleState.hidden + ])); + + mockAppLifecycleState.resume(); + expect(mockAppLifecycleState.appLifecycleState, + ui.AppLifecycleState.resumed); + expect( + states, + equals([ + 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.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.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 completer = Completer(); @@ -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++; + } +} diff --git a/engine/src/flutter/shell/platform/common/app_lifecycle_state.h b/engine/src/flutter/shell/platform/common/app_lifecycle_state.h index a3d79dcb74..fdce098b78 100644 --- a/engine/src/flutter/shell/platform/common/app_lifecycle_state.h +++ b/engine/src/flutter/shell/platform/common/app_lifecycle_state.h @@ -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,