Android实现多张图片合成GIF(附带源码)

一、项目介绍

在图片处理或社交类应用中,将多张静态图片合成动感的 GIF,可用于展示流程演示、轮播图打包、短动画效果等。Android 平台上,直接操作 Bitmap 并生成 GIF 文件,需要借助第三方库或手动实现对 GIF 格式的编码。本项目目标:

  1. 加载多张本地或网络图片,并将它们按照指定帧率合成为一个 GIF 文件;

  2. 支持自定义每帧延迟、循环次数与输出尺寸;

  3. 提供简单的 UI:多选图片、设定参数、生成并预览 GIF;

  4. 演示File I/O运行时权限管理Bitmap 操作异步处理

  5. 完整整合所有依赖、布局与代码,方便“一处复制”即用。

完成本项目后,你将掌握:

  • 引入并使用 GIF 生成库(如 GifEncoderandroid-gif-encoder

  • 在 Android 中对多张图片进行批量压缩、缩放与编码

  • 在后台线程执行耗时操作,并在主线程更新 UI

  • 在内部/外部存储中保存大文件并处理权限

  • 使用 ImageViewGifDrawable 预览本地 GIF


二、相关技术与知识

  1. GIF 格式与编码

    • GIF 是一种基于 LZW 压缩的逐帧动画格式,支持 256 色与透明

    • 常见 Java 实现有 GifEncoder(Kevin Weiner 原版移植)和 android-gif-encoder

  2. Bitmap 操作

    • 加载大图需控制内存,可使用 BitmapFactory.Options.inSampleSize 做降采样

    • 合成前可对每帧做缩放、裁剪、旋转或水印

  3. 异步处理

    • 生成 GIF 属 CPU 密集型与 I/O 密集型操作,应在 AsyncTaskExecutor 线程池中执行

    • 在完成后通过 HandlerrunOnUiThread() 更新 UI

  4. 存储读写权限

    • Android 6.0+ 动态申请 WRITE_EXTERNAL_STORAGE

    • Android 10+ 分区存储,需要使用 MediaStore 或 requestLegacyExternalStorage

  5. 第三方库集成

    • 通过 Gradle 引入 implementation 'com.github.bumptech.glide:glide:...’(若需加载网络图)

    • 引入 GIF 编码库:implementation 'pl.droidsonroids.gif:android-gif-encoder:1.0.3'

  6. UI 组件

    • 使用 RecyclerViewGridView 展示可选图片缩略图

    • 通过 ButtonSeekBar 让用户设定帧延迟、循环次数、输出尺寸

    • 使用 ImageView + GifDrawable 预览已生成的 GIF


三、实现思路

  1. 项目依赖配置

    • app/build.gradle 中添加 GIF 编码库与 Glide(可选)

    • 配置 Android 版本兼容与分区存储策略

  2. 布局设计

    • activity_main.xml:包含

      • RecyclerView(多选图片网格)

      • EditTextSeekBar(延迟、宽高输入)

      • Button(开始生成)

      • ImageView(GIF 预览)

    • 布局整合到 MainActivity 注释中

  3. 多选图片逻辑

    • 使用 Intent.ACTION_OPEN_DOCUMENT 或 Intent.ACTION_GET_CONTENT 多选

    • onActivityResult() 中获取 Uri[],通过 Glide 加载到缩略图和 List<Bitmap>

  4. GIF 生成

    • 在后台线程,遍历 List<Bitmap>

AnimatedGifEncoder encoder = new AnimatedGifEncoder();
encoder.start(outputFilePath);
encoder.setRepeat(loopCount);
encoder.setDelay(frameDelayMs);
for (Bitmap bmp: frames) {
  encoder.addFrame(bmp);
}
encoder.finish();

 对 Bitmap 做必要缩放:

Bitmap scaled = Bitmap.createScaledBitmap(
    bmp, targetWidth, targetHeight, true);

 

  1. 权限与存储

    • 在开始生成前,检查并申请写外部存储权限

    • 输出文件路径可为 getExternalFilesDir("gifs") 下,避免兼容问题

  2. UI 更新

    • 使用 ProgressDialog 或自定义进度条显示生成进度

    • 生成完毕后,通过 runOnUiThread()ImageView 中展示 GIF


四、完整代码

// ==============================================
// 文件:MainActivity.java
// 功能:多张图片合成 GIF 示例
// 包含:布局 XML、Gradle、Manifest、依赖说明
// ==============================================

package com.example.gifmaker;

import android.Manifest;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.ContentResolver;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.*;
import android.provider.OpenableColumns;
import android.view.View;
import android.widget.*;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.bumptech.glide.Glide;
import pl.droidsonroids.gif.encoder.AnimatedGifEncoder;

import java.io.*;
import java.util.ArrayList;
import java.util.List;

public class MainActivity extends AppCompatActivity {

    private static final int REQUEST_CODE_SELECT = 1001;
    private static final int REQUEST_PERM_WRITE = 1002;

    private Button btnSelect, btnGenerate;
    private SeekBar sbDelay, sbLoop;
    private TextView tvDelay, tvLoop;
    private ImageView ivPreview;
    private RecyclerView rvImages;

    private List<Uri> imageUris = new ArrayList<>();
    private List<Bitmap> bitmaps = new ArrayList<>();

    private ProgressDialog progressDialog;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 加载布局(布局 XML 已整合到注释中)
        setContentView(R.layout.activity_main);

        // 绑定控件
        btnSelect   = findViewById(R.id.btnSelect);
        btnGenerate = findViewById(R.id.btnGenerate);
        sbDelay     = findViewById(R.id.sbDelay);
        sbLoop      = findViewById(R.id.sbLoop);
        tvDelay     = findViewById(R.id.tvDelay);
        tvLoop      = findViewById(R.id.tvLoop);
        ivPreview   = findViewById(R.id.ivPreview);
        rvImages    = findViewById(R.id.rvImages);

        // 设置 RecyclerView 为网格布局
        rvImages.setLayoutManager(
          new GridLayoutManager(this, 3));
        final ImageAdapter adapter = new ImageAdapter(imageUris);
        rvImages.setAdapter(adapter);

        // 选择图片
        btnSelect.setOnClickListener(v -> {
            Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
            intent.setType("image/*");
            intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
            startActivityForResult(intent, REQUEST_CODE_SELECT);
        });

        // 更新延迟/循环显示
        sbDelay.setMax(500);
        sbDelay.setProgress(100);
        sbDelay.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
            @Override public void onProgressChanged(SeekBar sb, int p, boolean u) {
                tvDelay.setText("延迟: " + p + "ms");
            }
            @Override public void onStartTrackingTouch(SeekBar sb) { }
            @Override public void onStopTrackingTouch(SeekBar sb) { }
        });
        sbLoop.setMax(10);
        sbLoop.setProgress(0);
        sbLoop.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
            @Override public void onProgressChanged(SeekBar sb, int p, boolean u) {
                tvLoop.setText("循环: " + (p==0?"无限":p+"次"));
            }
            @Override public void onStartTrackingTouch(SeekBar sb) { }
            @Override public void onStopTrackingTouch(SeekBar sb) { }
        });

        // 生成 GIF
        btnGenerate.setOnClickListener(v -> {
            if (bitmaps.isEmpty()) {
                Toast.makeText(this, "请先选择图片", Toast.LENGTH_SHORT).show();
                return;
            }
            if (checkWritePermission()) {
                generateGif();
            } else {
                ActivityCompat.requestPermissions(this,
                  new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
                  REQUEST_PERM_WRITE);
            }
        });
    }

    /** 处理选择结果 */
    @Override protected void onActivityResult(int req, int res, @Nullable Intent data) {
        super.onActivityResult(req, res, data);
        if (req==REQUEST_CODE_SELECT && res==Activity.RESULT_OK && data!=null) {
            imageUris.clear();
            bitmaps.clear();
            if (data.getClipData()!=null) {
                int count = data.getClipData().getItemCount();
                for (int i=0;i<count;i++) {
                    Uri uri = data.getClipData().getItemAt(i).getUri();
                    imageUris.add(uri);
                }
            } else if (data.getData()!=null) {
                imageUris.add(data.getData());
            }
            // 加载 Bitmap 缩略图
            for (Uri uri: imageUris) {
                try {
                    Bitmap bmp = Glide.with(this)
                        .asBitmap()
                        .load(uri)
                        .submit(200,200).get();
                    bitmaps.add(bmp);
                } catch (Exception e) { e.printStackTrace(); }
            }
            rvImages.getAdapter().notifyDataSetChanged();
        }
    }

    /** 检查写权限 */
    private boolean checkWritePermission() {
        return ContextCompat.checkSelfPermission(this,
            Manifest.permission.WRITE_EXTERNAL_STORAGE)
            == PackageManager.PERMISSION_GRANTED;
    }

    /** 生成 GIF */
    private void generateGif() {
        final int delay = sbDelay.getProgress();
        final int loop  = sbLoop.getProgress(); // 0 无限
        progressDialog = ProgressDialog.show(this,
            "生成中", "请稍候...", true);

        new Thread(() -> {
            try {
                // 输出路径
                File dir = new File(
                  getExternalFilesDir(null), "gifs");
                if (!dir.exists()) dir.mkdirs();
                String fname = "output_" + System.currentTimeMillis()+".gif";
                File out = new File(dir, fname);
                FileOutputStream fos = new FileOutputStream(out);

                AnimatedGifEncoder encoder = new AnimatedGifEncoder();
                encoder.start(fos);
                encoder.setRepeat(loop==0?-1:loop-1);
                encoder.setDelay(delay);

                // 按顺序添加帧
                for (Bitmap bmp: bitmaps) {
                    // 可选:缩放到固定大小
                    Bitmap scaled = Bitmap.createScaledBitmap(
                      bmp, bmp.getWidth(), bmp.getHeight(), true);
                    encoder.addFrame(scaled);
                }
                encoder.finish();
                fos.close();

                // UI 更新
                runOnUiThread(() -> {
                    progressDialog.dismiss();
                    Toast.makeText(this,
                      "已生成: " + out.getAbsolutePath(),
                      Toast.LENGTH_LONG).show();
                    // 预览 GIF
                    Glide.with(this)
                      .asGif()
                      .load(out)
                      .into(ivPreview);
                });
            } catch (Exception e) {
                e.printStackTrace();
                runOnUiThread(() -> {
                    progressDialog.dismiss();
                    Toast.makeText(this,
                      "生成失败: " + e.getMessage(),
                      Toast.LENGTH_LONG).show();
                });
            }
        }).start();
    }

    @Override public void onRequestPermissionsResult(int rc,
        @NonNull String[] perms, @NonNull int[] grants) {
        if (rc==REQUEST_PERM_WRITE
         && grants.length>0
         && grants[0]==PackageManager.PERMISSION_GRANTED) {
            generateGif();
        } else {
            Toast.makeText(this,
              "需要存储权限才能保存 GIF",
              Toast.LENGTH_LONG).show();
        }
    }
}

/*
=========================== app/build.gradle 关键依赖 ===========================

dependencies {
    implementation 'androidx.appcompat:appcompat:1.5.1'
    implementation 'androidx.recyclerview:recyclerview:1.2.1'
    implementation 'com.github.bumptech.glide:glide:4.14.2'
    implementation 'pl.droidsonroids.gif:android-gif-encoder:1.0.3'
}
=========================== Gradle 结束 ===========================
*/

/*
=========================== AndroidManifest.xml ===========================

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.gifmaker">

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

    <application ...>
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>
=========================== Manifest 结束 ===========================
*/

/*
=========================== res/layout/activity_main.xml ===========================

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
  <LinearLayout
      android:orientation="vertical"
      android:padding="16dp"
      android:layout_width="match_parent"
      android:layout_height="wrap_content">

    <Button
        android:id="@+id/btnSelect"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="选择多张图片"/>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvImages"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:layout_marginTop="8dp"/>

    <TextView
        android:layout_marginTop="16dp"
        android:text="帧延迟 (ms)"/>
    <SeekBar
        android:id="@+id/sbDelay"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:layout_marginTop="16dp"
        android:text="循环次数 (0=无限)"/>
    <SeekBar
        android:id="@+id/sbLoop"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <Button
        android:id="@+id/btnGenerate"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="生成 GIF"
        android:layout_marginTop="16dp"/>

    <ImageView
        android:id="@+id/ivPreview"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:layout_marginTop="16dp"
        android:scaleType="centerInside"/>
  </LinearLayout>
</ScrollView>
=========================== 布局结束 ===========================
*/

五、方法解读

  1. 图片选择与加载

    • 使用 Intent.ACTION_OPEN_DOCUMENT 可多选图片;

    • onActivityResult() 中通过 data.getClipData()data.getData() 获取 Uri 列表;

    • 使用 Glide 的同步 .submit(width, height).get() 方法将 UriBitmap 缩略图,避免 OOM。

  2. GIF 编码

    • AnimatedGifEncoder:调用 start(OutputStream) 开始,addFrame(Bitmap) 添加帧,setDelay(ms)setRepeat(n) 设置参数,最后 finish() 完成编码;

    • 将输出写入 getExternalFilesDir("gifs"),符合 Android 10+ 分区存储要求。

  3. 异步与进度

    • 在后台 ThreadExecutorService 中执行编码与磁盘写入,避免阻塞 UI;

    • 使用 ProgressDialog(简易)或自定义 ProgressBar 通知用户;

  4. 权限处理

    • Android 6.0+ 在运行时申请 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE

    • 在用户同意后继续生成流程,否则提示并引导。

  5. GIF 预览

    • 生成后用 Glide .asGif().load(file).into(ivPreview) 渲染,支持自动播放;


六、项目总结

  • 核心优势

    1. 简单易用:只需几行主逻辑即完成 GIF 合成;

    2. 高度可配置:用户可自定义帧延迟、循环次数、输出分辨率;

    3. 兼容性好:适配 Android 6.0+ 的运行时权限与分区存储;

    4. UI 友好:支持多选、预览与参数调节。

  • 性能优化

    • 对大图做降采样 (inSampleSize) 或 createScaledBitmap()

    • 控制 SeekBar 范围,避免延迟过大或帧数太多;

    • 在真正部署中,用线程池代替裸 Thread,并能中断。

  • 扩展方向

    1. 网络图片合成:支持从 URL 列表加载图片并合成;

    2. 更多特效:在每帧加入文字水印、图标叠加或滤镜;

    3. 进度回调:使用自定义 GifEncoder 的回调报告当前帧进度;

    4. 分享到社交:生成后直接调用分享 Intent 发布到微信/QQ/微博;

    5. Jetpack Compose:未来可在 Compose 中用 LaunchedEffect + Canvas 动态生成并预览。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值