Android Compose大图加载碰壁之路

背景

近期新增了发布长图,核心诉求:支持双指缩放,下载原图。

整个app 以Compose为主,先前已经引入相关的三方库:

  1. github.com/coil-kt/coi...: 图片加载
  2. github.com/usuiat/Zoom...: 缩放

理所当然,选择基于上述三方库快速构建,但执行过程中发现有很多细节、坑需要处理。

初步接入

kotlin 复制代码
@Composable fun PhotoItem(
    uri: String,
    modifier: Modifier = Modifier,
) {
    AsyncImage(
        model = "https://example.com/image.jpg",
        contentDescription = null,
        modifier = Modifier.zoomable(rememberZoomState()),
    )
}

对于加载长图而言,该版本存在几个问题:

  1. 缺少loading效果
  2. maxScale无法动态适配网络图片宽高
  3. 图片放大后太糊

逐个击破

loading效果

使用SubcomposeAsyncImage

kotlin 复制代码
@Composable fun PhotoItem(
    uri: String,
    modifier: Modifier = Modifier,
) {
    SubcomposeAsyncImage(
        model = uri,
        loading = {
            CircularProgressIndicator()
        },
        modifier = Modifier.zoomable(rememberZoomState()),
    )
}

maxScale无法适配网络图片宽高

目前Zoomable最新版本1.6.1, maxScale作为ZoomState的构造参数,没法动态修改,如果在PhotoItem内部通过remember变量监听coil image result生成ZoomState,会导致图片无法交互的bug。

只能外围包裹一个ZoomablePhotoItem,代码如下:

kotlin 复制代码
@Composable
private fun ZoomablePhotoItem(uri: String) {
    var intrinsicSize by remember {
        mutableStateOf(Size.Zero)
    }
    var layoutSize by remember {
        mutableStateOf(IntSize.Zero)
    }
    key(intrinsicSize, layoutSize) {
        val zoomState: ZoomState = remember(intrinsicSize, layoutSize) {
            if (intrinsicSize == Size.Zero || layoutSize == IntSize.Zero) {
                ZoomState()
            } else {
                val maxScale = ZoomScaleUtil.getMaxZoomScale(
                    layoutWidth = layoutSize.width.toFloat(),
                    layoutHeight = layoutSize.height.toFloat(),
                    imageIntrinsicWidth = intrinsicSize.width,
                    imageIntrinsicHeight = intrinsicSize.height,
                )
                ZoomState(maxScale, intrinsicSize)
            }
        }
        PhotoItem(
            uri = uri,
            zoomState = zoomState,
            onIntrinsicSizeResult = { size ->
                intrinsicSize = size
            },
            onLayoutSizeResult = { intSize ->
                layoutSize = intSize
            },
        )
    }
}

@Composable
private fun PhotoItem(
    uri: String,
    zoomState: ZoomState,
    onIntrinsicSizeResult: (intrinsicSize: Size) -> Unit,
    onLayoutSizeResult: (IntSize) -> Unit,
    modifier: Modifier = Modifier,
) {
    SubcomposeAsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(uri)
            .listener { _, result ->
                val size = Size(
                    result.drawable.intrinsicWidth.toFloat(),
                    result.drawable.intrinsicHeight.toFloat(),
                )
                onIntrinsicSizeResult(size)
            }
            .build(),
        loading = {
            CircularProgressIndicator()
        },
        contentDescription = null,
        contentScale = ContentScale.Fit,
        modifier = modifier
            .fillMaxSize()
            .onSizeChanged(onLayoutSizeResult)
            .zoomable(zoomState),
    )
}

对于计算宽高比,提供了一个util方法:保证图片能放大到没有黑边

  1. scale=1 表示初始状态,同ImageView#centerInside
  2. maxScale=max(图片放大到没有黑边, 5)
kotlin 复制代码
internal object ZoomScaleUtil {
    private const val DEFAULT_MAX_SCALE = 5.0f

    fun getMaxZoomScale(
        layoutWidth: Float,
        layoutHeight: Float,
        imageIntrinsicWidth: Float,
        imageIntrinsicHeight: Float,
    ): Float {
        val layoutRatio = layoutWidth / layoutHeight
        val imageRatio = imageIntrinsicWidth / imageIntrinsicHeight

        val maxScale = if (layoutRatio > imageRatio) {
            // layoutHeight as image default displayHeight
            // displayWidth/displayHeight = imageIntrinsicWidth/imageHeight,  displayHeight = layoutHeight
            val displayWidth = layoutHeight * imageIntrinsicWidth / imageIntrinsicHeight
            layoutWidth / displayWidth
        } else {
            // layoutWidth as image default displayWidth
            // displayWidth/displayHeight = imageIntrinsicWidth/imageHeight,  displayWidth = layoutWidth
            val displayHeight = layoutWidth / (imageIntrinsicWidth / imageIntrinsicHeight)
            layoutHeight / displayHeight
        }

        return maxScale.toBigDecimal().setScale(2, RoundingMode.FLOOR).toFloat().coerceAtLeast(DEFAULT_MAX_SCALE)
    }
}

图片太糊

这是因为coil默认根据视图大小对原图进行了处理,zoomable只对布局进行放大。 所以只需要强制coil加载原图即可。

kotlin 复制代码
...
    SubcomposeAsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(uri)
            .size(coil.size.Size.ORIGINAL), // ORIGINAL表示原图
        ...
    )
...

本以为一切就好了,一运行直接crash了。 原图:

原图 crash日志

173185920 = 1440 x 30067 x 4,好家伙这张图直接干到165M了! 根据日志 可以追踪到RecordingCanvas中上限是100M。

大图处理

可以使用经典的三方库:github.com/davemorriss... 出于以下原因,没有引入该库:

  1. 项目中对三方包的审核较为严格,引入流程很繁琐
  2. 大部分图片<10M(上传限制了10M)

新写了一个 coil Transformation,保证展示的Bitmap内存占用小于100M

kotlin 复制代码
...
    SubcomposeAsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(uri)
            .size(coil.size.Size.ORIGINAL), // ORIGINAL表示原图
            .transformations(LargeImageTransformation()) // 图片压缩至80M以内
            .allowHardware(false)  // 部分图片hardware失败
        ...
    )
...

class LargeImageTransformation : Transformation {
    override val cacheKey: String
        get() = LargeImageTransformation::class.java.name

    override suspend fun transform(input: Bitmap, size: coil.size.Size): Bitmap {
        return input.extractThumbnail(input.byteCount, BITMAP_BYTE_COUNT_MAX_LIMIT)
    }

    companion object {
        // App will crash once Bitmap#getByteCount > 100M when displaying
        // @see android.graphics.RecordingCanvas.throwIfCannotDraw
        private const val BITMAP_BYTE_COUNT_MAX_LIMIT = 80 * 1024 * 1024
    }
}

private fun Bitmap.extractThumbnail(currentSize: Int, limitSize: Int): Bitmap {
    if (currentSize < limitSize) return this
    val ratio = sqrt(currentSize.toFloat() / limitSize) + 0.01
    val width = (this.width / ratio).toInt()
    val height = (this.height / ratio).toInt()
    return Bitmap.createScaledBitmap(this, width, height, true)
}

最终效果:

超宽图 超长图

总结

本文通过Coil和Zoomable实现了Compose大图预览,遇到了几个问题,并逐一给出解决方案。

相关推荐
游戏开发爱好者81 小时前
日常开发与测试的 App 测试方法、查看设备状态、实时日志、应用数据
android·ios·小程序·https·uni-app·iphone·webview
王码码20351 小时前
Flutter for OpenHarmony 实战之基础组件:第三十一篇 Chip 系列组件 — 灵活的标签化交互
android·flutter·交互·harmonyos
黑码哥1 小时前
ViewHolder设计模式深度剖析:iOS开发者掌握Android列表性能优化的实战指南
android·ios·性能优化·跨平台开发·viewholder
亓才孓1 小时前
[JDBC]元数据
android
独行soc2 小时前
2026年渗透测试面试题总结-17(题目+回答)
android·网络·安全·web安全·渗透测试·安全狮
金融RPA机器人丨实在智能2 小时前
Android Studio开发App项目进入AI深水区:实在智能Agent引领无代码交互革命
android·人工智能·ai·android studio
科技块儿2 小时前
利用IP查询在智慧城市交通信号系统中的应用探索
android·tcp/ip·智慧城市
独行soc2 小时前
2026年渗透测试面试题总结-18(题目+回答)
android·网络·安全·web安全·渗透测试·安全狮
王码码20353 小时前
Flutter for OpenHarmony 实战之基础组件:第二十七篇 BottomSheet — 动态底部弹窗与底部栏菜单
android·flutter·harmonyos
2501_915106323 小时前
app 上架过程,安装包准备、证书与描述文件管理、安装测试、上传
android·ios·小程序·https·uni-app·iphone·webview