对多状态视图框架的思考

时间:2023-02-01 18:14:53

在项目中都能碰到空载视图,加载视图,错误视图等等不同的情况,在这些情况下,还有设置多状态视图的位置的问题。总之来说是比较麻烦的问题。在本次问题中我们来看一个多状态视图框架需要什么或者说如何打造一个多状态视图框架。

在此处就不加图了,想必大家对于什么是多状态视图都很明白。在项目中,我们大多会在开发初期就把这套多状态视图框架写好,然后其他人直接使用即可,一般的形式是:

<com.longshihan.lh.ui.StatusLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/statuslayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#555555"
tools:context="com.longshihan.testlh.MainActivity">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"/>
</com.longshihan.lh.ui.StatusLayout>

但是这样写你真的明白精髓吗?下面就是我们所谈论的内容。

为下文方便描述,将其命名为StatusLayout,这个也就是本文的主角。

1.实现方案

总的来说大致的思路是将继承一个viewgroup作为父类,将不同的view,add进父类中,在不同的状态下控制不同的职能view的显示。基本原理是这样的,但这样处理会对原始布局具有入侵性,在下面会描述如何非入侵式的添加修改布局。
- 如何选择合适的viewgroup

首先我们可以明确FrameLayout、RelativeLayout、LinearLayout都可以作为StatusLayout的父布局,在这里我们只从view绘制的角度去看, 众所周知RelativeLayout在layout时需要measure两次,所以从有多个状态的view的控制显示和隐藏的话,不太合适。对于LinearLayout和FrameLayout来说,虽然都可以,但我还是倾向于FrameLayout,因为线性布局总是对于这种复杂场景的局限性的。

  • 如何处理多状态的view

对于多状态的view,我们将主逻辑放在主页面下,对于其他状态的view的出现次数不定,而且有时不会出现这个view,所以在这种情况下动态的实现addview和removeview是一种比较好的选择,对于不太容易出现的view使用ViewStub懒加载的方式加载。


做到只有使用,才能加载。尽量的使页面的叠加层不会因此增多。

  • 多种情况

要明确在实现一个框架之前要实现的功能:
实现的布局:
1. 空载布局
2. 加载布局
3. 错误布局

实现的位置:
1. 全局替换
2. 指定位置替换
3. 弹出框显示

拓展(具有类似功能的布局样式):
1. 引导操作界面

  • 使用方式

    在这里肯定优先Builder模式,相信都用过Glide,对Builder的操作方式感觉很方便吧。

    Glide.with(mContext)
    .load(url)
    .placeholder(R.drawable.loading_spinner)
    .crossFade()
    .into(myImageView);

很多人都见过这种模式或者用过,都表示不好写,太繁琐了。既然繁琐为什么不用工具呢,大家可以写一个插件用来将javabean的代码转成builder。但是在Intelij里面内置了添加的方式,在实现setter/getter的便捷键里面有可以一键实现builder。
对多状态视图框架的思考


setter template上选择builder就可以了。对于设置错误视图:

        mStatusLayout.showView(new CustomStateOptions()
.image(R.mipmap.ic_launcher)
.buttonClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(MainActivity.this, "测试", Toast.LENGTH_SHORT).show();
}
})
.error());

在最后可以.error(),.empty(),.loading()三种方式,对应添加不同的视图。在使用的时候直接使用

  mStatusLayout.showErrorView();

就可以了。

2.具体实现

在第一部分已经将编写多状态框架的基本注意点解释清,下面来看具体怎么实现。

1. 设置多状态的基本点

我们设置多状态的builder是这样的:

public class CustomStateOptions {
@DrawableRes
private int imageRes;
private String message;
@IdRes
private int messgaeRes;
private String buttonText;
@IdRes
private int buttonTextRes;
@LayoutRes
private int viewRes;
private boolean isLoading;
private boolean isEmpty;
private boolean isError;
private int listenertype=Status.CONTENTCLICK;
private View.OnClickListener buttonClickListener;
//builder的具体实现(在此省略不写)

}

这种情况下只适合修改默认视图的情况,对于自定义视图,下节有讲,此处省略。

使用方式见上图。下面来谈谈其中的实现:

 private void showCustomview(CustomStateOptions options) {
if (isdialog) {

} else if (isPositionView) {
if (mPositionView != null) {
ViewGroup.LayoutParams layoutParams = mPositionView.getLayoutParams();
for (int i = 0; i < getChildCount(); i++) {
if (getChildAt(i) == mPositionView) {
getChildAt(i).setTag(ViewTAG);
getChildAt(i).setVisibility(GONE);
}
}
ViewGroup viewGroup = (ViewGroup) mPositionView.getParent();
if (viewGroup instanceof RelativeLayout) {
mPositionView.setVisibility(INVISIBLE);
}
setCustomLayoutOption(options, layoutParams);
} else {
for (int i = 0; i < getChildCount(); i++) {
getChildAt(i).setTag(ViewTAG);
getChildAt(i).setVisibility(GONE);
}
setCustomLayoutOption(options, null);
}
} else {
for (int i = 0; i < getChildCount(); i++) {
getChildAt(i).setTag(ViewTAG);
getChildAt(i).setVisibility(GONE);
}
setCustomLayoutOption(options, null);
}
}

在上面简单的代码可以看出,使用dialog方式显示的view,暂时还没有处理,这可能会涉及依赖注入的问题,暂时还有没想好处理方案。下面来看最后一个else代表的含义:将全局界面替换为多状态view,这个没什么需要注意的,就是将内部的view设置一个tag,并隐藏View。现在需要注意的是positionview这种情况,既指定view进行替换。在这里需要注意的是positionview的parent可能是依赖性的布局,比如RelativeLayout,这种布局的控件的位置可能需要其他控件的相对或者是依赖,所以在这里的一个坑就是:


positionview不能直接gone,只能进行INVISIBLE。然后我们拿到了他的LayoutParams(因为不确定其组件的类型,所以用他们的父类,ViewGroup代替,我们将大量用到ViewGroup里面的属性和方法)。
下面进入中间层,也就是确定多状态view的加载。

/**
* 中层分发布局选项
*
* @param options
* @param layoutParams
*/
private void setCustomLayoutOption(CustomStateOptions options, ViewGroup.LayoutParams
layoutParams) {
if (options.isEmpty()) {
emptyoptions = options;
if (options.getViewRes() != 0) {
emptyView = inflater.inflate(options.getViewRes(), null);
} else {
showViewOption(options, Status.EMPTY_VIEW);
}
if (layoutParams != null) {
emptyView.setLayoutParams(layoutParams);
}
} else if (options.isError()) {
erroroptions = options;
if (options.getViewRes() != 0) {
errorView = inflater.inflate(options.getViewRes(), null);
} else {
showViewOption(options, Status.ERROR_VIEW);
}
if (layoutParams != null) {
errorView.setLayoutParams(layoutParams);
}

} else if (options.isLoading()) {
progressoptions = options;
if (options.getViewRes() != 0) {
progressView = inflater.inflate(options.getViewRes(), null);
}
if (layoutParams != null) {
progressView.setLayoutParams(layoutParams);
}

}
}

代码比较简单,就是纯判断,设置layoutParams,显示。写到这里可能就有问的了,一些控件的点击操作在哪里,不急,现在就开始像view上绘制内容和控制点击事件。

private void showViewOption(final CustomStateOptions options, int type) {
if (mOnClickListener != null && mOnClickListener == null) {
setOnClick(options.getButtonClickListener());
}
switch (type) {
case Status.EMPTY_VIEW:
if (options.getImageRes() != 0) {
emptyImageView.setBackgroundResource(options.getImageRes());
}
if (!TextUtils.isEmpty(options.getMessage())) {
emptyTextView.setText(options.getMessage());
}
if (options.getMessgaeRes() != 0) {
emptyTextView.setText(options.getMessgaeRes());
}

switch (options.getListenertype()) {
case Status.CONTENTCLICK:
emptyContentView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
mOnClickListener.onClick(v);
}
});
break;
case Status.TEXTCLICK:
emptyTextView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
mOnClickListener.onClick(v);
}
});
break;
case Status.IMAGECLICK:
emptyImageView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
mOnClickListener.onClick(v);
}
});
break;
case Status.BUTTONCLICK:
emptyTextView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
mOnClickListener.onClick(v);
}
});
break;
default:
break;
}
break;
}
}

上文仅仅是显示了空载view上绘制内容和处理多种情况下的点击事件,其余的错误View以及加载view的样式于此类似,在此不做赘述,在这处的操作仅是接口的回调,没有什么难点,大家看一眼就差不多了。
上面这么缀缀述述就是为了下面的使用简单,下面给大家展示一下用法:

//展示空白图
public void showEmptyView() {
if (emptyoptions != null) {
showCustomview(emptyoptions);
} else {
showCustomview(new CustomStateOptions().empty());
}
showContentParentView(emptyView);
}

在上面直接运行showEmptyView,showErrorView即可。下面的操作都是差不多的。
既然上面已经有了其他插入的view,那么怎么清理呢,下面提供了清理的方法:

 public void cleanallView() {
if (usedviews.size() > 0) {
for (int i = 0; i < usedviews.size(); i++) {
removeView(usedviews.get(i));
}
}
for (int i = 0; i < getChildCount(); i++) {
if (ViewTAG.equals(getChildAt(i).getTag())) {
getChildAt(i).setVisibility(VISIBLE);
}
}
}

从上文可知tag就是在这里用的,还有usedviews是为了保存引用View的引用,方面直接remove。在这里有一个注意点:view.setTag(),里面的参数必须是不可变的量,这个操作在下文还有用到。

2. 引导界面

想必大家都用过Viewpage,不知道大家有没有用过ViewFlipper,可以把它理解为一个缩减版的viewpage或者画廊,用法非常简单,也特别适合当前的场景。
因为这个界面大家用的不多,所以就设置成了viewStub了。这个界面一般来说是面向整个布局的,而且点击是对应的全局点击(或单个控件上),基于以上两点,我设置了如下的操作:
老地方,先看看他的builder模式:

public class CustomGuildeOptions {
private List<View> mViews;
private Context mContext;
private LayoutInflater mInflater;

public CustomGuildeOptions appendView(@LayoutRes int viewids) {
View currentview = mInflater.inflate(viewids, null);
mViews.add(currentview);
return this;
}

public CustomGuildeOptions appendView(View view) {
mViews.add(view);
return this;
}

public CustomGuildeOptions appendView(@LayoutRes int viewids, @IdRes int ids) {
View currentview = mInflater.inflate(viewids, null);
currentview.setTag(R.id.ids,ids);
mViews.add(currentview);
return this;
}

public CustomGuildeOptions appendView(View view, int ids) {
view.setTag(R.id.ids, ids);
mViews.add(view);
return this;
}

//下面还有一些不错赘述,

我们考虑到可能会有操作单个控件的点击(比如显示下一步等),在添加的时候需要将View和他的ID一起添加进来,将指定的ID设置在TAG中,这样他的点击控件就与view绑定在一起。
下面来看看如果加载VIewFilpper:

 /**
* 设置引导布局的参数
*
* @param options
*/
public void showGuildeView(final CustomGuildeOptions options) {
if (mStubView == null) {
mStubView = mGuideView.inflate();
mViewFlipper = (ViewFlipper) mStubView.findViewById(R.id.glideviewflipper);
}
guildeoptions = options;
if (mViewFlipper.getChildCount() == 0) {
for (int i = 0; i < options.getViews().size(); i++) {
View view = options.getViews().get(i);
mViewFlipper.addView(view);
final int finalI = i;
View currentid;
if (view.getTag(R.id.ids) != null) {
try {
currentid = view.findViewById(Integer.valueOf(view.getTag(R.id.ids)
.toString()));
} catch (Exception e) {
currentid = view;
}
} else {
currentid = view;
}
currentid.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (finalI == options.getViews().size() - 1) {
cleanallView();
} else {
mViewFlipper.showNext();
}
}
});
}
}
}

代码的逻辑比较简单,只使用了ViewFilpper的三个方法,向里面添加view,获取长度,以及下一步(一般来说没有上一步的操作)。先获取指定的TAG,判断是否存在,在确定其点击的位置,其中R.id.ids很明显是一个ID,但这个ID在哪定义呢,想必大家通过看其他开源项目的源码会看到ids.xml这个xml配置文件,可能当时很疑问为什么要在这个地方定义一个ID呢,其中的一个原因就在这个,我们定义的ids是:

<item type="id" name="ids"/>

他的显示的方法和显示空载view一致,不在添加重复的代码。现在来看一下用法,也很简单:

   mStatusLayout.showGuildeView(new CustomGuildeOptions(this)
.appendView(R.layout.test1, R.id.testnum)
.appendView(R.layout.test2)
.appendView(R.layout.test3)
);

在Builder模式中出现像上面appendView这个重复操作一般只有两个作用:
1.内部维护了一个list,在后面使用的一直是这个list,类似代码请看OKHTTP的拦截器的添加,内部的就是实现一个拦截器队列。
2.你重复使用,被刷新了,只有最后一个起作用。

3. 无入侵修改布局

所谓的无插桩是指不入侵原代码,保证业务代码比较纯洁。像Aspectj这种看起来是非常友善的无入侵式的(但是做了插桩,在通过build里面可以看到他生成了可执行的class文件),还有ButterKinfe这种,依赖注入的框架,都是生成了一个可执行的class文件。
现在我们现在要做的就是如何实现无入侵的方式:
这个得从window的绘制开始说起:(这个比较复杂,,我也不太懂),直接说我们用到的部分:通过device monitor可以看到页面的viewTree。
对多状态视图框架的思考
可以看到在整个viewd的上层就是这个android.R.id.content了,可能有些童鞋不信,好吧,看源码,源码总有这样的设置。AppCompatActivity作为观测对象,进入setContentView方法,可以看到
对多状态视图框架的思考

对多状态视图框架的思考
在这里关键的就是AppCompatDelegate这个对象,但发现这里面只是一个抽象类,我们通过create进去:
对多状态视图框架的思考
可以看待有很多他的实现对象,那么我们应该进那个呢,大家肯定说是大于14的那个,因为这是我们正在使用的。其实,我们这次的所要找的东西和这些都没关系,最后都会进入AppCompatDelegateImplV7这个类里面(不信的童鞋可以自行查找),我们打开AppCompatDelegateImplV7这个类,去看看实现,我们直接进入setContentView这个方法,可以清晰的看到:
对多状态视图框架的思考
在这里面实现的是android.R.id.content,也就是所能操作的布局的最外层,通过这个代码也能清晰的知道我们所有的布局也是通过这种addView的形式添加上的,最后补充一点,AppCompatDelegateImplV7这类的父类是AppCompatDelegateImplBase,而AppCompatDelegateImplBase是抽象类AppCompatDelegate的实现类,现在终于缕清Activity的继承过程了吗,最终就是由AppCompatDelegateImplBase开始配置的所有的一些响应。
有点跑题了,我们通过上文就可以知道可以通过在android.R.id.content内动态的将我们的多状态布局添加其中,再将原布局添加其中,就可以形成和上面阶段所描述的嵌套过程,只不过这次并没有入侵原有布局。
下面来看看我们所要做的工作,也很简单的,我们在BuildConfig里面添加一个是否配置content的属性,这个builder也是上文没有描述的可以实现添加自定义多状态view的配置项。

先看一下配置id相关:

 private void parseAttrs(Context context, AttributeSet attrs) {
inflater = LayoutInflater.from(context);
//获取最上层的主布局
contentView = ((Activity) mContext).findViewById(android.R.id.content);
emptyView = inflater.inflate(R.layout.empty, null);
emptyContentView = emptyView.findViewById(R.id.emptycontent);
emptyTextView = (TextView) emptyView.findViewById(R.id.emptytxt);
emptyImageView = (ImageView) emptyView.findViewById(R.id.emptyimage);
errorView = inflater.inflate(R.layout.error, null);
errorContentView = errorView.findViewById(R.id.errorcontent);
errorTextView = (TextView) errorView.findViewById(R.id.errortxt);
errorImageView = (ImageView) errorView.findViewById(R.id.errorimage);
progressView = inflater.inflate(R.layout.progress, null);
mflipperview = inflater.inflate(R.layout.viewstubflipper, null);
mGuideView = (ViewStub) mflipperview.findViewById(R.id.stubid);


// TODO: 2017/7/29 添加自适应修改
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.StatusLayout);
String errText = typedArray.getString(R.styleable.StatusLayout_errorText);
String progressText = typedArray.getString(R.styleable.StatusLayout_progressText);
String emptyText = typedArray.getString(R.styleable.StatusLayout_emptyText);
Drawable emptyDrawable = typedArray.getDrawable(R.styleable.StatusLayout_emptyDrawable);
Drawable errorDrawable = typedArray.getDrawable(R.styleable.StatusLayout_errorDrawable);

emptyTextView.setText(emptyText);
errorTextView.setText(errText);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
errorImageView.setBackground(errorDrawable);
errorImageView.setBackground(emptyDrawable);
}
}

具体的配置过程:

 public void setLayoutConfig(BuildConfig config) {
if (config.mEmptyView != null) {
this.emptyView = config.mEmptyView;
}
if (config.mErrorView != null) {
this.errorView = config.mErrorView;
}
if (config.mLoadingErrorView != null) {
this.progressView = config.mLoadingErrorView;
}
if (config.isContent) {//说明在xml中没有定义父布局,在代码中设置一个父布局
ViewGroup parentViewGroup = null;
if (config.mContentView == null) {//设置在父布局上的叠加层
parentViewGroup = (ViewGroup) contentView;
View parentView = parentViewGroup.getChildAt(0);
setLayoutParams(parentView.getLayoutParams());
parentViewGroup.removeView(parentView);
addView(parentView, 0);
parentViewGroup.addView(this);
} else {//不设置在这个位置的叠加层,在指定的view上替换
parentViewGroup = (ViewGroup) config.mContentView.getParent();
int index = 0;
for (int i = 0; i < ((ViewGroup) contentView).getChildCount(); i++) {
if (config.mContentView == ((ViewGroup) contentView).getChildAt(i)) {
index = i;
break;
}
}
setLayoutParams(config.mContentView.getLayoutParams());
if (parentViewGroup instanceof RelativeLayout) {//如果是relativelayout的样子,就要处理依赖
this.mContentView = config.mContentView;
} else {
parentViewGroup.removeView(config.mContentView);
addView(config.mContentView, 0);
parentViewGroup.addView(this, index);
}
}
}
this.isdialog = config.isDialog;
this.isPositionView = config.isPositionView;
this.mPositionView = config.mPositionView;
this.isguilde = config.isguild;
}

其他的代码比较好里面,关键的代码是

if (config.isContent) {//说明在xml中没有定义父布局,在代码中设置一个父布局

之后的代码,我们必要进行两个判断,是将总体的view放入其中还是在指定位置替换,下面我们一个一个的解析:

由于我们的代码习惯,在XML布局会使用一个父布局,这样就方便我们只取emptyContentView(android.R.id.content)下面的getChildAt(0)就可以拿到整体的布局view。我们只需要将布局view从emptyContentView中remove后,在加入我们自定义的FrameLayout中,再将FrameLayout放置进emptyContentView即可,要注意顺序,否则会出现重复添加的问题。
接下来对替换指定的view,在这里也需要注意是否是RelativeLayout。如果不是RelativeLayout的话就直接替换所在的view,如果是RelativeLayout的话,只能继续执行加载多状态view里面的适配操作。

至此,加载多状态view的全过程讲完了,代码比较简单,按着这种思想,大家肯定会做的更好。

3.总结

上面的内容就是绘制多状态的view的全过程,其中还有遗憾,就是dialog类型的view如果绘制,如果只是单纯的默认view,这是简单的,但是不太符合我们这里的现实场景,所以我思考的是使用一个自定义的view,绘制背景色和阴影部分,将这个自定义view绘制成dialog的样式,但这种样式有一个问题,将这个view必须要绘制在屏幕中,这个要求对于配置初始化的view(替换全局和替换指定)的判断要求更高,逻辑上也更复杂,我没有想到一个很好的方式去实现它(比如动态加载dialogFragment,或者是依赖注入的方式,其实都有问题)。如果大家有什么好的看法或者对上面的思想,代码有什么建议,欢迎留言,共同进步。