仿美团外卖双栏菜单:RecyclerView 滚动联动实现与调优
2026/9/17 10:09:13 网站建设 项目流程

简介:一份基于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 version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_category" android:layout_width="88dp" android:layout_height="match_parent" android:background="#F3F3F3" android:scrollbars="none" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_goods" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:background="#FFFFFF" android:scrollbars="vertical" /> </LinearLayout>

左侧宽度一般取 88dp 到 96dp。太窄放不下四字分类,太宽会挤压右侧商品图片的展示空间。右侧不要嵌套 ScrollView,RecyclerView 自己处理滚动就够了。

2.2 分类、商品与扁平数据源

菜单联动里最值得先想清楚的是数据结构。分类和商品分开建模,右侧列表运行时拼成一份扁平列表:

public class CategoryBean { public String name; public ArrayList<GoodsBean> 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; // 归属哪个分类,用下标索引 }

左侧列表直接用ArrayList<CategoryBean>,右侧列表则是一个List<Object>,交替存放“分类头部”和“商品条目”。构造时同步生成一个分区起始位置表:

List<Object> 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就是上面构造的flatList,Adapter 构造函数里直接传入。这样做的另一个好处是商品滚动位置天然连续,分类 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.pngmenu/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 LruCache<String, Bitmap> memoryCache = new LruCache<>( (int) (Runtime.getRuntime().maxMemory() / 8) );

缓存大小取最大内存的八分之一是通用经验值。图片 URL 场景下还要考虑磁盘缓存和 HTTP 缓存头,纯 assets 本地图不进网络,LruCache 就够了。在每个 ImageView 的绑定回调里先取缓存,没有再走异步加载,可以避免主线程反复 decode 大图造成列表滑动卡顿。

菜单图在视觉上要统一尺寸。建议用占位图验证布局时直接让 ImageViewscaleType="centerCrop",固定宽高比例 1:1 或 4:3。没有真实素材时可以在 assets 里放一张纯色占位图,本地环境下反复跑滚动测试,内存曲线稳定后再替换真实图片。验证时打开 AndroidStudio 的 Profile 面板,快速上下滑动一分钟,观察内存增量不超过 20MB 基本就算合格。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询