feat: 优化 H5 线路切换逻辑;实现独立 WebView 管理和状态控制
This commit is contained in:
@@ -8,7 +8,7 @@ https://h5-test.imharry.work/
|
||||
|
||||
## H5 线路切换
|
||||
|
||||
线路切换在 Flutter 套壳层完成,H5 页面不需要承载线路切换逻辑。每条线路都是一个独立 H5 地址,切换时 WebView 会直接加载被选中的地址。
|
||||
线路切换在 Flutter 套壳层完成,H5 页面不需要承载线路切换逻辑。每条线路对应一个独立 WebView,切换时只切换当前显示的 WebView,不会改写 H5 页面运行中的请求地址。
|
||||
|
||||
默认线路配置在:
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
212
lib/main.dart
212
lib/main.dart
@@ -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});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user