您好,登錄后才能下訂單哦!
本篇內容介紹了“Android中怎么手寫RecyclerView實現(xiàn)列表加載”的有關知識,在實際案例的操作過程中,不少人都會遇到這樣的困境,接下來就讓小編帶領大家學習一下如何處理這些情況吧!希望大家仔細閱讀,能夠學有所成!
我相信一點,只要我們的產品中,涉及到列表的需求,肯定第一時間想到RecyclerView,即便是自定義View,那么RecyclerView也會是首選,為什么會選擇RecyclerView而不是ListView,主要就是RecyclerView的內存復用機制,這也是RecyclerView的核心
當RecyclerView展示列表信息的時候,獲取ItemView的來源有2個:一個是從適配器拿,另一個是從復用池中去拿;一開始的時候就是從復用池去拿,如果復用池中沒有,那么就從Adapter中去拿,這個時候就是通過onCreateViewHolder來創(chuàng)建一個ItemView。
首先,當加載第一屏的時候,RecyclerView會向復用池中請求獲取View,這個時候復用池中是空的,因此就需要我們自己創(chuàng)建的Adapter,調用onCreateViewHolder創(chuàng)建ItemView,然后onBindViewHolder綁定數據,展示在列表上
當我們滑動的時候第一個ItemView移出屏幕時,會被放到復用池中;同時,底部空出位置需要加載新的ItemView,觸發(fā)加載機制,這個時候復用池不為空,拿到復用的ItemView,調用Adapter的onBIndViewHolder方法刷新數據,加載到尾部;
這里有個問題,放在復用池的僅僅是View嗎?其實不是的,因為RecyclerView可以根據type類型加載不同的ItemView,那么放在復用池中的ItemView也是根據type進行歸類,當復用的時候,根據type取出不同類型的ItemView;
例如ItemView07的類型是ImageView,那么ItemView01在復用池中的類型是TextView,那么在加載ItemView07時,從復用池中是取不到的,需要Adapter新建一個ImageView類型的ItemView。
其實RecyclerView,我們在使用的時候,知道怎么去用它,但是內部的原理并不清楚,而且就算是看了源碼,時間久了就很容易忘記,所以只有當自己自定義RecyclerView之后才能真正了解其中的原理。
通過第一節(jié)的加載流程,我們知道RecyclerView有3個重要的角色:RecyclerView、適配器、復用池,所以在自定義RecyclerView的時候,就需要先創(chuàng)建這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實現(xiàn)View的展示
從加載流程中,我們可以看到,RecyclerView是協(xié)調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
其實在RecyclerView當中,是對每個子View進行了測量,得到了它們的寬高,然后根據每個ItemView的高度擺放,這里我們就寫死了高度是200,僅做測試使用,后續(xù)優(yōu)化。
那么在擺放的時候,比如我們有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步的,首先 是從緩存池中去拿,在第一節(jié)加載流程中提及到,緩存池中不只是存了一個ItemView的布局,而是通過type標注了ItemView,所以從緩存池中需要根據type來獲取,如果沒有獲取到,那么就調用Adapter的onCreateViewHolder獲取,這種避免了每個ItemView都通過onCreateViewHolder創(chuàng)建,浪費系統(tǒng)資源;
在拿到了ViewHolder之后,調用根布局ItemView的layout方法進行位置擺放。
前面我們提到,在復用池中不僅僅是緩存了一個布局,而是每個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觸發(fā)加載機制的時候,首先會從緩存池中取出對應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; }
無論是從緩存池中拿到了緩存的ViewHolder,還是通過適配器創(chuàng)建了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的基礎功能,一個首屏列表的展示
對于RecyclerView來說,我們需要的其實是對于滑動事件的處理,對于點擊事件來說,通常是子View來響應,做相應的跳轉或者其他操作,所以對于點擊事件和滑動事件,RecyclerView需要做定向的處理。
那么如何區(qū)分點擊事件和滑動事件?
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { switch (ev.getAction()){ case MotionEvent.ACTION_MOVE: return true; } return false; }
在容器中,如果碰到MOVE事件就攔截就認為是滑動事件,這種靠譜嗎?顯然 不是的,當手指點擊到屏幕上時,首先系統(tǒng)會接收到一次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); }
在onTouchEvent中,我們使用了scrollBy進行滑動,那么scrollBy和scrollTo有什么區(qū)別,那就根據Android的坐標系開始說起
scrollBy滑動,其實是滑動的偏移量,相對于上一次View所在的位置,例如上圖中,View上滑,偏移量就是(200 - 100 = 100),所以調用scrollBy(0,100)就是向上滑動,反之就是上下滑動;
scrollTo滑動,滑動的是絕對距離,例如上圖中,View上滑,那么需要傳入詳細的坐標scrollTo(200,100),下滑scrollTo(200,300),其實scrollBy內部調用也是調用的scrollTo,所以偏移量就是用來計算絕對位置的。
當滑動屏幕的時候,有一部分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循環(huán)。
/** * 移除ViewHolder,放入回收池 * * @param holder */ private void removeView(ViewHolder holder) { MyRecyclerViewPool.putRecyclerView(holder); //系統(tǒng)方法,從RecyclerView中移除這個View removeView(holder.itemView); viewHolders.remove(holder); }
如果滑出去多個子View,那么就循環(huán)從viewHolders(當前屏幕展示的View的集合)中移除,移除的ViewHolder就被放在了回收池中,然后從當前屏幕中移除;
既然有移除,那么就會有新增,當底部出現(xiàn)空缺的時候,就會觸發(fā)加載機制,那么每次移除一個元素,都會有一個元素添加進來嗎?其實不然
像ItemView1移除之后,最底部的ItemView還沒有完全展示出來,其實是沒有觸發(fā)加載的,那么什么時候觸發(fā)加載呢?
在當前屏幕中展示的View其實是在緩存中的,那么只要計算緩存中全部ItemView的高度跟屏幕的高度比較,如果不足就需要填充。
//如果小于屏幕的高度 while (getRealHeight(firstRow) <= mHeight) { //觸發(fā)加載機制 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; }
這樣其實就實現(xiàn)了,一個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只要添加進來,就需要對他進行重新擺放,這樣上滑就實現(xiàn)了(只有上滑哦)
在此之前,我們處理了上滑的事件,頂部的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的高度拋開,繼續(xù)往下滑動,如果當前屏幕展示的View的高度超過了屏幕高度,那么就需要移除
//底部移除View while (!viewHolders.isEmpty() && getRealHeight(firstRow) - viewHolders.get(viewHolders.size() - 1).itemView.getHeight() >= mHeight) { //需要移除 removeView(viewHolders.remove(viewHolders.size() - 1)); }
當我們上滑或者下滑的時候,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,不會再執(zhí)行下滑的操作了。
接下來看上滑,正常情況下,如果200條數據,那么當firstRow = 10的時候,剩下190個ItemView的高度(減去上滑的高度)肯定是高于屏幕高度的,那么一直滑,當發(fā)現(xiàn)剩余的ItemView的高度不足以占滿整個屏幕的時候,就是沒有數據了,這個時候,其實就可以把scrollY設置為0,不能再繼續(xù)滑動了。
@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) { //觸發(fā)加載機制 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,但是其中的思想我們是可以借鑒的,尤其是回收池的思想,在開發(fā)中是可以借鑒的,下面展示的就是最后的成果
“Android中怎么手寫RecyclerView實現(xiàn)列表加載”的內容就介紹到這里了,感謝大家的閱讀。如果想了解更多行業(yè)相關的知識可以關注億速云網站,小編將為大家輸出更多高質量的實用文章!
免責聲明:本站發(fā)布的內容(圖片、視頻和文字)以原創(chuàng)、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。