Android 图片着色 Tint 详解

Stella981
• 阅读 750

问题描述

在app中可能存在一张图片只是因为颜色的不同而引入了多张图片资源的情况。比如 一张右箭头的图片,有白色、灰色和黑色三种图片资源存在。所以我们可不可以只保留一张基础图片,在此图片基础上只是颜色改变的情况是否可以通过代码设置来动态修改呢?

知识点概览:
1. setTint、setTintList :对drawable 进行着色。
2. DrawableCompat.wrap: 对drawable 进行包装,使其可以在不同版本中设置着色生效。
3. drawable.mutate(): 使drawable 可变,打破其共享资源模式。
4. ConstantState :① 享元模式。② 保存资源信息。③可通过自己创建新的drawable 对象。


初识tint

为了兼容android 的不同版本,google 在DrawableCompat API中提供了着色的相关方法。
Android 图片着色 Tint 详解


setTint、setTintList

先构造好我们的测试demo。提供一个工具类用于对Drawable 进行着色。
(注:为了测试对低版本的兼容,这里使用的测试机型为三星 galaxy s4 android版本为4.4.2)

public class SkxDrawableHelper {

    /** * 对目标Drawable 进行着色 * * @param drawable 目标Drawable * @param color 着色的颜色值 * @return 着色处理后的Drawable */ public static Drawable tintDrawable(@NonNull Drawable drawable, int color) { Drawable wrappedDrawable = DrawableCompat.wrap(drawable); DrawableCompat.setTint(wrappedDrawable, color); return wrappedDrawable; } /** * 对目标Drawable 进行着色 * * @param drawable 目标Drawable * @param colors 着色值 * @return 着色处理后的Drawable */ public static Drawable tintListDrawable(@NonNull Drawable drawable, ColorStateList colors) { Drawable wrappedDrawable = DrawableCompat.wrap(drawable); // 进行着色 DrawableCompat.setTintList(wrappedDrawable, colors); return wrappedDrawable; } }

测试代码:
调用此方法对Deawable进行着色。我们分别对设置背景的Drawable着色 #30c3a6, 图片着色为#ff4081

Drawable originBitmapDrawable = ContextCompat.getDrawable(this,
                R.drawable.icon_beijing);

mImageView1.setBackground( SkxDrawableHelper.tintDrawable(originBitmapDrawable, Color.parseColor("#30c3a6"))); mImageView2.setImageDrawable( SkxDrawableHelper.tintDrawable(originBitmapDrawable, Color.parseColor("#ff4081")));

没有进行着色处理的原效果:
Android 图片着色 Tint 详解

进行着色后的效果如下:
Android 图片着色 Tint 详解

一脸懵逼,这都什么跟什么啊?!!! 我只修改了下面的两个ImageView,并没有对上面的两个ImageView进行修改啊。而且 图4是怎么出来那么个畸形的。
好吧,一步步来!


DrawableCompat wrap

这里简单介绍下wrap 这个方法。这个方法的作用是对目标Drawable进行包装,它可以用于跨越不同的API级别,通过在这个类中的着色方法,简单来说就是为了兼容不同的版本。如果想对Drawable 进行着色就必须调用此方法。

* Drawable bg = DrawableCompat.wrap(view.getBackground());
* // Need to set the background with the wrapped drawable
* view.setBackground(bg);
*
* // You can now tint the drawable
* DrawableCompat.setTint(bg, ...);

与wrap 方法对应的有 unwrap(@NonNull Drawable drawable) 方法,用于解除对目标Drawable的包装。


ConstantState 享元模式

为什么会出现上面出现的这种情况呢?
这里简单解释下。不同的Drawble如果加载的是同一个资源,那么将拥有共同的状态,这是google对Drawable 做的内存优化。在Drawable 中的表现为 ConstantState,ConstantState是抽象静态内部类,Drawable 的子类如ColorDrawble,BitmapDrawable 也分别都进行了不同的实现。而在ConstantState 内部类中保存的就是Drawable 需要展示的信息,在ColorDrawable 中ConstantState 的实现类是ColorState,其中包含了一些颜色信息;在BitmapDrawable 中ConstantState的实现类是BitmapState,其中包含了Paint,Bitmap,ColorStateList等一些属性,不同的Drawable子类依靠其对应的ConstantState实现类来刷新渲染视图。默认情况下,从同一资源加载的所有drawables实例都共享一个公共状态,如果修改一个实例的状态,所有其他实例将接收相同的修改。

我们从ContextCompat类获取Drawable 方法一步步往下看android 是如何实现Drawable共享的。
ContextCompat.java

    public static final Drawable getDrawable(Context context, int id) { final int version = Build.VERSION.SDK_INT; if (version >= 21) { return ContextCompatApi21.getDrawable(context, id); } else { return context.getResources().getDrawable(id); } }

Resources.java

  public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme) throws NotFoundException {
        TypedValue value;
        ......
        // 从这里继续跟进去,这是加载Drawable的方法 final Drawable res = loadDrawable(value, id, theme); synchronized (mAccessLock) { if (mTmpValue == null) { mTmpValue = value; } } return res; }

   @Nullable
   Drawable loadDrawable(TypedValue value, int id, Theme theme) throws NotFoundException {

        ......

        final boolean isColorDrawable; // Drawable 的资源缓存类 final DrawableCache caches; // 缓存的key final long key; ...... // 这里先判断是否加载过,如果已经加载过就去缓存里面去取,如果成功从缓存中取到就返回。 if (!mPreloading) { final Drawable cachedDrawable = caches.getInstance(key, theme); if (cachedDrawable != null) { return cachedDrawable; } } // 缓存中没有,则根据ConstantState 来创建新的Drawable final ConstantState cs; if (isColorDrawable) { cs = sPreloadedColorDrawables.get(key); } else { cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key); } Drawable dr; if (cs != null) { dr = cs.newDrawable(this); } else if (isColorDrawable) { dr = new ColorDrawable(value.data); } else { dr = loadDrawableForCookie(value, id, null); } ...... // 缓存Drawable if (dr != null) { dr.setChangingConfigurations(value.changingConfigurations); cacheDrawable(value, isColorDrawable, caches, theme, canApplyTheme, key, dr); } return dr; }

可以看下cacheDrawable 这个方法,虽然从名字上理解是缓存Drawable,但其实是缓存的Drawable对应的ConstantState 。

  private void cacheDrawable(TypedValue value, boolean isColorDrawable, DrawableCache caches,
            Theme theme, boolean usesTheme, long key, Drawable dr) { final ConstantState cs = dr.getConstantState(); ...... // 缓存ConstantState caches.put(key, theme, cs, usesTheme); ...... } }

DrawableCache.java

public Drawable getInstance(long key, Resources.Theme theme) {

        // 注意这里,从缓存中取出来的是 ConstantState 
        final Drawable.ConstantState entry = get(key, theme); if (entry != null) { return entry.newDrawable(mResources, theme); } return null; }

跟到这里心里大概也有谱了,原来android 不是共享的Drawable ,而是共享的内部类 ConstantState,ConstantState 中才是保存相关信息的。所以也就会出现如果修改了资源的某一个项信息,引用相同资源的其他Drawable 也就一同变化。这会儿我们看下面的这张图也就不难理解了!
Android 图片着色 Tint 详解

而如果要实现对同一个Drawable进行不同着色就必须要打破这种共享状态。使之成为下图所展示的状态。
Android 图片着色 Tint 详解

那么如何才能打破这种状态呢?


mutate() 使Drawable可变

上面说到如果要实现对同一个Drawable进行不同着色就必须要打破这种共享状态。默认情况下,从同一资源加载的所有drawables实例都共享一个公共状态; 如果修改一个实例的状态,所有其他实例将接收相同的修改。而mutate() 方法就是使drawable 可变, 一个可变的drawable不与任何其他drawable共享它的状态,这样如果只修改可变drawable的属性就不会影响到其他与它加载同一个资源的drawable。

那么为mutate方法是如何打破共享状态呢?
Drawable 是抽象类,同时mutate()返回的是this,我们以BitmapDrawable 为例,看下mutate() 这个方法。

 /**
     * A mutable BitmapDrawable still shares its Bitmap with any other Drawable
     * that comes from the same resource.
     *
     * @return This drawable.
     */
    @Override
    public Drawable mutate() { /* mMutated 是个标签,用来保证mutate只会设置一次,也就解释了在Drawable中对mutate() 方法的一个解释,Calling this method on a mutable Drawable will have no effect(在已经可变的drawable上调用此方法无效),因为返回的还是自身 */ if (!mMutated && super.mutate() == this) { // 重新引用了一个新的状态对象 mBitmapState = new BitmapState(mBitmapState); mMutated = true; } return this; }

而BitmapState(BitmapState bitmapState) 这个构造方法是对自己的属性重新进行了赋值。这样就相当于不再引用共享的公共状态了,重新指向了一个新的状态。

ok,修改我们的工具类重新看下效果。

  /**
     * 对目标Drawable 进行着色
     *
     * @param drawable 目标Drawable
     * @param color    着色的颜色值
     * @return 着色处理后的Drawable
     */
    public static Drawable tintDrawable(@NonNull Drawable drawable, int color) { Drawable wrappedDrawable = DrawableCompat.wrap(drawable).mutate(); DrawableCompat.setTint(wrappedDrawable, color); return wrappedDrawable; }

效果:
Android 图片着色 Tint 详解

又是一脸懵逼中,怎么还是不对。虽然上面的两个ImageView 显示ok 了,但为下面的两个ImageVIew显示还是不对啊?奇了怪了!

猜想1:文档中有这么一句介绍 “Calling this method on a mutable Drawable will have no effect.”在可变的Drawable 上调用此方法无效。所以我猜想会不会因为目标Drawable 已经可变的了,但是因为 warp()方法是对同一个Drawable 对象做的包装,如果已经调用过mutate()方法了,那么再次调用mutate 方法无效,对Drawable的最后一次修改覆盖了之前的修改。猜想来源于俩个现象,1.上面的两个ImageView 没有受影响,显示的是正确的;2.后修改的红色生效,而原本应该显示绿色ImageView 却显示成了红色。

猜想2:以BitmapDrawable 为例,在BitmapDrawable 的mutate 方法中有这么一句描述:“A mutable BitmapDrawable still shares its Bitmap with any other Drawable that comes from the same resource.”

那么经过wrap 处理过的drawable 是否还是原来的drawable呢?
打印 DrawableCompat.wrap(drawable).toString() 发现两次得到的结果是不一样的,也就是说传入的和包装后的不是同一个对象。但是我用小米5 android版本是7.0 得到的结果又是一样的,即传入的和包装后的是同一个对象。

测试机型为小米5 系统版本为7.0。出现的效果和三星Galaxy s4 是一样。

Log.e("drawable", drawable.toString());
Log.e("wrap", DrawableCompat.wrap(drawable).toString()); 02-07 21:41:36.557 24675-24675/com.skx.tomike E/drawable: android.graphics.drawable.BitmapDrawable@12bc2f1 02-07 21:41:36.557 24675-24675/com.skx.tomike E/wrap: android.graphics.drawable.BitmapDrawable@12bc2f1 02-07 21:41:36.558 24675-24675/com.skx.tomike E/drawable: android.graphics.drawable.BitmapDrawable@12bc2f1 02-07 21:41:36.558 24675-24675/com.skx.tomike E/wrap: android.graphics.drawable.BitmapDrawable@12bc2f1

通过查看代码中也得到了相应的答案。

DrawableCompat.java

version >= 23
static class MDrawableImpl extends LollipopDrawableImpl { @Override public void setLayoutDirection(Drawable drawable, int layoutDirection) { DrawableCompatApi23.setLayoutDirection(drawable, layoutDirection); } @Override public int getLayoutDirection(Drawable drawable) { return DrawableCompatApi23.getLayoutDirection(drawable); } @Override public Drawable wrap(Drawable drawable) { // No need to wrap on M+ M以上版本不需要包装,直接返回drawable return drawable; } } version >= 19 static class KitKatDrawableImpl extends JellybeanMr1DrawableImpl { @Override public void setAutoMirrored(Drawable drawable, boolean mirrored) { DrawableCompatKitKat.setAutoMirrored(drawable, mirrored); } @Override public boolean isAutoMirrored(Drawable drawable) { return DrawableCompatKitKat.isAutoMirrored(drawable); } @Override public Drawable wrap(Drawable drawable) { // 这里是new 出来的新对象。 return DrawableCompatKitKat.wrapForTinting(drawable); } @Override public int getAlpha(Drawable drawable) { return DrawableCompatKitKat.getAlpha(drawable); } }

这里我摘出来两个来进行对比。当api版本>=23 时,wrap 方法返回是传入的drawable。当api版本>=19 && <21 时,warp方法返回的是DrawableCompatKitKat.wrapForTinting(drawable)。这也就解释了为什么api版本不同,返回的结果不同了。

在高版本上(api>23)也就验证了猜想1是正确的,因为前后两次着色都是针对同一个drawable对象,而mutate 方法又只会生效一次,所以第二次的设置就理所应当的覆盖了第一次的设置,那么表现出来的结果就应该都是后面设置的颜色。

但是对于低版本就不太清楚为什么了,对drawable 进行包装后得到的两个不同的对象,既然是不同的对象,而且还都进行了mutate()设置为什么还是会表现出一样呢?这里做个记录!

针对猜想1我们做个简单试验。如果只是因为引用的是同一个Drawable对象的话,那我们只需要引用不同的Drawable 对象就OK了。
这样做下简单修改:

Drawable originBitmapDrawable = ContextCompat.getDrawable(this,
        R.drawable.icon_beijing);

mImageView1.setBackground( SkxDrawableHelper.tintDrawable(originBitmapDrawable, Color.parseColor("#30c3a6"))); Drawable originBitmapDrawable2 = ContextCompat.getDrawable(this, R.drawable.icon_beijing); mImageView2.setImageDrawable( SkxDrawableHelper.tintDrawable(originBitmapDrawable2, Color.parseColor("#ff4081"))); 

效果:
Android 图片着色 Tint 详解

对了?还是很懵,还有好多想不通的地方!还是要多翻源码啊。


Drawable getConstantState()

返回一个持有此Drawable的共享状态的ConstantState实例。而ConstantState类中也提供了方法来创建Drawable,在上面的部分我们也见到过。

Android 图片着色 Tint 详解

newDrawable:从当前共享状态来创建一个drawable 实例。

这样的话我们就可以通过 getConstantState() 方法来获取drawable 所持有的共享状态的ConstantState,然后通过 newDrawable 方法来获取相应的drawable实例。


Android Tint工具类

public class SkxDrawableHelper {

    /** * 对目标Drawable 进行着色 * * @param drawable 目标Drawable * @param color 着色的颜色值 * @return 着色处理后的Drawable */ public static Drawable tintDrawable(@NonNull Drawable drawable, int color) { // 获取此drawable的共享状态实例 Drawable wrappedDrawable = getCanTintDrawable(drawable); // 进行着色 DrawableCompat.setTint(wrappedDrawable, color); return wrappedDrawable; } /** * 对目标Drawable 进行着色。 * 通过ColorStateList 指定单一颜色 * * @param drawable 目标Drawable * @param color 着色值 * @return 着色处理后的Drawable */ public static Drawable tintListDrawable(@NonNull Drawable drawable, int color) { return tintListDrawable(drawable, ColorStateList.valueOf(color)); } /** * 对目标Drawable 进行着色 * * @param drawable 目标Drawable * @param colors 着色值 * @return 着色处理后的Drawable */ public static Drawable tintListDrawable(@NonNull Drawable drawable, ColorStateList colors) { Drawable wrappedDrawable = getCanTintDrawable(drawable); // 进行着色 DrawableCompat.setTintList(wrappedDrawable, colors); return wrappedDrawable; } /** * 获取可以进行tint 的Drawable * <p> * 对原drawable进行重新实例化 newDrawable() * 包装 warp() * 可变操作 mutate() * * @param drawable 原始drawable * @return 可着色的drawable */ @NonNull private static Drawable getCanTintDrawable(@NonNull Drawable drawable) { // 获取此drawable的共享状态实例 Drawable.ConstantState state = drawable.getConstantState(); // 对drawable 进行重新实例化、包装、可变操作 return DrawableCompat.wrap(state == null ? drawable : state.newDrawable()).mutate(); } }
点赞
收藏
评论区
推荐文章
Jacquelyn38 Jacquelyn38
3年前
手把手教你实现一个图片压缩工具(Vue与Node的完美配合)
前言图片压缩对于我们日常生活来讲,是非常实用的一项功能。有时我们会在在线图片压缩网站上进行压缩,有时会在电脑下软件进行压缩。那么我们能不能用前端的知识来自己实现一个图片压缩工具呢?答案是有的。效果展示原图片大小:82KB压缩后的图片大小:17KB测试是不是特别good!!!看到上面的压缩后的图片,可能你还会质疑图片的清晰度,那么看下面(第一张图为压缩后的图片
红橙Darren 红橙Darren
3年前
NDK 开发实战 - 微信公众号二维码检测
关于二维码识别,我们一般都是用的或者,但它们的识别率其实不是很高,有些情况下是失灵的,比如下面这两张图:使用开源库扫描以上两张二维码,有一张死活不识别。使用微信是可以的,大家可以用支付宝试试(不行),那碰到这种情况到底该怎么办呢?哈哈,这次终于有用武之地了,我们琢磨着来优化一把。我们在微信公众号都用过这么一个功能,长按一张图片,如果该图片包含有二
浩浩 浩浩
3年前
【Flutter实战】图片和Icon
3.5图片及ICON3.5.1图片Flutter中,我们可以通过Image组件来加载并显示图片,Image的数据源可以是asset、文件、内存以及网络。ImageProviderImageProvider是一个抽象类,主要定义了图片数据获取的接口load(),从不同的数据源获取图片需要实现不同的ImageProvi
Stella981 Stella981
3年前
Qt加载SVG图片以及改变SVG图片颜色
1Qt加载SVG图片QTreeWidgetItemitemnewQTreeWidgetItem;//svg_path为SVG图片路径QSvgRenderersvg_rendernewQSvgRenderer(svg_path);QPixmappixmapnewQPixmap(32
Stella981 Stella981
3年前
SimpleRoundedImage
1.一张图片是如何显示在屏幕上的一张图片渲染到unity界面中的大致流程。!(https://oscimg.oschina.net/oscnet/af13e253634a4c3750d3954e773f2f1179e.png)<!more2.我们要做什么我们要做的就是在CP
Wesley13 Wesley13
3年前
CSS背景颜色、背景图片、平铺、定位、固定
CSS背景颜色设置backgroundcolor:red;如设置背景颜色为红色;背景颜色设置支持3种写法:颜色名16进制rgbCSS背景图片颜色设置backgroundimage:url(图片地址);如设置背景图片路径不在说明了!CSS背景图片平铺设置(如果不设置图片默认设置为x轴y轴同时平铺即值为repeat)b
可莉 可莉
3年前
10 使用 OpenCV、Kafka 和 Spark 技术进行视频流分析
问题引起基于分布式计算框架Spark的室内防盗预警系统首先用摄像头录一段视频,存在电脑里,下载一个ffmpeg的软件对视频进行处理,处理成一张张图片,然后通过hadoop里边的一个文件系统叫做hdfs进行储存,之后进行分析。用spark将hdfs中存储的图片进行读取,调用opencv的人形识别算法将图片中有人形的图片识别出来,然后就代表屋子里进人了,
Wesley13 Wesley13
3年前
FancyMoves,一款精美的图片轮播插件,可用键盘左右键进行轮播
   本次给各位介绍的是一个名叫FancyMoves的JQuery图片轮播插件。您可以使用鼠标点击,甚至是使用键盘左右键来进行图片的切换操作。   特性介绍:   1.轻松的改变幻灯变的宽度。   2.轻易改变下一张展示图片的数量。   3.最后一张图片会循环回到第一张图片里。   4.嵌入了Fancy
Stella981 Stella981
3年前
Cocos Creator基础教程(12)—精灵变身
在CocosCreator中使用率最高的非精灵(Sprite)莫属了,在游戏中我们经常会遇到将一张图片替换成另一张图片的情况,或者是在不同状态时来回切换图片。实现这个功能对程序员同学来说并不难,但是!回头检视一下编写的代码,能否让美术、策划同学使用上吗?如果不能的话,相信这篇教程可能对你和你的伙伴有更多启发!1\.SpriteIndex组件
一次单据图片处理的优化实践 | 京东物流技术团队
1引言日常开发中接到这样的需求,上游系统请求获取一张A4单据用于仓库打印及展示,要求PNG图片格式,但是我们内部得到的单据格式为PDF,需要提取PDF文档的元素并生成一张PNG图片。目前已经有不少开源工具实现了这一功能,我们找了网上使用比较多的Apache