Android | 说说从 android:text 到 TextView 的过程(主题&样式)

5,097 阅读10分钟

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)

前言

  • 在 Android UI 开发中,经常需要用到 属性,例如使用android:text设置文本框的文案,使用android:src设置图片。那么,android:text是如何设置到 TextView 上的呢?
  • 其实这个问题主要还是考察应试者对于源码(包括:LayoutInflater 布局解析、Style/Theme 系统 等)的熟悉度,在这篇文章里,我将跟你一起探讨。另外,文末的应试建议也不要错过哦,如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

相关文章


目录


1. 属性概述

1.1 属性的本质

属性 (View Attributes) 本质上是一个键值对关系,即:属性名 => 属性值。

1.2 如何定义属性?

定义属性需要用到<declare-styleable>标签,需要定义 属性名属性值类型,格式上可以分为以下 2 种:

格式 1 :

1.1 先定义属性名和属性值类型
<attr name="textColor" format="reference|color"/>

<declare-styleable name="TextView">
    1.2 引用上面定义的属性
    <attr name="textColor" />
</declare-styleable>

格式 2:

<declare-styleable name="TextView">
    一步到位
    <attr name="text" format="string" localization="suggested" />
</declare-styleable>
  • 格式 1:分为两步,先定义属性名和属性值类型,然后在引用;
  • 格式 2:一步到位,直接指定属性名和属性值类型。

1.3 属性的命名空间

使用属性时,需要指定属性的命名空间,命名空间用于区分属性定义的位置。目前一共有 4 种 命名空间:

  • 1、工具 —— toolsxmlns:tools="http://schemas.android.com/tools"

只在 Android Studio 中生效,运行时不生效。比如以下代码,背景色在编辑器的预览窗口显示白色,但是在运行时显示黑色:

tools:background="@android:color/white"
android:background="@android:color/black"
  • 2、原生 —— androidxmlns:android="http://schemas.android.com/apk/res/android"

原生框架中attrs定义的属性,例如,我们找到 Android P 定义的属性 attrs.xml,其中可以看到一些我们熟知的属性:

<!-- 文本颜色 -->
<attr name="textColor" format="reference|color"/>
<!-- 高亮文本颜色 -->
<attr name="textColorHighlight" format="reference|color" />
<!-- 高亮文本颜色 -->
<attr name="textColorHint" format="reference|color" />

你也可以在 SDK 中找到这个文件,有两种方法:

  • 文件夹:sdk/platform/android-28/data/res/values/attrs.xml

  • Android Studio(切换到 project 视图):External Libraries/<Android API 28 Platform>/res/values/attrs.xml

(你在这里看到的版本号是在app/build.gradle中的compileSdkVersion设置的)

  • 3、AppCompat 兼容库 —— 无需命名空间

Support 库 或 AndroidX 库中定义的属性,比如:

<attr format="color" name="colorAccent"/>

你也可以在 Android Studio 中找到这个文件:

  • Android Studio(切换到 project 视图):External Libraries/Gradle:com.android.support:appcompat-v7:[版本号]@aar/res/values/values.xml
  • 4、自定义 —— appxmlns:app="http://schemas.android.com/apk/res-auto"

用排除法,剩下的属性就是自定义属性了。包括 项目中自定义 的属性与 依赖库中自定义 的属性,比如ConstraintLayout中自定义的属性:

<attr format="reference|enum" name="layout_constraintBottom_toBottomOf">
		<enum name="parent" value="0"/>
</attr>

你也可以在 Android Studio 中找到这个文件:

  • Android Studio(切换到 project 视图):External Libraries/Gradle:com.android.support:constraint:constraint-layout:[版本号]@aar/res/values/values.xml

2. 样式概述

需要注意的是:虽然样式和主题长得很像,虽然两者截然不同!

2.1 样式的本质

样式(Style)是一组键值对的集合,本质上是一组可复用的 View 属性集合,代表一种类型的 Widget。类似这样:

<style name="BaseTextViewStyle">
    <item name="android:layout_width">wrap_content</item>
    <item name="android:layout_height">wrap_content</item>
    <item name="android:includeFontPadding">false</item>
</style>

2.2 样式的作用

使用样式可以 复用属性值,避免定义重复的属性值,便于项目维护

随着业务功能的叠加,项目中肯定会存在一些通用的,可以复用的样式。例如在很多位置会出现的标签样式:

观察可以发现,这些标签虽然颜色不一样,但是也是有共同之处:圆角、边线宽度、字体大小、内边距。如果不使用样式,那么这些相同的属性都需要在每处标签重复声明。

此时,假设 UI 需要修改全部标签的内边距,那么就需要修改每一处便签的属性值,那就很繁琐了。而使用样式的话,就可以将重复的属性 收拢 到一份样式上,当需要修改样式时,只需要修改一个文件,类似这样:

<style name="smallTagStyle" parent="BaseTextViewStyle">
    <item name="android:paddingTop">3dp</item>
    <item name="android:paddingBottom">3dp</item>
    <item name="android:paddingLeft">4dp</item>
    <item name="android:paddingRight">4dp</item>
    <item name="android:textSize">10sp</item>
    <item name="android:maxLines">1</item>
    <item name="android:ellipsize">end</item>
</style>

2.3 在 xml 中使用样式

使用样式时,需要用到style="",类似这样:

<TextView
    android:text="标签"
    style="@style/smallTagStyle"/>

关于这两句属性是如何生效的,我后文再说。

2.4 样式的注意事项

  • 样式不在多层级传递

样式只有在使用它的 View 上才起作用,而在它的子 View 上样式是无效的。举个例子,假设 ViewGroup 有三个按钮,若设置 MyStyle 样式到此 ViewGroup 上,此时,仅这个 ViewGroup 有效,而对三个按钮来说是无效的。


3. 主题概述

3.1 主题的本质

与样式相同的是,**主题(Theme)**也是一组键值对的集合,但是它们的本质截然不同。样式的本质是一组可复用的 View 属性集合,而主题是 一组可引用的命名资源集合。类似这样:

<style name="AppBaseTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <item name="windowNoTitle">true</item>
    <item name="windowActionBar">false</item>
    <item name="colorAccent">@color/colorAccent</item>
    <item name="dialogTheme">@style/customDialog</item>
</style>

3.2 主题的作用

主题背景定义了一组可以在多处引用的资源集合,这些资源可以在样式、布局文件、代码等位置使用。使用主题,可以方便全局替换属性的值。

举个例子,首先你可以定义一套深色主题和一套浅色主题:

<style name="BlackTheme" parent="AppBaseTheme">
    <item name="colorPrimary">@color/black</item>
</style>

<style name="WhiteTheme" parent="AppBaseTheme">
    <item name="colorPrimary">@color/white</item>
</style>

然后,你在需要主题化的地方引用它,类似这样:

<ViewGroup …
    android:background="?attr/colorPrimary">

此时,如果应用了 BlackTheme ,那么 ViewGroup 的背景就是黑色;反之,如果引用了 WhiteTheme,那么 ViewGroup 的背景就是白色。

在 xml 中使用主题属性,需要用到?表示获得此主题中的语义属性代表的值。我把所有格式都总结在这里:

格式描述
android:background="?attr/colorAccent"/
android:background="?colorAccent"("?attr/colorAccent" 的缩写)
android:background="?android:attr/colorAccent"(属性的命名空间为 android)
android:background="?android:colorAccent"("?android:attr/colorAccent")

3.3 在 xml 中使用主题

在 xml 中使用主题,需要用到android:theme,类似这样:

1. 应用层
<application …
    android:theme="@style/BlackTheme ">

2. Activity 层
<activity …
    android:theme="@style/BlackTheme "/>

3. View 层
<ConstraintLayout …
    android:theme="@style/BlackTheme ">

需要注意的是,android:theme本质上也是用到 ContextThemeWrapper 来使用主题的,这在我之前写过的两篇文章里说过:《Android | View & Fragment & Window 的 getContext() 一定返回 Activity 吗?》《Android | 带你探究 LayoutInflater 布局解析原理》。这里我简单复述一下:

LayoutInflater.java

private static final int[] ATTRS_THEME = new int[] {
    com.android.internal.R.attr.theme
};

final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
final int themeResId = ta.getResourceId(0, 0);
if (themeResId != 0) {
    构造 ContextThemeWrapper
    context = new ContextThemeWrapper(context, themeResId);
}
  • 1、LayoutInflater 在进行布局解析时,需要根据 xml 实例化 View;
  • 2、在解析流程中,会判断 View 是否使用了android:theme
  • 3、如果使用,则使用 ContextThemeWrapper 包装 Context,并将包装类用于子 View 的实例化过程。

3.4 在代码中使用主题

在代码中使用主题,需要用到ContextThemeWrapper & Theme,它们都提供了设置主题资源的方法:

ContextThemeWrapper.java

@Override
public void setTheme(int resid) {
    if (mThemeResource != resid) {
        mThemeResource = resid;
        最终调用的是 Theme#applyStyle(...)
        initializeTheme();
    }
}

Theme.java

public void applyStyle(int resId, boolean force) {
    mThemeImpl.applyStyle(resId, force);
}

当构造新的 ContextThemeWrapper 之后,它会分配新的主题 (Theme) 和资源 (Resources) 实例。那么,最终主题是在哪里生效的呢,我在 第 4 节 说。

3.5 主题的注意事项

  • 主题会在多层级传递

与样式不同的是,主题对于更低层级也是有效的。举个例子,假设 Activity 设置 BlackTheme,那么对于 Activity 上的所有 View 是有效的。此时,如果其中 View 单独指定了 android:theme,那么此 View 将单独使用新的主题。

  • 勿使用 Application Context 加载资源

Application 是 ContextWrapper 的子类,因此Application Context 不保留任何主题相关信息,在 manifest 中设置的主题仅用作未明确设置主题背景的 Activity 的默认选择。切勿使用 Application Context 加载可使用的资源。


4. 问题回归

现在,我们回过头来讨论 从 android:text 到 TextView 的过程。其实,这说的是如何将android:text属性值解析到 TextView 上。这个过程就是 LayoutInflater 布局解析的过程,我之前专门写过一篇文章探讨布局解析的核心过程:《Android | 带你探究 LayoutInflater 布局解析原理》,核心过程如下图:

4.1 AttributeSet

在前面的文章里,我们已经知道 LayoutInflater 通过反射的方式实例化 View。其中的参数args分别是 Context & AttributeSet:

  • Context:上下文,有可能是包装类 ContextThemeWrapper
  • AttributeSet:属性列表,xml 中 View声明的属性都会解析到这个对象上。

LayoutInflater.java

final View view = constructor.newInstance(args);

举个例子,假设有布局文件,我们尝试输出 LayoutInflater 实例化 View 时传入的 AttributeSet:

<...MyTextView
    android:text="标签"
    android:theme="@style/BlackTheme"
    android:textColor="?colorPrimary"
    style="@style/smallTagStyle"/>

MyTextView.java

public MyTextView(Context context, AttributeSet attrs) {
    super(context, attrs);
    总共有 4 个属性
    for (int index = 0; index < attrs.getAttributeCount(); index++) {
        System.out.println(attrs.getAttributeName(index) + " = " + attrs.getAttributeValue(index));
    }
}

AttributeSet.java

返回属性名称字符串(不包括命名空间)
public String getAttributeValue(int index);

返回属性值字符串
public String getAttributeValue(int index);

输出如下:

theme = @2131558563
textColor = ?2130837590
text = 标签
style = @2131558752

可以看到,AttributeSet 里只包含了在 xml 中直接声明的属性,对于引用类型的属性,AttributeSet 只是记录了资源 ID,并不会把它拆解开来。

4.2 TypedArray

想要取到真实的属性值,需要用到 TypeArray,另外还需要一个 int 数组(其中,int 值是属性 ID)。类似这样:

private static final int[] mAttr = {android.R.attr.textColor, android.R.attr.layout_width};

private static final int ATTR_ANDROID_TEXTCOLOR = 0;
private static final int ATTR_ANDROID_LAYOUT_WIDTH = 1;

1. 从 AttributeSet 中加载属性
TypedArray a = context.obtainStyledAttributes(attrs, mAttr);
for (int index = 0; index < a.getIndexCount(); index++) {
    2. 解析每个属性
    switch (index) {
        case ATTR_ANDROID_TEXTCOLOR:
            System.out.println("attributes : " + a.getColor(index, Color.RED));
        break;
        case ATTR_ANDROID_LAYOUT_WIDTH:
            System.out.println("attributes : " + a.getInt(index, 0));
        break;
    }
}

在这里,mAttr 数组是两个 int 值,分别是android.R.attr.textColorandroid.R.attr.layout_width,表示我们感兴趣的属性。当我们将 mAttr 用于Context#obtainStyledAttributes(),则只会解析出我们感兴趣的属性来。

输出:

-16777216 ,即:Color.BLACK => 这个值来自于 ?attr/colorPrimary 引用的主题属性
-2 ,即:WRAP_CONTENT => 这个值来自于 @style/smallTagStyle 中引用的样式属性

需要注意的是,大多数情况下并不需要在代码中硬编码,而是使用<declare-styleable>标签。编译器会自动在R.java中为我们声明相同的数组,类似这样:

<declare-styleable name="MyTextView">
    <attr name="android:textColor" />
    <attr name="android:layout_width" />
</declare-styleable>

R.java

public static final int[] MyTextView={ 相当于 mAttr
    0x01010098, 0x010100f4
};
public static final int MyTextView_android_textColor=0; 相当于 ATTR_ANDROID_TEXTCOLOR 
public static final int MyTextView_android_layout_width=1; 相当于 ATTR_ANDROID_LAYOUT_WIDTH 

提示: 使用R.styleable.设计的优点是:避免解析不需要的属性。

4.3 Context#obtainStyledAttributes() 取值顺序

现在,我们来讨论obtainStyledAttributes()解析属性值的优先级顺序,总共分为以下几个顺序。当在越优先的级别找到属性时,优先返回该处的属性值:View > Style > Default Style > Theme

  • View

指 xml 直接指定的属性,类似这样:

<TextView
    ...
    android:textColor="@color/black"/>
  • Style

指 xml 在样式中指定的属性,类似这样:

<TextView
    ...
    android:textColor="@style/colorTag"/>

<style name="colorTag">
    <item name="android:textColor">@color/black</item>
  • Default Style

指在 View 构造函数中指定的样式,它是构造方法的第 3 个参数,类似于 TextView 这样:

public AppCompatTextView(Context context, AttributeSet attrs) {
    this(context, attrs, android.R.attr.textViewStyle);
}

public AppCompatTextView(Context context, AttributeSet attrs, @AttrRes int defStyleAttr) {
    super(TintContextWrapper.wrap(context), attrs, defStyleAttr);
    ...
}

其中,android.R.attr.textViewStyle表示引用主题中的textViewStyle属性,这个值在主题资源中指定的是一个样式资源:

<item name="android:textViewStyle">@style/Widget.AppCompat.TextView</item>

提示:@AttrRes可以看出,defStyleAttr 一定要引用主题属性。

  • Default Style Resource

指在 View 构造函数中指定的样式资源,它是构造方法的第 3 个参数:

public View(Context context, AttributeSet attrs, @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
}

提示:@StyleRes 可以看出,defStyleRes 一定要引用样式资源。

  • Theme

如果以上层级全部无法匹配到属性,那么就会使用主题中的主题属性,类似这样:

<style name="AppTheme" parent="...">
    ...
    <item name="android:textColor">@color/black</item>
</style>

5. 属性值类型

前文提到,定义属性需要指定:属性名属性值类型,属性值类型可以分为资源类与特殊类

5.1 资源类

属性值类型描述TypedArray
fraction百分数getFraction(...)
float浮点数getFloat(...)
boolean布尔值getBoolean(...)
color颜色值getColor(...)
string字符串getString(...)
dimension尺寸值getDimensionPixelOffset(…)
getDimensionPixelSize(...)
getDimension(...)
integer整数值getInt(...)
getInteger(...)

5.2 特殊类

属性值类型描述TypedArray
flag标志位getInt(...)
enum枚举值getInt(…)等
reference资源引用getDrawable(...)等

fraction 比较难理解,这里举例解释下:

  • 1、属性定义
<declare-styleable name="RotateDrawable">
    // ...
    <attr name="pivotX" format="float|fraction" />
    <attr name="pivotY" format="float|fraction" />
    <attr name="drawable" />
</declare-styleable>
  • 设置属性值
<?xml version="1.0" encoding="utf-8"?>
<animated-rotate xmlns:android="http://schemas.android.com/apk/res/android"
   android:pivotX="50%"
   android:pivotY="50%"
   android:drawable="@drawable/fifth">
</animated-rotate>
if (a.hasValue(R.styleable.RotateDrawable_pivotX)) {
    // 取出对应的TypedValue
    final TypedValue tv = a.peekValue(R.styleable.RotateDrawable_pivotX);
    // 判断属性值是float还是fraction
    state.mPivotXRel = tv.type == TypedValue.TYPE_FRACTION;
    // 取出最终的值
    state.mPivotX = state.mPivotXRel ? tv.getFraction(1.0f, 1.0f) : tv.getFloat();
}

可以看到,pivotX 支持 float 和 fraction 两种类型,因此需要通过TypedValue#type判断属性值的类型,分别调用TypedValue#getFraction()TypedValue#getFloat()

getFraction(float base,float pbase)的两个参数为基数,最终的返回值是 基数*百分数。举个例子,当设置的属性值为 50% 时,返回值为 base*50% ;当设置的属性值为 50%p 时,返回值为 pbase*50%


6. 总结

  • 应试建议
    • 应理解样式和主题的区别,两者截然不同:样式是一组可复用的 View 属性集合,而主题是一组命名的资源集合。
    • 应掌握属性来源优先级顺序:View > Style > Default Style > Theme

参考资料

推荐阅读

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的GitHub!