Android 之路 (15) - 骨架状态布局(SkeletonLayout)的实现思路与封装

  aohanyao

前言

废话不多说,先看看成果物,如果有兴趣了再往下看,不感兴趣就直接关闭就行。

演示

骨架状态布局(SkeletonLayout),前几年开始流行至今,已经是大多数App的标配了。以往的做法是在页面开始请求的时候弹出一个LoadingDialog,失败的时候又弹出一个ConfirmDialog。而Dialog也需要和网络请求绑在一起,用户返回上一页,需要关闭LoadingDialog,并且要取消网络请求,一不注意就会造成内存泄露,整个过程也非常的繁琐。

骨架图就相对于方便一点,整个loading和error的提示都在页面上做的。对于用户来讲,用户需要取消,直接返回上一页就行,没有多余的操作。对于开发来说,更加方便封装和控制,减少内存泄露的风险。

正文

分析

一般骨架图都是继承并实现一个SkeletonLayout,先解析内容的布局放到第0个,后面再将自己的各种状态layout实例化出来,add到后面,遮盖住真正的内容,就像叠卡片一样。
而对于SkeletonLayout的使用有两种方式:xml引用和基类代码中封装,这两种方式均,没有太大的区别。xml应用是为了拓展、移植,而本系列是为了封装属于自己的基类库,所以选择直接将SkeletonLayout封装在CadnyBase,也是为了后续的封装打基础。

编码

确定状态

初步定义三个简单的状态:
- Loading:加载中,页面第一次加载数据的时候使用
- Empty:空状态,无数据的情况下使用
- Retry:请求发生错误,需要再次点击重试的情况下使用

我们先以这三种状态为基础,后续再进行扩充。

创建状态Layout

关于loading的动画使用的是AVLoadingIndicatorView

另外为了阅读方便,xml部分的代码进行了折叠,三个layout也是直接从网络上找的样式,也没有什么难度, 具体根据实际项目需求来编写,唯一要注意的是根布局需要设置clickable为truefocusable为true,直接把事件消费掉,否则会传递到真正的内容布局中。

skeleton_base_loading
展开查看skeleton_base_loading代码


    
<com.wang.avi.AVLoadingIndicatorView
    android:id="@id/mSkeletonLoading"
    android:layout_width="100dp"
    android:layout_height="50dp"
    android:layout_centerHorizontal="true"
    android:layout_gravity="center_horizontal"
    android:layout_marginTop="50dp"
    android:visibility="visible"
    app:indicator="BallPulse"
    app:indicator_color="#03a4f9" />

skeleton_base_loading预览图

skeleton_base_empty
展开查看skeleton_base_empty 代码


<ImageView
    android:id="@id/mSkeletonEmpty"
    android:layout_width="180dp"
    android:layout_height="220dp"
    android:layout_marginTop="50dp"
    android:layout_gravity="center"
    android:src="@drawable/skeleton_empty_image" />

<TextView
    android:id="@id/mSkeletonEmptyText"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/candy_empty"
    android:textColor="#ddd" />

skeleton_base_empty预览

skeleton_base_retry
展开查看 skeleton_base_retry 代码



<ImageView
    android:id="@id/mSkeletonRetry"
    android:layout_width="180dp"
    android:layout_height="220dp"
    android:layout_centerHorizontal="true"
    android:layout_gravity="center_horizontal"
    android:src="@drawable/skeleton_retry_image" />

<TextView
    android:id="@id/mSkeletonRetryText"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginLeft="56dp"
    android:layout_marginTop="10dp"
    android:layout_marginRight="56dp"
    android:layout_marginBottom="16dp"
    android:gravity="center"
    android:lineSpacingExtra="5.5dp"
    android:text="@string/candy_retry"
    android:textColor="#ddd" />

skeleton_base_retry预览

创建SkeletonLayout

layout已经创建好,接下来就是创建SkeletonLayout,将三个状态布局解析出来增加到SkeletonLayout中,代码也不复杂,都是在构造方法中节选布局。

public class SkeletonLayout extends FrameLayout {

    /**加载中布局*/
    private View mLoadingLayout = null;
    /**重试布局*/
    private View mRetryLayout = null;
    /**空布局*/
    private View mEmptyLayout = null;
    public SkeletonLayout(@NonNull Context context) {
        super(context);
        initSkeletonLayout();
		 // 不显示布局
        switchSkeleton(null);
    }

    /**
     * 初始化骨架布局
     */
    private void initSkeletonLayout() {
        FrameLayout.LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        setLayoutParams(layoutParams);
        initRetryView();
        initLoadingView();
        initEmptyView();
    }

    private void initLoadingView() {
        if (mLoadingLayout != null) {
            removeView(mLoadingLayout);
        }
        mLoadingLayout = View.inflate(getContext(), mLoadingLayoutId, null);
        addView(mLoadingLayout);
    }

    private void initEmptyView() {
        if (mEmptyLayout != null) {
            removeView(mEmptyLayout);
        }
        mEmptyLayout = View.inflate(getContext(), mEmptyLayoutId, null);
        addView(mEmptyLayout);
    }

    private void initRetryView() {
        if (mRetryLayout != null) {
            removeView(mRetryLayout);
        }
        mRetryLayout = View.inflate(getContext(), mRetryLayoutId, null);
        addView(mRetryLayout);
    }
	/**
     * 切换布局状态,进行显示和隐藏
     *
     * @param skeletonView 需要显示的布局
     */
    private void switchSkeleton(View skeletonView) {
        if (mLoadingLayout != null) {
            mLoadingLayout.setVisibility(skeletonView == mLoadingLayout ? VISIBLE : GONE);
        }
        if (mEmptyLayout != null) {
            mEmptyLayout.setVisibility(skeletonView == mEmptyLayout ? VISIBLE : GONE);
        }
        if (mRetryLayout != null) {
            mRetryLayout.setVisibility(skeletonView == mRetryLayout ? VISIBLE : GONE);
        }
    }
}
定义接口及回调

为了让外部能够控制状态的切换,及重试图标点击的回调,需要定义一个接口来约束,主要有以下四个方法:

    public interface OnSkeletonListener {
        /**显示loading状态*/
        void showSkeletonLoading();
        /**显示重试状态,请求失败的时候使用*/
        void showSkeletonRetry();
        /**隐藏所有状态,现在主内容*/
        void showSkeletonContent();
        /**显示空状态,没有数据的时候使用*/
        void showSkeletonEmpty();
        /**重试状态下被点击,用来确认下一步操作*/
        void onSkeletonRetry();
    }
实现接口及操作

接口相关都定义好了,接下来就是进行实现,不同的方法下隐藏其它的布局,只显示当前布局,另外为了命名域方便,将接口定义在了SkeletonLayout中,全部代码如下:

public class SkeletonLayout extends FrameLayout {
     /**加载中布局*/
    private View mLoadingLayout = null;
    /**重试布局*/
    private View mRetryLayout = null;
    /**空布局*/
    private View mEmptyLayout = null;
    /**状态监听 */
    private OnSkeletonListener onSkeletonListener;

    public SkeletonLayout(@NonNull Context context) {
        super(context);
        initSkeletonLayout();
    }

    /**
     * 初始化骨架布局
     */
    private void initSkeletonLayout() {
        FrameLayout.LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        setLayoutParams(layoutParams);
        initRetryView();
        initLoadingView();
        initEmptyView();
        // 不显示布局
        switchSkeleton(null);
    }


    private void initLoadingView() {
        if (mLoadingLayout != null) {
            removeView(mLoadingLayout);
        }
        mLoadingLayout = View.inflate(getContext(), mLoadingLayoutId, null);
        addView(mLoadingLayout);
    }

    private void initEmptyView() {
        if (mEmptyLayout != null) {
            removeView(mEmptyLayout);
        }
        mEmptyLayout = View.inflate(getContext(), mEmptyLayoutId, null);
        addView(mEmptyLayout);
    }

    private void initRetryView() {
        if (mRetryLayout != null) {
            removeView(mRetryLayout);
        }
        mRetryLayout = View.inflate(getContext(), mRetryLayoutId, null);
        addView(mRetryLayout);

        // 点击事件并回调
        View mSkeletonRetry = mRetryLayout.findViewById(R.id.mSkeletonRetry);
        if (mSkeletonRetry != null) {
            mSkeletonRetry.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    if (onSkeletonListener != null) {
                        onSkeletonListener.onSkeletonRetry();
                    }
                }
            });
        }
    }

    public void showSkeletonLoading() {
        switchSkeleton(mLoadingLayout);
    }

    public void showSkeletonRetry() {
        switchSkeleton(mRetryLayout);
    }

    public void showSkeletonContent() {
        switchSkeleton(null);
    }

    public void showSkeletonEmpty() {
        switchSkeleton(mEmptyLayout);
    }

    /**
     * 切换布局状态,进行显示和隐藏
     *
     * @param skeletonView 需要显示的布局
     */
    private void switchSkeleton(View skeletonView) {
        if (mLoadingLayout != null) {
            mLoadingLayout.setVisibility(skeletonView == mLoadingLayout ? VISIBLE : GONE);
        }
        if (mEmptyLayout != null) {
            mEmptyLayout.setVisibility(skeletonView == mEmptyLayout ? VISIBLE : GONE);
        }
        if (mRetryLayout != null) {
            mRetryLayout.setVisibility(skeletonView == mRetryLayout ? VISIBLE : GONE);
        }
    }
    /**
     * 回调接口
     */
    public interface OnSkeletonListener {
        /**显示loading状态*/
        void showSkeletonLoading();
        /**显示重试状态,请求失败的时候使用*/
        void showSkeletonRetry();
        /**隐藏所有状态,现在主内容*/
        void showSkeletonContent();
        /**显示空状态,没有数据的时候使用*/
        void showSkeletonEmpty();
        /**重试状态下被点击,用来确认下一步操作*/
        void onSkeletonRetry();
    }
}
封装SkeletonLayout到CandyBase

让CandyBase实现OnSkeletonListener接口,并且开放一个initSkeletonLayout(@LayoutRes int layoutId)方法给子类,让需要骨架的之类调用该方法进行初始化,OnSkeletonListener的实现方法也只是调用SkeletonLayout的实现方法,全部代码如下:

	/**
     * 骨架图
     */
    private SkeletonLayout mSkeletonLayout;
	/**
     * 初始化骨架图,需要骨架的地方才会进行初始化,传入的是Activity的根布局
     * @param layoutId 根布局
     * @return 解析好的根布局,增加了骨架图在原本的ViewGroup上面
     */
    protected View initSkeletonLayout(@LayoutRes int layoutId) {
        ViewGroup contentView = (ViewGroup) LayoutInflater.from(mActivity).inflate(layoutId, null, false);
        mSkeletonLayout = new SkeletonLayout(mActivity);
        contentView.addView(mSkeletonLayout);
        mSkeletonLayout.setOnSkeletonListener(this);
        return contentView;
    }
    public void showSkeletonLoading() {
        if (mSkeletonLayout != null) {
            mSkeletonLayout.showSkeletonLoading();
        }
    }
    public void showSkeletonRetry() {
        if (mSkeletonLayout != null) {
            mSkeletonLayout.showSkeletonRetry();
        }
    }
    public void showSkeletonContent() {
        if (mSkeletonLayout != null) {
            mSkeletonLayout.showSkeletonContent();
        }
    }
    public void showSkeletonEmpty() {
        if (mSkeletonLayout != null) {
            mSkeletonLayout.showSkeletonEmpty();
        }
    }
    @Override
    public void onSkeletonRetry() {
        //重试点击
    }
使用

使用方式也很简单,只需要将setContentView的值换成initSkeletonLayout方法初始化后的值即可,如下:

protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(initSkeletonLayout(R.layout.activity_skeleton_layout));
}

根据不同的需求调用OnSkeletonListener接口不同的方法。

演示

演示

优化
抽取ID & 适配不同项目

SkeletonLayout是创建在library-core里面中,对其它项目提供的也是打包好的aar,基本上是不可改变的,遇到需要不同项目需要不同状态布局的情况,我们需要将布局里面的id抽取出来,依赖library-core的项目只需要在layout下创建同名的状态布局layout文件,复用定义好的id,这样在构建的时候就会以我们创建的为主,进行替换。

以下是抽取的ID:

<!--状态布局中的ids-->
    <!--空布局文字-->
    <item name="mSkeletonEmptyText" type="id" />
    <!--空布局图片-->
    <item name="mSkeletonEmpty" type="id" />
    <!--加载中的文字-->
    <item name="mSkeletonLoadingText" type="id" />
    <!--loading-->
    <item name="mSkeletonLoading" type="id" />
    <!--重试-->
    <item name="mSkeletonRetry" type="id" />
    <!--错误提示-->
    <item name="mSkeletonRetryText" type="id" />
<!---->
适配单页面不同样式

前面针对单项目的样式进行适配,现在需要对单个页面需要不同样式进行适配,方法也简单,为每种布局提供setLayout的方法,设置完成后再解析add就行,然后在相应的子类Activity或者Fragment里面设置即可,代码如下:

    public void setLoadingLayoutId(@LayoutRes int mLoadingLayoutId) {
        this.mLoadingLayoutId = mLoadingLayoutId;
        initLoadingView();
    }
    public void setRetryLayoutId(@LayoutRes int mRetryLayoutId) {
        this.mRetryLayoutId = mRetryLayoutId;
        initRetryView();
    }
    public void setEmptyLayoutId(@LayoutRes int mEmptyLayoutId) {
        this.mEmptyLayoutId = mEmptyLayoutId;
        initEmptyView();
    }

演示,为了演示方便,在Activity中随机了一个布尔值,设置不同的layout。

优化演示1

结束

总结

骨架状态布局的实现和封装并不是特别复杂,只需要要一点思路,然后慢慢实现即可,这次的封装属于耦合性强的封装,主要是为了项目开发方便,也有一些需要注意的坑:
- 各个状态布局需要预留顶部ToolBar、状态栏的高度
- SkeletonLayout要手动设置高度和宽度
- 需要将id抽取出来,以便重写布局的时候使用

源码
参考

软广

来都来了,就给个关注吧,时不时会悄悄的推送一些小技巧的文章~~!
FullScreenDeveloper