当前位置: 首页 > news >正文

Android入门第64天-MVVM下瀑布流界面的完美实现-使用RecyclerView

前言

        网上充满着不完善的基于RecyclerView的瀑布流实现,要么根本是错的、要么就是只知其一不知其二、要么就是一充诉了一堆无用代码、要么用的是古老的MVC设计模式。

        一个真正的、用户体验类似于淘宝、抖音的瀑布流怎么实现目前基本为无解。因为本人正好自己空闲时也在做前后台一体化开发,所以直接从本人自己项目里(mao-sir.com)公开一部分核心代码以便于加快大家的学习进度,以使得各位不要在这种小东西上折腾太多时间。

什么是瀑布流

        注意看这边的这个布局,大家有没有发觉这些照片分成左右、最多的还有分成三列的,但是每一行的照片、视频、内容的高度是“错开”的。最早采用此布局的网站是Pinterest,逐渐在国内流行开来。国内大多数做的好的大厂的APP都是这种布局、尤以UGC(UGC它的中文意思是用户生产内容的意思,简称为UGC)为主的APP采用此布局最多像:知乎上的精品贴、推荐贴、小红书种草等都是这种风格。

瀑布流布局的优点为

  1. 吸引用户,当用户在浏览瀑布流式布局的时候(这里抛开懒加载),用户会产生一种错觉,就是信息是不停的在更新的,这会激发用户的好奇心,使用户不停的往下滑动。
  2. 良好视觉体验,采用瀑布流布局的方式可以打破常规网站布局排版,给用户眼前一亮的新鲜感,用户在浏览内容时会感到很有新鲜感,带来良好的视觉体验。
  3. 更好的适应移动端,由于移动设备屏幕比电脑小,一个屏幕显示的内容不会非常多,因此可能要经常翻页。而在建网站时使用瀑布流布局,用户则只需要进行滚动就能够不断浏览内容。(这一点和懒加载有一点像)

怎么实现瀑布流

        网上有一些第三方控件使用了瀑布流,但是这些第三方控件都已经废弃或者是停更了。这些第三方控件本人都用过,不是有各种BUG就是把问题搞了很复杂。这东西其实很简单,一天内就可以做出生产级别的应用了,哪有这么难。

        难就是难在太多初学者为了赶项目或者说很多人急功近利,只想着copy paste,因此搞了一堆其实无用的代码还把问题“混搅”了。

        因此本篇会从本质上还原最最干净的瀑布流,同时本人会基于mvvm设计模式来讲这个瀑布流。我们用的就是Android原生的控件:RecyclerView组件。下面是类淘宝、抖音用户极致体验的一个瀑布流“正例”(我们在文未还会给出一个体验不好的反例供读者对比)。

        <androidx.recyclerview.widget.RecyclerViewandroid:id="@+id/rv"android:layout_width="match_parent"android:layout_height="wrap_content"></androidx.recyclerview.widget.RecyclerView>

下面我们来看实现 。

基于MVVM设计模式的RecyclerView实现瀑布流代码

工程整体结构

        这是一个使用androidx的基于mvvm的工程。

        至于如何把一个工程变成androidx和mvvm此处就不再赘述了,在我前面的博客中已经写了很详细了。

布局

activity_main.xml布局

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"><data></data><LinearLayoutandroid:layout_width="match_parent"android:layout_height="match_parent"android:orientation="vertical"tools:context=".MainActivity"><androidx.recyclerview.widget.RecyclerViewandroid:id="@+id/rv"android:layout_width="match_parent"android:layout_height="wrap_content"></androidx.recyclerview.widget.RecyclerView></LinearLayout>
</layout>

瀑布流中具体的明细布局-rv_item.xml

        在明细布局里,整体瀑布流墙就两个元素,一个是照片的url另一个是文本框,实现很简单。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"><data><variablename="item"type="org.mk.android.demo.demo.staggerdrecyclerview.RVBean" /></data><LinearLayoutandroid:layout_width="match_parent"android:layout_height="wrap_content"><androidx.cardview.widget.CardViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:layout_margin="8dp"app:cardCornerRadius="8dp"app:cardElevation="4dp"><LinearLayoutandroid:layout_width="match_parent"android:layout_height="wrap_content"android:orientation="vertical"><ImageViewandroid:id="@+id/rvImageView"android:layout_width="match_parent"android:layout_height="wrap_content"android:adjustViewBounds="true"android:scaleType="fitXY"app:url="@{item.url}" /><TextViewandroid:id="@+id/rvTextView"android:layout_width="match_parent"android:layout_height="wrap_content"android:textAlignment="center"android:layout_margin="4dp"android:text="@{item.text}" /></LinearLayout></androidx.cardview.widget.CardView></LinearLayout>
</layout>

 现在就来看我们的代码。

后端代码

RVBean.java

package org.mk.android.demo.demo.staggerdrecyclerview;import android.util.Log;
import android.widget.ImageView;import androidx.databinding.BindingAdapter;import com.bumptech.glide.Glide;import java.util.Objects;public class RVBean {private String url;private String text;private final static String TAG = "DemoStaggerdRecyclerView";@BindingAdapter("url")public static void loadImg(ImageView imageView, String url) {Glide.with(imageView).load(url).into(imageView);}public String getUrl() {return url;}public void setUrl(String url) {this.url = url;}public String getText() {return text;}public void setText(String text) {this.text = text;}public RVBean(String url, String text) {this.url = url;this.text = text;}@Overridepublic boolean equals(Object o) {if (this == o) {//Log.i(TAG, ">>>>>>this==o return true");return true;}if (o == null || getClass() != o.getClass()) {//Log.i(TAG, ">>>>>>o==null||getClass()!=o.getClass() is false");return false;}RVBean rvBean = (RVBean) o;if (rvBean.url.length() != url.length() || rvBean.text.length() != text.length()) {//Log.i(TAG, ">>>>>>target length()!=existed url length");return false;}if(url.equals(rvBean.url)&&text.equals(rvBean.text)){//Log.i(TAG,">>>>>>url euqlas && text equals");return true;}else{//Log.i(TAG,">>>>>>not url euqlas && text equals");return false;}}@Overridepublic int hashCode() {int hashCode = Objects.hash(url, text);//Log.i(TAG, ">>>>>>hashCode->" + hashCode);return hashCode;}
}

        代码中它定义了两个元素,一个为文本框,一个为用于加载网络图片的url,网络图片我用的是我另一台VM上的Nginx做的静态图片资源服务。

RVAdapter.java

package org.mk.android.demo.demo.staggerdrecyclerview;import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;import androidx.annotation.NonNull;
import androidx.databinding.DataBindingUtil;
import androidx.recyclerview.widget.RecyclerView;import com.bumptech.glide.Glide;import org.mk.android.demo.demo.staggerdrecyclerview.databinding.RvItemBinding;import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;public class RVAdapter extends RecyclerView.Adapter<RVAdapter.VH> {private Context context;private List<RVBean> rvBeans;private final static String TAG = "DemoStaggerdRecyclerView";public RVAdapter(Context context, List<RVBean> rvBeans) {this.context = context;this.rvBeans = rvBeans;}@Overridepublic int getItemViewType(int position) {return position;}@NonNull@Overridepublic VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {return new VH(DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.rv_item, parent, false).getRoot());}@Overridepublic void onBindViewHolder(@NonNull VH holder, int position) {//try {RvItemBinding binding = DataBindingUtil.bind(holder.itemView);//binding.rvTextView.setText(rvBeans.get(position).getText());binding.setItem(rvBeans.get(position));/*//Set sizeBitmapFactory.Options options = new BitmapFactory.Options();options.inJustDecodeBounds = true;//这个参数设置为true才有效,Bitmap bmp = BitmapFactory.decodeFile(rvBeans.get(position).getUrl(), options);//这里的bitmap是个空int outHeight = options.outHeight;int outWidth = options.outWidth;Glide.with(context).load(rvBeans.get(position).getUrl()).override(outWidth,outHeight).into(binding.rvImageView);} catch (Exception e) {Log.e(TAG, ">>>>>>onbindViewHolder error: " + e.getMessage(), e);}*/}@Overridepublic int getItemCount() {return rvBeans.size();}public class VH extends RecyclerView.ViewHolder {public VH(@NonNull View itemView) {super(itemView);}}//增加外部调用增加一条记录public void refreshDatas(List<RVBean> datas) {int pc=0;if (datas != null && datas.size() > 0) {int oldSize = rvBeans.size();//List<RVBean> refreshedData = new ArrayList<RVBean>();boolean isItemExisted = false;for (Iterator<RVBean> newData = datas.iterator(); newData.hasNext(); ) {RVBean a = newData.next();for (Iterator<RVBean> existedData = rvBeans.iterator(); existedData.hasNext(); ) {RVBean b = existedData.next();if (b.equals(a)) {{isItemExisted = true;//Log.i(TAG, b.getText() + " -> " + b.getUrl() + " is existed");break;}}}if (!isItemExisted) {pc+=1;rvBeans.add(a);}}Log.i(TAG,">>>>>>pc->"+pc);if(pc>0){notifyItemRangeChanged(oldSize,rvBeans.size());}}}
}

 核心代码导读

  1. 这个adapter用的正是mvvm设计模式做的adapter;
  2. 这个adapter和网上那些错误、有坑的例子最大的不同在于getItemViewType方法内必须返回position,否则你的瀑布流在上划加载新数据时会产生界面内对照片重新进行左右切换、重排、或者把照片底部留出很大一块空白如:左边垂直排3张,右边一大边空白或者反之亦然的情况;
  3. 必须使用notifyItemRangeChanged来通知刷新新数据,网上很多例子用的是notifyDataSetChange或者是其它相关的notify,它们都是错的,这是因为RecyclerViewer在上划下划时会导致整个瀑布流重新布局、而RecyclerView里用的是Glide异步加载网络图片的,这会导致组件看到有一个组片就去开始计算它的高度而实际这个照片还未加载好因此才会导致RecyclerView在上划下划时整体布局重新刷新和重布局。一定记得这个notifyItemRangeChanged,同时这个方法在使用前加载所有的图片(list数据),传参有两个参数,参数1:加载新数据前原数据行.size(),参数2:新加载数据.size();

        有了adapter我们来看我们的应用了。

        应用前先别急,我们自定义了一个StaggeredGridLayoutManager。

自定义FullyStaggeredGridLayoutManager

        这边先说一下,为什么要自定义这个StaggeredGridLayoutManager?

public class FullyStaggeredGridLayoutManager extends StaggeredGridLayoutManager {

        大家可以认为,这个类是一个策略。这也是网上绝大部分教程根本不说的,这个策略就是从根本上避免RecyclerViewer在上划下划时不要进行左右切换、重新布局、图片闪烁以及“解决Scrollview中嵌套RecyclerView实现瀑布流时无法显示的问题,同时修复了子View显示时底部多出空白区域的问题”用的,我在代码中也做了注释。

        此处要敲一下黑板了。因此整个RecyclerView要做到类淘宝、抖音的这种用户体验必须是adapter里的代码和这个自定义StaggeredGridLayoutManager结合起来才能做到。

        因此我们下面就来看在MainActivity里如何把adapter结合着这个自定义的StaggeredGridLayoutManager的应用吧。

        先上我们自定义的这个StaggeredGridLayoutManager-我们在此把它命名叫做:FullyStaggeredGridLayoutManager的全代码。

FullyStaggeredGridLayoutManager.java代码

package org.mk.android.demo.demo.staggerdrecyclerview;import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;import java.lang.reflect.Field;/*** @descride 解决Scrollview中嵌套RecyclerView实现瀑布流时无法显示的问题,同时修复了子View显示时底部多出空白区域的问题*/
public class FullyStaggeredGridLayoutManager extends StaggeredGridLayoutManager {private static boolean canMakeInsetsDirty = true;private static Field insetsDirtyField = null;private static final int CHILD_WIDTH = 0;private static final int CHILD_HEIGHT = 1;private static final int DEFAULT_CHILD_SIZE = 100;private int spanCount = 0;private final int[] childDimensions = new int[2];private int[] childColumnDimensions;private int childSize = DEFAULT_CHILD_SIZE;private boolean hasChildSize;private final Rect tmpRect = new Rect();public FullyStaggeredGridLayoutManager(Context context, AttributeSet attrs, int defStyleAttr,int defStyleRes) {super(context, attrs, defStyleAttr, defStyleRes);}public FullyStaggeredGridLayoutManager(int spanCount, int orientation) {super(spanCount, orientation);this.spanCount = spanCount;}public static int makeUnspecifiedSpec() {return View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);}@Overridepublic void onMeasure(RecyclerView.Recycler recycler, RecyclerView.State state, int widthSpec,int heightSpec) {final int widthMode = View.MeasureSpec.getMode(widthSpec);final int heightMode = View.MeasureSpec.getMode(heightSpec);final int widthSize = View.MeasureSpec.getSize(widthSpec);final int heightSize = View.MeasureSpec.getSize(heightSpec);final boolean hasWidthSize = widthMode != View.MeasureSpec.UNSPECIFIED;final boolean hasHeightSize = heightMode != View.MeasureSpec.UNSPECIFIED;final boolean exactWidth = widthMode == View.MeasureSpec.EXACTLY;final boolean exactHeight = heightMode == View.MeasureSpec.EXACTLY;final int unspecified = makeUnspecifiedSpec();if (exactWidth && exactHeight) {// in case of exact calculations for both dimensions let's use default "onMeasure" implementationsuper.onMeasure(recycler, state, widthSpec, heightSpec);return;}final boolean vertical = getOrientation() == VERTICAL;initChildDimensions(widthSize, heightSize, vertical);int width = 0;int height = 0;// it's possible to get scrap views in recycler which are bound to old (invalid) adapter entities. This// happens because their invalidation happens after "onMeasure" method. As a workaround let's clear the// recycler now (it should not cause any performance issues while scrolling as "onMeasure" is never// called whiles scrolling)recycler.clear();final int stateItemCount = state.getItemCount();final int adapterItemCount = getItemCount();childColumnDimensions = new int[adapterItemCount];// adapter always contains actual data while state might contain old data (f.e. data before the animation is// done). As we want to measure the view with actual data we must use data from the adapter and not from  the// statefor (int i = 0; i < adapterItemCount; i++) {if (vertical) {if (!hasChildSize) {if (i < stateItemCount) {// we should not exceed state count, otherwise we'll get IndexOutOfBoundsException. For such items// we will use previously calculated dimensionsmeasureChild(recycler, i, widthSize, unspecified, childDimensions);} else {logMeasureWarning(i);}}childColumnDimensions[i] = childDimensions[CHILD_HEIGHT];//height += childDimensions[CHILD_HEIGHT];if (i == 0) {width = childDimensions[CHILD_WIDTH];}if (hasHeightSize && height >= heightSize) {break;}} else {if (!hasChildSize) {if (i < stateItemCount) {// we should not exceed state count, otherwise we'll get IndexOutOfBoundsException. For such items// we will use previously calculated dimensionsmeasureChild(recycler, i, unspecified, heightSize, childDimensions);} else {logMeasureWarning(i);}}width += childDimensions[CHILD_WIDTH];if (i == 0) {height = childDimensions[CHILD_HEIGHT];}if (hasWidthSize && width >= widthSize) {break;}}}int[] maxHeight = new int[spanCount];for (int i = 0; i < adapterItemCount; i++) {int position = i % spanCount;if (i < spanCount) {maxHeight[position] += childColumnDimensions[i];} else if (position < spanCount) {int mixHeight = maxHeight[0];int mixPosition = 0;for (int j = 0; j < spanCount; j++) {if (mixHeight > maxHeight[j]) {mixHeight = maxHeight[j];mixPosition = j;}}maxHeight[mixPosition] += childColumnDimensions[i];}}for (int i = 0; i < spanCount; i++) {for (int j = 0; j < spanCount - i - 1; j++) {if (maxHeight[j] < maxHeight[j + 1]) {int temp = maxHeight[j];maxHeight[j] = maxHeight[j + 1];maxHeight[j + 1] = temp;}}}height = maxHeight[0];//this is max heightif (exactWidth) {width = widthSize;} else {width += getPaddingLeft() + getPaddingRight();if (hasWidthSize) {width = Math.min(width, widthSize);}}if (exactHeight) {height = heightSize;} else {height += getPaddingTop() + getPaddingBottom();if (hasHeightSize) {height = Math.min(height, heightSize);}}setMeasuredDimension(width, height);}private void logMeasureWarning(int child) {if (BuildConfig.DEBUG) {Log.w("LinearLayoutManager", "Can't measure child #"+ child+ ", previously used dimensions will be reused."+ "To remove this message either use #setChildSize() method or don't run RecyclerView animations");}}private void initChildDimensions(int width, int height, boolean vertical) {if (childDimensions[CHILD_WIDTH] != 0 || childDimensions[CHILD_HEIGHT] != 0) {// already initialized, skippingreturn;}if (vertical) {childDimensions[CHILD_WIDTH] = width;childDimensions[CHILD_HEIGHT] = childSize;} else {childDimensions[CHILD_WIDTH] = childSize;childDimensions[CHILD_HEIGHT] = height;}}@Override public void setOrientation(int orientation) {// might be called before the constructor of this class is called//noinspection ConstantConditionsif (childDimensions != null) {if (getOrientation() != orientation) {childDimensions[CHILD_WIDTH] = 0;childDimensions[CHILD_HEIGHT] = 0;}}super.setOrientation(orientation);}public void clearChildSize() {hasChildSize = false;setChildSize(DEFAULT_CHILD_SIZE);}public void setChildSize(int childSize) {hasChildSize = true;if (this.childSize != childSize) {this.childSize = childSize;requestLayout();}}private void measureChild(RecyclerView.Recycler recycler, int position, int widthSize,int heightSize, int[] dimensions) {final View child;try {child = recycler.getViewForPosition(position);} catch (IndexOutOfBoundsException e) {if (BuildConfig.DEBUG) {Log.w("LinearLayoutManager","LinearLayoutManager doesn't work well with animations. Consider switching them off",e);}return;}final RecyclerView.LayoutParams p = (RecyclerView.LayoutParams) child.getLayoutParams();final int hPadding = getPaddingLeft() + getPaddingRight();final int vPadding = getPaddingTop() + getPaddingBottom();final int hMargin = p.leftMargin + p.rightMargin;final int vMargin = p.topMargin + p.bottomMargin;// we must make insets dirty in order calculateItemDecorationsForChild to workmakeInsetsDirty(p);// this method should be called before any getXxxDecorationXxx() methodscalculateItemDecorationsForChild(child, tmpRect);final int hDecoration = getRightDecorationWidth(child) + getLeftDecorationWidth(child);final int vDecoration = getTopDecorationHeight(child) + getBottomDecorationHeight(child);final int childWidthSpec =getChildMeasureSpec(widthSize, hPadding + hMargin + hDecoration, p.width,canScrollHorizontally());final int childHeightSpec =getChildMeasureSpec(heightSize, vPadding + vMargin + vDecoration, p.height,canScrollVertically());child.measure(childWidthSpec, childHeightSpec);dimensions[CHILD_WIDTH] = getDecoratedMeasuredWidth(child) + p.leftMargin + p.rightMargin;dimensions[CHILD_HEIGHT] = getDecoratedMeasuredHeight(child) + p.bottomMargin + p.topMargin;// as view is recycled let's not keep old measured valuesmakeInsetsDirty(p);recycler.recycleView(child);}private static void makeInsetsDirty(RecyclerView.LayoutParams p) {if (!canMakeInsetsDirty) {return;}try {if (insetsDirtyField == null) {insetsDirtyField = RecyclerView.LayoutParams.class.getDeclaredField("mInsetsDirty");insetsDirtyField.setAccessible(true);}insetsDirtyField.set(p, true);} catch (NoSuchFieldException e) {onMakeInsertDirtyFailed();} catch (IllegalAccessException e) {onMakeInsertDirtyFailed();}}private static void onMakeInsertDirtyFailed() {canMakeInsetsDirty = false;if (BuildConfig.DEBUG) {Log.w("LinearLayoutManager","Can't make LayoutParams insets dirty, decorations measurements might be incorrect");}}
}

 MainActivity.java

        从这儿开始我们要进入正题了,这边要说真正的RecyclerView的应用了。

        在此演示代码块里为了同时便于初学者和正在寻找RecyclerView上划下划时错位过大、重新布局影响体验的老手同时阅读和查询问题时方便,我要说一下整个Demo代码运行的设计思路如下:

  1. 上手先加载12条数据;
  2. 对上划(手指按住屏幕向上拉),一直拉、拉、拉,拉到第12条,触发了RecyclerView的onScrollStateChanged中的“往上拉拉不动”事件后开始再加载6条数据,以模拟实际项目中的“翻页时走一下后台API取新数据”的过程;
package org.mk.android.demo.demo.staggerdrecyclerview;import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.databinding.DataBindingUtil;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;import org.mk.android.demo.demo.staggerdrecyclerview.databinding.ActivityMainBinding;import java.util.ArrayList;
import java.util.List;public class MainActivity extends AppCompatActivity {private ActivityMainBinding binding;private List<RVBean> rvBeanList = new ArrayList<>();private RVAdapter adapter;private final static String TAG = "DemoStaggerdRecyclerView";private final static String CDN_URL="http://172.16.4.249/mkcdn";private FullyStaggeredGridLayoutManager slm=null;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);binding = DataBindingUtil.inflate(LayoutInflater.from(this), R.layout.activity_main, null, false);setContentView(binding.getRoot());slm=new FullyStaggeredGridLayoutManager(2,FullyStaggeredGridLayoutManager.VERTICAL);binding.rv.setLayoutManager(slm);((SimpleItemAnimator)binding.rv.getItemAnimator()).setSupportsChangeAnimations(false);((DefaultItemAnimator) binding.rv.getItemAnimator()).setSupportsChangeAnimations(false);binding.rv.getItemAnimator().setChangeDuration(0);binding.rv.setHasFixedSize(true);initData();}private void initData() {rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_13.jpeg", "1"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_14.jpeg", "2"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_15.jpeg", "3"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_16.jpeg", "4"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_17.jpeg", "5"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_18.jpeg", "6"));rvBeanList.add(new RVBean(CDN_URL+"img/recommend/recom_19.jpeg", "7"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_20.jpeg", "8"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_21.jpeg", "9"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_22.jpeg", "10"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_23.jpeg", "11"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_24.jpeg", "12"));adapter = new RVAdapter(this, rvBeanList);//主要就是这个LayoutManager,就是用这个来实现瀑布流的,2表示有2列(垂直)或3行(水平),我们这里用的垂直VERTICAL//binding.rv.addItemDecoration(new SpaceItemDecoration(2, 20));binding.rv.addOnScrollListener(new RecyclerView.OnScrollListener() {@Overridepublic void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {super.onScrollStateChanged(recyclerView, newState);if (!recyclerView.canScrollVertically(1) && newState == RecyclerView.SCROLL_STATE_IDLE) {Log.i(TAG, "上拉拉不动时触发加载新数据");rvBeanList = new ArrayList<>();rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_25.jpeg", "13"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_26.jpeg", "14"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_27.jpeg", "15"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_28.jpeg", "16"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_29.jpeg", "17"));rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_30.jpeg", "18"));adapter.refreshDatas(rvBeanList);}if (!recyclerView.canScrollVertically(-1) && newState == RecyclerView.SCROLL_STATE_IDLE) {Log.i(TAG, "下拉拉不动时触发加载新数据");}}@Overridepublic void onScrolled(RecyclerView recyclerView, int dx, int dy) {super.onScrolled(recyclerView, dx, dy);slm.invalidateSpanAssignments();//防止第一行到顶部有空白}});//((SimpleItemAnimator)RecyclerView.getItemAnimator()).setSupportsChangeAnimations(false);binding.rv.setAdapter(adapter);}
}

核心代码导读:

        以下这一陀就是我说的使用自定义的StaggeredGridLayoutManager+adapter中的事件覆盖一起实现了著名的RecyclerView上划下划时重新布局、翻页的梗。

         同时,一定要记得覆盖RecyclerView的onScrolled事件,在事件中加入这样的代码

@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {super.onScrolled(recyclerView, dx, dy);slm.invalidateSpanAssignments();//防止第一行到顶部有空白
}

总结

正确的做法

        因此这边总结一下共有5个点,做到这5个点才能真正避免网上一堆的关于RecyclerView快速上划下划时整个界面产生了春左右列切换、重新布局、布局不合理如:左边垂直排了3-4个图片而把右边留出一大块空白的梗的综合手段:

  1. 必须使用一个自定义的StaggeredGridLayoutManager,你可以直接使用我博客中的代码,它来自于我正在制作的上生产的mao-sir.com的app中的代码,这是我的个人的一个开源中台产品;
  2. 必须要设置上面代码截图中的4个属性即:

    SimpleItemAnimator里的setSupportsChangeAnimations(false);
    DefaultItemAnimator里的setSupportsChangeAnimations(false);
    setChangeDuration(0);
    setHasFixedSize(true);

  3. 必须要覆盖RecyclerView的onScrolled方法,在方法里设置防止第一行到顶部有空白的操作;

  4. 必须要在adapter里覆盖getItemViewType,在方法内返回position;

  5. 必须要在adapter里刷新数据时使用:notifyItemRangeChanged;

错误的做法

        这边我说网上绝大部分示例是错的可能是客气了点,因该说可以搜到的全是错的。我们也来总结一下,希望各位不要再去踩这种坑了。

  1. 把图片的尺寸预先存在后台、每次接口取图片时后台把这个尺寸返回给到RecyclerView的adapter。。。这是得有多。。。无聊的做法,覆盖一个getItemViewType不就同样实现了这样的手法?
  2. setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_NONE)的做法是错的,设完后图片要么在一开始进入界面时显示不出要么显示不全,我不知道这个问题最早是谁想出的解决办法?怎么自己也不去验证一下对不对?
  3. 网上还有一种手法也是错的离谱,就是在onBindViewHolder方法里通过BitMap+GLIDE重设图片的尺寸,这种方法根本无效;

        下面我们来感受一下使用了网上错误的手法后,这个瀑布流会变成什么样,大家就能感受到我说的这种:体验问题了。这种体验问题在APP上如果没有做好会直接要了你这家企业的“口碑的命”。

来看错误手法导致的差劲的瀑布流体验

        请看下面的“反例”的演示,注意到了上划或者下划时左右列切换、重排的问题了没?给大家感受一下。

后记

        此处我们需要写一下RecyclerView在生产上真正使用时在设计上的一个补充点。

        根据上面的代码我们可以看到我用了上划触发上划到一页底部并实现翻页的无缝衔接的体验。

        但实际在生产上我们会遇到上千~近万张图片的前后台传输。如果图片一多、翻页一多,因为这个翻而是介于前后台交互的一个动作,因此有时在遇有弱网或者是网络抖动时卡死Android主进程并导致App闪退、卡屏、白屏等现象。

        所以,我这边给出万级QPS并发下的瀑布流设计上的两个点:

  1. APP的首页、分类、搜索的list、瀑布流、三行三列这种界面的数据必须来自后台的es,而不是去什么后台查数据库这么来一下。在中/后台,我们通过企业内部的运营管理平台对于内容、商品进行“上下架”后会触发一个ES或者是RediSearch(我的开源产品里已经彻底不用ES了)的全量或者是增量索引;
  2. 在APP端,不能翻一页就去后台取一下!而是APP端也要做一层“缓存”,可以把已经获取到的数据存在本地的内嵌存储,本地取不到再去取后/中台的API接口获取数据;

        以此充分极大化加快我们APP端的反映速度同时取得最优的用户体验。

好,结束今天的教程。大家不妨自己动一下手试试看效果去吧。

相关文章:

Android入门第64天-MVVM下瀑布流界面的完美实现-使用RecyclerView

前言 网上充满着不完善的基于RecyclerView的瀑布流实现&#xff0c;要么根本是错的、要么就是只知其一不知其二、要么就是一充诉了一堆无用代码、要么用的是古老的MVC设计模式。 一个真正的、用户体验类似于淘宝、抖音的瀑布流怎么实现目前基本为无解。因为本人正好自己空闲时也…...

Windows PowerShell中成功进入conda虚拟环境

本人操作系统是Windows10&#xff08;输入命令cmd或在运运行中输入winver查看&#xff09;在cmd命令行中大家都很熟悉&#xff0c;很方便进入到指定创建了的虚拟环境中&#xff0c;那么在PowerShell中怎么进入呢&#xff1f;比如在VSCode中的TERMINAL使用的是PowerShell&#x…...

【C++】类与对象理解和学习(中)

专栏放在【C知识总结】&#xff0c;会持续更新&#xff0c;期待支持&#x1f339;六大默认成员函数前言每个类中都含有六大默认成员函数&#xff0c;也就是说&#xff0c;即使这个类是个空类&#xff0c;里面什么都没有写&#xff0c;但是编译器依然会自动生成六个默认成员函数…...

每日英语学习(11)大英复习单词和翻译

2023.2.20 单词 1.contemplate 思考、沉思 2.spark 激起 3.venture 冒险 4.stunning 极好的 5.dictate 影响 6.diplomatic 外交的 7.vicious 恶性的 8.premier 首要的 9.endeavor 努力 10.bypass 绕过 11.handicaps 不利因素 12.vulnerable 脆弱的 13.temperament 气质、性格…...

x79主板M.2无法识别固态硬盘

问题描述: 这几天在装电脑&#xff0c;买了块M.2接口固态硬盘。装上去始终无法读取到硬盘&#xff0c;一开始以为是寨板Bios问题不支持M.2的设备。更新了最新的BIOS然后还是没有识别出来&#xff0c;然而将日常用的电脑PM510硬盘装上发现可以识别&#xff0c;而且日常用电脑也…...

配置Tomcat性能优化

配置Tomcat性能优化 &#x1f4d2;博客主页&#xff1a; 微笑的段嘉许博客主页 &#x1f4bb;微信公众号&#xff1a;微笑的段嘉许 &#x1f389;欢迎关注&#x1f50e;点赞&#x1f44d;收藏⭐留言&#x1f4dd; &#x1f4cc;本文由微笑的段嘉许原创&#xff01; &#x1f4…...

Hive3 安装方式详解,datagrid自定义驱动连接hive

1 Hive的安装方式 hive的安装一共有三种方式:内嵌模式、本地模式、远程模式。 元数据服务(metastore&#xff09;作用是&#xff1a;客户端连接metastore服务&#xff0c;metastore再去连接MySQL数据库来存取元数据。有了metastore服务&#xff0c;就可以有多个客户端同时连接…...

约束优化:约束优化的三种序列无约束优化方法(罚函数法)

文章目录约束优化&#xff1a;约束优化的三种序列无约束优化方法&#xff08;罚函数法&#xff09;外点罚函数法L2-罚函数法&#xff1a;非精确算法对于等式约束对于不等式约束L1-罚函数法&#xff1a;精确算法内点罚函数法&#xff1a;障碍函数法参考文献约束优化&#xff1a;…...

你真的会做APP UI自动化测试吗?我敢打赌百分之九十的人都不知道这个思路

目录 前言 一&#xff0c;开发语言选择 二&#xff0c;UI测试框架选择 1&#xff0c;Appium 2&#xff0c;Airtest 3&#xff0c;选择框架 三&#xff0c;单元测试框架选择 四&#xff0c;测试环境搭建 1&#xff0c;测试电脑选择 2&#xff0c;测试手机选择 3&#…...

GIT:【基础三】Git工作核心原理

目录 一、Git本地四个工作区域 二、Git提交文件流程 一、Git本地四个工作区域 工作目录(Working Directory)&#xff1a;电脑上存放开发代码的地方。暂存区(Stage/Index)&#xff1a;用于l临时存放改动的文件&#xff0c;本质上只是一个文件&#xff0c;保存即将提交到文件列…...

【1.12 golang中的指针】

1. 指针 区别于C/C中的指针&#xff0c;Go语言中的指针不能进行偏移和运算&#xff0c;是安全指针。 要搞明白Go语言中的指针需要先知道3个概念&#xff1a;指针地址、指针类型和指针取值。 1.1. Go语言中的指针 Go语言中的函数传参都是值拷贝&#xff0c;当我们想要修改某…...

十五.程序环境和预处理

文章目录一.程序翻译环境和执行环境1.ANSI C 标准2.程序的翻译环境和执行环境二.程序编译和链接1.翻译环境2.编译本身的几个阶段3.运行环境三.预处理1.预定义符号2.#define&#xff08;1&#xff09;#define定义标识符&#xff08;2&#xff09;#define定义宏&#xff08;3&…...

高并发系统设计之负载均衡

本文已收录至Github&#xff0c;推荐阅读 &#x1f449; Java随想录 文章目录DNS负载均衡Nginx负载均衡负载均衡算法负载均衡配置超时配置被动健康检查与主动健康检查LVS/F5Nginx当我们的应用单实例不能支撑用户请求时&#xff0c;此时就需要扩容&#xff0c;从一台服务器扩容到…...

嵌入式Linux从入门到精通之第十四节:Linux IO控制技术

目录 设备控制概述 操作设备文件函数 监听文件描述符 示例 设备控制概述 对于硬件设备,Linux采用了与裸机完全不同的机制进行管理。 Linux下的所有硬件(IO、键盘、鼠标等)均是以文件的形式进行统一管理的,每个设备在/dev/目录下都有一个设备文件与之对应。操作相应的文件…...

/etc/fstab文件

文件/etc/fstab存放的是系统中的文件系统信息&#xff0c;当系统启动的时候&#xff0c;系统会自动地从这个文件读取信息&#xff0c;并且会自动将此文件中指定的文件系统挂载到指定的目录。当正确的设置了该文件&#xff0c;则可以通过mount /directoryname命令来加载一个文件…...

深度学习神经网络基础知识(一) 模型选择、欠拟合和过拟合

专栏&#xff1a;神经网络复现目录 深度学习神经网络基础知识(一) 本文讲述神经网络基础知识&#xff0c;具体细节讲述前向传播&#xff0c;反向传播和计算图&#xff0c;同时讲解神经网络优化方法&#xff1a;权重衰减&#xff0c;Dropout等方法&#xff0c;最后进行Kaggle实…...

同样做软件测试,为什么有人月入3k-5k,有人能拿到17-20k?

同样做软件测试&#xff0c;为什么有人月入3k-5k&#xff0c;有人能拿到17-20k&#xff1f; 虽然各大培训机构一直鼓吹软件测试行业薪资高&#xff0c;但是依旧有一些拿着3-5k薪资&#xff0c;甚至找不到软件测试工作的人。 先来看一些例子&#xff1a; 小A在一家培训机构学完…...

如何运行YOLOv5的代码,实现目标识别

YOLOv5和v8都由Ultralytics这家创业公司开发的https://github.com/ultralytics/yolov5环境配置git clone https://github.com/ultralytics/yolov5.git作者要求python3.6&#xff08;我用的3.8也能跑通&#xff09;torch1.7.0pip install -r requirements_my_version.txtrequire…...

【正点原子FPGA连载】第十四章SD卡读写TXT文本实验 摘自【正点原子】DFZU2EG_4EV MPSoC之嵌入式Vitis开发指南

1&#xff09;实验平台&#xff1a;正点原子MPSoC开发板 2&#xff09;平台购买地址&#xff1a;https://detail.tmall.com/item.htm?id692450874670 3&#xff09;全套实验源码手册视频下载地址&#xff1a; http://www.openedv.com/thread-340252-1-1.html 第十四章SD卡读写…...

【人工智能AI :Open AI】我想写一本书,书名是《中国文学史》,帮我列一下目录,细化到三级目录,不少于2000字。

我想写一本书&#xff0c;书名是《中国文学史》&#xff0c;帮我列一下目录&#xff0c;细化到三级目录&#xff0c;不少于2000字。 中国文学史 第一章 经典文学 1.1 先秦文学 1.1.1 先秦诗歌 1.1.1.1 小雅 1.1.1.2 大雅 1.1.1.3 颂 1.1…...

web vue 项目 Docker化部署

Web 项目 Docker 化部署详细教程 目录 Web 项目 Docker 化部署概述Dockerfile 详解 构建阶段生产阶段 构建和运行 Docker 镜像 1. Web 项目 Docker 化部署概述 Docker 化部署的主要步骤分为以下几个阶段&#xff1a; 构建阶段&#xff08;Build Stage&#xff09;&#xff1a…...

黑马Mybatis

Mybatis 表现层&#xff1a;页面展示 业务层&#xff1a;逻辑处理 持久层&#xff1a;持久数据化保存 在这里插入图片描述 Mybatis快速入门 ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/6501c2109c4442118ceb6014725e48e4.png //logback.xml <?xml ver…...

MFC内存泄露

1、泄露代码示例 void X::SetApplicationBtn() {CMFCRibbonApplicationButton* pBtn GetApplicationButton();// 获取 Ribbon Bar 指针// 创建自定义按钮CCustomRibbonAppButton* pCustomButton new CCustomRibbonAppButton();pCustomButton->SetImage(IDB_BITMAP_Jdp26)…...

循环冗余码校验CRC码 算法步骤+详细实例计算

通信过程&#xff1a;&#xff08;白话解释&#xff09; 我们将原始待发送的消息称为 M M M&#xff0c;依据发送接收消息双方约定的生成多项式 G ( x ) G(x) G(x)&#xff08;意思就是 G &#xff08; x ) G&#xff08;x) G&#xff08;x) 是已知的&#xff09;&#xff0…...

线程与协程

1. 线程与协程 1.1. “函数调用级别”的切换、上下文切换 1. 函数调用级别的切换 “函数调用级别的切换”是指&#xff1a;像函数调用/返回一样轻量地完成任务切换。 举例说明&#xff1a; 当你在程序中写一个函数调用&#xff1a; funcA() 然后 funcA 执行完后返回&…...

蓝桥杯 2024 15届国赛 A组 儿童节快乐

P10576 [蓝桥杯 2024 国 A] 儿童节快乐 题目描述 五彩斑斓的气球在蓝天下悠然飘荡&#xff0c;轻快的音乐在耳边持续回荡&#xff0c;小朋友们手牵着手一同畅快欢笑。在这样一片安乐祥和的氛围下&#xff0c;六一来了。 今天是六一儿童节&#xff0c;小蓝老师为了让大家在节…...

定时器任务——若依源码分析

分析util包下面的工具类schedule utils&#xff1a; ScheduleUtils 是若依中用于与 Quartz 框架交互的工具类&#xff0c;封装了定时任务的 创建、更新、暂停、删除等核心逻辑。 createScheduleJob createScheduleJob 用于将任务注册到 Quartz&#xff0c;先构建任务的 JobD…...

【HTTP三个基础问题】

面试官您好&#xff01;HTTP是超文本传输协议&#xff0c;是互联网上客户端和服务器之间传输超文本数据&#xff08;比如文字、图片、音频、视频等&#xff09;的核心协议&#xff0c;当前互联网应用最广泛的版本是HTTP1.1&#xff0c;它基于经典的C/S模型&#xff0c;也就是客…...

网站指纹识别

网站指纹识别 网站的最基本组成&#xff1a;服务器&#xff08;操作系统&#xff09;、中间件&#xff08;web容器&#xff09;、脚本语言、数据厍 为什么要了解这些&#xff1f;举个例子&#xff1a;发现了一个文件读取漏洞&#xff0c;我们需要读/etc/passwd&#xff0c;如…...

return this;返回的是谁

一个审批系统的示例来演示责任链模式的实现。假设公司需要处理不同金额的采购申请&#xff0c;不同级别的经理有不同的审批权限&#xff1a; // 抽象处理者&#xff1a;审批者 abstract class Approver {protected Approver successor; // 下一个处理者// 设置下一个处理者pub…...