导言:
根据文章标题,按三步走,一、视频播放;二、连续截图;三、转换成gif。视频播放很自然想到用MediaPlayer或者VideoView,但我在这里踩了几个坑,写在这里也希望别人少走点弯路。首先,是MediaPlayer+SurfaceView的坑,如果只是想实现视频播放,那么用这种方式确实不错,但是并不能实现截图,SurfaceView一般通过getHolder().lockCanvas()可以获取到Canvas,那么通过这个Canvas不就可以获取到它的bitmap了吗?错了!那只是针对普通的静态画面而言,像视频播放这样的动态画面来说,一开始播放,是不允许调用这个接口的,否则会出现SurfaceHolder: Exception locking surface和java.lang.IllegalArgumentException的错误。那么用下面这种方式呢:
View view = activity.getWindow().getDecorView();
view.setDrawingCacheEnabled(true);
view.buildDrawingCache();
bitmap = view.getDrawingCache();
依然不行,SurfaceView部分截取出来的是黑屏,原因很多文章讲过我就不重复了。那么用VideoView呢?事实上,VideoView也是继承自SurfaceView,所以一样会截屏失败。有人会说用MediaMetadataRetriever就可以很方便截屏了啊,管它是VideoView还是SurfaceView都能截。是的,MediaMetadataRetriever跟VideoView或者SurfaceView一点关系都没有,它只需获取到视频文件根本不需要视频播放出来就能通过getFrameAtTime(long timeUs)这个接口获取指定时间的视频。但是,我还想说,但是,MediaMetadataRetriever获取的是指定位置附近的关键帧,而视频文件的关键帧,就我所测试,2-5秒才有一个关键帧,所以如果通过getFrameAtTime接口获取2-5秒内的几十张bitmap,你会发现每张都是一样的,真是令人崩溃,根本无法满足制作gif需要的帧率。
那么用什么方式播放才能连续获取到正确的截图呢?答案是MediaPlayer+TextureView的方式。
一、视频播放
activity先实现SurfaceTextureListener接口,在onCreate的时候调用TextureView的setSurfaceTextureListener(TextureVideoActivity.this)即可,在TextureView初始化完成之后,会自动调用SurfaceTextureListener的接口方法onSurfaceTextureAvailable,在这里进行MediaPlayer的初始化并开始播放:
@Override
public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height) {
//surface不能重复使用,每次必须重新new一个
surface = new Surface(surfaceTexture);
if (!TextUtils.isEmpty(mUrl)) {
startPlay();
}
}
private void startPlay() {
if (mMediaPlayer == null) {
mMediaPlayer = new MediaPlayer();
}
//mUrl是本地视频的路径地址
mMediaPlayer.setDataSource(this, Uri.parse(mUrl));
mMediaPlayer.setSurface(surface);
mMediaPlayer.setLooping(false);
mMediaPlayer.prepareAsync();
mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(MediaPlayer mediaPlayer) {
mediaPlayer.start();
}
});
}
看,很简单,这样就可以开始进行播放了。需要注意的是,界面跳转或切换到后台再切回来,就会再次调用该接口,而原先的Surface不能再用,需要重新new一个。
注:后续为适配android10.0,targetSdkVersion升级到29,出现问题了,onSurfaceTextureAvailable不会执行导致画面没有了,解决方法在这里:TextureView有声音没画面&onSurfaceTextureAvailable没调用,不想看的也没关系,代码已更新。
二、截图
截图非常简单,只需要调用TextureView的getBitmap()方法就可以,连续快速地调用都没有问题。
三、转换成gif
这里用到了一个第三方开源项目GifBuilder(https://github.com/GLGJing/GIFBuilder),使用也很简单:
//另开线程并执行
GIFEncoder encoder = new GIFEncoder();
encoder.init(bitmaps.get(0));
encoder.setFrameRate(1000 / DURATION);
//filePath为本地gif存储路径
encoder.start(filePath);
for (int i = 1; i < bitmaps.size(); i++) {
encoder.addFrame(bitmaps.get(i));
}
encoder.finish();
bitmaps是在定时循环DURATION下总共取得的bitmap列表,这样一个gif就制作完成了。但是这样执行的速度会非常慢,三四十张bitmap的转换就需要好几分钟,显然不行,于是我参照GifEncoder类再写了一个GifEncoderWithSingleFrame的类,将每张bitmap各自转换成一张临时的.partgif文件,待所有的bitmap都转换完之后再合并成一张gif图片,代码稍微长了些:
List<String> fileParts = new ArrayList<>();
ExecutorService service = Executors.newCachedThreadPool();
final CountDownLatch countDownLatch = new CountDownLatch(bitmaps.size());
for (int i = 0; i < bitmaps.size(); i++) {
final int n = i;
final String fileName = getExternalCacheDir() + File.separator + (n + 1) + ".partgif";
fileParts.add(fileName);
Runnable runnable = new Runnable() {
@Override
public void run() {
GIFEncoderWithSingleFrame encoder = new GIFEncoderWithSingleFrame();
encoder.setFrameRate(1000 / frameRate / 1.5f);
Log.e(TAG, "总共" + bitmaps.size() + "帧,正在添加第" + (n + 1) + "帧");
if (n == 0) {
encoder.addFirstFrame(fileName, bitmaps.get(n));
} else if (n == bitmaps.size() - 1) {
encoder.addLastFrame(fileName, bitmaps.get(n));
} else {
encoder.addFrame(fileName, bitmaps.get(n));
}
countDownLatch.countDown();
}
};
service.execute(runnable);
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
handler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(TextureVideoActivity.this, "gif初始化成功,准备合并", Toast.LENGTH_SHORT).show();
}
});
SequenceInputStream sequenceInputStream = null;
FileOutputStream fos = null;
try {
Vector<InputStream> streams = new Vector<InputStream>();
for (String filePath : fileParts) {
InputStream inputStream = new FileInputStream(filePath);
streams.add(inputStream);
}
sequenceInputStream = new SequenceInputStream(streams.elements());
File file = new File(getExternalCacheDir() + File.separator + System.currentTimeMillis() + ".gif");
if (!file.exists()) {
file.createNewFile();
}
fos = new FileOutputStream(file);
byte[] buffer = new byte[1024];
int len = 0;
while ((len = sequenceInputStream.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
fos.flush();
fos.close();
sequenceInputStream.close();
handler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(TextureVideoActivity.this, "gif制作完成", Toast.LENGTH_SHORT).show();
}
});
for (String filePath : fileParts) {
File f = new File(filePath);
if (f.exists()) {
f.delete();
}
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (sequenceInputStream != null) {
try {
sequenceInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
稍微解释下,这里用了ExecutorService线程池和CountDownLatch线程控制工具类,保证所有线程执行完再执行countDownLatch.await()下面的代码。gif的第一帧和最后一帧分别需要加入文件头和结束符等,所以需要区别对待,分别调用了addFirstFrame和addLastFrame,其他帧调用addFrame即可。然后利用SequenceInpustream这个类将所有.partgif文件统一加入到输入流,最终再用FileOutputStream输出来就可以。经过这样修改之后,gif的转换时间从几分钟缩短到了几秒钟(像素高一点图片数量多一点可能也需要20S左右)。
细节注意:
从TextureView.getBimap()获取到的bitmap像素因不同手机而不同,如果不做处理直接加入bitmap列表很容易引起OOM,所以需要对bitmap先进行尺寸压缩:
Bitmap bitmap = mTexureView.getBitmap();
String path = getExternalCacheDir() + File.separator + String.valueOf(count + 1) + ".jpg";
BitmapSizeUtils.compressSize(bitmap, path, 720, 80);
Bitmap bmp = BitmapFactory.decodeFile(path);
//压缩后再添加
bitmaps.add(bmp);
public static void compressSize(Bitmap bitmap, String toFile, int targetWidth, int quality) {
try {
int bitmapWidth = bitmap.getWidth();
int bitmapHeight = bitmap.getHeight();
int targetHeight = bitmapHeight * targetWidth / bitmapWidth;
Bitmap resizeBitmap = Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true);
File myCaptureFile = new File(toFile);
FileOutputStream out = new FileOutputStream(myCaptureFile);
if (resizeBitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)) {
out.flush();
out.close();
}
if (!bitmap.isRecycled()) {
bitmap.recycle();
}
if (!resizeBitmap.isRecycled()) {
resizeBitmap.recycle();
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException ex) {
ex.printStackTrace();
}
}
压缩成宽度720像素,这样压缩出来的图片比较清晰,当然最终的gif图片也会比较大,30-40张bitmap转换成的gif大概有3-4M左右,如果想让gif小一点,宽度设置成400左右也就够了。
上效果图:
由25张分辨率440*247的bitmap合并而成,大小1.36M
转存失败重新上传取消转存失败重新上传取消转存失败重新上传取消直接由本地传的图片居然不动,有知道怎么传的请告诉我!!!
最后,上Github源码, 源码还包括surfaceview和videoview的播放方式的代码,想看gif生成代码的只需要看TextureVideoActivity这个界面就可以了。
PS:后续测试,在执行GIFEncoderWithSingleFrame类的提取像素值方法getImagePixels时,因为涉及到密集型数据运算,CPU会飙高到90%左右,而不同手机因为CPU型号不同,转换100张440*260像素的bitmap在运行到这个方法时,有些手机如小米运算速度仍然非常快,只需要几秒钟,有些手机如华为、三星速度就慢成狗了,达到两分钟以上,忍无可忍。在JAVA层面计算大量数据确实不是明智的选择,所以我又把这个方法移到了JNI去计算,效果非常显著,执行这个方法最多只需要两秒钟,源码已更新。
PSS:发现手机拍的视频播放到TextureViewActivity界面的时候宽高比不对,又优化了下。首先想到的是调用mediaPlayer.getVideoWidth()和mediaPlayer.getVideoHeight()来对TextureView重新设置宽高,但失败了,mediaPlayer一旦准备就绪后就没办法再修改TextureView的size,否则播放无图像。这时候又想到了MediaMetadataRetriever,不得不说这时候它还是很好用的:
/**
* dp转换px
*/
public int dip2px(Context context, float dipValue) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dipValue, context.getResources()
.getDisplayMetrics());
}
private float videoWidth;
private float videoHeight;
private int videoRotation;
private void initVideoSize() {
MediaMetadataRetriever mmr = new MediaMetadataRetriever();
try {
mmr.setDataSource(mUrl);
String width = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
String height = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
String rotation = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
videoWidth = Float.valueOf(width);
videoHeight = Float.valueOf(height);
videoRotation = Integer.valueOf(rotation);
int w1;
if (videoRotation == 90) {
w1 = (int) ((videoHeight / videoWidth) * dip2px(TextureVideoActivity.this, 250));
} else {
w1 = (int) (videoWidth / videoHeight * dip2px(TextureVideoActivity.this, 250));
}
LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) mPreview.getLayoutParams();
layoutParams.width = w1;
layoutParams.height = mPreview.getHeight();
mPreview.setLayoutParams(layoutParams);
} catch (Exception ex) {
Log.e(TAG, "MediaMetadataRetriever exception " + ex);
} finally {
mmr.release();
}
}
播放前先调用以上代码进行TextureView的宽高初始化,MediaMetadataRetriever可以获取到视频源的宽高和旋转角度。手机拍摄的视频,不论是横着拍的还是竖着拍的,视频源的宽高都是默认横屏拍的宽高,所以必须要用到旋转角度进行判断。代码已更新到github上。