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

    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,这有可能就是出问题的关键点。

image-20200421110826921.png

查看源码 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 都会改变。如果同时满足上面条件,那么就可以知道出问题的源头了。

image-20200421143328799.png

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

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

image.png

注意堆栈中从 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 的调用栈。

你可能感兴趣的:(ColorDrawable 引起的全局背景 alpha 变化问题)