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
相关产品推荐
相关产品推荐

