一、项目介绍
在图片处理或社交类应用中,将多张静态图片合成动感的 GIF,可用于展示流程演示、轮播图打包、短动画效果等。Android 平台上,直接操作 Bitmap
并生成 GIF 文件,需要借助第三方库或手动实现对 GIF 格式的编码。本项目目标:
-
加载多张本地或网络图片,并将它们按照指定帧率合成为一个 GIF 文件;
-
支持自定义每帧延迟、循环次数与输出尺寸;
-
提供简单的 UI:多选图片、设定参数、生成并预览 GIF;
-
演示File I/O、运行时权限管理、Bitmap 操作与异步处理;
-
完整整合所有依赖、布局与代码,方便“一处复制”即用。
完成本项目后,你将掌握:
-
引入并使用 GIF 生成库(如
GifEncoder
、android-gif-encoder
) -
在 Android 中对多张图片进行批量压缩、缩放与编码
-
在后台线程执行耗时操作,并在主线程更新 UI
-
在内部/外部存储中保存大文件并处理权限
-
使用
ImageView
和GifDrawable
预览本地 GIF
二、相关技术与知识
-
GIF 格式与编码
-
GIF 是一种基于 LZW 压缩的逐帧动画格式,支持 256 色与透明
-
常见 Java 实现有
GifEncoder
(Kevin Weiner 原版移植)和android-gif-encoder
-
-
Bitmap 操作
-
加载大图需控制内存,可使用
BitmapFactory.Options.inSampleSize
做降采样 -
合成前可对每帧做缩放、裁剪、旋转或水印
-
-
异步处理
-
生成 GIF 属 CPU 密集型与 I/O 密集型操作,应在
AsyncTask
或Executor
线程池中执行 -
在完成后通过
Handler
或runOnUiThread()
更新 UI
-
-
存储读写权限
-
Android 6.0+ 动态申请
WRITE_EXTERNAL_STORAGE
-
Android 10+ 分区存储,需要使用
MediaStore
或 requestLegacyExternalStorage
-
-
第三方库集成
-
通过 Gradle 引入
implementation 'com.github.bumptech.glide:glide:...’
(若需加载网络图) -
引入 GIF 编码库:
implementation 'pl.droidsonroids.gif:android-gif-encoder:1.0.3'
-
-
UI 组件
-
使用
RecyclerView
或GridView
展示可选图片缩略图 -
通过
Button
、SeekBar
让用户设定帧延迟、循环次数、输出尺寸 -
使用
ImageView
+GifDrawable
预览已生成的 GIF
-
三、实现思路
-
项目依赖配置
-
在
app/build.gradle
中添加 GIF 编码库与 Glide(可选) -
配置 Android 版本兼容与分区存储策略
-
-
布局设计
-
activity_main.xml
:包含-
RecyclerView
(多选图片网格) -
EditText
或SeekBar
(延迟、宽高输入) -
Button
(开始生成) -
ImageView
(GIF 预览)
-
-
布局整合到 MainActivity 注释中
-
-
多选图片逻辑
-
使用
Intent.ACTION_OPEN_DOCUMENT
或Intent.ACTION_GET_CONTENT
多选 -
在
onActivityResult()
中获取Uri[]
,通过 Glide 加载到缩略图和List<Bitmap>
-
-
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);
-
权限与存储
-
在开始生成前,检查并申请写外部存储权限
-
输出文件路径可为
getExternalFilesDir("gifs")
下,避免兼容问题
-
-
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>
=========================== 布局结束 ===========================
*/
五、方法解读
-
图片选择与加载
-
使用
Intent.ACTION_OPEN_DOCUMENT
可多选图片; -
在
onActivityResult()
中通过data.getClipData()
或data.getData()
获取Uri
列表; -
使用 Glide 的同步
.submit(width, height).get()
方法将Uri
转Bitmap
缩略图,避免 OOM。
-
-
GIF 编码
-
AnimatedGifEncoder
:调用start(OutputStream)
开始,addFrame(Bitmap)
添加帧,setDelay(ms)
、setRepeat(n)
设置参数,最后finish()
完成编码; -
将输出写入
getExternalFilesDir("gifs")
,符合 Android 10+ 分区存储要求。
-
-
异步与进度
-
在后台
Thread
或ExecutorService
中执行编码与磁盘写入,避免阻塞 UI; -
使用
ProgressDialog
(简易)或自定义ProgressBar
通知用户;
-
-
权限处理
-
Android 6.0+ 在运行时申请
READ_EXTERNAL_STORAGE
与WRITE_EXTERNAL_STORAGE
; -
在用户同意后继续生成流程,否则提示并引导。
-
-
GIF 预览
-
生成后用 Glide
.asGif().load(file).into(ivPreview)
渲染,支持自动播放;
-
六、项目总结
-
核心优势
-
简单易用:只需几行主逻辑即完成 GIF 合成;
-
高度可配置:用户可自定义帧延迟、循环次数、输出分辨率;
-
兼容性好:适配 Android 6.0+ 的运行时权限与分区存储;
-
UI 友好:支持多选、预览与参数调节。
-
-
性能优化
-
对大图做降采样 (
inSampleSize
) 或createScaledBitmap()
; -
控制
SeekBar
范围,避免延迟过大或帧数太多; -
在真正部署中,用线程池代替裸
Thread
,并能中断。
-
-
扩展方向
-
网络图片合成:支持从 URL 列表加载图片并合成;
-
更多特效:在每帧加入文字水印、图标叠加或滤镜;
-
进度回调:使用自定义
GifEncoder
的回调报告当前帧进度; -
分享到社交:生成后直接调用分享 Intent 发布到微信/QQ/微博;
-
Jetpack Compose:未来可在 Compose 中用
LaunchedEffect
+Canvas
动态生成并预览。
-