【Compose】将Compostion(Composable)保存为截图/图片

645 阅读3分钟

项目要求:

要将页面中一部分内容渲染后保存为图片

直接上代码

第1种方式:依靠Android原生

优点:

  • 可做到离屏绘制

  • 方便使用逻辑代码控制Composition显示、隐藏等

  • 可以使用LazyColunm组件

缺点:

  • 生成图片依靠一部分Android端原生能力,无法跨端

  • 网络图片需要主动预加载到内存中,否则图片无法显示

1. 获取Bitmap

  • 根据宽、高和屏幕密度,计算像素宽、像素高。

  • 创建一个visibility == View.INVISIBLEFrameLayout的布局作为Composition的容器。

  • 将需要保存图片的Composition添加到一个ComposeView中,并且设置ComposeViewViewCompositionStrategyViewCompositionStrategy.DisposeOnDetachedFromView

  • 将需要Composition添加到FrameLayout中。

  • FrameLayout添加到DecorView中。

  • ComposeView创建测量规范,如果没有提供高度或者宽度,则使用AT_MOST让内容决定高度。

  • 等待ComposeView绘制并得到Bitmap

  • 获取成功获取Bitmap后,将FrameLayoutDecorView中移除。

class BitmapComposer constructor(private val coroutineScope: CoroutineScope) {
    suspend fun composableToBitmap(
        activity: Activity,
        width: Dp? = null,
        height: Dp? = null,
        screenDensity: Density,
        content: @Composable () -> Unit
    ): Bitmap = suspendCancellableCoroutine { continuation ->
        coroutineScope.launch {
            val contentWidthInPixels = (screenDensity.density * (width ?: 3000.dp).value).roundToInt()
            val contentHeightInPixels = (screenDensity.density * (height ?: 3000.dp).value).roundToInt()

            val composeViewContainer = FrameLayout(activity).apply {
                layoutParams = ViewGroup.LayoutParams(contentWidthInPixels, contentHeightInPixels)
                visibility = View.INVISIBLE // Keep it invisible
            }

            val composeView = ComposeView(activity).apply {
                setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
                setContent { content() }
            }

            composeViewContainer.addView(composeView)

            val decorView = activity.window.decorView as ViewGroup
            decorView.addView(composeViewContainer) 

            val widthMeasureSpecs = if (width == null) {
                View.MeasureSpec.AT_MOST 
            } else {
                View.MeasureSpec.EXACTLY
            }

            val heightMeasureSpecs = if (height == null) {
                View.MeasureSpec.AT_MOST 
            } else {
                View.MeasureSpec.EXACTLY
            }

            Handler(Looper.getMainLooper()).post {
                // ask for the container view to measure itself
                composeViewContainer.measure(
                    View.MeasureSpec.makeMeasureSpec(
                        contentWidthInPixels,
                        widthMeasureSpecs
                    ),
                    View.MeasureSpec.makeMeasureSpec(
                        contentHeightInPixels,
                        heightMeasureSpecs
                    )
                )

                composeViewContainer.layout(0, 0, contentWidthInPixels, contentHeightInPixels)

                val bitmap = composeView.drawToBitmap() 
                continuation.resume(bitmap) 
                decorView.removeView(composeViewContainer)
            }
        }
    }
}

2. 实际使用代码

data class ComposableSnapshot(
    val currentActivity: Activity,
    val screenDensity: Density,
    val composableView: @Composable () -> Unit
)
@Composable
fun MainPage(uiState: MainUiState) {
    val screenDensity = LocalDensity.current
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    Scaffold() {innerPadding -> 
        Button(onClick = {
            val bitmapComposer = BitmapComposer(CoroutineScope(Dispatchers.IO))
            scope.launch(Dispatchers.IO){
                bitmap = bitmapComposer.composableToBitmap(
                activity = context as Activity,
                width = 375.dp,
                screenDensity = screenDensity,
                content = {
                    SaveImage(uiState) 可以根据提供的State来进行渲染,包括网络图片也是可以的
                    }
                )
                uiState.saveImage(bitmap) // 保存图片的逻辑, 在本文前面
            }
        }) {
            Text("保存图片")
        }
    }
}

第2种方式:只使用Compose能力(此方式是FunnySaltyFish这位同学在评论区告诉我的)

优点:

  • 生成图片,完全依靠Compose,具备跨端能力

  • 网络图片无需预加载

  • 实现方式简单直接

缺点:

  • 只能捕获当前已经在屏幕上渲染(或至少已经组合和布局完成)的内容

  • 无法离屏绘制,无法使用LazyColumn组件(因为是惰性列表,为了优化性能,只会组合和布局当前可见在视图内的项)

实际使用代码

@Composable
fun SecondPage(uiState: SecondUiState) {
    Scaffold() { innerPadding ->
        val graphicsLayer = rememberGraphicsLayer()
        val scope = rememberCoroutineScope()
        Column(
            modifier = Modifier.fillMaxWidth()
                .drawWithContent {
                    graphicsLayer.record {
                        this@drawWithContent.drawContent()
                    }
                    drawLayer(graphicsLayer)
                }
        ){
            for(it in 0..50){
                Text("第${it}Item")
            }
            Button(onClick = {
                scope.launch {
                    val imageBitmap = graphicsLayer.toImageBitmap()
                    val androidBitmap = imageBitmap.asAndroidBitmap()
                    uiState.onSaveImg(androidBitmap)
                }
            }) {
                Text("截图")
            }
        }
    }
}

获取bitmap后,对图片进行保存

// 先定义一个文件名
val imgName = "${System.currentTimeMillis()}.png"

项目AndroidManifest.xml清单可以不声明写入权限,可两种情况:

1. 第一种:当安卓设备API大于等于Android API 29(Code Q)可直接保存到手机的Pictures目录下

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    // 保存到文件
    val values = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, imgName)
        put(MediaStore.Images.Media.MIME_TYPE, "image/png")
        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
    }
    val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
    val stream = if (uri != null) {
        context.contentResolver.openOutputStream(uri)
    } else {
        null
    }
    if (stream != null) {
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
    }
    // 释放bitmap
    bitmap.recycle()
} 

2. 第二种:当安卓设备API小于Android API 29时(Code Q)需要用户手动选择目录后,保存截图/图片

saveImageResult.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
    // 此处要在全局将之前获取的bitmap保存起来
    _bitmap.value = bitmap
    addCategory(Intent.CATEGORY_OPENABLE)
    setType("image/*")
    putExtra(Intent.EXTRA_TITLE, imgName)
})

注册ActivityResult

saveImageResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
    val uri = result.data?.data
    val bitmap = _bitmap.value
    if (result.resultCode == Activity.RESULT_OK && uri != null && bitmap != null) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val baos = ByteArrayOutputStream()
                bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
                val bytes = baos.toByteArray()
                val pfd = AppApplication.context.contentResolver.openFileDescriptor(uri, "w")
                val fileOutputStream = FileOutputStream(pfd!!.fileDescriptor)
                fileOutputStream.write(bytes)
                fileOutputStream.close()
                pfd.close()
            } catch (e: Exception) {
                Log.e(TAG, e)
            } finally {
                bitmap.recycle()
            }
        }
    } else {
        // 此处处理失败逻辑
    }
}

End~

欢迎大家在评论区讨论。