Styling Colors & Drawables w/ Theme Attributes

原文:Styling Colors & Drawables w/ Theme Attributes 

当你写入类似下面代码的时候:

context.getResources().getColor(R.color.some_color_resource_id);

你很可能注意到Android Studio会给你 “Resources#getColor(int) 方法在Marshmallow已经过时,请使用新的Resources#getColor(int, Theme)方法”的警告。也许你已经知道避免这个警告的简单方法是调用:

ContextCompat.getColor(context, R.color.some_color_resource_id);

这个方法在底层其实本质上只是下面代码的简便写法:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  return context.getResources().getColor(id, context.getTheme());
} else {
  return context.getResources().getColor(id);
}

如此简单哪。但是这里到底发生了什么呢?为什么以前的方法过时了而新的加了Theme的方法又多了些什么东西呢?

Resources#getColor(int)以及 Resources#getColorStateList(int)存在的问题

首先弄清楚老的、过时了的方法到底做了什么:

“什么时候这两个方法会带来麻烦呢?”

要理解为什么原先的方法过时了,考虑以下一个定义在XML中的ColorStateList。当把这个ColorStateList应用到一个TextView上的时候,disabled 与 enabled文字颜色应该分别取自主题属性R.attr.colorAccent 和 R.attr.colorPrimary:

<!-- res/colors/button_text_csl.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="?attr/colorAccent" android:state_enabled="false"/>
    <item android:color="?attr/colorPrimary"/>
</selector>

现在,假设你想在代码中获得这个ColorStateList的实例:

ColorStateList csl = context.getResources().getColorStateList(R.color.button_text_csl);

Perhaps surprisingly, the result of the above call is undefined! You’ll see a stack trace in your logcat output similar to the one below:

可能会觉得意外,上面的调用的不对的!你会在logcat中看到类似下面的 stack trace:

W/Resources: ColorStateList color/button_text_csl has unresolved theme attributes!
             Consider using Resources.getColorStateList(int, Theme)
             or Context.getColorStateList(int)
        at android.content.res.Resources.getColorStateList(Resources.java:1011)
        ...

“出了什么问题?”

问题在于Resources对象本身并不会和app的特定主题关联,所以它们不能自己获取主题属性比如R.attr.colorAccent和R.attr.colorPrimary指向的值。实际上在API 23之前在ColorStateList XML文件中指定主题属性都是不支持的,API 23引入了两个新的方法来从XML中获取ColorStateList:

这两个额外的方法也被添加到了Context类以及兼容包的 ResourcesCompatContextCompat类中。

“如何解决这些问题呢?”

更新:至于AppCompat support library v24.0,现在你可以使用新的AppCompatResources 类来解决所有这些问题!从XML获取一个有主题属性的ColorStateList,只需:

ColorStateList csl = AppCompatResources.getColorStateList(context, R.color.button_text_csl);

在API 23+上,AppCompat将把调用委托给相应的framework方法,而在更早的平台它将自己主动解析XML,遇到主题属性就去获取。而且它还能兼容ColorStateList的新属性android:alpha(之前只在运行在API 23及以上的设备)。

支持 minSdkVersion 小于API 23的app最好按照Android lint 建议的那样使用support library中的静态工具方法 ContextCompat和,但是不管使用何种方法,在pre-Marshmallow 设备上尝试在XMl文件中的ColorStateList里面获取主题属性都是行的。

那要是你确实想在ColorStateList中使用主题属性咋办呢?XML中不能不代表Java代码中也不能。可以尝试动态的获取主题属性,然后使用它们来构造ColorStateList。查看下面的样例代码来获取一些例子。

The problem with Resources#getDrawable(int)

是的你猜对了!最近过时的 Resources#getDrawable(int) 方法有着和 Resources#getColor(int) 以及 Resources#getColorStateList(int)方法相类似的问题。结果API 21之前drawable XML文件中的主题属性不能正常获取,所以如果你的app支持Lollipop以下的设备,要么完全避免使用主题属性,要么从Java代码中获取然后构造Drawable。

“我才不信!难道就没有例外吗?”

Of course there is an exception, isn’t there always? :)当然有,例外总是有的,不是吗?

其实类似于 AppCompatResources 类, VectorDrawableCompatAnimatedVectorDrawableCompat 类也能处理这些问题,而且它们聪明到可以在所有平台上检测xml中的主题属性。比如,如果你想把VectorDrawableCompat 弄成标准的灰色,你可以使用attr/colorControlNormal 为drawable着色,并且仍能保持兼容旧版本:

<vector 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="24.0"
    android:viewportHeight="24.0"
    android:tint="?attr/colorControlNormal">
    <path
        android:pathData="..."
        android:fillColor="@android:color/white"/>
</vector>

(如果你对它是如何实现的感到好奇,答案就是support library自己做了自定义的XML解析并使用 [Theme#obtainStyledAttributes(AttributeSet, int[], int, int)](http://developer.android.com/reference/android/content/res/Resources.Theme.html#obtainStyledAttributes(android.util.AttributeSet, int[], int, int)) 方法去获取遇到的主题属性。酷!)

Pop quiz!

让我们用一个简单的例子来测试我们的知识。考虑如下ColorStateList:

<!-- res/colors/button_text_csl.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="?attr/colorAccent" android:state_enabled="false"/>
    <item android:color="?attr/colorPrimary"/>
</selector>

假设你在写一个定义了如下主题的app:

<!-- res/values/themes.xml -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <item name="colorPrimary">@color/vanillared500</item>
    <item name="colorPrimaryDark">@color/vanillared700</item>
    <item name="colorAccent">@color/googgreen500</item>
</style>
<style name="CustomButtonTheme" parent="ThemeOverlay.AppCompat.Light">
    <item name="colorPrimary">@color/brown500</item>
    <item name="colorAccent">@color/yellow900</item>
</style>

最后,假设你有如下的帮助方法去获取主题属性并动态构造constructColorStateLists:

@ColorInt
private static int getThemeAttrColor(Context context, @AttrRes int colorAttr) {
  TypedArray array = context.obtainStyledAttributes(null, new int\[\]{colorAttr});
  try {
    return array.getColor(0, 0);
  } finally {
    array.recycle();
  }
}
private static ColorStateList createColorStateList(Context context) {
  return new ColorStateList(
      new int\[\]\[\]{
          new int\[\]{-android.R.attr.state_enabled}, // Disabled state.
          StateSet.WILD_CARD,                       // Enabled state.
      },
      new int\[\]{
          getThemeAttrColor(context, R.attr.colorAccent),  // Disabled state.
          getThemeAttrColor(context, R.attr.colorPrimary), // Enabled state.
      });
}

尝试预测在下面各种情况下按钮在 API 19 和 API 23 设备上的enabled 和 disabled外观(对于#5 和 #8,假设按钮被赋予了一个自定义主题android:theme="@style/CustomButtonTheme"):

Resources res = ctx.getResources();
// (1)
int deprecatedTextColor = res.getColor(R.color.button_text_csl);
button1.setTextColor(deprecatedTextColor);
// (2)
ColorStateList deprecatedTextCsl = res.getColorStateList(R.color.button_text_csl);
button2.setTextColor(deprecatedTextCsl);
// (3)
int textColorXml = 
    AppCompatResources.getColorStateList(ctx, R.color.button_text_csl).getDefaultColor();
button3.setTextColor(textColorXml);
// (4)
ColorStateList textCslXml = AppCompatResources.getColorStateList(ctx, R.color.button_text_csl);
button4.setTextColor(textCslXml);
// (5)
Context themedCtx = button5.getContext();
ColorStateList textCslXmlWithCustomTheme =
    AppCompatResources.getColorStateList(themedCtx, R.color.button_text_csl);
button5.setTextColor(textCslXmlWithCustomTheme);
// (6)
int textColorJava = getThemeAttrColor(ctx, R.attr.colorPrimary);
button6.setTextColor(textColorJava);
// (7)
ColorStateList textCslJava = createColorStateList(ctx);
button7.setTextColor(textCslJava);
// (8)
Context themedCtx = button8.getContext();
ColorStateList textCslJavaWithCustomTheme = createColorStateList(themedCtx);
button8.setTextColor(textCslJavaWithCustomTheme);

Solutions

这里是按钮在 API 19 vs. API 23 设备上的截图。

Example code solutions, API 19

Example code solutions, API 23

注意这两张截图里怪异的粉红色并没有特别的东西在里面。只是在尝试获取一个主题属性却没有相应主题的时候出现的未知行为。

source code for these examples on GitHub