项目要求:
要将页面中一部分内容渲染后保存为图片
直接上代码
第1种方式:依靠Android原生
优点:
-
可做到离屏绘制
-
方便使用逻辑代码控制Composition显示、隐藏等
-
可以使用LazyColunm组件
缺点:
-
生成图片依靠一部分Android端原生能力,无法跨端
-
网络图片需要主动预加载到内存中,否则图片无法显示
1. 获取Bitmap
-
根据宽、高和屏幕密度,计算像素宽、像素高。
-
创建一个
visibility == View.INVISIBLE的FrameLayout的布局作为Composition的容器。 -
将需要保存图片的
Composition添加到一个ComposeView中,并且设置ComposeView的ViewCompositionStrategy为ViewCompositionStrategy.DisposeOnDetachedFromView。 -
将需要
Composition添加到FrameLayout中。 -
将
FrameLayout添加到DecorView中。 -
为
ComposeView创建测量规范,如果没有提供高度或者宽度,则使用AT_MOST让内容决定高度。 -
等待
ComposeView绘制并得到Bitmap。 -
获取成功获取
Bitmap后,将FrameLayout从DecorView中移除。
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~
欢迎大家在评论区讨论。