ColorDrawable 引起的全局背景 alpha 变化问题

在最近项目的版本迭代中测试同学发现了一个偶现的 bug,比较容易的复现路径是在该页面快速来回滑动列表,bug 表现为标题栏背景变透明,进一步排查发现 App 中使用到该色值做背景的地方全部都被改了,即以色值 #ffffff 做背景的透明度都被改了。

初步分析

查看代码发现问题页面使用到该色值的地方主要有两个:

  1. xml 设置控件背景 android:background="@color/white_an"
  2. 设置加载图片的占位图 ImageLoadParams.defaultholder = R.color.white_a

这两个色值都是定义在 values 中 #ffffff,都是很常规的操作。

查看系统方法 android.graphics.drawable.ColorDrawable#setAlpha

1
2
3
4
5
6
7
8
9
10
public void setAlpha(int alpha) {
alpha += alpha >> 7; // make it 0..256
final int baseAlpha = mColorState.mBaseColor >>> 24;
final int useAlpha = baseAlpha * alpha >> 8;
final int useColor = (mColorState.mBaseColor << 8 >>> 8) | (useAlpha << 24);
if (mColorState.mUseColor != useColor) {
mColorState.mUseColor = useColor;
invalidateSelf();
}
}

代码很简单,计算 alpha,颜色不一致时把最终计算得到的 useColor 赋值给 mColorState,并重绘自身。这个 mColorState 是什么呢?查看源码发现它是 ColorState 的实例,而 ColorState 又是继承自抽象类 ConstantState。

ConstatntState 的源码描述:

1
2
This abstract class is used by {@link Drawable}s to store shared constant state and data between Drawables. 
{@link BitmapDrawable}s created from the same resource will for instance share a unique bitmap stored in their ConstantState.

也就是说,每个 Drawable 都共享一个唯一的 ConstantState 对象,这是为了共享 Drawable 的状态和数据,从同一个 res 中创建的 Drawable,它们会共享同一个 ConstantState 对象。

具体分析

从 xml 加载 backgroud 的过程

在 View 中解析 attr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);
...
for (int i = 0; i < N; i++) {
int attr = a.getIndex(i);
switch (attr) {
case com.android.internal.R.styleable.View_background:
background = a.getDrawable(attr);
break;
...
}
}

...
}

继续跟到 android.content.res.TypedArray

1
2
3
4
5
6
7
8
9
10
11
12
public Drawable getDrawable(@StyleableRes int index) {
return getDrawableForDensity(index, 0);
}

public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
...
if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
...
return mResources.loadDrawable(value, value.resourceId, density, mTheme);
}
return null;
}

最终会走到 android.content.res.ResourcesImpl#loadDrawable,重点看一下这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
Drawable loadDrawable(@NonNull Resources wrapper, @NonNull TypedValue value, int id,
int density, @Nullable Resources.Theme theme)
throws NotFoundException {
// If the drawable's XML lives in our current density qualifier,
// it's okay to use a scaled version from the cache. Otherwise, we
// need to actually load the drawable from XML.
final boolean useCache = density == 0 || value.density == mMetrics.densityDpi;

// Pretend the requested density is actually the display density. If
// the drawable returned is not the requested density, then force it
// to be scaled later by dividing its density by the ratio of
// requested density to actual device density. Drawables that have
// undefined density or no density don't need to be handled here.
if (density > 0 && value.density > 0 && value.density != TypedValue.DENSITY_NONE) {
if (value.density == density) {
value.density = mMetrics.densityDpi;
} else {
value.density = (value.density * mMetrics.densityDpi) / density;
}
}

try {
if (TRACE_FOR_PRELOAD) {
// Log only framework resources
if ((id >>> 24) == 0x1) {
final String name = getResourceName(id);
if (name != null) {
Log.d("PreloadDrawable", name);
}
}
}

final boolean isColorDrawable;
final DrawableCache caches;
final long key;
if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT
&& value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
isColorDrawable = true;
caches = mColorDrawableCache;
key = value.data;
} else {
isColorDrawable = false;
caches = mDrawableCache;
key = (((long) value.assetCookie) << 32) | value.data;
}

// First, check whether we have a cached version of this drawable
// that was inflated against the specified theme. Skip the cache if
// we're currently preloading or we're not using the cache.
if (!mPreloading && useCache) {
final Drawable cachedDrawable = caches.getInstance(key, wrapper, theme);
if (cachedDrawable != null) {
cachedDrawable.setChangingConfigurations(value.changingConfigurations);
return cachedDrawable;
}
}

// Next, check preloaded drawables. Preloaded drawables may contain
// unresolved theme attributes.
final Drawable.ConstantState cs;
if (isColorDrawable) {
cs = sPreloadedColorDrawables.get(key);
} else {
cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key);
}

Drawable dr;
boolean needsNewDrawableAfterCache = false;
if (cs != null) {
if (TRACE_FOR_DETAILED_PRELOAD) {
// Log only framework resources
if (((id >>> 24) == 0x1) && (android.os.Process.myUid() != 0)) {
final String name = getResourceName(id);
if (name != null) {
Log.d(TAG_PRELOAD, "Hit preloaded FW drawable #"
+ Integer.toHexString(id) + " " + name);
}
}
}
dr = cs.newDrawable(wrapper);
} else if (isColorDrawable) {
dr = new ColorDrawable(value.data);
} else {
dr = loadDrawableForCookie(wrapper, value, id, density);
}
// DrawableContainer' constant state has drawables instances. In order to leave the
// constant state intact in the cache, we need to create a new DrawableContainer after
// added to cache.
if (dr instanceof DrawableContainer) {
needsNewDrawableAfterCache = true;
}

// Determine if the drawable has unresolved theme attributes. If it
// does, we'll need to apply a theme and store it in a theme-specific
// cache.
final boolean canApplyTheme = dr != null && dr.canApplyTheme();
if (canApplyTheme && theme != null) {
dr = dr.mutate();
dr.applyTheme(theme);
dr.clearMutated();
}

// If we were able to obtain a drawable, store it in the appropriate
// cache: preload, not themed, null theme, or theme-specific. Don't
// pollute the cache with drawables loaded from a foreign density.
if (dr != null) {
dr.setChangingConfigurations(value.changingConfigurations);
if (useCache) {
cacheDrawable(value, isColorDrawable, caches, theme, canApplyTheme, key, dr);
if (needsNewDrawableAfterCache) {
Drawable.ConstantState state = dr.getConstantState();
if (state != null) {
dr = state.newDrawable(wrapper);
}
}
}
}

return dr;
} catch (Exception e) {
String name;
try {
name = getResourceName(id);
} catch (NotFoundException e2) {
name = "(missing name)";
}

// The target drawable might fail to load for any number of
// reasons, but we always want to include the resource name.
// Since the client already expects this method to throw a
// NotFoundException, just throw one of those.
final NotFoundException nfe = new NotFoundException("Drawable " + name
+ " with resource ID #0x" + Integer.toHexString(id), e);
nfe.setStackTrace(new StackTraceElement[0]);
throw nfe;
}
}

主要过程可以分为

1、判断 Drawable 类型

1
2
3
4
5
6
7
8
9
if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
isColorDrawable = true;
caches = mColorDrawableCache;
key = value.data;
} else {
isColorDrawable = false;
caches = mDrawableCache;
key = (((long) value.assetCookie) << 32) | value.data;
}

TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT 如果资源是以#开头并且是色值,则是 ColorDrawable ,所以本例中 isColorDrawable 为 true。如果是 ColorDrawable,缓存 key 实际就是代表色值的 #ffffff 这一串内容。

2、尝试从缓存中取图片

1
2
3
4
5
6
7
if (!mPreloading && useCache) {
final Drawable cachedDrawable = caches.getInstance(key, wrapper, theme);
if (cachedDrawable != null) {
cachedDrawable.setChangingConfigurations(value.changingConfigurations);
return cachedDrawable;
}
}

预加载是在 zygote 进程启动的时候被执行,此时预加载已经完成,所以 mPreloading 必定是 false。

1
final boolean useCache = density == 0 || value.density == mMetrics.densityDpi;

在取资源的第一步,会传入 density=0,所以此时 useCache 为 true。

接着看下取缓存的方法 caches.getInstance(key, wrapper, theme)
android.content.res.DrawableCache#getInstance

1
2
3
4
5
6
7
8
public Drawable getInstance(long key, Resources resources, Resources.Theme theme) {
final Drawable.ConstantState entry = get(key, theme);
if (entry != null) {
return entry.newDrawable(resources, theme);
}

return null;
}

可以看到系统是从缓存中找到 Drawable.ConstantState,调用 Drawable.ConstantState#newDrawable() 返回一个新的 Drawable。所以在本例中,使用 ColorState#newDrawable() 创建新的 ColorDrawable,在没有特殊情况下,此时 ColorDrawable 的状态数据是全局独一份的,也就是 ColorState 是唯一的。

3、如果缓存中没有,则创建一个新的资源,然后缓存下来

首先检查预加载的资源文件中,是否存在要查找的 Drawable

1
2
3
4
5
6
final Drawable.ConstantState cs;
if (isColorDrawable) {
cs = sPreloadedColorDrawables.get(key);
} else {
cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key);
}

可以看到此时找到的也是 Drawable.ConstantState,接着根据不同情况调用不同的方法生成新的 Drawable。

从 xml 加载 backgroud 的过程到此结束。使用 android.view.View#setBackgroundResource() 代码中设置background 的过程也是类似的。

如何做到牵一发而动全身?

回到前面讲的 android.graphics.drawable.ColorDrawable#setAlpha

1
2
3
4
5
6
7
public void setAlpha(int alpha) {
...
if (mColorState.mUseColor != useColor) {
mColorState.mUseColor = useColor;
invalidateSelf();
}
}

计算得到的颜色值不一致时会重绘自身。调用的是父类方法 android.graphics.drawable.Drawable#invalidateSelf

1
2
3
4
5
6
public void invalidateSelf() {
final Callback callback = getCallback();
if (callback != null) {
callback.invalidateDrawable(this);
}
}

可以看到最终是通过 android.graphics.drawable.Drawable.Callback 的实现类调用 invalidateDrawable 来重绘。一般 background 都是和 View 关联的,而 View 又实现了该接口,所以最终是通知关联的 View 重新绘制自身,做到牵一发而动全身。

小结

类似的,android.graphics.drawable.ColorDrawable#setColor 也会影响色值,所以对于 ColorDrawable,它会关联同一个 ColorState 对象,color 的颜色值是保存在 ColorState 对象中。如果修改 ColorDrawable 的颜色值,会修改到 ColorState 的值,进而会导致和 ColorState 关联的所有的 ColorDrawable 的颜色都改变。

解决方法

那么怎么解决 ColorState 共享的问题呢?

使用 android.graphics.drawable.ColorDrawable#mutate

1
2
3
4
5
6
7
8
9
10
11
public Drawable mutate() {
// 如果没有改变过,并且是同一个Drawable
if (!mMutated && super.mutate() == this) {
// 直接 new ColorState,所以不会和其他 ColorDrawable 共享状态,因此不会相互影响
mColorState = new ColorState(mColorState);
// 标记为已改变
mMutated = true;
}
// 返回已经改变后的 ColorDrawable
return this;
}

那如果还想共享 ColorDrawable 状态,怎么办?

系统也提供了方法 android.graphics.drawable.ColorDrawable#clearMutated

1
2
3
4
5
6
7
/**
* @hide
*/
public void clearMutated() {
super.clearMutated();
mMutated = false;
}

但是该方法实际已经使用注解 @hide,是无法调用的,所以一旦调用 mutate 就不可撤销。

使用构造方法生成新的 ColorDrawable

在构造方法中是直接 new ColorState,所以不会和其他 ColorDrawable 共享状态,因此不会相互影响。

1
2
3
4
5
6
7
8
9
public ColorDrawable() {
mColorState = new ColorState();
}
或者
public ColorDrawable(@ColorInt int color) {
mColorState = new ColorState();

setColor(color);
}

复现问题

通过前面的分析,可以知道肯定有地方调用了 android.graphics.drawable.ColorDrawable#setAlpha,所以问题又变成了找出 android.graphics.drawable.Drawable#setAlpha 的调用栈。

经过小伙伴的提醒可以使用 Android studio 自带工具 CPU profiler dump trace。

这里采用 Sample Java Methods 在列表滚动的时候记录一段时间的开始和结束,因为项目中使用 Fresco 图片加载框架,所以调用栈可以看到很多 fresco 相关类。

从调用栈中可以看到,都是调用系统及第三方的方法,并没有业务主动调用的地方。倒数第四行,在调用 com.facebook.drawee.drawable.ForwardingDrawable#setAlpha 后马上又调用系统 android.graphics.drawable.ColorDrawable#setAlpha,这有可能就是出问题的关键点。

image-20200421110826921.png

查看源码 com.facebook.drawee.drawable.ForwardingDrawable#setAlpha

1
2
3
4
5
6
public void setAlpha(int alpha) {
mDrawableProperties.setAlpha(alpha);
if (mCurrentDelegate != null) {
mCurrentDelegate.setAlpha(alpha);
}
}

mDrawableProperties 是 DrawableProperties 的实例,用来统一设置 drawable 属性。

继续看 com.facebook.drawee.drawable.DrawableProperties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static final int UNSET = -1;
// mAlpha 默认 -1
private int mAlpha = UNSET;

// 设置 alpha,这个 alpha 会一直存在
public void setAlpha(int alpha) {
mAlpha = alpha;
}

// 设置 drawable 属性,包括 alpha
public void applyTo(Drawable drawable) {
if (drawable == null) {
return;
}
if (mAlpha != UNSET) {
drawable.setAlpha(mAlpha);
}
...
}

可以看到 mAlpha 默认为 -1,只有不等于 -1 才设置到 Drawable。

假如出异常最终会调到这里,为了进一步验证,在这里打个条件断点,条件是:

1、drawable 类型是 ColorDrawable

2、drawable#ConstantState 的 hashCode 等于全局标题栏背景 drawable#ConstantState 的 hashCode

3、 alpha 小于 255

前面分析过,从同一个资源 res 创建的 Drawable#ConstantState 是唯一的,如果改动其中一个 drawable 的 alpha,其它 ConstantState 关联的所有的 Drawable 都会改变。如果同时满足上面条件,那么就可以知道出问题的源头了。

image-20200421143328799.png

144002320 就是全局标题栏背景 drawable#ConstantState 的 hashCode。

快速反复来回滑动列表,确实会进到设置好的条件断点,验证过程中可以偶现该异常,发现页面中标题栏背景变了,查看 App 其他页面也有同样的问题。堆栈如下:

image.png

注意堆栈中从 fillView 到 applyTo 的调用过程,其中有一行 setImageHolder,调用的是项目底层封装的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
private void setImageHolder(IFrescoImageView draweeView, FrescoPainterPen pen) {
int defaultResID = pen.getDefaultHolder();
ScaleType defaultScaleType = pen.getDefaultHolderScaleType();
...
if (defaultResID > 0) {
if (defaultScaleType == null) {
this.getHierarchy(draweeView).setPlaceholderImage(defaultResID);
} else {
this.getHierarchy(draweeView).setPlaceholderImage(defaultResID, defaultScaleType);
}
}
...
}

这两个分支的区别是有无设置占位图的缩放类型,最终处理都是一样的,我们看其中一个 com.facebook.drawee.generic.GenericDraweeHierarchy#setPlaceholderImage(int)

1
2
3
4
5
6
7
8
9
10
11
12
public void setPlaceholderImage(int resourceId) {
Drawable drawable = null;
// 第一步
if(mFrescoPainterDraweeInterceptor != null){
drawable = mFrescoPainterDraweeInterceptor.onSetPlaceholderImage(resourceId);
}
// 第二步
if(drawable == null){
drawable = mResources.getDrawable(resourceId);
}
setPlaceholderImage(drawable);
}

mFrescoPainterDraweeInterceptor 是项目中设置的拦截器,主要作用是从皮肤包加载图片,App 默认没开换肤,所以第一步 drawable 为 null。假设开启换肤,底层最终是使用 new ColorDrawable(newId) 的方式,所以不会出现这个异常状况。

第二步出现了熟悉的代码 mResources.getDrawable(resourceId),前面有提过使用色值 #ffffff 的地方,占位图是其中一个,这个 resourceId 就是我们外部设置的占位图,所以这里肯定是先从系统缓存里取图,所以使用的 ConstantState 肯定关联了其他的 ColorDrawable。这就有可能出现异常状况。

问题原因

fresco 视图是一个多层级的结构,列表滑动时,移出屏幕的视图释放资源,移入屏幕的视图加载资源,视图层有个变换的过程,简单表示就是:

ActaulImage —> PlaceHolderImage —> ActualImage

中间的变换是通过 GenericDraweeHierarchy 控制 FadeDrawable 做淡入淡出渐变。在列表快速来回滑动时,图层会多次变换,设置到 DrawableProperties 的 alpha 可能是 0~1.0 的随机值,在某些极端条件下,如果此时刚好又触发了 PainterWorksapce#setImageHolder 那就会把之前保存的 alpha 设置到占位图上,进而会导致这个异常问题。

所以如果 App 全局有背景刚好和占位图的背景是相同色值,那么也有可能会出现这个异常。

最终解决方案

通过前面知道最佳解决办法是在使用 Drawable 之前调用 android.graphics.drawable.ColorDrawable#mutate。

还有一些其它的方法

1、临时解决办法是,在该页面的几个关键点,如 onPause、onResume、侧滑返回等,检测使用到该色值的控件,如果 alpha 小于 255,那么再手动设置回 255,代码如下:

1
2
3
4
5
6
private void fixWhiteAnAlpha() {
if (titleBarCommon != null && titleBarCommon.getBackground() != null && titleBarCommon.getBackground().getAlpha() < 255) {
LogUtils.d("===>VideoThemeDetail", "出错了===drawable alpha: " + titleBarCommon.getBackground().getAlpha());
titleBarCommon.getBackground().setAlpha(255);
}
}

2、采用自定义 xml drawable 的方式,这种方式最终会解析成 GradientDrawable,而该类的 setAlpha 方法并不会改动到 ConstantState 的状态,所以可以避免 Drawable 状态共享的问题。

1
2
3
4
5
6
7
8
# 设置占位图
ImageLoadParams.defaultholder = R.drawable.bg_white

# bg_white
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/white_an"/>
</shape>

android.graphics.drawable.GradientDrawable#setAlpha

1
2
3
4
5
6
public void setAlpha(int alpha) {
if (alpha != mAlpha) {
mAlpha = alpha;
invalidateSelf();
}
}

一些其他思考

fresco 设置占位图的方式,参考链接 https://frescolib.org/docs/placeholder-failure-retry.html

xml

1
2
3
4
5
6
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/my_image_view"
android:layout_width="20dp"
android:layout_height="20dp"
fresco:placeholderImage="@drawable/my_placeholder_drawable"
/>

code

1
mSimpleDraweeView.getHierarchy().setPlaceholderImage(placeholderImage);

如果是从 xml 设置占位图,在一开始初始化的时候,fresco 就已经帮我们 mutate 了一份独立的 drawable,所以肯定不会有问题。

1
2
3
4
5
6
7
8
GenericDraweeHierarchy(GenericDraweeHierarchyBuilder builder) {
...省略
// top-level drawable
mTopLevelDrawable = new RootDrawable(maybeRoundedDrawable);
// 这里会遍历图层 mutate
mTopLevelDrawable.mutate();
resetFade();
}

而使用 code 的方式,在初始化时并没有看到有调用 mutate 方法,那么传入 ColorDrawable,就很有可能会出现上面类似场景的异常。

1
2
3
4
5
6
7
8
9
10
11
12
public void setPlaceholderImage(@Nullable Drawable drawable) {
setChildDrawableAtIndex(PLACEHOLDER_IMAGE_INDEX, drawable);
}

private void setChildDrawableAtIndex(int index, @Nullable Drawable drawable) {
if (drawable == null) {
mFadeDrawable.setDrawable(index, null);
return;
}
drawable = WrappingUtils.maybeApplyLeafRounding(drawable, mRoundingParams, mResources);
getParentDrawableAtIndex(index).setDrawable(drawable);
}

其他的排查方法

后来发现也可以使用 AspectJ 插桩的方式,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Around("call(* android.graphics.drawable.Drawable.setAlpha(..))")
public void hookSetAlpha(ProceedingJoinPoint joinPoint) throws Throwable {
joinPoint.proceed();
LogUtils.i("===>hookSetAlpha", "cur:"+joinPoint.getSignature().getDeclaringType().getSimpleName()+"#:"+joinPoint.getSignature().getName());
StackTraceElement[] stackTraceElements = (new Throwable()).getStackTrace();
for (int i = 0; i < stackTraceElements.length; i++) {
StackTraceElement stackTraceElement = stackTraceElements[i];
LogUtils.i("===>hookSetAlpha", "===" + stackTraceElement.getClassName()
+ ", " + stackTraceElement.getMethodName()
+ ", " + stackTraceElement.getLineNumber());
}
}

也可以得到 android.graphics.drawable.Drawable.setAlpha 的调用栈。