feat: 优化 H5 URL 生成逻辑,移除品牌参数;更新测试用例以反映更改

This commit is contained in:
Booker
2026-05-26 20:09:38 +07:00
parent f47895d893
commit 2eef27a0e1
3 changed files with 20 additions and 411 deletions

View File

@@ -21,39 +21,25 @@ class AppConfig {
final host = environmentHosts.isNotEmpty
? environmentHosts.first
: 'h5-im.imharry.work';
return withFreshShellParams(
_normalizeHomeUrl(host),
appName: appName,
appLogo: appLogo,
);
return withFreshShellParams(_normalizeHomeUrl(host));
}
static String withFreshShellParams(
String url, {
String? appName,
String? appLogo,
}) {
static String withFreshShellParams(String url) {
final uri = Uri.parse(url);
final queryParameters = Map<String, String>.from(uri.queryParameters)
..['flutter_shell'] = '1'
..['shell_cache_bust'] = DateTime.now().millisecondsSinceEpoch.toString();
queryParameters.remove('shell_app_name');
queryParameters.remove('shell_app_logo');
final fragmentParameters = Uri.splitQueryString(uri.fragment);
final trimmedName = (appName ?? AppConfig.appName).trim();
if (trimmedName.isNotEmpty) {
queryParameters['shell_app_name'] = trimmedName;
}
final trimmedLogo = (appLogo ?? AppConfig.appLogo).trim();
if (trimmedLogo.isNotEmpty) {
fragmentParameters['shell_app_logo'] = trimmedLogo;
}
fragmentParameters.remove('shell_app_name');
fragmentParameters.remove('shell_app_logo');
return uri
.replace(
queryParameters: queryParameters,
fragment: fragmentParameters.isEmpty
? null
? ''
: Uri(queryParameters: fragmentParameters).query,
)
.toString();

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -16,8 +15,6 @@ const _shellBackground = Color(0xFFF8FBFF);
const _shellAccent = Color(0xFF0089FF);
const _shellSubText = Color(0xFF8E9AB0);
const _resumeCoverDuration = Duration(milliseconds: 700);
const _urlOnlyShellMode = true;
const _urlOnlyWebViewUrl = 'https://h5-im.imharry.work/';
const _noCacheHeaders = {
'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache',
@@ -27,87 +24,6 @@ const _shellBrandingChannel =
MethodChannel('io.openim.flutter.im_webview_app/shell_branding');
const _shellWebViewCacheChannel =
MethodChannel('io.openim.flutter.im_webview_app/webview_cache');
const _runtimeCachePurgeFallbackDuration = Duration(seconds: 2);
const _purgeWebRuntimeCacheScript = r'''
(() => {
if (window.__openimShellRuntimeCachePurgeStarted) {
return;
}
window.__openimShellRuntimeCachePurgeStarted = true;
const targetUrl = () => {
try {
const target = new URL(window.location.href);
target.searchParams.set('shell_cache_bust', Date.now().toString());
target.searchParams.set('shell_runtime_cache_purged', '1');
return target.toString();
} catch (_) {
return window.location.href;
}
};
const notifyFlutter = (payload) => {
const channel = window.OpenIMFlutterShell;
if (channel && typeof channel.postMessage === 'function') {
channel.postMessage(JSON.stringify(payload));
return true;
}
return false;
};
const purgeRuntimeCache = async () => {
const result = {
cacheStorageKeys: 0,
serviceWorkerRegistrations: 0,
};
try {
if ('caches' in window && caches.keys) {
const keys = await caches.keys();
result.cacheStorageKeys = keys.length;
await Promise.all(keys.map((key) => caches.delete(key)));
}
} catch (_) {}
try {
if (
navigator.serviceWorker &&
typeof navigator.serviceWorker.getRegistrations === 'function'
) {
const registrations = await navigator.serviceWorker.getRegistrations();
result.serviceWorkerRegistrations = registrations.length;
await Promise.all(
registrations.map((registration) => registration.unregister())
);
}
} catch (_) {}
return result;
};
purgeRuntimeCache()
.then((result) => {
const payload = {
type: 'runtimeCachePurged',
url: targetUrl(),
result,
};
if (!notifyFlutter(payload)) {
window.location.replace(payload.url);
}
})
.catch(() => {
const payload = {
type: 'runtimeCachePurged',
url: targetUrl(),
};
if (!notifyFlutter(payload)) {
window.location.replace(payload.url);
}
});
})();
''';
const _inspectH5SnapshotScript = r'''
(() => {
const toAbsoluteUrl = (value) => {
@@ -265,10 +181,6 @@ Future<void> main() async {
systemNavigationBarIconBrightness: Brightness.dark,
),
);
if (_urlOnlyShellMode) {
runApp(const UrlOnlyWebViewApp());
return;
}
final shellBranding = await ShellBranding.load();
runApp(ImWebViewApp(shellBranding: shellBranding));
}
@@ -306,121 +218,6 @@ class ShellBranding {
static String _trim(String? value) => value?.trim() ?? '';
}
class UrlOnlyWebViewApp extends StatelessWidget {
const UrlOnlyWebViewApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: _urlOnlyWebViewUrl,
debugShowCheckedModeBanner: false,
theme: ThemeData(scaffoldBackgroundColor: Colors.white),
home: const UrlOnlyWebViewPage(),
);
}
}
class UrlOnlyWebViewPage extends StatefulWidget {
const UrlOnlyWebViewPage({super.key});
@override
State<UrlOnlyWebViewPage> createState() => _UrlOnlyWebViewPageState();
}
class _UrlOnlyWebViewPageState extends State<UrlOnlyWebViewPage> {
late final WebViewController _controller;
int _progress = 0;
String? _loadError;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.white)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (progress) {
if (mounted) {
setState(() => _progress = progress);
}
},
onPageStarted: (_) {
if (mounted) {
setState(() {
_loadError = null;
_progress = 0;
});
}
},
onPageFinished: (url) {
debugPrint('[UrlOnlyWebView] finished: $url');
if (mounted) {
setState(() => _progress = 100);
}
},
onWebResourceError: (error) {
if (error.isForMainFrame ?? true) {
if (mounted) {
setState(() => _loadError = error.description);
}
}
},
onNavigationRequest: _handleUrlOnlyNavigationRequest,
),
)
..loadRequest(Uri.parse(_urlOnlyWebViewUrl));
}
Future<NavigationDecision> _handleUrlOnlyNavigationRequest(
NavigationRequest request,
) async {
final uri = Uri.tryParse(request.url);
if (uri == null) {
return NavigationDecision.prevent;
}
const webSchemes = {'http', 'https', 'about', 'data'};
if (webSchemes.contains(uri.scheme)) {
return NavigationDecision.navigate;
}
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {}
return NavigationDecision.prevent;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
bottom: false,
child: Stack(
children: [
WebViewWidget(controller: _controller),
if (_progress < 100)
LinearProgressIndicator(
value: _progress == 0 ? null : _progress / 100,
minHeight: 2,
),
if (_loadError != null)
_ErrorPanel(
message: _loadError!,
onRetry: () {
setState(() => _loadError = null);
_controller.loadRequest(Uri.parse(_urlOnlyWebViewUrl));
},
),
],
),
),
);
}
}
class ImWebViewApp extends StatelessWidget {
const ImWebViewApp({
super.key,
@@ -459,11 +256,7 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
int _progress = 0;
String? _loadError;
bool _showShellCover = true;
bool _runtimeCachePurgeStarted = false;
bool _runtimeCachePurgeReloaded = false;
String? _h5SnapshotDebugText;
Timer? _shellCoverTimer;
Timer? _runtimeCachePurgeFallbackTimer;
@override
void initState() {
@@ -476,7 +269,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
@override
void dispose() {
_shellCoverTimer?.cancel();
_runtimeCachePurgeFallbackTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@@ -596,10 +388,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
if (type == 'openAppSettings') {
unawaited(openAppSettings());
}
if (type == 'runtimeCachePurged' && payload != null) {
debugPrint('[H5Shell] runtime cache purged: ${jsonEncode(payload)}');
unawaited(_reloadAfterRuntimeCachePurge(payload['url'] as String?));
}
if (type == 'requestMediaPermissions' && payload != null) {
unawaited(_handleMediaPermissionBridgeRequest(payload));
}
@@ -665,37 +453,11 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
}
String _freshHomeUrl() {
return AppConfig.homeUrl(
appName: widget.shellBranding.appName,
appLogo: widget.shellBranding.appLogo,
);
}
String _freshShellUrl(String? url) {
final value = url?.trim();
if (value == null || value.isEmpty) {
return _freshHomeUrl();
}
try {
return AppConfig.withFreshShellParams(
value,
appName: widget.shellBranding.appName,
appLogo: widget.shellBranding.appLogo,
);
} catch (_) {
return _freshHomeUrl();
}
return AppConfig.homeUrl();
}
Future<void> _handlePageFinished() async {
final waitingForRuntimeCacheReload =
await _purgeWebRuntimeCacheAndReloadIfNeeded();
if (waitingForRuntimeCacheReload) {
return;
}
unawaited(_syncShellBranding());
await _syncShellBranding();
unawaited(_injectShellMediaPermissionBridge());
unawaited(_logLoadedH5Snapshot());
if (mounted) {
@@ -706,56 +468,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
}
}
Future<bool> _purgeWebRuntimeCacheAndReloadIfNeeded() async {
if (_runtimeCachePurgeStarted) {
return false;
}
_runtimeCachePurgeStarted = true;
try {
await _controller.runJavaScript(_purgeWebRuntimeCacheScript);
} catch (_) {
_runtimeCachePurgeStarted = false;
return false;
}
_runtimeCachePurgeFallbackTimer?.cancel();
_runtimeCachePurgeFallbackTimer = Timer(
_runtimeCachePurgeFallbackDuration,
() {
if (!_runtimeCachePurgeReloaded) {
unawaited(_reloadAfterRuntimeCachePurge(null));
}
},
);
return true;
}
Future<void> _reloadAfterRuntimeCachePurge(String? url) async {
if (_runtimeCachePurgeReloaded) {
return;
}
_runtimeCachePurgeReloaded = true;
_runtimeCachePurgeFallbackTimer?.cancel();
if (mounted) {
setState(() {
_loadError = null;
_progress = 0;
_showShellCover = true;
});
}
String? currentUrl;
if (url == null || url.trim().isEmpty) {
try {
currentUrl = await _controller.currentUrl();
} catch (_) {}
}
await _loadUrl(_freshShellUrl(url ?? currentUrl));
}
Future<void> _prepareWebViewForFreshLoad() async {
await _configureAndroidNoCache();
try {
@@ -815,61 +527,11 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
debugPrint(
'[H5Shell] loaded H5 snapshot: $snapshot',
);
_updateH5SnapshotDebugText(snapshot);
} catch (error) {
debugPrint('[H5Shell] loaded H5 snapshot failed: $error');
}
}
void _updateH5SnapshotDebugText(String snapshot) {
if (!kDebugMode || !mounted) {
return;
}
setState(() {
_h5SnapshotDebugText = _formatH5SnapshotDebugText(snapshot);
});
}
String _formatH5SnapshotDebugText(String snapshot) {
try {
final decoded = jsonDecode(snapshot);
if (decoded is! Map) {
return snapshot;
}
final href = (decoded['href'] as String? ?? '').trim();
final title = (decoded['title'] as String? ?? '').trim();
final bodyText = (decoded['bodyText'] as String? ?? '').trim();
final scripts = decoded['scripts'];
final links = decoded['links'];
final assets = <String>[
if (scripts is List)
...scripts.whereType<String>().where((value) => value.isNotEmpty),
if (links is List)
...links.whereType<String>().where((value) => value.isNotEmpty),
];
final importantAssets = assets
.where(
(asset) =>
asset.contains('/assets/index-') ||
asset.contains('LoginBrandHeader') ||
asset.contains('useLoginBranding'),
)
.take(8)
.join('\n');
return [
if (title.isNotEmpty) 'title: $title',
if (href.isNotEmpty) 'url: $href',
if (importantAssets.isNotEmpty) 'assets:\n$importantAssets',
if (bodyText.isNotEmpty) 'text: $bodyText',
].join('\n');
} catch (_) {
return snapshot;
}
}
String _decodeJavaScriptStringResult(Object? result) {
if (result == null) {
return '';
@@ -1038,15 +700,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
message: _loadError!,
onRetry: () => unawaited(_loadHome()),
),
if (kDebugMode && _h5SnapshotDebugText != null)
Positioned(
left: 8,
right: 8,
bottom: 12,
child: IgnorePointer(
child: _H5SnapshotDebugPanel(text: _h5SnapshotDebugText!),
),
),
],
),
),
@@ -1055,36 +708,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
}
}
class _H5SnapshotDebugPanel extends StatelessWidget {
const _H5SnapshotDebugPanel({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
text,
maxLines: 10,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
height: 1.25,
letterSpacing: 0,
),
),
),
);
}
}
class _ErrorPanel extends StatelessWidget {
const _ErrorPanel({required this.message, required this.onRetry});

View File

@@ -11,7 +11,7 @@ void main() {
);
});
test('loads the configured H5 root URL with shell branding', () {
test('loads the configured H5 root URL with shell launch params', () {
const logo = 'data:image/png;base64,abc+/=';
final uri = Uri.parse(
@@ -22,29 +22,29 @@ void main() {
expect(uri.host, 'h5-im.imharry.work');
expect(uri.path, '/');
expect(uri.queryParameters['flutter_shell'], '1');
expect(uri.queryParameters['shell_app_name'], 'Shell Test');
expect(uri.queryParameters.containsKey('shell_app_name'), isFalse);
expect(uri.queryParameters.containsKey('shell_app_logo'), isFalse);
expect(uri.queryParameters['shell_cache_bust'], isNotEmpty);
expect(uri.toString(), isNot(contains('%25')));
expect(Uri.splitQueryString(uri.fragment)['shell_app_logo'], logo);
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
isFalse);
});
test('keeps shell branding when refreshing an H5 route URL', () {
const logo = 'data:image/png;base64,routeLogo+/=';
test('refreshes an H5 route URL without adding branding to the URL', () {
final uri = Uri.parse(
AppConfig.withFreshShellParams(
'https://h5-im.imharry.work/login?from=runtime',
appName: 'Shell Route',
appLogo: logo,
'https://h5-im.imharry.work/login?from=runtime&shell_app_name=Old'
'#shell_app_logo=old',
),
);
expect(uri.path, '/login');
expect(uri.queryParameters['from'], 'runtime');
expect(uri.queryParameters['flutter_shell'], '1');
expect(uri.queryParameters['shell_app_name'], 'Shell Route');
expect(uri.queryParameters.containsKey('shell_app_name'), isFalse);
expect(uri.queryParameters.containsKey('shell_app_logo'), isFalse);
expect(uri.queryParameters['shell_cache_bust'], isNotEmpty);
expect(Uri.splitQueryString(uri.fragment)['shell_app_logo'], logo);
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
isFalse);
});
}