简介
作为苦逼的程序员,产品和设计提出来的需求咱也没法拒绝,这不,前两天设计就给提了个需求,要求在帧动画结束后,把原位置的动画替换成一段文字。我们知道,在Android中,帧动画的实现类为AnimationDrawable,而这玩意儿又不像Animator一样可以通过addListener之类的方法监听动画的开始、结束等事件,那我们该怎么监听AnimationDrawable的结束事件呢?
目前网上大多数的做法都是获取帧动画的总时长,然后用Handler做一个postDelayed执行结束后的事情。这种方法怎么说呢?能用,但是不够精准也不够优雅,本文我们将从源码层面解析AnimationDrawable是如何将一帧帧的图片组合起来展示成连续的动画的,再从中寻求动画监听的切入点。
注:只想看实现的朋友们可以直接跳到 包装Drawable.Callback 这一节看最终实现
ImageView如何展示Drawable
AnimationDrawable说到底它也就是个Drawable,而我们一般都是使用ImageView作为Drawable展示的布局,那我们就以此作为入口开始分析Drawable在ImageView中是如何被展示的。
回想一下,我们想要给一个ImageView设置图片一般可以用下面几种方法:
setImageBitmapsetImageResourcesetImageURIsetImageDrawable
setImageBitmap会将Bitmap包装成一个BitmapDrawable,然后再调用setImageDrawable方法。
setImageResource和setImageURI方法会通过resolveUri方法从Resource或Uri中解析出Drawable,然后调用updateDrawable方法
setImageDrawable方法则会直接调用updateDrawable方法
最终殊途同归走到updateDrawable方法中
1 | private void updateDrawable(Drawable d) { |
可以看到,这里将我们设置的图片资源赋值到mDrawable上。注意,这里有一个Drawable动起来的关键点,同时也是我们动画监听的最终切入点:Drawable.setCallback(this),我们后面分析帧切换的时候会详细去聊它。
我们知道,一个控件想要绘制内容得在onDraw方法中操作Canvas,所以让我们再来看看onDraw方法
1 | protected void onDraw(Canvas canvas) { |
可以看到,这里调用了Drawable.draw方法将Drawable自身绘制到ImageView的Canvas上
DrawableContainer
查看AnimationDrawable的继承关系我们可以得知它继承自DrawableContainer,从命名中我们就能看出来,它是Drawable的容器,我们来看一下它所实现的draw方法:
1 | public void draw(Canvas canvas) { |
mLastDrawable是为了完成动画的切换效果(出入场动画)所准备的,我们可以不用关心它。
我们可以发现,它的内部有一个名为mCurrDrawable的成员变量,我们可以合理猜测它是通过切换mCurrDrawable指向的目标Drawable来完成展示不同图片的功能,那么事实是这样吗?
没错,DrawableContainer给我们提供了一个selectDrawable方法,用来切换不同的图片:
1 | public boolean selectDrawable(int index) { |
可以看到,和我们猜想的一样,在DrawableContainer的内部有一个子类DrawableContainerState用于保存所有的Drawable,它继承自Drawable.ConstantState,是用来储存Drawable间的常量状态和数据的。在DrawableContainerState中有一个mDrawables数组用于保存所有的Drawable,通过addChild方法将Drawable加入到这个数组中
而在selectDrawable方法中,它通过getChild方法去获取当前应该显示的Drawable,并将其和index分别赋值给它的两个成员变量mCurrDrawable和mCurIndex,然后调用invalidateSelf方法执行重绘:
1 | public void invalidateSelf() { |
invalidateSelf被定义实现在Drawable类中,还记得我之前让大家注意的Callback吗?在设置图片这一步时,它就被赋值了,实际上这个接口被View所实现,所以在前面我们可以看到调用setCallback时,我们传入的参数为this
不过ImageView在继承View的同时也重写了这个invalidateDrawable方法,最终调用了invalidate方法执行重绘,此时,一张新的图片就被展示到我们的屏幕上了
1 | //ImageView.invalidateDrawable |
AnimationDrawable
DrawableContainer分析完后,我们可以很自然的想到,AnimationDrawable就是通过DrawableContainer这种可以切换图片的机制,每隔一定时间执行一下selectDrawable便可以达成帧动画的效果了。
我们先回想一下,在代码中怎么构造出一个多帧的AnimationDrawable?没错,用默认构造方法实例化出来后,调用它的addFrame方法往里一帧帧的添加图片:
1 | public void addFrame(@NonNull Drawable frame, int duration) { |
可以看到AnimationDrawable也有一个内部类AnimationState,继承自DrawableContainerState,它的addFrame方法就是调用DrawableContainerState.addChild方法添加图片,同时将这张图片的持续时间保存在mDurations数组中:
1 | public void addFrame(Drawable dr, int dur) { |
想让AnimationDrawable动起来的话,我们得要调用它的start方法,那我们就从这个方法开始分析:
1 | public void start() { |
这里将mAnimating状态置为true,然后调用setFrame方法从第0帧开始展示图片
1 | private void setFrame(int frame, boolean unschedule, boolean animate) { |
这里可以看到,和我们所想的一样,调用了DrawableContainer.selectDrawable切换当前展示图片,由于我们之前将mAnimating赋值为了true,所以会调用scheduleSelf方法调度展示下一张图片,时间为当前帧持续时间后
1 | public void scheduleSelf(@NonNull Runnable what, long when) { |
scheduleSelf方法调用了Drawable.Callback.scheduleDrawable方法,我们去View里面看实现:
1 | public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) { |
实际上两个分支最终都是通过Handler实现延时调用,而调用的Runnable对象就是之前scheduleSelf传入的this。没错,AnimationDrawable实现了Runnable接口:
1 | public void run() { |
可以看到,在一帧持续时间结束后,便会调用nextFrame方法,计算下一帧的index,然后调用setFrame方法切换下一帧,形成一个循环,这样一帧帧的图片便动了起来,形成了帧动画
包装Drawable.Callback
我们从源码层面分析了帧动画是如何运作的,那么怎么监听动画事件相信各位应该都能得出结论了吧?没错,就是重设Drawable的Callback
当Drawable被设置到控件中后,控件会将自身作为Drawable.Callback设置给Drawable,那么我们只需要重新给Drawable设置一个Drawable.Callback,在其中调用View回调方法的同时,加入自己的监听逻辑即可
1 | val animDrawable = imageView.drawable as AnimationDrawable |
以上的代码便是示例,当满足动画运行到最后一帧,且满足结束状态时,在最后一帧的持续时间后处理结束后需要做的事
当AnimationDrawable切换Visible状态为false时,动画会被暂停,如果在动画结束后触发setVisible(false)事件,也会触发invalidateDrawable回调,所以这里需要额外判断一下isVisible
自己包装的Drawable.Callback一定需要找个东西将它强引用起来,因为Drawable.Callback是以弱引用的形式被保存在Drawable内的,很容易被回收,一旦被回收,整个AnimationDrawable动画就动不起来了
尾声
为了这么简单一个小功能,还得跑到源码里看怎么实现,对此我的感受是:一入安卓深似海,从此头发是路人