【Android】布局优化:include、merge、ViewStub的使用及注意事项

524 阅读10分钟

【Android】布局优化:include、merge、ViewStub的使用及注意事项

在 Android 布局优化中,include、merge 和 ViewStub 是三种常用的布局标签。include 主要用于布局重用,merge 一般和 include 配合使用,它可以减少布局嵌套层级,而 ViewStub 则提供了按需加载的功能,当需要时才会将 ViewStub 中的布局加载到内存,提高了程序初始化效率,下面分别介绍它们的使用方法:

一、include

在 Android 开发中,<include> 标签用于实现布局复用。我们通常会将一些通用的界面元素单独抽取到一个独立的布局文件中,然后通过 <include> 标签在其他布局中进行引用。这样不仅方便对相同视图进行统一维护和修改,也有效提高了布局的重用性与开发效率。

举个栗子,以标题栏为例,抽取布局如下:

my_title_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#00BCD4">

    <ImageButton
        android:id="@+id/back_btn"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:src="@drawable/ic_back"
        android:backgroundTint="#00FFFFFF"/>

    <TextView
        android:id="@+id/title_tv"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerVertical="true"
        android:layout_marginStart="20dp"
        android:layout_toEndOf="@+id/back_btn"
        android:gravity="center"
        android:text="我的title"
        android:textSize="18sp" />

</RelativeLayout>

使用也很简单,如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <include layout="@layout/my_title_layout"/>

</LinearLayout>

注意事项

include 使用有几点需要注意:

  1. 当同一个 XML 布局文件中包含多个 <include> 标签时,建议为每个 <include> 单独设置 id 属性。否则,在代码中通过 findViewById() 获取子视图时,只能找到第一个被引入的布局及其内部控件,后续的 <include> 所对应的视图将无法正确访问。
  2. 如果被引入的布局文件的根视图本身定义了 android:id,而 <include> 标签也设置了 android:id,则建议保持两者一致。否则在代码中通过 findViewById() 访问根视图时,可能会出现返回 null 的情况。
  3. <include> 标签中,我们可以重写被引入布局中的所有 layout 属性,但无法重写普通的非 layout 属性(如背景颜色、文字大小等)。需要特别注意的是,若要在 <include> 标签中对 layout 属性进行重写,必须同时显式指定 layout_widthlayout_height,否则所覆写的属性将不会生效。

二、merge

merge标签可用于减少视图层级来优化布局,可以配合include使用,如果include标签的父布局 和 include布局的根容器是相同类型的,那么根容器的可以使用merge代替。<include>标签存在着一个不好的地方,可能会导致产生多余的布局嵌套。举个栗子:

my_choice_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:id="@+id/ok"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginEnd="40dp"
        android:layout_marginStart="40dp"
        android:text="确定"/>

    <Button
        android:id="@+id/cancel"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginEnd="40dp"
        android:layout_marginStart="40dp"
        android:text="取消"/>

</LinearLayout>

这里定义了两个按钮,在布局中引用:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <include layout="@layout/my_title_layout"/>

    <EditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="输入"
        android:layout_margin="40dp"/>

    <include layout="@layout/my_choice_layout"/>

</LinearLayout>

运行结果如下:

看起来没什么问题,其实不知不觉中我们多嵌套了一层布局。我们用工具查看一下此时布局结构:

其实这种情况下:在主界面中,<include>标签的parent ViewGroup与包含的layout根容器 ViewGroup 是相同的类型,这里都是LinearLayout,那么则可以将包含的 layout 根容器 ViewGroup 使用<merge>标签代替,从而减少一层 ViewGroup 的嵌套,提升UI渲染性能。

修改my_choice_layout.xml代码如下:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">

    <Button
        android:id="@+id/ok"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginEnd="40dp"
        android:layout_marginStart="40dp"
        android:text="确定"/>

    <Button
        android:id="@+id/cancel"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginEnd="40dp"
        android:layout_marginStart="40dp"
        android:text="取消"/>

</merge>

此时布局结构如下:

可以看到,这里去除了多余的嵌套。

注意事项

  1. 如果一个布局文件的根容器是 FrameLayout,且没有设置 backgroundpadding 等属性,那么完全可以使用 <merge> 来替代它。因为 Activity 的默认 ContentView 外层本身就是一个 FrameLayout,此时再嵌套一层 FrameLayout 会造成多余的层级。使用 <merge> 可以让布局内容直接插入到父容器中,从而减少渲染层次,提升性能。
  2. 由于 <merge> 并非一个实际的 View 对象,因此在通过 LayoutInflater.inflate() 手动加载时必须为其指定父容器,并且第三个参数要传入 true,表示将子视图立即附加到父容器中。
  3. <merge> 只能作为布局文件的根节点使用,不能嵌套在其他布局中。如果它出现在非根层级位置,Android Studio 会直接报错或在运行时崩溃。此外,ViewStub 引用的布局文件中禁止使用 <merge> 作为根节点,因为 ViewStub 会通过 inflate() 动态创建视图,而 <merge> 无法独立生成视图对象,这会导致 InflateException 异常。
  4. 与普通布局不同,当使用 <include> 引入一个以 <merge> 为根的布局时,不能在 <include> 标签中重写布局属性(如 layout_widthlayout_height),因为 <merge> 没有自己的根容器,这些属性会被直接忽略。

三、ViewStub

在开发中,我们可能会遇到这样的情况:页面中存在一些在初始化阶段暂时不需要显示的布局。虽然可以通过将它们的可见性设置为 invisiblegone 来隐藏,但这些布局在界面加载时依然会被解析与创建,从而增加页面的初始化开销。为了解决这一问题,Android 提供了一个轻量级的解决方案 —— ViewStub。它是一个不可见、尺寸为 0 的占位视图,具备 懒加载(延迟加载) 的特性。ViewStub 虽然存在于视图层级结构中,但只有在调用 setVisibility()inflate() 方法时才会真正加载并替换成目标布局,因此不会影响页面的初始渲染性能。

举个栗子:

extra_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <EditText
        android:id="@+id/et_1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginEnd="40dp"
        android:layout_marginStart="40dp"
        android:hint="学号"/>

    <EditText
        android:id="@+id/et_2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginEnd="40dp"
        android:layout_marginStart="40dp"
        android:hint="班级"/>

</LinearLayout>

这里设置两个输入框,作为要延迟加载的布局。

布局中使用:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <include layout="@layout/my_title_layout"/>

    <EditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="姓名"
        android:layout_marginTop="40dp"
        android:layout_marginStart="40dp"
        android:layout_marginEnd="40dp"/>
    
    <Button
        android:id="@+id/btn_more"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="更多"
        android:layout_gravity="end"/>
    
    <ViewStub
        android:id="@+id/view_stub"
        android:layout="@layout/extra_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <include layout="@layout/my_choice_layout"/>

</LinearLayout>

在代码中加载:

public class MainActivity extends AppCompatActivity {
    
    private EditText editText1;
    private EditText editText2;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_main);
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });

        Button button = (Button) findViewById(R.id.btn_more);
        button.setOnClickListener(v -> {
            ViewStub viewStub = (ViewStub) findViewById(R.id.view_stub);
            if(viewStub != null) {
                View view = viewStub.inflate();
                editText1 = view.findViewById(R.id.et_1);
                editText2 = view.findViewById(R.id.et_2);
            }
        });
    }
}

运行程序,效果如下:

注意事项

  1. 由于 ViewStub 不是一个实际的视图容器,因此它在加载布局时不支持使用 <merge> 作为根布局。因此这有可能导致加载出来的布局存在着多余的嵌套结构。

  2. ViewStub 的懒加载机制决定了它在第一次调用 inflate() 或设置 setVisibility(View.VISIBLE) 后会被实际布局替换,并从视图树中移除。因此,同一个 ViewStub 不能被重复加载

    如果第二次调用 inflate(),系统会抛出 IllegalStateException 异常。

    若需多次显示该布局,建议保存 inflate() 返回的视图引用,通过 setVisibility() 控制显示与隐藏。

  3. 虽然 ViewStub 自身不参与绘制,也几乎不占用空间,但它仍然是一个有效的视图占位符。因此,布局文件中若未显式声明 android:layout_widthandroid:layout_height,系统在解析时会抛出异常。

源码分析

inflate() 方法分析
public View inflate() {
    final ViewParent viewParent = getParent();
    
    // 前置条件检查
    if (viewParent != null && viewParent instanceof ViewGroup) {
        if (mLayoutResource != 0) {
            final ViewGroup parent = (ViewGroup) viewParent;
            // 1. 创建视图但不立即添加
            final View view = inflateViewNoAdd(parent);
            // 2. 用新视图替换自身
            replaceSelfWithView(view, parent);
            // 3. 保存弱引用并触发回调
            mInflatedViewRef = new WeakReference<>(view);
            if (mInflateListener != null) {
                mInflateListener.onInflate(this, view);
            }
            return view;
        } else {
            throw new IllegalArgumentException("ViewStub must have a valid layoutResource");
        }
    } else {
        throw new IllegalStateException("ViewStub must have a non-null ViewGroup viewParent");
    }
}
  • 前置条件检查
    • 必须确保 ViewStub 已经添加到父容器中(getParent() != null
    • 必须设置了有效的布局资源(mLayoutResource != 0
    • 注意事项:如果 ViewStub 还没有被添加到视图树中就调用 inflate(),会抛出 IllegalStateException
  • 视图创建阶段inflateViewNoAdd(parent)):
    • 使用 LayoutInflater.inflate(mLayoutResource, parent, false) 创建目标视图
    • 第三个参数为 false 表示不立即添加到父容器,避免重复添加
    • 注意事项:这里只是创建了视图对象,还没有添加到视图树中
  • 视图替换阶段replaceSelfWithView(view, parent)):
    • 获取 ViewStub 在父容器中的位置索引
    • 从父容器中移除 ViewStub 自身(parent.removeViewInLayout(this)
    • 将新创建的视图添加到 ViewStub 原来的位置
    • 注意事项ViewStub 只能被 inflate 一次,因为调用后它就被从视图树中移除了
  • 引用保存与回调
    • 使用 WeakReference 保存 inflate 出来的视图引用
    • 触发 OnInflateListener 回调
    • 注意事项不要依赖这个弱引用,因为内存紧张时可能被 GC 回收,应该自己保存强引用

inflateViewNoAdd() 创建视图但不添加:

private View inflateViewNoAdd(ViewGroup parent) {
    final LayoutInflater factory;
    if (mInflater != null) {
        factory = mInflater;
    } else {
        factory = LayoutInflater.from(mContext);
    }
    // inflate但不attach到parent,避免重复添加
    final View view = factory.inflate(mLayoutResource, parent, false);
    // 设置inflated ID
    if (mInflatedId != NO_ID) {
        view.setId(mInflatedId);
    }
    return view;
}
  • 这里使用 inflate(..., false) 确保视图不会立即被添加到父容器
  • 如果设置了 mInflatedId,会为目标视图设置这个 ID,方便后续查找

replaceSelfWithView() 替换操作:

private void replaceSelfWithView(View view, ViewGroup parent) {
    // 获取当前ViewStub在父容器中的位置
    final int index = parent.indexOfChild(this);
    // 从父容器中移除ViewStub
    parent.removeViewInLayout(this);
    // 获取ViewStub的LayoutParams
    final ViewGroup.LayoutParams layoutParams = getLayoutParams();
    // 将新视图添加到原来ViewStub的位置
    if (layoutParams != null) {
        parent.addView(view, index, layoutParams);
    } else {
        parent.addView(view, index);
    }
}
  • ViewStub 的 LayoutParams(宽高、边距等)会传递给 inflate 出来的视图
  • 新视图会完全替换 ViewStub 在视图树中的位置
setVisibility() 方法分析
public void setVisibility(int visibility) {
    if (mInflatedViewRef != null) {
        // 情况1:已经inflate过
        View view = mInflatedViewRef.get();
        if (view != null) {
            view.setVisibility(visibility);
        } else {
            throw new IllegalStateException("setVisibility called on un-referenced view");
        }
    } else {
        // 情况2:还未inflate
        super.setVisibility(visibility);
        if (visibility == VISIBLE || visibility == INVISIBLE) {
            inflate();  // 触发inflate
        }
    }
}
  • 已 inflate 的情况
    • 如果 ViewStub 已经 inflate 过,直接设置目标视图的可见性
    • 通过弱引用获取之前 inflate 的视图
    • 注意事项:如果弱引用已被 GC 回收,会抛出 IllegalStateException,这就是为什么要自己保存强引用
  • 未 inflate 的情况
    • 调用父类的 setVisibility(visibility),但 ViewStub 本身是不可见的,所以这个调用实际上没有视觉效果
    • 当设置为 VISIBLEINVISIBLE 时,自动触发 inflate() 方法
    • 注意事项:设置为 GONE 时不会触发 inflate,这是为了性能考虑
  • 自动触发机制
    • setVisibility(VISIBLE)setVisibility(INVISIBLE) 都会触发 inflate
    • setVisibility(GONE) 不会触发 inflate
    • 注意事项:这意味着你不能通过 setVisibility(GONE) 来取消一个还未 inflate 的 ViewStub

总结

  • include 主要用于布局复用,将公共的布局部分提取出来,在多个地方重复使用。
  • merge 主要用于减少布局层级,消除不必要的 ViewGroup,优化布局性能。
  • ViewStub 主要用于按需加载布局,提高初始布局性能,只有在需要时才加载视图。