From 294f3d8af6b65421ad9dbd67407e7ba45a537473 Mon Sep 17 00:00:00 2001 From: Booker Date: Tue, 9 Jun 2026 13:24:50 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=A4=84=E7=90=86=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20WebView=20=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 117 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 0e748b2..d1fb9e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,7 @@ const _keyboardAnimationDuration = Duration(milliseconds: 250); const _h5DomainCacheUrlKey = 'h5_random_domain_url_v1'; const _lineProbeTimeout = Duration(seconds: 5); const _maxGeneratedLineAttempts = 6; +const _longBackgroundRefreshThreshold = Duration(minutes: 20); Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -115,6 +116,8 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { int _keyboardSyncToken = 0; int _lineGeneration = 0; int _h5LineAttempt = 0; + int _resumeRecoveryToken = 0; + DateTime? _backgroundedAt; late ShellBranding _shellBranding; _H5LineWebViewSlot get _currentSlot => _lineSlot; @@ -244,10 +247,19 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.detached) { + _backgroundedAt ??= DateTime.now(); + return; + } + if (state == AppLifecycleState.resumed) { - unawaited(_syncShellRequestUrl()); - unawaited(_syncShellBranding()); - unawaited(_syncKeyboardState()); + final backgroundDuration = _backgroundedAt == null + ? Duration.zero + : DateTime.now().difference(_backgroundedAt!); + _backgroundedAt = null; + unawaited(_recoverWebViewAfterResume(backgroundDuration)); } } @@ -629,6 +641,97 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { return _runJavaScriptSafely(script); } + Future _notifyH5AppResumed(Duration backgroundDuration) { + final payload = jsonEncode({ + 'backgroundDurationMs': backgroundDuration.inMilliseconds, + 'refreshedAt': DateTime.now().millisecondsSinceEpoch, + }); + final script = ''' +(() => { + try { + const detail = $payload; + window.dispatchEvent(new CustomEvent('openim-shell-app-resumed', { detail })); + } catch (_) {} +})(); +'''; + return _runJavaScriptSafely(script); + } + + Future _isH5RuntimeResponsive() async { + try { + final result = await _lineSlot.controller.runJavaScriptReturningResult( + ''' +(() => { + try { + return JSON.stringify({ + marker: 'openim-h5-runtime-alive', + readyState: document.readyState, + href: window.location.href, + now: Date.now() + }); + } catch (_) { + return 'openim-h5-runtime-error'; + } +})(); +''', + ).timeout(const Duration(seconds: 2)); + return result.toString().contains('openim-h5-runtime-alive'); + } catch (_) { + return false; + } + } + + Future _currentReloadUri({required bool cacheBust}) async { + String? currentUrl; + try { + currentUrl = await _lineSlot.controller.currentUrl(); + } catch (_) { + currentUrl = null; + } + + final parsedCurrentUrl = + currentUrl == null ? null : Uri.tryParse(currentUrl); + final uri = parsedCurrentUrl?.hasScheme == true + ? parsedCurrentUrl! + : Uri.parse(_lineSlot.line.url); + + if (!cacheBust) { + return uri; + } + + final queryParameters = Map.from(uri.queryParameters) + ..['_shell_resume_ts'] = DateTime.now().millisecondsSinceEpoch.toString(); + return uri.replace(queryParameters: queryParameters); + } + + Future _recoverWebViewAfterResume(Duration backgroundDuration) async { + final token = ++_resumeRecoveryToken; + final generation = _lineGeneration; + await _syncShellRequestUrl(); + await _syncShellBranding(); + await _syncKeyboardState(); + await _notifyH5AppResumed(backgroundDuration); + + if (!_isCurrentLineGeneration(generation) || + token != _resumeRecoveryToken || + backgroundDuration < _longBackgroundRefreshThreshold) { + return; + } + + final responsive = await _isH5RuntimeResponsive(); + if (!mounted || + !_isCurrentLineGeneration(generation) || + token != _resumeRecoveryToken) { + return; + } + + _logH5LineDebug( + '后台 ${backgroundDuration.inMinutes} 分钟后恢复,' + '${responsive ? '刷新 WebView 以拉取最新 H5' : 'WebView JS 无响应,重载当前页'}', + ); + await _reloadCurrentLine(forceRefresh: true); + } + Future _handleShellMediaPermissionRequest({ required int generation, required String requestId, @@ -769,7 +872,7 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { await _syncKeyboardState(); } - Future _reloadCurrentLine() async { + Future _reloadCurrentLine({bool forceRefresh = false}) async { final slot = _currentSlot; if (slot.availability == _LineAvailability.unavailable || slot.loadError != null) { @@ -787,7 +890,11 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { }); } - if (slot.hasLoadedInitialRequest) { + if (forceRefresh) { + await slot.controller.loadRequest( + await _currentReloadUri(cacheBust: true), + ); + } else if (slot.hasLoadedInitialRequest) { await slot.controller.reload(); } else { await _loadCurrentLine();