
你大概率遇到过这个场景选了五张照片拼长图拼出来第一张正常第二张往左偏了半个身位。或者做了个GIF明明每张图都处理好了动起来却左右横跳看得人头晕。这些问题的根源就一个你没有统一画布基准也没有居中绘制。这篇文章不讲API怎么调就讲清楚三件事图片拼接时坐标到底怎么算为什么必须统一尺寸加居中以及这套逻辑怎么直接平移给GIF生成。读完你能自己手写拼接和GIF生成的核心代码并且知道每一行在干什么。第一部分画布是什么第一版代码为什么必崩任何图片处理本质上就是三件事定一块矩形内存区域当画布算清楚每张图放在画布的哪个坐标然后把像素填进去。拼接就是把多张图填到同一块区域GIF就是把多张图按时间顺序依次填到同一块区域。所以第一个问题永远是这块画布设多大很多人的第一版代码长这样拿第一张图的宽度当画布宽拿所有图高度之和当画布高然后一张一张往下贴。假设第一张图宽800第二张图宽500画布宽度是800第二张图画上去之后右边空300像素如果画布背景是透明的那300像素就是空的如果背景是白色就是一块白边。如果第二张图宽1000比画布还大它根本画不进去要么被截断右边200像素要么系统直接抛异常。正确做法是先把所有图过一遍找出最大宽度以这个最大值作为画布宽度。代码实现就是遍历取最大值val maxWidthbitmaps.maxOf{it.width}var totalHeight0for(bmpinbitmaps){totalHeightbmp.height}val resultBitmap.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 resultBitmap.createBitmap(maxWidth, totalHeight, Bitmap.Config.ARGB_8888)val canvasCanvas(result)canvas.drawColor(Color.TRANSPARENT)第一行指定ARGB_8888配置每个像素用4个字节存Alpha透明度、红、绿、蓝。如果用RGB_565只有红绿蓝没有透明度存不了透明信息。第二行把画布所有像素的Alpha通道设为0完全透明后续画上去的内容如果自带透明通道就能正确叠加。有一个细节你需要知道透明背景只对PNG和WEBP有效。JPG格式不支持透明通道你画了透明背景保存成JPG时系统会自动把它转成白色或黑色。代码里要根据用户选的格式做判断选JPG就填充白色背景选PNG或WEBP才用透明背景。如果不管格式一律用透明背景用户选JPG存出来发现预览跟成品不一样问题就出在这。第三部分完整拼接流程的代码骨架把上面的逻辑串起来纵向拼接的完整函数长这样fun mergeVertical(bitmaps: ListBitmap): Bitmap?{if(bitmaps.isEmpty())returnnull val maxWidthbitmaps.maxOf{it.width}var totalHeight0for(bmpinbitmaps){totalHeightbmp.height}val requiredMemmaxWidth.toLong()* totalHeight.toLong()*4if(requiredMemRuntime.getRuntime().maxMemory()/2){returnnull}val resultBitmap.createBitmap(maxWidth, totalHeight, Bitmap.Config.ARGB_8888)val canvasCanvas(result)canvas.drawColor(Color.TRANSPARENT)var currentY0for(bmpinbitmaps){val offsetX(maxWidth - bmp.width)/ 2f canvas.drawBitmap(bmp, offsetX, currentY.toFloat(), null)currentYbmp.height}returnresult}这里多了一步内存检查requiredMem是拼接结果图所需的内存字节数宽乘高乘4是因为ARGB_8888每个像素占4字节。如果这个值超过系统最大可用内存的一半直接返回null让上层处理而不是硬撑导致OOM崩溃。横向拼接就是把宽高对调fun mergeHorizontal(bitmaps: ListBitmap): Bitmap?{if(bitmaps.isEmpty())returnnull val maxHeightbitmaps.maxOf{it.height}var totalWidth0for(bmpinbitmaps){totalWidthbmp.width}val requiredMemtotalWidth.toLong()* maxHeight.toLong()*4if(requiredMemRuntime.getRuntime().maxMemory()/2)returnnull val resultBitmap.createBitmap(totalWidth, maxHeight, Bitmap.Config.ARGB_8888)val canvasCanvas(result)canvas.drawColor(Color.TRANSPARENT)var currentX0for(bmpinbitmaps){val offsetY(maxHeight - bmp.height)/ 2f canvas.drawBitmap(bmp, currentX.toFloat(), offsetY, null)currentXbmp.width}returnresult}网格拼接稍微复杂一点因为要先算格子尺寸再算每个格子的位置最后在格子内部居中。单元格宽高取所有图片的最大值而不是取平均值或最小值fun mergeGrid(bitmaps: ListBitmap, rows: Int, cols: Int, spacing: Int): Bitmap?{val totalCellsrows * colsif(bitmaps.isEmpty()||totalCells0)returnnull val cellWidthbitmaps.maxOf{it.width}val cellHeightbitmaps.maxOf{it.height}val totalWidthcellWidth * cols spacing *(cols -1)val totalHeightcellHeight * rows spacing *(rows -1)val requiredMemtotalWidth.toLong()* totalHeight.toLong()*4if(requiredMemRuntime.getRuntime().maxMemory()/2)returnnull val resultBitmap.createBitmap(totalWidth, totalHeight, Bitmap.Config.ARGB_8888)val canvasCanvas(result)canvas.drawColor(Color.TRANSPARENT)for(iin0untilrows){for(jin0untilcols){val indexi * cols jif(indexbitmaps.size)breakval bmpbitmaps[index]val xj *(cellWidth spacing)val yi *(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: ListBitmap): ListBitmap{if(frames.isEmpty())returnemptyList()val maxWframes.maxOf{it.width}val maxHframes.maxOf{it.height}val resultmutableListOfBitmap()for(frameinframes){val unifiedBitmap.createBitmap(maxW, maxH, Bitmap.Config.ARGB_8888)val canvasCanvas(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_allTrue和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 resolvercontext.contentResolver val mimeTypewhen(format){Bitmap.CompressFormat.JPEG -image/jpegBitmap.CompressFormat.PNG -image/pngBitmap.CompressFormat.WEBP -image/webpelse-image/jpeg}val contentValuesContentValues().apply{put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)put(MediaStore.MediaColumns.MIME_TYPE, mimeType)if(Build.VERSION.SDK_INTBuild.VERSION_CODES.Q){put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /PictureToolbox)put(MediaStore.MediaColumns.IS_PENDING,1)}}val uriresolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)uri?.let{resolver.openOutputStream(it)?.use{stream -bitmap.compress(format, quality, stream)}if(Build.VERSION.SDK_INTBuild.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 sourceExifExifInterface(context.contentResolver.openInputStream(sourceUri)!!)val destExifExifInterface(destPath)val tagslistOf(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 valuesourceExif.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高8000PNG格式可能超过10MB。用户如果只为了发朋友圈根本不需要无损让他在JPEG和WEBP里选。保存对话框里给个质量滑条默认90%并且明确提示PNG无损但文件大JPEG有损但文件小。WEBP作为折中方案可以设为默认选中。结尾拼接和GIF生成说到底就三条原则统一基准居中绘制透明背景。统一基准就是取所有图片的最大宽高作为画布尺寸居中绘制就是每张图在画布内居中放置而不是左对齐透明背景就是用ARGB_8888配合透明填充而不是白色背景。做到这三点拼接图不歪GIF不跳代码逻辑横向纵向网格GIF全复用换平台只需要翻译API调用算法逻辑一字不改。之后会发布对应的图片合并软件小工具如果有需要关注一下方便之后发布新内容获取软件工具