问题描述

在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();
    }
}