You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose如何实现指定/可滚动Composable精准截图

问题背景

我正在开发一款截图库,最初通过如下代码获取View实例:

val view: View = LocalView.current

我最初的实现逻辑是获取View边界、转换Composable坐标位置,以此捕获Composable的可见区域,ScreenshotBox组件实现代码如下:

@Composable
fun ScreenshotBox(
    modifier: Modifier = Modifier,
    screenshotState: ScreenshotState,
    parentView: ComposeView? = null,
    content: @Composable () -> Unit,
) {
    val view: View = parentView ?: 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()
    }
}

配套的View扩展截图函数实现如下:

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) {

            // Android O以上版本不用PixelCopy会抛出异常
            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(
                                        "目标不是合法的复制对象,通常是因为Bitmap尺寸超过硬件限制、或者目标已经被销毁"
                                    )
                                )
                            )
                        }
                        PixelCopy.ERROR_SOURCE_INVALID -> {
                            bitmapCallback.invoke(
                                ImageResult.Error(
                                    Exception(
                                        "源内容无法复制,通常是因为源内容受硬件保护、或者已经被销毁"
                                    )
                                )
                            )
                        }
                        PixelCopy.ERROR_TIMEOUT -> {
                            bitmapCallback.invoke(
                                ImageResult.Error(
                                    Exception(
                                        "从源获取缓冲区超时"
                                    )
                                )
                            )
                        }
                        PixelCopy.ERROR_SOURCE_NO_DATA -> {
                            bitmapCallback.invoke(
                                ImageResult.Error(
                                    Exception(
                                        "源没有可复制的内容,比如Surface还没有提交任何帧,等待源渲染第一帧后重试即可"
                                    )
                                )
                            )
                        }
                        else -> {
                            bitmapCallback.invoke(
                                ImageResult.Error(
                                    Exception(
                                        "像素复制请求出现未知错误"
                                    )
                                )
                            )
                        }
                    }

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

这个方案存在明显问题:截图会捕获屏幕范围内的所有元素,比如Scaffold中用于触发截图的FloatingActionButton也会被截取到最终Bitmap中,对应的布局代码如下:

Scaffold(
    floatingActionButton = {
        ExtendedFloatingActionButton(onClick = {
            screenshotState.capture()
        }) {
            Text(text = "Capture")
        }
    },
    floatingActionButtonPosition = FabPosition.End,
    content = { paddingValues: PaddingValues ->
        ScreenshotSample(screenshotState, paddingValues)

        if (showDialog) {
            ImageAlertDialog(imageResult = imageResult) {
                showDialog = false
            }
        }
    }
)

我之前尝试实现ScreenshotBoxWithView组件,通过AndroidView包裹ComposeView并将其作为parentView传入ScreenshotBox,实现代码如下:

@Composable
fun ScreenshotBoxWithView(
    modifier: Modifier = Modifier,
    screenshotState: ScreenshotState,
    content: @Composable () -> Unit
) {

    AndroidView(
        factory = {
            ComposeView(it)
        },
        update = {
            it.setContent {
                ScreenshotBox(
                    modifier = modifier,
                    parentView = it,
                    screenshotState = screenshotState
                ) {
                    content()
                }
            }
        }
    )
}

测试后这个方案仍然会把FloatingActionButton截入最终Bitmap。现在需要实现两个核心能力:

  • 对指定Composable做精准截图,不会混入同页面其他组件(比如FAB、弹窗)
  • 支持对配置了Modifier.scroll()的可滚动Composable做全量截图,替代当前从根View捕获全屏内容的实现。
实现方案
  • 原有方案失效的核心原因:不管是拿根View调用draw,还是用PixelCopy截取Window,本质都是从整个窗口的渲染缓冲区取内容,Z轴层级高于目标区域的组件(比如FAB、弹窗)只要和目标区域重叠,就会被一起截进去。单独包ComposeView没有用,因为这个ComposeView仍然在同一个Window的视图树里,渲染时还是和其他组件共用同一个Window的Surface。
  • 精准截图的核心思路:放弃全局Window/根View的绘制捕获,直接从目标Composable自身的绘制流程里拿内容,完全绕开全局视图树的其他组件。
    • 用Modifier.graphicsLayer配合离屏缓冲,在目标Composable绘制的时候,把绘制内容直接输出到我们自建的Bitmap上,这个过程只会捕获当前Composable及其子项的内容,同页面其他组件不会参与这个绘制流程,自然不会被截入。
    • Android 12及以上版本可以直接用Compose官方提供的drawToBitmap()扩展API,本质就是封装了上述离屏绘制逻辑,兼容性和稳定性更好,低版本可以自己用graphicsLayer实现兼容逻辑。
  • 可滚动组件全量截图实现思路:不要只捕获当前可见视口的内容,先获取滚动组件的完整内容尺寸,再把所有内容按实际布局位置绘制到对应尺寸的Bitmap上:
    • 对普通Modifier.verticalScroll/horizontalScroll的组件,可以通过ScrollState.maxValue + 可见视口尺寸计算出内容总宽高,创建对应尺寸的Bitmap后,将Canvas平移对应滚动偏移量,再调用组件绘制即可得到全量长截图。
    • 对LazyColumn/LazyRow这类懒加载组件,通过LazyListState.layoutInfo获取所有已布置item的总尺寸、每个item的偏移量,遍历将所有item绘制到Bitmap对应位置即可,不需要把所有item滚到可见区域。
  • 核心实现参考代码:
// 截图状态类,用来触发截图、持有结果
class ScreenshotState {
    var bitmapState by mutableStateOf<Bitmap?>(null)
    internal var captureTrigger by mutableStateOf(false)

    fun capture() {
        captureTrigger = true
    }
}

@Composable
fun ScreenshotBox(
    modifier: Modifier = Modifier,
    screenshotState: ScreenshotState,
    content: @Composable () -> Unit
) {
    val contentSize = remember { mutableStateOf(IntSize.Zero) }

    DisposableEffect(Unit) {
        onDispose {
            screenshotState.bitmapState?.takeIf { !it.isRecycled }?.recycle()
            screenshotState.bitmapState = null
        }
    }

    Box(
        modifier = modifier
            .onGloballyPositioned {
                contentSize.value = it.size
            }
            .graphicsLayer {
                if (screenshotState.captureTrigger && contentSize.value != IntSize.Zero) {
                    val outputBitmap = Bitmap.createBitmap(
                        contentSize.value.width,
                        contentSize.value.height,
                        Bitmap.Config.ARGB_8888
                    )
                    val canvas = Canvas(outputBitmap)
                    // 将当前层的内容直接绘制到自建Bitmap
                    draw(canvas, 1f)
                    screenshotState.bitmapState = outputBitmap
                    screenshotState.captureTrigger = false
                }
            }
    ) {
        content()
    }
}
  • 如果需要做滚动组件的全量截图,只需要在截图触发时,先测量滚动内容的总尺寸,再把内容完整绘制到对应大小的Bitmap即可,不需要依赖组件在屏幕上的显示位置。

内容的提问来源于stack exchange,提问作者Thracian

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 17:25:05