feat: 优化 H5 线路切换逻辑;实现独立 WebView 管理和状态控制

This commit is contained in:
Booker
2026-05-27 13:03:50 +07:00
parent 8659a2e66e
commit 6e3920972b
4 changed files with 141 additions and 124 deletions

View File

@@ -8,7 +8,7 @@ https://h5-test.imharry.work/
## H5 线路切换 ## H5 线路切换
线路切换在 Flutter 套壳层完成H5 页面不需要承载线路切换逻辑。每条线路都是一个独立 H5 地址,切换时 WebView 会直接加载被选中的地址。 线路切换在 Flutter 套壳层完成H5 页面不需要承载线路切换逻辑。每条线路对应一个独立 WebView切换时只切换当前显示的 WebView不会改写 H5 页面运行中的请求地址。
默认线路配置在: 默认线路配置在:

View File

@@ -76,29 +76,11 @@ class AppConfig {
} }
static bool shouldRewriteMainFrameUrl(String url) { static bool shouldRewriteMainFrameUrl(String url) {
final uri = Uri.tryParse(url);
if (uri == null) {
return false; return false;
} }
final host = uri.host.toLowerCase();
return (legacyWebHosts.contains(host) && !_isConfiguredH5Host(host)) ||
_hasShellParams(uri);
}
static String canonicalizeMainFrameUrl(String url) { static String canonicalizeMainFrameUrl(String url) {
final uri = _removeShellParams(Uri.parse(url)); return url;
final host = uri.host.toLowerCase();
if (!legacyWebHosts.contains(host) || _isConfiguredH5Host(host)) {
return uri.toString();
}
return uri
.replace(
scheme: 'https',
host: canonicalWebHost,
)
.toString();
} }
static int? lineIndexForUrl(String 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) { static int _safeLineIndex(int index, int length) {
if (length <= 0 || index < 0 || index >= length) { if (length <= 0 || index < 0 || index >= length) {
return 0; return 0;
@@ -151,10 +123,6 @@ class AppConfig {
return index; return index;
} }
static bool _isConfiguredH5Host(String host) {
return h5Lines.any((line) => line.uri.host.toLowerCase() == host);
}
static bool _isSameLine(Uri lineUri, Uri uri) { static bool _isSameLine(Uri lineUri, Uri uri) {
if (lineUri.host.toLowerCase() != uri.host.toLowerCase()) { if (lineUri.host.toLowerCase() != uri.host.toLowerCase()) {
return false; return false;

View File

@@ -93,67 +93,68 @@ class H5ShellPage extends StatefulWidget {
} }
class _H5ShellPageState extends State<H5ShellPage> { class _H5ShellPageState extends State<H5ShellPage> {
late final WebViewController _controller;
late final List<H5Line> _h5Lines; late final List<H5Line> _h5Lines;
late final List<_H5LineWebViewSlot> _lineSlots;
int _progress = 0;
String? _loadError;
bool _showShellCover = true;
bool _shellBrandingLoaded = false; bool _shellBrandingLoaded = false;
int _currentLineIndex = 0; int _currentLineIndex = 0;
Timer? _shellCoverFallbackTimer;
late ShellBranding _shellBranding; late ShellBranding _shellBranding;
H5Line get _currentLine => _h5Lines[_currentLineIndex]; H5Line get _currentLine => _h5Lines[_currentLineIndex];
_H5LineWebViewSlot get _currentSlot => _lineSlots[_currentLineIndex];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_h5Lines = AppConfig.h5Lines; _h5Lines = AppConfig.h5Lines;
_shellBranding = widget.initialShellBranding; _shellBranding = widget.initialShellBranding;
_controller = _buildController(); _lineSlots = [
unawaited(_loadHome()); 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() return WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted) ..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel( ..addJavaScriptChannel(
'OpenIMShell', 'OpenIMShell',
onMessageReceived: _handleShellMessage, onMessageReceived: (message) => _handleShellMessage(lineIndex, message),
) )
..setBackgroundColor(_shellBackground) ..setBackgroundColor(_shellBackground)
..setNavigationDelegate( ..setNavigationDelegate(
NavigationDelegate( NavigationDelegate(
onProgress: (progress) { onProgress: (progress) {
if (mounted) { if (mounted) {
setState(() => _progress = progress); setState(() => _lineSlots[lineIndex].progress = progress);
} }
}, },
onPageStarted: (url) { onPageStarted: (_) {
_shellCoverFallbackTimer?.cancel(); final slot = _lineSlots[lineIndex];
final lineIndex = AppConfig.lineIndexForUrl(url); slot.shellCoverFallbackTimer?.cancel();
if (mounted) { if (mounted) {
setState(() { setState(() {
if (lineIndex != null) { slot.loadError = null;
_currentLineIndex = lineIndex; slot.progress = 0;
} slot.showShellCover = true;
_loadError = null;
_progress = 0;
_showShellCover = true;
}); });
} }
}, },
onPageFinished: (_) { onPageFinished: (_) {
unawaited(_handlePageFinished()); unawaited(_handlePageFinished(lineIndex));
}, },
onWebResourceError: (error) { onWebResourceError: (error) {
if (error.isForMainFrame ?? true) { if (error.isForMainFrame ?? true) {
_shellCoverFallbackTimer?.cancel(); final slot = _lineSlots[lineIndex];
slot.shellCoverFallbackTimer?.cancel();
if (mounted) { if (mounted) {
setState(() { setState(() {
_loadError = error.description; slot.loadError = error.description;
_showShellCover = false; slot.showShellCover = false;
}); });
} }
} }
@@ -165,63 +166,63 @@ class _H5ShellPageState extends State<H5ShellPage> {
@override @override
void dispose() { void dispose() {
_shellCoverFallbackTimer?.cancel(); for (final slot in _lineSlots) {
slot.dispose();
}
super.dispose(); super.dispose();
} }
Future<void> _runJavaScriptSafely(String source) async { Future<void> _runJavaScriptSafely(int lineIndex, String source) async {
try { try {
await _controller.runJavaScript(source); await _lineSlots[lineIndex].controller.runJavaScript(source);
} catch (_) { } catch (_) {
// WebView can reject JavaScript while a page is still navigating. // WebView can reject JavaScript while a page is still navigating.
} }
} }
String _freshHomeUrl() { Future<void> _handlePageFinished(int lineIndex) async {
return AppConfig.homeUrl(lineIndex: _currentLineIndex);
}
Future<void> _handlePageFinished() async {
await _loadShellBrandingIfNeeded(); await _loadShellBrandingIfNeeded();
await _syncShellBranding(); await _syncShellBranding(lineIndex);
if (mounted) { if (mounted) {
setState(() { setState(() {
_progress = 100; _lineSlots[lineIndex].progress = 100;
}); });
} }
_scheduleShellCoverFallback(); _scheduleShellCoverFallback(lineIndex);
} }
void _handleShellMessage(JavaScriptMessage message) { void _handleShellMessage(int lineIndex, JavaScriptMessage message) {
try { try {
final decoded = jsonDecode(message.message); final decoded = jsonDecode(message.message);
if (decoded is Map && decoded['type'] == 'first-screen-ready') { if (decoded is Map && decoded['type'] == 'first-screen-ready') {
_hideShellCover(); _hideShellCover(lineIndex);
} }
} catch (_) { } catch (_) {
if (message.message == 'first-screen-ready') { if (message.message == 'first-screen-ready') {
_hideShellCover(); _hideShellCover(lineIndex);
} }
} }
} }
void _scheduleShellCoverFallback() { void _scheduleShellCoverFallback(int lineIndex) {
_shellCoverFallbackTimer?.cancel(); final slot = _lineSlots[lineIndex];
_shellCoverFallbackTimer = Timer( slot.shellCoverFallbackTimer?.cancel();
slot.shellCoverFallbackTimer = Timer(
const Duration(seconds: 4), const Duration(seconds: 4),
_hideShellCover, () => _hideShellCover(lineIndex),
); );
} }
void _hideShellCover() { void _hideShellCover(int lineIndex) {
_shellCoverFallbackTimer?.cancel(); final slot = _lineSlots[lineIndex];
if (!mounted || !_showShellCover) { slot.shellCoverFallbackTimer?.cancel();
if (!mounted || !slot.showShellCover) {
return; return;
} }
setState(() { setState(() {
_progress = 100; slot.progress = 100;
_showShellCover = false; slot.showShellCover = false;
}); });
} }
@@ -242,7 +243,7 @@ class _H5ShellPageState extends State<H5ShellPage> {
}); });
} }
Future<void> _syncShellBranding() { Future<void> _syncShellBranding(int lineIndex) {
final payload = jsonEncode({ final payload = jsonEncode({
'name': _shellBranding.appName, 'name': _shellBranding.appName,
'logo': _shellBranding.appLogo, 'logo': _shellBranding.appLogo,
@@ -256,30 +257,44 @@ class _H5ShellPageState extends State<H5ShellPage> {
} catch (_) {} } catch (_) {}
})(); })();
'''; ''';
return _runJavaScriptSafely(script); return _runJavaScriptSafely(lineIndex, script);
} }
Future<void> _loadHome() async { Future<void> _ensureLineLoaded(int lineIndex) async {
await _loadUrl(_freshHomeUrl()); final slot = _lineSlots[lineIndex];
if (slot.hasLoadedInitialRequest) {
return;
} }
Future<void> _loadUrl(String url) async { slot.hasLoadedInitialRequest = true;
final targetUrl = AppConfig.canonicalizeMainFrameUrl(url);
final lineIndex = AppConfig.lineIndexForUrl(targetUrl);
if (mounted) { if (mounted) {
_shellCoverFallbackTimer?.cancel(); slot.shellCoverFallbackTimer?.cancel();
setState(() { setState(() {
if (lineIndex != null) { slot.loadError = null;
_currentLineIndex = lineIndex; slot.progress = 0;
} slot.showShellCover = true;
_loadError = null;
_progress = 0;
_showShellCover = true;
}); });
} }
await _controller.loadRequest(Uri.parse(targetUrl)); await slot.controller.loadRequest(Uri.parse(slot.line.url));
}
Future<void> _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<void> _loadLine(int index) async { Future<void> _loadLine(int index) async {
@@ -290,7 +305,7 @@ class _H5ShellPageState extends State<H5ShellPage> {
}); });
} }
await _loadUrl(_h5Lines[safeIndex].url); await _ensureLineLoaded(safeIndex);
} }
void _showLineSwitcher() { void _showLineSwitcher() {
@@ -327,11 +342,6 @@ class _H5ShellPageState extends State<H5ShellPage> {
const webSchemes = {'http', 'https', 'about', 'data'}; const webSchemes = {'http', 'https', 'about', 'data'};
if (webSchemes.contains(uri.scheme)) { if (webSchemes.contains(uri.scheme)) {
if (request.isMainFrame &&
AppConfig.shouldRewriteMainFrameUrl(request.url)) {
unawaited(_loadUrl(AppConfig.canonicalizeMainFrameUrl(request.url)));
return NavigationDecision.prevent;
}
return NavigationDecision.navigate; return NavigationDecision.navigate;
} }
@@ -345,8 +355,9 @@ class _H5ShellPageState extends State<H5ShellPage> {
} }
Future<void> _handleBackNavigation() async { Future<void> _handleBackNavigation() async {
if (await _controller.canGoBack()) { final controller = _currentSlot.controller;
await _controller.goBack(); if (await controller.canGoBack()) {
await controller.goBack();
} else { } else {
await SystemNavigator.pop(); await SystemNavigator.pop();
} }
@@ -354,9 +365,11 @@ class _H5ShellPageState extends State<H5ShellPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentSlot = _currentSlot;
final bottomInset = MediaQuery.viewInsetsOf(context).bottom; final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
final showLineSwitch = final showLineSwitch = !currentSlot.showShellCover &&
!_showShellCover && _loadError == null && bottomInset == 0; currentSlot.loadError == null &&
bottomInset == 0;
return PopScope( return PopScope(
canPop: false, canPop: false,
@@ -371,23 +384,38 @@ class _H5ShellPageState extends State<H5ShellPage> {
bottom: false, bottom: false,
child: Stack( child: Stack(
children: [ children: [
Positioned.fill(child: _ShellFallback(progress: _progress)), Positioned.fill(
WebViewWidget(controller: _controller), child: _ShellFallback(progress: currentSlot.progress),
if (_showShellCover) ),
Positioned.fill(
child: IndexedStack(
index: _currentLineIndex,
children: [
for (var index = 0; index < _lineSlots.length; index += 1)
WebViewWidget(
key: ValueKey<int>(index),
controller: _lineSlots[index].controller,
),
],
),
),
if (currentSlot.showShellCover)
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
child: _ShellFallback(progress: _progress), child: _ShellFallback(progress: currentSlot.progress),
), ),
), ),
if (!_showShellCover && _progress < 100) if (!currentSlot.showShellCover && currentSlot.progress < 100)
LinearProgressIndicator( LinearProgressIndicator(
value: _progress == 0 ? null : _progress / 100, value: currentSlot.progress == 0
? null
: currentSlot.progress / 100,
minHeight: 2, minHeight: 2,
), ),
if (_loadError != null) if (currentSlot.loadError != null)
_ErrorPanel( _ErrorPanel(
message: _loadError!, message: currentSlot.loadError!,
onRetry: () => unawaited(_loadHome()), onRetry: () => unawaited(_reloadCurrentLine()),
), ),
if (showLineSwitch) if (showLineSwitch)
Positioned( Positioned(
@@ -407,6 +435,26 @@ class _H5ShellPageState extends State<H5ShellPage> {
} }
} }
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 { class _ErrorPanel extends StatelessWidget {
const _ErrorPanel({required this.message, required this.onRetry}); const _ErrorPanel({required this.message, required this.onRetry});

View File

@@ -68,7 +68,7 @@ void main() {
isFalse); 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( final uri = Uri.parse(
AppConfig.canonicalizeMainFrameUrl( AppConfig.canonicalizeMainFrameUrl(
'https://line-two.example/login?shell_app_name=Old' 'https://line-two.example/login?shell_app_name=Old'
@@ -78,13 +78,13 @@ void main() {
expect(uri.host, 'line-two.example'); expect(uri.host, 'line-two.example');
expect(uri.path, '/login'); expect(uri.path, '/login');
expect(uri.queryParameters.containsKey('shell_app_name'), isFalse); expect(uri.queryParameters['shell_app_name'], 'Old');
expect(uri.queryParameters.containsKey('flutter_shell'), isFalse); expect(uri.queryParameters['flutter_shell'], '1');
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'), 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( final uri = Uri.parse(
AppConfig.canonicalizeMainFrameUrl( AppConfig.canonicalizeMainFrameUrl(
'https://h5-test.imharry.work/login?from=runtime' 'https://h5-test.imharry.work/login?from=runtime'
@@ -96,8 +96,9 @@ void main() {
expect(uri.host, 'h5-test.imharry.work'); expect(uri.host, 'h5-test.imharry.work');
expect(uri.path, '/login'); expect(uri.path, '/login');
expect(uri.queryParameters['from'], 'runtime'); 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'), expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
isFalse); isTrue);
expect(AppConfig.shouldRewriteMainFrameUrl(uri.toString()), isFalse);
}); });
} }