feat: 添加 WebView 无缓存配置和运行时缓存清理逻辑,优化页面加载体验
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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://')) {
|
||||
|
||||
228
lib/main.dart
228
lib/main.dart
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user