简介:这是一份面向Android开发初学者的移动点餐系统实战项目源码,聚焦餐饮行业典型业务场景,涵盖客户端与服务端协同开发全流程,适用于高校课程设计、课后实验及移动端入门实践。资源包含149个文件,总大小2.68MB:其中23个Java源文件构成ph2客户端核心逻辑(如OrderActivity、PayActivity等),55个class文件为编译产物,20个XML用于界面布局与资源配置,39个PNG提供UI图标素材,另有jar、apk、properties等支撑构建与运行;结构清晰体现Android MVC分层思想。目前已有165人学习下载。读者可直接导入Android Studio运行ph2.apk体验完整点餐流程,同步参考ph2web服务端设计思路,掌握从UI交互、购物车管理、HTTP订单提交到SQLite本地存储的全链路开发技能,并通过源码理解Activity生命周期、Service后台服务及网络通信等关键知识点。
1. 移动点餐源代码:一个能真正在安卓手机上跑通、改得动、上线前敢压测的完整点餐系统
上周帮某高校实验室调试一个毕业设计项目,学生拿着「移动点餐源代码」压缩包发来消息:“老师,为什么启动就闪退?登录页空白?后台接口404?”——结果解压一看,是套结构清晰、包名规范、连 Retrofit 配置都带注释的 Android Studio 工程,app/src/main/java/com/example/foodorder/下从model到viewmodel到network分层明确,build.gradle里连 OkHttp 的日志拦截器都开着。这不是 Demo,是能直接连真实后端(或本地 Mock)跑通“浏览菜单→加购→下单→查看订单”全链路的生产级骨架。它不炫技,没用 Jetpack Compose 做实验性重构,专注在 Activity + Fragment + ViewModel + LiveData 这套经受过千场线上灰度验证的组合上;它适合两类人:一是刚学完《第一行代码》想啃真实项目的新手,二是需要快速搭个 MVP 版本给客户看原型的外包开发者。你不需要懂 Spring Boot,只要会改几个 IP 和端口,就能让这个 App 在 Pixel 5 或红米 Note 12 上完成一次真实下单——这才是「移动点餐源代码」最硬核的价值。
2. 项目结构与技术栈:从 Gradle 依赖到包目录,看清它是怎么“稳住”的
这套代码不是靠堆砌新特性撑场面,而是用一套克制但扎实的技术组合,在 Android 10~14 上保持高度兼容性。我把它拆成三层来看:构建层、架构层、能力层。构建层决定你能不能顺利 sync;架构层决定你改一行代码会不会牵出十处 bug;能力层决定它能不能干点实事,比如扫码支付、离线缓存、订单状态推送。
2.1 构建配置:Gradle 脚本里的生存指南
app/build.gradle是整个项目的入口契约。它没用 AGP 8.0+ 的新 DSL,停在 7.4.2(对应 Android Studio Flamingo),这是关键——太多新手卡在 AGP 版本不匹配导致kapt报错。核心依赖如下:
dependencies { implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.9.0' // 网络层:Retrofit 2.9.0 + OkHttp 4.11.0(带自动重试和日志) implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.okhttp3:okhttp:4.11.0' implementation 'com.squareup.okhttp3:logging-interceptor:4.11.0' // 架构组件:ViewModel + LiveData + Room(1.2.1,避开了 2.x 的迁移坑) implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2' implementation 'androidx.room:room-runtime:2.5.0' implementation 'androidx.room:room-ktx:2.5.0' kapt 'androidx.room:room-compiler:2.5.0' // 其他:Glide 加载图片、EventBus 解耦、Timber 日志 implementation 'com.github.bumptech.glide:glide:4.15.1' implementation 'org.greenrobot:eventbus:3.3.1' implementation 'com.jakewharton.timber:timber:5.0.1' }提示:
kapt 'androidx.room:room-compiler:2.5.0'这行必须存在,否则@Entity注解不会生成 DAO 实现类,编译时不会报错,但运行时Room.databaseBuilder()会抛IllegalStateException——这是新手最常踩的静默陷阱。
2.2 包结构解析:为什么ui.order下有OrderListActivity而不是OrderFragment
整个 Java 目录按功能垂直切分,不是按 MVC 水平分层。这种结构让新人改一个页面时,所有相关代码(布局、逻辑、数据绑定)都在同一包下,不用满项目跳转:
com.example.foodorder/ ├── base/ // BaseActivity / BaseFragment / BaseAdapter(含空状态处理) ├── model/ // Entity 类:FoodItem.kt, Order.kt, CartItem.kt(含 @Entity 注解) ├── network/ // ApiService.kt(Retrofit 接口)、ApiResult.kt(泛型封装)、NetworkModule.kt(OkHttpClient 配置) ├── repository/ // FoodRepository.kt(聚合网络+DB)、CartRepository.kt(含本地 Room 操作) ├── ui/ // 所有界面入口 │ ├── home/ // HomeActivity.kt + home_fragment.xml │ ├── menu/ // MenuActivity.kt + menu_recycler_view.xml(含分类 TabLayout) │ ├── order/ // OrderListActivity.kt(历史订单)、OrderDetailActivity.kt(订单详情) │ └── cart/ // CartActivity.kt(购物车)、CheckoutActivity.kt(结算页) ├── viewmodel/ // 每个 UI 对应一个 ViewModel:HomeViewModel.kt、CartViewModel.kt(含 LiveData 状态暴露) └── util/ // StringUtils.kt、TimeUtils.kt、SPUtils.kt(SharedPreferences 封装)注意:ui.order下是Activity而非Fragment,因为订单列表页需独立任务栈(避免返回键误跳回菜单页),且涉及分享、通知跳转等强生命周期场景——这是从某外卖 App 线上崩溃率报表里抄来的血泪经验:Fragment 在复杂导航中onDestroyView()后LiveDataobserve 未移除,极易引发内存泄漏。
2.3 核心能力模块:它到底能干几件事?
别被“点餐”二字局限。这套代码实际提供了 5 个可独立启用/禁用的能力模块:
| 模块名 | 技术实现 | 是否默认启用 | 关键文件 |
|---|---|---|---|
| 在线菜单浏览 | RecyclerView + DiffUtil + Glide | 是 | MenuActivity.kt,MenuAdapter.kt |
| 购物车本地持久化 | Room DB + CartRepository | 是 | CartDao.kt,CartDatabase.kt |
| 订单提交与状态同步 | Retrofit + WebSocket(Mock) | 否(需配 WebSocket 地址) | OrderService.kt,OrderWebSocket.kt |
| 支付模拟 | WebView 加载 H5 支付页(沙箱) | 是(mock) | PaymentWebViewActivity.kt |
| 离线菜单缓存 | Room + Retrofit Cache Control | 是 | @Headers("Cache-Control: public, max-age=3600") |
特别说明:WebSocket 模块被注释掉了,但保留了OrderWebSocket.kt和OrderStateObserver.kt。如果你要接入真实支付回调,只需取消OrderListActivity.onCreate()中的// initWebSocket()注释,并填入你的 WSS 地址——它已预埋心跳保活和断线重连逻辑,不用从零写。
3. 运行前必做三件事:改 IP、启 Mock、清缓存,绕开 90% 的首次失败
很多开发者解压即 run,结果卡在java.net.ConnectException: Failed to connect to /10.0.2.2:8080。这不是代码问题,是环境契约没对齐。这三步做完,80% 的人能直接看到首页菜单。
3.1 修改 API 基地址:从 localhost 切到你的开发机 IP
Android 模拟器里localhost指向模拟器自身,不是宿主机。所以ApiService.kt里这行必须改:
// network/ApiService.kt interface ApiService { @GET("api/v1/foods") suspend fun getFoods(): ApiResponse<List<FoodItem>> } // 对应的 BaseUrl 配置在 NetworkModule.kt object NetworkModule { private const val BASE_URL = "http://10.0.2.2:8080/" // ← 模拟器专用 // 改成你的宿主机 IP,例如: // private const val BASE_URL = "http://192.168.1.105:8080/" }注意:Windows 用户若用 WSL2,IP 应填
172.28.0.1(WSL2 默认网关);Mac 用户若用 Parallels Desktop,IP 填10.37.129.2。不确定?在宿主机终端执行ifconfig | grep "inet " | grep -v 127.0.0.1,取第一个非 127 的 IPv4 地址。
3.2 启动 Mock 后端服务:用 JSON Server 快速喂数据
项目没附带后端,但提供了mock/db.json文件。你只需:
# 全局安装 JSON Server(需 Node.js) npm install -g json-server # 进入项目根目录,启动 Mock 服务 json-server --watch mock/db.json --port 8080 --delay 500此时访问http://localhost:8080/api/v1/foods应返回 JSON 数组。--delay 500是关键:模拟真实网络延迟,避免因请求太快导致DiffUtil动画失效或LiveData观察者来不及注册。
3.3 清除旧 APK 缓存:解决“改了代码却还是旧界面”
Android Studio 的 Instant Run(现为 Apply Changes)在某些机型上会残留旧 class。务必执行:
# 在项目根目录执行 ./gradlew clean # 然后在 AS 中点击 Build → Clean Project # 最后 Run → app(不要点小闪电⚡)如果仍闪退,去手机设置 → 应用管理 → 找到 “FoodOrder” → 存储 → 清除数据 + 清除缓存。这是比 Logcat 更快的定位手段——很多“空指针”其实是 SharedPreferences 里旧版 key 导致的解析异常。
4. 避坑指南:那些让你加班到凌晨三点的典型翻车现场
别笑,这些坑我都替你踩过。它们不写在 README 里,但每一条都来自真实崩溃日志和用户反馈。
4.1 现象:App 启动后黑屏 3 秒,然后跳转到登录页,但登录按钮点击无响应
原因:LoginActivity的onCreate()里调用了checkUserToken(),该方法内部用SharedPrefs读取 token 后,直接startActivity(intent)跳转,但未调用finish()。导致 Activity 栈里同时存在LoginActivity和HomeActivity,返回键会回到黑屏的 Login 页面。
解决:在LoginActivity.kt的 token 验证成功分支末尾,强制finish():
if (token.isNotEmpty()) { startActivity(Intent(this, HomeActivity::class.java)) finish() // ← 必加!否则栈混乱 }4.2 现象:购物车数量显示为 0,但CartDao.getAll()返回 3 条记录
原因:CartAdapter.kt的getItemCount()方法写成了return cartItems.size(少了个括号),而cartItems是LiveData<List<CartItem>>,size是LiveData对象的属性(永远为 1),不是列表长度。
解决:改为return cartItems.value?.size ?: 0,并在CartViewModel.kt的cartItems.observe()中确保submitList()被调用:
// CartViewModel.kt fun loadCart() { cartRepository.getAll().observeForever { items -> cartItems.value = items cartAdapter.submitList(items) // ← 关键!不然 RecyclerView 不刷新 } }4.3 现象:下单成功后,订单列表页不自动刷新,需手动下拉
原因:OrderListActivity.kt的swipeRefreshLayout.setOnRefreshListener里只调用了viewModel.refreshOrders(),但refreshOrders()方法内部没有重置swipeRefreshLayout.isRefreshing = false。
解决:在viewModel.refreshOrders()的finally块中通知 UI:
// OrderViewModel.kt fun refreshOrders() { viewModelScope.launch { try { orders.value = orderRepository.fetchAll() } finally { _isRefreshing.value = false // ← 此值绑定到 swipeRefreshLayout.isRefreshing } } }4.4 现象:Glide 加载菜品图片时,部分图片显示为占位图,Logcat 显示Failed to load resource
原因:mock/db.json中菜品图片路径是相对路径(如"image": "images/noodle.jpg"),但 JSON Server 默认不提供静态资源服务。
解决:两种方案任选其一:
- 方案 A(推荐):将
mock/images/目录复制到 JSON Server 启动目录,启动时加--static mock/images参数; - 方案 B:在
MenuAdapter.kt中将图片 URL 拼接为绝对路径:
Glide.with(holder.itemView) .load("http://192.168.1.105:8080/images/${item.image}") // ← 拼接完整 URL .placeholder(R.drawable.ic_food_placeholder) .into(holder.imageView)4.5 现象:切换手机语言后,App 内文字未适配,仍显示中文
原因:strings.xml只有values/目录,缺少values-zh-rCN/和values-en-rUS/。Android 12+ 强制要求多语言资源必须显式声明。
解决:在app/src/main/res/下新建目录:
values-zh-rCN/strings.xml(简体中文)values-en-rUS/strings.xml(美式英文)
内容从values/strings.xml复制,翻译对应字段即可。无需改代码,系统自动加载。
5. 核心模块拆解:从购物车添加到订单创建,一个请求的完整链路
我们以“用户点击‘加入购物车’按钮”为起点,追踪代码如何从 View 层穿透到数据库,再触发 UI 更新。这条链路覆盖了 MVVM 的核心协作模式,也是你二次开发时最常修改的部分。
5.1 View 层:CartActivity.kt 如何响应点击事件
CartActivity并不直接操作数据,它只做两件事:监听 ViewModel 的状态变化、转发用户操作。关键代码在onCreate():
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_cart) // 绑定 ViewModel val viewModel = ViewModelProvider(this)[CartViewModel::class.java] // 观察购物车商品列表 viewModel.cartItems.observe(this) { items -> cartAdapter.submitList(items) updateCartSummary(items) // 更新总价和数量 } // 设置添加按钮点击事件(注意:这里不是直接 add,而是通知 ViewModel) findViewById<Button>(R.id.btn_add_to_cart).setOnClickListener { viewModel.addToCart( foodId = selectedFoodId, quantity = quantityInput.text.toString().toIntOrNull() ?: 1 ) } }逻辑说明:
setOnClickListener里调用的是viewModel.addToCart(),而非cartRepository.insert()。这是 MVVM 的铁律——View 层绝不触碰数据层,所有业务逻辑必须收口到 ViewModel。
5.2 ViewModel 层:CartViewModel.kt 如何协调数据与状态
CartViewModel是真正的指挥中心。它持有CartRepository实例,并暴露LiveData给 UI。重点看addToCart()方法:
class CartViewModel(private val cartRepository: CartRepository) : ViewModel() { val cartItems: MutableLiveData<List<CartItem>> = MutableLiveData() fun addToCart(foodId: Long, quantity: Int) { viewModelScope.launch { try { // 1. 先查是否存在同款商品 val existing = cartRepository.findByFoodId(foodId) if (existing != null) { // 2. 存在则更新数量 cartRepository.updateQuantity(existing.id, existing.quantity + quantity) } else { // 3. 不存在则插入新项 val newItem = CartItem(foodId = foodId, quantity = quantity) cartRepository.insert(newItem) } // 4. 刷新列表(触发 observe 回调) refreshCart() } catch (e: Exception) { Timber.e(e, "Add to cart failed") // 5. 错误状态暴露给 UI(可选) _snackbarMessage.value = "添加失败:${e.message}" } } } private fun refreshCart() { viewModelScope.launch { cartItems.value = cartRepository.getAll() } } }参数说明:
foodId是菜品唯一标识(来自MenuActivity传递的 Intent Extra);quantity是用户输入框的值,做了toIntOrNull()安全转换,避免NumberFormatException。viewModelScope确保协程随 Activity 销毁自动取消,防止内存泄漏。
5.3 Repository 层:CartRepository.kt 如何桥接网络与数据库
CartRepository是数据来源的抽象。当前版本只使用 Room 本地数据库,但接口已预留网络扩展点:
class CartRepository(private val cartDao: CartDao) { // 仅本地操作(当前实现) suspend fun insert(item: CartItem): Long = cartDao.insert(item) suspend fun updateQuantity(id: Long, quantity: Int) = cartDao.updateQuantity(id, quantity) suspend fun findByFoodId(foodId: Long): CartItem? = cartDao.findByFoodId(foodId) suspend fun getAll(): List<CartItem> = cartDao.getAll() // 预留:未来可在此处添加同步逻辑 // suspend fun syncToServer() { // val localItems = getAll() // apiService.syncCart(localItems) // ← 调用网络层 // } }关键点:
CartDao是 Room 的接口,定义了 SQL 操作。@Insert(onConflict = OnConflictStrategy.REPLACE)确保重复插入时自动更新,省去先查后插的判断逻辑。
5.4 Data 层:CartDao.kt 与 Room 数据库定义
CartDao.kt是纯数据操作,不涉业务:
@Dao interface CartDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(item: CartItem): Long @Update suspend fun update(item: CartItem) @Query("UPDATE cart_item SET quantity = :quantity WHERE id = :id") suspend fun updateQuantity(id: Long, quantity: Int) @Query("SELECT * FROM cart_item WHERE food_id = :foodId LIMIT 1") suspend fun findByFoodId(foodId: Long): CartItem? @Query("SELECT * FROM cart_item") suspend fun getAll(): List<CartItem> @Query("DELETE FROM cart_item WHERE food_id = :foodId") suspend fun deleteByFoodId(foodId: Long) }对应的CartItem.kt实体类:
@Entity(tableName = "cart_item") data class CartItem( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "food_id") val foodId: Long, @ColumnInfo(name = "quantity") val quantity: Int = 1, @ColumnInfo(name = "added_time") val addedTime: Long = System.currentTimeMillis() )注意:
@ColumnInfo(name = "food_id")显式指定列名,避免 Kotlin 属性名foodId被 Room 自动转为food_id(虽默认如此,但显式声明更可控)。
6. 二次开发实战:如何接入微信支付 SDK 并保证订单状态最终一致
这是你接手项目后最可能被提的需求:客户说“我们要微信支付,明天演示”。别慌,这套代码的支付模块(PaymentWebViewActivity.kt)就是为你预留的钩子。我带你走一遍从接入 SDK 到状态兜底的全流程。
6.1 替换 WebView 支付为原生微信 SDK
当前PaymentWebViewActivity是用WebView加载 H5 沙箱页。要换成微信原生 SDK,分三步:
第一步:添加微信 SDK 依赖
在app/build.gradle的dependencies块中加入:
// 微信 SDK(以 6.8.0 为例,适配 AndroidX) implementation 'com.tencent.mm.opensdk:wechat-sdk-android-without-mta:6.8.0'第二步:注册微信 AppID
在AndroidManifest.xml的<application>标签下添加:
<activity android:name=".wxapi.WXPayEntryActivity" android:exported="true" android:launchMode="singleTop" android:theme="@android:style/Theme.Translucent.NoTitleBar" />并在app/src/main/下新建包wxapi/,创建WXPayEntryActivity.kt(微信回调必须放此包名下):
class WXPayEntryActivity : AppCompatActivity(), IWXAPIEventHandler { private lateinit var api: IWXAPI override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) api = WXAPIFactory.createWXAPI(this, "wxd930ea5d5a258f4f", false) api.registerApp("wxd930ea5d5a258f4f") // ← 替换为你的 AppID api.handleIntent(intent, this) } override fun onReq(req: BaseReq?) {} override fun onResp(resp: BaseResp?) { when (resp?.errCode) { 0 -> { // 支付成功,但注意:这只是客户端回调,服务端可能未收到通知! Toast.makeText(this, "支付成功", Toast.LENGTH_SHORT).show() // 此处应发起订单状态查询(见 6.2 节) queryOrderStatus(resp.prepayId) } -1 -> Toast.makeText(this, "支付失败", Toast.LENGTH_SHORT).show() -2 -> Toast.makeText(this, "用户取消", Toast.LENGTH_SHORT).show() } finish() } }第三步:在 CheckoutActivity 中发起支付
修改CheckoutActivity.kt的支付按钮逻辑:
btnPay.setOnClickListener { // 1. 先调用你自己的后端接口,获取 prepayId(微信统一下单返回) viewModel.requestWechatPay(orderId) { result -> if (result.isSuccess) { // 2. 构造微信支付参数 val req = PayReq().apply { appId = "wxd930ea5d5a258f4f" partnerId = result.data.partnerId prepayId = result.data.prepayId packageValue = "Sign=WXPay" nonceStr = result.data.nonceStr timeStamp = result.data.timeStamp sign = result.data.sign } // 3. 发起支付 api.sendReq(req) } } }6.2 订单状态兜底:为什么客户端回调不可信,以及如何补救
微信支付文档白纸黑字写着:“客户端回调不能作为支付成功的依据”。因为网络抖动、用户杀进程、系统广播延迟,都可能导致onResp()未执行。我们必须在服务端通知(异步)和客户端轮询(同步)双保险。
服务端通知(你后端的事,但前端要配合)
你的后端必须监听微信的notify_url,收到支付成功通知后,更新订单状态为PAID,并调用你自己的order/status接口。前端只需确保queryOrderStatus()能被可靠调用。
客户端轮询(前端自己做)
在WXPayEntryActivity.onResp()支付成功分支中,启动轮询:
private fun queryOrderStatus(prepayId: String) { var retryCount = 0 val handler = Handler(Looper.getMainLooper()) val runnable = object : Runnable { override fun run() { viewModel.checkOrderStatus(prepayId) { status -> when (status) { "PAID" -> { Toast.makeText(this@WXPayEntryActivity, "订单已支付成功", Toast.LENGTH_LONG).show() // 跳转到订单成功页 startActivity(Intent(this@WXPayEntryActivity, OrderSuccessActivity::class.java)) finish() } "PROCESSING" -> { if (retryCount < 5) { retryCount++ handler.postDelayed(this, 2000) // 2秒后重试 } else { Toast.makeText(this@WXPayEntryActivity, "支付状态未知,请稍后查看订单", Toast.LENGTH_LONG).show() finish() } } else -> { Toast.makeText(this@WXPayEntryActivity, "支付失败", Toast.LENGTH_SHORT).show() finish() } } } } } handler.post(runnable) }关键设计:轮询最多 5 次(10 秒),超时则降级为提示用户“稍后查看”,避免无限等待。
checkOrderStatus()方法应调用你后端的GET /api/v1/orders/{prepayId}/status接口,返回PAID/PROCESSING/FAILED。
6.3 支付安全加固:防重复提交与金额校验
用户狂点支付按钮怎么办?订单金额被篡改怎么办?两个检查必须加:
防重复提交:在CheckoutActivity的btnPay点击后立即禁用:
btnPay.setOnClickListener { btnPay.isEnabled = false // ← 禁用按钮 viewModel.requestWechatPay(orderId) { result -> btnPay.isEnabled = true // ← 成功或失败后恢复 // ...后续逻辑 } }金额校验:微信 SDK 的PayReq对象里sign是签名,但前端无法验证。你必须在requestWechatPay()的回调中,比对result.data.totalFee与本地订单金额:
viewModel.requestWechatPay(orderId) { result -> val localAmount = viewModel.getOrderTotalAmount() // 从 ViewModel 获取本地计算的总金额 if (result.data.totalFee != localAmount) { Toast.makeText(this, "金额异常,支付已取消", Toast.LENGTH_LONG).show() return@requestWechatPay } // ...继续支付流程 }从那以后我每次接入第三方支付,都强制走一遍「客户端回调 + 服务端通知 + 客户端轮询」三重验证,哪怕客户说“没必要”。因为线上订单状态不一致,带来的不是技术债,是财务纠纷和客诉电话。希望帮到你。
本文还有配套的精品资源,点击获取