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 线路切换
线路切换在 Flutter 套壳层完成H5 页面不需要承载线路切换逻辑。每条线路都是一个独立 H5 地址,切换时 WebView 会直接加载被选中的地址。
线路切换在 Flutter 套壳层完成H5 页面不需要承载线路切换逻辑。每条线路对应一个独立 WebView切换时只切换当前显示的 WebView不会改写 H5 页面运行中的请求地址。
默认线路配置在:

View File

@@ -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;

View File

@@ -93,67 +93,68 @@ class H5ShellPage extends StatefulWidget {
}
class _H5ShellPageState extends State<H5ShellPage> {
late final WebViewController _controller;
late final List<H5Line> _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<H5ShellPage> {
@override
void dispose() {
_shellCoverFallbackTimer?.cancel();
for (final slot in _lineSlots) {
slot.dispose();
}
super.dispose();
}
Future<void> _runJavaScriptSafely(String source) async {
Future<void> _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<void> _handlePageFinished() async {
Future<void> _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<H5ShellPage> {
});
}
Future<void> _syncShellBranding() {
Future<void> _syncShellBranding(int lineIndex) {
final payload = jsonEncode({
'name': _shellBranding.appName,
'logo': _shellBranding.appLogo,
@@ -256,30 +257,44 @@ class _H5ShellPageState extends State<H5ShellPage> {
} catch (_) {}
})();
''';
return _runJavaScriptSafely(script);
return _runJavaScriptSafely(lineIndex, script);
}
Future<void> _loadHome() async {
await _loadUrl(_freshHomeUrl());
}
Future<void> _loadUrl(String url) async {
final targetUrl = AppConfig.canonicalizeMainFrameUrl(url);
final lineIndex = AppConfig.lineIndexForUrl(targetUrl);
Future<void> _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<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 {
@@ -290,7 +305,7 @@ class _H5ShellPageState extends State<H5ShellPage> {
});
}
await _loadUrl(_h5Lines[safeIndex].url);
await _ensureLineLoaded(safeIndex);
}
void _showLineSwitcher() {
@@ -327,11 +342,6 @@ class _H5ShellPageState extends State<H5ShellPage> {
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<H5ShellPage> {
}
Future<void> _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<H5ShellPage> {
@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<H5ShellPage> {
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<int>(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<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 {
const _ErrorPanel({required this.message, required this.onRetry});

View File

@@ -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);
});
}