feat: 添加 WebView 无缓存配置和运行时缓存清理逻辑,优化页面加载体验

This commit is contained in:
Booker
2026-05-26 18:38:36 +07:00
parent 81f0f342a9
commit ca25c2d706
3 changed files with 270 additions and 19 deletions

View File

@@ -11,18 +11,22 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import android.webkit.ServiceWorkerController
import android.webkit.WebSettings
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import org.conscrypt.Conscrypt import org.conscrypt.Conscrypt
import java.security.Security import java.security.Security
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.webviewflutter.WebViewFlutterAndroidExternalApi
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val CHANNEL = "io.openim.flutter.openim/apk_info" private val CHANNEL = "io.openim.flutter.openim/apk_info"
private val SHELL_BRANDING_CHANNEL = "io.openim.flutter.im_webview_app/shell_branding" private val SHELL_BRANDING_CHANNEL = "io.openim.flutter.im_webview_app/shell_branding"
private val WEBVIEW_CACHE_CHANNEL = "io.openim.flutter.im_webview_app/webview_cache"
private val TAG = "MainActivity" private val TAG = "MainActivity"
private val MAX_BRANDING_ICON_SIZE = 192 private val MAX_BRANDING_ICON_SIZE = 192
@@ -107,6 +111,55 @@ class MainActivity : FlutterActivity() {
} }
} }
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WEBVIEW_CACHE_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"configureNoCache" -> {
val webViewId = readLongArgument(call.argument<Any>("webViewId"))
if (webViewId == null) {
result.error("INVALID_ARGUMENT", "webViewId 不能为空", null)
return@setMethodCallHandler
}
try {
result.success(configureWebViewNoCache(flutterEngine, webViewId))
} catch (e: Exception) {
result.error("ERROR", "无法配置 WebView 缓存策略: ${e.message}", null)
}
}
else -> {
result.notImplemented()
}
}
}
}
private fun readLongArgument(value: Any?): Long? {
return when (value) {
is Int -> value.toLong()
is Long -> value
is Number -> value.toLong()
else -> null
}
}
private fun configureWebViewNoCache(flutterEngine: FlutterEngine, webViewId: Long): Boolean {
val webView = WebViewFlutterAndroidExternalApi.getWebView(flutterEngine, webViewId) ?: return false
webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE
webView.clearCache(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
try {
ServiceWorkerController
.getInstance()
.serviceWorkerWebSettings
.cacheMode = WebSettings.LOAD_NO_CACHE
} catch (e: Exception) {
Log.w(TAG, "ServiceWorker cache mode configure failed: ${e.message}")
}
}
return true
} }
private fun getCurrentAppName(): String { private fun getCurrentAppName(): String {

View File

@@ -28,6 +28,14 @@ class AppConfig {
); );
} }
static String withFreshShellCacheBust(String url) {
final uri = Uri.parse(url);
final queryParameters = Map<String, String>.from(uri.queryParameters)
..['shell_cache_bust'] = DateTime.now().millisecondsSinceEpoch.toString();
return uri.replace(queryParameters: queryParameters).toString();
}
static String _normalizeHomeUrl(String host) { static String _normalizeHomeUrl(String host) {
final value = host.trim(); final value = host.trim();
if (value.startsWith('http://') || value.startsWith('https://')) { if (value.startsWith('http://') || value.startsWith('https://')) {

View File

@@ -22,6 +22,89 @@ const _noCacheHeaders = {
}; };
const _shellBrandingChannel = const _shellBrandingChannel =
MethodChannel('io.openim.flutter.im_webview_app/shell_branding'); 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 _shellMediaPermissionBridgeScript = r''' const _shellMediaPermissionBridgeScript = r'''
(() => { (() => {
if (window.__openimShellMediaBridgeInstalled) { if (window.__openimShellMediaBridgeInstalled) {
@@ -211,21 +294,19 @@ class H5ShellPage extends StatefulWidget {
class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver { class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
late final WebViewController _controller; late final WebViewController _controller;
late final String _homeUrl;
int _progress = 0; int _progress = 0;
String? _loadError; String? _loadError;
bool _showShellCover = true; bool _showShellCover = true;
bool _runtimeCachePurgeStarted = false;
bool _runtimeCachePurgeReloaded = false;
Timer? _shellCoverTimer; Timer? _shellCoverTimer;
Timer? _runtimeCachePurgeFallbackTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_homeUrl = AppConfig.homeUrl(
appName: widget.shellBranding.appName,
appLogo: widget.shellBranding.appLogo,
);
_controller = _buildController(); _controller = _buildController();
unawaited(_loadHome()); unawaited(_loadHome());
} }
@@ -233,6 +314,7 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_shellCoverTimer?.cancel(); _shellCoverTimer?.cancel();
_runtimeCachePurgeFallbackTimer?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@@ -292,14 +374,7 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
} }
}, },
onPageFinished: (_) { onPageFinished: (_) {
unawaited(_syncShellBranding()); unawaited(_handlePageFinished());
unawaited(_injectShellMediaPermissionBridge());
if (mounted) {
setState(() {
_progress = 100;
_showShellCover = false;
});
}
}, },
onWebResourceError: (error) { onWebResourceError: (error) {
if (error.isForMainFrame ?? true) { if (error.isForMainFrame ?? true) {
@@ -356,6 +431,9 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
if (type == 'openAppSettings') { if (type == 'openAppSettings') {
unawaited(openAppSettings()); unawaited(openAppSettings());
} }
if (type == 'runtimeCachePurged' && payload != null) {
unawaited(_reloadAfterRuntimeCachePurge(payload['url'] as String?));
}
if (type == 'requestMediaPermissions' && payload != null) { if (type == 'requestMediaPermissions' && payload != null) {
unawaited(_handleMediaPermissionBridgeRequest(payload)); unawaited(_handleMediaPermissionBridgeRequest(payload));
} }
@@ -420,6 +498,118 @@ 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.withFreshShellCacheBust(value);
} catch (_) {
return _freshHomeUrl();
}
}
Future<void> _handlePageFinished() async {
final waitingForRuntimeCacheReload =
await _purgeWebRuntimeCacheAndReloadIfNeeded();
if (waitingForRuntimeCacheReload) {
return;
}
unawaited(_syncShellBranding());
unawaited(_injectShellMediaPermissionBridge());
if (mounted) {
setState(() {
_progress = 100;
_showShellCover = false;
});
}
}
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 {
await _controller.clearCache();
} catch (_) {
// Some WebView implementations can reject cache operations during setup.
}
}
Future<void> _configureAndroidNoCache() async {
final platformController = _controller.platform;
if (platformController is! AndroidWebViewController) {
return;
}
try {
await _shellWebViewCacheChannel.invokeMethod<void>(
'configureNoCache',
{'webViewId': platformController.webViewIdentifier},
);
} catch (_) {
// Older shells may not expose the native cache channel yet.
}
}
Future<void> _syncShellBranding() { Future<void> _syncShellBranding() {
final payload = jsonEncode({ final payload = jsonEncode({
'name': widget.shellBranding.appName, 'name': widget.shellBranding.appName,
@@ -446,6 +636,10 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
} }
Future<void> _loadHome() async { Future<void> _loadHome() async {
await _loadUrl(_freshHomeUrl());
}
Future<void> _loadUrl(String url) async {
if (mounted) { if (mounted) {
setState(() { setState(() {
_loadError = null; _loadError = null;
@@ -454,14 +648,10 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
}); });
} }
try { await _prepareWebViewForFreshLoad();
await _controller.clearCache();
} catch (_) {
// Some WebView implementations can reject cache operations during setup.
}
await _controller.loadRequest( await _controller.loadRequest(
Uri.parse(_homeUrl), Uri.parse(url),
headers: _noCacheHeaders, headers: _noCacheHeaders,
); );
} }