簡體   English   中英

Jetpack Compose 截取可組合 function 的屏幕截圖?

[英]Jetpack Compose take screenshot of composable function?

我想在 Jetpack Compose 上截取特定可組合 function 的屏幕截圖。 我怎樣才能做到這一點? 請任何人幫助我。 我想截取可組合 function 的屏幕截圖並與其他應用程序共享。

我的 function 示例:

@Composable
fun PhotoCard() {
    Stack() {
        Image(imageResource(id = R.drawable.background))
        Text(text = "Example")
    }
}

如何截取這個 function 的截圖?

我制作了一個小型,可以單次或定期對 Composables 進行截圖。

一個 state 用於捕獲和存儲 Bitmap 或 ImageBitmap

/**
 * Create a State of screenshot of composable that is used with that is kept on each recomposition.
 * @param delayInMillis delay before each screenshot
 * if [ScreenshotState.liveScreenshotFlow] is collected.
 */
@Composable
fun rememberScreenshotState(delayInMillis: Long = 20) = remember {
    ScreenshotState(delayInMillis)
}

/**
 * State of screenshot of composable that is used with.
 * @param timeInMillis delay before each screenshot if [liveScreenshotFlow] is collected.
 */
class ScreenshotState internal constructor(
    private val timeInMillis: Long = 20,
) {
    val imageState = mutableStateOf<ImageResult>(ImageResult.Initial)

    val bitmapState = mutableStateOf<Bitmap?>(null)

    internal var callback: (() -> Unit)? = null

    /**
     * Captures current state of Composables inside [ScreenshotBox]
     */
    fun capture() {
        callback?.invoke()
    }

    val liveScreenshotFlow = flow {
        while (true) {
            callback?.invoke()
            delay(timeInMillis)
            bitmapState.value?.let {
                emit(it)
            }
        }
    }
        .map {
            it.asImageBitmap()
        }
        .flowOn(Dispatchers.Default)


    val bitmap: Bitmap?
        get() = bitmapState.value

    val imageBitmap: ImageBitmap?
        get() = bitmap?.asImageBitmap()
}

根據處理結果包含錯誤或成功的 ImageResult

sealed class ImageResult {
    object Initial : ImageResult()
    data class Error(val exception: Exception) : ImageResult()
    data class Success(val data: Bitmap) : ImageResult()
}

捕獲其子項的屏幕截圖的可組合項

/**
 * A composable that gets screenshot of Composable that is in [content].
 * @param screenshotState state of screenshot that contains [Bitmap].
 * @param content Composable that will be captured to bitmap on action or periodically.
 */
@Composable
fun ScreenshotBox(
    modifier: Modifier = Modifier,
    screenshotState: ScreenshotState,
    content: @Composable () -> Unit,
) {
    val view: View = LocalView.current

    var composableBounds by remember {
        mutableStateOf<Rect?>(null)
    }

    DisposableEffect(Unit) {

        screenshotState.callback = {
            composableBounds?.let { bounds ->
                if (bounds.width == 0f || bounds.height == 0f) return@let

                view.screenshot(bounds) { imageResult: ImageResult ->
                    screenshotState.imageState.value = imageResult

                    if (imageResult is ImageResult.Success) {
                        screenshotState.bitmapState.value = imageResult.data
                    }
                }
            }
        }

        onDispose {
            val bmp = screenshotState.bitmapState.value
            bmp?.apply {
                if (!isRecycled) {
                    recycle()
                }
            }
            screenshotState.bitmapState.value = null
            screenshotState.callback = null
        }
    }

    Box(modifier = modifier
        .onGloballyPositioned {
            composableBounds = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                it.boundsInWindow()
            } else {
                it.boundsInRoot()
            }
        }
    ) {
        content()
    }
}

用於捕獲屏幕截圖的功能。 O 及以上版本的設備需要PixelCopy 您也可以在沒有 Composables 的情況下使用這些功能

fun View.screenshot(
    bounds: Rect
): ImageResult {

    try {

        val bitmap = Bitmap.createBitmap(
            bounds.width.toInt(),
            bounds.height.toInt(),
            Bitmap.Config.ARGB_8888,
        )

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

            // Above Android O not using PixelCopy throws exception
            // https://stackoverflow.com/questions/58314397/java-lang-illegalstateexception-software-rendering-doesnt-support-hardware-bit
            PixelCopy.request(
                (this.context as Activity).window,
                bounds.toAndroidRect(),
                bitmap,
                {},
                Handler(Looper.getMainLooper())
            )
        } else {
            val canvas = Canvas(bitmap)
                .apply {
                    translate(-bounds.left, -bounds.top)
                }
            this.draw(canvas)
            canvas.setBitmap(null)
        }
        return ImageResult.Success(bitmap)
    } catch (e: Exception) {
        return ImageResult.Error(e)
    }
}

fun View.screenshot(
    bounds: Rect,
    bitmapCallback: (ImageResult) -> Unit
) {

    try {

        val bitmap = Bitmap.createBitmap(
            bounds.width.toInt(),
            bounds.height.toInt(),
            Bitmap.Config.ARGB_8888,
        )

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

            // Above Android O not using PixelCopy throws exception
            // https://stackoverflow.com/questions/58314397/java-lang-illegalstateexception-software-rendering-doesnt-support-hardware-bit
            PixelCopy.request(
                (this.context as Activity).window,
                bounds.toAndroidRect(),
                bitmap,
                {
                    when (it) {
                        PixelCopy.SUCCESS -> {
                            bitmapCallback.invoke(ImageResult.Success(bitmap))
                        }
                        PixelCopy.ERROR_DESTINATION_INVALID -> {
                            bitmapCallback.invoke(
                                ImageResult.Error(
                                    Exception(
                                        "The destination isn't a valid copy target. " +
                                                "If the destination is a bitmap this can occur " +
                                                "if the bitmap is too large for the hardware to " +
                                                "copy to. " +
                                                "It can also occur if the destination " +
                                                "has been destroyed"
                                    )
                                )
                            )
                        }
                        PixelCopy.ERROR_SOURCE_INVALID -> {
                            bitmapCallback.invoke(
                                ImageResult.Error(
                                    Exception(
                                        "It is not possible to copy from the source. " +
                                                "This can happen if the source is " +
                                                "hardware-protected or destroyed."
                                    )
                                )
                            )
                        }
                        PixelCopy.ERROR_TIMEOUT -> {
                            bitmapCallback.invoke(
                                ImageResult.Error(
                                    Exception(
                                        "A timeout occurred while trying to acquire a buffer " +
                                                "from the source to copy from."
                                    )
                                )
                            )
                        }
                        PixelCopy.ERROR_SOURCE_NO_DATA -> {
                            bitmapCallback.invoke(
                                ImageResult.Error(
                                    Exception(
                                        "The source has nothing to copy from. " +
                                                "When the source is a Surface this means that " +
                                                "no buffers have been queued yet. " +
                                                "Wait for the source to produce " +
                                                "a frame and try again."
                                    )
                                )
                            )
                        }
                        else -> {
                            bitmapCallback.invoke(
                                ImageResult.Error(
                                    Exception(
                                        "The pixel copy request failed with an unknown error."
                                    )
                                )
                            )
                        }
                    }

                },
                Handler(Looper.getMainLooper())
            )
        } else {
            val canvas = Canvas(bitmap)
                .apply {
                    translate(-bounds.left, -bounds.top)
                }
            this.draw(canvas)
            canvas.setBitmap(null)
            bitmapCallback.invoke(ImageResult.Success(bitmap))
        }
    } catch (e: Exception) {
        bitmapCallback.invoke(ImageResult.Error(e))
    }
}

執行

val screenshotState = rememberScreenshotState()

var progress by remember { mutableStateOf(0f) }

ScreenshotBox(screenshotState = screenshotState) {
    Column(
        modifier = Modifier
            .border(2.dp, Color.Green)
            .padding(5.dp)
    ) {

        Image(
            bitmap = ImageBitmap.imageResource(
                LocalContext.current.resources,
                R.drawable.landscape
            ),
            contentDescription = null,
            modifier = Modifier
                .background(Color.LightGray)
                .fillMaxWidth()
                // This is for displaying different ratio, optional
                .aspectRatio(4f / 3),
            contentScale = ContentScale.Crop
        )

        Text(text = "Counter: $counter")
        Slider(value = progress, onValueChange = { progress = it })
    }
}

捕獲屏幕截圖

Button(onClick = {
    screenshotState.capture()
}) {
    Text(text = "Take Screenshot")
}

結果

在此處輸入圖像描述

您可以創建一個測試,將內容設置為可組合的,然后調用composeTestRule.captureToImage() 它返回一個ImageBitmap

截圖比較器中的使用示例: https : //github.com/android/compose-samples/blob/e6994123804b976083fa937d3f5bf926da4facc5/Rally/app/src/androidTest/java/com/example/compose/rally/ScreenshotComparator.kt

正如@Commonsware在評論中提到的,假設這與屏幕截圖測試無關

根據官方文檔,您可以使用LocalView.current訪問可組合函數的視圖版本,並將該視圖導出到這樣的位圖文件(以下代碼位於可組合函數內部):

    val view = LocalView.current
    val context = LocalContext.current

    val handler = Handler(Looper.getMainLooper())
    handler.postDelayed(Runnable {
        val bmp = Bitmap.createBitmap(view.width, view.height,
            Bitmap.Config.ARGB_8888).applyCanvas {
            view.draw(this)
        }
        bmp.let {
            File(context.filesDir, "screenshot.png")
                .writeBitmap(bmp, Bitmap.CompressFormat.PNG, 85)
        }
    }, 1000)

writeBitmap方法是 File 類的簡單擴展函數。 例子:

private fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
    outputStream().use { out ->
        bitmap.compress(format, quality, out)
        out.flush()
    }
}

您可以使用onGloballyPositioned在根組合視圖中獲取可組合視圖的位置,然后將根視圖的所需部分繪制onGloballyPositioned Bitmap

val view = LocalView.current
var capturingViewBounds by remember { mutableStateOf<Rect?>(null) }
Button(onClick = {
    val bounds = capturingViewBounds ?: return@Button
    val image = Bitmap.createBitmap(
        bounds.width.roundToInt(), bounds.height.roundToInt(),
        Bitmap.Config.ARGB_8888
    ).applyCanvas {
        translate(-bounds.left, -bounds.top)
        view.draw(this)
    }
}) {
    Text("Capture")
}
ViewToCapture(
    modifier = Modifier
        .onGloballyPositioned {
            capturingViewBounds = it.boundsInRoot()
        }
)

請注意,如果您在ViewToCapture之上有一些視圖,例如與Box一起放置,它仍然會在圖像上。

ps 有一個錯誤,它使Modifier.graphicsLayer效果、 offset { IntOffset(...) } (在這種情況下您仍然可以使用offset(dp) )、 scrollable和惰性視圖位置未正確顯示在屏幕截圖上。 如果您遇到過,請為該問題加注星標以獲得更多關注。

您可以使用 @Preview 創建預覽功能,在手機或模擬器上運行該功能並截取​​組件的屏幕截圖。

使用 PixelCopy 對我有用:

@RequiresApi(Build.VERSION_CODES.O)
suspend fun Window.drawToBitmap(
    config: Bitmap.Config = Bitmap.Config.ARGB_8888,
    timeoutInMs: Long = 1000
): Bitmap {
    var result = PixelCopy.ERROR_UNKNOWN
    val latch = CountDownLatch(1)

    val bitmap = Bitmap.createBitmap(decorView.width, decorView.height, config)
    PixelCopy.request(this, bitmap, { copyResult ->
        result = copyResult
        latch.countDown()
    }, Handler(Looper.getMainLooper()))

    var timeout = false
    withContext(Dispatchers.IO) {
        runCatching {
            timeout = !latch.await(timeoutInMs, TimeUnit.MILLISECONDS)
        }
    }

    if (timeout) error("Failed waiting for PixelCopy")
    if (result != PixelCopy.SUCCESS) error("Non success result: $result")

    return bitmap
}

例子:

val scope = rememberCoroutineScope()
val context = LocalContext.current as Activity
var bitmap by remember { mutableStateOf<Bitmap?>(null) }

Button(onClick = {
    scope.launch {
        //wrap in a try catch/block
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            bitmap = context.window.drawToBitmap()
        }
    }

}) {
    Text(text = "Take Screenshot")
}

Box(
    modifier = Modifier
        .background(Color.Red)
        .padding(10.dp)
) {
    bitmap?.let {
        Image(
            bitmap = it.asImageBitmap(),
            contentDescription = null,
            modifier = Modifier.fillMaxSize(),
        )
    }
}

我一直在尋找如何在測試中截取可組合的屏幕截圖,這個問題出現在結果中的第一個。 因此,對於想要在測試中截取/保存/比較屏幕截圖或進行屏幕截圖測試的未來用戶,我將我的答案放在這里(感謝這個)。

確保您擁有此依賴項以及其他 Compose 依賴項:

debugImplementation("androidx.compose.ui:ui-test-manifest:<version>")

注意:您可以簡單地在androidTest目錄中添加一個AndroidManifest.xml文件,並在manifestapplication元素中添加<activity android:name="androidx.activity.ComponentActivity" />而不是上述依賴項。
參考這個答案

以下是獲取、保存、讀取和比較名為MyComposableFunction的可組合函數的屏幕截圖的完整示例:

class ScreenshotTest {

    @get:Rule val composeTestRule = createComposeRule()

    @Test fun takeAndSaveScreenshot() {
        composeTestRule.setContent { MyComposableFunction() }
        val node = composeTestRule.onRoot()
        val screenshot = node.captureToImage().asAndroidBitmap()
        saveScreenshot("screenshot.png", screenshot)
    }

    @Test fun readAndCompareScreenshots() {
        composeTestRule.setContent { MyComposableFunction() }
        val node = composeTestRule.onRoot()
        val screenshot = node.captureToImage().asAndroidBitmap()

        val context = InstrumentationRegistry.getInstrumentation().targetContext
        val path = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        val file = File(path, "screenshot.png")
        val saved = readScreenshot(file)

        println("Are screenshots the same: ${screenshot.sameAs(saved)}")
    }

    private fun readScreenshot(file: File) = BitmapFactory.decodeFile(file.path)

    private fun saveScreenshot(filename: String, screenshot: Bitmap) {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        // Saves in /Android/data/your.package.name.test/files/Pictures on external storage
        val path = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        val file = File(path, filename)
        file.outputStream().use { stream ->
            screenshot.compress(Bitmap.CompressFormat.PNG, 100, stream)
        }
    }
}

我也在這里回答了一個類似的問題。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM