FEATURED · 精选文章

Android点餐系统开发实战:从MVVM架构到购物车与下单流程

发布时间 / 2026/9/19 12:53:29
来源 / 创域科博编辑部
栏目 / 资讯中心
Android点餐系统开发实战:从MVVM架构到购物车与下单流程 简介这是一份基于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暴露LiveDataUI在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): ResponseListDish POST(api/order) suspend fun submitOrder(Body request: OrderRequest): ResponseOrderResult GET(api/order/{orderId}) suspend fun getOrderStatus(Path(orderId) orderId: String): ResponseOrderStatus }三个方法分别对应拉取菜单、提交订单、查询订单状态。suspend关键字让这几个方法可以在协程里直接调用不需要写Call回调。返回值统一用Response包裹这样后端返回4xx或5xx也不抛异常业务错误和网络错误可以在代码里分别处理。getMenu的category参数可空为空时返回全量菜单前端按分类做分组展示。提交订单的请求体需要体现桌号、明细列表和备注data class OrderRequest( val tableNo: String, // 桌号例如 A12 val items: ListOrderItemReq, 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热菜无ListDishcategory为空返回全量POST/api/orderOrderRequestOrderResult提交订单返回订单号GET/api/order/abc123无OrderStatus查询订单当前状态OrderStatus里的状态值建议和后端约定为数字而不是字符串比如0已提交、1后厨制作中、2已完成、3已取消数字在前后端都便于走when分支字符串多一个大小写不一致的隐患。4. 核心功能实现菜单加载、购物车与下单流程4.1 用DiffUtil做菜单列表的局部刷新菜单数据的规模决定了加载策略。中小餐厅的菜品通常只有几十道上限一次性拉全量加客户端分组是最直接的方案。分页加载在这个场景是过度设计还要处理加载更多触发条件、已到结尾的标记、分页中分类切换的竞态投入和收益不成正比。菜单页面要做的是把后端返回的ListDish按category分组使用RecyclerView展示每道菜一个卡片。数据更新时不要直接notifyDataSetChanged这个方法会让整个列表重建菜单图片闪一下、滚动位置跳回去体验很差。用DiffUtil计算新旧列表差异后定向更新class MenuAdapter : RecyclerView.AdapterMenuAdapter.DishViewHolder() { private val items mutableListOfDish() fun submitDishes(newItems: ListDish) { 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实现与原子性购物车的数据结构选用MutableLiveDataMapLong, CartItem键是菜品ID值是该菜品的数量和当前单价信息。每次数量变化时生成一个新的不可变Map替换旧值这样LiveData的观察者不会漏掉任何一次更新。用可变Map的add/remove操作然后手动调用value value触发通知效果等同但容易忘不可变Map把每次修改必然产生新引用变成语言层面的保证class CartViewModel : ViewModel() { private val _cartItems MutableLiveDataMapLong, CartItem(emptyMap()) val cartItems: LiveDataMapLong, 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: ListOrderItemReq) viewModelScope.launch { if (isSubmitting) returnlaunch 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的电子点餐系统在中小餐厅的日常到店流量下已经足够稳定。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻