尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

仿美团外卖双栏菜单:RecyclerView 滚动联动实现与调优

仿美团外卖双栏菜单:RecyclerView 滚动联动实现与调优 简介一份基于Android Studio的仿美团外卖菜单实战项目完整代码包适合Android初学者或需要练习界面搭建、Gradle配置与图片资源管理的开发者。项目实现了餐厅列表、菜品分类、左右菜单联动等核心界面代码和图片素材配套齐全可直接导入Android Studio运行调试。压缩包共541个文件主要包含java源码、xml布局、gradle构建脚本、json数据以及png/webp图片资源并附有app-debug.apk便于直接安装查看效果构建脚本和资源目录按Android规范组织有助于理解模块划分与打包流程包体约13.53MB。目前已有2319人浏览学习。通过该项目可掌握Android项目目录组织、Gradle自动化构建、drawable/mipmap资源分类、事件监听与数据绑定等关键技能也能体会从布局设计到功能联调的真实开发流程对想从零复刻商业App菜单交互的开发者很有参考价值。1. 仿美团外卖菜单凭什么做出来简单做对却很难双栏菜单是很多 AndroidStudio 初学者练手时第一个想做的完整界面左边一列分类右边一列商品图片从本地资源里加载。做完列表骨架很快问题几乎都出在联动上——右侧往上滑左侧分类要么不动要么在几个分类之间来回跳手指停下来之后左侧高亮又对不上。用“当前第一个可见商品直接映射分类”的思路做视觉上看着合理快速甩动时并不可靠。这一篇按项目该有的结构整理一套可运行的实现双栏 RecyclerView 布局、分类和商品的数据模型、两个适配器的职责划分、assets 图片读取与缓存以及最核心的滚动联动与点击分类平滑定位。代码按可直接粘贴的粒度给出参数含义和调参方向会一并讲清楚。适合刚学完 RecyclerView 基础、想用完整小项目练手的人也适合项目里已经写了联动但总被测试提“左侧选中项不对”的开发对照排查。2. AndroidStudio 菜单工程搭建双栏布局、数据模型与资源目录先把页面拆成三个部分左侧分类列表、右侧商品列表、一份共享的联动数据。联动逻辑不要堆在 Activity 里后面调参数会非常难受。2.1 用两个 RecyclerView 拼出仿美团外卖菜单骨架activity_main.xml用横向 LinearLayout左侧固定宽度右侧占剩余空间?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationhorizontal androidx.recyclerview.widget.RecyclerView android:idid/rv_category android:layout_width88dp android:layout_heightmatch_parent android:background#F3F3F3 android:scrollbarsnone / androidx.recyclerview.widget.RecyclerView android:idid/rv_goods android:layout_width0dp android:layout_heightmatch_parent android:layout_weight1 android:background#FFFFFF android:scrollbarsvertical / /LinearLayout左侧宽度一般取 88dp 到 96dp。太窄放不下四字分类太宽会挤压右侧商品图片的展示空间。右侧不要嵌套 ScrollViewRecyclerView 自己处理滚动就够了。2.2 分类、商品与扁平数据源菜单联动里最值得先想清楚的是数据结构。分类和商品分开建模右侧列表运行时拼成一份扁平列表public class CategoryBean { public String name; public ArrayListGoodsBean goodsList; public CategoryBean(String name) { this.name name; this.goodsList new ArrayList(); } } public class GoodsBean { public String goodsId; public String name; public String imgPath; // assets 下的路径 public int categoryIndex; // 归属哪个分类用下标索引 }左侧列表直接用ArrayListCategoryBean右侧列表则是一个ListObject交替存放“分类头部”和“商品条目”。构造时同步生成一个分区起始位置表ListObject flatList new ArrayList(); int[] sectionStart new int[categories.size()]; int index 0; for (int i 0; i categories.size(); i) { sectionStart[i] index; // 当前分类的 header 在扁平列表中的位置 flatList.add(categories.get(i)); // 分类名那一行 index; for (GoodsBean goods : categories.get(i).goodsList) { flatList.add(goods); index; } }sectionStart是后面所有联动计算的地基。它记录每个分类在右侧列表里的第一个位置比每次滚动时都去遍历商品判断归属要快得多也避免字符串比较分类名带来的隐患。2.3 右侧适配器必须区分 Header 与商品两种 Item右侧列表一个 Adapter 处理两种视图类型分类标题和商品卡片。getItemViewType用 instanceof 判断public static final int TYPE_HEADER 0; public static final int TYPE_GOODS 1; Override public int getItemViewType(int position) { Object item dataList.get(position); return item instanceof CategoryBean ? TYPE_HEADER : TYPE_GOODS; }onCreateViewHolder根据 viewType 加载不同布局分类标题只显示一个 TextView商品卡片里放商品图和名称、价格。两个 ViewHolder 各自持有一份 view 引用不要混用。数据源dataList就是上面构造的flatListAdapter 构造函数里直接传入。这样做的另一个好处是商品滚动位置天然连续分类 Header 不需要额外计算相对位置。2.4 图片资源放 assets读取路径与基础工具方法仿美团外卖这种菜单图建议放assets/menu/下不建议大量堆进 drawable。drawable 里的图片参与资源编译与混淆处理图片一多构建速度会明显下降assets 目录按相对路径读取后续换成网络图只需要改一个加载入口。public static Bitmap readBitmapFromAssets(Context context, String assetPath) { try { InputStream is context.getAssets().open(assetPath); Bitmap bitmap BitmapFactory.decodeStream(is); if (is ! null) { is.close(); } return bitmap; } catch (IOException e) { e.printStackTrace(); return null; } }context.getAssets()只能通过 Context 拿不能用 ApplicationContext 之外的随便一个生命周期对象。路径要写相对 assets 根目录的路径例如menu/beef.png。文件名大小写敏感Menu/beef.png和menu/beef.png是两个地址测试时是最容易踩的一个坑。3. 右侧滚动左侧分类乱跳的本质锚点计算与过半修正很多双栏菜单一开始都用最简单的思路拿到右侧第一个可见 item 的 position然后反推它属于哪个分类再让左侧选中那个分类。实际跑起来之后会发现几个问题右侧商品行高只要不一致分类和左侧高亮就对不上快速滚动时右侧位置一顿一顿地变左侧高亮就在相邻分类之间疯狂横跳。3.1 用sectionStart建立位置到分类的映射滚动时最基本的映射逻辑是从sectionStart倒序遍历找到最后一个不大于当前位置的分类下标private int findCategoryByPosition(int position) { for (int i sectionStart.length - 1; i 0; i--) { if (position sectionStart[i]) { return i; } } return 0; }传入的 position 是LinearLayoutManager.findFirstVisibleItemPosition()的返回值如果右侧列表滚动到某个商品位置倒序遍历能快速确定它属于哪个分类。这个遍历的前提是sectionStart数组有序构造时按分类顺序追加数据即可保证。3.2 只看第一个可见 item 的缺陷半个商品露出也会触发切换沿用上面的映射逻辑当右侧第一个可见 item 是某个分类 header 的最后几个像素时左侧就已经提前切过去了。反过来如果 header 已经滚出去但它的最后一个商品在屏幕上方只露出一条底边左侧还停留在上一个分类视觉上慢半拍。这里的修正办法是计算第一个可见 item 内部实际滚过了多少距离超过 item 高度的一半才把下一个 item 当作当前锚点public class CategoryLinker extends RecyclerView.OnScrollListener { private final LinearLayoutManager goodsManager; private final LinearLayoutManager categoryManager; private final GoodsAdapter goodsAdapter; private final CategoryAdapter categoryAdapter; private final int[] sectionStart; private int currentCategory -1; Override public void onScrolled(NonNull RecyclerView recyclerView, int dx, int dy) { int firstPos goodsManager.findFirstVisibleItemPosition(); if (firstPos RecyclerView.NO_POSITION) return; // 过半修正露出不足一半不切换左侧选中项 View firstView goodsManager.findViewByPosition(firstPos); if (firstView ! null) { int top firstView.getTop(); if (-top firstView.getHeight() / 2) { firstPos; } } int target findCategoryByPosition(firstPos); if (target ! currentCategory) { currentCategory target; categoryAdapter.setSelected(target); // 让左侧滚动到选中的分类避免分类多时高亮项跑出屏幕 categoryManager.scrollToPositionWithOffset(target, 0); } } }findViewByPosition只对当前已经布局出来的 item 有效在onScrolled里调用没问题。判断条件是-top height / 2也就是该 item 的上边缘已经滑出屏幕超出一半此时下一个 item 才“值得”接管锚点。3.3 商品行高不一致时的退化方案如果右侧商品卡高度不固定比如有的展示两行文字有的展示三行height / 2这个阈值就会失效。大卡片一半可能 30dp小卡片一半可能只有 15dp视觉上左侧切换的时机忽早忽晚。行高差异很大的场景我一般直接把findFirstVisibleItemPosition换成findFirstCompletelyVisibleItemPosition。虽然切换时机晚一些但至少保证“用户看到的完整商品归属分类”和高亮一致。这个方案动态流畅性稍差胜在稳定适合商品信息块高低差距大的仿美团外卖菜单。3.4 快速滑动的关键幂等更新高频滚动时onScrolled一秒会回调几十次左侧setSelected不能每次都触发。上面代码里通过currentCategory做了幂等判断只有 target 变化时才刷新左侧。if (target ! currentCategory) { currentCategory target; ... }这行判断看着简单实际价值很大它过滤掉相同分类之间的重复回调让左侧的notifyItemChanged不会在滚动时高频执行也能避免左侧 Adapter 反复重绘造成掉帧。4. 点击分类跳转与平滑滚动SmoothScroller 调参与手势冲突点击左侧分类右侧要准确定位到对应分区这是整个仿美团外卖菜单里交互要求最高的一步。直接用scrollToPosition(sectionStart[i])一瞬间跳过去没有过渡不符合 App 的滚动体验用默认smoothScrollToPosition又可能滚得极其慢在分类多、商品多的列表里用户会失去耐心。4.1 自定义 LinearSmoothScroller 控制滚动速度RecyclerView自带的平滑滚动速度受系统 display metrics 影响默认速度在长列表里偏慢。自定义时主要调两个方法private void smoothScrollToCategory(int categoryIndex) { SmoothScroller smoothScroller new LinearSmoothScroller(this) { Override protected int getVerticalSnapPreference() { return LinearSmoothScroller.SNAP_TO_START; } Override protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { return 120f / displayMetrics.densityDpi; } Override protected void onStop() { super.onStop(); isProgramScrolling false; } }; smoothScroller.setTargetPosition(sectionStart[categoryIndex]); goodsManager.startSmoothScroll(smoothScroller); isProgramScrolling true; }SNAP_TO_START让目标 item 停在 RecyclerView 顶部正好是分类 header 吸附的效果。calculateSpeedPerPixel返回值单位是“每像素毫秒数”值越大滚动越慢120f 在大多数屏幕上大概是每像素 0.5 ms 左右视觉速度接近美团外卖的真实手感。想要更激进可以改成 80f但商品列表很长时会感觉像“砸”过去。4.2 程序滚动会打断用户手势触摸监听复位自定义 SmoothScroller 开始滚动后如果用户中途用手指按住列表右侧的平滑滚动不能继续跑否则会出现列表自己动、用户手指也拖动的双重冲突。需要在一开始就处理触摸打断rightList.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() { Override public boolean onInterceptTouchEvent(NonNull RecyclerView rv, NonNull MotionEvent e) { if (e.getActionMasked() MotionEvent.ACTION_DOWN isProgramScrolling) { isProgramScrolling false; rightList.stopScroll(); } return false; } Override public void onTouchEvent(NonNull RecyclerView rv, NonNull MotionEvent e) {} Override public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {} });OnItemTouchListener优先于 Adapter/ItemTouch 逻辑执行所以这里拦截 ACTION_DOWN 能做到“用户一碰就停”不用去 Activity 里覆写 dispatchTouchEvent。注意stopScroll()调用之后SmoothScroller 的onStop也会被触发把isProgramScrolling再清一次所以复位逻辑要写在监听回调里。4.3 程序滚动结束后左侧高亮必须收敛到目标分类平滑滚动中途会触发onScrolled如果不加限制左侧高亮会跟着滚动路径一路跳最后虽然到了目标分类但用户看到的是左侧分类各种闪烁。处理方式是在滚动过程中抑制联动回调public void onScrolled(...) { if (isProgramScrolling) { return; // 程序滚动过程中不做左侧跟随 } // 正常的左侧联动逻辑 ... }onStop里再把isProgramScrolling置为 false此时不需要依赖最后一次onScrolled直接调用categoryAdapter.setSelected(targetCategory)即可。这样左侧不会在滚动过程中产生无谓的刷新体验最好。4.4 双击分类、分类未变化时的边界处理快速双击左侧两个分类时第一次平滑滚动还没结束第二次点击触发了新滚动旧 SmoothScroller 会被回收isProgramScrolling的状态链容易断。建议在smoothScrollToCategory开头加一行rightList.stopScroll()把上一次没走完的滚动停掉再启动新的。点击当前已选中的分类时sectionStart[target]几乎不动这种情况下也不需要重新滚动。判断一下currentCategory ! targetCategory能省去一次无效动画调用。4.5 常见联动异常排查症状原因调整方式左侧高亮只在滚动停止后才变化onScrolled里没有做幂等判断重复调用太严重被系统限制确保currentCategory更新逻辑正确必要时打印日志确认回调频率点击分类跳到空白位置sectionStart 数组构造时机不对数据源还没填充完就开始滚动在notifyDataSetChanged()后重新计算 sectionStart平滑滚动巨慢默认 speedPerPixel 未覆盖按上面示例重写calculateSpeedPerPixel左列表项增多时高亮项跑到屏幕外只setSelected没有滚动左侧调用categoryManager.scrollToPositionWithOffset(target, 0)程序滚动时左侧疯狂闪烁isProgramScrolling没有同步复位在触摸 ACTION_DOWN 和 SmoothScroller onStop 两处都置 false这五个问题覆盖了我在不同项目里遇到过的绝大多数双栏菜单 bug排查顺序建议按下表从上往下先看滚动回调再看数据源最后查状态位。5. 菜单图片资源再多也不 OOM采样解码与缓存复用图片资源是“仿美团外卖菜单”里最容易忽略的一环。assets 图片直接BitmapFactory.decodeStream全尺寸加载菜单里如果有几十张 1080p 图片快速滚动时内存瞬间暴涨低端机闪退是必然结果。菜单列表的图不需要原始分辨率一张 200dp 宽的商品图加载 1080×1620 的原图纯属浪费。核心做法是先用inJustDecodeBounds读取图片宽高计算采样率inSampleSize再真正解码public static Bitmap decodeSampledBitmap(InputStream is, int reqWidth, int reqHeight) { BitmapFactory.Options options new BitmapFactory.Options(); options.inJustDecodeBounds true; BitmapFactory.decodeStream(is, null, options); int sampleSize 1; while (options.outWidth / sampleSize reqWidth * 2 || options.outHeight / sampleSize reqHeight * 2) { sampleSize * 2; } options.inJustDecodeBounds false; options.inSampleSize sampleSize; return BitmapFactory.decodeStream(is, null, options); }注意两次解码用的是同一个 InputStream读取 bounds 之后流的位置已经在末尾第二次 decode 前必须重新context.getAssets().open()。项目里更常见的做法是第一次打开流读 bounds关闭后再次打开做正式解码。图片加载进来之后用 LruCache 缓存key 用 assets 下的相对路径。因为 assets 图片内容相对固定应用进程生命周期内不会发生变化缓存命中率很高private final LruCacheString, Bitmap memoryCache new LruCache( (int) (Runtime.getRuntime().maxMemory() / 8) );缓存大小取最大内存的八分之一是通用经验值。图片 URL 场景下还要考虑磁盘缓存和 HTTP 缓存头纯 assets 本地图不进网络LruCache 就够了。在每个 ImageView 的绑定回调里先取缓存没有再走异步加载可以避免主线程反复 decode 大图造成列表滑动卡顿。菜单图在视觉上要统一尺寸。建议用占位图验证布局时直接让 ImageViewscaleTypecenterCrop固定宽高比例 1:1 或 4:3。没有真实素材时可以在 assets 里放一张纯色占位图本地环境下反复跑滚动测试内存曲线稳定后再替换真实图片。验证时打开 AndroidStudio 的 Profile 面板快速上下滑动一分钟观察内存增量不超过 20MB 基本就算合格。本文还有配套的精品资源点击获取
返回列表