feat:加载远程配置

This commit is contained in:
TangJo 2026-03-26 09:54:18 +08:00
parent 44cf59749d
commit c08d5b58eb
6 changed files with 461 additions and 4 deletions

View File

@ -55,6 +55,7 @@ part 'src/ui/unsupported_platform_page.dart';
// //
late ShellEnvironment _env; late ShellEnvironment _env;
final ValueNotifier<int> _runtimeConfigVersion = ValueNotifier<int>(0);
Color get _shellAccentColor => _env.accentColor; Color get _shellAccentColor => _env.accentColor;
Color get _shellBackgroundColor => _env.backgroundColor; Color get _shellBackgroundColor => _env.backgroundColor;
@ -84,6 +85,41 @@ Future<void> runShellApp(ShellEnvironment environment) async {
runApp(const ShellApp()); runApp(const ShellApp());
} }
///
Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfig() async {
final configUrl = ShellUpgradeService.instance._configUrl?.trim();
if (configUrl == null || configUrl.isEmpty) {
return null;
}
final remoteConfig = await ShellUpgradeService.instance._fetchConfig(
configUrl,
);
if (remoteConfig == null) {
return null;
}
await _applyUpgradeRuntimeConfig(remoteConfig, source: '升级配置');
return remoteConfig;
}
///
Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfigAndCheckVersion(
BuildContext context, {
bool showNoUpdateToast = false,
}) async {
final remoteConfig = await reloadUpgradeRuntimeConfig();
if (remoteConfig == null) {
return null;
}
await ShellUpgradeService.instance.checkVersion(
context,
showNoUpdateToast: showNoUpdateToast,
);
return remoteConfig;
}
/// upgradeConfigUrl /// upgradeConfigUrl
Future<String?> _applyBootstrapConfig() async { Future<String?> _applyBootstrapConfig() async {
String? bootstrapConfigUrl; String? bootstrapConfigUrl;
@ -146,6 +182,38 @@ void _mergeBootstrapConfig(
); );
} }
Future<void> _applyUpgradeRuntimeConfig(
ShellUpgradeConfig remoteConfig, {
required String source,
}) async {
final runtimeConfig = remoteConfig.config;
if (runtimeConfig == null) {
return;
}
final previousInitialUrl = _env.initialUrl?.trim();
final previousPreferredOrientations = _env.preferredOrientations;
_mergeBootstrapConfig(runtimeConfig, source: source);
final nextUpgradeConfigUrl = runtimeConfig.upgradeConfigUrl?.trim();
if (nextUpgradeConfigUrl != null && nextUpgradeConfigUrl.isNotEmpty) {
ShellUpgradeService.instance.setupConfigUrl(nextUpgradeConfigUrl);
}
_initializeUrls();
await SystemChrome.setPreferredOrientations(_shellPreferredOrientations);
final hasInitialUrlChanged = previousInitialUrl != _env.initialUrl?.trim();
final hasOrientationsChanged = !listEquals(
previousPreferredOrientations,
_env.preferredOrientations,
);
if (hasInitialUrlChanged || hasOrientationsChanged) {
_runtimeConfigVersion.value += 1;
}
}
Future<void> _enterImmersiveMode() async { Future<void> _enterImmersiveMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(

View File

@ -103,6 +103,21 @@ void _setStatusBarFromBridge(Map<String, dynamic> payload) {
); );
} }
Map<String, dynamic> _runtimeConfigSnapshotFromBridge(
ShellUpgradeConfig? remoteConfig,
) {
return <String, dynamic>{
'initialUrl': _initialUrl,
'preferredOrientations': _shellPreferredOrientations
.map((item) => item.name)
.toList(),
'upgradeConfigUrl': ShellUpgradeService.instance._configUrl,
'versionName': remoteConfig?.versionName,
'version': remoteConfig?.version,
'describe': remoteConfig?.describe,
};
}
// Toast // Toast
class _ToastWidget extends StatefulWidget { class _ToastWidget extends StatefulWidget {

View File

@ -91,6 +91,11 @@ String _buildAppShellBridgeScript() {
openExternal: (url) => send('openExternal', { url }), openExternal: (url) => send('openExternal', { url }),
requestPermissions: (types = []) => send('requestPermissions', { types }), requestPermissions: (types = []) => send('requestPermissions', { types }),
reloadPage: () => send('reloadPage'), reloadPage: () => send('reloadPage'),
reloadUpgradeRuntimeConfig: () => send('reloadUpgradeRuntimeConfig'),
reloadUpgradeRuntimeConfigAndCheckVersion: (options = {}) => send(
'reloadUpgradeRuntimeConfigAndCheckVersion',
options,
),
goBack: () => send('goBack'), goBack: () => send('goBack'),
closeApp: () => send('closeApp'), closeApp: () => send('closeApp'),
getDeviceInfo: () => send('getDeviceInfo'), getDeviceInfo: () => send('getDeviceInfo'),

View File

@ -53,6 +53,22 @@ class ShellCoreTestHooks {
/// ///
Future<String?> applyBootstrapConfig() => _applyBootstrapConfig(); Future<String?> applyBootstrapConfig() => _applyBootstrapConfig();
///
Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfigForTest() {
return reloadUpgradeRuntimeConfig();
}
///
Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfigAndCheckVersionForTest(
BuildContext context, {
bool showNoUpdateToast = false,
}) {
return reloadUpgradeRuntimeConfigAndCheckVersion(
context,
showNoUpdateToast: showNoUpdateToast,
);
}
/// ///
ShellUpgradeConfig? parseUpgradeConfigString(String content) { ShellUpgradeConfig? parseUpgradeConfigString(String content) {
return ShellUpgradeService.instance._parseConfigString(content); return ShellUpgradeService.instance._parseConfigString(content);

View File

@ -35,9 +35,13 @@ class _WebShellPageState extends State<WebShellPage>
bool _hasStartedRemoteMainFrame = false; bool _hasStartedRemoteMainFrame = false;
bool _hasMainFrameError = false; bool _hasMainFrameError = false;
bool _hasMeasuredProgress = false; bool _hasMeasuredProgress = false;
bool _isApplyingRuntimeConfigReload = false;
bool _hasPendingRuntimeConfigReload = false;
Timer? _startupWatchdogTimer; Timer? _startupWatchdogTimer;
int _progress = 0; int _progress = 0;
int _startupRetryCount = 0; int _startupRetryCount = 0;
int _lastRuntimeConfigVersion = _runtimeConfigVersion.value;
String _appliedInitialUrl = _initialUrl;
String _currentUrl = _initialUrl; String _currentUrl = _initialUrl;
String _errorTitle = '页面加载失败'; String _errorTitle = '页面加载失败';
String _errorMessage = '请检查网络后重试。'; String _errorMessage = '请检查网络后重试。';
@ -48,6 +52,7 @@ class _WebShellPageState extends State<WebShellPage>
super.initState(); super.initState();
debugPrint('WebShell 初始化,初始地址=$_initialUrl'); debugPrint('WebShell 初始化,初始地址=$_initialUrl');
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_runtimeConfigVersion.addListener(_handleRuntimeConfigChanged);
_androidCompatibilityFuture = _prepareAndroidCompatibility(); _androidCompatibilityFuture = _prepareAndroidCompatibility();
_recreateWebView(); _recreateWebView();
@ -57,8 +62,8 @@ class _WebShellPageState extends State<WebShellPage>
} }
_hasTriggeredInitialLoad = true; _hasTriggeredInitialLoad = true;
unawaited(_loadInitialPage()); unawaited(_loadInitialPage());
// //
unawaited(ShellUpgradeService.instance.checkVersion(context)); unawaited(reloadUpgradeRuntimeConfigAndCheckVersion(context));
}); });
} }
@ -444,10 +449,51 @@ class _WebShellPageState extends State<WebShellPage>
@override @override
void dispose() { void dispose() {
_cancelStartupWatchdog(); _cancelStartupWatchdog();
_runtimeConfigVersion.removeListener(_handleRuntimeConfigChanged);
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
void _handleRuntimeConfigChanged() {
final nextVersion = _runtimeConfigVersion.value;
if (nextVersion == _lastRuntimeConfigVersion) {
return;
}
_lastRuntimeConfigVersion = nextVersion;
_hasPendingRuntimeConfigReload = true;
unawaited(_applyPendingRuntimeConfigReload());
}
Future<void> _applyPendingRuntimeConfigReload() async {
if (_isApplyingRuntimeConfigReload) {
return;
}
_isApplyingRuntimeConfigReload = true;
try {
while (_hasPendingRuntimeConfigReload && mounted) {
_hasPendingRuntimeConfigReload = false;
final nextInitialUrl = _initialUrl;
final shouldReloadInitialPage = _appliedInitialUrl != nextInitialUrl;
_appliedInitialUrl = nextInitialUrl;
if (!shouldReloadInitialPage) {
continue;
}
debugPrint('WebShell 检测到运行时配置更新,重新加载初始地址: $nextInitialUrl');
await _ensureCompatibilityPlanApplied();
if (!mounted) {
return;
}
await _startLoadSequence(rebuildWebView: false, resetRetryCount: true);
}
} finally {
_isApplyingRuntimeConfigReload = false;
}
}
// //
Future<void> _loadInitialPage() async { Future<void> _loadInitialPage() async {
@ -693,6 +739,20 @@ class _WebShellPageState extends State<WebShellPage>
case 'reloadPage': case 'reloadPage':
await _reloadPage(); await _reloadPage();
data = true; data = true;
case 'reloadUpgradeRuntimeConfig':
data = _runtimeConfigSnapshotFromBridge(
await reloadUpgradeRuntimeConfig(),
);
case 'reloadUpgradeRuntimeConfigAndCheckVersion':
data = _runtimeConfigSnapshotFromBridge(
await reloadUpgradeRuntimeConfigAndCheckVersion(
context,
showNoUpdateToast: _boolValue(
payload['showNoUpdateToast'],
defaultValue: false,
),
),
);
case 'goBack': case 'goBack':
data = await _goBackFromBridge(); data = await _goBackFromBridge();
case 'closeApp': case 'closeApp':

View File

@ -22,6 +22,9 @@ const MethodChannel _platformChannel = SystemChannels.platform;
const _permissionChannel = MethodChannel( const _permissionChannel = MethodChannel(
'flutter.baseflow.com/permissions/methods', 'flutter.baseflow.com/permissions/methods',
); );
const _packageInfoChannel = MethodChannel(
'dev.fluttercommunity.plus/package_info',
);
const _testEnvironment = ShellEnvironment( const _testEnvironment = ShellEnvironment(
appName: '测试应用', appName: '测试应用',
@ -121,6 +124,19 @@ void main() {
for (final permission in permissions) permission: statusValue, for (final permission in permissions) permission: statusValue,
}; };
}); });
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_packageInfoChannel, (call) async {
if (call.method != 'getAll') {
return null;
}
return <String, String>{
'appName': 'web_shell_core_test',
'packageName': 'com.example.web_shell_core_test',
'version': '1.0.0',
'buildNumber': '1',
};
});
}); });
tearDown(() { tearDown(() {
@ -129,6 +145,8 @@ void main() {
.setMockMethodCallHandler(_platformChannel, null); .setMockMethodCallHandler(_platformChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_permissionChannel, null); .setMockMethodCallHandler(_permissionChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_packageInfoChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMessageHandler('flutter/assets', null); .setMockMessageHandler('flutter/assets', null);
}); });
@ -1394,7 +1412,7 @@ void main() {
expect(script, contains('window.AppShell')); expect(script, contains('window.AppShell'));
}); });
test('脚本暴露全部 12 个 Action', () { test('脚本暴露全部 14 个 Action', () {
final script = shellCoreTestHooks.buildAppShellBridgeScript(); final script = shellCoreTestHooks.buildAppShellBridgeScript();
for (final action in <String>[ for (final action in <String>[
@ -1404,6 +1422,8 @@ void main() {
'openExternal', 'openExternal',
'requestPermissions', 'requestPermissions',
'reloadPage', 'reloadPage',
'reloadUpgradeRuntimeConfig',
'reloadUpgradeRuntimeConfigAndCheckVersion',
'goBack', 'goBack',
'closeApp', 'closeApp',
'getDeviceInfo', 'getDeviceInfo',
@ -1618,6 +1638,7 @@ void main() {
isNull, isNull,
); );
}); });
}); });
group('Phase 2: 新增 Bridge Action', () { group('Phase 2: 新增 Bridge Action', () {
@ -1958,6 +1979,197 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
testWidgets('checkVersion 不会隐式覆盖运行时首页配置', (tester) async {
shellCoreTestHooks.initializeEnvironment(
_testEnvironment.copyWith(
initialUrl: 'https://before.example.com/home',
preferredOrientations: <DeviceOrientation>[
DeviceOrientation.landscapeLeft,
],
),
);
addTearDown(
() => shellCoreTestHooks.initializeEnvironment(_testEnvironment),
);
late BuildContext pageContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
pageContext = context;
return const SizedBox.shrink();
},
),
),
);
await tester.runAsync(() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
try {
server.listen((request) async {
request.response.statusCode = HttpStatus.ok;
request.response.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
request.response.write(
jsonEncode({
'success': true,
'data': {
'config': jsonEncode({
'upgrade': {
'versionName': '1.0.0',
'version': 1,
},
'config': {
'initialUrl': 'https://remote.example.com/runtime',
'preferredOrientations': ['portraitUp', 'portraitDown'],
},
}),
},
}),
);
await request.response.close();
});
shellCoreTestHooks.setupUpgradeConfigUrl(
'http://127.0.0.1:${server.port}/config.json',
);
await _runWithRealHttpClient(
() => shellCoreTestHooks.checkVersion(pageContext),
);
} finally {
await server.close(force: true);
}
});
await tester.pumpAndSettle();
expect(
shellCoreTestHooks.configuredInitialUrl,
'https://before.example.com/home',
);
expect(shellCoreTestHooks.initialUrl, 'https://before.example.com/home');
expect(
shellCoreTestHooks.configuredPreferredOrientations,
<DeviceOrientation>[DeviceOrientation.landscapeLeft],
);
});
testWidgets('reloadUpgradeRuntimeConfigAndCheckVersion 先重载配置再检查升级', (
tester,
) async {
shellCoreTestHooks.initializeEnvironment(
_testEnvironment.copyWith(
initialUrl: 'https://before.example.com/home',
),
);
addTearDown(() {
shellCoreTestHooks.initializeEnvironment(_testEnvironment);
shellCoreTestHooks.setupUpgradeConfigUrl(null);
});
late BuildContext pageContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
pageContext = context;
return const SizedBox.shrink();
},
),
),
);
await tester.runAsync(() async {
final upgradeServer = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
);
var upgradeCheckCount = 0;
upgradeServer.listen((request) async {
upgradeCheckCount += 1;
request.response.statusCode = HttpStatus.ok;
request.response.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
request.response.write(
jsonEncode({
'success': true,
'data': {
'installOssUrl': 'https://example.com/app.apk',
'config': jsonEncode({
'upgrade': {
'versionName': '1.0.0',
'version': 1,
},
}),
},
}),
);
await request.response.close();
});
final configServer = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
);
configServer.listen((request) async {
request.response.statusCode = HttpStatus.ok;
request.response.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
request.response.write(
jsonEncode({
'success': true,
'data': {
'config': jsonEncode({
'upgrade': {
'versionName': '1.0.0',
'version': 1,
},
'config': {
'initialUrl': 'https://remote.example.com/runtime',
'preferredOrientations': ['portraitUp', 'portraitDown'],
'upgradeConfigUrl':
'http://127.0.0.1:${upgradeServer.port}/upgrade.json',
},
}),
},
}),
);
await request.response.close();
});
try {
shellCoreTestHooks.setupUpgradeConfigUrl(
'http://127.0.0.1:${configServer.port}/config.json',
);
final remoteConfig = await _runWithRealHttpClient(
() => shellCoreTestHooks
.reloadUpgradeRuntimeConfigAndCheckVersionForTest(
pageContext,
),
);
expect(remoteConfig, isNotNull);
expect(
shellCoreTestHooks.configuredInitialUrl,
'https://remote.example.com/runtime',
);
expect(
shellCoreTestHooks.upgradeConfigUrl,
'http://127.0.0.1:${upgradeServer.port}/upgrade.json',
);
expect(upgradeCheckCount, 1);
} finally {
await configServer.close(force: true);
await upgradeServer.close(force: true);
}
});
});
test('升级配置解析仅支持新版接口响应', () { test('升级配置解析仅支持新版接口响应', () {
final wrappedContent = jsonEncode({ final wrappedContent = jsonEncode({
'success': true, 'success': true,
@ -2265,6 +2477,73 @@ void main() {
shellCoreTestHooks.initializeEnvironment(_testEnvironment); shellCoreTestHooks.initializeEnvironment(_testEnvironment);
}); });
test('reloadUpgradeRuntimeConfig 会应用升级配置中的运行时启动配置', () async {
shellCoreTestHooks.initializeEnvironment(
_testEnvironment.copyWith(
initialUrl: 'https://before.example.com/home',
preferredOrientations: <DeviceOrientation>[
DeviceOrientation.landscapeLeft,
],
),
);
addTearDown(() {
shellCoreTestHooks.initializeEnvironment(_testEnvironment);
shellCoreTestHooks.setupUpgradeConfigUrl(null);
});
final successUri = await _startJsonServer(
(_) async => jsonEncode({
'success': true,
'data': {
'config': jsonEncode({
'upgrade': {
'versionName': '2.0.0',
'version': 200,
},
'config': {
'initialUrl': 'https://remote.example.com/runtime',
'preferredOrientations': ['portraitUp', 'portraitDown'],
'upgradeConfigUrl': 'https://remote.example.com/upgrade.json',
},
}),
},
}),
);
shellCoreTestHooks.setupUpgradeConfigUrl(successUri.toString());
final remoteConfig = await _runWithRealHttpClient(
shellCoreTestHooks.reloadUpgradeRuntimeConfigForTest,
);
expect(remoteConfig, isNotNull);
expect(
shellCoreTestHooks.configuredInitialUrl,
'https://remote.example.com/runtime',
);
expect(
shellCoreTestHooks.initialUrl,
'https://remote.example.com/runtime',
);
expect(
shellCoreTestHooks.configuredPreferredOrientations,
<DeviceOrientation>[
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
],
);
expect(
shellCoreTestHooks.preferredOrientations,
<DeviceOrientation>[
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
],
);
expect(
shellCoreTestHooks.upgradeConfigUrl,
'https://remote.example.com/upgrade.json',
);
});
testWidgets('LaunchOverlay 配置品牌图片时渲染 Image', (tester) async { testWidgets('LaunchOverlay 配置品牌图片时渲染 Image', (tester) async {
shellCoreTestHooks.initializeEnvironment( shellCoreTestHooks.initializeEnvironment(
_testEnvironment.copyWith( _testEnvironment.copyWith(
@ -2641,6 +2920,7 @@ class _FakePlatformWebViewController extends PlatformWebViewController {
_FakePlatformWebViewController(super.params) : super.implementation(); _FakePlatformWebViewController(super.params) : super.implementation();
final javaScriptCalls = <String>[]; final javaScriptCalls = <String>[];
final javaScriptChannels = <String, JavaScriptChannelParams>{};
PlatformNavigationDelegate? delegate; PlatformNavigationDelegate? delegate;
bool throwOnRunJavaScript = false; bool throwOnRunJavaScript = false;
bool canGoBackValue = false; bool canGoBackValue = false;
@ -2648,7 +2928,20 @@ class _FakePlatformWebViewController extends PlatformWebViewController {
Uri? lastLoadedUri; Uri? lastLoadedUri;
@override @override
Future<void> addJavaScriptChannel(JavaScriptChannelParams params) async {} Future<void> addJavaScriptChannel(JavaScriptChannelParams params) async {
javaScriptChannels[params.name] = params;
}
Future<void> dispatchJavaScriptMessage(
String channelName,
String message,
) async {
final channel = javaScriptChannels[channelName];
if (channel == null) {
throw StateError('JavaScript channel not found: $channelName');
}
channel.onMessageReceived(JavaScriptMessage(message: message));
}
@override @override
Future<void> clearCache() async {} Future<void> clearCache() async {}