简介:这是一份基于 Android Studio 的仿美团外卖菜单实战项目源码包,面向已掌握安卓基础、希望通过完整案例提升工程能力的开发者。项目围绕菜单列表展示与交互展开,涉及约束布局等界面设计、列表控件与适配器、本地数据库和偏好存储、网络请求与数据解析、图片加载缓存,以及权限管理、版本兼容、单元测试和界面测试,适合用于课程设计、毕业项目或技能进阶参考。压缩包采用 7z 格式,共 541 个文件、约 10.31MB,包含 93 个 xml 布局与配置、128 个 json 数据、143 个 flat 资源、12 个 dex、11 个 jar、8 个 java 源码及 png、webp 图片等,覆盖源码、构建产物和资源文件。已有 12733 人学习,便于对照完整目录理解项目结构、梳理关键实现,帮助读者从界面到数据链路形成整体认识,也可作为项目复盘样例,并积累实战排错经验。
1. 仿外卖点餐页的难点不在布局,在双列表联动
打开任何一个外卖 App 的店铺页,左边一列分类、右边一长条菜品,看着平平无奇。真用 Android Studio 从零搭一个仿外卖点餐菜单出来,才会发现布局那一步十分钟就写完了,剩下的时间全耗在「左边点了右边滚到哪」「右边滑着左边高亮跟着变」这两件事上。分类选中态闪回、吸顶头把第一条菜压住、快速连点之后位置错乱,这些都不是大问题,但每一个都能让一个能跑的 Demo 变成不能用的半成品。这篇笔记按我自己的实现顺序走:先把菜单模型和布局骨架定死,再把左侧分类、右侧吸顶头和滚动反推一段段写出来,最后落到加购和购物车浮层。适合已经能写 RecyclerView、但联动一直做得不顺手的 Android 开发。
2. 布局与数据结构:先把菜单模型定死
2.1 两个 RecyclerView 还是一个多类型列表
做仿外卖点餐菜单,右侧菜品区有三种典型写法,选错了后面全在还债。
第一种是「外层 RecyclerView 套内层 RecyclerView」,外层每个 item 是一个分类,内层再放一个横向铺满的 RecyclerView 渲染这个分类下的菜。这个写法看着分层清晰,实际用起来最难受:内层列表要全部setNestedScrollingEnabled(false),否则滑动事件会被内层吃掉;每个分类都要单独建一个 Adapter,加购数量变化的时候得挨个通知内层刷新;分类一多,一次性把十几条菜全绑上来,内存和首帧都会难看。除非你的菜品区是那种「每个分类下面还带横向楼层」的结构,否则别走这条路。
第二种是「左右两个独立 RecyclerView 并排」。这是最常见的做法,也是我推荐的。左侧一个窄的RecyclerView只放分类,右侧一个宽的RecyclerView放「分类头 + 菜品」的混合列表。两个列表各自管各自的滚动,互不干扰,联动完全靠监听和代码控制。好处是每一帧只渲染可见的那十几条,滚动再快也不会卡;坏处是需要自己处理同步,也就是后面几章要讲的东西。
第三种是「单个 RecyclerView,用 ViewType 区分左侧和右侧」。这种写法会把你拖进手动计算宽度、手动处理横竖混排的深坑,除了极少数定制场景,没有理由选它。
所以最后的结构是:rvCategory+ 分割线 +rvDish,底部一条购物车栏。右侧那个RecyclerView承担了吸顶头、菜品项、滚动反推三件事,是整页的核心。
2.2 菜单数据模型:价格存整数分
模型这块的坑主要在价格上。用Double存金额,购物车里累加几次就会冒出39.999999这种数字,再toString到角标上,就是线上事故。所以单价从一开始就用整数存「分」,只在显示的时候除以 100。
// Menu.kt —— 菜单模型,金额统一用「分」存 Int,避免浮点累加误差 data class Category( val id: Int, val name: String, val dishList: List<Dish> ) data class Dish( val id: Int, val name: String, val priceFen: Int, // 单价,单位:分 val originPriceFen: Int? = null, val soldCount: Int = 0, // 月售,用于展示「月售 300+」 val desc: String = "", val imageUrl: String = "" ) { // 只在渲染时转成元,展示层拿到的永远是格式化好的字符串 val priceYuan: String get() = "%.2f".format(priceFen / 100f) } // 购物车条目,和菜品是一对多关系,用 dishId 做 key data class CartItem( val dish: Dish, var count: Int )priceFen用Int而不是Long,是因为单个菜品的单价不可能超过 21 亿分(约 2100 万元),Int足够;但购物车总价要换成Long累加,几十个菜乘起来虽然也到不了上限,但养成习惯更稳。priceYuan写成计算属性,是为了让 Adapter 里只写一句binding.tvPrice.text = dish.priceYuan,不在 UI 层做任何算术。
2.3 XML 骨架与几个必须定死的尺寸参数
布局本身不复杂,但有几个尺寸必须在 XML 里一次定死,后面所有偏移计算都要用到它们,中途改一次会牵动一大片。
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 左侧分类:宽度固定,不随屏幕伸缩 --> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rvCategory" android:layout_width="96dp" android:layout_height="0dp" android:background="#F5F5F5" android:overScrollMode="never" app:layout_constraintBottom_toTopOf="@id/cartBar" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <View android:id="@+id/divider" android:layout_width="0.5dp" android:layout_height="0dp" android:background="#EEEEEE" app:layout_constraintBottom_toTopOf="@id/cartBar" app:layout_constraintStart_toEndOf="@id/rvCategory" app:layout_constraintTop_toTopOf="parent" /> <!-- 右侧菜品:宽度 0dp 撑满剩余空间 --> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rvDish" android:layout_width="0dp" android:layout_height="0dp" android:clipToPadding="false" android:paddingBottom="8dp" app:layout_constraintBottom_toTopOf="@id/cartBar" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/divider" app:layout_constraintTop_toTopOf="parent" /> <include android:id="@+id/cartBar" layout="@layout/layout_cart_bar" android:layout_width="0dp" android:layout_height="56dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>| 参数 | 取值 | 为什么是这个值 |
|---|---|---|
| 左侧分类宽度 | 96dp | 再窄中文分类名会折行,再宽会压缩菜品区;窄屏机型可以用 88dp |
| 分割线宽度 | 0.5dp | 1dp 在高密度屏上偏粗,0.5dp 正好是一条视觉上的细线 |
| 购物车栏高度 | 56dp | 符合 Material 的最小可点区域,低于 48dp 容易误触 |
| 吸顶分类头高度 | 44dp | 与列表内普通分类头保持一致,切换时不会有高度跳变 |
| 菜品项左边距 | 12dp | 和分割线拉开呼吸感,同时保证图片不会贴边 |
clipToPadding="false"这一行别省。右侧列表给它加上paddingBottom之后,如果不关掉裁剪,最后一个分类滚到底会被切掉一截,看起来像数据丢了,实际是 padding 被裁掉了。这个现象我第一次遇到时排查了快半小时。
3. 左侧分类列表:选中态、居中与点击驱动
3.1 分类 Adapter 与选中态刷新
左侧列表的 item 极简,只有一个文字,但选中态的处理方式直接决定滑动时会不会掉帧。
class CategoryAdapter( private val data: List<Category> ) : RecyclerView.Adapter<CategoryAdapter.VH>() { // 当前选中的分类下标,唯一数据源 var selectedIndex = 0 private set var onCategoryClick: ((Int) -> Unit)? = null // 只刷新「旧选中」和「新选中」两个位置,不做全量刷新 fun select(index: Int) { if (index == selectedIndex) return // 判重是防抖动的第一道关 if (index !in data.indices) return val old = selectedIndex selectedIndex = index notifyItemChanged(old) notifyItemChanged(index) } inner class VH(val binding: ItemCategoryBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val binding = ItemCategoryBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return VH(binding) } override fun onBindViewHolder(holder: VH, position: Int) { val item = data[position] holder.binding.tvName.text = item.name // 用 isSelected 驱动 selector,不在代码里手动改背景色 holder.binding.tvName.isSelected = position == selectedIndex holder.binding.root.setOnClickListener { onCategoryClick?.invoke(position) } } override fun getItemCount() = data.size }这里最关键的两句是if (index == selectedIndex) return和notifyItemChanged(old)。前者挡住重复回调——右侧列表一次滑动会连续触发几十次onScrolled,如果不判重,每一帧都会走一遍notifyItemChanged,左侧列表在快速滑动时会出现肉眼可见的闪烁。后者避免了notifyDataSetChanged(),那个方法会让整个列表重新绑定,分类一多就是明显的掉帧。
选中态本身交给 selector 处理,res/color/category_text_selector.xml里写两个状态:state_selected="true"给深色加粗,默认给灰色。这样切换选中态时不需要在 Adapter 里碰setTextColor,也就不存在「上一个没恢复」的问题。
3.2 三个滚动 API 的差别,以及我为什么只用其中一个
RecyclerView 控制滚动位置有三个常用方法,很多人混着用,结果就是「点一下滚不到位」或者「滚过去又被弹回来」。
val lm = rvDish.layoutManager as LinearLayoutManager // 1)瞬间定位,没有任何动画。特点是执行完立刻就到,但用户看不到过程 lm.scrollToPosition(targetPos) // 2)平滑滚动,有动画。但它只保证「目标可见」,不保证停在顶部 rvDish.smoothScrollToPosition(targetPos) // 3)瞬间定位,并让目标条目顶部距离 RecyclerView 顶部 offset 像素 lm.scrollToPositionWithOffset(targetPos, offsetPx)第一个和第三个的区别在于「停在哪」。scrollToPosition只保证目标进可视区,如果目标本来就可见,它甚至不会滚;scrollToPositionWithOffset是强制的,一定把目标条目的顶部对齐到「父容器顶部 + offsetPx」这个位置。
左侧点击驱动右侧,要的正是「这个分类的第一条菜必须出现在顶部下方」,所以只用第三个。第二个方法在这里帮不上忙:它滚得慢,用户连点两下就会看到列表来回晃,体验反而更差。
// 吸顶头高度从 dimen 里取,别在代码里写死像素 private val stickyHeaderPx by lazy { resources.getDimensionPixelSize(R.dimen.dish_header_height) } private var suppressRightCallback = false categoryAdapter.onCategoryClick = { index -> // 1. 左侧自身先切选中态 categoryAdapter.select(index) // 2. 找到这个分类第一条菜品在右侧的 adapter position val target = menuAdapter.positionOfCategory(index) // 3. 标记「这次滚动是代码触发的」,防止右侧反推又把选中改回去 suppressRightCallback = true // 4. offset 传吸顶头高度,否则第一条菜会被吸顶头压住 (rvDish.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(target, stickyHeaderPx) // 5. 让左侧被点的分类尽量滚到可视区中间 centerCategoryInList(index) // 兜底释放,防止右侧没触发滚动时标志位一直挂着 rvDish.postDelayed({ suppressRightCallback = false }, 120L) } // 左侧自身滚动:目标是让选中项落在可视区中间,而不是简单滚到可见 private fun centerCategoryInList(index: Int) { val lm = rvCategory.layoutManager as LinearLayoutManager val visibleCount = lm.findLastVisibleItemPosition() - lm.findFirstVisibleItemPosition() + 1 // 减去半个可视高度,让选中项居中 val target = index - visibleCount / 2 rvCategory.smoothScrollToPosition( target.coerceIn(0, categoryAdapter.itemCount - 1) ) }scrollToPositionWithOffset的第二个参数单位是像素,不是 dp。这是最常见的错误来源:直接传44以为传的是 44dp,在高密度屏上吸顶头只有 44px 高,菜品依然会被盖住一半。正确做法是从dimen.xml里用getDimensionPixelSize取,让系统按密度换算好。
centerCategoryInList里那个visibleCount / 2是让选中项居中的关键。如果直接smoothScrollToPosition(index),选中项只会滚到「刚好可见」的边界,用户点的时候视线在屏幕下方,点完列表纹丝不动,会以为没响应。
3.3 菜单数据从 assets 的 JSON 读进内存
做 Demo 的时候菜品数据通常来自本地,放在assets/menu.json里。结构跟数据类对齐,解析的时候注意必填和选填的区分。
object MenuRepository { fun load(context: Context): List<Category> { val json = context.assets.open("menu.json") .bufferedReader().use { it.readText() } val arr = JSONArray(json) val result = ArrayList<Category>(arr.length()) for (i in 0 until arr.length()) { val c = arr.getJSONObject(i) // 分类名为空说明数据有问题,直接跳过比渲染一个空分类更安全 val name = c.optString("name") if (name.isBlank()) continue val dishArr = c.optJSONArray("dishList") ?: JSONArray() val dishes = ArrayList<Dish>(dishArr.length()) for (j in 0 until dishArr.length()) { val d = dishArr.getJSONObject(j) dishes.add( Dish( id = d.getInt("id"), // 必填,缺失直接抛异常 name = d.getString("name"), priceFen = d.getInt("priceFen"), soldCount = d.optInt("soldCount", 0), // 选填给默认值 desc = d.optString("desc"), imageUrl = d.optString("imageUrl") ) ) } // 空分类不进列表:右侧没有菜品可滚,联动会算出越界位置 if (dishes.isEmpty()) continue result.add(Category(c.getInt("id"), name, dishes)) } return result } }必填字段用getInt/getString,选填字段用optInt/optString带默认值。这个区分不是风格问题:必填字段缺失说明数据源有问题,早点崩在解析阶段,比等到 UI 上出现一个叫 "null" 的分类更容易定位。optString在没有这个 key 的时候返回空字符串而不是 null,省掉了后面一层判空。
两个continue也很重要。空分类如果在列表里存在,positionOfCategory会算出指向下一个分类头的下标,点下去右侧滚到的是别的分类,这种 bug 在数据少的时候完全看不出来,上线接了真实接口才炸。
4. 右侧菜品列表:吸顶分类头与滚动反推
4.1 MenuAdapter:把分类和菜品拍平成一维列表
右侧列表里既有「分类头」又有「菜品」,最省事的做法不是嵌套,而是把它们拍平成一个一维数组,用 ViewType 区分。
class MenuAdapter( private val data: List<Category> ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { companion object { const val TYPE_HEADER = 0 const val TYPE_DISH = 1 const val PAYLOAD_COUNT = "payload_count" } // 拍平后的索引表:position -> (categoryIndex, dishIndex) // dishIndex == -1 表示这一项是分类头 private val flat = ArrayList<Pair<Int, Int>>() init { rebuild() } private fun rebuild() { flat.clear() data.forEachIndexed { ci, category -> flat.add(ci to -1) // 分类头 category.dishList.forEachIndexed { di, _ -> flat.add(ci to di) // 菜品 } } } // 某个分类的第一条菜品位置 = 分类头位置 + 1 fun positionOfCategory(categoryIndex: Int): Int { val headerPos = flat.indexOfFirst { it.first == categoryIndex && it.second == -1 } return if (headerPos < 0) 0 else headerPos + 1 } fun isHeader(position: Int): Boolean = flat[position].second == -1 fun categoryOf(position: Int): Int = flat[position].first fun categoryName(categoryIndex: Int): String = data[categoryIndex].name fun dishOf(position: Int): Dish { val (ci, di) = flat[position] return data[ci].dishList[di] } override fun getItemViewType(position: Int): Int = if (isHeader(position)) TYPE_HEADER else TYPE_DISH override fun getItemCount(): Int = flat.size }flat这张表是整个联动的地基。有了它,position、分类下标、菜品下标三者之间的换算全是一次数组访问,不需要任何嵌套循环。positionOfCategory返回headerPos + 1而不是headerPos,是因为滚过去的落点应该是第一条菜,不是分类头本身——分类头会被吸顶头遮住,滚到它没有意义。
这里有个隐含前提:每个分类至少要有一条菜。3.3 里已经过滤掉空分类了,所以headerPos + 1一定落在菜品上,不会串到下一个分类头。
4.2 ItemDecoration 手写吸顶头
吸顶分类头有两种实现:一种是给每个分类头一个「sticky」的标记,在onDrawOver里把它画到顶部;另一种是用第三方库。我一般手写,因为需求往往会有细微差别(比如分类名右边要带一个「共 N 件」),手写改起来更自由。
class StickyHeaderDecoration( private val adapter: MenuAdapter, private val headerHeight: Int, // px,从 dimen 取 private val textSizePx: Float, // px,从 dimen 取 private val bgColor: Int = Color.WHITE ) : RecyclerView.ItemDecoration() { private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = bgColor } private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#333333") textSize = textSizePx // 必须传 px,写死 13f 在高密度屏上会小到看不清 } // 给每个分类的第一条菜品预留吸顶头高度,避免内容被压住 override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State ) { val pos = parent.getChildAdapterPosition(view) if (pos == RecyclerView.NO_POSITION) return // 分类头本身也要占位,这样第一条菜品不会贴在吸顶头下面 if (adapter.isHeader(pos)) { outRect.top = 0 } else if (adapter.isHeader(pos - 1) || pos == 0) { outRect.top = 0 } } override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { val lm = parent.layoutManager as? LinearLayoutManager ?: return val first = lm.findFirstVisibleItemPosition() if (first == RecyclerView.NO_POSITION) return val categoryIndex = adapter.categoryOf(first) var offsetY = 0f // 找到下一个可见的分类头。如果它已经顶到吸顶头下方, // 就把吸顶头往上推,形成推挤效果 for (i in 0 until parent.childCount) { val child = parent.getChildAt(i) val pos = parent.getChildAdapterPosition(child) if (pos == RecyclerView.NO_POSITION || pos == first) continue if (adapter.isHeader(pos)) { if (child.top < headerHeight) { offsetY = (child.top - headerHeight).toFloat() } break } } // 吸顶头背景 c.drawRect(0f, offsetY, parent.width.toFloat(), offsetY + headerHeight, bgPaint) // 文字垂直居中:baseline 要通过字体度量算,不能用 headerHeight / 2 val title = adapter.categoryName(categoryIndex) val baseline = offsetY + headerHeight / 2f - (textPaint.descent() + textPaint.ascent()) / 2f c.drawText(title, parent.resources.displayMetrics.density * 12f, baseline, textPaint) } }这段代码里有三个地方值得单独说。
第一,getItemOffsets里我把outRect.top全部设成了 0,看起来像没做事。实际上空间是由 XML 里分类头 item 自身的高度提供的,getItemOffsets在这里主要起兜底作用——如果你后面改成「分类头不占 item 空间」的方案,才需要在这里给菜品加outRect.top = headerHeight。两种方案都能跑,但只能选一种,混用会导致间距翻倍。
第二,onDrawOver里的循环是找「下一个可见的分类头」,不是first + 1。因为first + 1可能是一条菜品,adapter.isHeader判断会失败,推挤效果就永远不出现。遍历childCount是 O(可见项数),最多十几个,性能上可以忽略。
第三,文字 baseline 的计算不能偷懒写offsetY + headerHeight / 2f。那样算出来的是文字的中间线,实际渲染会整体偏下小半个字高。正确写法是把descent + ascent的平均值减掉,这个细节在短文字上不明显,分类多的时候整列看起来会有点歪。
注册的方式和普通 ItemDecoration 一样:
rvDish.addItemDecoration( StickyHeaderDecoration( adapter = menuAdapter, headerHeight = resources.getDimensionPixelSize(R.dimen.dish_header_height), textSizePx = resources.getDimension(R.dimen.text_13sp) ) )注意resources.getDimension返回的已经是 px(sp 会按字体缩放换算),不需要再手动乘 density。
4.3 滚动反推当前分类与防止抖动的标志位
滚动反推的逻辑本身很短:右侧滚到什么位置,就把左侧对应的分类设为选中。
rvDish.addOnScrollListener(object : RecyclerView.OnScrollListener() { private var lastUpdateTime = 0L private val throttleMs = 50L // 50ms 内的连续回调只处理一次 override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (dy == 0) return // 代码触发的滚动不回写,否则点分类会导致左闪闪闪 if (suppressRightCallback) return val now = SystemClock.elapsedRealtime() if (now - lastUpdateTime < throttleMs) return lastUpdateTime = now val lm = recyclerView.layoutManager as LinearLayoutManager val first = lm.findFirstVisibleItemPosition() if (first == RecyclerView.NO_POSITION) return val ci = menuAdapter.categoryOf(first) if (ci != categoryAdapter.selectedIndex) { categoryAdapter.select(ci) centerCategoryInList(ci) } } override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { // 滑动停止时释放标志位,这是最可靠的释放时机 if (newState == RecyclerView.SCROLL_STATE_IDLE) { suppressRightCallback = false } } })这里不用判断 ViewType。因为categoryOf(first)返回的是第一可见项所属的分类下标,不管是分类头还是菜品,都属于同一个分类,答案是一样的。少一个分支就少一个出错点。
suppressRightCallback是整段逻辑里最容易被写错的地方。它的作用是区分「这次滚动是人手滑的」还是「代码点分类触发的」。如果不做区分,点分类 → 右侧滚动 →onScrolled触发 → 反推 → 又去改选中 → 又触发滚动,列表就会来回抖。
释放时机我踩过两个版本。第一版是在post {}里释放,结果右侧的onScrolled是分帧分发的,post跑完了滚动事件还在来,标志位已经放了,抖动照旧。第二版改成postDelayed(120ms),能跑但机器一卡就不准。最终定在onScrollStateChanged的IDLE,因为滚动结束时状态一定会回到 IDLE,这是最稳的信号。3.2 里的postDelayed兜底不能省,它防的是「右侧本来就在这个位置、根本没触发滚动」的情况,没有这个兜底,下一次手动滑动右侧时左侧就不跟了。
5. 联动菜单避坑记录:从现象到解决的 5 条排查清单
5.1 点分类后第一条菜被吸顶头压住
现象:点「主食」分类,右侧确实滚过去了,但最上面那道菜只露出下半截,上半截在分类头底下。
原因:scrollToPositionWithOffset(target, 0)把目标条目顶到了 RecyclerView 的顶部边缘,而吸顶头是onDrawOver画上去的,绘制层级在 item 之上,于是盖住了第一行。offset 传 0 等于完全没给吸顶头让位。
解决:offset 传stickyHeaderPx,让目标条目顶部对齐到吸顶头下沿。如果分类头本身也占据 item 空间(我这里的方案),那么 offset 只需要吸顶头高度,不要重复加分类头高度。
5.2 手动快速滑动右侧,左侧高亮疯狂闪烁
现象:手指快速上下滑右侧菜品,左侧选中项像是坏了,来回跳。
原因:一次手指滑动会连续触发几十次onScrolled,每次都调用categoryAdapter.select(ci)。虽然select里有判重,但快速滑过多个分类时,ci每次都不一样,判重挡不住,等于每一帧都在刷两个 item。
解决:一是在onScrolled里加 50ms 节流,滑动过程中每 50ms 才更新一次选中;二是select里保留判重作为最后一道防线。实测加上节流之后,连续滑过十几个分类左手边也只更新三四次,视觉上完全够用。
5.3 连续快速点三个分类,右侧停在中间那个
现象:手快连点「热销」「套餐」「饮品」,期望停在「饮品」,实际停在「套餐」。
原因:scrollToPositionWithOffset是同步的,但它触发的onScrolled是异步分发的。第三次点击时
本文还有配套的精品资源,点击获取