文章伊始,让我们先静心回忆三秒:在我们写过的Android应用当中,是不是有很多地方都离不开数据加载的需求呢?如果是,那么我们首先来看下图:
好的,从这里开始我们暂时忘记自己是一个安卓开发者,而是以一个不懂技术的APP使用者的身份来继续接下来的交流。
如果是作为一个使用者,那么现在我们的内心应该是懵逼的。因为自打我们打开这个应用进入到第一个界面后,就发现没有任何内容。
这个时我们可能会开始推测:什么鬼?手机断网啦?再一看,网络正常啊!继续推测…推测…,最后得出结论:这应用傻x吧,删掉删掉…..
好的,现在切换回开发者的身份,来推测分析下答案:那么原因可能为事实上该界面存放有一个ListView用来显示从服务器获取的一些信息。
但是,的确很可能因为网络不顺畅,服务器内部异常等一些原因造成请求失败,没有数据返回。于是造成了页面没有内容显示的窘境。
显然,作为一个优秀的应用,用户的体验肯定是头等大事。所以很必须的,针对于这种类似的情况,我们应该给用户一些友好的提示。
那么,现在我们可以初步的改进一下代码,比如改进为下面这样的效果:
可以看到,现在当我们发现本次请求没有成功返回数据的时候及时对用户做了交代,起码用户明白这次没有获取到“数据”。这就比之前友好一些了。
不得不说这其实是很一种很常见,但是也很有必要的做法。而且要实现这种需求也相当简单,为listview进行setEmptyView就可以了。
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/lv_animals"
android:layout_width="match_parent"
android:layout_height="match_parent">
</ListView>
<RelativeLayout
android:id="@+id/empty"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/empty_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/ic_empty"
android:layout_centerInParent="true"/>
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/empty_icon"
android:layout_centerHorizontal="true"
android:text="还没有数据哦,亲!"/>
</RelativeLayout>
</RelativeLayout>
这种东西已经不是新鲜事了。以上布局文件中,id为empty的部分就是我们想要在没有获取到的数据的时候提供给用户看的view。
随后的做法也很简单,在Java代码中调用setEmptyView把指定的view设置给ListView就可以了。
listView = (ListView) findViewById(R.id.listview);
View empty = findViewById(R.id.empty);
listView.setEmptyView(empty);
现在就搞定了,确实非常简单。但简单其实是因为Android的设计者已经考虑到了ListView的数据为空的情况,我们调用现有的方法,当然就容易了。
现在我们是不是就可以满足了呢?当然不是。就像我们在图2中可以看到,虽然现在针对于获取数据为空的情况对用户做了交代。但是!我们知道:
比如通过网络去加载数据,肯定是需要一点时间的。那么就像图2演示的,在本次请求结果返回之前,有一段时间我们的界面是一直没有内容的。
那么该怎么改进呢?其实就像绝大多数应用所做的一样,当界面在等待数据加载的过程里,是有一个loading提示的,通常是一个漂亮的动画。
这样做的好处显而易见:一是告诉用户,让它知道现在正处于数据读取的过程中。二来loading动画越有趣,也就不那么让用户因为等待而烦躁了。
好的,现在我们考虑的问题自然就是如何来实现这种需求了。有了setEmptyView的经验,难免就想看看ListView还有没有叫做setLoadingView的方法?
很遗憾,答案是没有。那么问题来了:为什么listview的设计者已经考虑到了empty view的情况,却没有考虑到loading呢?我们试着从源码中寻找答案。
很显然,为了得到我们想要的答案。我们自然是去瞧一瞧setEmptyView的内部实现原理是怎么样的?打开该方法的代码如下:
public void setEmptyView(View emptyView) {
mEmptyView = emptyView;
// If not explicitly specified this view is important for accessibility.
if (emptyView != null
&& emptyView.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
emptyView.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
}
final T adapter = getAdapter();
final boolean empty = ((adapter == null) || adapter.isEmpty());
updateEmptyStatus(empty);
}
我们发现其实这个方法的实现还是很简单的,就是把我们传入的view赋值给AdapterView内部的实例变量mEmptyView 。
之后会判断adapter == null || adapter.isEmpty(),实际意义其实就是判断AdapterView现在是否有数据,没有的话就updateEmptyStatus。
updateEmptyStatus的方法我们也不用全看,通过截取后的如下代码就能把其逻辑的核心思想体现的淋漓尽致:
private void updateEmptyStatus(boolean empty) {
// ...
if (empty) {
if (mEmptyView != null) {
mEmptyView.setVisibility(View.VISIBLE);
setVisibility(View.GONE);
}
// ...
} else {
if (mEmptyView != null)
mEmptyView.setVisibility(View.GONE);
setVisibility(View.VISIBLE);
}
}
通过代码我们可以发现:其实内部是通过传入的布尔变量empty来确定显示的view的。本质则是通过设置View的Visibility属性,来控制是显示AdapterView本身还是EmptyView。是的,原理就是如此简单。但我们接着分析:当ListView的数据发生改变,显然就需要再次判断需要显示何种内容。
那么,这个判断又是如何进行的的呢?实际上是借助“观察者”设计模式来完成的。打开BaseAdapter的源码,首先就会看到如下的代码:
public abstract class BaseAdapter implements ListAdapter, SpinnerAdapter {
private final DataSetObservable mDataSetObservable = new DataSetObservable();
public boolean hasStableIds() {
return false;
}
public void registerDataSetObserver(DataSetObserver observer) {
mDataSetObservable.registerObserver(observer);
}
也就是说BaseAdapter内部有一个“观察”mDataSetObservable,顾名思义它就是用来确定adapter的数据是否发生变化的。
通过registerDataSetObserver可以注册“观察者”给观察对象,而这个调用发生在为AdapterView设置adapter的时候,即发生在setAdapter方法当中:
public void setAdapter(ListAdapter adapter) {
if (mAdapter != null && mDataSetObserver != null) {
mAdapter.unregisterDataSetObserver(mDataSetObserver);
}
//.......省略代码
mDataSetObserver = new AdapterDataSetObserver();
mAdapter.registerDataSetObserver(mDataSetObserver);
也就是说,当我们调用setAdapter后,“观察者”和“观察对象”就都齐了。那么,我们接下来的工作就是查看“观察”的行为究竟是如何进行的。
回想一下,当ListView的数据发生更改的时候,通常我们会调用什么方法?没错,就是notifyDataSetChanged方法。OK,打开该方法的源码:
public void notifyDataSetChanged() {
mDataSetObservable.notifyChanged();
}
上述代码是在Adapter顶层基类BaseAdapter中该方法的实现,由此我们可以发现当数据更新后,mDataSetObservable就会调用notifyChanged方法:
public void notifyChanged() {
synchronized(mObservers) {
for (int i = mObservers.size() - 1; i >= 0; i--) {
mObservers.get(i).onChanged();
}
}
}
由此可以发现,当我们调用notifyDataSetChanged,最终其实是通过Adapter内部的“Observer”们来完成执行对应的操作的。
我们之前说到BaseAdapter的观察者类型是DataSetObserver,这个抽象类其实结构很简单,只有两个方法定义:
public abstract class DataSetObserver {
public void onChanged() {
// Do nothing
}
public void onInvalidated() {
// Do nothing
}
}
顾名思义,也就是观察到数据发生改变时的回调,和数据无效时的回调。之前我们说到当调用notifyDataSetChanged方法时,观察者的onChanged方法最终被回调了。而对应的,onInvalidated则是发生在对adapter调用notifyDataSetInvalidated的时候。
好了,事实上现在就已经万事俱备了。只剩最后一步,就能彻底弄明白ListView究竟是怎么在EmptyView和自身内容之间切换显示的。
回忆一下之前说的,当我们对ListView调用setAdapter的时候,就会将观察者注册给Adapter,而以ListView来说:
这个观察者的实际类型是:定义在AdapterView的中的一个内部类,它继承自DataSetObserver,名叫AdapterDataSetObserver:
class AdapterDataSetObserver extends DataSetObserver {
private Parcelable mInstanceState = null;
@Override
public void onChanged() {
mDataChanged = true;
mOldItemCount = mItemCount;
mItemCount = getAdapter().getCount();
//省略若干代码...
checkFocus();
requestLayout();
}
@Override
public void onInvalidated() {
mDataChanged = true;
//省略若干代码...
mItemCount = 0;
//省略若干代码...
checkFocus();
requestLayout();
}
//...
}
我们这里省略了部分不关心的代码,从剩下的代码中可以清晰的看到onChanged和onInvalidated都有几乎相同的几个操作:
- 为mItemCount赋值,不同的是onChanged是通过adapter.getCount()获取的值;onInvalidated则直接设置为0.
- 最终都会调用checkFocus()和requestLayout方法。
显然,requestLayout是View当中的方法,也就是通知控件重绘。那么关键就在checkFocus身上了,打开其源码:
void checkFocus() {
final T adapter = getAdapter();
final boolean empty = adapter == null || adapter.getCount() == 0;
//省略若干代码...
if (mEmptyView != null) {
updateEmptyStatus((adapter == null) || adapter.isEmpty());
}
}
可以看到这里又出现了熟悉的代码,通过判断adapter是否为null或者adapter.getCount是否为0,来确定empty的值,最后就回到了我们熟悉的updateEmptyStatus。好的, 相信我们已经大概弄清了对ListView设置emptyView的实现原理。我们简单的来总结一下:
- 当我们对ListView调用setAdapter()方法后,就会注册一个类型为AdapterDataSetObserver 的观察者对象给Adapter自身。
- 随后,当ListView出现数据更新,则会调用到notifyDataSetChanged或者说notifyDataSetInvalidated方法。
- 于是Adapter内部的观察者对象的onChanged和onInvalidated方法就会被回调。而这两个方法最后都会触发checkFocus方法。
- checkFocus方法会通过判断adapter是否null和adapter.getCount()是否为0来判断当前ListView是否有数据,从而决定显示ListView还是EmptyView。
好了,现在还是回到我们之前的问题:为什么ListView已经提供了setEmptyView这样的方法,却没有提供setLoadingView这样的东西呢?
其实了解了setEmptyView的原理后,已经很清楚了。我们已经知道AdapterView内部是通过“观察者”来监听adpter的数据变化。
那么很显然,“数据发生变化”这种东西是设计者是很容易为我们进行观察的,但是否处于loading状态这种东西,就没那么好“观察”了。
因为其实我们都说不好开发时会因为各种各样的需求在某个时候会出现处于Loading状态。
但是,没关系,从以上的源码分析中,我们可以从中学习一个关键,那就是所谓不同状态的界面切换显示,实际上本质就是:
为数据加载可能出现的状态分别提供对应的View,然后根据不同的状态来让需要显示的View-VISIBLE,其余的View-Gone。
了解了这个原理我们就很容易实现类似的需求了。但是!我们也说到了数据加载这种东西在应用里是很常见的。
如果我们每次遇到类似的需求都要去写一遍同样逻辑的代码,实际上也是很蛋疼的。有没有可能造一个轮子让我们重复使用呢?肯定是可以的额。
有了想法就付诸行动吧,让我们自己来写一个完成数据加载时不同状态的视图切换的开源库。
首先,让我们来总结分析一下:其实以“数据加载”而言,结合我们平时的经验,通常会需要四种切换状态:
- 内容视图:即成功获取到数据后显示这些数据的视图
- Loading视图:即当数据开始加载到还未加载返回的这段时间显示的视图
- 空视图:即没有查询到我们想要获取的数据时显示的视图
- 异常视图:比如我们通过网络请求数据时,因为网络中断而发生异常,就可以通过对应视图告诉用户,网络出错了。
举个例子来说,就像下面这样:
以上的效果都是通过最近没事写的一个很简单的小开源库SmartLoadingLayout实现的。今天把它整理了下上传到了bintary和github。
用起来还是比较简单的。(github项目地址:https://github.com/RawnHwang/SmartLoadingLayout)有兴趣的一起接着看:
首先肯定是配置依赖,在项目下的build.gradle添加maven仓库,然后在module的build.gradle中导入。
repositories {
jcenter()
maven {
url "http://dl.bintray.com/rawnhwang/RawnHwangAndroid"
}
}
//============================================
compile 'me.rawnhwang.library:smart-loading-layout:1.2.2@aar'
现在,我们就来一起看看:如何通过这个小工具库,来方便的完成如上所示的效果图。首先我们先新建一个初始的界面布局:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="Hello Android!" />
</LinearLayout>
这种布局简单且具有代表性,与我们平常写的没有任何不同。然后来看下怎么通过SmartLoadingLayout来为这个界面加上不同的切换状态。
public class MainActivity extends AppCompatActivity {
private TextView tvContent;
private DefaultLoadingLayout mLayout; //SmartLoadingLayout对象
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
tvContent = (TextView) findViewById(R.id.tv_content);
mLayout = SmartLoadingLayout.createDefaultLayout(this, tvContent);
}
}
以上所有的代码相信我们都很熟悉,唯一的额外工作是需要一个DefaultLoadingLayout对象。
DefaultLoadingLayout通过调用SmartLoadingLayout的静态方法createDefaultLayout就可以成功获取。
该方法需要两个参数,一个是界面的宿主Activity,另一个就是传入我们选择作为内容的view(比如这里就是TextView)。
OK,到了这里,我们的一切准备工作就已经完成了,就是这么的简单。那么,通过以下的代码逻辑我们就可以模拟出等待数据加载并显示的效果:
private Handler mHandler = new Handler(){
@Override
public void handleMessage(Message msg) {
// 数据下载完成,转换状态,显示内容视图
mLayout.onDone();
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
//......
//进入loading状态
mLayout.onLoading();
new Thread(new Runnable() {
@Override
public void run() {
// 这里是模拟下载数据的耗时过程
downloadData();
// 数据下载完毕后,通知handler
mHandler.sendEmptyMessage(1);
}
}).start();
}
然后让我们运行程序看一下效果:
看上去是不是还不错呢?可以看到我们需要做的就是在开始加载数据的时候调用onLoading方法,然后在成功获取数据后调用onDone方法就搞定了。
那么,现在假设这次我们进入时没有获取到数据,也就是我们之前说的空视图。那么,也很简单,将之前代码的onDone改为onEmpty就可以了。
private Handler mHandler = new Handler(){
@Override
public void handleMessage(Message msg) {
mLayout.onEmpty();
}
};
让我们再次运行程序看看效果:
现在我们就轻车熟路了。同理,如果是读取数据的过程中发生了网络异常呢?我们再把onEmpty改为onError。然后再看看效果:
再接着看,可以看到就像上一张图中演示的一样,error状态下通常会有一个按钮,来执行某种操作,通常来说是用于再次发送请求刷新界面的。
那么,我们应该怎么样为这个按钮添加这个操作呢?别急。大家可能还会有其它问题,比如:
我觉得这个错误界面的图形太丑了,我不喜欢;或者我想自己定义错误的描述信息;或者按钮什么鬼?同样也不是我想要的等等等等..
没关系,结合以上问题一起,我们用如下的代码能够一次体现所有的解决方式:
mLayout.setErrorButtonListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mLayout.replaceErrorIcon(R.mipmap.ic_launcher);
mLayout.setErrorDescription("This my error information.");
mLayout.setErrorDescriptionColor(Color.BLUE);
mLayout.setErrorDescriptionTextSize(20);
mLayout.setErrorButtonText("oh!no!");
mLayout.setErrorButtonTextColor(Color.RED);
mLayout.setErrorButtonBackground(R.drawable.bg_error);
}
});
同样的,我们再次运行程序来看看效果:
同理的,如果你不喜欢Loading的效果,不喜欢Empty界面的效果,都可以通过对应的方法来定制为自己喜欢的风格。
但问题又来了,你说了:到目前为止所有的一切我都不太喜欢怎么办。那么我就很尴尬了.
不过也没关系。还有另一个类型叫做CustomLoadingLayout,通过它所有的界面都可以完全由你自己定制。
比如说,现在我们在自己的项目里分别定义好了数据加载时各种不同状态的布局文件。那么,接下来就把它include进来就行了:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ListView
android:id="@+id/lv_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true" />
<!-- this is your custom part -->
<include
android:id="@+id/my_empty_page"
layout="@layout/my_empty_page" />
<include
android:id="@+id/my_error_page"
layout="@layout/my_error_page" />
<include
android:id="@+id/my_loading_page"
layout="@layout/my_loading_page" />
</LinearLayout>
接着是代码控制:
private CustomLoadingLayout mLayout; //SmartLoadingLayout对象
//...............
mLayout = SmartLoadingLayout.createCustomLayout(this);
mLayout.setLoadingView(R.id.my_loading_page);
mLayout.setContentView(R.id.lv_content);
mLayout.setEmptyView(R.id.my_empty_page);
mLayout.setErrorView(R.id.my_error_page);
这样就完成了准备工作,接下来的工作就仍然是在需要的时候调用onLoading此类的方法了。好啦,就写到这里吧!