Android图片拼接与GIF生成原理:为什么你的图总是对不齐?

你大概率遇到过这个场景:选了五张照片拼长图,拼出来第一张正常,第二张往左偏了半个身位。或者做了个GIF,明明每张图都处理好了,动起来却左右横跳,看得人头晕。这些问题的根源就一个:你没有统一画布基准,也没有居中绘制。
这篇文章不讲API怎么调,就讲清楚三件事:图片拼接时坐标到底怎么算,为什么必须统一尺寸加居中,以及这套逻辑怎么直接平移给GIF生成。读完你能自己手写拼接和GIF生成的核心代码,并且知道每一行在干什么。

第一部分:画布是什么,第一版代码为什么必崩
任何图片处理,本质上就是三件事:定一块矩形内存区域当画布,算清楚每张图放在画布的哪个坐标,然后把像素填进去。拼接就是把多张图填到同一块区域,GIF就是把多张图按时间顺序依次填到同一块区域。所以第一个问题永远是:这块画布设多大?
很多人的第一版代码长这样:拿第一张图的宽度当画布宽,拿所有图高度之和当画布高,然后一张一张往下贴。假设第一张图宽800,第二张图宽500,画布宽度是800,第二张图画上去之后右边空300像素,如果画布背景是透明的,那300像素就是空的,如果背景是白色,就是一块白边。如果第二张图宽1000,比画布还大,它根本画不进去,要么被截断右边200像素,要么系统直接抛异常。
正确做法是先把所有图过一遍,找出最大宽度,以这个最大值作为画布宽度。代码实现就是遍历取最大值:

val maxWidth = bitmaps.maxOf { it.width }
var totalHeight = 0
for (bmp in bitmaps) {
    totalHeight += bmp.height
}
val result = Bitmap.createBitmap(maxWidth, totalHeight, Bitmap.Config.ARGB_8888)

取最大的原因是大的能装下小的,但小的装不下大的。取最大之后每张图都有了容身之地,剩下的只是放在哪个位置的问题。这个取最大的动作是整个算法的地基,跳过这一步后面全是补丁。

画布定好之后,很多人继续犯错——直接从(0,0)坐标开始画第一张图,然后第二张图贴在下面也从(0, currentY)开始画。这就是左对齐堆叠,代码最短,效果最烂。人的视觉习惯是找对称和重心,左对齐会让窄图贴在左边右边空一大截,整个画面歪向一侧。宽图如果超过基准宽度还会被裁掉右边内容,用户根本不知道裁了什么。
正确做法是居中绘制。计算偏移量就是画布尺寸减图片尺寸除以二:

val offsetX = (maxWidth - bmp.width) / 2f
canvas.drawBitmap(bmp, offsetX, currentY.toFloat(), null)

垂直方向同理。横向拼接时高度取最大值,每张图垂直居中。网格拼接时每个格子取统一尺寸,每张图在格子内部居中。这套居中逻辑无论纵向横向网格都能复用,而且跟GIF生成完全一致。

第二部分:透明背景为什么必须选
画布定好了,位置算好了,接下来画图。但画之前有个问题:画布的背景色是什么?大多数Bitmap默认是黑色或未初始化的乱数据,你不主动填充,画出来可能花屏或全黑。很多人填充白色,因为看着干净但你应该填充透明,也就是Color.TRANSPARENT。
原因有三条。第一,你要拼的图可能本身带透明区域,比如一张PNG图标只有中间有内容周围是透明的,白底一铺透明区域全变白,圆角变直角。第二,拼完之后你可能还要继续编辑,比如加文字水印或贴纸,透明背景让你可以随意叠加而不会盖住下面图层的边缘。第三,存成PNG发出去之后别人可以在任意颜色的背景上展示,没有白边干扰。
具体实现就两行:

val result = Bitmap.createBitmap(maxWidth, totalHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
canvas.drawColor(Color.TRANSPARENT)

第一行指定ARGB_8888配置,每个像素用4个字节存Alpha透明度、红、绿、蓝。如果用RGB_565,只有红绿蓝没有透明度,存不了透明信息。第二行把画布所有像素的Alpha通道设为0,完全透明,后续画上去的内容如果自带透明通道就能正确叠加。

有一个细节你需要知道:透明背景只对PNG和WEBP有效。JPG格式不支持透明通道,你画了透明背景保存成JPG时,系统会自动把它转成白色或黑色。代码里要根据用户选的格式做判断,选JPG就填充白色背景,选PNG或WEBP才用透明背景。如果不管格式一律用透明背景,用户选JPG存出来发现预览跟成品不一样,问题就出在这。

第三部分:完整拼接流程的代码骨架

把上面的逻辑串起来,纵向拼接的完整函数长这样:

fun mergeVertical(bitmaps: List<Bitmap>): Bitmap? {
    if (bitmaps.isEmpty()) return null
    
    val maxWidth = bitmaps.maxOf { it.width }
    var totalHeight = 0
    for (bmp in bitmaps) {
        totalHeight += bmp.height
    }
    
    val requiredMem = maxWidth.toLong() * totalHeight.toLong() * 4
    if (requiredMem > Runtime.getRuntime().maxMemory() / 2) {
        return null
    }
    
    val result = Bitmap.createBitmap(maxWidth, totalHeight, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(result)
    canvas.drawColor(Color.TRANSPARENT)
    
    var currentY = 0
    for (bmp in bitmaps) {
        val offsetX = (maxWidth - bmp.width) / 2f
        canvas.drawBitmap(bmp, offsetX, currentY.toFloat(), null)
        currentY += bmp.height
    }
    return result
}

这里多了一步内存检查:requiredMem是拼接结果图所需的内存字节数,宽乘高乘4是因为ARGB_8888每个像素占4字节。如果这个值超过系统最大可用内存的一半,直接返回null让上层处理,而不是硬撑导致OOM崩溃。
横向拼接就是把宽高对调:

fun mergeHorizontal(bitmaps: List<Bitmap>): Bitmap? {
    if (bitmaps.isEmpty()) return null
    
    val maxHeight = bitmaps.maxOf { it.height }
    var totalWidth = 0
    for (bmp in bitmaps) {
        totalWidth += bmp.width
    }
    
    val requiredMem = totalWidth.toLong() * maxHeight.toLong() * 4
    if (requiredMem > Runtime.getRuntime().maxMemory() / 2) return null
    
    val result = Bitmap.createBitmap(totalWidth, maxHeight, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(result)
    canvas.drawColor(Color.TRANSPARENT)
    
    var currentX = 0
    for (bmp in bitmaps) {
        val offsetY = (maxHeight - bmp.height) / 2f
        canvas.drawBitmap(bmp, currentX.toFloat(), offsetY, null)
        currentX += bmp.width
    }
    return result
}

网格拼接稍微复杂一点,因为要先算格子尺寸,再算每个格子的位置,最后在格子内部居中。单元格宽高取所有图片的最大值,而不是取平均值或最小值:

fun mergeGrid(bitmaps: List<Bitmap>, rows: Int, cols: Int, spacing: Int): Bitmap? {
    val totalCells = rows * cols
    if (bitmaps.isEmpty() || totalCells == 0) return null
    
    val cellWidth = bitmaps.maxOf { it.width }
    val cellHeight = bitmaps.maxOf { it.height }
    val totalWidth = cellWidth * cols + spacing * (cols - 1)
    val totalHeight = cellHeight * rows + spacing * (rows - 1)
    
    val requiredMem = totalWidth.toLong() * totalHeight.toLong() * 4
    if (requiredMem > Runtime.getRuntime().maxMemory() / 2) return null
    
    val result = Bitmap.createBitmap(totalWidth, totalHeight, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(result)
    canvas.drawColor(Color.TRANSPARENT)
    
    for (i in 0 until rows) {
        for (j in 0 until cols) {
            val index = i * cols + j
            if (index >= bitmaps.size) break
            val bmp = bitmaps[index]
            val x = j * (cellWidth + spacing)
            val y = i * (cellHeight + spacing)
            val offsetX = (cellWidth - bmp.width) / 2f
            val offsetY = (cellHeight - bmp.height) / 2f
            canvas.drawBitmap(bmp, x + offsetX, y + offsetY, null)
        }
    }
    return result
}

如果你选的图片数量少于格子总数,比如选了5张但要排成3行3列共9格,剩余4格留空而不是报错。这里用break跳出循环就行,画布初始化是透明的,没画到的格子自然就是空的。

第四部分:GIF生成就是时间维度的拼接
不做任何处理的话就会出现下面这样的问题:
图片示例
很多人觉得GIF生成很复杂,要调专门的库要写编码器。但如果你已经搞定了图片拼接,GIF的核心你已经会了。GIF的本质就是在时间轴上依次显示图片帧,每一帧是一张独立的图片,快速切换形成动画。GIF翻车的唯一原因就是每一帧的尺寸不一致:第一帧800×600,第二帧500×400,播放时画面会前后跳动,因为每一帧的左上角对齐位置不同。
解决方案就是拼接那套逻辑:先遍历所有帧取最大宽高,然后每帧居中绘制到统一尺寸的画布上,生成一组新图片作为帧序列。代码实现:

fun prepareFramesForGif(frames: List<Bitmap>): List<Bitmap> {
    if (frames.isEmpty()) return emptyList()
    
    val maxW = frames.maxOf { it.width }
    val maxH = frames.maxOf { it.height }
    val result = mutableListOf<Bitmap>()
    
    for (frame in frames) {
        val unified = Bitmap.createBitmap(maxW, maxH, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(unified)
        canvas.drawColor(Color.TRANSPARENT)
        val x = (maxW - frame.width) / 2f
        val y = (maxH - frame.height) / 2f
        canvas.drawBitmap(frame, x, y, null)
        result.add(unified)
    }
    return result
}

预处理完之后,把这组统一尺寸的Bitmap传给GIF编码器,设置帧间隔(一般100到500毫秒),编码输出。这一步做完之后GIF播放时画面纹丝不动,每一帧的边界完全重合,只有内容在变化。如果你之前写过拼接代码,这段逻辑几乎可以直接复制粘贴改几个变量名。

关于GIF编码器的选型,Android上可以用AnimatedGifEncoder这个开源库,或者用ImageIO配合GIFSequenceWriter。服务端用Python的话,PIL的Image.save()直接传save_all=True和duration参数就能生成。不同平台的API不同,但帧预处理的逻辑完全一样。

第五部分:保存时的格式判断和EXIF处理

拼接或GIF生成完之后要存到相册。这里有一个容易出问题的环节:保存格式的选择和EXIF信息的保留。
Android 10开始强制分区存储,不能用File直接写文件到相册了,要用MediaStore API。核心写法是这样的:

fun saveBitmap(
    context: Context,
    bitmap: Bitmap,
    fileName: String,
    format: Bitmap.CompressFormat,
    quality: Int,
    sourceUri: Uri?
): Uri? {
    val resolver = context.contentResolver
    val mimeType = when (format) {
        Bitmap.CompressFormat.JPEG -> "image/jpeg"
        Bitmap.CompressFormat.PNG -> "image/png"
        Bitmap.CompressFormat.WEBP -> "image/webp"
        else -> "image/jpeg"
    }
    
    val contentValues = ContentValues().apply {
        put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
        put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            put(MediaStore.MediaColumns.RELATIVE_PATH, 
                Environment.DIRECTORY_PICTURES + "/PictureToolbox")
            put(MediaStore.MediaColumns.IS_PENDING, 1)
        }
    }
    
    val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
    uri?.let {
        resolver.openOutputStream(it)?.use { stream ->
            bitmap.compress(format, quality, stream)
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            contentValues.clear()
            contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
            resolver.update(it, contentValues, null, null)
        }
    }
    return uri
}

IS_PENDING字段是Android 10引入的,表示文件正在写入中,其他应用暂时不能访问。写入完成后设为0,系统才知道文件已就绪。如果漏掉这一步,相册里可能看不到刚保存的图片,或者显示为损坏文件。

关于EXIF信息,用户原图可能有拍摄时间、地理位置、旋转方向等元数据,处理完之后这些信息默认全丢了。如果你要保留,需要在保存时从原图读取EXIF再写到结果图里。但注意PNG格式不支持EXIF,只有JPEG支持:

fun copyExif(sourceUri: Uri, destPath: String) {
    try {
        val sourceExif = ExifInterface(context.contentResolver.openInputStream(sourceUri)!!)
        val destExif = ExifInterface(destPath)
        val tags = listOf(
            ExifInterface.TAG_MAKE,
            ExifInterface.TAG_MODEL,
            ExifInterface.TAG_DATETIME,
            ExifInterface.TAG_ORIENTATION,
            ExifInterface.TAG_ISO,
            ExifInterface.TAG_EXPOSURE_TIME,
            ExifInterface.TAG_APERTURE_VALUE,
            ExifInterface.TAG_FOCAL_LENGTH
        )
        for (tag in tags) {
            val value = sourceExif.getAttribute(tag)
            if (value != null) {
                destExif.setAttribute(tag, value)
            }
        }
        destExif.saveAttributes()
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

这个函数只对JPEG调用,PNG和WEBP调用会抛异常,调用前要判断格式。

第六部分:实际踩过的坑

OOM的处理。加载图片时用BitmapFactory.Options的inSampleSize做采样压缩。一张4000×3000的照片,如果最终拼接只需要800宽,采样系数设为5,加载后的内存从48MB降到不到2MB。拼接时如果估算内存超过可用一半,主动降采样或提示用户减少图片数量。处理完的Bitmap要及时调用recycle()释放Native内存,循环里处理多张图的时候,每处理完一张就回收一张,不要让它们同时驻留。
EXIF方向信息的处理。手机拍照时传感器记录了旋转方向,横拍竖拍倒置都有对应的Orientation值。加载图片时必须读取这个值做旋转校正,否则显示出来是歪的。保存JPEG时要把原图的Orientation复制到结果图,这样用户在相册里看才是正的。PNG不支持EXIF,所以选PNG格式保存时方向信息会丢失,这一点要在UI上提示用户。

图片去重。用户可能重复选了同一张图,或者两张内容相同但文件名不同的图。拼出来之后连续重复帧浪费空间也难看。用MD5或SHA-1对图片内容做哈希可以精确去重,但两张图分辨率不同但内容相同的情况哈希值不同,这种需要用户手动判断,代码里不要自动删除。
保存路径适配。Android 10之前可以直接写/sdcard/Pictures/目录,Android 10之后必须用MediaStore。如果用旧方式,Android 11以上会直接报FileNotFoundException没有权限。统一用MediaStore接口,不要再碰File。

PNG文件太大的问题。一张拼接长图如果宽1000高8000,PNG格式可能超过10MB。用户如果只为了发朋友圈,根本不需要无损,让他在JPEG和WEBP里选。保存对话框里给个质量滑条默认90%,并且明确提示PNG无损但文件大,JPEG有损但文件小。WEBP作为折中方案可以设为默认选中。

结尾

拼接和GIF生成说到底就三条原则:统一基准,居中绘制,透明背景。统一基准就是取所有图片的最大宽高作为画布尺寸,居中绘制就是每张图在画布内居中放置而不是左对齐,透明背景就是用ARGB_8888配合透明填充而不是白色背景。做到这三点拼接图不歪GIF不跳,代码逻辑横向纵向网格GIF全复用,换平台只需要翻译API调用,算法逻辑一字不改。
之后会发布对应的图片合并软件小工具如果有需要关注一下,方便之后发布新内容获取软件工具

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值