Perf(ToolView): Optimize webview loading performance
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user