前言
最近给组内同学做了一次“动态换肤和换文案”的主题分享,其中的核心就是LayoutInflater类,所以把LayoutInflater源码梳理了一遍。巧了,这周掘金新榜和部分公众号都发布了LayoutInflater或者换肤主题之类的文章。那只好站在各位大佬的肩膀上,也来凑个热闹,分析一下LayoutInflater类。(前方长文预警,会有很多源码分析,源码基于Android 9.0)
LayoutInflater简介
官方文档 https://developer.android.com/reference/android/view/LayoutInflater
我们在加载布局的时候都会主动或者被动的用到 LayoutInflater ,比如 Activity 的setContentView
方法和Fragment的onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
回调等。LayoutInflater 的作用就是把布局文件xml实例化为相应的View组件。我们可以通过三种方法获取 LayoutInflater:
-
Activity.getLayoutInflater()
;
-
LayoutInflater.from(context)
;
-
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE)
;
每个方法都和 Context 相关联,其中方法1和方法2最终都会通过方法3来实现。
获取到 LayoutInflater 后,通过调用inflate
方法来实例化布局。而inflate
方法由很多重载,我们常用的是inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot)
,所有 inflate 方法最终会调用到 inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot)
。下面就从这个方法入手,开始分析 LayoutInflater 的源码。
源码分析
inflate方法
先看一下inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot)
的三个参数:
- XmlPullParser parser:很显然是一个 XML 解析器,这个解析器就是 LayoutInflater 所要加载的 XML 布局转化来的,通过 PULL 方式解析。
- ViewGroup root:装载要加载的 XML 布局的根容器,比如,在 Activity 的
setContentView
方法中就是 id 为android.R.id.content
的 FrameLayout 根布局了。
- boolean attachToRoot:是否将所解析的布局添加到根容器中,同时也影响了所解析布局的宽高。
被广泛讨论的是root
和attachToRoot
的不同传参对被加载的布局文件的影响,下面看代码。
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
final Context inflaterContext = mContext;
// 将parser转成AttributeSet接口,用来读取xml中设置的View属性
final AttributeSet attrs = Xml.asAttributeSet(parser);
Context lastContext = (Context) mConstructorArgs[0];
mConstructorArgs[0] = inflaterContext;
View result = root; // 此方法返回的View,默认是root
try {
// Look for the root node.
int type;
while ((type = parser.next()) != XmlPullParser.START_TAG &&
type != XmlPullParser.END_DOCUMENT) {
// Empty
}
...
final String name = parser.getName(); // 获取当前的标签名
...
if (TAG_MERGE.equals(name)) { // 处理标签
if (root == null || !attachToRoot) {
throw new InflateException(" can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
// 递归处理
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// Temp is the root view that was found in the xml
// 创建View对象
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
if (root != null) {
...
// Create layout params that match root, if supplied
params = root.generateLayoutParams(attrs); // 获取根View的宽高
if (!attachToRoot) { // 如果attachToRoot为false,则给根View设置宽高
// Set the layout params for temp if we are not
// attaching. (If we are, we use addView, below)
temp.setLayoutParams(params);
}
}
...
// Inflate all children under temp against its context.
rInflateChildren(parser, temp, attrs, true); // 递归处理
...
// We are supposed to attach all the views we found (int temp)
// to root. Do that now.
if (root != null && attachToRoot) {
// 如果root不空,且attachToRoot为true,则将根View添加到容器中
root.addView(temp, params);
}
// Decide whether to return the root that was passed in or the
// top view found in xml.
if (root == null || !attachToRoot) {
// 如果root空或者attachToRoot为false,则将返回结果设置为根View
result = temp;
}
}
} catch (XmlPullParserException e) {
...
} catch (Exception e) {
...
} finally {
// Don't retain static reference on context.
mConstructorArgs[0] = lastContext;
mConstructorArgs[1] = null;
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
// 要么是root,要么是创建的根View
return result;
}
}
从代码中可以看出root
和attachToRoot
不同传参的影响:
- 如果root不为null,attachToRoot设为true,则会将加载的布局添加到一个父布局中,即root,并且返回root;
- 如果root不为null,attachToRoot设为false,则会对布局文件最外层的所有layout属性进行设置,并且返回该布局的根View,当该view被添加到父view当中时,这些layout属性会自动生效;
- 如果root为null,attachToRoot将失去作用,设置任何值都没有意义,返回的也是要加载的布局的根View;
rInflate方法
从上面的方法中可以看到处理
标签时会调用rInflate
,处理子View时会调用rInflateChildren
方法。其实rInflateChildren
中调用的是rInflate
,而rInflate
也调用了rInflateChildren
,从而形成了递归调用,也就是递归处理子View。
void rInflate(XmlPullParser parser, View parent, Context context,
AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
final int depth = parser.getDepth();
int type;
boolean pendingRequestFocus = false;
while (((type = parser.next()) != XmlPullParser.END_TAG ||
parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
if (type != XmlPullParser.START_TAG) {
continue;
}
final String name = parser.getName();
if (TAG_REQUEST_FOCUS.equals(name)) {
// 处理标签
pendingRequestFocus = true;
consumeChildElements(parser);
} else if (TAG_TAG.equals(name)) {
// 处理标签
parseViewTag(parser, parent, attrs);
} else if (TAG_INCLUDE.equals(name)) {
// 处理标签
if (parser.getDepth() == 0) {
throw new InflateException(" cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
} else if (TAG_MERGE.equals(name)) { // 标签异常
throw new InflateException(" must be the root element");
} else { // 创建View对象
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
rInflateChildren(parser, view, attrs, true); // 递归处理孩子节点
viewGroup.addView(view, params); // 将View添加到父布局中
}
}
if (pendingRequestFocus) { // 父布局处理焦点
parent.restoreDefaultFocus();
}
if (finishInflate) { // 结束加载
parent.onFinishInflate();
}
}
该方法中会处理
、
、
、
和普通View标签。其中:
-
是重新定位焦点的,调用的consumeChildElements
方法其实没干什么事,只是简单的把该标签消费结束掉。
-
标签一般很少用,它主要用来标记View,给View设置一个标签值,例如:
findViewById(R.id.tv).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 试一下tag标签
Toast.makeText(MainActivity.this, (String) v.getTag(R.id.tag), Toast.LENGTH_SHORT).show();
}
});
在ListView的自定义Adapter中,应该都有用到过View的setTag方法,即:使用ViewHolder来重复利用View。
parseViewTag
方法:
private void parseViewTag(XmlPullParser parser, View view, AttributeSet attrs)
throws XmlPullParserException, IOException {
final Context context = view.getContext();
final TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.ViewTag);
// 读取tag的id
final int key = ta.getResourceId(R.styleable.ViewTag_id, 0);
// 读取tag的值
final CharSequence value = ta.getText(R.styleable.ViewTag_value);
// 给View设置该tag
view.setTag(key, value);
ta.recycle();
// 结束该标签(子View无效)
consumeChildElements(parser);
}
-
标签不能是根标签,parseInclude
方法单独分析。
-
标签只能是根标签,这里会抛异常。
parseInclude方法
private void parseInclude(XmlPullParser parser, Context context, View parent,
AttributeSet attrs) throws XmlPullParserException, IOException {
int type;
if (parent instanceof ViewGroup) { // 必须在ViewGroup里才有效
// 处理theme属性
...
// If the layout is pointing to a theme attribute, we have to
// massage the value to get a resource identifier out of it.
// 拿到layout指定的布局
int layout = attrs.getAttributeResourceValue(null, ATTR_LAYOUT, 0);
...
if (layout == 0) { // 必须是合法的id
final String value = attrs.getAttributeValue(null, ATTR_LAYOUT);
throw new InflateException("You must specify a valid layout "
+ "reference. The layout ID " + value + " is not valid.");
} else { // 类似于inflate的处理
// 拿到layout的解析器
final XmlResourceParser childParser = context.getResources().getLayout(layout);
try {
final AttributeSet childAttrs = Xml.asAttributeSet(childParser);
while ((type = childParser.next()) != XmlPullParser.START_TAG &&
type != XmlPullParser.END_DOCUMENT) {
// Empty.
}
if (type != XmlPullParser.START_TAG) {
throw new InflateException(childParser.getPositionDescription() +
": No start tag found!");
}
// layout的根标签
final String childName = childParser.getName();
if (TAG_MERGE.equals(childName)) { // 处理
// The tag doesn't support android:theme, so
// nothing special to do here.
rInflate(childParser, parent, context, childAttrs, false);
} else { // 处理View
final View view = createViewFromTag(parent, childName,
context, childAttrs, hasThemeOverride);
final ViewGroup group = (ViewGroup) parent;
final TypedArray a = context.obtainStyledAttributes(
attrs, R.styleable.Include);
// 获取里设置的id
final int id = a.getResourceId(R.styleable.Include_id, View.NO_ID);
// 获取里设置的visibility
final int visibility = a.getInt(R.styleable.Include_visibility, -1);
a.recycle();
ViewGroup.LayoutParams params = null;
try { // 获取里设置的宽高
params = group.generateLayoutParams(attrs);
} catch (RuntimeException e) {
// Ignore, just fail over to child attrs.
}
if (params == null) {
// 获取layout里设置的宽高
params = group.generateLayoutParams(childAttrs);
}
// 里设置的宽高优先于layout里设置的
view.setLayoutParams(params);
// Inflate all children.
rInflateChildren(childParser, view, childAttrs, true);
if (id != View.NO_ID) {
// include里设置的id优先级高
view.setId(id);
}
// include里设置的visibility优先级高
switch (visibility) {
case 0:
view.setVisibility(View.VISIBLE);
break;
case 1:
view.setVisibility(View.INVISIBLE);
break;
case 2:
view.setVisibility(View.GONE);
break;
}
group.addView(view);
}
} finally {
childParser.close();
}
}
} else {
throw new InflateException(" can only be used inside of a ViewGroup");
}
LayoutInflater.consumeChildElements(parser);
}
- include里必须设置layout属性,且layout的id必须合法;
- include里设置的id优先级高于layout里设置的id,即:两者同时设置时,后者会失效;
- include里设置的width和height属性优先级高于layout里设置的宽高;
- include里设置的visibility属性优先级高于layout设置的visibility。
createViewFromTag方法
正常View标签都是通过createViewFromTag
来创建对应的View对象的。
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
if (name.equals("view")) {
// 真正的View标签名存在class属性中
name = attrs.getAttributeValue(null, "class");
}
...
try {
View view;
if (mFactory2 != null) { // 先使用Factory2
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) { // 再使用Factory
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
// 通过标签名中是否包含'.'来区分是否为自定义View
if (-1 == name.indexOf('.')) {
// 处理系统View
view = onCreateView(parent, name, attrs);
} else { // 自定义View用的是全限定类名
// 处理自定义View
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (InflateException e) {
...
} catch (ClassNotFoundException e) {
...
} catch (Exception e) {
...
}
}
- 优先通过Factory2和Factory来创建View,这两个Factory等会再说;
- 通过标签名中是否包含'.'来区分待创建的View是自定义View还是系统View;
- 系统View会在
onCreateView
方法中添加android.view.
前缀,然后交由createView
处理。
createView方法
public final View createView(String name, String prefix, AttributeSet attrs)
throws ClassNotFoundException, InflateException {
// 有缓存
Constructor extends View> constructor = sConstructorMap.get(name);
if (constructor != null && !verifyClassLoader(constructor)) {
constructor = null;
sConstructorMap.remove(name);
}
Class extends View> clazz = null;
try {
if (constructor == null) { // 第一次则通过反射创建constructor
// Class not found in the cache, see if it's real, and try to add it
clazz = mContext.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);
if (mFilter != null && clazz != null) {
boolean allowed = mFilter.onLoadClass(clazz);
if (!allowed) {
failNotAllowed(name, prefix, attrs);
}
}
// 使用的是包含Context, AttributeSet这两个参数的构造函数
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
sConstructorMap.put(name, constructor); // 添加到缓存中
} else { // 命中缓存
// If we have a filter, apply it to cached constructor
if (mFilter != null) { // 先过滤
// Have we seen this name before?
Boolean allowedState = mFilterMap.get(name);
if (allowedState == null) {
// New class -- remember whether it is allowed
clazz = mContext.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);
boolean allowed = clazz != null && mFilter.onLoadClass(clazz);
mFilterMap.put(name, allowed);
if (!allowed) {
failNotAllowed(name, prefix, attrs);
}
} else if (allowedState.equals(Boolean.FALSE)) {
failNotAllowed(name, prefix, attrs);
}
}
}
Object lastContext = mConstructorArgs[0];
if (mConstructorArgs[0] == null) {
// Fill in the context if not already within inflation.
mConstructorArgs[0] = mContext;
}
Object[] args = mConstructorArgs;
args[1] = attrs;
// 反射创建View实例对象
final View view = constructor.newInstance(args);
if (view instanceof ViewStub) {
// 如果是ViewStub则懒加载
// Use the same context when inflating ViewStub later.
final ViewStub viewStub = (ViewStub) view;
viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
}
mConstructorArgs[0] = lastContext;
return view;
}
...
}
通过反射待创建View的构造函数(两个参数:Context和AttributeSet的构造函数)来实例化View对象,如果是ViewStub对象还会进行懒加载。
LayoutInflater.Factory/Factory2
通过以上流程,使用LayoutInflater的infalte方法加载布局文件的整体流程就分析完了。但出现了Factory2
和Factory
类,它们会优先创建View,我们来看看着两个类到底是什么!
它们都是LayoutInflater的内部类——两个接口:
public interface Factory {
public View onCreateView(String name, Context context, AttributeSet attrs);
}
public interface Factory2 extends Factory {
public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
}
Factory2
继承了Factory
,增加了一个带View parent
参数的onCreateView
重载方法。它们是在createViewFromTag
中被调用的,默认为null,说明开发人员可以自定义这两个Factory,则通过它们可以改造待加载XML布局中的View标签,来使用自定义规则创建View。
来看一下它们的设置方法:
public void setFactory(Factory factory) {
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
// 和setFactory2类似
...
}
}
public void setFactory2(Factory2 factory) {
if (mFactorySet) { // 只能设置一次
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
if (factory == null) {
throw new NullPointerException("Given factory can not be null");
}
mFactorySet = true;
if (mFactory == null) {
mFactory = mFactory2 = factory;
} else { // 合并原有的Factory
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}
可以看到Factory
和Factory2
只能设置一次,否则会抛异常。
这两个Factory的区别是什么?
- Factory2 是API 11 被加进来的;
- Factory2 继承自 Factory,也就说现在直接使用Factory2即可;
- Factory2 可以对创建 View 的 Parent 进行操作;
那如何应用呢?
Factory2/Factory的应用
AppCompatActivity中的应用
先看一张图:
这个布局中使用的是正常的标签
和,但通过Layout Inspector工具分析页面会发现它们被替换成了
AppCompatTextView
和AppCompatButton
。
跟踪一下AppCompatActivity
的onCreate
方法:
protected void onCreate(@Nullable Bundle savedInstanceState) {
AppCompatDelegate delegate = this.getDelegate();
delegate.installViewFactory();
delegate.onCreate(savedInstanceState);
...
super.onCreate(savedInstanceState);
}
委托到了AppCompatDelegate
类,并且调用了installViewFactory
方法。找到这个类的一个实现AppCompatDelegateImpl
(不同版本的源码这个实现类的名字不同):
class AppCompatDelegateImpl extends AppCompatDelegate implements Callback, Factory2
看到关键的Factory2
了,直接看installViewFactory
方法:
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(this.mContext);
if (layoutInflater.getFactory() == null) { // 设置自身到LayoutInflater
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");
}
}
通过LayoutInflaterCompat.setFactory2
将AppCompatDelegateImpl
设置到LayoutInflater中。继续跟踪onCreateView
的实现,会走到createView
方法:
public View createView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs) {
if (this.mAppCompatViewInflater == null) {
TypedArray a = this.mContext.obtainStyledAttributes(styleable.AppCompatTheme);
String viewInflaterClassName = a.getString(styleable.AppCompatTheme_viewInflaterClass);
if (viewInflaterClassName != null && !AppCompatViewInflater.class.getName().equals(viewInflaterClassName)) {
try {
Class viewInflaterClass = Class.forName(viewInflaterClassName);
this.mAppCompatViewInflater = (AppCompatViewInflater)viewInflaterClass.getDeclaredConstructor().newInstance();
} catch (Throwable var8) {
Log.i("AppCompatDelegate", "Failed to instantiate custom view inflater " + viewInflaterClassName + ". Falling back to default.", var8);
this.mAppCompatViewInflater = new AppCompatViewInflater();
}
} else {
this.mAppCompatViewInflater = new AppCompatViewInflater();
}
}
...
return this.mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext, IS_PRE_LOLLIPOP, true, VectorEnabledTintResources.shouldBeUsed());
}
会创建一个AppCompatViewInflater
类,并且调用了它的createView
方法,看样子找到源头了。
来到AppCompatViewInflater
类:
public class AppCompatViewInflater {
...
final View createView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs, boolean inheritContext, boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
Context originalContext = context;
...
View view = null;
byte var12 = -1;
switch(name.hashCode()) {
...
case -938935918:
if (name.equals("TextView")) {
var12 = 0;
}
break;
...
}
switch(var12) {
case 0:
view = this.createTextView(context, attrs);
this.verifyNotNull((View)view, name);
break;
...
default:
view = this.createView(context, name, attrs);
}
...
return (View)view;
}
@NonNull
protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
return new AppCompatTextView(context, attrs);
}
...
}
通过name
参数拿到TextView标签后,直接替换成了AppCompatTextView。
通过这波操作,将一些 widget 自动变成兼容widget (例如将 TextView 变成 AppCompatTextView)以便于向下兼容新版本中的特性。
那我们也可以仿照AppCompatActivity来自定义Factory实现自己需要的替换效果。
自定义Factory2
大多换肤功能的实现就是通过实现自定义Factory,拦截特定View,然后修改这些View的属性值,或者直接返回自定义的View。举个栗子:
- 替换TextView的文字颜色;
- 在不创建selector文件前提下实现圆角按钮;
先看XML:
注意:这里用的是系统View的标签,但属性里用到了自定义属性。
注意:如果想直接替换Android自带属性,需要在自定义属性里加上android:
前缀。
public class MyFactory implements LayoutInflater.Factory2 {
public LayoutInflater.Factory mOriginalFactory;
// 这个模拟新资源
private Map mColorMap;
public MyFactory(LayoutInflater.Factory factory) {
this.mOriginalFactory = factory;
mColorMap = new HashMap<>();
// 模拟新的皮肤资源——新文字颜色
mColorMap.put("third_tv_text_color", "#0000ff");
}
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
return onCreateView(name, context, attrs);
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
View view = null;
if (mOriginalFactory != null) {
view = mOriginalFactory.onCreateView(name, context, attrs);
}
if ("TextView".equals(name)) {
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MyTextView);
// 注意这里的属性名:android:textColor,不用自定义命名空间
int resourceId = ta.getResourceId(R.styleable.MyTextView_android_textColor, -1);
String resourceName = context.getResources().getResourceName(resourceId);
resourceName = resourceName.substring(resourceName.lastIndexOf('/') + 1);
view = new TextView(context, attrs); // 可以直接修改原TextView的属性
ta.recycle();
// 这里模拟替换原TextView的textColor属性值
String color = mColorMap.get(resourceName);
((TextView) view).setTextColor(Color.parseColor(color));
}
if ("Button".equals(name)) {
view = new Button(context, attrs);
// 读取自定义的属性值
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RoundButton);
float radius = ta.getDimension(R.styleable.RoundButton_cornerRadius, 0);
float strokeWidth = ta.getDimension(R.styleable.RoundButton_strokeWidth, 0);
int strokeColor = ta.getColor(R.styleable.RoundButton_strokeColor, -1);
// 构造圆角按钮
GradientDrawable drawable = new GradientDrawable();
drawable.setCornerRadius(DensityUtil.dip2px(context, radius));
drawable.setStroke(DensityUtil.dip2px(context, strokeWidth), strokeColor);
view.setBackground(drawable);
ta.recycle();
}
return view;
}
}
注意:新资源可以通过其他方式存储和获取,从而实现动态热换肤;如果使用的是AppCompatActivity,自定义Factory必须在调用super.onCreate
之前设置,因为它已经有了一个Factory;如果使用的是Activity,则必须在调用setContentView
方法之前设置。
效果:
可以看到TextView文字的颜色变成了蓝色;在不提供自定义drawable的xml文件以及不使用自定义View标签的前提下,实现了圆角按钮。
本文的相关示例代码都在:zjxstar的GitHub上,感兴趣的同学可以看下。
总结
LayoutInflater的相关分析就这么多,文章有点长,慢慢看吧!
- LayoutInflater的inflate的过程的核心方法是:createViewFromTag 和 createView 方法;
- LayoutInflater通过PULL解析器来解析XML布局文件,通过反射来创建View对象;
- LayoutInflater.Factory只能设置一次,可以用来替换View;
参考资料
- 换肤、全局字体替换、无需编写shape、selector 的原理Factory小结
https://mp.weixin.qq.com/s/1ua0geFnrbQbyHi8KG2VJQ
- Android系统源码分析--View绘制流程之-inflate
https://juejin.im/post/5bfa7f2e51882539c60cdaa3?utm_source=gold_browser_extension
- Android LayoutInflater Factory 源码解析
https://www.jianshu.com/p/9c16bbaee442
- Android LayoutInflater 源码解析
https://www.jianshu.com/p/f0f3de2f63e3