diff --git a/README.md b/README.md index 5a29859..2c21616 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ https://h5-test.imharry.work/ ## H5 线路切换 -线路切换在 Flutter 套壳层完成,H5 页面不需要承载线路切换逻辑。每条线路都是一个独立 H5 地址,切换时 WebView 会直接加载被选中的地址。 +线路切换在 Flutter 套壳层完成,H5 页面不需要承载线路切换逻辑。每条线路对应一个独立 WebView,切换时只切换当前显示的 WebView,不会改写 H5 页面运行中的请求地址。 默认线路配置在: diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 79bf696..8f591ea 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -76,29 +76,11 @@ class AppConfig { } static bool shouldRewriteMainFrameUrl(String url) { - final uri = Uri.tryParse(url); - if (uri == null) { - return false; - } - - final host = uri.host.toLowerCase(); - return (legacyWebHosts.contains(host) && !_isConfiguredH5Host(host)) || - _hasShellParams(uri); + return false; } static String canonicalizeMainFrameUrl(String url) { - final uri = _removeShellParams(Uri.parse(url)); - final host = uri.host.toLowerCase(); - if (!legacyWebHosts.contains(host) || _isConfiguredH5Host(host)) { - return uri.toString(); - } - - return uri - .replace( - scheme: 'https', - host: canonicalWebHost, - ) - .toString(); + return url; } static int? lineIndexForUrl(String url) { @@ -134,16 +116,6 @@ class AppConfig { ); } - static bool _hasShellParams(Uri uri) { - final fragmentParameters = Uri.splitQueryString(uri.fragment); - return uri.queryParameters.containsKey('flutter_shell') || - uri.queryParameters.containsKey('shell_cache_bust') || - uri.queryParameters.containsKey('shell_app_name') || - uri.queryParameters.containsKey('shell_app_logo') || - fragmentParameters.containsKey('shell_app_name') || - fragmentParameters.containsKey('shell_app_logo'); - } - static int _safeLineIndex(int index, int length) { if (length <= 0 || index < 0 || index >= length) { return 0; @@ -151,10 +123,6 @@ class AppConfig { return index; } - static bool _isConfiguredH5Host(String host) { - return h5Lines.any((line) => line.uri.host.toLowerCase() == host); - } - static bool _isSameLine(Uri lineUri, Uri uri) { if (lineUri.host.toLowerCase() != uri.host.toLowerCase()) { return false; diff --git a/lib/main.dart b/lib/main.dart index 804260c..f96f9e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -93,67 +93,68 @@ class H5ShellPage extends StatefulWidget { } class _H5ShellPageState extends State { - late final WebViewController _controller; late final List _h5Lines; + late final List<_H5LineWebViewSlot> _lineSlots; - int _progress = 0; - String? _loadError; - bool _showShellCover = true; bool _shellBrandingLoaded = false; int _currentLineIndex = 0; - Timer? _shellCoverFallbackTimer; late ShellBranding _shellBranding; H5Line get _currentLine => _h5Lines[_currentLineIndex]; + _H5LineWebViewSlot get _currentSlot => _lineSlots[_currentLineIndex]; @override void initState() { super.initState(); _h5Lines = AppConfig.h5Lines; _shellBranding = widget.initialShellBranding; - _controller = _buildController(); - unawaited(_loadHome()); + _lineSlots = [ + for (var index = 0; index < _h5Lines.length; index += 1) + _H5LineWebViewSlot( + line: _h5Lines[index], + controller: _buildController(index), + ), + ]; + unawaited(_ensureLineLoaded(_currentLineIndex)); } - WebViewController _buildController() { + WebViewController _buildController(int lineIndex) { return WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel( 'OpenIMShell', - onMessageReceived: _handleShellMessage, + onMessageReceived: (message) => _handleShellMessage(lineIndex, message), ) ..setBackgroundColor(_shellBackground) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { if (mounted) { - setState(() => _progress = progress); + setState(() => _lineSlots[lineIndex].progress = progress); } }, - onPageStarted: (url) { - _shellCoverFallbackTimer?.cancel(); - final lineIndex = AppConfig.lineIndexForUrl(url); + onPageStarted: (_) { + final slot = _lineSlots[lineIndex]; + slot.shellCoverFallbackTimer?.cancel(); if (mounted) { setState(() { - if (lineIndex != null) { - _currentLineIndex = lineIndex; - } - _loadError = null; - _progress = 0; - _showShellCover = true; + slot.loadError = null; + slot.progress = 0; + slot.showShellCover = true; }); } }, onPageFinished: (_) { - unawaited(_handlePageFinished()); + unawaited(_handlePageFinished(lineIndex)); }, onWebResourceError: (error) { if (error.isForMainFrame ?? true) { - _shellCoverFallbackTimer?.cancel(); + final slot = _lineSlots[lineIndex]; + slot.shellCoverFallbackTimer?.cancel(); if (mounted) { setState(() { - _loadError = error.description; - _showShellCover = false; + slot.loadError = error.description; + slot.showShellCover = false; }); } } @@ -165,63 +166,63 @@ class _H5ShellPageState extends State { @override void dispose() { - _shellCoverFallbackTimer?.cancel(); + for (final slot in _lineSlots) { + slot.dispose(); + } super.dispose(); } - Future _runJavaScriptSafely(String source) async { + Future _runJavaScriptSafely(int lineIndex, String source) async { try { - await _controller.runJavaScript(source); + await _lineSlots[lineIndex].controller.runJavaScript(source); } catch (_) { // WebView can reject JavaScript while a page is still navigating. } } - String _freshHomeUrl() { - return AppConfig.homeUrl(lineIndex: _currentLineIndex); - } - - Future _handlePageFinished() async { + Future _handlePageFinished(int lineIndex) async { await _loadShellBrandingIfNeeded(); - await _syncShellBranding(); + await _syncShellBranding(lineIndex); if (mounted) { setState(() { - _progress = 100; + _lineSlots[lineIndex].progress = 100; }); } - _scheduleShellCoverFallback(); + _scheduleShellCoverFallback(lineIndex); } - void _handleShellMessage(JavaScriptMessage message) { + void _handleShellMessage(int lineIndex, JavaScriptMessage message) { try { final decoded = jsonDecode(message.message); if (decoded is Map && decoded['type'] == 'first-screen-ready') { - _hideShellCover(); + _hideShellCover(lineIndex); } } catch (_) { if (message.message == 'first-screen-ready') { - _hideShellCover(); + _hideShellCover(lineIndex); } } } - void _scheduleShellCoverFallback() { - _shellCoverFallbackTimer?.cancel(); - _shellCoverFallbackTimer = Timer( + void _scheduleShellCoverFallback(int lineIndex) { + final slot = _lineSlots[lineIndex]; + slot.shellCoverFallbackTimer?.cancel(); + slot.shellCoverFallbackTimer = Timer( const Duration(seconds: 4), - _hideShellCover, + () => _hideShellCover(lineIndex), ); } - void _hideShellCover() { - _shellCoverFallbackTimer?.cancel(); - if (!mounted || !_showShellCover) { + void _hideShellCover(int lineIndex) { + final slot = _lineSlots[lineIndex]; + slot.shellCoverFallbackTimer?.cancel(); + if (!mounted || !slot.showShellCover) { return; } setState(() { - _progress = 100; - _showShellCover = false; + slot.progress = 100; + slot.showShellCover = false; }); } @@ -242,7 +243,7 @@ class _H5ShellPageState extends State { }); } - Future _syncShellBranding() { + Future _syncShellBranding(int lineIndex) { final payload = jsonEncode({ 'name': _shellBranding.appName, 'logo': _shellBranding.appLogo, @@ -256,30 +257,44 @@ class _H5ShellPageState extends State { } catch (_) {} })(); '''; - return _runJavaScriptSafely(script); + return _runJavaScriptSafely(lineIndex, script); } - Future _loadHome() async { - await _loadUrl(_freshHomeUrl()); - } - - Future _loadUrl(String url) async { - final targetUrl = AppConfig.canonicalizeMainFrameUrl(url); - final lineIndex = AppConfig.lineIndexForUrl(targetUrl); + Future _ensureLineLoaded(int lineIndex) async { + final slot = _lineSlots[lineIndex]; + if (slot.hasLoadedInitialRequest) { + return; + } + slot.hasLoadedInitialRequest = true; if (mounted) { - _shellCoverFallbackTimer?.cancel(); + slot.shellCoverFallbackTimer?.cancel(); setState(() { - if (lineIndex != null) { - _currentLineIndex = lineIndex; - } - _loadError = null; - _progress = 0; - _showShellCover = true; + slot.loadError = null; + slot.progress = 0; + slot.showShellCover = true; }); } - await _controller.loadRequest(Uri.parse(targetUrl)); + await slot.controller.loadRequest(Uri.parse(slot.line.url)); + } + + Future _reloadCurrentLine() async { + final slot = _currentSlot; + slot.shellCoverFallbackTimer?.cancel(); + if (mounted) { + setState(() { + slot.loadError = null; + slot.progress = 0; + slot.showShellCover = true; + }); + } + + if (slot.hasLoadedInitialRequest) { + await slot.controller.reload(); + } else { + await _ensureLineLoaded(_currentLineIndex); + } } Future _loadLine(int index) async { @@ -290,7 +305,7 @@ class _H5ShellPageState extends State { }); } - await _loadUrl(_h5Lines[safeIndex].url); + await _ensureLineLoaded(safeIndex); } void _showLineSwitcher() { @@ -327,11 +342,6 @@ class _H5ShellPageState extends State { const webSchemes = {'http', 'https', 'about', 'data'}; if (webSchemes.contains(uri.scheme)) { - if (request.isMainFrame && - AppConfig.shouldRewriteMainFrameUrl(request.url)) { - unawaited(_loadUrl(AppConfig.canonicalizeMainFrameUrl(request.url))); - return NavigationDecision.prevent; - } return NavigationDecision.navigate; } @@ -345,8 +355,9 @@ class _H5ShellPageState extends State { } Future _handleBackNavigation() async { - if (await _controller.canGoBack()) { - await _controller.goBack(); + final controller = _currentSlot.controller; + if (await controller.canGoBack()) { + await controller.goBack(); } else { await SystemNavigator.pop(); } @@ -354,9 +365,11 @@ class _H5ShellPageState extends State { @override Widget build(BuildContext context) { + final currentSlot = _currentSlot; final bottomInset = MediaQuery.viewInsetsOf(context).bottom; - final showLineSwitch = - !_showShellCover && _loadError == null && bottomInset == 0; + final showLineSwitch = !currentSlot.showShellCover && + currentSlot.loadError == null && + bottomInset == 0; return PopScope( canPop: false, @@ -371,23 +384,38 @@ class _H5ShellPageState extends State { bottom: false, child: Stack( children: [ - Positioned.fill(child: _ShellFallback(progress: _progress)), - WebViewWidget(controller: _controller), - if (_showShellCover) + Positioned.fill( + child: _ShellFallback(progress: currentSlot.progress), + ), + Positioned.fill( + child: IndexedStack( + index: _currentLineIndex, + children: [ + for (var index = 0; index < _lineSlots.length; index += 1) + WebViewWidget( + key: ValueKey(index), + controller: _lineSlots[index].controller, + ), + ], + ), + ), + if (currentSlot.showShellCover) Positioned.fill( child: IgnorePointer( - child: _ShellFallback(progress: _progress), + child: _ShellFallback(progress: currentSlot.progress), ), ), - if (!_showShellCover && _progress < 100) + if (!currentSlot.showShellCover && currentSlot.progress < 100) LinearProgressIndicator( - value: _progress == 0 ? null : _progress / 100, + value: currentSlot.progress == 0 + ? null + : currentSlot.progress / 100, minHeight: 2, ), - if (_loadError != null) + if (currentSlot.loadError != null) _ErrorPanel( - message: _loadError!, - onRetry: () => unawaited(_loadHome()), + message: currentSlot.loadError!, + onRetry: () => unawaited(_reloadCurrentLine()), ), if (showLineSwitch) Positioned( @@ -407,6 +435,26 @@ class _H5ShellPageState extends State { } } +class _H5LineWebViewSlot { + _H5LineWebViewSlot({ + required this.line, + required this.controller, + }); + + final H5Line line; + final WebViewController controller; + + int progress = 0; + String? loadError; + bool showShellCover = true; + bool hasLoadedInitialRequest = false; + Timer? shellCoverFallbackTimer; + + void dispose() { + shellCoverFallbackTimer?.cancel(); + } +} + class _ErrorPanel extends StatelessWidget { const _ErrorPanel({required this.message, required this.onRetry}); diff --git a/test/widget_test.dart b/test/widget_test.dart index 26cf647..c44fdbf 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -68,7 +68,7 @@ void main() { isFalse); }); - test('keeps independent non-legacy H5 hosts when removing shell params', () { + test('keeps H5 runtime URLs untouched during shell routing', () { final uri = Uri.parse( AppConfig.canonicalizeMainFrameUrl( 'https://line-two.example/login?shell_app_name=Old' @@ -78,13 +78,13 @@ void main() { expect(uri.host, 'line-two.example'); expect(uri.path, '/login'); - expect(uri.queryParameters.containsKey('shell_app_name'), isFalse); - expect(uri.queryParameters.containsKey('flutter_shell'), isFalse); + expect(uri.queryParameters['shell_app_name'], 'Old'); + expect(uri.queryParameters['flutter_shell'], '1'); expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'), - isFalse); + isTrue); }); - test('rewrites legacy H5 host main-frame URLs to the canonical host', () { + test('does not rewrite H5 main-frame URLs for line switching', () { final uri = Uri.parse( AppConfig.canonicalizeMainFrameUrl( 'https://h5-test.imharry.work/login?from=runtime' @@ -96,8 +96,9 @@ void main() { expect(uri.host, 'h5-test.imharry.work'); expect(uri.path, '/login'); expect(uri.queryParameters['from'], 'runtime'); - expect(uri.queryParameters.containsKey('shell_app_name'), isFalse); + expect(uri.queryParameters['shell_app_name'], 'Old'); expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'), - isFalse); + isTrue); + expect(AppConfig.shouldRewriteMainFrameUrl(uri.toString()), isFalse); }); }