在Android开发中,RecyclerView是一个非常重要且经常用到的框架,它的功能十分强大,且性能很好。为了弄懂RecyclerView是怎么实现的,完成了一个简单的基本原理实现,本章通过简单的不能再简单的语句与图片搞懂RecyclerView最最基本的原理。
首先搞懂这个英文名什么意思:Recycler View(可回收使用的View)。
为什么RecyclerView能够加载几万甚至上亿的数据量还能保持如此优秀的效率?其实不过是通过一个Recycler来回收使用View罢了。举个简单的例子:在饭店吃饭时,大饭店平均每天要面对成千上万的顾客。饭店通常只有几百个盘子,这些盘子被使用后并不会丢掉,而是洗好后接着用,每个盘子都会被使用很多很多次。相同的道理,RecyclerView后台通过一个SparseArray来维护回收数据,它可以提高内存效率,每个View又需要一个List来存放。为了简单方便,我直接使用了Stack数据来存放数据,每个View都需要使用一个独立的Stack来存放:
public class Recycler {
private Stack[] stacks;
public Recycler(int typeNumber){
stacks = new Stack[typeNumber];
for (int i = 0; i<typeNumber;i++){
stacks[i] = new Stack<View>();
}
}
public View get(int typeNumber){
try {
return (View) stacks[typeNumber].pop();
}catch (Exception e){
return null;
}
}
public void put(View view,int type){
stacks[type].push(view);
}
}
这样一个简单的Recycler就好了,分别有根据View的类型存和取的方法。
接下来就是Adapter适配器,这个适配器是一个接口,需要由开发者实现,
public interface Adapter {
View onCreateViewHolder(ViewGroup parent, int position);
View onBindViewHolder(View recyclerView, int position);
int getItemViewType(int position);
int getCount();
int getViewTypeCount();
int getHeight();
}
分别定义了:
1、onCreateViewHolder:创建view的方法。因为View是不会无中生有的,初始化必须得创建若干个View,就好像饭店的盘子必须从超市先买才行。
2、onBindViewHolder:绑定数据到已有的View上。这时候View已经被创建,使用完后放入回收池,绑定新的数据重回放回RecyclerView中。类似于:盘子上菜了一盘龙虾,吃完后盘子拿回后厨,洗干净后重新放置新的菜上桌。
3、getItemViewType:根据item的位置获取它的种类。
4、getCount:数据总量。
5、getViewTypeCount:一共有多少种类的数据。
6、getHeight:每个数据的高度。
接着就可以定义容器布局了:
public class RecyclerView extends ViewGroup {
public RecyclerView(Context context) {
this(context,null);
}
public RecyclerView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
}
很简单的实现ViewGroup,因为RecyclerView本质上是一个View容器。
开发者必须实现Adapter的所有方法,并将接口对象返回给RecylerView,RecylerView才能根据这些实现方法摆放数据。
如何摆放数据?覆写RecylerView的onMeasure和onLayout方法:
onMeasure中,使用一个 int[]数组存放所有的item数据高度,然后设置父容器的高度:
int[] heights;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
Log.d(TAG, "onMeasure: ");
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
Log.d(TAG, "onMeasure: " + heightSize);
if(adapter != null){
for (int i=0;i<adapter.getCount();i++){
heights[i] = adapter.getHeight();
}
int h = Math.min(sumArray(heights,0,heights.length),heightSize);
setMeasuredDimension(widthSize,h);
}
}
sumArray是一个工具类,用于计算高度数组从x的位置开始,后面y项数据这一数据片段的高度:
private int sumArray(int[] array, int firstIndex,int count){
int sum = 0;
count += firstIndex;
for (int i = firstIndex;i<count;i++){
sum += array[i];
}
return sum;
}
接着在onLayout中对添加的item进行测量并布局:
int top = 0, bottom = 0;
for (int i = 0; i < adapter.getCount() && top < height; i++) {
bottom += heights[i];
View view = makeAndStep(i, 0, top, width, bottom); //获取数据
viewList.add(view);
top = bottom;
}
这里的for循环中加了top<height的结束判断,因为只需要加载一屏的数据就够了,后面无论多少数据都不需要加载。通过循环,把top和bottom的值循环替代可以获取所有的view。
如何获取View呢?首先从回收池取,取不到然后再创建它:
private View makeAndStep(int row, int left, int top, int right, int bottom) {
View view = obtainView(row, right - left, bottom - top);
view.layout(left, top, right, bottom);
return view;
}
private View obtainView(int position, int width, int height) {
// key type
int itemType= adapter.getItemViewType(position);
// 取不到
View recyclerView = recycler.get(itemType);
View view;
if (recyclerView == null) {
view = adapter.onCreateViewHolder(this,position);
if (view == null) {
throw new RuntimeException("onCreateViewHodler 必须填充布局");
}
}else {
view = adapter.onBindViewHolder(recyclerView,position);
}
view.setTag(R.id.type_item, itemType);
view.measure(MeasureSpec.makeMeasureSpec(width,MeasureSpec.EXACTLY)
,MeasureSpec.makeMeasureSpec(height,MeasureSpec.EXACTLY));
addView(view,0);
return view;
}
这一步完成后,初始化的数据就添加完成了!只是现在依然不能滑动
接着添加滑动事件:
首先获取屏幕的最小滑动距离,这个系统已经定义了,直接获取就行:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept =false;
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
originY= (int) ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
if(Math.abs(originY- ev.getRawY()) > touchSlop){
intercept = true;
}
break;
}
return intercept;
}
如果大于最小距离,那么进行拦截并处理滑动事件:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_MOVE:
int diffY = (int) (originY- event.getRawY());
scrollBy(0,diffY);
break;
}
return true;
}
在onTouchEvent事件中调用scrollBy方法,并进行覆写:
判断滑动的方向:通过两点之间的距离来判断方向,如果diffY大于0,那么为上滑
这里使用一个重要的参数:scrollY,它表示屏幕中第一个item的左上顶点到屏幕左上顶点的距离
上滑情况:
首先,灰色部分是屏幕,屏幕高度是固定的。什么时候第一项(这里的第一项仅仅表示在屏幕中的第一个数据,而不是总数据的第一个,事实上它可以是总数据中任意一段数据)可以被移除呢?可以发现,上滑的中,scrollY是不断变大的,因为scrollY表示的永远是第一个数据左上角顶点到屏幕左上角的距离。当scroll增大到临界值:
图中处于临界值,当继续上滑时,scrollY就会大于第一个数据的高度,同时此时也是第一个数据该被移除的时刻,因为继续上滑就是如下情况:
会发现,scrollY的值重新开始计算,因为之前的数据已被移除,第二个数据此时变成第一个了。相关代码:
while (scrollY > heights[firstRow]){
removeView(viewList.remove(0));
scrollY -= heights[firstRow];
firstRow ++;
}
@Override
public void removeView(View view) {
super.removeView(view);
int key = (int) view.getTag(R.id.type_item);
recycler.put(view,key);
}
一旦scrollY大于第一个数据的高度后,”删除"第一个数据,然后将scrollY重置。可以发现删除并不是真的删除,而是将他放入回收池中,等着以后被绑定数据后重新展示。
屏幕下方的数据如何添加呢?
可以发现,随着不断上滑x的值会不断变小(x = sumArray - scrollY - height),scrollY是不断增大的,其他2个数是定值,因此x会不断变小。当x<0时:
此时达到了下边添加数据的临界状态,继续上滑,x<0,添加数据,相关代码:
while(getFillHeight() < height){
int nextItemIndex = viewList.size() + firstRow;
View view = obtainView(nextItemIndex,width,heights[nextItemIndex]);
viewList.add(viewList.size(),view);
}
private int getFillHeight(){
return sumArray(heights,firstRow,viewList.size()) - scrollY;
}
首先确定下个数据的索引,之后与初始化数据相同:从回收池中先取数据,取不到再创建数据,然后添加到当前屏幕中的view列表中。
下滑情况:
下滑情况与上滑相反,不断从屏幕上边添加1数据,从下边删除数据。
添加数据的临界图:
此时scrollY可以发现为0,如果继续下滑,然后scrollY就会为负数,这个时候是添加数据的时刻:
while(scrollY<0){
firstRow--;
scrollY += heights[firstRow];
View view = obtainView(firstRow,width,heights[firstRow]);
viewList.add(0,view);
}
首先将索引减一作为新数据的索引,添加数据与之前都是先沟通的:首先从回收池中取,取不到则创建。
下边什么时候删除数据呢?看看删除数据的临界:
可以发现最下方的数据即将被删除,距离被删除只剩一步之遥:
继续下滑后,出现了一个 x,这个x本不该出现,现在x>0了,说明当x>=0的时候,数据该被删除了:
x=sumArray - item[viewList.size() - 1] - height(屏幕高度) - scrollY.相关代码:
while(sumArray(heights,firstRow,viewList.size()) - scrollY - heights[firstRow + viewList.size() - 1] - height >= 0){
removeView(viewList.remove(viewList.size() -1));
}
最后,无论上滑还是下滑,都别忘了对viewList(当面屏幕中存在的view)中所有的view进行重新摆放(layout)。
private void repositionViews(){
int top,bottom,i;
top = - scrollY;
i = firstRow;
for (View view : viewList){
bottom = top + heights[i++];
view.layout(0,top,width,bottom);
top = bottom;
}
}
到了这里还未结束,因为最后剩下一个极限位置,那就是总数据的第一个数据如果位于屏幕最上方,继续上滑会数组越界异常,这是因为继续上滑也没有数据填充了。因为取不到一个heights[-1]的数据。同样的,如果已经滑动到数据的最低端,继续滑动也无法获得数据了,无法取得一个heights[height.length]的数据。因此就应该再添加或删除数据前进行判断:
//上滑
if(scrollY >0){
scrollY = Math.min(scrollY,sumArray(heights,firstRow,heights.length - firstRow) - height);
}
//下滑
else{
scrollY = Math.max(scrollY,-sumArray(heights,0,firstRow));
}
首先看下滑情况:
可以看到,继续下滑已经没有数据能添加了,此时scrollY<0,scrollY是不会等于0的。这里使用-sumArray(heights,0,firstRow)来和它取最大值。可以分为2种情况
正常情况(有数据补充):
此时sumArray(heights,0,firstRow)永远是一个正数,因此-sumArray(heights,0,firstRow)就是一个负数,理由是firstRow代表的总数据的索引值,firstRow可以是1、2、3.。。因此scrollY永远会大于-sumArray(heights,0,firstRow),没有影响。
极限情况(没有数据可补充了):
滑动到最顶层,此时firstRow为0,-sumArray(heights,0,firstRow)(表示总数据中从第一项开始,到第0项的数据)为0,但是此时scrollY为负数,因此0大于负数,scrollY被赋值为0了
正常情况都取这个值 极限情况取这个值
scrollY = Math.max( scrollY, -sumArray(heights,0,firstRow));
上滑情况也相同:
如图:滑动数据最低端,此时屏幕中数据的总长度为:
sumArray(heights,firstRow,heights.length - firstRow)
正常情况:如下图:
sumArray(heights,firstRow,heights.length - firstRow)- height的长度一定为大于scrollY,因为scrollY + 一个item的高度 > scrollY,因为总会加上下面新添加上来的一个高度。
极限情况:scrollY + item高度 = scrollY。这个情况说明item高度为0 👉 最下方没有数据添加上来了👉已经滑动到数据最底端了。
正常情况取这个值 极限情况取这个值
scrollY = Math.min( scrollY, sumArray(heights,firstRow,heights.length - firstRow) - height);
demo地址:https://github.com/lyx19970504/RecylerView_By_Self