文章目录

  • 概述
  • 动态添加 View 的基本流程
  • 代码模板
  • 将 View 加载至内存并获得其引用
  • 方式一:new 一个 View 对象
  • 方式二:使用 LayoutInflater 将 XML 加载为 View 实例
  • 获得 LayoutInflater 实例
  • 使用 inflate 方法
  • LayoutParams
  • 为什么需要布局属性?
  • 给 View 设置布局属性
  • ViewGroup.addView


概述

本文提供了动态添加 View 的代码模板、阐述了 LayoutInflater 的获取方法、解答了获取 LayoutInflater 时传入不同类型 Context 的差别,并对 LayoutInflater.inflate 的不同重载方法进行分析,详细解释了 root 以及 attachToRoot 参数的作用,最后还讲解了什么是布局属性,以及布局属性存在的意义。

动态添加 View 的基本流程

将一个 View 添加在屏幕上,总共需要三步

  1. 将 View 加载到内存,并获得其引用
  2. 设置其布局属性、控件属性
  3. 调用 addView 方法

代码模板

这里先给出了动态添加 View 的场景与代码模板,具体细节后面再慢慢说,尽量知其然又知其所以然。

场景一:添加已经写好布局 XML 的 View 树到固定的父布局中
parent 为父布局的引用,则直接使用 View.inflate(context, resource, parent) 即可。

场景二:加载已经写好布局 XML 的 View 树到一个不定的父布局中
先使用 View.inflate(context, resource, null) 将XML加载到内存,再使用 parent.addView(view) 添加到父布局,最后通过 View.setLayoutParams 设置布局属性,或者直接使用 parent.addView(view,-1,layoutParams);

场景三:先将XML加载进内存,在某个实际再固定到特定父布局中
先使用 LayoutInflater.from(context).inflate(resource,parent,false); 将XML加载进内存,并保存其应用,到某个时刻再调用 parent.addView(view) 将其固定到布局

将 View 加载至内存并获得其引用

方式一:new 一个 View 对象

例如 在 MainActivity.java 中 new 一个 Button 对象

new Button(MainActivity.this)

通常,继承自 View 的类都有如下构造方法

// 简单创建一个View,新创建 View 会被设置上它自带的默认样式
public View(Context context) ;

// LayoutInflater 在 createView 时,通过反射调用了这个构造函数,
// 通常不需要使用这个构造方法来创建我们的 View 实例,
// 但是自定义 View 时一定要重载这个构造函数
public View(Context context, @Nullable AttributeSet attrs);

// 比上一个构造函数多了一个 defStyleAttr ,
// 在 defStyleAttr 传入一个样式 id,
// 未在 attrs 中定义的样式,将会从这里取得
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr);

// 比上一个构造函数多了一个 defStyleRes ,
// 当 defStyleAttr 传入 0 ,或者无法找到时,则会从这里尝试查找
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes);

参数解析

  • context:上下文,即创建这个 View 时的环境信息,在创建 View 的时候,需要通过 context 获取当前的主题配置、资源文件等信息,阅读源码发现其专用调用 ContextWrapper 内的方法,ApplicationContextActivityService,都直接或者间接集成于 ContextWrapper ,所以给其传入 ApplicationContext 似乎没有什么问题。
  • attrs : 属性集,即对应着使用 XML 文件定义 View 时,XML 标签上的那些属性
  • defStyleAttr :用户在当前主题下定义的默认样式id,可以传入 0 表示不使用自定义的默认样式
  • defStyleRes:用户定义的默认样式id(未必是当前主题下),可以传入 0 表示不使用自定义的默认样式

四个构造函数,主要就是关于样式的参数有差别,每个样式属性都可以在attrs、defStyleAttr 、defStyleRes 中设定,但是他们之中存在优先级:

  1. 若在 attrs 中设定,则优先使用 attrs 内设定的样式
  2. 若在 defStyleAttr 中设定,则若 attrs 内未设定,就使用 defStyleAttr 内设定的属性
  3. 若在 defStyleRes 中设定,则只有在 attrs 、defStyleAttr 都未涉及时,才会生效
  4. 若 attrs、defStyleAttr 、defStyleRes 都没涉及某个样式属性,则该属性会采用这个 View 的基础样式,比如 Button 就会使用 R.attr.buttonStyle 提供的属性

方式二:使用 LayoutInflater 将 XML 加载为 View 实例

获得 LayoutInflater 实例

在 Activity 中获得 LayoutInflater 有如下几种

LayoutInflater inflater1 = LayoutInflater.from(this);  
LayoutInflater inflater2 = getLayoutInflater();  
LayoutInflater inflater3 = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE);

其中 LayoutInflater.from(this); 的源码为:

public static LayoutInflater from(Context context) {
        LayoutInflater LayoutInflater =
            (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    if (LayoutInflater == null) {
        throw new AssertionError("LayoutInflater not found.");
    }
    return LayoutInflater;
}

Activity.getLayoutInflater() 的源码为

public LayoutInflater getLayoutInflater() {
    return getWindow().getLayoutInflater();
}

getWindow() 返回的是一个 PhoneWindow 对象,在 PhoneWindow 构造时,会调用 LayoutInflater.from(context); 方法,为其组合一个与其 context 对应的 LayoutInflater 对象

public PhoneWindow(Context context) {
    super(context);
    mLayoutInflater = LayoutInflater.from(context);
    mRenderShadowsInCompositor = Settings.Global.getInt(context.getContentResolver(),
            DEVELOPMENT_RENDER_SHADOWS_IN_COMPOSITOR, 1) != 0;
}

所以归根到底,都是调用的

(LayoutInflater) context.getSystemService(LAYOUT_INFLATER_SERVICE);

得到的 LayoutInflater 对象
getSystemServiceContext 的方法,在 ContextThemeWrapper 中有:

public Object getSystemService(String name) {
    if (LAYOUT_INFLATER_SERVICE.equals(name)) {
        if (mInflater == null) {
            mInflater = LayoutInflater.from(getBaseContext()).cloneInContext(this);
        }
        return mInflater;
    }
    return getBaseContext().getSystemService(name);
}

这里注意几点问题:

  1. getSystemServiceContext 的方法,ApplicationContextActivityService 都可以使用
  2. Activity 继承自 ContextThemeWrapper ,所以 Activity 于其他 context 略有不同
  3. ApplicationContextService 得到的 LayoutInflater 是全局单例,不管在哪里获取,获取的都是一个对象
  4. Activity 得到的是全剧单例的拷贝,即同一个 Activity 得到的 LayoutInflater 是同一个实例,不同的 Activity 得到的 LayoutInflater 是不同的实例
  5. LayoutInflater 除了有 inflate 方法外,还有 onCreateView 这样的回调方法,所以有时候需要使用一个“特定”的实例

使用 inflate 方法

LayoutInflater.inflate 方法有多种重载,常用的是前两种重载,但是大多数实现逻辑都在最后一种重载中。

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root);
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot);
public View inflate(XmlPullParser parser, @Nullable ViewGroup root);
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot)

参数分析:

  • resource:资源id,要加载的布局文件的id
  • root :根布局,可选,ViewGroup 类型的对象,继承 ViewGroup 的对象就是那几种布局,以及部分可以包含子 View 的控件,所以称其为根布局。根布局可以为加载的 View 提供布局属性
  • attachToRoot:是否把生成的 View 固定到根布局上,具体用法看下面分析。

结合源码进行分析,先看第一种重载:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root) {
    return inflate(parser, root, root != null);
}

第一种重载的源码表明,使用该方法加载布局时:

  • 若根布局存在,则将加载的 View 固定在根布局上
  • 若根布局不存在,则不将加载的 View 固定在根布局上

但是什么叫 “固定在根布局上” 再需要继续看源码,第二种重载方法其实调用的是最后一种重载,所以直接看最后一种重载的源码,这里挑出部分进行分析:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {
    	......
    	View result = root;
		......
        try {
          ......

            if (TAG_MERGE.equals(name)) {
            	// 这里是如果 XML 标签是 merge 时的处理,这里略
				......
            } else {
				......
				// 如果根布局对象存在,attachToRoot为false,则只将根布局支持的布局属性抽取出来设置给temp
                if (root != null) {
					......
					// 将XML中的根布局支持的布局属性抽取出来,布局属性就是那些以layout_*命名的属性
                    params = root.generateLayoutParams(attrs);
                    if (!attachToRoot) {
                        // 将布局属性设置给 temp
                        temp.setLayoutParams(params);
                    }
                }
                ......
                
                // 如果根布局存在,attachToRoot为true,则将新加载进来的View附加在根布局之下
                if (root != null && attachToRoot) {
                    root.addView(temp, params);
                }

                // 如果根布局不存在,或者 attachToRoot为false,
                // 该方法直接将新加载进来的View返回
                // 否则就会把根布局对象返回
                if (root == null || !attachToRoot) {
                    result = temp;
                }
            }
		......
        return result;
    }
}

总结一下就是:

  • root 不为 null
  • attachToRoot 为 true :
  1. 新加载的 View 中,其根布局支持的布局属性将会生效
  2. 新加载的 View 将会被 add 到根布局上
  3. 返回的是根布局对象
  • attachToRoot 为 false:
  1. 新加载的 View 中,其根布局支持的布局属性将会生效
  2. 返回是新加载的 View 对象
  • root 为 null:
  • 不管 attachToRoot 如何设置
  1. 新加载的 View 布局属性不会生效
  2. 返回是新加载的 View 对象

还有一种更常用的 inflate 方法,View.inflate(context,resource,root)

public static View inflate(Context context, @LayoutRes int resource, ViewGroup root) {
    LayoutInflater factory = LayoutInflater.from(context);
    return factory.inflate(resource, root);
}

这个静态方法就是对上述内容的封装。

LayoutParams

LayoutParams 布局属性,在 XML 中定义的控件有两种属性:

  1. 布局属性,指该控件在当前布局下的样式
  2. 自身属性,指该控件自身的特性

在 XML 文件中,布局属性是那些 layout_* 命名的属性,没有 layout 前缀的属性,就是自身属性

为什么需要布局属性?

一个控件可以放在不同布局下,但是同一控件在不同的布局下,可能需要一些“特色属性”,比如在 LinearLayout 中的 layout_weight、在 RelativeLayout 中的 layout_toLeftOf

如果将这些属性归为控件的自身属性,那么一个在A布局的控件,很难不加改动的在B布局复用,这无疑增加了控件与布局的耦合。

又考虑到同一个布局下的所有“特色属性”都应该是一样的,所以不妨以布局为单位,对这些属性进行分类,就成了我们所谓的布局属性。

将布局属性抽取出来以后,很方便实现:
1. 父布局不变,更换控件
2.控件不变,更换父布局

由于布局属性是与控件所在父布局相关的属性,所以布局属性的类型要与父布局类型一致,例如 LinearLayout.LayoutParams 对应 LinearLayout 的布局属性,RelativeLayout.LayoutParams 对应 RelativeLayout 的布局属性。

给 View 设置布局属性

View 可以被代码动态设置布局属性,使用 View.setLayoutParams 方法

public void setLayoutParams(ViewGroup.LayoutParams params) {
    if (params == null) {
        throw new NullPointerException("Layout parameters cannot be null");
    }
    mLayoutParams = params;
    resolveLayoutParams();
    if (mParent instanceof ViewGroup) {
        ((ViewGroup) mParent).onSetLayoutParams(this, params);
    }
    requestLayout();
}

可以看到,当调用 setLayoutParams 方法时,会同时请求根布局与 View 的重新绘制,并执行其父布局的 onSetLayoutParams 回调函数。

ViewGroup.addView

addViewViewGroup 内持有的方法,即那些可以包含子控件的布局与控件才可以执行addView,在普通的 View 是没有的。

ViewGroup.addView 有四种重载

// 添加一个子控件,如果控件未设置布局属性,则使用其父布局的默认属性
// 该方法不应该在draw(Canvas), onDraw(Canvas), dispatchDraw(Canvas)以及任何相关方法中调用。
public void addView(View child) {
    addView(child, -1);
}
// index 参数表示添加的子空间在父布局中的位置
// index传入 -1 表示添加在最后
//由源码可见,如果 child 不包含布局属性,则使用 generateDefaultLayoutParams() 获得默认布局属性
public void addView(View child, int index) {
    if (child == null) {
        throw new IllegalArgumentException("Cannot add a null child view to a ViewGroup");
    }
    LayoutParams params = child.getLayoutParams();
    if (params == null) {
        params = generateDefaultLayoutParams();
        if (params == null) {
            throw new IllegalArgumentException(
                    "generateDefaultLayoutParams() cannot return null");
        }
    }
    addView(child, index, params);
}
//添加一个子控件,并为子空间赋予一个布局属性
public void addView(View child, int index, LayoutParams params) {
    if (DBG) {
        System.out.println(this + " addView");
    }

    if (child == null) {
        throw new IllegalArgumentException("Cannot add a null child view to a ViewGroup");
    }

    // addViewInner() will call child.requestLayout() when setting the new LayoutParams
    // therefore, we call requestLayout() on ourselves before, so that the child's request
    // will be blocked at our level
    requestLayout();
    invalidate(true);
    addViewInner(child, index, params, false);
}