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!
推薦閱讀:
- RecyclerView實現橫向滾動效果
- android RecyclerView添加footerview詳解
- RecyclerView使用payload實現局部刷新
- Android MVVM架構實現RecyclerView列表詳解流程
- Android Studio實現智能聊天