简介:这是一份基于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 + OkHttp | Volley | 注解式接口定义,和Kotlin协程的suspend配合最省代码 |
| 数据解析 | Gson | Moshi / kotlinx.serialization | 兼容字段变更,改动最小 |
| 本地存储 | Room 2.6 | SQLite / DataStore | 编译期校验SQL,返回Flow或LiveData都能直接观察 |
| 图片加载 | Coil 2.5 | Glide / Fresco | Kotlin协程友好,包体小,菜单列表量级够用 |
| 状态管理 | ViewModel + LiveData | 手写Presenter | 生命周期安全,屏幕旋转不丢数据 |
| 异步 | kotlinx.coroutines | RxJava | 项目没有复杂背压和线程切换,协程更轻 |
依赖写入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/order | OrderRequest | OrderResult | 提交订单,返回订单号 |
| GET | /api/order/abc123 | 无 | OrderStatus | 查询订单当前状态 |
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的电子点餐系统在中小餐厅的日常到店流量下已经足够稳定。
本文还有配套的精品资源,点击获取