feat: 添加 H5 线路切换功能;支持动态加载和切换不同线路的 URL

This commit is contained in:
Booker
2026-05-27 12:53:47 +07:00
parent e5d3c12c15
commit 8659a2e66e
4 changed files with 472 additions and 10 deletions

View File

@@ -6,6 +6,22 @@ Flutter WebView 套壳 App默认加载
https://h5-test.imharry.work/
```
## H5 线路切换
线路切换在 Flutter 套壳层完成H5 页面不需要承载线路切换逻辑。每条线路都是一个独立 H5 地址,切换时 WebView 会直接加载被选中的地址。
默认线路配置在:
```text
openim_common/lib/src/config.dart
```
也可以在打包时覆盖:
```bash
flutter build apk --release --dart-define=H5_LINE_URLS=https://h5-one.example/,https://h5-two.example/
```
## 本地打包
```bash

View File

@@ -1,12 +1,35 @@
import 'package:openim_common/openim_common.dart' as openim_common;
enum Environment {
production,
}
class H5Line {
const H5Line({
required this.label,
required this.url,
});
final String label;
final String url;
Uri get uri => Uri.parse(url);
String get displayAddress {
final parsed = uri;
final host = parsed.hasPort ? '${parsed.host}:${parsed.port}' : parsed.host;
final path = parsed.path.isEmpty || parsed.path == '/' ? '' : parsed.path;
return '$host$path';
}
}
class AppConfig {
static const Environment currentEnvironment = Environment.production;
static const String appName = '本地打包';
static const String appLogo = '';
static const String canonicalWebHost = 'h5-test.imharry.work';
static const String _dartDefinedH5LineUrls =
String.fromEnvironment('H5_LINE_URLS');
static const Set<String> legacyWebHosts = {
'h5-test.imharry.work',
};
@@ -18,14 +41,34 @@ class AppConfig {
};
static List<String> get environmentHosts {
return _environmentHosts[currentEnvironment] ?? const [];
final generatedHosts = _normalizedUniqueUrls(
openim_common.Config.environmentHosts,
);
if (generatedHosts.isNotEmpty) {
return generatedHosts;
}
return _normalizedUniqueUrls(_environmentHosts[currentEnvironment] ?? []);
}
static String homeUrl({String? appName, String? appLogo}) {
final host =
environmentHosts.isNotEmpty ? environmentHosts.first : canonicalWebHost;
final uri = Uri.parse(_normalizeHomeUrl(host));
return uri.toString();
static List<H5Line> get h5Lines {
final configuredUrls = _dartDefinedH5LineUrls.trim().isEmpty
? environmentHosts
: _normalizedUniqueUrls(_dartDefinedH5LineUrls.split(','));
final urls = configuredUrls.isEmpty
? _normalizedUniqueUrls(const [canonicalWebHost])
: configuredUrls;
return [
for (var index = 0; index < urls.length; index += 1)
H5Line(label: '线路${index + 1}', url: urls[index]),
];
}
static String homeUrl({int lineIndex = 0, String? appName, String? appLogo}) {
final lines = h5Lines;
final safeIndex = _safeLineIndex(lineIndex, lines.length);
return lines[safeIndex].url;
}
static String withFreshShellParams(String url) {
@@ -39,13 +82,14 @@ class AppConfig {
}
final host = uri.host.toLowerCase();
return legacyWebHosts.contains(host) || _hasShellParams(uri);
return (legacyWebHosts.contains(host) && !_isConfiguredH5Host(host)) ||
_hasShellParams(uri);
}
static String canonicalizeMainFrameUrl(String url) {
final uri = _removeShellParams(Uri.parse(url));
final host = uri.host.toLowerCase();
if (!legacyWebHosts.contains(host)) {
if (!legacyWebHosts.contains(host) || _isConfiguredH5Host(host)) {
return uri.toString();
}
@@ -57,6 +101,21 @@ class AppConfig {
.toString();
}
static int? lineIndexForUrl(String url) {
final uri = Uri.tryParse(url);
if (uri == null) {
return null;
}
final lines = h5Lines;
for (var index = 0; index < lines.length; index += 1) {
if (_isSameLine(lines[index].uri, uri)) {
return index;
}
}
return null;
}
static Uri _removeShellParams(Uri uri) {
final queryParameters = Map<String, String>.from(uri.queryParameters);
queryParameters.remove('flutter_shell');
@@ -85,8 +144,70 @@ class AppConfig {
fragmentParameters.containsKey('shell_app_logo');
}
static int _safeLineIndex(int index, int length) {
if (length <= 0 || index < 0 || index >= length) {
return 0;
}
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;
}
if (lineUri.hasPort && lineUri.port != uri.port) {
return false;
}
if (lineUri.scheme.isNotEmpty && lineUri.scheme != uri.scheme) {
return false;
}
final linePath = _pathWithTrailingSlash(lineUri.path);
final uriPath = _pathWithTrailingSlash(uri.path);
return linePath == '/' || uriPath.startsWith(linePath);
}
static String _pathWithTrailingSlash(String path) {
if (path.isEmpty) {
return '/';
}
return path.endsWith('/') ? path : '$path/';
}
static List<String> _normalizedUniqueUrls(Iterable<String> values) {
final urls = <String>[];
final seen = <String>{};
for (final value in values) {
final normalized = _tryNormalizeHomeUrl(value);
if (normalized == null || seen.contains(normalized)) {
continue;
}
urls.add(normalized);
seen.add(normalized);
}
return List.unmodifiable(urls);
}
static String? _tryNormalizeHomeUrl(String value) {
try {
return _normalizeHomeUrl(value);
} catch (_) {
return null;
}
}
static String _normalizeHomeUrl(String host) {
final value = host.trim();
if (value.isEmpty) {
throw const FormatException('Empty H5 line URL');
}
final normalized =
value.startsWith('http://') || value.startsWith('https://')
? value

View File

@@ -94,17 +94,22 @@ class H5ShellPage extends StatefulWidget {
class _H5ShellPageState extends State<H5ShellPage> {
late final WebViewController _controller;
late final List<H5Line> _h5Lines;
int _progress = 0;
String? _loadError;
bool _showShellCover = true;
bool _shellBrandingLoaded = false;
int _currentLineIndex = 0;
Timer? _shellCoverFallbackTimer;
late ShellBranding _shellBranding;
H5Line get _currentLine => _h5Lines[_currentLineIndex];
@override
void initState() {
super.initState();
_h5Lines = AppConfig.h5Lines;
_shellBranding = widget.initialShellBranding;
_controller = _buildController();
unawaited(_loadHome());
@@ -125,10 +130,14 @@ class _H5ShellPageState extends State<H5ShellPage> {
setState(() => _progress = progress);
}
},
onPageStarted: (_) {
onPageStarted: (url) {
_shellCoverFallbackTimer?.cancel();
final lineIndex = AppConfig.lineIndexForUrl(url);
if (mounted) {
setState(() {
if (lineIndex != null) {
_currentLineIndex = lineIndex;
}
_loadError = null;
_progress = 0;
_showShellCover = true;
@@ -169,7 +178,7 @@ class _H5ShellPageState extends State<H5ShellPage> {
}
String _freshHomeUrl() {
return AppConfig.homeUrl();
return AppConfig.homeUrl(lineIndex: _currentLineIndex);
}
Future<void> _handlePageFinished() async {
@@ -256,10 +265,14 @@ class _H5ShellPageState extends State<H5ShellPage> {
Future<void> _loadUrl(String url) async {
final targetUrl = AppConfig.canonicalizeMainFrameUrl(url);
final lineIndex = AppConfig.lineIndexForUrl(targetUrl);
if (mounted) {
_shellCoverFallbackTimer?.cancel();
setState(() {
if (lineIndex != null) {
_currentLineIndex = lineIndex;
}
_loadError = null;
_progress = 0;
_showShellCover = true;
@@ -269,6 +282,41 @@ class _H5ShellPageState extends State<H5ShellPage> {
await _controller.loadRequest(Uri.parse(targetUrl));
}
Future<void> _loadLine(int index) async {
final safeIndex = index < 0 || index >= _h5Lines.length ? 0 : index;
if (mounted) {
setState(() {
_currentLineIndex = safeIndex;
});
}
await _loadUrl(_h5Lines[safeIndex].url);
}
void _showLineSwitcher() {
showModalBottomSheet<void>(
context: context,
useSafeArea: true,
showDragHandle: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return _LineSwitcherSheet(
lines: _h5Lines,
currentIndex: _currentLineIndex,
onSelect: (index) {
Navigator.of(sheetContext).pop();
if (index != _currentLineIndex) {
unawaited(_loadLine(index));
}
},
);
},
);
}
Future<NavigationDecision> _handleNavigationRequest(
NavigationRequest request,
) async {
@@ -306,6 +354,10 @@ class _H5ShellPageState extends State<H5ShellPage> {
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
final showLineSwitch =
!_showShellCover && _loadError == null && bottomInset == 0;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
@@ -337,6 +389,16 @@ class _H5ShellPageState extends State<H5ShellPage> {
message: _loadError!,
onRetry: () => unawaited(_loadHome()),
),
if (showLineSwitch)
Positioned(
left: 20,
right: 20,
bottom: MediaQuery.paddingOf(context).bottom + 14,
child: _LineSwitchBar(
currentLine: _currentLine,
onSwitch: _showLineSwitcher,
),
),
],
),
),
@@ -386,6 +448,233 @@ class _ErrorPanel extends StatelessWidget {
}
}
class _LineSwitchBar extends StatelessWidget {
const _LineSwitchBar({
required this.currentLine,
required this.onSwitch,
});
final H5Line currentLine;
final VoidCallback onSwitch;
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: SizedBox(
width: double.infinity,
child: Material(
color: Colors.white.withValues(alpha: 0.94),
elevation: 8,
shadowColor: const Color(0xFF0F4C81).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(32),
child: InkWell(
onTap: onSwitch,
borderRadius: BorderRadius.circular(32),
child: Container(
height: 64,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
border: Border.all(color: const Color(0xFFE1EAF5)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.sync_rounded,
size: 19,
color: _shellSubText,
),
const SizedBox(width: 8),
const Text(
'当前: ',
style: TextStyle(
color: _shellSubText,
fontSize: 15,
height: 1.2,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
),
Flexible(
child: Text(
currentLine.label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF17233D),
fontSize: 16,
height: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: 0,
),
),
),
const SizedBox(width: 12),
const Text(
'切换',
style: TextStyle(
color: _shellAccent,
fontSize: 15,
height: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: 0,
),
),
],
),
),
),
),
),
),
);
}
}
class _LineSwitcherSheet extends StatelessWidget {
const _LineSwitcherSheet({
required this.lines,
required this.currentIndex,
required this.onSelect,
});
final List<H5Line> lines;
final int currentIndex;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final sheetHeight = MediaQuery.sizeOf(context).height * 0.58;
final maxHeight = sheetHeight > 420 ? 420.0 : sheetHeight;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'切换线路',
style: TextStyle(
color: Color(0xFF17233D),
fontSize: 18,
height: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: 0,
),
),
const SizedBox(height: 14),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: ListView.separated(
shrinkWrap: true,
itemCount: lines.length,
separatorBuilder: (context, index) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final line = lines[index];
final selected = index == currentIndex;
return _LineOptionTile(
line: line,
selected: selected,
onTap: () => onSelect(index),
);
},
),
),
],
),
);
}
}
class _LineOptionTile extends StatelessWidget {
const _LineOptionTile({
required this.line,
required this.selected,
required this.onTap,
});
final H5Line line;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: selected ? const Color(0xFFEFF7FF) : Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: selected ? _shellAccent : const Color(0xFFE1EAF5),
),
),
child: Row(
children: [
Icon(
selected
? Icons.radio_button_checked_rounded
: Icons.radio_button_unchecked_rounded,
color: selected ? _shellAccent : _shellSubText,
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
line.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF17233D),
fontSize: 16,
height: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: 0,
),
),
const SizedBox(height: 4),
Text(
line.displayAddress,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: _shellSubText,
fontSize: 12,
height: 1.2,
fontWeight: FontWeight.w500,
letterSpacing: 0,
),
),
],
),
),
const SizedBox(width: 8),
const Icon(
Icons.chevron_right_rounded,
color: _shellSubText,
size: 22,
),
],
),
),
),
);
}
}
class _ShellFallback extends StatelessWidget {
const _ShellFallback({required this.progress});

View File

@@ -30,6 +30,26 @@ void main() {
isFalse);
});
test('exposes configured H5 URLs as Flutter shell lines', () {
final lines = AppConfig.h5Lines;
expect(lines, isNotEmpty);
expect(lines.first.label, '线路1');
expect(lines.first.url, AppConfig.homeUrl(lineIndex: 0));
expect(Uri.parse(lines.first.url).host, 'h5-test.imharry.work');
});
test('falls back to the first H5 line for invalid line indexes', () {
expect(AppConfig.homeUrl(lineIndex: -1), AppConfig.h5Lines.first.url);
expect(AppConfig.homeUrl(lineIndex: 99), AppConfig.h5Lines.first.url);
});
test('matches runtime URLs back to their Flutter shell line', () {
final lineUrl = AppConfig.h5Lines.first.url;
expect(AppConfig.lineIndexForUrl('${lineUrl}login'), 0);
});
test('refreshes an H5 route URL without adding branding to the URL', () {
final uri = Uri.parse(
AppConfig.withFreshShellParams(
@@ -48,6 +68,22 @@ void main() {
isFalse);
});
test('keeps independent non-legacy H5 hosts when removing shell params', () {
final uri = Uri.parse(
AppConfig.canonicalizeMainFrameUrl(
'https://line-two.example/login?shell_app_name=Old'
'&flutter_shell=1#shell_app_logo=old',
),
);
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.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
isFalse);
});
test('rewrites legacy H5 host main-frame URLs to the canonical host', () {
final uri = Uri.parse(
AppConfig.canonicalizeMainFrameUrl(