<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
我相信一點,只要我們的產品中,涉及到列表的需求,肯定第一時間想到RecyclerView,即便是自定義View,那麼RecyclerView也會是首選,為什麼會選擇RecyclerView而不是ListView,主要就是RecyclerView的記憶體複用機制,這也是RecyclerView的核心
當RecyclerView展示列表資訊的時候,獲取ItemView的來源有2個:一個是從介面卡拿,另一個是從複用池中去拿;一開始的時候就是從複用池去拿,如果複用池中沒有,那麼就從Adapter中去拿,這個時候就是通過onCreateViewHolder來建立一個ItemView。
首先,當載入第一屏的時候,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。
其實RecyclerView,我們在使用的時候,知道怎麼去用它,但是內部的原理並不清楚,而且就算是看了原始碼,時間久了就很容易忘記,所以只有當自己自定義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的展示
從載入流程中,我們可以看到,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
其實在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方法進行位置擺放。
前面我們提到,在複用池中不僅僅是快取了一個佈局,而是每個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; }
無論是從快取池中拿到了快取的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的基礎功能,一個首屏列表的展示
對於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); }
在onTouchEvent中,我們使用了scrollBy進行滑動,那麼scrollBy和scrollTo有什麼區別,那就根據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迴圈。
/** * 移除ViewHolder,放入回收池 * * @param holder */ private void removeView(ViewHolder holder) { MyRecyclerViewPool.putRecyclerView(holder); //系統方法,從RecyclerView中移除這個View removeView(holder.itemView); viewHolders.remove(holder); }
如果滑出去多個子View,那麼就回圈從viewHolders(當前螢幕展示的View的集合)中移除,移除的ViewHolder就被放在了回收池中,然後從當前螢幕中移除;
既然有移除,那麼就會有新增,當底部出現空缺的時候,就會觸發載入機制,那麼每次移除一個元素,都會有一個元素新增進來嗎?其實不然
像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只要新增進來,就需要對他進行重新擺放,這樣上滑就實現了(只有上滑哦)
在此之前,我們處理了上滑的事件,頂部的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)); }
當我們上滑或者下滑的時候,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!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45