refactor(webview): 将 WebView 活动重构为 Compose 组件

- 移除传统 View 系统实现,采用 Jetpack Compose 架构
- 创建 XWebViewScreen 组件替代原有的 Activity 布局
- 集成 Material Design 3 主题支持
- 保留 WebView 的核心功能和配置选项
- 添加导航状态回调接口支持
- 简化窗口边距和系统栏适配逻辑
- 优化组件生命周期管理和资源释放
这个提交包含在:
XuqmGroup 2026-06-08 11:20:17 +08:00
父节点 2a9b1c0f36
当前提交 0efb1b6f0f
共有 2 个文件被更改,包括 20 次插入222 次删除

查看文件

@ -1,235 +1,22 @@
package com.xuqm.sdk.webview
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import java.io.File
class XWebViewActivity : ComponentActivity() {
private lateinit var webView: WebView
private val mainHandler = Handler(Looper.getMainLooper())
private var pendingWebRtcRequest: PermissionRequest? = null
private var pendingFileCallback: ValueCallback<Array<Uri>>? = null
private var pendingCameraUri: Uri? = null
private var shouldLaunchCamera = false
private var currentUrl: String? = null
private val webRtcPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
val req = pendingWebRtcRequest
pendingWebRtcRequest = null
if (granted) req?.grant(req.resources) else req?.deny()
}
private val takePictureLauncher = registerForActivityResult(
ActivityResultContracts.TakePicture()
) { success ->
val cb = pendingFileCallback
val uri = pendingCameraUri
pendingFileCallback = null
pendingCameraUri = null
cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null)
}
private val pickContentLauncher = registerForActivityResult(GetContentWithMimeTypes()) { uri ->
val cb = pendingFileCallback
pendingFileCallback = null
cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null)
}
private val fileCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
val launch = shouldLaunchCamera
shouldLaunchCamera = false
if (granted && launch) {
runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", cacheDir)
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", imageFile)
pendingCameraUri = uri
takePictureLauncher.launch(uri)
}.onFailure {
pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = null
}
} else {
pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = null
}
}
private fun launchCamera() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", cacheDir)
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", imageFile)
pendingCameraUri = uri
takePictureLauncher.launch(uri)
}.onFailure {
pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = null
}
} else {
shouldLaunchCamera = true
fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
val config = getXWebViewConfig()
WebView.setWebContentsDebuggingEnabled(config.debugEnabled)
val container = FrameLayout(this)
webView = WebView(this).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
config.userAgent?.let { settings.userAgentString = it }
addJavascriptInterface(
XWebViewJsBridge(mainHandler, { config.onMessage }, { null }),
config.jsBridgeName,
)
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
currentUrl = url ?: view?.url
super.onPageFinished(view, url)
view?.evaluateJavascript(buildInjectedJs(config), null)
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false
}
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE in request.resources) {
if (ContextCompat.checkSelfPermission(this@XWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
request.grant(request.resources)
} else {
pendingWebRtcRequest = request
webRtcPermissionLauncher.launch(Manifest.permission.CAMERA)
}
} else {
request.deny()
}
}
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams,
): Boolean {
pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = filePathCallback
val acceptMimes = resolvePickerMimeTypes(fileChooserParams.acceptTypes)
val isCameraCapture = fileChooserParams.isCaptureEnabled &&
fileChooserParams.acceptTypes.any { it.contains("image") }
val isImageOnly = acceptMimes.all { it.startsWith("image/") || it == "image/*" }
when {
isCameraCapture -> launchCamera()
isImageOnly -> android.app.AlertDialog.Builder(this@XWebViewActivity)
.setTitle("选择图片")
.setItems(arrayOf("从相册选择", "拍照")) { _, which ->
if (which == 0) pickContentLauncher.launch(arrayOf("image/*"))
else launchCamera()
}
.setOnCancelListener {
pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = null
}
.show()
else -> pickContentLauncher.launch(acceptMimes)
}
return true
}
setContent {
MaterialTheme {
XWebViewScreen(
config = getXWebViewConfig(),
onBack = { finish() },
)
}
}
container.addView(webView, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
))
setContentView(container)
ViewCompat.setOnApplyWindowInsetsListener(container) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
)
v.setPadding(0, bars.top, 0, bars.bottom)
insets
}
container.viewTreeObserver.addOnGlobalLayoutListener {
recalculateBottomPadding(container)
}
setXWebViewController(object : XWebViewController {
override fun canGoBack(): Boolean = webView.canGoBack()
override fun canGoForward(): Boolean = webView.canGoForward()
override fun currentUrl(): String? = currentUrl ?: webView.url
override fun goBack() { if (webView.canGoBack()) webView.goBack() }
override fun goForward() { if (webView.canGoForward()) webView.goForward() }
override fun reload() { webView.reload() }
override fun loadUrl(url: String) { webView.loadUrl(url) }
override fun postMessageToWeb(js: String) { webView.evaluateJavascript(js, null) }
})
if (config.url.isNotBlank()) {
webView.loadUrl(config.url)
}
}
private fun recalculateBottomPadding(container: View) {
val visibleFrame = Rect()
container.getWindowVisibleDisplayFrame(visibleFrame)
val screenHeight = resources.displayMetrics.heightPixels
val bottomHidden = screenHeight - visibleFrame.bottom
val currentTop = container.paddingTop
val currentBottom = container.paddingBottom
val neededBottom = maxOf(currentBottom, bottomHidden)
if (neededBottom != currentBottom) {
container.setPadding(0, currentTop, 0, neededBottom)
}
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onBackPressed() {
if (webView.canGoBack()) webView.goBack() else super.onBackPressed()
}
override fun onDestroy() {
setXWebViewController(null)
webView.destroy()
super.onDestroy()
}
}

查看文件

@ -203,6 +203,7 @@ internal fun String.escapeJs(): String = replace("\\", "\\\\")
fun XWebViewView(
modifier: Modifier = Modifier,
config: XWebViewConfig = getXWebViewConfig(),
onNavigationChanged: ((canGoBack: Boolean, canGoForward: Boolean, pageTitle: String) -> Unit)? = null,
) {
val context = LocalContext.current
var webView by remember { mutableStateOf<WebView?>(null) }
@ -287,6 +288,9 @@ fun XWebViewView(
val xwvMessageRef = remember { mutableStateOf(xwvMessageHandler) }
SideEffect { xwvMessageRef.value = xwvMessageHandler }
val onNavigationChangedRef = remember { mutableStateOf(onNavigationChanged) }
SideEffect { onNavigationChangedRef.value = onNavigationChanged }
// WebRTC getUserMedia() camera permission
val pendingWebRtcRequest = remember { mutableStateOf<PermissionRequest?>(null) }
val webRtcPermissionLauncher = rememberLauncherForActivityResult(
@ -389,6 +393,13 @@ fun XWebViewView(
super.onPageFinished(view, url)
// Inject DIALOG_OVERRIDE_JS + user script after every page load.
view?.evaluateJavascript(buildInjectedJs(config), null)
mainHandler.post {
onNavigationChangedRef.value?.invoke(
view?.canGoBack() == true,
view?.canGoForward() == true,
view?.title.orEmpty(),
)
}
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {