首頁 > 軟體

Android 手寫RecyclerView實現列表載入

2022-08-28 14:01:29

前言

我相信一點,只要我們的產品中,涉及到列表的需求,肯定第一時間想到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 內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com