稀土掘金技术社区 2024年12月17日
Android 同频共帧动画效果
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了Android中实现“同频共帧”动画效果的技术方案,即在多个View上同步展示同一动画。文章分析了传统帧动画的内存占用和性能问题,以及现有动画引擎的局限性,提出利用Drawable Wrapper进行画面投影的创新思路。通过创建多个Drawable实例,并使用Matrix进行缩放和适配,实现了在不同大小的View上同步绘制动画。文章还详细讲解了如何利用Drawable.Callback机制和Choreographer来管理动画的更新和同步,最终实现了高性能、低内存的同频共帧动画效果。

✨ 核心概念:文章提出了“同频共帧”的概念,即多个View以相同的频率和画面同步展示动画,区别于传统的“同频共振”。这种效果常用于综艺节目中的火焰、礼花等特效,以及手机QQ聊天列表中的头像动画。

🚀 技术难点:实现同频共帧动画面临多个技术挑战,包括传统帧动画的内存占用和CPU消耗问题,以及现有动画引擎的局限性。文章指出,直接使用多个AnimationDrawable会导致性能问题,而共享Drawable会引发状态冲突。

💡 解决方案:文章提出了基于Drawable Wrapper的画面投影方案。通过创建多个Drawable实例,并使用Matrix进行缩放和适配,实现了在不同大小的View上同步绘制动画。该方案避免了内存占用和性能问题,提高了动画的效率。

🔄 更新机制:文章详细讲解了如何利用Drawable.Callback机制和Choreographer来管理动画的更新和同步。通过scheduleDrawable和unscheduleDrawable定时处理Runnable,并利用invalidateSelf方法刷新View,实现了动画的流畅播放。

原创 时光少年 2024-12-17 08:31 重庆

点击关注公众号,“技术干货” 及时达!

同频共帧

我们听过“同频共振”,其原理是多个物体物体以同样的频率振动。本篇实现的效果是“同频共帧”,两者有一定的联系,但属于不同的概念。
“同频共帧”的含义是:一种动画效果,以同样的频率、同样的画面展示在多个不同View上。

特点:

下面我们使用6个ImageView来同步播放同一个动画。

效果1:

fire_95.gif

效果2:

fire_96.gif

看似这两种动画就是简单的动画,你可能会想,直接创建6个AnimationDrawable不就行了?显然这种做法仅仅适合图片数量有限或者内存较大的设备,另外AnimationDrawable越多,低性能设备由于CPU性能差,可能出现帧不一致的情况。原则上说,本篇是属于动画性能优化的文章。

另外有同学提出了多个View共享同一个Drawable,事实上Drawable的共享会改变原Drawable的Bounds,其次状态会出现冲突,因此,这条路不可行。

场景:

我们看一些综艺节目,电视两侧都会出现各种火焰、礼花等效果,而且左右是对称的,想象一下,这种情况如果你给一个超大图片去实现,这个时候占用内存肯定会很大,因此是最好的方式是让动画绘制区域更小。

这种动效其实在手机版QQ上就有,如果你给自己的头像设置为一个动态图,当你在聊天群连发多条消息,那么在消息列表中你就会发现,在同一个页面上你的头像动画是同步同帧率展示的。

现状 & 痛点

现状

我们以帧动画问题展开,要知道帧动画有难以容忍的内存占用问题、以及主线程解码问题,同时包体积问题也相当严重,为此市面上出现了很多方案。libpag、lottie、VapPlayer、AlphaPlayer、APNG、GIF、SVGA、AnimationDrawable等。但你在开发时就会发现,每一种引擎都有自己独特的优势,也有自己独特的劣势,你往往想着用一种引擎统一所有动效实现,但往往现实不允许。

我们来说说几大引擎的优缺点:

libPag: 目前支持功能最多的动效引擎,普通动画性能也非常不错,相比其他引擎快很多。该引擎使用自研渲染引擎和解码器实现,但是对于预合成动效(超长动效和复杂动效可能会用到),由于其使用的是软解,在低配设备上比VapPlayer和AlphaPlayer卡的多,另外lib so相比其他引擎也是大很多。

VapPlayer、AlphaPlayer : 这两种其都是通过alpha 遮罩实现,大部分情况下使用的是设备硬解码器,不过,VapPlayer缺乏硬解码器筛选机制,偶尔有时会拿到软解码器,另外其本身存在音画同步问题,至于AlphaPlayer把播放交给系统和三方播放器,避免了此类问题。但是,如果是音视频类app,他们都有共同的问题,终端设备上硬解码器的实例数量是受限制的,甚至有的设备解码器同一时刻只能使用一个,同时使用这种解码器就会造成业务播放器绿屏、起播失败、解码器卡住等问题。不过解决办法是将特效和业务播放器资源类型隔离,如果业务播放器是使用h264资源,那么动效播放可以使用h265、mpeg2、av1等其他编码类型资源,以此规避解码器竞争。

lottie: lottie目前是比较广为人知的动效引擎,使用也相当广泛,上手难度也比较低,可以解决大部分动效播放问题。但lottie动效也存在一些跨平台兼容性,需要单独适配,其次缺少很多特效支持,性能方面,一般情况下其性能是不如libpag的。不过总体能覆盖到大部分场景,至于兼容性问题每个平台单独提供不同的lottie规则即可。
其实,lottie开发也有一定的难度。开发中常常会遇到的问题是,UI设计人员对于lottie的compose layer理解存在问题,往往会出现将lottie动画做成和帧动画一样的动画,显然,compose layer的思想是多张图片合成,那就意味着图片本身应该有大有小,按一定轨迹运动和渐变,而不是类似帧动画一样一帧一帧简单播放。

APNG、GIF、WebP: 这类动画属于资源型动画,其本身存在很多缺点,比如占内存和耗cpu,另外APNG和WebP兼容性不足,不过简短的动效的还是可以使用的,特别是WebP在Android、Web、部分iOS设备上优势也比较大。

SVGA: 很多平台对这种动画抱有期待,特别是其矢量性质和低内存的特点,然而,其本身面临标准不统一的问题,造成跨平台的能力不足,其次是因为SVGA需要转为Path绘制,但Path的绘制非常消耗性能。

LazyAnimationDrawable: 几乎所有的动画对低配设备都不友好,帧动画比上不足比下有余,低配设备上,为了解决libpag、VapPlayer、lottie对低配设备上音视频类app不友好的问题,使用AnimationDrawble显然是不行的,因此我们往往会实现了自己的AnimationDrawable,使其具备兜底的能力,主要原理: 独立线程解码 + 展示一帧预 + 加载下一帧 + 帧缓存,其实也就是LazyAnimationDrawable。

痛点

以上我们罗列了很多问题,看似和我们的主要目的毫无关系,其实我们可以想想,如果使用上述引擎,哪种方式可以实现兼容性更好的 “同频共帧” 动效呢 ?

实际上,几乎没有引擎能承担此任务,那有没有办法实现呢?

原理

我们很难让每个View同时执行和绘制同样的画面,另一个问题是,如果设计多个View绘制Bitmap,那么还可能造成资源加载的内存OOM的问题。另外一方面如果使用LazyAnimationDrawable、VapX、AlphaPlayer等,同时执行相同的动效,那么解码线程需要创建多个,显然性能、内存问题也是重中之重。

有没有更加简单方法呢 ?

实际上是有的,那就是投影。

我们无论使用CompositeDrawable、LazyAnimationDrawable、AnimationDrawable还是VectorDrawable,我们可以保证在使用个实例的情况下,将画面绘制到不同View上即可。

「不过:本篇以AnimationDrawable 为例子实现,其实其他Drawable动画类似。」

实现

这种难度也是很高的,如果我们使用一个View 管理器,然后构建一个播放器,显然还要处理View各种状态,显然避免不了耦合问题。这里我们回到开头说过的drawable方案,当然,一个drawable显然无法设置给多个View,这点显然是我们需要处理的难点,此外,每个View的大小也不一致,如何处理这种问题呢。

之前的文章中我们实现了很多动效,但几乎都是基于View本身实现的,但是在Android中,Drawable最容易扩展和移植的渲染组件。通过Drawable提供的接口,我们可以接入libpag、lottie、SVG、APNG、gif,LazyAnimationDrawable、AnimationDrawable等动效,更加方便移植,同时Drawable支持setHotspot和setState接口,可以实现复杂度较低的交互效果。

Drawable Wrapper

一个Drawable不能同时设置给不同的View,但是我们可以创建多个Drawable从目标Drawable上“投影”画面到自身。

这里我们参考Glide中com.bumptech.glide.request.target.FixedSizeDrawable 实现,其原理是通过FixedSizeDrawable代理真实的drawble绘制,从而达到投影效果。在drawable更新时,利用Matrix实现Canvas缩放,即可适配不同大小的View。

FixedSizeDrawable(State state, Drawable wrapped) {  this.state = Preconditions.checkNotNull(state);  this.wrapped = Preconditions.checkNotNull(wrapped);
// We will do our own scaling. wrapped.setBounds(0, 0, wrapped.getIntrinsicWidth(), wrapped.getIntrinsicHeight());
matrix = new Matrix(); wrappedRect = new RectF(0, 0, wrapped.getIntrinsicWidth(), wrapped.getIntrinsicHeight()); bounds = new RectF();}

Matrix 的作用:

这里主要是将原有的画面缩放至目标View

matrix.setRectToRect(wrappedRect, drawableBounds, Matrix.ScaleToFit.CENTER);canvas.concat(matrix);  //Canvas Matrix 转换

当然,必要时支持下alpha和colorFilter,以此来实现变色效果,下面是完整实现。

public static class AnimationDrawableWrapper extends Drawable {
private final Drawable animationDrawable; //动画drawable private final Matrix matrix = new Matrix(); private final RectF wrappedRect; private final RectF drawableBounds; private final Matrix.ScaleToFit scaleToFit; private int alpha = 255; private ColorFilter colorFilter;
public AnimationDrawableWrapper(Drawable drawable, Matrix.ScaleToFit scaleToFit) { this.animationDrawable = drawable; this.wrappedRect = new RectF(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); this.drawableBounds = new RectF(); this.scaleToFit = scaleToFit; }
@Override public void draw(Canvas canvas) { Drawable current = animationDrawable.getCurrent(); if (current == null) { return; } current.setAlpha(this.alpha); current.setColorFilter(this.colorFilter); Rect drawableRect = current.getBounds(); wrappedRect.set(drawableRect); drawableBounds.set(getBounds());
// 变化坐标 matrix.setRectToRect(wrappedRect, drawableBounds, scaleToFit);
int save = canvas.save();
canvas.concat(matrix); current.draw(canvas);
canvas.restoreToCount(save);
current.setAlpha(255);//还原 current.setColorFilter(null); //还原
}
@Override public void setAlpha(int alpha) { this.alpha = alpha; }
@Override public void setColorFilter(ColorFilter colorFilter) { this.colorFilter = colorFilter; }
@Override public int getOpacity() { return PixelFormat.TRANSLUCENT; }
}

View更新

我们知道AnimationDrawable每一帧都是不一样的,那怎么将每一帧都能绘制在View上呢,了解过Drawable更新机制的开发者都知道,每一个View都实现了Drawable.Callback,当给View设置drawable时,Drawable.Callback也会设置给drawable。

Drawable刷新View时需要调用invalidate,显然是通过Drawable.Callback实现,当然,Drawable自身就实现了更新方法Drawable#invalidateSelf,我们只需要调用改方法刷新View即可触发View#onDraw,从而触发drawable#draw方法。

public void invalidateSelf() {    final Callback callback = getCallback();    if (callback != null) {        callback.invalidateDrawable(this);    }}

更新AnimationDrawable

显然,任何动画都具备时间属性,因此更新Drawable是必要的,View本身是可以通过Drawable.Callback机制更新Drawable的。通过scheduleDrawable和unscheduleDrawable 定时处理Runnable和取消Runnable。

public interface Callback {
void invalidateDrawable(@NonNull Drawable who); void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when);
void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what);}

而AnimationDrawable实现了Runnable接口

@Overridepublic void run() {    nextFrame(false);}

然而,如果使用的RecyclerView,那么还可能会出现View 从页面移除的问题,因此依靠View显然是不行的,这里我们引入Handler或者Choreograper。

this.choreographer = Choreographer.getInstance();

但是,我们什么时候调用呢?显然还得利用Drawable.Callback机制

给animationDrawable设置Drawable.Callback

this.drawable.setCallback(callback);

更新逻辑实现

@Overridepublic void invalidateDrawable(@NonNull Drawable who) {//更新所有wrapper    for (int i = 0; i < drawableList.size(); i++) {        WeakReference<AnimationDrawableWrapper> reference = drawableList.get(i);        AnimationDrawableWrapper wrapper = reference.get();        if (wrapper == null) {            return;        }        wrapper.invalidateSelf();    }}
@Overridepublic void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) { this.scheduleTask = what; this.choreographer.postFrameCallbackDelayed(this, when - SystemClock.uptimeMillis());}
@Overridepublic void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) { this.scheduleTask = null; this.choreographer.removeFrameCallback(this);}

既然使用Choreographer,那doFrame需要实现的

@Overridepublic void doFrame(long frameTimeNanos) {    if(this.scheduleTask != null) {        this.scheduleTask.run();    }}

好了,以上就是核心逻辑,到此我们就实现了核心逻辑

完整代码

下面是镜像动画的完整代码,因为是比较成熟的代码,接入了LazyAnimationDrawable。

当然,如果你没有LazyAnimationDrawable的实现的话,删掉LazyAnimationDrawable的引,仅仅通过AnimationDrawable也是可以正常使用的。另外,你还可添加其他动画实现,比如VerctorDrawable等。

我们通过MirrorFrameAnimation,实现同频共帧动画效果。

public class MirrorFrameAnimation implements Drawable.Callback, Choreographer.FrameCallback {    private final Drawable drawable;    private final int drawableWidth;    private final int drawableHeight;    private List<WeakReference<AnimationDrawableWrapper>> drawableList = new ArrayList<>();    private Choreographer choreographer;    private Runnable scheduleTask;
public MirrorFrameAnimation(Resources resources, int resId, boolean isLazyDrawableAnimation, int drawableWidth, int drawableHeight) {
//设置宽高,防止AnimationDrawable大小不稳定问题 this.drawableWidth = drawableWidth; this.drawableHeight = drawableHeight; this.drawable = isLazyDrawableAnimation ? LazyAnimationDrawableInflater.getDrawable(resources, resId, null) : resources.getDrawable(resId); this.drawable.setBounds(0, 0, drawableHeight, drawableHeight); this.drawable.setCallback(this); this.choreographer = Choreographer.getInstance(); }
public MirrorFrameAnimation(LazyAnimationDrawable lazyAnimationDrawable, int drawableWidth, int drawableHeight) {
//设置宽高,防止AnimationDrawable大小不稳定问题 this.drawableWidth = drawableWidth; this.drawableHeight = drawableHeight; this.drawable = lazyAnimationDrawable; this.drawable.setBounds(0, 0, drawableHeight, drawableHeight); this.drawable.setCallback(this); this.choreographer = Choreographer.getInstance(); }
public MirrorFrameAnimation(AnimationDrawable animationDrawable, int drawableWidth, int drawableHeight) {
//设置宽高,防止AnimationDrawable大小不稳定问题 this.drawableWidth = drawableWidth; this.drawableHeight = drawableHeight; this.drawable = animationDrawable; this.drawable.setBounds(0, 0, drawableHeight, drawableHeight); this.drawable.setCallback(this); this.choreographer = Choreographer.getInstance(); }
public void start() { choreographer.removeFrameCallback(this); if (drawable instanceof AnimationDrawable) { ((AnimationDrawable) drawable).start(); } else if (drawable instanceof LazyAnimationDrawable) { ((LazyAnimationDrawable) drawable).start(); } }
public void stop() { choreographer.removeFrameCallback(this); if (drawable instanceof AnimationDrawable) { ((AnimationDrawable) drawable).stop(); } else if (drawable instanceof LazyAnimationDrawable) { ((LazyAnimationDrawable) drawable).stop(); } }
/** * @return The number of frames in the animation */ public int getNumberOfFrames() { if (drawable instanceof AnimationDrawable) { return ((AnimationDrawable) drawable).getNumberOfFrames(); } else if (drawable instanceof LazyAnimationDrawable) { return ((LazyAnimationDrawable) drawable).getNumberOfFrames(); } return 0; }
/** * @return The Drawable at the specified frame index */ public Drawable getFrame(int index) { if (drawable instanceof AnimationDrawable) { return ((AnimationDrawable) drawable).getFrame(index); } else if (drawable instanceof LazyAnimationDrawable) { return ((LazyAnimationDrawable) drawable).getDrawableOfFrame(index); } return drawable; }
/** * @return The duration in milliseconds of the frame at the * specified index */ public int getDuration(int index) { if (drawable instanceof AnimationDrawable) { return ((AnimationDrawable) drawable).getDuration(index); } else if (drawable instanceof LazyAnimationDrawable) { return ((LazyAnimationDrawable) drawable).getDuration(index); } return 0; }
/** * @return True of the animation will play once, false otherwise */ public boolean isOneShot() { if (drawable instanceof AnimationDrawable) { return ((AnimationDrawable) drawable).isOneShot(); } else if (drawable instanceof LazyAnimationDrawable) { return ((LazyAnimationDrawable) drawable).isOneShot(); } return true; }
public boolean isRunning() { if (drawable instanceof AnimationDrawable) { return ((AnimationDrawable) drawable).isRunning(); } else if (drawable instanceof LazyAnimationDrawable) { return ((LazyAnimationDrawable) drawable).isRunning(); } return false; }
public long getDuration(){ long duration = 0; if (drawable instanceof AnimationDrawable) { AnimationDrawable animationDrawable = (AnimationDrawable) drawable; int numberOfFrames = animationDrawable.getNumberOfFrames(); for (int i = 0; i < numberOfFrames; i++) { duration += animationDrawable.getDuration(i); } } else if (drawable instanceof LazyAnimationDrawable) { LazyAnimationDrawable animationDrawable = (LazyAnimationDrawable) drawable; int numberOfFrames = animationDrawable.getNumberOfFrames(); for (int i = 0; i < numberOfFrames; i++) { duration += animationDrawable.getDuration(i); } } return duration; }
public boolean isEndFrame() { if (drawable instanceof AnimationDrawable) { AnimationDrawable animationDrawable = (AnimationDrawable) drawable; int frameIndex = animationDrawable.getNumberOfFrames() - 1; return animationDrawable.isOneShot() && animationDrawable.getFrame(frameIndex) == animationDrawable.getCurrent(); } if (drawable instanceof LazyAnimationDrawable) { LazyAnimationDrawable animationDrawable = (LazyAnimationDrawable) drawable; int frameIndex = animationDrawable.getNumberOfFrames() - 1; if (animationDrawable.isOneShot() && animationDrawable.getFrame(frameIndex) == animationDrawable.getCurrentFrame()) { return true; } } return false; }
public boolean isEndAnimation() { if (isEndFrame()) { return true; } if (drawable instanceof LazyAnimationDrawable) { LazyAnimationDrawable animationDrawable = (LazyAnimationDrawable) drawable; if (animationDrawable.isLoopFinish()) { return true; } } return false; }
/** * Sets whether the animation should play once or repeat. * * @param oneShot Pass true if the animation should only play once */ public void setOneShot(boolean oneShot) { if (drawable instanceof AnimationDrawable) { ((AnimationDrawable) drawable).setOneShot(oneShot); } else if (drawable instanceof LazyAnimationDrawable) { ((LazyAnimationDrawable) drawable).setOneShot(oneShot); } }
public void syncDrawable(View view, Matrix.ScaleToFit scaleToFit) { if (!(drawable instanceof AnimationDrawable) && !(drawable instanceof LazyAnimationDrawable)) { if (view instanceof ImageView) { ((ImageView) view).setImageDrawable(drawable); } else { view.setBackground(drawable); } return; }
AnimationDrawableWrapper wrapper = new AnimationDrawableWrapper(drawable,scaleToFit); drawableList.add(new WeakReference<>(wrapper));
if (view instanceof ImageView) { ((ImageView) view).setImageDrawable(wrapper); } else { view.setBackground(wrapper); } }
@Override public void invalidateDrawable(Drawable who) { for (int i = 0; i < drawableList.size(); i++) { WeakReference<AnimationDrawableWrapper> reference = drawableList.get(i); AnimationDrawableWrapper wrapper = reference.get(); if (wrapper == null) { return; } wrapper.invalidateSelf(); } }
@Override public void scheduleDrawable(Drawable who, Runnable what, long when) { this.scheduleTask = what; this.choreographer.postFrameCallbackDelayed(this, when - SystemClock.uptimeMillis()); }
@Override public void unscheduleDrawable(Drawable who, Runnable what) { this.scheduleTask = null; this.choreographer.removeFrameCallback(this); }

@Override public void doFrame(long frameTimeNanos) { if (this.scheduleTask != null) { this.scheduleTask.run(); } }
@Nullable public MirrorFrameAnimation cloneDrawable(int drawableWidth,int drawableHeight) { Drawable newDrawable = drawable.getConstantState().newDrawable(); if(newDrawable instanceof LazyAnimationDrawable) { return new MirrorFrameAnimation((LazyAnimationDrawable) newDrawable, drawableWidth, drawableHeight); }else if(newDrawable instanceof AnimationDrawable) { return new MirrorFrameAnimation((AnimationDrawable) newDrawable, drawableWidth, drawableHeight); } return null; }
public static class AnimationDrawableWrapper extends Drawable {
private final Drawable animationDrawable; //动画drawable private final Matrix matrix = new Matrix(); private final RectF wrappedRect; private final RectF drawableBounds; private final Matrix.ScaleToFit scaleToFit; private int alpha = 255; private ColorFilter colorFilter;
public AnimationDrawableWrapper(Drawable drawable, Matrix.ScaleToFit scaleToFit) { this.animationDrawable = drawable; this.wrappedRect = new RectF(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); this.drawableBounds = new RectF(); this.scaleToFit = scaleToFit; }
@Override public void draw(Canvas canvas) { Drawable current = animationDrawable.getCurrent(); if (current == null) { return; } current.setAlpha(this.alpha); current.setColorFilter(this.colorFilter); Rect drawableRect = current.getBounds(); wrappedRect.set(drawableRect); drawableBounds.set(getBounds());
// 变化坐标 matrix.setRectToRect(wrappedRect, drawableBounds, scaleToFit);
int save = canvas.save();
canvas.concat(matrix); current.draw(canvas);
canvas.restoreToCount(save);
current.setAlpha(255);//还原 current.setColorFilter(null); //还原
}
@Override public void setAlpha(int alpha) { this.alpha = alpha; } @Override public void setColorFilter(ColorFilter colorFilter) { this.colorFilter = colorFilter; } @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; }
}}

使用方法

int dp2px = (int) dp2px(100);MirrorFrameAnimation mirrorFrameAnimation = new MirrorFrameAnimation(getResources(),R.drawable.loading_animation,dp2px,dp2px);
mirrorFrameAnimation.syncDrawable(imageView1,Matrix.ScaleToFit.CENTER);mirrorFrameAnimation.syncDrawable(imageView2,Matrix.ScaleToFit.CENTER);mirrorFrameAnimation.syncDrawable(imageView3,Matrix.ScaleToFit.FILL);mirrorFrameAnimation.syncDrawable(imageView4,Matrix.ScaleToFit.FILL);mirrorFrameAnimation.syncDrawable(imageView5,Matrix.ScaleToFit.CENTER);mirrorFrameAnimation.syncDrawable(imageView6,Matrix.ScaleToFit.CENTER);
mStart.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { mirrorFrameAnimation.start(); }});mStop.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { mirrorFrameAnimation.stop(); }});

适用范围

图像同步执行需求

我们常常会出现屏幕边缘方向同时展示相同动画的问题,由于每个动画启动存在一定的延时,以及控制逻辑不稳定,往往会出现一边动画播放结束,另一边动画还在展示的情况。

本篇我们实现了“同频共帧动效”,实际上这也是一种对称动画的优化方法。

总结

动效一直是Android设备的上需要花大力气优化的,如果是图像同步执行、对称动效,本篇方案显然可以帮助我们减少线程和内存的消耗。

点击关注公众号,“技术干货” 及时达!

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

同频共帧 Drawable Wrapper 动画优化 Android Choreographer
相关文章