Perf(ToolView): Optimize webview loading performance

This commit is contained in:
2024-10-12 11:55:15 +08:00
parent cd97f6156f
commit 0eb0667d2a
3 changed files with 224 additions and 145 deletions

View File

@@ -1,18 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Preview</title>
<!-- es-module-shims -->
</head>
<body>
<script type="module" id="appSrc">{{replace_code}}</script>
<div id="root">
<div
style="position:absolute;top: 0;left:0;width:100%;height:100%;display: flex;justify-content: center;align-items: center;">
Loading...
</div>
</div>
</body>
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Preview</title>
<!-- es-module-shims -->
</head>
<body>
<script type="module" id="appDictSrc">{{replace_dict_code}}</script>
<script type="module" id="appBaseSrc">{{replace_base_code}}</script>
<div id="root">
<div style="position:absolute;top: 0;left:0;width:100%;height:100%;display: flex;justify-content: center;align-items: center;">
Loading...
</div>
</div>
</body>
</html>

View File

@@ -10,22 +10,21 @@ import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.ValueCallback
import android.webkit.WebView
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@@ -59,12 +58,14 @@ internal fun ToolViewRoute(
viewModel: ToolViewScreenViewModel = hiltViewModel(),
onBackClick: () -> Unit
) {
val isPreview by viewModel.isPreview.collectAsStateWithLifecycle()
val toolViewUiState by viewModel.toolViewUiState.collectAsStateWithLifecycle()
val webViewInstanceState by viewModel.webviewInstance.collectAsStateWithLifecycle()
val isPreview by viewModel.isPreview.collectAsStateWithLifecycle()
ToolViewScreen(
modifier = modifier,
toolViewUiState = toolViewUiState,
webViewInstanceState = webViewInstanceState,
isPreview = isPreview,
onBackClick = onBackClick
)
@@ -76,68 +77,79 @@ internal fun ToolViewRoute(
internal fun ToolViewScreen(
modifier: Modifier = Modifier,
toolViewUiState: ToolViewUiState,
webViewInstanceState: WebViewInstanceState,
isPreview: Boolean,
onBackClick: () -> Unit
) {
Column(
modifier
.fillMaxWidth()
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal
)
)
) {
TopBar(
toolViewUiState = toolViewUiState,
isPreview = isPreview,
onBackClick = onBackClick
)
Content(
toolViewUiState = toolViewUiState,
webViewInstanceState = webViewInstanceState
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(
toolViewUiState: ToolViewUiState,
isPreview: Boolean,
onBackClick: () -> Unit
) = OxygenTopAppBar(
title = {
Text(
text = when (toolViewUiState) {
ToolViewUiState.Loading -> stringResource(R.string.core_loading)
ToolViewUiState.Error -> stringResource(R.string.feature_tools_can_not_open)
is ToolViewUiState.Success -> if (isPreview) stringResource(
R.string.feature_tool_view_preview_suffix,
toolViewUiState.toolName
) else toolViewUiState.toolName
}
)
},
navigationIcon = OxygenIcons.Back,
navigationIconContentDescription = stringResource(R.string.core_back),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
),
onNavigationClick = onBackClick
)
@Composable
private fun Content(
toolViewUiState: ToolViewUiState,
webViewInstanceState: WebViewInstanceState
) {
val context = LocalContext.current
var fileChooserCallback by remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
val fileChooserLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri ->
fileChooserCallback?.onReceiveValue(arrayOf(uri))
} ?: {
fileChooserCallback?.onReceiveValue(emptyArray())
}
} else {
fileChooserCallback?.onReceiveValue(emptyArray())
}
val fileChooserLauncher = rememberFileChooserLauncher(fileChooserCallback)
val permissionLauncher = rememberPermissionLauncher()
when (webViewInstanceState) {
WebViewInstanceState.Loading -> {
Indicator()
}
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
Permissions.continuation?.resume(it)
}
Scaffold(
modifier = Modifier,
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(left = 0, top = 0, right = 0, bottom = 0)
) { padding ->
Column(
modifier
.fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal
)
)
) {
OxygenTopAppBar(
title = {
Text(
text = when (toolViewUiState) {
ToolViewUiState.Loading -> stringResource(R.string.core_loading)
ToolViewUiState.Error -> stringResource(R.string.feature_tools_can_not_open)
is ToolViewUiState.Success -> if (isPreview) stringResource(
R.string.feature_tool_view_preview_suffix,
toolViewUiState.toolName
) else toolViewUiState.toolName
}
)
},
navigationIcon = OxygenIcons.Back,
navigationIconContentDescription = stringResource(R.string.core_back),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
),
onNavigationClick = onBackClick
)
is WebViewInstanceState.Success -> {
when (toolViewUiState) {
ToolViewUiState.Loading -> {
Indicator()
@@ -162,79 +174,18 @@ internal fun ToolViewScreen(
.fillMaxSize()
.imePadding(),
state = webViewState,
chromeClient = remember {
object : AccompanistWebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
consoleMessage?.let {
Timber.tag("WebView").log(
priority = when (it.messageLevel()) {
ConsoleMessage.MessageLevel.TIP -> Log.VERBOSE
ConsoleMessage.MessageLevel.LOG -> Log.INFO
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
else -> Log.DEBUG
},
message = "${it.message()} (${it.lineNumber()})"
)
return true
}
return false
}
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
fileChooserCallback = filePathCallback
val intent = fileChooserParams?.createIntent()
?: Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
fileChooserLauncher.launch(
Intent.createChooser(
intent,
fileChooserParams?.title ?: ResourcesUtils.getString(
context = context,
resId = R.string.core_file_select_one_text
)
)
)
return true
}
}
chromeClient = rememberChromeClient(
context = context,
fileChooserLauncher = fileChooserLauncher
) {
fileChooserCallback = it
},
onCreated = {
it.settings.javaScriptEnabled = true
it.settings.domStorageEnabled = true
it.addJavascriptInterface(
NativeWebApi(context = context, webView = it, permissionLauncher),
"NativeApi"
)
it.setDownloadListener { url, userAgent, _, mimetype, _ ->
if (!listOf("http://", "https://").any(url::startsWith)) {
it.evaluateJavascript(
"alert('${
ResourcesUtils.getString(
context = context,
resId = R.string.core_can_only_download_http_https,
url
)
}')"
) {}
return@setDownloadListener
}
val request = DownloadManager.Request(Uri.parse(url)).apply {
addRequestHeader("User-Agent", userAgent)
setMimeType(mimetype)
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
}
val downloadManager: DownloadManager =
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
downloadManager.enqueue(request)
}
onCreated = initWebView(
context = context,
permissionLauncher = permissionLauncher
),
factory = {
webViewInstanceState.webView
}
)
}
@@ -242,3 +193,108 @@ internal fun ToolViewScreen(
}
}
}
@Composable
private fun rememberFileChooserLauncher(fileChooserCallback: ValueCallback<Array<Uri>>?) =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri ->
fileChooserCallback?.onReceiveValue(arrayOf(uri))
} ?: {
fileChooserCallback?.onReceiveValue(emptyArray())
}
} else {
fileChooserCallback?.onReceiveValue(emptyArray())
}
}
@Composable
private fun rememberPermissionLauncher() =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
Permissions.continuation?.resume(it)
}
@SuppressLint("SetJavaScriptEnabled")
private fun initWebView(
context: Context,
permissionLauncher: ManagedActivityResultLauncher<String, Boolean>
) = { webview: WebView ->
webview.settings.javaScriptEnabled = true
webview.settings.domStorageEnabled = true
webview.addJavascriptInterface(
NativeWebApi(context = context, webView = webview, permissionLauncher),
"NativeApi"
)
webview.setDownloadListener { url, userAgent, _, mimetype, _ ->
if (!listOf("http://", "https://").any(url::startsWith)) {
webview.evaluateJavascript(
"alert('${
ResourcesUtils.getString(
context = context,
resId = R.string.core_can_only_download_http_https,
url
)
}')"
) {}
return@setDownloadListener
}
val request = DownloadManager.Request(Uri.parse(url)).apply {
addRequestHeader("User-Agent", userAgent)
setMimeType(mimetype)
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
}
val downloadManager: DownloadManager =
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
downloadManager.enqueue(request)
}
}
@Composable
private fun rememberChromeClient(
context: Context,
fileChooserLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
processCallback: (ValueCallback<Array<Uri>>?) -> Unit
) = remember {
object : AccompanistWebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
consoleMessage?.let {
Timber.tag("WebView").log(
priority = when (it.messageLevel()) {
ConsoleMessage.MessageLevel.TIP -> Log.VERBOSE
ConsoleMessage.MessageLevel.LOG -> Log.INFO
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
else -> Log.DEBUG
},
message = "${it.message()} (${it.lineNumber()})"
)
return true
}
return false
}
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
processCallback(filePathCallback)
val intent = fileChooserParams?.createIntent()
?: Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
fileChooserLauncher.launch(
Intent.createChooser(
intent,
fileChooserParams?.title ?: ResourcesUtils.getString(
context = context,
resId = R.string.core_file_select_one_text
)
)
)
return true
}
}
}

View File

@@ -1,9 +1,12 @@
package top.fatweb.oxygen.toolbox.ui.view
import android.content.Context
import android.webkit.WebView
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +30,7 @@ import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class ToolViewScreenViewModel @Inject constructor(
@ApplicationContext context: Context,
storeRepository: StoreRepository,
toolRepository: ToolRepository,
savedStateHandle: SavedStateHandle
@@ -55,6 +59,15 @@ class ToolViewScreenViewModel @Inject constructor(
initialValue = ToolViewUiState.Loading,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds)
)
val webviewInstance = flow<WebViewInstanceState> {
val webviewInstance = WebView(context)
emit(WebViewInstanceState.Success(webviewInstance))
}.stateIn(
viewModelScope,
initialValue = WebViewInstanceState.Loading,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds)
)
}
private fun toolViewUiState(
@@ -141,12 +154,22 @@ sealed interface ToolViewUiState {
data object Loading : ToolViewUiState
}
sealed interface WebViewInstanceState {
data class Success(
val webView: WebView
) : WebViewInstanceState
data object Loading : WebViewInstanceState
}
@OptIn(ExperimentalEncodingApi::class)
private fun processHtml(toolViewTemplate: String, distBase64: String, baseBase64: String): String {
val dist = Base64.decodeToStringWithZip(distBase64)
val base = Base64.decodeToStringWithZip(baseBase64)
return toolViewTemplate.replace("{{replace_code}}", "$dist\n$base")
return toolViewTemplate
.replace(oldValue = "{{replace_dict_code}}", newValue = dist)
.replace(oldValue = "{{replace_base_code}}", newValue = base)
}
private const val IS_PREVIEW = "IS_PREVIEW"