Jetpack Compose 截取可组合函数的屏幕截图?

Jetpack Compose take screenshot of composable function?

我想截取 Jetpack Compose 上特定可组合函数的屏幕截图。我怎样才能做到这一点?请任何人帮助我。我想截取可组合函数的屏幕截图并与其他应用程序共享。

我的函数示例:

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

这个功能怎么截图?

您可以使用 @Preview 创建预览功能,运行 phone 或模拟器上的功能并截取​​组件的屏幕截图。

您可以创建一个测试,将内容设置为该可组合项,然后调用 composeTestRule.captureToImage()。它 returns 一个 ImageBitmap.

屏幕截图比较器中的用法示例:https://github.com/android/compose-samples/blob/e6994123804b976083fa937d3f5bf926da4facc5/Rally/app/src/androidTest/java/com/example/compose/rally/ScreenshotComparator.kt

如评论中@Commonsware所述,并假设这是而非关于屏幕截图测试:

根据 official docs you can access the view version of your composable function using 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方法是Fileclass的简单扩展函数。示例:

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

我在 tests 中寻找如何截取可组合项的屏幕截图,这个问题出现在结果中的第一个。因此,对于想要在测试中 take/save/compare 屏幕截图或进行 屏幕截图测试 的未来用户,我将答案放在这里(感谢 this)。

确保您拥有此依赖项以及其他 Compose 依赖项:

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

注意:除了上面的依赖,你可以简单地在androidTest[=37=中添加一个AndroidManifest.xml文件] 目录并在 manifest 中添加 <activity android:name="androidx.activity.ComponentActivity" />application 元素。
参考this answer.

以下是截取、保存、读取和比较名为 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)
        }
    }
}

我也回答过类似的问题

使用 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(),
        )
    }
}

您可以使用 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 一起放置,它仍会出现在图像上。

p.s。有一个 bug 会产生 Modifier.graphicsLayer 效果,offset { IntOffset(...) }(在这种情况下您仍然可以使用 offset(dp)),scrollable 和惰性视图位置无法正确显示屏幕截图。如果您遇到过,请给问题加注星号以引起更多关注。

我制作了一个小型库,可以单次或定期对可组合项进行屏幕截图。

用于捕获和存储 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 [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
) {
    internal var callback: (() -> Bitmap?)? = null

    private val bitmapState = mutableStateOf(callback?.invoke())

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

    val liveScreenshotFlow = flow {
        while (true) {
            val bmp = callback?.invoke()
            bmp?.let {
                emit(it)
            }
            delay(timeInMillis)
        }
    }

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

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

捕获其子可组合项屏幕截图的可组合项

/**
 * 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 = LocalView.current

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

    DisposableEffect(Unit) {

        var bitmap: Bitmap? = null

        screenshotState.callback = {
            composableBounds?.let { bounds ->

                if (bounds.width == 0f || bounds.height == 0f) return@let

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

                bitmap?.let { bmp ->
                    val canvas = Canvas(bmp)
                        .apply {
                            translate(-bounds.left, -bounds.top)
                        }
                    view.draw(canvas)
                }
            }
            bitmap
        }

        onDispose {
            bitmap?.apply {
                if (!isRecycled) {
                    recycle()
                    bitmap = null
                }
            }
            screenshotState.callback = null
        }
    }

    Box(modifier = modifier
        .onGloballyPositioned {
            composableBounds = it.boundsInRoot()
        }
    ) {
        content()
    }
}

实施

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")
}

结果