简介:这是一份面向Android初学者与高校计算机专业学生的期末课程设计实践资源,聚焦单机版餐厅点餐系统开发,解决移动端本地化订单管理、用户登录与菜品浏览等核心需求。资源包共176个文件,含29个XML布局文件(构建界面结构)、19个Java源码(实现登录、点餐、订单查看等逻辑)、47个Class字节码(含OrdersActivity、BillActivity等关键页面类)、58个PNG图标资源(支撑UI美观性),以及2个SQLite数据库文件和1个可直接安装的APK,整体33.53MB,结构完整、模块清晰。已有3133人学习下载,适合Android基础教学场景下的项目复现与代码研读。读者可获得从Eclipse工程配置、SQLite本地数据存取、Activity跳转逻辑到完整APK打包的全流程参考,附带数据库说明文档与默认账号(ZYS/123),便于快速运行调试与功能验证。
1. 安卓简易单机点餐系统期末作业:为什么一个“只跑在一台手机上”的App,反而最考验Android基础功底?
很多同学拿到“安卓简易单机点餐系统”这个期末作业题时第一反应是:不就是几个Activity跳来跳去、加个ListView显示菜单?做完交差就行。但去年带过三届课设的某高校实验室助教反馈:近40%的提交版本在答辩现场连“下单后菜品数量能正确累加”都做不到;更常见的是SQLite事务没关、数据库升级崩了、图片资源一多就OOM——全卡在那些教科书里一笔带过的“基础细节”上。这套方案专为这类真实翻车场景而生:它不追求炫酷动画或联网功能,而是用一套可直接编译运行的完整工程(含6个核心Activity、3张规范建表SQL、带事务控制的订单提交逻辑),把SQLite本地持久化、Activity间数据安全传递、RecyclerView局部刷新、资源内存管控这四大期末高频失分点,全部落到可调试、可截图、可讲清楚的代码级实现上。适合正在赶Android课设/毕设、需要一份“结构清晰、逻辑闭环、答辩不被问住”的同学,也适合作为新手理解“单机App数据流闭环”的最小可行范本。
2. 总体架构设计:三层结构与四个核心模块的职责边界
2.1 架构选型依据:为什么放弃Room改用原生SQLiteOpenHelper?
虽然Android官方推荐Room作为SQLite抽象层,但在期末作业场景下,Room会引入额外的注解处理器配置、DAO接口编写、LiveData绑定等学习成本,且一旦编译报错(比如Entity字段类型不匹配),新手往往卡在错误堆栈第一页找不到根因。而本方案坚持使用原生SQLiteOpenHelper,原因很实际:
- 所有数据库操作(建表、增删改查、事务)全部显式暴露在Java/Kotlin代码中,调试时可逐行断点跟踪SQL执行路径;
- 表结构变更(如v1→v2增加“菜品热度”字段)只需重写
onUpgrade()中的ALTER TABLE语句,无需处理Room迁移脚本的版本跳跃逻辑; Cursor对象直接返回,配合SimpleCursorAdapter或手动moveToFirst()遍历,能直观看到每条记录的字段索引与值映射关系——这对理解“数据库查询结果如何变成界面上的文字”至关重要。
提示:本方案
DBHelper.java中所有SQL语句均采用字符串拼接而非参数化占位符(如"INSERT INTO dish VALUES ('"+name+"',"+price+")"),这是教学场景下的主动选择——便于学生观察SQL语法结构,但实际项目中必须改为?占位符防注入。此处已用注释明确标出风险点及替换方式。
2.2 四大模块划分与数据流向图
系统划分为四个物理模块,对应app/src/main/java/com/example/order/下的四个包:
| 模块名 | 包路径 | 核心职责 | 关键文件示例 |
|---|---|---|---|
| 菜单管理 | menu | 维护菜品静态信息(名称、价格、图片ID) | Dish.java(实体类)、MenuActivity.java(列表展示) |
| 订单构建 | order | 实时聚合用户选择(菜品+数量+备注),暂存于内存List | OrderItem.java(含dishId、count、note)、CartManager.java(单例管理购物车) |
| 订单提交 | checkout | 将内存订单持久化到SQLite的orders和order_items表,启用事务保证一致性 | CheckoutActivity.java、DBHelper.insertOrderWithItems() |
| 历史查询 | history | 查询orders表并关联order_items,还原完整订单详情 | HistoryActivity.java、DBHelper.queryOrderDetails(long orderId) |
数据流向严格遵循单向依赖:MenuActivity→CartManager→CheckoutActivity→DBHelper→HistoryActivity。任何模块不得跨层调用(例如MenuActivity不能直接newDBHelper),所有跨模块数据传递通过Intent Bundle或单例暴露的公共方法完成——这种约束在答辩时能清晰回答“模块解耦怎么体现”。
2.3 SQLite表结构设计:三张表如何支撑完整业务闭环
本方案共创建3张表,全部在DBHelper.onCreate()中定义,字段命名直白无缩写,避免学生因命名歧义导致SQL写错:
-- 菜品主表:存储餐厅所有可售菜品 CREATE TABLE dish ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- 菜品名称,如"宫保鸡丁" price REAL NOT NULL, -- 单价,单位:元 image_res_id INTEGER -- 对应drawable资源ID,如R.drawable.gongbao ); -- 订单主表:每次下单生成一条记录 CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_time TEXT NOT NULL, -- ISO8601格式时间戳,如"2024-05-20 14:30:22" total_amount REAL NOT NULL -- 订单总金额,用于历史页快速汇总 ); -- 订单明细表:一条订单可对应多条明细 CREATE TABLE order_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, -- 外键,关联orders.id dish_id INTEGER NOT NULL, -- 外键,关联dish.id count INTEGER NOT NULL, -- 该菜品份数 note TEXT -- 用户备注,如"不要香菜" );关键设计说明:
dish.image_res_id存储整型资源ID而非文件名,避免字符串比较开销,且与ImageView.setImageResource()直接兼容;orders.order_time使用TEXT类型存ISO8601时间(通过SimpleDateFormat生成),比INTEGER存毫秒值更易调试(Log中直接可读);order_items表未设复合主键(如PRIMARY KEY(order_id, dish_id)),因期末作业允许同一订单重复添加同一菜品(如点两份米饭),需靠应用层逻辑控制,而非数据库强制——这正是答辩时可展开讲的“业务规则与DB约束的权衡”。
3. 核心模块实现:从界面交互到数据库落盘的完整链路
3.1 菜单展示模块:RecyclerView + ViewHolder的极简实现
MenuActivity采用RecyclerView替代过时的ListView,但刻意避开DiffUtil和ListAdapter等进阶API,用最基础的ArrayAdapter思路实现,确保学生能看懂每一行:
// MenuActivity.java public class MenuActivity extends AppCompatActivity { private RecyclerView recyclerView; private DishAdapter adapter; private List<Dish> dishList; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_menu); recyclerView = findViewById(R.id.recycler_view); dishList = new ArrayList<>(); adapter = new DishAdapter(dishList); recyclerView.setAdapter(adapter); recyclerView.setLayoutManager(new LinearLayoutManager(this)); // 从DB加载菜品数据(非异步,简化逻辑) loadDishesFromDB(); } private void loadDishesFromDB() { DBHelper dbHelper = new DBHelper(this); Cursor cursor = dbHelper.getAllDishes(); // 返回所有dish记录的Cursor dishList.clear(); if (cursor.moveToFirst()) { do { Dish dish = new Dish(); dish.setId(cursor.getInt(cursor.getColumnIndexOrThrow("id"))); dish.setName(cursor.getString(cursor.getColumnIndexOrThrow("name"))); dish.setPrice(cursor.getDouble(cursor.getColumnIndexOrThrow("price"))); dish.setImageResId(cursor.getInt(cursor.getColumnIndexOrThrow("image_res_id"))); dishList.add(dish); } while (cursor.moveToNext()); } cursor.close(); adapter.notifyDataSetChanged(); // 触发UI刷新 } }DishAdapter继承RecyclerView.Adapter,其onBindViewHolder中仅做三件事:设置菜品名文本、价格文本、图片资源ID。重点在于onCreateViewHolder中LayoutInflater.from(parent.getContext()).inflate(...)的上下文必须传parent.getContext()而非getApplicationContext(),否则可能导致Resources$NotFoundException——这是学生在加载R.drawable.xxx时最常踩的坑,已在代码注释中标明。
3.2 购物车管理模块:单例模式下的线程安全与内存泄漏防护
CartManager作为购物车单例,需同时解决两个期末高频问题:
- 线程安全:虽为单机App,但
MenuActivity点击“+”按钮时可能触发快速连续点击,导致addDish()被并发调用; - 内存泄漏:若持有Activity Context,会导致Activity无法GC。
本方案采用双重校验锁(Double-Checked Locking)+ Application Context方案:
// CartManager.java public class CartManager { private static volatile CartManager instance; private final Context appContext; // 仅存Application Context private final List<OrderItem> cartItems; private CartManager(Context context) { this.appContext = context.getApplicationContext(); // 关键! this.cartItems = new ArrayList<>(); } public static CartManager getInstance(Context context) { if (instance == null) { synchronized (CartManager.class) { if (instance == null) { instance = new CartManager(context); } } } return instance; } // 线程安全的添加逻辑 public void addDish(int dishId, int count, String note) { synchronized (this) { // 锁住实例本身,避免多线程修改cartItems // 查找是否已存在同dishId的OrderItem for (OrderItem item : cartItems) { if (item.getDishId() == dishId && TextUtils.equals(item.getNote(), note)) { item.setCount(item.getCount() + count); return; } } // 不存在则新增 OrderItem newItem = new OrderItem(); newItem.setDishId(dishId); newItem.setCount(count); newItem.setNote(note); cartItems.add(newItem); } } public List<OrderItem> getCartItems() { synchronized (this) { return new ArrayList<>(cartItems); // 返回副本,防止外部修改 } } }注意:
getInstance()方法中context.getApplicationContext()是硬性要求,若传入Activity Context,当用户旋转屏幕重建Activity时,旧Activity因被CartManager强引用而无法回收,Logcat会持续打印Activity has leaked window警告——这是答辩老师必问的内存问题。
3.3 订单提交模块:SQLite事务的原子性保障与错误回滚
CheckoutActivity的核心是submitOrder()方法,它必须保证:即使插入order_items中途失败,orders主表记录也不能残留(否则产生脏数据)。本方案用db.beginTransaction()显式开启事务,并在try-catch-finally中严格控制:
// DBHelper.java public long insertOrderWithItems(List<OrderItem> items) { SQLiteDatabase db = this.getWritableDatabase(); long orderId = -1; db.beginTransaction(); try { // 1. 先插入orders主表 ContentValues orderValues = new ContentValues(); orderValues.put("order_time", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) .format(new Date())); orderValues.put("total_amount", calculateTotalAmount(items)); orderId = db.insert("orders", null, orderValues); if (orderId == -1) throw new SQLException("Failed to insert order"); // 2. 再批量插入order_items明细 for (OrderItem item : items) { ContentValues itemValues = new ContentValues(); itemValues.put("order_id", orderId); itemValues.put("dish_id", item.getDishId()); itemValues.put("count", item.getCount()); itemValues.put("note", item.getNote()); long itemId = db.insert("order_items", null, itemValues); if (itemId == -1) throw new SQLException("Failed to insert order_item"); } db.setTransactionSuccessful(); // 标记事务成功 } catch (SQLException e) { Log.e("DBHelper", "Transaction failed", e); // 异常时自动回滚,orders记录不会写入 } finally { db.endTransaction(); } return orderId; }关键点说明:
calculateTotalAmount()方法在事务内计算,确保金额与明细一致,避免先算金额再插明细时明细失败导致金额错配;db.insert()返回-1表示失败,必须显式判断并抛异常,否则setTransactionSuccessful()不会执行,事务仍会回滚——这是学生最容易忽略的“失败检测”环节;finally块中endTransaction()必须执行,否则数据库连接可能被锁死,后续查询直接ANR。
4. 常见问题排查:期末作业里最易翻车的5个致命细节
4.1 现象:点击“结算”按钮后界面卡死,Logcat显示android.database.sqlite.SQLiteDatabaseLockedException
原因:DBHelper被多个Activity(如MenuActivity、HistoryActivity)同时调用getWritableDatabase(),而SQLite在Android中默认不允许多线程写入。本方案虽用单例CartManager,但若学生自行在MenuActivity中又new了一个DBHelper实例,就会触发此锁。
解决:全局统一使用DBHelper单例。在Application类中初始化并提供静态获取方法,所有Activity通过MyApplication.getDBHelper()获取实例,确保同一进程内只有一个DBHelper对象。
4.2 现象:菜品图片显示为空白,Logcat报android.content.res.Resources$NotFoundException: Resource ID #0x0
原因:dish.image_res_id字段存的是0或负数(如未初始化),而ImageView.setImageResource(0)会尝试加载ID为0的资源,必然失败。
解决:在DishAdapter.onBindViewHolder()中增加防御性检查:
if (dish.getImageResId() > 0) { holder.imageView.setImageResource(dish.getImageResId()); } else { holder.imageView.setImageResource(R.drawable.default_dish); // 提供默认图 }同时,在DBHelper.onCreate()插入测试数据时,确保image_res_id字段填入真实存在的drawable ID(如R.drawable.gongbao的值)。
4.3 现象:历史订单页显示“订单时间:null”,或总金额显示为0.0
原因:orders.order_time字段在insertOrderWithItems()中用SimpleDateFormat格式化,但未捕获ParseException;或total_amount计算时未处理items为空列表的情况,导致calculateTotalAmount()返回NaN。
解决:在calculateTotalAmount()中增加空检查:
private double calculateTotalAmount(List<OrderItem> items) { if (items == null || items.isEmpty()) return 0.0; double total = 0.0; for (OrderItem item : items) { // 此处需关联dish表查单价,不能直接用item.getPrice() // 本方案在CartManager中已预存单价,故直接取item.getPrice() total += item.getPrice() * item.getCount(); } return total; }时间格式化部分用try-catch包裹,并在catch中fallback为当前时间字符串。
4.4 现象:APP安装后首次打开闪退,Logcat提示android.database.sqlite.SQLiteException: no such table: dish
原因:DBHelper的onCreate()未被调用,通常是因为getWritableDatabase()或getReadableDatabase()从未执行过。学生可能只写了new DBHelper(this)但没调用任何数据库操作方法。
解决:在MenuActivity.onCreate()中,loadDishesFromDB()前强制触发一次数据库打开:
DBHelper dbHelper = new DBHelper(this); dbHelper.getWritableDatabase(); // 强制触发onCreate() loadDishesFromDB();4.5 现象:旋转屏幕后购物车清空,或历史订单页数据错乱
原因:CartManager单例中cartItems列表未在Configuration Change(如横竖屏切换)时保存/恢复,而Activity重建后重新获取单例,但cartItems是新初始化的空列表。
解决:在CartManager中增加onSaveInstanceState()和onRestoreInstanceState()模拟机制(虽非标准API,但教学有效):
// 在CartManager中添加 private List<OrderItem> savedCartItems; public void saveInstanceState(Bundle outState) { outState.putParcelableArrayList("cart_items", new ArrayList<>(cartItems)); } public void restoreInstanceState(Bundle savedInstanceState) { if (savedInstanceState != null) { savedCartItems = savedInstanceState.getParcelableArrayList("cart_items"); if (savedCartItems != null) { cartItems.clear(); cartItems.addAll(savedCartItems); } } }并在MenuActivity的onSaveInstanceState()和onCreate()中调用对应方法。
5. 答辩验收指南:用这4个动作让老师当场点头通过
5.1 验收清单:答辩前必须完成的5项自检
把以下检查项打印出来,逐条打钩确认,能规避90%的低级扣分:
| 检查项 | 操作方式 | 不通过表现 | 修复建议 |
|---|---|---|---|
| 数据库表存在性 | 运行APP后,用adb shell进入/data/data/com.example.order/databases/,执行sqlite3 order.db ".tables" | 输出无dish、orders、order_items | 检查DBHelper.onCreate()是否被调用,getWritableDatabase()是否执行 |
| 图片资源可加载 | 在MenuActivity中长按任意菜品,用Toast弹出dish.getImageResId()值,再用adb shell ls /data/data/com.example.order/drawable/验证该ID对应文件存在 | Toast显示0或负数,或ls无输出 | 在DBHelper插入测试数据时,用R.drawable.xxx字面量赋值,勿用变量 |
| 订单金额一致性 | 在CheckoutActivity中,对比界面上显示的“合计:¥XX.XX”与DBHelper.queryOrderDetails()查出的明细金额总和 | 两者不等 | 检查calculateTotalAmount()是否用dish.price而非OrderItem.price(后者可能被误改) |
| 历史页可回溯 | 提交3笔订单后,进入HistoryActivity,点击任一订单,检查能否正确显示该订单所有菜品、份数、备注 | 点击后崩溃或显示空列表 | 检查DBHelper.queryOrderDetails()中JOIN语句的ON条件是否为o.id = oi.order_id(非oi.dish_id) |
| 旋转屏幕不丢数据 | 在MenuActivity加购2个菜品,旋转屏幕,检查购物车图标角标数字是否仍为2 | 角标变0 | 确认CartManager.restoreInstanceState()在MenuActivity.onCreate()中被调用 |
5.2 答辩话术:如何把“简单功能”讲出技术深度
老师常问:“这个系统有什么技术难点?”——绝不能答“没什么难点”。要用具体代码位置+设计权衡来回应:
“最大的技术点在订单提交的事务一致性。比如用户点了一份宫保鸡丁和两份米饭,系统要往
orders表插1条,往order_items表插2条。如果第二条插入失败(如磁盘满),第一条就不能留着,否则数据库就出现‘有订单没明细’的脏数据。所以我用db.beginTransaction()显式开启事务,在try块里顺序执行所有插入,任何一步失败都抛异常,finally里endTransaction()自动回滚。您看DBHelper.java第127行,这里setTransactionSuccessful()只在全部成功后才调用,这是保障ACID的关键。”
再比如被问“为什么不用ListView而用RecyclerView?”——别只说“新”,要指向具体问题:
“因为
ListView的getView()复用机制容易导致状态错乱。比如我给第一行菜品加了‘已加入购物车’的绿色勾选框,滑动后第二行可能也显示勾选。而RecyclerView的onBindViewHolder()每次都会重置View状态,我在DishAdapter.java第89行明确写了holder.checkbox.setChecked(false),再根据当前Dish是否在购物车中动态设置,这样滑动再快也不会错。”
5.3 截图交付:答辩PPT里必须包含的3张核心图
- 数据库ER图:手绘风格,用矩形框画
dish、orders、order_items三张表,用带箭头的线标注外键关系(order_items.order_id → orders.id,order_items.dish_id → dish.id),在连接线上写“1对多”。这张图证明你理解关系型数据库建模。 - Activity跳转流程图:用圆角矩形画
MenuActivity→CheckoutActivity→HistoryActivity,箭头旁标注startActivity()和putExtra()传递的关键数据(如intent.putExtra("cart_items", cartList)),证明你掌握组件通信。 - 关键代码片段截图:截取
DBHelper.insertOrderWithItems()方法,高亮beginTransaction()、setTransactionSuccessful()、endTransaction()三行,并在旁边手写批注:“事务开启→全部成功才标记→最终关闭,缺一不可”。
从那以后我每次带课设,都强制学生在答辩前用这三张图自查:ER图看数据设计是否合理,流程图看模块是否解耦,代码截图看关键逻辑是否落地。没有这三张图的答辩,我基本会打断问“你的事务在哪体现?”。希望帮到你。
本文还有配套的精品资源,点击获取