无尽码路

清凉夏日,您升官了吗?
小心kotlin中lambda引用外闭包可变变量的潜在问题
at 2023-04-01 22:56:37, by 鹏城奋青

在Compose,子级可组合项的事件lambda函数,使用了父级的var变量,子级事件函数中不断检测该变量的赋值情况。在debug版中运行正常,但release版即使该变量被修改,而子级获取的值永不变,这有可能是由于release优化引起的问题。谨慎起见,使用委托语法创建变量,该问题得以解决:

var snapshot by remember {
    mutableStateOf<Bitmap?>(null)
}

以下为原代码:

package com.wisforce.app.ui.map

import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Rect
import android.webkit.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.wisforce.app.R
import com.wisforce.app.chatty.Chatty
import com.wisforce.app.chatty.protol.LocationData
import com.wisforce.app.theme.CoreTheme
import com.wisforce.app.theme.widgets.CoreText
import com.wisforce.app.theme.widgets.CoreTextButton
import com.wisforce.app.widgets.dialog.rememberDialogState
import com.wisforce.app.widgets.misc.SyncIndicator
import com.wisforce.app.widgets.page.BackTitlePage
import kotlinx.coroutines.*
import org.bouncycastle.util.encoders.Base64
import java.io.File

@SuppressLint("SetJavaScriptEnabled")
@Composable
fun LocationChoosePage(locationData: LocationData, onFinish: (LocationChooseResult?)->Unit) {
    var webView by remember{ mutableStateOf<WebView?> (null) }
    var size by remember { mutableStateOf(IntSize.Zero) }
    var address by remember { mutableStateOf("") }
    var longitude by remember { mutableStateOf(locationData.longitude) }
    var latitude by remember { mutableStateOf(locationData.latitude) }
    val dialog = rememberDialogState()
    val scope = rememberCoroutineScope()
    val density = LocalDensity.current
    // 注意此项:委托变量才能使下文等待循环正确,debug版没有问题,而release版是有问题的
    var snapshotBitmap by remember { mutableStateOf<Bitmap?>(null) }
    val context = LocalContext.current

    LaunchedEffect(longitude, latitude) {
        address = Chatty.getLocationAddress(
            longitude = longitude,
            latitude = latitude
        ) ?: ""
    }

    DisposableEffect(true) {
        onDispose {
            snapshotBitmap?.recycle()
        }
    }

    fun prepareResult(): LocationChooseResult {
        var cropFile: File? = null
        snapshotBitmap?.let { snapshot ->
            // 最好是能将webview截图下来,再获取中心部分即可,但实践发现无法截取
            // webview中地图图像,有可能和地图使用webgl渲染有关,因此通过在html中使用html2canvas
            // 渲染出地图部分,但仍然有一个问题,即html2canvas无法渲染一些元素,比如带position:fixed
            // 等,无奈之下只用它截取地图底图,然后再将标记点绘制上去

            // 截取中心300x140大小的部分
            val cropWidth = with(density) { 300.dp.toPx().toInt() }
            val cropHeight = with(density) { 140.dp.toPx().toInt() }

            val cropBitmap = Bitmap.createBitmap(minOf(size.width, cropWidth),
                minOf(size.height, cropHeight), Bitmap.Config.ARGB_8888)

            val cropCanvas = Canvas(cropBitmap)

            // 大图中获取的rect各项
            val left = (snapshot.width - cropBitmap.width) / 2
            val top = (snapshot.height - cropBitmap.height) / 2
            val right = left + cropBitmap.width
            val bottom = top + cropBitmap.height

            cropCanvas.drawBitmap(
                snapshot,
                Rect(left, top, right, bottom),
                Rect(0, 0, cropBitmap.width, cropBitmap.height),
                null,
            )

            // 将标记绘制上去,因为html截图得不到标记元素
            val markerBitmap = context.assets.open("poi_marker.png").use { si ->
                BitmapFactory.decodeStream(si)
            }

            cropCanvas.drawBitmap(markerBitmap,
                ((cropWidth-markerBitmap.width)/2).toFloat(),
                (cropHeight/2-markerBitmap.height).toFloat(),
                null)

            markerBitmap.recycle()

            // 将图压缩回传
            val tempFile = File.createTempFile("location", ".png")
            tempFile.outputStream().use { so ->
                if (cropBitmap.compress(Bitmap.CompressFormat.PNG, 10, so)) {
                    cropFile = tempFile
                }
            }
        }

        return LocationChooseResult(
            picture = cropFile?.path ?: "",
            location = LocationData().apply {
                this.longitude = longitude
                this.latitude = latitude
                this.address = address
            }
        )
    }

    CoreTheme {
        BackTitlePage(action = {
            CoreTextButton(onClick = {
                scope.launch(Dispatchers.IO) {
                    dialog.show { SyncIndicator() }
                    withContext(Dispatchers.Main) {
                        webView?.evaluateJavascript(
                            "takeSnapshot()",
                            null
                        )
                    }
                    // 5秒超时后,如果js截图还不完成就不管了。
                    var timeout = 5000
                    do {
                        if (snapshotBitmap != null) {
                            break
                        }
                        timeout -= 100
                        if(timeout <= 0) {
                            break
                        }
                        delay(100)
                    } while (isActive)
                    val result = prepareResult()
                    dialog.dismiss()
                    onFinish(result)
                }
            }, enabled = webView != null) {
                CoreText(text = stringResource(id = R.string.send), )
            }

        }) {
            var progress by remember { mutableStateOf(0) }
            var done by remember { mutableStateOf(false) }
            Box(modifier = Modifier
                .fillMaxWidth()
                .weight(1f)) {
                AndroidView(
                    factory = {
                        WebView(it).apply {
                            webView = this
                            settings.javaScriptEnabled = true
                            settings.domStorageEnabled = true
                            settings.databaseEnabled = true
                            settings.blockNetworkImage = false
                            settings.blockNetworkLoads = false
                            settings.loadsImagesAutomatically = true
                            settings.allowUniversalAccessFromFileURLs = true
                            settings.allowFileAccessFromFileURLs = true
                            settings.allowFileAccess = true
                            settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
                            webChromeClient = object : WebChromeClient() {
                                override fun onProgressChanged(view: WebView?, newProgress: Int) {
                                    progress = newProgress
                                }
                            }
                            webViewClient = object: WebViewClient() {
                                override fun onPageFinished(view: WebView?, url: String?) {
                                    super.onPageFinished(view, url)
                                    done = true
                                }
                            }

                            class Bridge {
                                @JavascriptInterface
                                fun locationUpdate(lng: Double, lat: Double) {
                                    longitude = lng
                                    latitude = lat
                                }
                                @JavascriptInterface
                                fun snapshotUpdate(data: String) {
                                    val index = data.indexOfFirst { c -> c == ',' }
                                    val bytes = Base64.decode(data.substring(index+1, data.length))
                                    snapshotBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
                                }
                            }
                            addJavascriptInterface(Bridge(), "bridge")
                            loadUrl(
                                Chatty.getLocationChooseUrl(
                                    longitude = locationData.longitude,
                                    latitude = locationData.latitude
                                )
                            )
                        }
                    },
                    modifier = Modifier
                        .fillMaxSize()
                        .onSizeChanged { size = it },
                ) {
                    it.webChromeClient = object : WebChromeClient() {
                        override fun onProgressChanged(view: WebView?, newProgress: Int) {
                            progress = newProgress
                        }
                    }
                }

                LaunchedEffect(done, size) {
                    if (done) {
                        val w = with(density) { size.width.toDp().value.toInt() }
                        val h = with(density) { size.height.toDp().value.toInt() }
                        withContext(Dispatchers.Main) {
                            webView?.evaluateJavascript(
                                "onSizeChanged(${w},${h})",
                                null
                            )
                        }
                    }
                }
            }
            CoreText(text = address)
        }
    }
}