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.util.Base64
import android.util.Log
import android.webkit.ServiceWorkerController
import android.webkit.WebSettings
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity
import org.conscrypt.Conscrypt
import java.security.Security
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.webviewflutter.WebViewFlutterAndroidExternalApi
import java.io.ByteArrayOutputStream
import java.io.File
class MainActivity : FlutterActivity() {
private val CHANNEL = "io.openim.flutter.openim/apk_info"
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 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 {

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) {
final value = host.trim();
if (value.startsWith('http://') || value.startsWith('https://')) {

View File

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