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

View File

@@ -10,22 +10,21 @@ import android.util.Log
import android.webkit.ConsoleMessage import android.webkit.ConsoleMessage
import android.webkit.ValueCallback import android.webkit.ValueCallback
import android.webkit.WebView import android.webkit.WebView
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -59,12 +58,14 @@ internal fun ToolViewRoute(
viewModel: ToolViewScreenViewModel = hiltViewModel(), viewModel: ToolViewScreenViewModel = hiltViewModel(),
onBackClick: () -> Unit onBackClick: () -> Unit
) { ) {
val isPreview by viewModel.isPreview.collectAsStateWithLifecycle()
val toolViewUiState by viewModel.toolViewUiState.collectAsStateWithLifecycle() val toolViewUiState by viewModel.toolViewUiState.collectAsStateWithLifecycle()
val webViewInstanceState by viewModel.webviewInstance.collectAsStateWithLifecycle()
val isPreview by viewModel.isPreview.collectAsStateWithLifecycle()
ToolViewScreen( ToolViewScreen(
modifier = modifier, modifier = modifier,
toolViewUiState = toolViewUiState, toolViewUiState = toolViewUiState,
webViewInstanceState = webViewInstanceState,
isPreview = isPreview, isPreview = isPreview,
onBackClick = onBackClick onBackClick = onBackClick
) )
@@ -76,68 +77,79 @@ internal fun ToolViewRoute(
internal fun ToolViewScreen( internal fun ToolViewScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
toolViewUiState: ToolViewUiState, toolViewUiState: ToolViewUiState,
webViewInstanceState: WebViewInstanceState,
isPreview: Boolean, isPreview: Boolean,
onBackClick: () -> Unit 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 val context = LocalContext.current
var fileChooserCallback by remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) } var fileChooserCallback by remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
val fileChooserLauncher = val fileChooserLauncher = rememberFileChooserLauncher(fileChooserCallback)
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) { val permissionLauncher = rememberPermissionLauncher()
it.data?.data?.let { uri ->
fileChooserCallback?.onReceiveValue(arrayOf(uri)) when (webViewInstanceState) {
} ?: { WebViewInstanceState.Loading -> {
fileChooserCallback?.onReceiveValue(emptyArray()) Indicator()
}
} else {
fileChooserCallback?.onReceiveValue(emptyArray())
}
} }
val permissionLauncher = is WebViewInstanceState.Success -> {
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
)
when (toolViewUiState) { when (toolViewUiState) {
ToolViewUiState.Loading -> { ToolViewUiState.Loading -> {
Indicator() Indicator()
@@ -162,79 +174,18 @@ internal fun ToolViewScreen(
.fillMaxSize() .fillMaxSize()
.imePadding(), .imePadding(),
state = webViewState, state = webViewState,
chromeClient = remember { chromeClient = rememberChromeClient(
object : AccompanistWebChromeClient() { context = context,
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { fileChooserLauncher = fileChooserLauncher
consoleMessage?.let { ) {
Timber.tag("WebView").log( fileChooserCallback = it
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
}
}
}, },
onCreated = { onCreated = initWebView(
it.settings.javaScriptEnabled = true context = context,
it.settings.domStorageEnabled = true permissionLauncher = permissionLauncher
it.addJavascriptInterface( ),
NativeWebApi(context = context, webView = it, permissionLauncher), factory = {
"NativeApi" webViewInstanceState.webView
)
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)
}
} }
) )
} }
@@ -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 package top.fatweb.oxygen.toolbox.ui.view
import android.content.Context
import android.webkit.WebView
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +30,7 @@ import kotlin.time.Duration.Companion.seconds
@HiltViewModel @HiltViewModel
class ToolViewScreenViewModel @Inject constructor( class ToolViewScreenViewModel @Inject constructor(
@ApplicationContext context: Context,
storeRepository: StoreRepository, storeRepository: StoreRepository,
toolRepository: ToolRepository, toolRepository: ToolRepository,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
@@ -55,6 +59,15 @@ class ToolViewScreenViewModel @Inject constructor(
initialValue = ToolViewUiState.Loading, initialValue = ToolViewUiState.Loading,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds) 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( private fun toolViewUiState(
@@ -141,12 +154,22 @@ sealed interface ToolViewUiState {
data object Loading : ToolViewUiState data object Loading : ToolViewUiState
} }
sealed interface WebViewInstanceState {
data class Success(
val webView: WebView
) : WebViewInstanceState
data object Loading : WebViewInstanceState
}
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
private fun processHtml(toolViewTemplate: String, distBase64: String, baseBase64: String): String { private fun processHtml(toolViewTemplate: String, distBase64: String, baseBase64: String): String {
val dist = Base64.decodeToStringWithZip(distBase64) val dist = Base64.decodeToStringWithZip(distBase64)
val base = Base64.decodeToStringWithZip(baseBase64) 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" private const val IS_PREVIEW = "IS_PREVIEW"