Android点餐系统开发实战:从MVVM架构到购物车与下单流程
2026/9/19 12:53:28 网站建设 项目流程

简介:这是一份基于Android的电子点餐系统毕业设计论文资源,面向具备一定编程基础、希望了解Android应用开发或餐饮信息化系统设计的学习者与研究者。内容围绕传统纸质菜单的弊端,完整呈现了从系统需求分析、总体设计到功能实现与测试的过程,涉及Java语言、Eclipse开发环境、SQLite数据库等技术栈,并包含系统功能结构图、数据流程图、主要界面图及中英文摘要。文档还覆盖了显示菜品分类、单价、口味、已点数量与总价等核心功能,以及结账说明、测试过程等章节,可直接用作餐厅服务信息化升级的参考方案,也可作为Android开发教学案例。资源为1个docx文档,压缩包仅58KB,便于快速下载浏览。目前已有170人学习,对于需要快速梳理论文框架或借鉴点餐系统设计思路的用户来说,是一份结构完整、可直接参考的毕业论文资料。

1. 从扫码到上菜,Android点餐系统到底做了什么

餐厅的纸质菜单翻来翻去,服务员下单靠喊,高峰期一桌菜等半小时——这不是段子,是中小餐饮店的日常。基于Android的电子点餐系统要解决的,就是把"看菜、选菜、下单"这条链路从人工搬进手机:顾客扫码打开菜单,加购、提交、后厨接单,全程不依赖服务员转述。这套系统从技术上说不算复杂,但要做到好用的程度,埋着不少细节:购物车的数据结构怎么设计、下单时的库存校验怎么防并发、页面旋转购物车会不会丢。整条链路里需要做的几个关键决定,下面逐个过一遍,从架构、数据建模到功能实现和上线验证,覆盖Android开发者从课程设计到商业落地的完整路径。

2. 整体架构与Android技术选型:先定四个决定

2.1 为什么点餐应用适合MVVM而不是MVC

点餐应用有一个典型特征:数据状态多,UI联动频繁。购物车角标、分类列表的选中态、菜品卡片上的加号数量、订单提交按钮的可用状态,全部都要跟随购物车数据变化。用MVC写,Activity要同时承接所有点击事件和数据处理,一个MainActivity超过一千行是常态,后期加一个"桌号备注"功能都要在Activity里翻半天。

MVP把交互逻辑抽到Presenter里,表面上解决了Activity膨胀,但Presenter和View之间被迫声明大量接口。新增一个购物车删除功能,要给CartPresenter和CartView各加一个方法,改动面积同步放大。MVVM的不同在于数据驱动:ViewModel暴露LiveData,UI在onCreate绑定一次,之后所有界面元素跟随数据自己刷新。购物车里某一项数量从1变到2时,LiveData自动通知角标、列表项和结算栏三处更新,不需要三处都写手动调用的代码。

实际落地时不用真的上全套DataBinding,用ViewBinding加ViewModel就够了。DataBinding的表达式语法在布局文件里写逻辑,调试时多一层模板生成错误,点餐项目的UI关联用viewModel.cartItems.observe(this) { ... }这行代码就能表达清楚,没必要为了MVVM而MVVM。购物车数量加减都收拢到CartViewModel的同一个方法里,UI只观察一个数据源,所有页面显示的购物车状态天然一致,ViewModel把业务逻辑和Activity生命周期解耦,旋转屏幕时数据自动保留。

2.2 Android Studio里的依赖选型:一个表格说清

技术选型不是看着流行就选,要按点餐业务的实际请求量来衡量。绝大多数餐厅的单店并发在几十个请求以内,技术选型的第一原则是省事、社区方案成熟、团队接手门槛低。下面这个表格是这套系统在Android Studio里的基础技术栈,按模块、选型、备选方案、选择理由四列排列:

模块选型备选方案选择理由
网络请求Retrofit 2.9 + OkHttpVolley注解式接口定义,和Kotlin协程的suspend配合最省代码
数据解析GsonMoshi / kotlinx.serialization兼容字段变更,改动最小
本地存储Room 2.6SQLite / DataStore编译期校验SQL,返回Flow或LiveData都能直接观察
图片加载Coil 2.5Glide / FrescoKotlin协程友好,包体小,菜单列表量级够用
状态管理ViewModel + LiveData手写Presenter生命周期安全,屏幕旋转不丢数据
异步kotlinx.coroutinesRxJava项目没有复杂背压和线程切换,协程更轻

依赖写入app/build.gradle的dependencies闭包,注意Room需要用到kapt或ksp插件,否则编译器不会生成Dao实现:

android { compileSdk 34 buildFeatures { viewBinding true } } dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.room:room-runtime:2.6.1' kapt 'androidx.room:room-compiler:2.6.1' implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'io.coil-kt:coil:2.5.0' }

compileSdk和minSdk建议分别设为34和24。minSdk 24覆盖Android 7.0及以上的设备,餐厅用来点餐的手机通常不是旧机型,提高minSdk能少处理很多运行时权限分支。viewBinding开启后,布局文件会自动生成对应的Binding类,比findViewById安全,也比已经废弃的kotlin-android-extensions快。

2.3 设计模式在点餐系统里的落点与项目分包

不需要框架级设计模式体系,最常见的三个模式的落点就在这个项目的代码里。单例模式封装网络层和数据库层,Retrofit和Room的实例创建代价高,整个App公用一份即可。观察者模式就是LiveData和Flow的底层机制,UI层只需要观察数据源的改变。工厂模式用在ViewModel的创建环节,通过ViewModelProvider.Factory把Retrofit接口注入到ViewModel构造函数中,避免ViewModel内部直接拿全局单例。

项目结构按业务功能分包而不是按技术层次分包。按menu、cart、order三个核心业务域建包,每个包内放自己的Fragment、Adapter、ViewModel和资源,和业务相关的数据模型也放在对应包内,公共的network和database包只保存框架级代码。要修改下单逻辑时,进order目录就能看清全部相关文件,不需要在adapter和viewmodel之间来回跳。Android Studio里同名类在跳转时会混淆,加业务前缀命名,MenuFragment和CartFragment比FragmentA和FragmentB好排查得多。

3. 数据模型与接口设计:菜单、购物车、订单怎么建模

3.1 Room实体:菜品表与订单表的设计要点

业务上最核心的表是菜品表(dish)、订单表(order_table)、订单明细表(order_item)。菜单是商家维护的基础资料,订单是顾客一次下单动作的产物,明细把订单和菜品联系起来。初次设计时容易犯的错误是在订单表里存一个逗号拼接的菜品ID字符串,后面要统计某个菜卖了多少份时,SQL查询写起来非常痛苦。正确做法是把订单和购物车里的每一行拆成两张表,用orderId关联。

菜品表的最简建模如下:

@Entity(tableName = "dish") data class Dish( @PrimaryKey val id: Long, // 菜品唯一ID val name: String, // 菜品名称 val price: Double, // 单价,展示用 val category: String, // 分类,如“热菜” val imageUrl: String, // 图片地址 val stock: Int // 剩余库存,仅用于展示 )

price用Double在计算总价时会出现浮点精度问题,严格做法是用Int存储以分为单位的金额,展示层再除以100转换成带两位小数的字符串。stock字段在菜单展示阶段只用于界面提示"已售罄",真正的扣减动作由后端在订单接口里完成,客户端的stock只做展示参考。

订单表不建外键是有意为之。外键约束会在删除父记录时执行级联操作,但订单是历史数据,提交之后不应该被菜品表的变化影响。订单主键也不推荐用自增Long,而是用服务端生成的业务单号,格式可以是日期加随机数,这样后端在下单接口做幂等去重时有天然的幂等键:

@Entity(tableName = "order_item") data class OrderItemEntity( @PrimaryKey val itemId: Long, val orderId: String, // 订单业务单号 val dishId: Long, val dishName: String, // 下单时菜品名快照 val unitPrice: Double, // 下单时单价快照 val quantity: Int )

明细表里的dishName和unitPrice是冗余存储,这是故意的:订单提交过后商家改了菜价,甚至下架了某个菜,历史订单里的菜名和价格仍然保持提交时的快照,对账时有据可查。

3.2 购物车的存储边界:本地内存还是服务端

扫码点餐和电商App的购物车有一个本质区别:顾客不会隔几天再来把购物车结算掉,购物车的生命周期就是一次用餐时长,通常几十分钟。因此不必要把每一笔加购操作都同步到服务端,常见做法是购物车放在客户端,用ViewModel持有,配合Room做进程被系统回收后的恢复。

数据保存在ViewModel里时,旋转屏幕和配置变更都不会丢,因为它脱离了Activity生命周期。但进程被系统杀掉后,ViewModel也会消失。恢复方案有两种:一种是把购物车快照存进Room的本地表,另一种是存SharedPreferences。在菜品数据本身已经在Room里有一份的情况下,我倾向于用Room存购物车表,查询、更新、清空都是一行SQL的事,不需要自己处理JSON序列化。SharedPreferences存复杂对象要借助Gson转字符串,提交订单时还要经历一遍反序列化,代码更绕。

购物车的同步问题发生在多端协同场景:一桌人分别用两台手机扫同一个桌号点餐时,两份购物车在服务端要合并成同一个订单。这种情况必须把购物车合并逻辑放到后端,每次加购请求携带菜品ID和数量,后端做合并。如果只是单人点餐,纯本地方案实现最简单,注意写入购物车表时用事务包裹"读旧值、计算新数量、写回"这三步,避免多线程环境下数量错乱。

3.3 用Retrofit定义一套可对接后端的接口

后端接口按REST风格拆成菜单和订单两组。菜单只有GET请求,订单有提交和查询。Retrofit接口定义如下:

interface DiningApi { @GET("api/menu") suspend fun getMenu(@Query("category") category: String? = null): Response<List<Dish>> @POST("api/order") suspend fun submitOrder(@Body request: OrderRequest): Response<OrderResult> @GET("api/order/{orderId}") suspend fun getOrderStatus(@Path("orderId") orderId: String): Response<OrderStatus> }

三个方法分别对应拉取菜单、提交订单、查询订单状态。suspend关键字让这几个方法可以在协程里直接调用,不需要写Call回调。返回值统一用Response包裹,这样后端返回4xx或5xx也不抛异常,业务错误和网络错误可以在代码里分别处理。getMenu的category参数可空,为空时返回全量菜单,前端按分类做分组展示。

提交订单的请求体需要体现桌号、明细列表和备注:

data class OrderRequest( val tableNo: String, // 桌号,例如 "A12" val items: List<OrderItemReq>, val remark: String // 口味备注 ) data class OrderItemReq( val dishId: Long, val quantity: Int )

后端收到OrderRequest后验库存、算总价、生成订单号返回。接口路径在debug和release环境通常不一致,Retrofit的baseUrl建议放在BuildConfig里,用buildConfigField区分测试服和正式服,避免每次切换环境都改代码:

buildTypes { debug { buildConfigField "String", "BASE_URL", "\"https://test-api.example.com/\"" } release { buildConfigField "String", "BASE_URL", "\"https://api.example.com/\"" } }

BuildConfig功能需要在android闭包里单独打开buildConfig true,否则生成的类里找不到BASE_URL字段。接口对照关系整理如下:

方法路径请求体响应体说明
GET/api/menu?category=热菜List<Dish>category为空返回全量
POST/api/orderOrderRequestOrderResult提交订单,返回订单号
GET/api/order/abc123OrderStatus查询订单当前状态

OrderStatus里的状态值建议和后端约定为数字而不是字符串,比如0已提交、1后厨制作中、2已完成、3已取消,数字在前后端都便于走when分支,字符串多一个大小写不一致的隐患。

4. 核心功能实现:菜单加载、购物车与下单流程

4.1 用DiffUtil做菜单列表的局部刷新

菜单数据的规模决定了加载策略。中小餐厅的菜品通常只有几十道上限,一次性拉全量加客户端分组是最直接的方案。分页加载在这个场景是过度设计,还要处理加载更多触发条件、已到结尾的标记、分页中分类切换的竞态,投入和收益不成正比。

菜单页面要做的是把后端返回的List<Dish>按category分组,使用RecyclerView展示,每道菜一个卡片。数据更新时不要直接notifyDataSetChanged,这个方法会让整个列表重建,菜单图片闪一下、滚动位置跳回去,体验很差。用DiffUtil计算新旧列表差异后定向更新:

class MenuAdapter : RecyclerView.Adapter<MenuAdapter.DishViewHolder>() { private val items = mutableListOf<Dish>() fun submitDishes(newItems: List<Dish>) { val oldItems = items.toList() val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { override fun getOldListSize(): Int = oldItems.size override fun getNewListSize(): Int = newItems.size override fun areItemsTheSame(old: Dish, new: Dish): Boolean = old.id == new.id override fun areContentsTheSame(old: Dish, new: Dish): Boolean = old == new }) items.clear() items.addAll(newItems) diff.dispatchUpdatesTo(this) } }

DiffUtil的回调里,areItemsTheSame判断的是"是不是同一个菜品",用id比较;areContentsTheSame判断的是"同一个菜品的内容有没有变化",直接比整个数据类。第二层比较会让价格改动、售罄状态变化、图片地址变化只更新对应item的显示。菜品被后端下架时,DiffUtil计算出remove操作,列表自动做移除动画。

4.2 购物车的不可变Map实现与原子性

购物车的数据结构选用MutableLiveData<Map<Long, CartItem>>,键是菜品ID,值是该菜品的数量和当前单价信息。每次数量变化时生成一个新的不可变Map替换旧值,这样LiveData的观察者不会漏掉任何一次更新。用可变Map的add/remove操作,然后手动调用value = value触发通知,效果等同但容易忘,不可变Map把"每次修改必然产生新引用"变成语言层面的保证:

class CartViewModel : ViewModel() { private val _cartItems = MutableLiveData<Map<Long, CartItem>>(emptyMap()) val cartItems: LiveData<Map<Long, CartItem>> = _cartItems fun addDish(dish: Dish) { val current = _cartItems.value ?: return val currentItem = current[dish.id] val newItem = if (currentItem == null) { CartItem(dish, 1) } else { currentItem.copy(quantity = currentItem.quantity + 1) } _cartItems.value = current + (dish.id to newItem) } fun decrease(dishId: Long) { val current = _cartItems.value ?: return val currentItem = current[dishId] ?: return val newMap = if (currentItem.quantity <= 1) { current - dishId } else { current + (dishId to currentItem.copy(quantity = currentItem.quantity - 1)) } _cartItems.value = newMap } fun clearCart() { _cartItems.value = emptyMap() } }

CartItem是一个data class,包含菜品信息字段和quantity数量字段。加购数量的上限校验在客户端同样要做,菜品对象里的stock字段就是用来干这个的,当加购后的数量超过stock时直接Toast提示"已售罄"。购物车里存的是Dish当前价格信息,价格更新后购物车显示总价只是个预览值,最终价格由服务端下单时重新计算。

LiveData更新位置有讲究:setValue必须在主线程调用,postValue可以在任意线程,但postValue在快速连续调用时会合并中间态。购物车场景的所有操作都发生在UI线程的点击回调里,用setValue最合适。将来如果要把购物车操作搬到协程的IO线程,一定记得改用postValue。

4.3 下单的幂等控制与异常分支处理

用户点击"确认下单"后是一个网络请求,最常见的故障是连点了两次按钮发出两笔完全一样的订单。防重复的常见做法是下单请求发出时用一个isSubmitting标志位做互斥,请求结束后不管成功失败都复位。这个标志位放在ViewModel里,Activity重建不会丢失:

class OrderViewModel(private val api: DiningApi) : ViewModel() { private var isSubmitting = false fun submitOrder(tableNo: String, items: List<OrderItemReq>) = viewModelScope.launch { if (isSubmitting) return@launch isSubmitting = true try { val response = api.submitOrder(OrderRequest(tableNo, items, "")) if (response.isSuccessful) { onOrderSuccess(response.body()!!) } else { handleBusinessError(response.code()) } } catch (e: IOException) { orderErrorMsg.value = "网络连接失败,购物车数据未丢失" } finally { isSubmitting = false } } }

viewModelScope.launch把协程绑定在ViewModel生命周期上,页面销毁时协程自动取消。isSubmitting的互斥只覆盖了客户端单设备场景,两道菜库存只剩一份、两台设备同时提交的场景,必须由后端在扣减库存时加行级锁或乐观锁处理,客户端收到的HTTP状态码会是409。

常见的非2xx响应码对应的业务含义和处理方式如下:

HTTP状态码业务含义客户端处理
200下单成功跳转订单详情页,清空购物车
400参数缺失(桌号为空、明细为空)Toast提示"请完善订单信息"
409库存不足或菜品已下架提示具体菜品名,回列表刷新
503后厨系统临时不可用提示稍后重试,不销毁购物车

409提示的具体菜品名,最好由后端在响应体json的message字段里返回,前端原样展示。客户端不维护一份菜品错误码表,避免后端加了某个菜品的限售规则,前端还要发版才能适配。

注意:下单失败时购物车里的数据不要清空。用户调整数量后还要再次提交,清空会导致用户被迫重新选一遍。

5. 上线前的性能优化与验证清单:少走三个弯路

5.1 图片加载与列表的流畅性

菜单列表的图片是个隐藏性能坑。Coil加载图片时磁盘缓存默认开启,内存缓存由LruCache管理,基础用法够用。需要注意图片URL的设计:后端返回的imageUrl通常会带一个尺寸参数,列表缩略图和菜品详情页大图应该请求不同尺寸,而不是同一个URL让Coil做二次采样。后端的图片服务一般支持在路径末尾拼接宽高参数,列表用200x200的WebP,详情页用720px宽的原图,加载速度和流量都更优。

如果点餐系统里还有管理员端需要拍照新增菜品,选图和拍照直接使用ActivityResultContracts.PickVisualMedia和TakePicture这类Activity Result API,它们返回的content:// URI不需要手动申请存储权限,Coil可以直接加载显示。老代码里用startActivityForResult加onActivityResult回调的方式已经不推荐维护,迁移到Activity Result API的同时把AndroidManifest里冗余的存储权限声明一并删掉。上传图片时注意ContentResolver里读取到的InputStream用完即关,否则图片较多时App会出现TransactionTooLargeException。

5.2 订单状态的同步策略与弱网处理

提交订单后的状态刷新,使用轮询是点餐场景里最简单的方案。每隔3秒调用一次getOrderStatus接口,把返回的状态映射到界面上。轮询的启动位置放在LifecycleScope里,onResume启动、onPause取消,页面在后台时不消耗流量。接口设计时把状态查询独立成单独接口,将来换WebSocket或SSE,只需要把查询逻辑替换成消息接收,UI层代码不动。

一个小细节:轮询回来后不要每次都把整个订单详情刷新一遍,比较status字段变化后再更新页面状态文字和背景色,降低界面重组频率。网络超时时间建议设置得比普通请求短一些,订单详情页的超时设为5秒,快速失败后提示用户手动下拉刷新。

5.3 上线前必跑的场景清单

最后给一张适合在Android Studio模拟器里直接执行的验证清单,每一项都对应真实发生过的问题:

检查项操作预期结果
弱网提交开启飞行模式后点击下单提示网络失败,购物车保持原样
双击提交连续点击下单按钮只产生一个订单,第二个请求被拦截
库存临界将某菜品库存改为1后不同设备同时加购后到请求收到409库存不足提示
旋转屏幕下单页旋转手机购物车数量与选中项不变
进程回收开发者选项开启"不保留活动",退出再进入购物车从Room恢复,无崩溃
时间跨天订单详情页跨天测试显示当天日期,无八小时时差

这张表里的每一项都值得在提测前自己跑一遍。进程回收这一项最容易暴露问题:全套MVVM架构但忘了Room的购物车恢复逻辑,或者时间字段用了Date类型导致缺少TypeConverter,崩溃日志只会在用户手机上出现。把购物车Dish的copy方法和Room的TypeConverter提前写好,下单接口的幂等键用服务端返回的orderId,菜单接口加一个简单的本地缓存,这套基于Android的电子点餐系统在中小餐厅的日常到店流量下已经足够稳定。

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

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

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

立即咨询