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

@@ -7,10 +7,10 @@
<!-- 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>
<script type="module" id="appBaseSrc">{{replace_base_code}}</script>
<div id="root"> <div id="root">
<div <div style="position:absolute;top: 0;left:0;width:100%;height:100%;display: flex;justify-content: center;align-items: center;">
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>

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,48 +77,39 @@ 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
) { ) {
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 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( Column(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding( .windowInsetsPadding(
WindowInsets.safeDrawing.only( WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal WindowInsetsSides.Horizontal
) )
) )
) { ) {
OxygenTopAppBar( 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 = { title = {
Text( Text(
text = when (toolViewUiState) { text = when (toolViewUiState) {
@@ -138,6 +130,26 @@ internal fun ToolViewScreen(
), ),
onNavigationClick = onBackClick 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 = rememberFileChooserLauncher(fileChooserCallback)
val permissionLauncher = rememberPermissionLauncher()
when (webViewInstanceState) {
WebViewInstanceState.Loading -> {
Indicator()
}
is WebViewInstanceState.Success -> {
when (toolViewUiState) { when (toolViewUiState) {
ToolViewUiState.Loading -> { ToolViewUiState.Loading -> {
Indicator() Indicator()
@@ -162,7 +174,87 @@ internal fun ToolViewScreen(
.fillMaxSize() .fillMaxSize()
.imePadding(), .imePadding(),
state = webViewState, state = webViewState,
chromeClient = remember { chromeClient = rememberChromeClient(
context = context,
fileChooserLauncher = fileChooserLauncher
) {
fileChooserCallback = it
},
onCreated = initWebView(
context = context,
permissionLauncher = permissionLauncher
),
factory = {
webViewInstanceState.webView
}
)
}
}
}
}
}
@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() { object : AccompanistWebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
consoleMessage?.let { consoleMessage?.let {
@@ -186,7 +278,7 @@ internal fun ToolViewScreen(
filePathCallback: ValueCallback<Array<Uri>>?, filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams? fileChooserParams: FileChooserParams?
): Boolean { ): Boolean {
fileChooserCallback = filePathCallback processCallback(filePathCallback)
val intent = fileChooserParams?.createIntent() val intent = fileChooserParams?.createIntent()
?: Intent(Intent.ACTION_OPEN_DOCUMENT).apply { ?: Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE) addCategory(Intent.CATEGORY_OPENABLE)
@@ -205,40 +297,4 @@ internal fun ToolViewScreen(
return true return true
} }
} }
},
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)
}
}
)
}
}
}
}
} }

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"