Android 手寫RecyclerView實現列表加載

前言

我相信一點,隻要我們的產品中,涉及到列表的需求,肯定第一時間想到RecyclerView,即便是自定義View,那麼RecyclerView也會是首選,為什麼會選擇RecyclerView而不是ListView,主要就是RecyclerView的內存復用機制,這也是RecyclerView的核心 

 當RecyclerView展示列表信息的時候,獲取ItemView的來源有2個:一個是從適配器拿,另一個是從復用池中去拿;一開始的時候就是從復用池去拿,如果復用池中沒有,那麼就從Adapter中去拿,這個時候就是通過onCreateViewHolder來創建一個ItemView。

1 RecyclerView的加載流程

 首先,當加載第一屏的時候,RecyclerView會向復用池中請求獲取View,這個時候復用池中是空的,因此就需要我們自己創建的Adapter,調用onCreateViewHolder創建ItemView,然後onBindViewHolder綁定數據,展示在列表上 

當我們滑動的時候第一個ItemView移出屏幕時,會被放到復用池中;同時,底部空出位置需要加載新的ItemView,觸發加載機制,這個時候復用池不為空,拿到復用的ItemView,調用Adapter的onBIndViewHolder方法刷新數據,加載到尾部;

這裡有個問題,放在復用池的僅僅是View嗎?其實不是的,因為RecyclerView可以根據type類型加載不同的ItemView,那麼放在復用池中的ItemView也是根據type進行歸類,當復用的時候,根據type取出不同類型的ItemView;

例如ItemView07的類型是ImageView,那麼ItemView01在復用池中的類型是TextView,那麼在加載ItemView07時,從復用池中是取不到的,需要Adapter新建一個ImageView類型的ItemView。

2 自定義RecyclerView

其實RecyclerView,我們在使用的時候,知道怎麼去用它,但是內部的原理並不清楚,而且就算是看瞭源碼,時間久瞭就很容易忘記,所以隻有當自己自定義RecyclerView之後才能真正瞭解其中的原理。

2.1 RecyclerView三板斧

通過第一節的加載流程,我們知道RecyclerView有3個重要的角色:RecyclerView、適配器、復用池,所以在自定義RecyclerView的時候,就需要先創建這3個角色;

/**
 * 自定義RecyclerView
 */
public class MyRecyclerView extends ViewGroup {
    
    public MyRecyclerView(Context context) {
        super(context);
    }
    public MyRecyclerView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public MyRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {

    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        return super.onTouchEvent(event);
    }
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return super.onInterceptTouchEvent(ev);
    }
    @Override
    public void scrollBy(int x, int y) {
        super.scrollBy(x, y);

    }
    interface Adapter<VH extends ViewHolder>{
        VH onCreateViewHolder(ViewGroup parent,int viewType);
        void onBindViewHolder(VH holder,int position);
        int getItemCount();
        int getItemViewType(int position);
    }
}
/**
 * 復用池
 */
public class MyRecyclerViewPool {
}
/**
 * Rv的ViewHolder
 */
public class ViewHolder {

    private View itemView;

    public ViewHolder(View itemView) {
        this.itemView = itemView;
    }
}

真正在應用層使用到的就是MyRecyclerView,通過設置Adapter實現View的展示

2.2 初始化工作

從加載流程中,我們可以看到,RecyclerView是協調Adapter和復用池的關系,因此在RecyclerView內部是持有這兩個對象的引用的。

//持有Adapter和復用池的引用
private Adapter mAdapter;
private MyRecyclerViewPool myRecyclerViewPool;
//Rv的寬高
private int mWidth;
private int mHeight;
//itemView的高度
private int[] heights;

那麼這些變量的初始化,是在哪裡做的呢?首先肯定不是在構造方法中做的,我們在使用Adapter的時候,會調用setAdapter,其實就是在這個時候,進行初始化的操作。

public void setAdapter(Adapter mAdapter) {
    this.mAdapter = mAdapter;
    this.needLayout = true;
    //刷新頁面
    requestLayout();
}

/**
 * 對子View進行位置計算擺放
 * @param changed
 * @param l
 * @param t
 * @param r
 * @param b
 */
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    if(changed || needLayout){
        needLayout = false;
        mWidth = r - l;
        mHeight = b - t;
    }
}

每次調用setAdapter的時候,都會調用requestLayout刷新重新佈局,這個時候會調用onLayout,因為onLayout的調用很頻繁非常耗性能,因此我們通知設置一個標志位needLayout,隻有當需要刷新的時候,才能刷新重新擺放子View

2.3 ItemView的獲取與擺放

其實在RecyclerView當中,是對每個子View進行瞭測量,得到瞭它們的寬高,然後根據每個ItemView的高度擺放,這裡我們就寫死瞭高度是200,僅做測試使用,後續優化。

那麼在擺放的時候,比如我們有200條數據,肯定不會把200條數據全部加載進來,默認就展示一屏的數據,所以需要判斷如果最後一個ItemView的bottom超過瞭屏幕的高度,就停止加載。

 @Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    if(changed || needLayout){
        needLayout = false;
        if(mAdapter != null){
            mWidth = r - l;
            mHeight = b - t;

            //計算每個ItemView的寬高,然後擺放位置
            rowCount = mAdapter.getItemCount();
            //這裡假定每個ItemView的高度為200,實際Rv是需要測量每個ItemView的高度
            heights = new int[rowCount];
            for (int i = 0; i < rowCount; i++) {
                heights[i] = 200;
            }
            //擺放 -- 滿第一屏就停止擺放
            for (int i = 0; i < rowCount; i++) {
                bottom = top + heights[i];
                //獲取View
                ViewHolder holder = getItemView(i,0,top,mWidth,bottom);
                viewHolders.add(holder);
                //第二個top就是第一個的bottom
                top = bottom;
            }

        }
    }
}

我們先拿到之前的圖,確定下子View的位置 

 其實每個子View的left都是0,right都是RecyclerView的寬度,變量就是top和bottom,其實從第2個ItemView開始,top都是上一個ItemView的bottom,那麼bottom就是 top + ItemView的高度

在確定瞭子View的位置參數之後,就可以獲取子View來進行擺放,其實在應用層是對子View做瞭一層包裝 — ViewHolder,因此這裡獲取到的也是ViewHolder。

private ViewHolder getItemView(int row,int left, int top, int right, int bottom) {

    ViewHolder viewHolder = obtainViewHolder(row,right - left,bottom - top);
    viewHolder.itemView.layout(left,top,right,bottom);
    return viewHolder;
}

private ViewHolder obtainViewHolder(int row, int width, int height) {

    ViewHolder viewHolder = null;
    //首先從復用池中查找

    //如果找不到,那麼就通過適配器生成
    if(mAdapter !=null){
        viewHolder = mAdapter.onCreateViewHolder(this,mAdapter.getItemViewType(row));
    }
    return viewHolder;
}

通過調用obtainViewHolder來獲取ViewHolder對象,其實是分2步的,首先 是從緩存池中去拿,在第一節加載流程中提及到,緩存池中不隻是存瞭一個ItemView的佈局,而是通過type標註瞭ItemView,所以從緩存池中需要根據type來獲取,如果沒有獲取到,那麼就調用Adapter的onCreateViewHolder獲取,這種避免瞭每個ItemView都通過onCreateViewHolder創建,浪費系統資源;

在拿到瞭ViewHolder之後,調用根佈局ItemView的layout方法進行位置擺放。

2.4 復用池

前面我們提到,在復用池中不僅僅是緩存瞭一個佈局,而是每個type都對應一組回收的Holder,所以在復用池中存在一個容器存儲ViewHolder

/**
 * 復用池
 */
public class MyRecyclerViewPool {

    static class scrapData{
        List<ViewHolder> viewHolders = new ArrayList<>();
    }

    private SparseArray<scrapData> array = new SparseArray<>();

    /**
     * 從緩存中獲取ViewHolder
     * @param type ViewHolder的類型,用戶自己設置
     * @return ViewHolder
     */
    public ViewHolder getRecyclerView(int type){

    }

    /**
     * 將ViewHolder放入緩存池中
     * @param holder
     */
    public void putRecyclerView(ViewHolder holder){

    }
}

當RecyclerView觸發加載機制的時候,首先會從緩存池中取出對應type的ViewHolder;當ItemView移出屏幕之後,相應的ViewHolder會被放在緩存池中,因此存在對應的2個方法,添加及獲取

/**
 * 從緩存中獲取ViewHolder
 *
 * @param type ViewHolder的類型,用戶自己設置
 * @return ViewHolder
 */
public static ViewHolder getRecyclerView(int type) {
    //首先判斷type
    if (array.get(type) != null && !array.get(type).viewHolders.isEmpty()) {

        //將最後一個ViewHolder從列表中移除
        List<ViewHolder> scrapData = array.get(type).viewHolders;
        for (int i = scrapData.size() - 1; i >= 0; i--) {
            return scrapData.remove(i);
        }
    }
    return null;
}

/**
 * 將ViewHolder放入緩存池中
 *
 * @param holder
 */
public static void putRecyclerView(ViewHolder holder) {

    int key = holder.getItemViewType();
    //獲取集合
    List<ViewHolder> viewHolders = getScrapData(key).viewHolders;
    viewHolders.add(holder);
}

private static ScrapData getScrapData(int key) {
    ScrapData scrapData = array.get(key);
    if(scrapData == null){
        scrapData = new ScrapData();
        array.put(key,scrapData);
    }
    return scrapData;
}

2.5 數據更新

無論是從緩存池中拿到瞭緩存的ViewHolder,還是通過適配器創建瞭ViewHolder,最終都需要將ViewHolder進行數據填充

private ViewHolder obtainViewHolder(int row, int width, int height) {

    int itemViewType = mAdapter.getItemViewType(row);
    //首先從復用池中查找
    ViewHolder viewHolder = MyRecyclerViewPool.getRecyclerView(itemViewType);
    //如果找不到,那麼就通過適配器生成
    if(viewHolder == null){
        viewHolder = mAdapter.onCreateViewHolder(this,itemViewType);
    }
    //更新數據
    if (mAdapter != null) {
        mAdapter.onBindViewHolder(viewHolder, row);
        //設置ViewHOlder的類型
        viewHolder.setItemViewType(itemViewType);

        //測量
        viewHolder.itemView.measure(
                MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
        );
        addView(viewHolder.itemView);
    }
    return viewHolder;
}

如果跟到這裡,我們其實已經完成瞭RecyclerView的基礎功能,一個首屏列表的展示

3 RecyclerView滑動事件處理

3.1 點擊事件與滑動事件

對於RecyclerView來說,我們需要的其實是對於滑動事件的處理,對於點擊事件來說,通常是子View來響應,做相應的跳轉或者其他操作,所以對於點擊事件和滑動事件,RecyclerView需要做定向的處理。

那麼如何區分點擊事件和滑動事件?

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {

    switch (ev.getAction()){

        case MotionEvent.ACTION_MOVE:
            return true;
    }
    return false;
}

在容器中,如果碰到MOVE事件就攔截就認為是滑動事件,這種靠譜嗎?顯然 不是的,當手指點擊到屏幕上時,首先系統會接收到一次ACTION_DWON時間,在手指抬起之前,ACTION_DWON隻會響應一次,而且ACTION_MOVE會有無數次,因為人體手指是有面積的,當我們點下去肯定不是一個點,而是一個面肯定會存在ACTION_MOVE事件,但這種我們會認為是點擊事件;

所以對於滑動事件,我們會認為當手指移動一段距離之後,超出某個距離就是滑動事件,這個最小滑動距離通過ViewConfiguration來獲取。

private void init(Context context) {
    ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
    this.touchSlop = viewConfiguration.getScaledTouchSlop();
}

因為列表我們認為是豎直方向滑動的,所以我們需要記錄手指在豎直方向上的滑動距離。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    //判斷是否攔截
    boolean intercept = false;

    switch (ev.getAction()){
        case MotionEvent.ACTION_DOWN:
            mCurrentY = (int) ev.getY();
            break;

        case MotionEvent.ACTION_MOVE:
            //y值在不停改變
            int y = (int) ev.getY();
            if(Math.abs(y - mCurrentY) > touchSlop){
                //認為是滑動瞭
                intercept = true;
            }
            break;
    }
    return intercept;
}

我們通過intercept標志位,來判斷當前是否在進行滑動,如果滑動的距離超出瞭touchSlop,那麼就將事件攔截,在onTouchEvent中消費這個事件。

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_MOVE: {
            //判斷滑動的方向
            int diff = (int) (mCurrentY - event.getRawY());
            if(Math.abs(diff) > touchSlop){
                Log.e(TAG,"diff --- "+diff);
                scrollBy(0, diff);
                mCurrentY = (int) event.getRawY();
            }
            break;
        }
    }
    return super.onTouchEvent(event);
}

3.2 scrollBy和scrollTo

在onTouchEvent中,我們使用瞭scrollBy進行滑動,那麼scrollBy和scrollTo有什麼區別,那就根據Android的坐標系開始說起 

 scrollBy滑動,其實是滑動的偏移量,相對於上一次View所在的位置,例如上圖中,View上滑,偏移量就是(200 – 100 = 100),所以調用scrollBy(0,100)就是向上滑動,反之就是上下滑動;

scrollTo滑動,滑動的是絕對距離,例如上圖中,View上滑,那麼需要傳入詳細的坐標scrollTo(200,100),下滑scrollTo(200,300),其實scrollBy內部調用也是調用的scrollTo,所以偏移量就是用來計算絕對位置的。

3.3 滑動帶來的View回收

當滑動屏幕的時候,有一部分View會被滑出到屏幕外,那麼就涉及到瞭View的回收和View的重新擺放。

首先分析向上滑動的操作,首先我們用scrollY來標記,屏幕中第一個子View左上角距離屏幕左上角的距離,默認就是0.

@Override
public void scrollBy(int x, int y) {
    super.scrollBy(x, y);
    scrollY += y;

    if (scrollY > 0) {
        Log.e(TAG, "上滑");
        //防止一次滑動多個子View出去
        while (scrollY > heights[firstRow]) {
            //被移除,放入回收池
            if (!viewHolders.isEmpty()) {
                removeView(viewHolders.remove(0));
            }
            scrollY -= heights[firstRow];
            firstRow++;
        }

    } else {
        Log.e(TAG, "下滑");
    }
}

在這裡插入圖片描述

 當ItemView1移出屏幕之後,因為上滑scrollY > 0,所以scrollY肯定會超過Itemiew 的高度,這裡有個情況就是,如果一次滑出去多個ItemView,那麼高度肯定是超過單個ItemView的高度,這裡用firstRow來標記,當前子View在數據集合中的位置,所以這裡使用的是while循環。

/**
 * 移除ViewHolder,放入回收池
 *
 * @param holder
 */
private void removeView(ViewHolder holder) {
    MyRecyclerViewPool.putRecyclerView(holder);
    //系統方法,從RecyclerView中移除這個View
    removeView(holder.itemView);
    viewHolders.remove(holder);
}

如果滑出去多個子View,那麼就循環從viewHolders(當前屏幕展示的View的集合)中移除,移除的ViewHolder就被放在瞭回收池中,然後從當前屏幕中移除;

3.4 加載機制

既然有移除,那麼就會有新增,當底部出現空缺的時候,就會觸發加載機制,那麼每次移除一個元素,都會有一個元素添加進來嗎?其實不然 

 像ItemView1移除之後,最底部的ItemView還沒有完全展示出來,其實是沒有觸發加載的,那麼什麼時候觸發加載呢?

在當前屏幕中展示的View其實是在緩存中的,那麼隻要計算緩存中全部ItemView的高度跟屏幕的高度比較,如果不足就需要填充。

 //如果小於屏幕的高度
 while (getRealHeight(firstRow) <= mHeight) {
     //觸發加載機制
     int addIndex = firstRow + viewHolders.size();
     ViewHolder viewHolder = obtainViewHolder(addIndex, mWidth, heights[addIndex]);
     viewHolders.add(viewHolders.size(), viewHolder);
     Log.e(TAG,"添加一個View");
 }
/**
 * 獲取實際展示的高度
 *
 * @param firstIndex
 * @return
 */
private int getRealHeight(int firstIndex) {
    return getSumArray(firstRow, viewHolders.size()) - scrollY;
}

private int getSumArray(int firstIndex, int count) {
    int totalHeight = 0;
    count+= firstIndex;
    for (int i = firstIndex; i < count; i++) {
        totalHeight += heights[i];
    }
    return totalHeight;
}

這樣其實就實現瞭,一個View移除屏幕之後,會有一個新的View添加進來

/**
 * 重新擺放View
 */
private void repositionViews() {
    int left = 0;
    int top = -scrollY;
    int right = mWidth;
    int bottom = 0;

    int index = firstRow;

    for (int i = 0; i < viewHolders.size(); i++) {
        bottom = top + heights[index++];
        viewHolders.get(i).itemView.layout(left,top,right,bottom);
        top = bottom;
    }
}

當然新的View隻要添加進來,就需要對他進行重新擺放,這樣上滑就實現瞭(隻有上滑哦) 

3.5 RecyclerView下滑處理

在此之前,我們處理瞭上滑的事件,頂部的View移出,下部分的View添加進來,那麼下滑正好相反。 

 那麼下滑添加View的時機是什麼呢?就是scrollY小於0的時候,會有新的View添加進來

//下滑頂部添加View
while (scrollY < 0) {

    //獲取ViewHolder
    ViewHolder viewHolder = obtainViewHolder(firstRow - 1, mWidth, heights[firstRow - 1]);
    //放到屏幕緩存ViewHolder最頂部的位置
    viewHolders.add(0, viewHolder);
    firstRow--;
    //當頂部ItemView完全加進來之後,需要改變scrollY的值
    scrollY += heights[firstRow];
}

此時需要將添加的View,放在屏幕展示View緩存的首位,然後firstRow需要-1;

那麼當新的View添加進來之後,底部View需要移除,那麼移除的時機是什麼呢?先把尾部最後一個View的高度拋開,繼續往下滑動,如果當前屏幕展示的View的高度超過瞭屏幕高度,那麼就需要移除

//底部移除View
while (!viewHolders.isEmpty() &&
        getRealHeight(firstRow) - viewHolders.get(viewHolders.size() - 1).itemView.getHeight() >= mHeight) {
    //需要移除
    removeView(viewHolders.remove(viewHolders.size() - 1));
}

3.6 邊界問題

當我們上滑或者下滑的時候,firstRow都在遞增或者遞減,但是firstRow肯定是有邊界的,例如滑到最上端的時候,firstRow最小就是0,如果再-1,那麼就會數組越界,最下端也有邊界,那就是數組的最大長度。

/**
 * @param scrollY
 * @param firstRow
 */
private void scrollBounds(int scrollY, int firstRow) {

    if (scrollY > 0) {
        //上滑
        if (getSumArray(firstRow, heights.length - firstRow) - scrollY > mHeight) {
            this.scrollY = scrollY;
        } else {
            this.scrollY = getSumArray(firstRow, heights.length - firstRow) - mHeight;
        }
    } else {
        //下滑
        this.scrollY = Math.max(scrollY, -getSumArray(0, firstRow));
    }
}

首先看下滑,這個時候firstRow > 0,這個時候getSumArray的值是逐漸減小的,等到最頂部,也就是滑到firstRow = 0的時候,這個時候getSumArray = 0,那麼再往下滑其實還是能滑的,這個時候我們需要做限制,取scrollY 和 getSumArray的最大值,如果一致下滑,getSumArray一致都是0,然後scrollY < 0,最終scrollY = 0,不會再執行下滑的操作瞭。

接下來看上滑,正常情況下,如果200條數據,那麼當firstRow = 10的時候,剩下190個ItemView的高度(減去上滑的高度)肯定是高於屏幕高度的,那麼一直滑,當發現剩餘的ItemView的高度不足以占滿整個屏幕的時候,就是沒有數據瞭,這個時候,其實就可以把scrollY設置為0,不能再繼續滑動瞭。

 @Override
 public void scrollBy(int x, int y) {
//        super.scrollBy(x, y);

     scrollY += y;
     scrollBounds(scrollY, firstRow);


     if (scrollY > 0) {
         Log.e(TAG, "上滑");
         //防止一次滑動多個子View出去
         while (scrollY > heights[firstRow]) {
             //被移除,放入回收池
             if (!viewHolders.isEmpty()) {
                 removeView(viewHolders.remove(0));
             }
             scrollY -= heights[firstRow];
             firstRow++;
             Log.e("scrollBy", "scrollBy 移除一個View size =="+viewHolders.size());
         }

         //如果小於屏幕的高度
         while (getRealHeight(firstRow) < mHeight) {
             //觸發加載機制
             int addIndex = firstRow + viewHolders.size();
             ViewHolder viewHolder = obtainViewHolder(addIndex, mWidth, heights[addIndex]);
             viewHolders.add(viewHolders.size(), viewHolder);
             Log.e("scrollBy", "scrollBy 添加一個View size=="+viewHolders.size());
         }
         //重新擺放
         repositionViews();

     } else {
         Log.e(TAG, "下滑");

         //底部移除View
         while (!viewHolders.isEmpty() &&
                 getRealHeight(firstRow) - viewHolders.get(viewHolders.size() - 1).itemView.getHeight() >= mHeight) {
             //需要移除
             removeView(viewHolders.remove(viewHolders.size() - 1));
         }

         //下滑頂部添加View
         while (scrollY < 0) {

             //獲取ViewHolder
             ViewHolder viewHolder = obtainViewHolder(firstRow - 1, mWidth, heights[firstRow - 1]);
             //放到屏幕緩存ViewHolder最頂部的位置
             viewHolders.add(0, viewHolder);
             firstRow--;
             //當頂部ItemView完全加進來之後,需要改變scrollY的值
             scrollY += heights[firstRow];
         }
     }
 }

OK,這其實跟RecyclerView的源碼相比,簡直就是一個窮人版的RecyclerView,但是其中的思想我們是可以借鑒的,尤其是回收池的思想,在開發中是可以借鑒的,下面展示的就是最後的成果 

到此這篇關於Android 手寫RecyclerView實現列表加載的文章就介紹到這瞭,更多相關Android RecyclerView 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: