Android实现高性能的帧动画礼物播放效果
引言:我们都知道Android实现动画的常见方式有那么几种,比如属性动画,值动画等,这些动画都能实现一定的动画效果,比如平移,缩放和透明等,但是对于复杂的动画效果用这些去实现则显得比较困难和麻烦,效果也不是很好,比如各种礼物动画等,这些动画肯定要求比较酷炫点的,而这些使用Android常规的动画是很难实现想要的效果的。
如果要实现比较好的动画效果,实现方式有那么几种:
- Lottie:Lottie是Airbnb开源的一个面向 iOS、Android、React Native 的动画库,可实现非常复杂的动画,使用也及其简单,极大释放人力,值得一试。目前QQ礼物就是使用了这个。Lottie
- SVGA:这是YY开源的一个动画框架,占用资源少,动画文件大小也比较小,集成很方便,目前虎牙直播等YY系在线上使用。SVGA
- GIF动画:GIF动画实现简单,对于APP端来说直接用glide加载就可以了,Gif图实质上就是把一帧帧的静态图片打包到一起,打成一个压缩包,实际上这个压缩包一点都不小,在播放性能和内存控制上还是差好多。
- 帧动画:帧动画相比前面几个来说其实也不是最好的选择,但是帧动画的好处是可以实现的非常酷炫的动画效果,坏处是占用资源大,我们都知道图片是最占用内存的了,使用帧动画相比前面几个来说占用内存肯定是比较多的,这个就要求我们使用帧动画播放的时候要管理好内存,避免oom。
本来我是想使用Lottie动画或者SVGA实现动画效果的,毕竟占用内存资源相当少,一个文件可以说才几百K,而且实现方式都相当简单,都封装的很完善了,而且播放的礼物效果也还不错,但是由于想要更好的动画效果,所以需要使用到帧动画,最后通过使用surfaceview实现了使用帧动画播放大量的图片并且很好的控制了内存,实现效果感觉不错所以就在这里做下总结和分享,参考。
效果:这里给了两个播放效果,限制于素材,其中播放的星空图片都是从网络上下载的,所以看起来不像第一个具有连贯性。
实现:如果用Android实现帧动画的API去实现礼物帧动画,则必定会出现内存溢出,因为图片比较大,比较占内存,内存不好控制,而且播放性能也不是很好,因为礼物帧动画一般帧数都会比较多,礼物帧动画图片是不可能放到项目目录下的,不然图片资源比较大会加大apk的大小而且也不利于扩展。在这里使用SurfaceView去实现礼物帧动画功能,效果还不错,内存控制的也比较好,主要利用了SurfaceView不与其宿主窗口共享同一个绘图表面以及它的双缓冲等功能,详细看下面对SurfaceView的介绍。
我们可以判断有WiFi的情况下在后台从网络上下载图片等资源保存到SDCard中,没有WiFi网络的情况下比如点击礼物播放或者发送礼物等情况下去下载相对应的动画压缩文件再去解压。然后从SDCard中读取图片资源,如果有音乐则可以在播放动画的时候同时播放音乐,这个可以根据音乐时间设置播放动画的时间从而保持一致(根据音乐时间计算出图片每帧之间的间隔时间)。这边我就不写下载解压之类的代码了,直接把礼物文件放到手机的SDCard中,礼物文件这些可以看下图,或者在最下面点击前往Github下载代码,我会把这些文件放到项目目录中(frameAnimation文件夹),大家如果下载代码查看效果可以直接将这些文件所在的文件夹放到手机的SDCard中。
SurfaceView: 这里我简单的介绍下SurfaceView,它不与其宿主窗口共享同一个绘图表面,我们都知道宿主窗口比如Activity中的view的绘制是层次结构的,里面的任何一个view改变都会导致整个视图结构重绘一次,对于复杂的动画效果效率比较低下,而SurfaceView自身拥有独立的绘图表面,这样SurfaceView的UI就可以在一个独立的线程中进行行绘制,SurfaceView的窗口刷新的时候不需要重绘应用程序的窗口,可以不占用主线程的资源从而可以实现比较复杂的UI效果。
要了解 SurfaceView ,还须了解它的另外两个组件:Surface 和 SurfaceHolder,他们三者之间的关系实质上就是 MVC,Model就是数据模型的意思也就是这里的Surface;View即视图也就是这里的SurfaceView;SurfaceHolder很明显可以理解为Controller(控制器)。在SurfaceView中你可以通过SurfaceHolder接口访问它内部的surface,而我们执行绘制的方法就是操作这个 Surface内部的Canvas,处理Canvas画的效果和动画,大小,像素等,getHolder()方法可以得到这个SurfaceHolder,通过SurfaceHolder来控制surface的尺寸和格式,或者修改监视surface的变化等等。可以通过SurfaceHolder.Callback来监听Surface的生命周期。
双缓冲:SurfaceView在更新视图时用了两个Canvas,一张frontCanvas和一张backCanvas,每次实际显示的是frontCanvas,backCanvas存储的是上一次更改前的视图,当使用lockCanvas()获取画布时,得到的实际上是backCanvas而不是正在显示的frontCanvas,当你在获取到的backCanvas上绘制完成后,再使用unlockCanvasAndPost(canvas)提交backCanvas视图,那么这张backCanvas将替换正在显示的frontCanvas被显示出来,原来的frontCanvas将切换到后台作为backCanvas,这样做的好处是在绘制期间不会出现黑屏。想要详细了解可以查看SurfaceView 与view区别详解 。
代码流程分析
GiftFragmentAnimationDirector director = new GiftFragmentAnimationDirector();
mGiftFrameAnimation = director.createGiftFrameAnimation(new GiftFrameAnimationBuilder(mSurfaceView));
首先我这边通过建造者模式创建一个类GiftFrameAnimation,这个类就是具体的功能实现类了,我们先来看看GiftFrameAnimationBuilder。
public class GiftFrameAnimationBuilder implements IBuildGiftFragmentAnimation {
private final GiftFrameAnimation mGiftFrameAnimation;
public GiftFrameAnimationBuilder(SurfaceView surfaceView) {
//创建GiftFrameAnimation,同时初始化SurfaceView
mGiftFrameAnimation = new GiftFrameAnimation(surfaceView);
}
@Override
public void buildSupportInBitmap() {
//支持inBitmap,改善内存抖动的问题
mGiftFrameAnimation.setSupportInBitmap(true);
}
@Override
public void buildCacheCount() {
//设置缓存图片个数
mGiftFrameAnimation.setCacheCount(5);
}
@Override
public void buildScaleType() {
//设置图片的展现形式
mGiftFrameAnimation.setScaleType(GiftFrameAnimation.SCALE_TYPE_CENTER_CROP);
}
@Override
public void buildtRepeatMode() {
// 设置图片播放模式,播放一次或者重复播放
mGiftFrameAnimation.setRepeatMode(GiftFrameAnimation.MODE_ONCE);
}
@Override
public void buildFrameInterval() {
//设置帧动画每帧之间的时间间隔
mGiftFrameAnimation.setFrameInterval(50);
}
@Override
public void buildMatrix() {
// 给定绘制bitmap的matrix不能和设置ScaleType同时起作用
// mGiftFrameAnimation.setMatrix(null);
}
@Override
public GiftFrameAnimation createGiftFragmentAnimation() {
// 返回设置好的GiftFrameAnimation
return mGiftFrameAnimation;
}
}
在这里主要是对GiftFrameAnimation进行了一些初始化,设置一些具体的属性,比如初始化surfaceView、图片播放的间隔时间,图片的播放模式以及scaleType等,接下来我们就对这些配置具体分析。
public GiftFrameAnimation(SurfaceView surfaceView) {
this.mSurfaceView = surfaceView;
this.mSurfaceHolder = surfaceView.getHolder();
mCallBack = new MyCallBack();
mSurfaceHolder.setFormat(PixelFormat.TRANSLUCENT);
mSurfaceView.setZOrderOnTop(true);
mSurfaceHolder.addCallback(mCallBack);
mBitmapCache = new SparseArray<>();
mContext = surfaceView.getContext();
mDrawMatrix = new Matrix();
mPaint = new Paint();
mPaint.setAntiAlias(true);
}
这是在创建GiftFrameAnimation的同时初始化SurfaceView,主要是获取SurfaceHolder、SurfaceHolder.Callback、将SurfaceView背景设置为透明以及初始化缓存集合类和用于进行绘制Bitmap的Matrix和Paint。
public void setSupportInBitmap(boolean support) {
this.mSupportInBitmap = support;
}
这个是用来设置是否支持inBitmap,支持inBitmap会非常显著的改善内存抖动的问题,因为存在bitmap复用的问题,当设置支持inBitmap时,请务必保证帧动画所有的图片分辨率和颜色位数完全一致。默认为true,详情可查看。
public void setFrameInterval(int time) {
this.mFrameInterval = time;
}
设置帧动画图片之间的播放间隔时间, 如果给定了播放总时间mFrameTime不为0,则这个设置无效,要按照计算mFrmeTime / pictureNumber得出。因为我在文件中的json文件中指定了图片的播放总时间,这是为了有背景音乐时播放动画时间和背景播放音乐时间保持一致。
public void setCacheCount(int count) {
this.mCacheCount = count;
}
这是设置缓存到内存中的图片的个数,便于图片的快速加载绘制。
public void setMatrix(@NonNull Matrix matrix) {
mDrawMatrix = matrix;
mScaleType = SCALE_TYPE_MATRIX;
}
给定绘制bitmap的matrix,不能和ScaleType同时起作用。
/**
* 表示给定的matrix
*/
private final int SCALE_TYPE_MATRIX = 0;
/**
* 完全拉伸,不保持原始图片比例,铺满
*/
public static final int SCALE_TYPE_FIT_XY = 1;
/**
* 保持原始图片比例,整体拉伸图片至少填充满X或者Y轴的一个
* 并最终依附在视图的上方或者左方
*/
public static final int SCALE_TYPE_FIT_START = 2;
/**
* 保持原始图片比例,整体拉伸图片至少填充满X或者Y轴的一个
* 并最终依附在视图的中心
*/
public static final int SCALE_TYPE_FIT_CENTER = 3;
/**
* 保持原始图片比例,整体拉伸图片至少填充满X或者Y轴的一个
* 并最终依附在视图的下方或者右方
*/
public static final int SCALE_TYPE_FIT_END = 4;
/**
* 将图片置于视图中央,不缩放
*/
public static final int SCALE_TYPE_CENTER = 5;
/**
* 整体缩放图片,保持原始比例,将图片置于视图中央,
* 确保填充满整个视图,超出部分将会被裁剪
*/
public static final int SCALE_TYPE_CENTER_CROP = 6;
/**
* 整体缩放图片,保持原始比例,将图片置于视图中央,
* 确保X或者Y至少有一个填充满屏幕
*/
public static final int SCALE_TYPE_CENTER_INSIDE = 7;
@IntDef({SCALE_TYPE_FIT_XY, SCALE_TYPE_FIT_START, SCALE_TYPE_FIT_CENTER, SCALE_TYPE_FIT_END,
SCALE_TYPE_CENTER, SCALE_TYPE_CENTER_CROP, SCALE_TYPE_CENTER_INSIDE})
@Retention(RetentionPolicy.SOURCE)
public @interface ScaleType {
}
public void setScaleType(@ScaleType int type) {
this.mScaleType = type;
}
这是设置图片的scaleType,这边主要定义了8种选择,主要是通过下面的代码根据指定的scaleType配置绘制bitmap的Matrix实现的。
private void configureDrawMatrix(Bitmap bitmap) {
final int srcWidth = bitmap.getWidth();
final int srcHeight = bitmap.getHeight();
final int dstWidth = mSurfaceView.getWidth();
final int dstHeight = mSurfaceView.getHeight();
final boolean nothingChanged = srcWidth == mLastFrameWidth
&& srcHeight == mLastFrameHeight
&& mLastFrameScaleType == mScaleType
&& mLastSurfaceWidth == dstWidth
&& mLastSurfaceHeight == dstHeight;
if (nothingChanged) {
return;
}
mLastFrameScaleType = mScaleType;
mLastFrameWidth = srcWidth;
mLastFrameHeight = srcHeight;
mLastSurfaceWidth = dstWidth;
mLastSurfaceHeight = dstHeight;
if (mScaleType == SCALE_TYPE_MATRIX) {
return;
} else if (mScaleType == SCALE_TYPE_CENTER) {
mDrawMatrix.setTranslate(
Math.round((dstWidth - srcWidth) * 0.5f),
Math.round((dstHeight - srcHeight) * 0.5f));
} else if (mScaleType == SCALE_TYPE_CENTER_CROP) {
float scale;
float dx = 0, dy = 0;
//按照高缩放
if (dstHeight * srcWidth > dstWidth * srcHeight) {
scale = (float) dstHeight / (float) srcHeight;
dx = (dstWidth - srcWidth * scale) * 0.5f;
} else {
scale = (float) dstWidth / (float) srcWidth;
dy = (dstHeight - srcHeight * scale) * 0.5f;
}
mDrawMatrix.setScale(scale, scale);
mDrawMatrix.postTranslate(dx, dy);
} else if (mScaleType == SCALE_TYPE_CENTER_INSIDE) {
float scale;
float dx;
float dy;
//小于dst时不缩放
if (srcWidth <= dstWidth && srcHeight <= dstHeight) {
scale = 1.0f;
} else {
scale = Math.min((float) dstWidth / (float) srcWidth,
(float) dstHeight / (float) srcHeight);
}
dx = Math.round((dstWidth - srcWidth * scale) * 0.5f);
dy = Math.round((dstHeight - srcHeight * scale) * 0.5f);
mDrawMatrix.setScale(scale, scale);
mDrawMatrix.postTranslate(dx, dy);
} else {
RectF srcRect = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight());
RectF dstRect = new RectF(0, 0, mSurfaceView.getWidth(), mSurfaceView.getHeight());
mDrawMatrix.setRectToRect(srcRect, dstRect, MATRIX_SCALE_ARRAY[mScaleType - 1]);
}
}
private final Matrix.ScaleToFit[] MATRIX_SCALE_ARRAY = {
Matrix.ScaleToFit.FILL,
Matrix.ScaleToFit.START,
Matrix.ScaleToFit.CENTER,
Matrix.ScaleToFit.END
};
上面大致是介绍了一下GiftFrameAnimation一些设置,下面就来主要介绍下代码功能实现的整体的流程分析。
mGiftFrameAnimation.start(giftId, 0);
public void start(String giftId, int startFramePositon) {
if (mCallBack.isDrawing) {
stop();
}
mPathList = getPathList(giftId);
if (mPathList == null || mPathList.size() == 0) {
return;
}
mTotalCount = mPathList.size();
//缓存图片个数不能超过总图片数
if (mCacheCount > mTotalCount) {
mCacheCount = mTotalCount;
}
mStartFramePositon = startFramePositon;
if (mStartFramePositon >= mTotalCount) {
mStartFramePositon = 0;
}
startDecodeThread();
}
这是开始播放帧动画,第一个参数giftId是获取图片文件的位置的标志,比如我保存在SD卡上的图片文件名1000,那么我将giftId参数设为1000,则会去读取这个文件夹下的图片进行播放,下面我都指定之歌第二个参数播放开始的图片,比如传0,也就是表示从第一个图片开始播放。
private List getPathList(String giftId) {
List list = new ArrayList<>();
GiftModel giftModel = GainGiftUtils.getGiftModel(giftId);
if (giftModel == null) {
if (mAnimationStateListener != null) {
mAnimationStateListener.giftFileNotExists();
}
return list;
}
String backgroundColor = giftModel.backgroundColor;
String musicPath = giftModel.backgroundMusic;
list = giftModel.imageArray;
int size = list.size();
mFrmeTime = giftModel.playTimeMillisecond;
if (mFrmeTime == 0) {
mFrmeTime = mFrameInterval * size;
// 为了避免帧动画的播放跟背景音乐时长不一致,故禁掉音乐播放
musicPath = null;
} else {
//根据json文件中给定的播放时间计算每帧图片之间的播放间隔时间
mFrameInterval = mFrmeTime / size;
}
if (mAnimationStateListener != null) {
mAnimationStateListener.giftFrameType(musicPath, mFrmeTime, backgroundColor);
}
return list;
}
这个是获取保存在SD卡上的图片的路径的集合,首先通过读取1000.json文件中的json,然后进行判断json中所记录的文件是否存在,可解析成GiftModel,这时可以获取图片路径集合、音乐文件路径,背景颜色以及播放总时间。
public class GiftModel {
public String backgroundMusic;
public int playTimeMillisecond;
public String backgroundColor;
public List imageArray;
}
接下来我们再来看下startDecodeThread
private void startDecodeThread() {
new Thread() {
@Override
public void run() {
super.run();
Looper.prepare();
mDecodeHandler = new Handler(Looper.myLooper()) {
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
if (msg.what == STOP_ANIMATION) {
decodeBitmap(STOP_ANIMATION);
getLooper().quit();
return;
}
decodeBitmap(msg.what);
}
};
decodeBitmap(START_ANIMATION);
Looper.loop();
}
}.start();
}
这里开启了一个线程去处理, 根据不同指令 进行不同操作,START_ANIMATION表示开始动画标志,STOP_ANIMATION表示动画结束标志,至于decodeBitmap(msg.what)则是用来处理继续缓存下个Bitmap的。
private void decodeBitmap(int position) {
if (position == START_ANIMATION) {
//初始化存储
if (mSupportInBitmap) {
mOptions = new BitmapFactory.Options();
mOptions.inMutable = true;
mOptions.inSampleSize = 1;
}
for (int i = mStartFramePositon; i < mCacheCount + mStartFramePositon; i++) {
int putPosition = i;
if (putPosition > mTotalCount - 1) {
putPosition = putPosition % mTotalCount;
}
mBitmapCache.put(putPosition, decodeBitmapReal(mPathList.get(putPosition)));
}
mCallBack.startAnim();
} else if (position == STOP_ANIMATION) {
mCallBack.stopAnim();
} else if (mPlayMode == MODE_ONCE) {
if (position + mCacheCount <= mTotalCount - 1) {
//由于surface的双缓冲,不能直接复用上一帧的bitmap,因为上一帧的bitmap可能还没有post
writeInBitmap(position);
mBitmapCache.put(position + mCacheCount, decodeBitmapReal(mPathList.get(position + mCacheCount)));
}
//循环播放
} else if (mPlayMode == MODE_INFINITE) {
//由于surface的双缓冲,不能直接复用上一帧的bitmap,上一帧的bitmap可能还没有post
writeInBitmap(position);
//播放到尾部时,取mod
if (position + mCacheCount > mTotalCount - 1) {
mBitmapCache.put((position + mCacheCount) % mTotalCount, decodeBitmapReal(mPathList.get((position + mCacheCount) % mTotalCount)));
} else {
mBitmapCache.put(position + mCacheCount, decodeBitmapReal(mPathList.get(position + mCacheCount)));
}
}
}
先来看看START_ANIMATION开始播放动画这块,mStartFramePositon就是我们一开始调用start的时候传进来的播放开始位置,这边根据设置的缓存个数对图片进行了mBitmapCache缓存,然后调用了mCallBack.startAnim(),我们进入看看
private void startAnim() {
if (mAnimationStateListener != null) {
mAnimationStateListener.onStart();
}
isDrawing = true;
position = mStartFramePositon;
//绘制线程
drawThread = new Thread() {
@Override
public void run() {
super.run();
while (isDrawing) {
try {
long now = System.currentTimeMillis();
drawBitmap();
//控制两帧之间的间隔
sleep(mFrameInterval - (System.currentTimeMillis() - now) > 0 ? mFrameInterval - (System.currentTimeMillis() - now) : 0);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
}
};
drawThread.start();
}
可以看到这里开启了一个线程去执行drawBitmap(),这里就是图片的进行绘制的方法了
private void drawBitmap() {
//当循环播放时,获取真实的position
if (mPlayMode == MODE_INFINITE && position >= mTotalCount) {
position = position % mTotalCount;
}
if (position >= mTotalCount) {
mDecodeHandler.sendEmptyMessage(STOP_ANIMATION);
clearSurface();
return;
}
if (mBitmapCache.get(position, null) == null) {
stopAnim();
return;
}
final Bitmap currentBitmap = mBitmapCache.get(position);
mDecodeHandler.sendEmptyMessage(position);
mCanvas = mSurfaceHolder.lockCanvas();
if (mCanvas == null) {
return;
}
mCanvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG));
mCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
configureDrawMatrix(currentBitmap);
mCanvas.drawBitmap(currentBitmap, mDrawMatrix, mPaint);
mSurfaceHolder.unlockCanvasAndPost(mCanvas);
position++;
}
这里首先获取了播放的图片位置position,首先判断了当前的播放模式是不是MODE_INFINITE重复播放,如果是,则计算出当前的播放位置,避免超出图片数,当position大约等于图片总数mTotalCount时,则表示播放完毕了,这时通过mDecodeHandler.sendEmptyMessage(STOP_ANIMATION)发送了一个停止动画标志,这个在startDecodeThread()中进行处理,也就是上面的decodeBitmap的position == STOP_ANIMATION;然后又发送了一个mDecodeHandler.sendEmptyMessage(position),也就是decodeBitmap(msg.what)通知缓存下一个图片。
再来看看绘制过程,通过mSurfaceHolder.lockCanvas()获取Surface的Canvas后,SurfaceView会利用Surface的一个同步锁锁住画布Canvas,直到调用unlockCanvasAndPost(Canvas canvas)函数,才解锁画布并提交改变,将图形显示,这里的同步机制保证Surface的Canvas在绘制过程中不会被改变(被摧毁、修改),避免多个不同的线程同时操作同一个Canvas对象。
我们往前看decodeBitmap()方法,接下来我们继续看看position == STOP_ANIMATION,也就是停止播放动画,这里调用了mCallBack.stopAnim()。
private void stopAnim() {
if (!isDrawing) {
return;
}
isDrawing = false;
position = 0;
mBitmapCache.clear();
clearSurface();
if (mDecodeHandler != null) {
mDecodeHandler.sendEmptyMessage(STOP_ANIMATION);
}
if (drawThread != null) {
drawThread.interrupt();
}
if (mAnimationStateListener != null) {
mAnimationStateListener.onFinish();
}
mInBitmapFlag = 0;
mInBitmap = null;
}
private void clearSurface() {
try {
mCanvas = mSurfaceHolder.lockCanvas();
if (mCanvas != null) {
mCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
mSurfaceHolder.unlockCanvasAndPost(mCanvas);
}
} catch (Exception e) {
e.printStackTrace();
}
}
停止播放动画这里主要是清除缓存,终止绘制线程,调用clearSurface()获取mCanvas清理画布。
在加载帧动画的同时会播放背景音乐,这里附上加载播放音乐文件的MediaManager类。
public class MediaManager {
private static MediaPlayer mPlayer;
private static boolean isPause;
public static void playSound(String filePathString, OnCompletionListener onCompletionListener) {
// TODO Auto-generated method stub
if (mPlayer == null) {
mPlayer = new MediaPlayer();
//保险起见,设置报错监听
mPlayer.setOnErrorListener(new OnErrorListener() {
@Override
public boolean onError(MediaPlayer mp, int what, int extra) {
// TODO Auto-generated method stub
mPlayer.reset();
return false;
}
});
} else {
mPlayer.reset();//就回复
}
try {
mPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
mPlayer.setOnCompletionListener(onCompletionListener);
mPlayer.setDataSource(filePathString);
mPlayer.prepare();
mPlayer.start();
} catch (IllegalArgumentException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (SecurityException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IllegalStateException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public static void stop(){
if (mPlayer != null && mPlayer.isPlaying()) {
mPlayer.stop();
}
}
//停止函数
public static void pause() {
if (mPlayer != null && mPlayer.isPlaying()) {
mPlayer.pause();
isPause = true;
}
}
//继续
public static void resume() {
if (mPlayer != null && isPause) {
mPlayer.start();
isPause = false;
}
}
public static void release() {
if (mPlayer != null) {
mPlayer.release();
mPlayer = null;
}
}
}
好了,上面就是关于实现的具体介绍了,如果想要详细了解和查看功能效果请点传送门