三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

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

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

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

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

val maxWidth=bitmaps.maxOf{it.width}var totalHeight=0for(bmpinbitmaps){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())returnnull val maxWidth=bitmaps.maxOf{it.width}var totalHeight=0for(bmpinbitmaps){totalHeight+=bmp.height}val requiredMem=maxWidth.toLong()* totalHeight.toLong()*4if(requiredMem>Runtime.getRuntime().maxMemory()/2){returnnull}val result=Bitmap.createBitmap(maxWidth, totalHeight, Bitmap.Config.ARGB_8888)val canvas=Canvas(result)canvas.drawColor(Color.TRANSPARENT)var currentY=0for(bmpinbitmaps){val offsetX=(maxWidth - bmp.width)/ 2f canvas.drawBitmap(bmp, offsetX, currentY.toFloat(), null)currentY+=bmp.height}returnresult}

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

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

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

fun mergeGrid(bitmaps: List<Bitmap>, rows: Int, cols: Int, spacing: Int): Bitmap?{val totalCells=rows * colsif(bitmaps.isEmpty()||totalCells==0)returnnull 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()*4if(requiredMem>Runtime.getRuntime().maxMemory()/2)returnnull val result=Bitmap.createBitmap(totalWidth, totalHeight, Bitmap.Config.ARGB_8888)val canvas=Canvas(result)canvas.drawColor(Color.TRANSPARENT)for(iin0untilrows){for(jin0untilcols){val index=i * cols + jif(index>=bitmaps.size)breakval 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)}}returnresult}

如果你选的图片数量少于格子总数,比如选了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())returnemptyList()val maxW=frames.maxOf{it.width}val maxH=frames.maxOf{it.height}val result=mutableListOf<Bitmap>()for(frameinframes){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)}returnresult}

预处理完之后,把这组统一尺寸的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)}}returnuri}

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(tagintags){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调用,算法逻辑一字不改。
之后会发布对应的图片合并软件小工具如果有需要关注一下,方便之后发布新内容获取软件工具

← 返回列表