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