在最近项目的版本迭代中测试同学发现了一个偶现的 bug,比较容易的复现路径是在该页面快速来回滑动列表,bug 表现为标题栏背景变透明,进一步排查发现 App 中使用到该色值做背景的地方全部都被改了,即以色值 #ffffff
做背景的透明度都被改了。
初步分析
查看代码发现问题页面使用到该色值的地方主要有两个:
- xml 设置控件背景
android:background="@color/white_an"
- 设置加载图片的占位图
ImageLoadParams.defaultholder = R.color.white_a
这两个色值都是定义在 values 中 #ffffff
,都是很常规的操作。
查看系统方法 android.graphics.drawable.ColorDrawable#setAlpha
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 的源码描述:
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
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
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,重点看一下这个方法
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 类型
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、尝试从缓存中取图片
if (!mPreloading && useCache) {
final Drawable cachedDrawable = caches.getInstance(key, wrapper, theme);
if (cachedDrawable != null) {
cachedDrawable.setChangingConfigurations(value.changingConfigurations);
return cachedDrawable;
}
}
预加载是在 zygote 进程启动的时候被执行,此时预加载已经完成,所以 mPreloading 必定是 false。
final boolean useCache = density == 0 || value.density == mMetrics.densityDpi;
在取资源的第一步,会传入 density=0,所以此时 useCache 为 true。
接着看下取缓存的方法 caches.getInstance(key, wrapper, theme)
android.content.res.DrawableCache#getInstance
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
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
public void setAlpha(int alpha) {
...
if (mColorState.mUseColor != useColor) {
mColorState.mUseColor = useColor;
invalidateSelf();
}
}
计算得到的颜色值不一致时会重绘自身。调用的是父类方法 android.graphics.drawable.Drawable#invalidateSelf
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
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
/**
* @hide
*/
public void clearMutated() {
super.clearMutated();
mMutated = false;
}
但是该方法实际已经使用注解 @hide,是无法调用的,所以一旦调用 mutate 就不可撤销。
使用构造方法生成新的 ColorDrawable
在构造方法中是直接 new ColorState,所以不会和其他 ColorDrawable 共享状态,因此不会相互影响。
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,这有可能就是出问题的关键点。
查看源码 com.facebook.drawee.drawable.ForwardingDrawable#setAlpha
public void setAlpha(int alpha) {
mDrawableProperties.setAlpha(alpha);
if (mCurrentDelegate != null) {
mCurrentDelegate.setAlpha(alpha);
}
}
mDrawableProperties 是 DrawableProperties 的实例,用来统一设置 drawable 属性。
继续看 com.facebook.drawee.drawable.DrawableProperties
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 都会改变。如果同时满足上面条件,那么就可以知道出问题的源头了。
144002320
就是全局标题栏背景 drawable#ConstantState 的 hashCode。
快速反复来回滑动列表,确实会进到设置好的条件断点,验证过程中可以偶现该异常,发现页面中标题栏背景变了,查看 App 其他页面也有同样的问题。堆栈如下:
注意堆栈中从 fillView 到 applyTo 的调用过程,其中有一行 setImageHolder,调用的是项目底层封装的方法
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)
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,代码如下:
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 状态共享的问题。
# 设置占位图
ImageLoadParams.defaultholder = R.drawable.bg_white
# bg_white
android.graphics.drawable.GradientDrawable#setAlpha
public void setAlpha(int alpha) {
if (alpha != mAlpha) {
mAlpha = alpha;
invalidateSelf();
}
}
一些其他思考
fresco 设置占位图的方式,参考链接 https://frescolib.org/docs/placeholder-failure-retry.html
xml
code
mSimpleDraweeView.getHierarchy().setPlaceholderImage(placeholderImage);
如果是从 xml 设置占位图,在一开始初始化的时候,fresco 就已经帮我们 mutate 了一份独立的 drawable,所以肯定不会有问题。
GenericDraweeHierarchy(GenericDraweeHierarchyBuilder builder) {
...省略
// top-level drawable
mTopLevelDrawable = new RootDrawable(maybeRoundedDrawable);
// 这里会遍历图层 mutate
mTopLevelDrawable.mutate();
resetFade();
}
而使用 code 的方式,在初始化时并没有看到有调用 mutate 方法,那么传入 ColorDrawable,就很有可能会出现上面类似场景的异常。
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 插桩的方式,代码如下:
@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 的调用栈。