理解 ViewStub 原理

6,182 阅读9分钟

本人只是Android小菜一个,写技术文档只是为了总结自己在最近学习到的知识,从来不敢为人师,如果里面有些不正确的地方请大家尽情指出,谢谢!

本文基于原生Android 9.0源码来解析 ViewStub的实现原理

android/view/ViewStub.java
android/view/View.java

1. 概述

在进行Android程序开发时,除了要实现基本功能外,还要关注程序的性能,例如使用更少的内存、消耗更少的电量、更快地响应用户操作以及更快地启动显示等等。这个特点注定在我们平时工作中,有很大一部分精力都在进行性能优化,其中一个优化方向就是让程序在尽可能短的时间内启动并显示,让用户感觉不到延迟,保证良好的用户体验。

“懒加载”就是为了让程序尽可能快地启动而提出的一个优化策略,即让那些对用户不重要或者不需要立即显示的布局控件做延迟加载,只在需要显示的时候才进行加载,这样就可以让程序在启动显示的过程中加载更少的控件,占用更少的内存空间,从而更快启动并显示。“懒加载”策略的具体实现方式多种多样,Android系统也提供了一种用于实现布局控件懒加载的工具ViewStub,它能够让相关布局在显示时再进行加载,从而提升程序启动速度。

本文先简单介绍ViewStub的使用方法,再介绍其实现“懒加载”的原理,以帮助大家加深对它的理解。

2. ViewStub 使用方法

在讲解ViewStub的使用方法前,按照惯例,我们还是先来看看它的声明:

/**
 * A ViewStub is an invisible, zero-sized View that can be used to lazily inflate
 * layout resources at runtime.
 *
 * When a ViewStub is made visible, or when {@link #inflate()}  is invoked, the layout resource 
 * is inflated. The ViewStub then replaces itself in its parent with the inflated View or Views.
 * Therefore, the ViewStub exists in the view hierarchy until {@link #setVisibility(int)} or
 * {@link #inflate()} is invoked.
 * ......
 */
public final class ViewStub extends View { ... }

从这段介绍中可以知道ViewStub是一个不可见并且大小为0的控件,其作用就是用来实现布局资源的“懒加载”,当调用setVisibility()或者inflate()时,和ViewStub相关的布局资源就会被加载并在控件层级结构中替代ViewStub,同时ViewStub会从控件层级结构中移除,不再存在。

现在再来看下它的使用方法,首先需要在布局文件配置:

<ViewStub
    android:id="@+id/view_stub_id"
    android:layout="@layout/view_stub"
    android:inflatedId="@+id/view_stub_id"
    android:layout_width="200dp"
    android:layout_height="50dp" />

由于ViewStub是直接继承自View的,所以它在xml里的基本使用方法和其他控件是一样的,只是有一些重要属性需要注意,其中android:layout指的是真正需要加载的布局资源,android:inflatedId指的是布局资源被加载后的View ID,总结如下:

属性 含义/作用 属性级别 是否可选
android:id ViewStub 在布局文件中的ID,用于在代码中访问。 View 共有 必写
android:layout 在显示 ViewStub 时真正加载并显示的布局文件 ViewStub 特有 必写
android:inflatedId 真正布局文件加载后创建的控件ID ViewStub 特有 可选

xml中定义ViewStub后就可以在代码里直接使用并根据具体业务逻辑在需要显示的时候对其进行加载:

ViewStub viewStub = (ViewStub) findViewById(R.id.view_stub_id);
if (viewStub != null) {
    // 调用 inflate 加载真正的布局资源并返回创建的 View 对象
    View inflatedView = viewStub.inflate();
    // 在得到真正的 View 对象后,就可以和直接加载的控件一样使用了。
    TextView textView = inflatedView.findViewById(R.id.view_stub_textview);
}

ViewStub的“懒加载”能起多大效果,取决于是否能在最合适的时机显示它,由于每个模块的业务逻辑不同,其最合适的显示时机也不相同,但基本的原则就是在使用它的前一刻进行加载,这才能使ViewStub的"懒加载"作用最大化,也才能使性能最好。

3. ViewStub 原理解析

3.1 构造过程

前面在ViewStub的声明中看到它是一个不可见且大小为0的控件,那么是如何做到这点的呢?首先看下它的构造过程:

public final class ViewStub extends View {
    // 在 xml 中定义的 android:inflatedId 值,用于加载后的 View Id。
    private int mInflatedId;
    // 在 xml 中定义的 android:layout 值,是需要真正加载的布局资源。
    private int mLayoutResource;
    // 保存布局创建的 View 弱引用,方便在 setVisibility() 函数中使用。
    private WeakReference<View> mInflatedViewRef;

    // 布局加载器
    private LayoutInflater mInflater;
    // 布局加载回调接口,默认为空。
    private OnInflateListener mInflateListener;

    public ViewStub(Context context) {
        this(context, 0);
    }

    /**
     * Creates a new ViewStub with the specified layout resource.
     *
     * @param context The application's environment.
     * @param layoutResource The reference to a layout resource that will be inflated.
     */
    public ViewStub(Context context, @LayoutRes int layoutResource) {
        this(context, null);

        mLayoutResource = layoutResource;
    }

    public ViewStub(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ViewStub(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public ViewStub(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context);

        final TypedArray a = context.obtainStyledAttributes(attrs,
                R.styleable.ViewStub, defStyleAttr, defStyleRes);
        // 获取 xml 中定义的 android:inflatedId 和 android:layout 属性值
        mInflatedId = a.getResourceId(R.styleable.ViewStub_inflatedId, NO_ID);
        mLayoutResource = a.getResourceId(R.styleable.ViewStub_layout, 0);
        // 获取 ViewStub 在 xml 中定义的 id 值
        mID = a.getResourceId(R.styleable.ViewStub_id, NO_ID);
        a.recycle();

        // 设置 ViewStub 控件的显示属性,直接设置为不显示。
        setVisibility(GONE);
        // 设置 ViewStub 不进行绘制
        setWillNotDraw(true);
    }
    
    ...
    
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 设置宽高都为 0
        setMeasuredDimension(0, 0);
    }

    @Override
    public void draw(Canvas canvas) {
        // 空方法,不进行任何绘制。
    }

    @Override
    protected void dispatchDraw(Canvas canvas) {
    }
}

ViewStub的构造过程比较简单,相信很容易看懂,小菜也在代码的关键点都增加了注释,从中可以发现几点关键信息:

  1. ViewStub的不可见性:在ViewStub的构造函数中,利用setVisibility(GONE)将可见性设置为不可见,所以无论在 xml里如何设置,都是不可见的。
  2. ViewStub的不绘制性:在 ViewStub的构造函中,利用setWillNotDraw(true)使其不进行绘制并且把draw()实现为空方法,这些都保证了ViewStub在加载的时候并不会进行实际的绘制工作。
  3. ViewStub的零大小性:在onMeasure()中把宽高都直接指定为0,保证了其大小为0。

正是由于ViewStub的这些特性,其加载过程几乎不需要时间,可以认为它的存在不会对相关程序的启动产生影响。

3.2 懒加载过程

前面提到:在调用inflate()或者setVisibility()时,ViewStub才会加载真正的布局资源并在控件层级结构中替换为真正的控件,同时ViewStub从控件层级结构中移除,这是“懒加载”的核心思想,是如何实现的呢?既然是调用inflate()setVisibility(),就直接分析相关代码。

首先看下inflate()函数代码:

/**
 * Inflates the layout resource identified by {@link #getLayoutResource()}
 * and replaces this StubbedView in its parent by the inflated layout resource.
 *
 * @return The inflated layout resource.
 *
 */
public View inflate() {
    // 获取 ViewStub 在控件层级结构中的父控件。
    final ViewParent viewParent = getParent();

    if (viewParent != null && viewParent instanceof ViewGroup) {
        if (mLayoutResource != 0) {
            final ViewGroup parent = (ViewGroup) viewParent;
            // 根据 android:layout 指定的 mLayoutResource 加载真正的布局资源,渲染成 View 对象。
            final View view = inflateViewNoAdd(parent);
            // 在控件层级结构中把 ViewStub 替换为新创建的 View 对象。
            replaceSelfWithView(view, parent);

            // 保存 View 对象的弱引用,方便其他地方使用。
            mInflatedViewRef = new WeakReference<>(view);
            // 渲染回调,默认不存在。
            if (mInflateListener != null) {
                mInflateListener.onInflate(this, view);
            }
            // 返回新创建的 View 对象
            return view;
        } else {
            // 如果没有在 xml 指定 android:layout 会走到这个路径,所以 android:layout 是必须指定的。
            throw new IllegalArgumentException("ViewStub must have a valid layoutResource");
        }
    } else {
        // 在第一次调用 inflate() 后,ViewStub 会从控件层级结构中移除,不再有父控件,
        // 此后再调用 inflate() 会走到这个路径,所以 inflate() 只能调用一次。
        throw new IllegalStateException("ViewStub must have a non-null ViewGroup viewParent");
    }
}

private View inflateViewNoAdd(ViewGroup parent) {
    // 获取布局渲染器
    final LayoutInflater factory;
    if (mInflater != null) {
        factory = mInflater;
    } else {
        factory = LayoutInflater.from(mContext);
    }
    // 把真正需要加载的布局资源渲染成 View 对象。
    final View view = factory.inflate(mLayoutResource, parent, false);
    // 如果在 xml 中指定 android:inflatedId 就设置到新创建的 View 对象中,可以不指定。
    if (mInflatedId != NO_ID) {
        view.setId(mInflatedId);
    }
    return view;
}

private void replaceSelfWithView(View view, ViewGroup parent) {
    final int index = parent.indexOfChild(this);
    // 把 ViewStub 从控件层级中移除。
    parent.removeViewInLayout(this);

    // 把新创建的 View 对象加入控件层级结构中,并且位于 ViewStub 的位置,
    // 并且在这个过程中,会使用 ViewStub 的布局参数,例如宽高等。
    final ViewGroup.LayoutParams layoutParams = getLayoutParams();
    if (layoutParams != null) {
        parent.addView(view, index, layoutParams);
    } else {
        parent.addView(view, index);
    }
}

inflate()函数详细地展示了如何渲染布局资源以及如何在控件层级结构中把ViewStub替换为新创建的View对象,代码比较简单,小菜也在关键地方增加了注释。

再来看下setVisibility()函数代码:

/**
 * When visibility is set to {@link #VISIBLE} or {@link #INVISIBLE},
 * {@link #inflate()} is invoked and this StubbedView is replaced in its parent
 * by the inflated layout resource. After that calls to this function are passed
 * through to the inflated view.
 *
 * @param visibility One of {@link #VISIBLE}, {@link #INVISIBLE}, or {@link #GONE}.
 *
 * @see #inflate() 
 */
@Override
@android.view.RemotableViewMethod(asyncImpl = "setVisibilityAsync")
public void setVisibility(int visibility) {
    if (mInflatedViewRef != null) {
        // 如果已经调用过 inflate() 函数,mInflatedViewRef 会保存新创建 View 对象的弱引用,
        // 此时直接更新其可见性即可。
        View view = mInflatedViewRef.get();
        if (view != null) {
            view.setVisibility(visibility);
        } else {
            throw new IllegalStateException("setVisibility called on un-referenced view");
        }
    } else {
        // 如果没有调用过 inflate() 函数就会走到这个路径,会在设置可见性后直接调用 inflate() 函数。
        super.setVisibility(visibility);
        if (visibility == VISIBLE || visibility == INVISIBLE) {
            inflate();
        }
    }
}

setVisibility()的代码逻辑也很简单:如果inflate()已经被调用过就直接更新控件可见性,否则更新可见性并调用inflate()加载真正的布局资源,渲染成 View 对象。

4. 总结

一些布局控件在开始时并不需要显示,在程序启动后再根据业务逻辑进行显示,通常的做法是在 xml中将其定义为不可见,然后在代码中通过setVisibility()更新其可见性,但是这样做会对程序性能产生不利影响,因为虽然该控件的初始状态是不可见,但仍然会在程序启动时进行创建和绘制,增加了程序的启动时间。正是由于这种情况的存在,Android系统提供了ViewStub框架,能够很容易实现“懒加载”以提升程序性能,本文从“使用方法”和“实现原理”两个方面对其进行讲解,希望能对大家有所帮助。