
简介这是一份面向Android初学者与高校计算机专业学生的期末课程设计实践资源聚焦单机版餐厅点餐系统开发解决移动端本地化订单管理、用户登录与菜品浏览等核心需求。资源包共176个文件含29个XML布局文件构建界面结构、19个Java源码实现登录、点餐、订单查看等逻辑、47个Class字节码含OrdersActivity、BillActivity等关键页面类、58个PNG图标资源支撑UI美观性以及2个SQLite数据库文件和1个可直接安装的APK整体33.53MB结构完整、模块清晰。已有3133人学习下载适合Android基础教学场景下的项目复现与代码研读。读者可获得从Eclipse工程配置、SQLite本地数据存取、Activity跳转逻辑到适配器列表渲染的全流程实现附带数据库说明文档与默认账号ZYS/123便于快速运行调试与功能拓展。1. 一个能真机扫码下单、本地结账、带完整 SQLite 数据库的安卓点餐 Demo专治期末作业交不上、答辩被问“你这数据存在哪”的焦虑上周帮某高校实验室带毕设的导师改一个学生项目对方拿着“安卓点餐系统”PPT讲了十分钟结果导师一问“菜品加进购物车后关掉 App 再打开购物车还在吗”——学生当场卡壳翻代码发现用的是 ArrayList 临时存重启就清空。这种场景太典型了功能界面画得挺全但数据没落盘、状态不持久、SQLite 表结构没主键外键、连最基础的“下单成功后清空购物车”都靠 Toast 蒙混过关。这个“安卓简易单机点餐系统期末作业”就是为这类真实痛点而生它不是 UI 演示器而是真跑在 Android 10 真机上的可交互闭环系统——从首页菜品浏览、分类筛选、加入购物车、修改数量、本地结算、生成订单记录到 SQLite 中完整维护dishes菜品、orders订单主表、order_items订单明细三张表并带外键约束。所有操作不依赖网络、不调后端、不连云数据库纯本地 SQLite ContentProvider 封装 Activity 生命周期安全保存。适合大二大三刚学完《移动应用开发》课程的同学直接编译安装、替换图片文字、改两行 SQL 就能交作业也适合想快速验证 Android 本地数据流设计逻辑的开发者当脚手架复用。2. 项目结构与技术栈Activity SQLiteOpenHelper CursorAdapter 的经典组合没有 Jetpack Compose、没有 Room、没有 MVVM 套壳这个项目刻意回归 Android 开发早期但至今仍扎实有效的技术组合纯 Java 编写、基于 Activity 的页面跳转、手动管理 SQLite 数据库、用 CursorAdapter 绑定 ListView、用 ContentValues 插入数据。它不炫技但每一步都可调试、可打断点、可查 Logcat。整个工程是标准的 Android Studio 项目结构无 Gradle 插件魔改兼容 AndroidXtargetSdkVersion 30适配 Android 11 权限变更minSdkVersion 19覆盖 4.4 设备。核心模块全部落在app/src/main/java/com/example/foodorder/下目录干净无冗余包com.example.foodorder/ ├── MainActivity.java // 首页菜品列表 分类 Tab ├── CartActivity.java // 购物车页ListView 展示、数量增减、删除、结算按钮 ├── OrderConfirmActivity.java // 订单确认页显示明细 总价 “立即下单”按钮 ├── OrderDetailActivity.java // 订单详情页按订单 ID 查看历史订单 ├── database/ │ ├── DatabaseHelper.java // 继承 SQLiteOpenHelper建表、升级逻辑集中在此 │ └── DatabaseContract.java // 定义所有表名、列名常量避免硬编码字符串 ├── adapter/ │ └── DishCursorAdapter.java // 绑定菜品 Cursor 到 ListView含图片加载占位逻辑 └── model/ └── OrderItem.java // 简单 POJO仅用于购物车内存暂存非 ORM提示项目未使用 Room 或 DataStore是因为期末作业评审重点在“能否说清数据如何存、如何查、如何关联”而非“用了哪个新框架”。SQLiteOpenHelper 是理解底层数据持久化机制的必经之路——它逼你写 SQL逼你处理 onUpgrade 的版本迁移逼你思考外键是否开启、事务是否包裹多条插入。2.1 DatabaseHelper三张表的建表语句与外键约束是核心DatabaseHelper.java是整个数据层的基石。它在onCreate()中一次性执行建表 SQL并在onUpgrade()中用ALTER TABLE安全升级非暴力删库重建。关键点在于三张表的关联设计// database/DatabaseHelper.java Override public void onCreate(SQLiteDatabase db) { // 菜品表id主键、name、price、category、image_res_id本地图片资源ID db.execSQL(CREATE TABLE dishes ( _id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price REAL NOT NULL, category TEXT NOT NULL, image_res_id INTEGER );); // 订单主表id主键、order_time时间戳、total_amount总价、statuscompleted 固定 db.execSQL(CREATE TABLE orders ( _id INTEGER PRIMARY KEY AUTOINCREMENT, order_time INTEGER NOT NULL, total_amount REAL NOT NULL, status TEXT DEFAULT completed );); // 订单明细表id主键、order_id外键、dish_id外键、quantity数量 // 关键启用外键约束必须在建表后单独执行 PRAGMA db.execSQL(CREATE TABLE order_items ( _id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, dish_id INTEGER NOT NULL, quantity INTEGER NOT NULL, FOREIGN KEY(order_id) REFERENCES orders(_id) ON DELETE CASCADE, FOREIGN KEY(dish_id) REFERENCES dishes(_id) ON DELETE SET NULL );); // 必须显式开启外键支持Android SQLite 默认关闭 db.execSQL(PRAGMA foreign_keysON;); }这段代码里藏着三个血泪经验PRAGMA foreign_keysON;不能少很多同学写了外键语法却没开 pragma导致外键形同虚设删订单时明细不会自动级联删除ON DELETE CASCADE和ON DELETE SET NULL的选择订单删除时明细必须删CASCADE但菜品删除时明细 dish_id 应置空SET NULL避免因菜品下架导致历史订单无法显示image_res_id存的是 R.drawable.xxx 的整数值不是文件路径这样图片资源随 APK 打包不依赖 SD 卡读写权限适配 Android 10 分区存储。2.2 DatabaseContract把所有字符串常量收口杜绝“magic string”DatabaseContract.java是一个纯静态常量类定义所有表名、列名、URI是防止拼写错误的后悔药// database/DatabaseContract.java public final class DatabaseContract { private DatabaseContract() {} public static class DishEntry implements BaseColumns { public static final String TABLE_NAME dishes; public static final String COLUMN_NAME name; public static final String COLUMN_PRICE price; public static final String COLUMN_CATEGORY category; public static final String COLUMN_IMAGE_RES_ID image_res_id; } public static class OrderEntry implements BaseColumns { public static final String TABLE_NAME orders; public static final String COLUMN_ORDER_TIME order_time; public static final String COLUMN_TOTAL_AMOUNT total_amount; public static final String COLUMN_STATUS status; } public static class OrderItemEntry implements BaseColumns { public static final String TABLE_NAME order_items; public static final String COLUMN_ORDER_ID order_id; public static final String COLUMN_DISH_ID dish_id; public static final String COLUMN_QUANTITY quantity; } }为什么重要当你在CartActivity.java里写查询语句时String[] projection { DatabaseContract.OrderItemEntry._ID, DatabaseContract.OrderItemEntry.COLUMN_DISH_ID, DatabaseContract.OrderItemEntry.COLUMN_QUANTITY }; Cursor cursor db.query( DatabaseContract.OrderItemEntry.TABLE_NAME, projection, DatabaseContract.OrderItemEntry.COLUMN_ORDER_ID ?, new String[]{String.valueOf(orderId)}, null, null, null );——所有字段名、表名都来自 ContractIDE 可跳转、可重构、拼错会编译报错而不是运行时报android.database.sqlite.SQLiteException: no such column: dish_id。2.3 DishCursorAdapterListView 绑定的轻量级实现含图片资源加载逻辑DishCursorAdapter.java继承自CursorAdapter负责将dishes表的 Cursor 渲染到MainActivity的 ListView。它不依赖 Glide/Picasso而是用ImageView.setImageResource()直接加载image_res_id// adapter/DishCursorAdapter.java Override public void bindView(View view, Context context, Cursor cursor) { ViewHolder holder (ViewHolder) view.getTag(); String name cursor.getString(cursor.getColumnIndexOrThrow(DatabaseContract.DishEntry.COLUMN_NAME)); double price cursor.getDouble(cursor.getColumnIndexOrThrow(DatabaseContract.DishEntry.COLUMN_PRICE)); int imageResId cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseContract.DishEntry.COLUMN_IMAGE_RES_ID)); holder.textViewName.setText(name); holder.textViewPrice.setText(String.format(¥%.1f, price)); // 关键安全加载图片资源防 Resources.NotFoundException if (imageResId ! 0 context.getResources().getIdentifier( drawable/ context.getResources().getResourceEntryName(imageResId), null, context.getPackageName()) ! 0) { holder.imageView.setImageResource(imageResId); } else { holder.imageView.setImageResource(R.drawable.ic_dish_placeholder); // 默认占位图 } }这里有个易忽略细节getResources().getIdentifier()用于校验image_res_id是否真实存在避免因资源重命名或删除导致setImageResource(0)显示空白。R.drawable.ic_dish_placeholder是项目 assets 中自带的默认图标确保任何菜品都有视觉反馈。3. 核心业务流程拆解从点击“加入购物车”到 SQLite 写入订单的完整链路整个点餐流程看似简单但涉及跨 Activity 数据传递、数据库事务、UI 状态同步三重挑战。我们以“用户在首页点击一份宫保鸡丁 → 加入购物车 → 返回首页再点一份 → 进入购物车页 → 结算”为例逐帧拆解数据如何流动。3.1 “加入购物车”按钮背后的 ContentValues 插入逻辑MainActivity.java中每个菜品 Item 的OnClickListener并不直接操作数据库而是先构建一个内存中的OrderItem对象再通过CartManager单例统一管理// MainActivity.java listView.setOnItemClickListener((parent, view, position, id) - { Cursor cursor (Cursor) parent.getItemAtPosition(position); int dishId cursor.getInt(cursor.getColumnIndexOrThrow(BaseColumns._ID)); String dishName cursor.getString(cursor.getColumnIndexOrThrow(DatabaseContract.DishEntry.COLUMN_NAME)); double dishPrice cursor.getDouble(cursor.getColumnIndexOrThrow(DatabaseContract.DishEntry.COLUMN_PRICE)); // 构建内存对象传给购物车管理器 OrderItem item new OrderItem(dishId, dishName, dishPrice, 1); CartManager.getInstance().addItem(item); Toast.makeText(this, 已加入购物车, Toast.LENGTH_SHORT).show(); });CartManager.java是关键中介它内部用ArrayListOrderItem暂存当前会话购物车并提供saveToDatabase(Context)方法将全部商品写入order_items表// CartManager.java public void saveToDatabase(Context context) { DatabaseHelper dbHelper new DatabaseHelper(context); SQLiteDatabase db dbHelper.getWritableDatabase(); // 开启事务确保多条插入原子性 db.beginTransaction(); try { long orderId insertOrder(db); // 先插入 orders 主表获取新订单 ID for (OrderItem item : cartItems) { ContentValues values new ContentValues(); values.put(DatabaseContract.OrderItemEntry.COLUMN_ORDER_ID, orderId); values.put(DatabaseContract.OrderItemEntry.COLUMN_DISH_ID, item.getDishId()); values.put(DatabaseContract.OrderItemEntry.COLUMN_QUANTITY, item.getQuantity()); db.insert(DatabaseContract.OrderItemEntry.TABLE_NAME, null, values); } db.setTransactionSuccessful(); // 标记事务成功 } finally { db.endTransaction(); } }注意insertOrder()方法中order_time使用System.currentTimeMillis()total_amount是遍历cartItems累加计算得出不是从数据库查——因为这是新订单明细还没写入必须在内存中算准再写。3.2 购物车页CartActivity的 ListView 刷新机制CartActivity的 ListView 不是靠notifyDataSetChanged()简单刷新而是每次onResume()时重新查询数据库确保数据绝对一致// CartActivity.java Override protected void onResume() { super.onResume(); // 每次回到购物车页都从数据库拉最新状态防后台被杀后数据不同步 reloadCartFromDatabase(); } private void reloadCartFromDatabase() { DatabaseHelper dbHelper new DatabaseHelper(this); SQLiteDatabase db dbHelper.getReadableDatabase(); // 关联查询dishes 表 order_items 表获取菜品名、价格、数量 String sql SELECT oi._id as item_id, d. DatabaseContract.DishEntry.COLUMN_NAME as dish_name, d. DatabaseContract.DishEntry.COLUMN_PRICE as dish_price, oi. DatabaseContract.OrderItemEntry.COLUMN_QUANTITY as quantity FROM DatabaseContract.OrderItemEntry.TABLE_NAME oi JOIN DatabaseContract.DishEntry.TABLE_NAME d ON oi. DatabaseContract.OrderItemEntry.COLUMN_DISH_ID d. BaseColumns._ID WHERE oi. DatabaseContract.OrderItemEntry.COLUMN_ORDER_ID ?; Cursor cursor db.rawQuery(sql, new String[]{String.valueOf(getLatestOrderId(db))}); cartAdapter.changeCursor(cursor); // CursorAdapter 自动更新视图 }getLatestOrderId(db)查询orders表中order_time最大的那条记录 ID —— 因为本系统只维护一个“当前购物车”对应的订单即最近一次未完成的订单所有购物车操作都绑定到该订单 ID 下。这种设计简化了逻辑符合单机点餐场景。3.3 结算按钮触发的三步原子操作“立即下单”按钮在OrderConfirmActivity.java中触发执行三个不可分割的操作更新订单状态为 completed原为 NULL 或 pending清空当前购物车对应的order_items记录利用外键ON DELETE CASCADE删orders行则order_items自动删跳转到订单详情页显示本次订单号和明细。// OrderConfirmActivity.java buttonSubmit.setOnClickListener(v - { DatabaseHelper dbHelper new DatabaseHelper(this); SQLiteDatabase db dbHelper.getWritableDatabase(); db.beginTransaction(); try { // 步骤1更新 orders 表状态 ContentValues values new ContentValues(); values.put(DatabaseContract.OrderEntry.COLUMN_STATUS, completed); db.update( DatabaseContract.OrderEntry.TABLE_NAME, values, BaseColumns._ID ?, new String[]{String.valueOf(currentOrderId)} ); // 步骤2无需手动删 order_items —— 外键 CASCADE 已保证 // 步骤3跳转传订单 ID Intent intent new Intent(this, OrderDetailActivity.class); intent.putExtra(ORDER_ID, currentOrderId); startActivity(intent); finish(); db.setTransactionSuccessful(); } finally { db.endTransaction(); } });注意这里没写db.delete()删order_items正是为了验证外键是否生效。如果外键未开启或语法错误order_items表会残留脏数据下次进入购物车页就会显示已结算的商品——这是排查外键问题的第一线索。4. 常见问题排查5 条真机实测踩坑记录每一条都对应一个答辩可能被问到的点开发和调试过程中以下问题是高频翻车点均来自某高校 3 个班级共 27 份作业的共性缺陷。它们不是理论假设而是真机上Logcat报错截图、ADB shell 查数据库、断点调试后定位的真实原因。4.1 现象App 启动闪退Logcat 报android.database.sqlite.SQLiteException: no such table: dishes原因DatabaseHelper的onCreate()方法从未被调用通常因为SQLiteDatabase.openOrCreateDatabase()被误用或new DatabaseHelper(context)后没调用getWritableDatabase()/getReadableDatabase()。更隐蔽的情况是MainActivity中DatabaseHelper dbHelper new DatabaseHelper(this);但后续没执行任何数据库操作导致onCreate()延迟到首次query()才触发而此时ListView已尝试绑定空 Cursor。解决在DatabaseHelper构造函数末尾加一行getWritableDatabase();强制初始化仅调试期或确保MainActivity.onCreate()中至少执行一次dbHelper.getReadableDatabase().query(...)。4.2 现象购物车数量加减后ListView 显示错乱比如点 1 却变成 2原因CartActivity中ListView的OnItemClickListener被重复 set或CartManager的addItem()方法未做去重判断同一菜品多次点击会添加多条OrderItem对象而非合并数量。解决在CartManager.addItem()中先遍历cartItems若dishId已存在则item.quantity否则cartItems.add(newItem)。同时检查CartActivity的setContentView()后是否有多余的listView.setOnItemClickListener(...)调用。4.3 现象真机上图片不显示Logcat 出现android.content.res.Resources$NotFoundException: Resource ID #0x0原因dishes表中image_res_id字段存的是 0 或无效值而DishCursorAdapter.bindView()中未做if (imageResId ! 0)判断直接setImageResource(0)。R.drawable.xxx的值在不同编译环境下可能变化不能硬编码。解决严格使用DatabaseContract定义的列名插入数据在DatabaseHelper.onCreate()的INSERT INTO dishes语句中为image_res_id指定有效值如R.drawable.dish_chicken的实际整数并在DishCursorAdapter中增加资源存在性校验见 2.3 节代码。4.4 现象结算后返回首页再进购物车发现上次订单商品还在原因外键ON DELETE CASCADE未生效根本原因是PRAGMA foreign_keysON;未执行或执行位置错误必须在onCreate()建表 SQL 之后、onUpgrade()中也需补上。解决在DatabaseHelper.onCreate()最后一行加db.execSQL(PRAGMA foreign_keysON;);在onUpgrade()中先db.execSQL(PRAGMA foreign_keysOFF;)删表重建后再ON用 ADB 命令验证adb shell sqlite3 /data/data/com.example.foodorder/databases/food.db PRAGMA foreign_keys;应返回1。4.5 现象Android 10 设备上购物车数据重启 App 后丢失原因CartManager的cartItems是内存 List未在onPause()中序列化保存且DatabaseHelper的onCreate()被调用多次如 targetSdkVersion 29 时权限模型不同。更常见的是CartManager单例在 Application 被杀后重建但cartItems初始化为空 ArrayList未从数据库恢复。解决CartManager构造函数中增加loadFromDatabase(Context)从orders表查statuspending的最新订单再关联查order_items加载商品onPause()中不需额外保存因所有状态已落库。5. 二次开发改造指南3 个高价值、低工作量的扩展方向附可直接粘贴的代码块交作业只是起点真正体现能力的是“我能基于它改出什么”。下面三个改造方向每个都能在 1 小时内完成、编译通过、真机验证且直击课程设计评分要点数据完整性、UI 交互性、业务逻辑合理性。5.1 方向一为菜品增加“库存”字段下单时校验库存不足则 Toast 提示强化数据约束改造点在dishes表加stock INTEGER DEFAULT 0列在“加入购物车”逻辑中查询当前库存若quantity stock则阻止添加。步骤修改DatabaseHelper.onCreate()在dishes建表语句中添加, stock INTEGER DEFAULT 0修改DatabaseHelper.onUpgrade()用ALTER TABLE dishes ADD COLUMN stock INTEGER DEFAULT 0在CartManager.addItem()中插入前查库存// CartManager.java - addItem() 方法内新增 SQLiteDatabase db dbHelper.getReadableDatabase(); Cursor stockCursor db.query( DatabaseContract.DishEntry.TABLE_NAME, new String[]{DatabaseContract.DishEntry.COLUMN_STOCK}, BaseColumns._ID ?, new String[]{String.valueOf(item.getDishId())}, null, null, null ); int availableStock 0; if (stockCursor.moveToFirst()) { availableStock stockCursor.getInt(stockCursor.getColumnIndexOrThrow(DatabaseContract.DishEntry.COLUMN_STOCK)); } stockCursor.close(); if (item.getQuantity() availableStock) { // 提示库存不足不添加 Toast.makeText(context, 库存不足仅剩 availableStock 份, Toast.LENGTH_SHORT).show(); return false; // 添加失败 }提示stock字段加在dishes表而非order_items是因为库存是菜品维度属性不是订单维度。这样设计也方便后续做“库存预警”统计。5.2 方向二购物车页增加“编辑数量”弹窗替代原始的 / - 按钮提升 UI 专业度改造点将CartActivity中每个 Item 的/-按钮替换为长按弹出AlertDialog输入框输入数字点击确定后更新数据库。关键代码替换CartActivity中原onItemClick// CartActivity.java - 替换原 ListView 的 setOnItemClickListener listView.setOnItemLongClickListener((parent, view, position, id) - { Cursor cursor (Cursor) parent.getItemAtPosition(position); final long itemId cursor.getLong(cursor.getColumnIndexOrThrow(BaseColumns._ID)); final int currentQty cursor.getInt(cursor.getColumnIndexOrThrow(quantity)); EditText input new EditText(this); input.setText(String.valueOf(currentQty)); input.setInputType(InputType.TYPE_CLASS_NUMBER); new AlertDialog.Builder(this) .setTitle(修改数量) .setView(input) .setPositiveButton(确定, (dialog, which) - { try { int newQty Integer.parseInt(input.getText().toString()); if (newQty 0) { // 删除该商品 deleteCartItem(itemId); } else { updateCartItemQuantity(itemId, newQty); } reloadCartFromDatabase(); // 刷新 ListView } catch (NumberFormatException e) { Toast.makeText(this, 请输入有效数字, Toast.LENGTH_SHORT).show(); } }) .setNegativeButton(取消, null) .show(); return true; });配套的updateCartItemQuantity()方法直接执行UPDATE order_items SET quantity ? WHERE _id ?比反复增减更健壮。5.3 方向三订单详情页增加“重新下单”按钮一键复制上次订单体现业务连续性改造点在OrderDetailActivity底部加按钮点击后创建一个新订单内容完全复制当前订单的order_items菜品 数量状态设为pending。核心逻辑OrderDetailActivity.javabuttonReorder.setOnClickListener(v - { DatabaseHelper dbHelper new DatabaseHelper(this); SQLiteDatabase db dbHelper.getWritableDatabase(); // 1. 插入新订单status pending ContentValues orderValues new ContentValues(); orderValues.put(DatabaseContract.OrderEntry.COLUMN_ORDER_TIME, System.currentTimeMillis()); orderValues.put(DatabaseContract.OrderEntry.COLUMN_TOTAL_AMOUNT, 0.0); // 待计算 orderValues.put(DatabaseContract.OrderEntry.COLUMN_STATUS, pending); long newOrderId db.insert(DatabaseContract.OrderEntry.TABLE_NAME, null, orderValues); // 2. 查询原订单所有明细 String sql SELECT DatabaseContract.OrderItemEntry.COLUMN_DISH_ID , DatabaseContract.OrderItemEntry.COLUMN_QUANTITY FROM DatabaseContract.OrderItemEntry.TABLE_NAME WHERE DatabaseContract.OrderItemEntry.COLUMN_ORDER_ID ?; Cursor items db.rawQuery(sql, new String[]{String.valueOf(orderId)}); // 3. 批量插入新明细 db.beginTransaction(); try { while (items.moveToNext()) { ContentValues itemValues new ContentValues(); itemValues.put(DatabaseContract.OrderItemEntry.COLUMN_ORDER_ID, newOrderId); itemValues.put(DatabaseContract.OrderItemEntry.COLUMN_DISH_ID, items.getInt(items.getColumnIndexOrThrow(DatabaseContract.OrderItemEntry.COLUMN_DISH_ID))); itemValues.put(DatabaseContract.OrderItemEntry.COLUMN_QUANTITY, items.getInt(items.getColumnIndexOrThrow(DatabaseContract.OrderItemEntry.COLUMN_QUANTITY))); db.insert(DatabaseContract.OrderItemEntry.TABLE_NAME, null, itemValues); } db.setTransactionSuccessful(); Toast.makeText(this, 已复制到购物车, Toast.LENGTH_SHORT).show(); finish(); // 返回购物车页 } finally { db.endTransaction(); items.close(); } });从那以后我每次接到类似“单机数据管理”的需求都强制走一遍这个流程先手写三张表的 CREATE 语句用纸笔画外键连线再敲PRAGMA foreign_keysON;最后用 ADB shell 连数据库SELECT * FROM dishes;看一眼——不是为了炫技而是确保自己没在数据层埋下答辩时答不出的雷。希望帮到你。本文还有配套的精品资源点击获取