这是一篇 2018 年的老文章,全文行云流水,由浅入深的介绍了 Factory2 的一些奇技淫巧,值得一读。
原文作者:ahmed el-helw
当我们新建 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
。