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大图预览,遇到了几个问题,并逐一给出解决方案。

相关推荐
懒人村杂货铺2 小时前
Android BLE 扫描完整实战
android
TeleostNaCl4 小时前
如何安装 Google 通用的驱动以便使用 ADB 和 Fastboot 调试(Bootloader)设备
android·经验分享·adb·android studio·android-studio·android runtime
fatiaozhang95275 小时前
中国移动浪潮云电脑CD1000-系统全分区备份包-可瑞芯微工具刷机-可救砖
android·网络·电脑·电视盒子·刷机固件·机顶盒刷机
2501_915918416 小时前
iOS 开发全流程实战 基于 uni-app 的 iOS 应用开发、打包、测试与上架流程详解
android·ios·小程序·https·uni-app·iphone·webview
lichong9516 小时前
【混合开发】vue+Android、iPhone、鸿蒙、win、macOS、Linux之dist打包发布在Android工程asserts里
android·vue.js·iphone
Android出海6 小时前
Android 15重磅升级:16KB内存页机制详解与适配指南
android·人工智能·新媒体运营·产品运营·内容运营
一只修仙的猿6 小时前
毕业三年后,我离职了
android·面试
编程乐学7 小时前
安卓非原创--基于Android Studio 实现的新闻App
android·ide·android studio·移动端开发·安卓大作业·新闻app
雅雅姐8 小时前
Android14 init.rc中on boot阶段操作4
android
fatiaozhang95278 小时前
中国移动中兴云电脑W132D-RK3528-2+32G-刷机固件包(非原机制作)
android·xml·电脑·电视盒子·刷机固件·机顶盒刷机