这是一篇 2018 年的老文章,全文行云流水,由浅入深的介绍了 Factory2 的一些奇技淫巧,值得一读。
原文作者:ahmed el-helw 原文地址:https://helw.net/2018/08/06/appcompat-view-inflation/
当我们新建 Activity 的时候,大部分情况是继承 AppCompatActivity
。对 Android 开发者来说,它提供了向后兼容性,大大简化了我们的开发。但是它是如何工作的呢?特别是它如何将 xml 布局文件中的 TextView
替换成 AppCompatTextView
的呢?
这篇文章将深入探索 AppCompatActivity
的 视图加载 过程。
Factory2
在 Android 中,我们经常在 xml 文件中书写布局。这些文件被打包进 app(因为性能原因由 aapt/2 转换为二进制 xml),并且在运行时由 LayoutInflater
加载。
在 LayoutInflater
中有两个方法 setFactory
和 setFactory2
,文档中是这样描述的:
当使用 LayoutInflater 创建 View 的时候,绑定一个自定义的 factory 实例。不能为 null,并且只能设置一次,设置之后无法修改,当 xml 中每一个元素名字被解析的时候调用。如果 factory 返回一个 View,将被添加到视图层级中。如果返回 null,factory 的下一个默认方法
onCreateView(View, String, AttributeSet)
将被调用。
注意,Factory2 implements Factory
,所以对于 Api 11 的应用来说,应该使用 setFactory2
。这就相当于给了我们介入 xml 中每一个 View 元素的创建过程的机会。让我们看一个实际使用:
class FactoryActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
layoutInflater.factory2 = object : LayoutInflater.Factory2 {
override fun onCreateView(parent: View?,
name: String,
context: Context,
attrs: AttributeSet): View? {
// 将 TextView 替换为 RedTextView
if (name == "TextView") {
return RedTextView(context, attrs)
}
return null
}
override fun onCreateView(name: String,
context: Context,
attrs: AttributeSet): View? {
return onCreateView(null, name, context, attrs)
}
}
super.onCreate(savedInstanceState)
setContentView(R.layout.factory)
}
上面的代码中,我们仅仅为当前 Context
的 LayoutInflater
设置了一个 Factory2
。这样只要发现了 TextView
,都会被替换为我们自己的实现类 RedTextView
。
RedTextView
是 TextView
的子类,提供了 setBackgroundColor
方法,将背景置为红色:
class RedTextView : AppCompatTextView {
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet?) :
super(context, attrs) { initialize() }
constructor(context: Context, attr: AttributeSet?, defStyleAttr: Int) :
super(context, attr, defStyleAttr) { initialize() }
private fun initialize() { setBackgroundColor(Color.RED) }
}
布局文件 factory.xml
是这样的:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Hello" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="World" />
</LinearLayout>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Welcome" />
</LinearLayout>
运行应用并使用 Layout Inspector,我们发现所有的 TextView
都变成了 RedTextView
。棒极了!
AppcompatActivity 和 Factory2
如果把上面的 FactoryActivity
修改为继承 AppCompatActivity
,我们会看到 TextView
确实变成了 RedTextView
。但是我们添加的 Button
仍然是 Button,并没有变成 AppCompatButton
,这是为什么?
AppCompatActivity
的 onCreate
方法的前两行是:
final AppCompatDelegate delegate = getDelegate();
delegate.installViewFactory();
getDelegate()
根据 api 版本的不同返回对应的代理类(AppCompatDelegateImplV14
, AppCompatDelegateImplV23
, AppCompatDelegateImplN
等等)。
下一行代码 delegate.installViewFactory()
:
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(this.mContext);
if (layoutInflater.getFactory() == null) {
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
Log.i("AppCompatDelegate", "The Activity's LayoutInflater already has a Factory installed so we can not install AppCompat's");
}
}
当 layoutInflater.getFactory()
为空的时候,会调用 setFactory2
。如果不为空,什么都不会做。
所以 Button
没有发生变化的原因是,已经设置过了 Factory,导致 AppcompatActivity
自己的 factory 没有被 install。
注意,FactoryActivity
的 setFactory2()
方法是在 super.onCreate
之前调用的。如果不是的话,当父类是 AppcompatActivity
,setFactory2
会抛出异常。因为 AppCompatActivity
设置了自己的 Factory 。文档中是这样描述的:它不能为空,且只能被设置一次;在设置之后,你不能对 Factory 进行改变 。
如何兼容 AppCompatActivity 的 Factory2
如何既能使用自己的 Factory2,又能让 AppCompatActivity
保留自己的 Facotory 呢?下面给出几种解决方法。
代理给 AppCompatDelegate
在 AppCompatDelegate
内部有一个 createView
方法,不要和 Factory
、Factory2
的 onCreateView
混淆。
/**
* This should be called from a
* {@link android.view.LayoutInflater.Factory2 LayoutInflater.Factory2}
* in order to return tint-aware widgets.
* <p>
* This is only needed if you are using your own
* {@link android.view.LayoutInflater LayoutInflater} factory, and have
* therefore not installed the default factory via {@link #installViewFactory()}.
*/
public abstract View createView(@Nullable View parent,
String name,
@NonNull Context context,
@NonNull AttributeSet attrs);
我们仅仅只需要修改 setFactory2
,将不需要处理的情况代理给 AppCompatDelegate
:
override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
layoutInflater.factory2 = object : LayoutInflater.Factory2 {
override fun onCreateView(parent: View?,
name: String,
context: Context,
attrs: AttributeSet): View? {
if (name == "TextView") {
return RedTextView(context, attrs)
}
// 代理给 AppCompatActivity's getDelegate()
return delegate.createView(parent, name, context, attrs)
}
override fun onCreateView(name: String,
context: Context,
attrs: AttributeSet): View? {
return onCreateView(null, name, context, attrs)
}
}
super.onCreate(savedInstanceState)
setContentView(R.layout.factory)
}
运行一下,TextView
变成了 RedTextView
,Button
变成了 AppCompatButton
,成功!
重写 viewInflaterClass
我们看一下 AppCompatDelegate
的 createView
方法,当 AppCompatViewInflater
没有初始化时,会通过反射创建。要初始化的类由 R.styleable.AppCompatTheme_viewInflaterClass
指定,默认就是 AppCompatViewInflater
。
对 FactoryActivity
的 theme 进行如下修改:
<style name="FactoryTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="viewInflaterClass">com.cafesalam.experiments.app.ui.CustomViewInflater</item> </style>
就可以让 AppCompatDelegate
使用我们自定义的 AppCompatViewInflater
的子类 CustomViewInflater
:
class CustomViewInflater : AppCompatViewInflater() {
override fun createTextView(context: Context, attrs: AttributeSet) =
RedTextView(context, attrs)
}
Google 的 Material Design Components 实际上就是使用这种方法来将 Button
修改为对应的 MaterialButton
,在 这里 可以看到 。
这个方法很强大,它可以让你的 App 使用 Material Design Components 这样的类库,却仅仅只需要设置合适的主题。
注意 AppCompatViewInflater
还提供了一个可以被重写的 createView()
方法,用来处理默认情况下没有被处理的新的组件。当 AppCompatViewInflater
没有处理特定的组件类型,就可以使用这个方法。
自定义 LayoutInflater
第三种方法是重写 Activity
的 attachBaseContext
,改写 ContextThemeWrapper
的 getSystemService
方法,返回自定义的 LayoutInflater
。自定义的 LayoutInflater
可以重写 setFactory2
方法,加入自己的处理逻辑。这个方法是我从 ViewPump 学到的。
一些小细节
下面介绍了 AppCompatDelegate 在进行视图加载过程中的几个小细节。
onCreateView
我们希望 Factory2
的 onCreateView
方法直接调用 createView
(代理给 AppCompatDelegate
那一小节中提到过) 。事实上,的确也是这么做的。但是代码中还多了一点东西 - 调用了 callActivityOnCreateView
。在 AppCompatDelegateImplV14
中是这样的:
@Override
View callActivityOnCreateView(View parent,
String name,
Context context,
AttributeSet attrs) {
// On Honeycomb , Activity's private inflater factory will handle
// calling its onCreateView(...)
return null;
}
看一下 LayoutInflater
的 源码 , createViewFromTag
尝试通过 factory 获取 view 。如果没有获取到,会使用 mPrivateFactory
。如果依旧没有获取到,会通过视图标签去创建 view 。mPrivateFactory
是在 Activity 中设置的。
有意思的是, mPrivateFactory
的作用是解析 fragment
标签。
在 API 14 之前,LayoutInflater
并没有提供 mPrivateFactory
让 Activity 可以有个兜底方案来创建 View 。因此,callActivityOnCreateView
在低版本中提供了这一功能。但这现在都没有关系了,反正 AppCompat 目前只兼容 Api 14 。
另一个有意思的知识点是 Window.Callback 。Window.Callback
是一个回调,让调用者可以拦截 key 的分发,面板,菜单等等。它让 AppCompatActivity 可以处理一些特定时间,例如菜单键,返回键等。
createView
总的来说,AppCompatDelegateImplV9
做了两件事。首先,创建了 AppCompatViewInflater
或者在 theme 中指定的其他子类。第二,通过 inflater 创建 View 。
AppCompatViewInflater
的 createView
使用了正确的 Context
(考虑到支持 app:theme
和 android:theme
,需要对 Context 进行包装),根据组件名称创建对应的 AppCompat 组件(例如,如果是 TextView
,就调用 createTextView
方法返回 AppCompatTextView
)。
支持 app:theme
从 Android 5.0 开始,可以给 View 设置 app:theme
以覆盖特定 View 及其子类的属性。AppCompat 通过继承父 View 的 context 在 Android 5.0 之前复制这一行为。
在 AppCompat 加载 View 之前,它先拿到父 View 的 Context,然后尝试创建一个 ContextThemeWrapper(android:theme
或者 app:theme
),保证使用正确的 context 来加载组件。
另外,如果开发者明确声明需要在资源中使用矢量图,AppCompat 在 Android 5.0 之前还提供了 TintContextWrapper
来包装 Context 。
View 的创建和兜底
通过这些信息,系统已经准备好如何创建 View 了。
遍历支持的组件列表,对于通用的 View,如 TextView
, ImageView
,直接生成对应的 AppCompat 子类。如果是未知类型的 View,将使用正确的 Context 调用 createView
,默认返回 null,但一般会被 AppCompatViewInflater 的子类重写。
如果这时候 view 仍然是 null,会检查 view 的原始 context 是否和父 View 的 context 一致。这种情况会发生在子 View 的 android:theme
和 父 View 不一致。
在检查 android:onClick
之后,view 就被返回了。
总结和使用实例
总结一下,AppCompatActivity
通过给 LayoutInflater
设置 Factory2
来介入 View 的创建过程,以提供向后兼容性(为组件提供 tint,处理 android:theme
等)。它也保证了可扩展性,开发者可以进行一些定制处理。
除了 Appcompat,这一技巧被用来完成了更多有意思的事情。Probe (现已废弃) 提供了 OvermeasureInterceptor 来记录 View 的测量次数,LayoutBoundsInterceptor 来高亮 View 的边界。
Calligraphy 使用这一技巧方便的为 TextView 添加字体。它使用了ViewPump 库,在 wiki 中提供了一些可能的使用方式。
最后,Google 的 Material Components for Android 通过自定义 AppCompatViewInflater
将 Button
替换为 MaterialButton
。