
简介面向Android初学者的单机点餐系统期末项目源码适合课程设计、期末作业或简易餐厅点餐Demo参考。项目基于Eclipse构建数据层使用SQLite完整覆盖登录、选择桌号、点餐、订单查询等核心流程界面素材与布局较完整无需联网即可本地演示整体代码量适中便于二次修改和学习。资源包共176个文件约33.53MB以58个PNG界面图片、29个XML布局/配置、19个Java源码、47个class编译文件为主同时附带可直接安装的APK、SQLite数据库文件和文本说明既能快速安装体验也能直接对照源码分析实现细节。目前已有3133人学习/下载这份资源。通过学习压缩包内的源码与文档可以重点演练Android基础UI搭建、SQLite增删改查、页面间数据传递、列表适配器与事件绑定等关键知识点包内还附有数据库说明默认测试账号为ZYS、密码123方便期末验收、答辩演示或课设改造时快速跑通并继续扩展。1. 安卓简易单机点餐系统期末作业不是“为了交差”而是你安卓数据流的第一次完整闭环很多人把“安卓简易单机点餐系统期末作业”当成一个被迫完成的课设觉得单机、无服务端、界面简单随便写写就能过。但我的判断正好相反这正是把 Activity 生命周期、SQLite 事务、Adapter 刷新机制、购物车状态同步一次串起来的最好机会。你不需要后端不需要网络权限所有数据都落在本地 SQLite 里做完之后你能清楚说出“用户点了一道菜之后数据从点击事件到数据库写入到底走了哪几步”这比背十道面试题都管用。这篇笔记面向两类人一类是刚学完安卓四大组件、想在期末作业里做出点“能演示、能答辩、数据不丢”东西的同学另一类是想快速搭一个离线点餐 Demo 做原型验证的开发者。我把建表、DAO、界面绑定、订单生成、异常排查到导出备份的完整链路拆开讲每一步都给你能直接抄的参数和代码。2. 把点餐数据落到 SQLite建表、DAO 与初始化种子数据2.1 为什么单机项目选 SQLite而不是 SharedPreferences 或文件存储这是第一个要说服自己的选型问题。很多同学习惯用 SharedPreferences 存购物车用 JSON 文件存菜单写起来好像更快。但这类方案在“期末作业答辩”场景里非常吃亏老师一问“你的数据存在哪里、怎么保证多条菜品数据的一致性”你很难说清楚。SQLite 的优势在于它把菜单、订单、订单明细这三类数据的关系天然地用外键表达出来而且它的事务能力能让“下单同时扣减库存、写入订单头、写入订单明细”这三个动作要么全部成功要么全部回滚。我之前用 SharedPreferences 写过一版点餐 Demo结果用户快速点“加购”时偶发数据错乱排查了很久才发现是多次 commit 互相覆盖。换成 SQLite 之后这类问题从机制上就消失了。单机应用不需要考虑并发访问数据库的极端情况SQLite 的轻量锁机制足够应付学生的演示场景。2.2 三张表的建表语句字段、类型与主外键设计点餐系统的数据模型相对固定我一般拆成三张表菜品表dish、订单主表orders、订单明细表order_item。菜品表存菜单的固定信息订单主表存一次点餐的整体信息桌号、总价、下单时间、状态订单明细表存每一道菜在某个订单里的快照菜名、数量、单价这样即使以后改了菜单表历史订单的展示也完全不受影响。-- 菜品表 CREATE TABLE dish ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- 菜名 category TEXT NOT NULL DEFAULT 热菜, -- 分类凉菜/热菜/主食/饮品 price REAL NOT NULL DEFAULT 0, -- 单价 stock INTEGER NOT NULL DEFAULT 99, -- 库存点餐即扣减 image_res TEXT, -- 图片资源名比如 dish_fish status INTEGER NOT NULL DEFAULT 1 -- 1上架 0下架 ); -- 订单主表 CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_no TEXT NOT NULL, -- 桌号比如 A01 total_price REAL NOT NULL DEFAULT 0, create_time TEXT NOT NULL, -- 下单时间ISO8601 格式 status INTEGER NOT NULL DEFAULT 0 -- 0已下单 1已完成 ); -- 订单明细表 CREATE TABLE order_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, dish_id INTEGER NOT NULL, dish_name TEXT NOT NULL, -- 快照字段下单时的菜名 price REAL NOT NULL, -- 下单时的单价 quantity INTEGER NOT NULL DEFAULT 1, FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE );说明一下关键设计决策。price字段我用REAL但实际项目里更推荐用整数“分”来存储因为浮点数的精度问题会在总价计算时暴露初学者为了省事用 REAL 也说得过去但你在计算总价时要记得用BigDecimal或手动保留两位小数。order_item里的dish_name和price是故意冗余的这叫“快照字段”订单显示时不参与联表查询避免菜单价格修改后历史订单跟着变。2.3 DAO 层的增删改查用事务包装“下单扣库存”的原子操作建完表之后数据访问对象层DAO是连接 SQLite 和界面的桥梁。很多同学喜欢直接在MainActivity里写数据库操作短时间能跑但一旦页面多了每个 Activity 都维护一个SQLiteOpenHelper实例连接管理与代码清晰度都会失控。我习惯的做法是做一个单例 DAO 类把业务方法比如getDishList()、createOrder()暴露给界面层Activity 不直接接触SQLiteDatabase对象。public class OrderDao { private final SQLiteDatabase db; private static OrderDao instance; private OrderDao(Context context) { DBHelper helper new DBHelper(context); db helper.getWritableDatabase(); } public static synchronized OrderDao get(Context context) { if (instance null) { instance new OrderDao(context.getApplicationContext()); } return instance; } // 下单订单头 明细 扣库存全部包在一个事务里 public boolean createOrder(String tableNo, ListCartItem items) { db.beginTransaction(); try { ContentValues orderValues new ContentValues(); orderValues.put(table_no, tableNo); double total 0; for (CartItem item : items) { total item.getPrice() * item.getQuantity(); } orderValues.put(total_price, total); orderValues.put(create_time, getNowIso8601()); orderValues.put(status, 0); long orderId db.insert(orders, null, orderValues); // 批量写入明细并同步扣库存 for (CartItem item : items) { ContentValues itemValues new ContentValues(); itemValues.put(order_id, orderId); itemValues.put(dish_id, item.getDishId()); itemValues.put(dish_name, item.getDishName()); itemValues.put(price, item.getPrice()); itemValues.put(quantity, item.getQuantity()); db.insert(order_item, null, itemValues); db.execSQL(UPDATE dish SET stock stock - ? WHERE id ? AND stock ?, new Object[]{item.getQuantity(), item.getDishId(), item.getQuantity()}); } db.setTransactionSuccessful(); return true; } catch (Exception e) { return false; } finally { db.endTransaction(); } } private String getNowIso8601() { SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.CHINA); return sdf.format(new Date()); } }这里有几个关键点值得单独拿出来说。beginTransaction到endTransaction之间的所有写操作只有调用了setTransactionSuccessful()才会真正提交否则任何一步抛异常前面的insert和update都会被回滚。UPDATE dish SET stock stock - ? WHERE id ? AND stock ?这个写法利用 SQLite 的单语句原子性把扣库存和“库存不足检查”合并了如果影响行数为 0说明库存不够你可以主动抛异常触发回滚。DAO 用单例模式是因为 Activity 重建时不应该重新打开数据库连接否则容易出现SQLiteException: database is locked。2.4 初始化与种子数据让 Demo 启动就有菜可点空菜单没法展示效果所以应用第一次启动时要往dish表写入种子数据。常见的做法是在DBHelper.onCreate()里执行INSERT也可以用SharedPreferences记录一个“已初始化”标志位避免重复插入。我推荐前者因为数据库版本升级时种子数据可以被onUpgrade逻辑重新处理。public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME ordering.db; private static final int DB_VERSION 1; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE_DISH_SQL); db.execSQL(CREATE_ORDER_SQL); db.execSQL(CREATE_ORDER_ITEM_SQL); insertSeedData(db); } private void insertSeedData(SQLiteDatabase db) { db.execSQL(INSERT INTO dish (name, category, price, stock) VALUES (宫保鸡丁, 热菜, 28.0, 50)); db.execSQL(INSERT INTO dish (name, category, price, stock) VALUES (西红柿炒蛋, 热菜, 18.0, 50)); db.execSQL(INSERT INTO dish (name, category, price, stock) VALUES (拍黄瓜, 凉菜, 12.0, 30)); db.execSQL(INSERT INTO dish (name, category, price, stock) VALUES (米饭, 主食, 2.0, 200)); db.execSQL(INSERT INTO dish (name, category, price, stock) VALUES (可乐, 饮品, 6.0, 100)); } }参数方面要注意onCreate只在数据库文件第一次创建时调用如果你的建表 SQL 写错了改了代码也不会自动生效。想重置数据库就卸载应用或者在onUpgrade里做版本号递增。演示时如果菜品数据不够丰富可以在insertSeedData里多写几行这不算坏味道反而方便答辩时展示列表滚动效果。3. 从列表到购物车点餐主流程的界面绑定与状态同步3.1 RecyclerView 展示菜品Adapter 里别直接操作数据库菜单列表是整个应用的视觉核心。用ListView还是RecyclerView期末作业用 RecyclerView 更保险因为它是现在的主流而且自带 ViewHolder 复用机制滑动流畅度比 ListView 好。Adapter 里我强烈建议只持有一个“内存数据源”也就是ListDish所有对列表的修改加购、减购、更新库存先改这个 List再调用notifyItemChanged或notifyDataSetChanged而不是在 Adapter 里直接开数据库连接去查。public class DishAdapter extends RecyclerView.AdapterDishAdapter.ViewHolder { private final ListDish dishList; private final OnDishClickListener listener; private final MapInteger, Integer cartCountMap new HashMap(); // dishId - 已加购数量 public DishAdapter(ListDish dishList, OnDishClickListener listener) { this.dishList dishList; this.listener listener; } Override public void onBindViewHolder(ViewHolder holder, int position) { Dish dish dishList.get(position); holder.nameText.setText(dish.getName()); holder.priceText.setText(¥ String.format(%.2f, dish.getPrice())); int cartCount cartCountMap.getOrDefault(dish.getId(), 0); holder.countText.setText(cartCount 0 ? 已选 cartCount : ); holder.itemView.setOnClickListener(v - listener.onClick(dish, cartCount 1)); } // 由 Activity 在购物车发生变化时调用避免 Adapter 自己写数据库 public void updateCartCount(int dishId, int newCount) { cartCountMap.put(dishId, newCount); for (int i 0; i dishList.size(); i) { if (dishList.get(i).getId() dishId) { notifyItemChanged(i); break; } } } }这段代码的要点是点击回调把“加购数量 1”的意图抛给 Activity 层处理Activity 负责更新购物车数据结构和数据库然后调updateCartCount刷新单行。这样职责分离的好处是你以后想改成“点击弹窗选择数量”只需要改 Activity 里的逻辑Adapter 不用动。cartCountMap是界面层的临时状态它不持久化退出应用购物车清空——单机点餐的演示场景里这是可接受的但如果你想做到“退出后购物车还在”那就得给它加一张表或者用SharedPreferences我建议初学者先把内存态做好再加持久化不要一上来就两头抓。3.2 购物车数据结构用 HashMap 组装而不是维护一份重复的 List购物车本质上是“菜品的 id 到数量”的映射附带菜品的单价、菜名信息用于结算展示。很多同学会把它设计成ListCartItem每次加购就遍历一遍看有没有相同的菜有就加数量没有就新增一项。这个逻辑没毛病但频繁的线性查找既不优雅也容易在 Adapter 刷新时搞错位置索引。我一般维护一个LinkedHashMapInteger, CartItemkey是dishIdvalue是 CartItem。加购时直接put如果已存在就拿到旧值把数量加一结算时把values()转成 List 传给结算页。这样做的好处是查找复杂度是常量级而且LinkedHashMap能保持加购顺序结算页展示时用户看到的就是他自己的点菜顺序。public class CartManager { private final LinkedHashMapInteger, CartItem cart new LinkedHashMap(); public void add(Dish dish) { CartItem item cart.get(dish.getId()); if (item null) { cart.put(dish.getId(), new CartItem(dish.getId(), dish.getName(), dish.getPrice(), 1)); } else { item.setQuantity(item.getQuantity() 1); } } public void decrease(int dishId) { CartItem item cart.get(dishId); if (item null) return; if (item.getQuantity() 1) { cart.remove(dishId); } else { item.setQuantity(item.getQuantity() - 1); } } public double getTotalPrice() { double total 0; for (CartItem item : cart.values()) { total item.getPrice() * item.getQuantity(); } // 保留两位小数避免浮点误差 return Math.round(total * 100) / 100.0; } public ListCartItem toList() { return new ArrayList(cart.values()); } public void clear() { cart.clear(); } }注意getTotalPrice()里的Math.round是为了把 0.10.2 这类浮点误差挡在门外。更进一步的做法是内部用int存“分”显示时再除以 100这里为了代码可读性先保留 double 精度处理。这个CartManager可以做成普通实例交给 Activity 持有不需要单例因为它只服务于当前会话。3.3 菜品详情弹窗与数量选择DialogFragment 比自定义 PopupWindow 更稳点击菜品后常见做法是弹一个底部弹出框或者居中的 Dialog 展示菜品图片、描述、价格下面放数量加减按钮和“加入购物车”按钮。用DialogFragment而不是直接在 Activity 里new AlertDialog是为了旋转屏幕时对话框状态能由 FragmentManager 自动恢复不会出现“转一下屏幕对话框自己消失了”的尴尬。public class DishDetailDialog extends DialogFragment { private static final String ARG_DISH dish; public static DishDetailDialog newInstance(Dish dish) { DishDetailDialog dialog new DishDetailDialog(); Bundle args new Bundle(); args.putSerializable(ARG_DISH, dish); dialog.setArguments(args); return dialog; } NonNull Override public Dialog onCreateDialog(Bundle savedInstanceState) { Dish dish (Dish) getArguments().getSerializable(ARG_DISH); AlertDialog.Builder builder new AlertDialog.Builder(requireContext()); View view LayoutInflater.from(requireContext()).inflate(R.layout.dialog_dish_detail, null); TextView name view.findViewById(R.id.dialog_dish_name); TextView price view.findViewById(R.id.dialog_dish_price); TextView count view.findViewById(R.id.dialog_dish_count); name.setText(dish.getName()); price.setText(¥ dish.getPrice()); builder.setView(view); // 数量加减逻辑省略按钮点击时通过接口回调传回菜品和数量 return builder.create(); } }setArguments传参数的写法属于 Fragment 的标准姿势比直接调new DishDetailDialog(dish)然后setDish更安全因为系统重建 Fragment 时会保留 arguments 里的数据。把Dish做成Serializable是最省事的但答辩时如果老师问“为什么不用 Parcelable”你要能答出 Parcelable 性能更好、专为 IPC 设计不过Serializable在 Java 里是现成的写课设用它可以接受。3.4 购物车角标与结算页联动EventBus 还是接口回调购物车图标上的数字角标需要随时同步比如你在详情页加了 3 道菜回到列表页底部栏的“去结算”按钮上的角标要立刻变成 3。最直觉的做法是在onResume里重新读一次CartManager但这样会带来一次全量刷新列表会明显闪一下。更好的方案是让列表 Activity 实现一个CartUpdateListener在DishDetailDialog点“加入购物车”按钮时通过接口回调通知 Activity。接口回调比 EventBus 适合这个场景因为事件是单向且立即的不需要引入额外的第三方库。你写期末作业时如果只为了一个角标引 EventBus答辩时反而不好解释“为什么需要事件总线直接回调不行吗”。回调核心就是定义一个方法签名比如void onCartUpdated(CartManager cart)Activity 里拿到新的总数量后更新角标同时调dishAdapter.updateCartCount(dishId, newCount)刷新对应菜品行的已选数量。4. 订单生成与历史订单把一次点餐变成可回看的会话4.1 下单动作的完整链路购物车 → 订单主表 → 明细表 → 清空购物车用户点击“去结算”后进入确认页展示购物车所有项和总价输入桌号点“确认下单”。这个动作在三张表之间的流转顺序是固定的先确认桌号不为空再调用OrderDao.createOrder(tableNo, items)成功后清空CartManager最后跳转到订单完成页。这里最容易翻车的地方是有人先清购物车再写数据库结果数据库写入失败购物车也没了用户只能重新点一遍。public void onConfirmOrderClick(View view) { String tableNo tableNoEdit.getText().toString().trim(); if (tableNo.isEmpty()) { Toast.makeText(this, 请输入桌号, Toast.LENGTH_SHORT).show(); return; } ListCartItem items cartManager.toList(); boolean success OrderDao.get(this).createOrder(tableNo, items); if (success) { cartManager.clear(); // 刷新购物车角标和列表选中状态 refreshCartBadge(); finish(); // 跳转到下单成功页或订单详情页 } else { Toast.makeText(this, 下单失败库存不足或数据异常, Toast.LENGTH_LONG).show(); } }这里有三个隐藏细节值得注意。一是items要取出为局部变量再传给 DAO不要直接把cartManager.toList()的结果链式调用因为clear()之后集合内容就变了。二是下单成功后如果要刷新列表菜品上的“已选数量”需要把 dishId 对应的 count 归零这需要 Adapter 提供批量重置方法而不是逐条去删cartCountMap。三是失败时不要清空购物车让用户有机会调整数量重新下单这是体验上的一个底线。4.2 历史订单列表与详情展示CursorAdapter 还是手动查询订单列表页需要展示“桌号、时间、总价”点进去看明细。这里我建议直接用SQLiteDatabase.query查orders表按时间倒序然后用SimpleCursorAdapter直接把 Cursor 绑到 ListView 上。当然用ListOrder也行但 Cursor 方式更接近原生 SQLite 的交互模型代码量更少。SQLiteDatabase db OrderDao.get(this).getReadableDatabase(); Cursor cursor db.rawQuery( SELECT id, table_no, total_price, create_time FROM orders ORDER BY create_time DESC, null); SimpleCursorAdapter adapter new SimpleCursorAdapter( this, R.layout.item_order, cursor, new String[]{table_no, total_price, create_time}, new int[]{R.id.order_table_no, R.id.order_total, R.id.order_time}, CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER); ListView listView findViewById(R.id.order_list); listView.setAdapter(adapter);SimpleCursorAdapter的坑在于如果你忘记调用cursor.close()会有内存泄漏的警告而且 Activity 销毁时要记得adapter.changeCursor(null)来释放旧游标的引用。另一个注意点是原始 SQL 里的ORDER BY create_time DESC在字符串格式是yyyy-MM-dd HH:mm:ss时能按字典序正确排序因为你用的格式每个字段都是定长的这是 ISO8601 格式的额外好处。订单详情页则比较简单根据 orderId 查order_item表用一个普通的ArrayAdapter展示菜名、单价、数量、小计再在页面顶部显示桌号和总价即可。这里不需要快照设计之外的复杂逻辑重点是把数据和 UI 对应清楚。4.3 库存联动下架菜品与售罄状态的前端表现如果你在dish表加了stock字段并且每次下单都扣减那菜单列表里就必须有“售罄”或“库存不足”的展示。最简单的处理是在 Adapter 的onBindViewHolder里判断stock 0把点击事件禁用同时把菜品名称显示成灰色。难点在于购物车中如果已经加了某道菜下单时库存不够事务回滚之后要给出准确提示。我踩过的一个坑是在createOrder的for循环里每执行一次UPDATE dish SET stock stock - ? WHERE id ? AND stock ?后没有检查返回值导致一个订单里前一种菜扣成功了、后一种菜库存不足抛异常整个事务虽然回滚了但界面上的购物车数量还显示着原来的值用户完全不知道哪里出了问题。后来的做法是UPDATE返回受影响行数为 0 时用一个自定义异常携带菜品名在 Activity 捕获后 Toast 提示“库存不足XX”同时把购物车中该菜品标记为不可下单。5. 单机点餐的 5 个高频翻车点现象、原因与排查步骤5.1 数据库被锁SQLiteException database is locked现象快速点击“下单”两次或者从一个页面跳转到另一个页面再返回时偶发闪退日志里出现android.database.sqlite.SQLiteException: database is locked。原因最常见的情况是同一个SQLiteDatabase对象在多个线程或多次打开的 helper 实例之间没有互斥。比如你在下单的异步任务里用了db.beginTransaction()同时主线程又在查询订单列表两个连接同时写或读写碰撞。单机应用里没有高并发但 Activity 重建时如果旧连接没关闭新连接再去访问就会锁冲突。解决统一使用OrderDao单例保证全局只有一个SQLiteDatabase实例。所有写操作放在事务里读操作尽量走同一个连接。如果真的要在子线程做耗时数据库操作就用runOnUiThread更新 UI不要绕过单例再创建新的 helper。如果确定是 Activity 重建导致的检查onDestroy里是否有未关闭的游标或连接。5.2 图片资源引用崩溃Resources.NotFoundException 或解码卡顿现象菜品列表加载本地图片时直接崩溃报错找不到资源或者滑动列表时图片区域出现明显卡顿、内存飙升。原因onBindViewHolder里每次加载大图资源时都重新解码RecyclerView 快速滑动时会积累大量未回收的 Bitmap。另一个常见问题是把图片文件名写错了比如R.drawable.dish_fish写成dish_frish在资源不存在时第一次点击就崩。解决购物车列表或者菜单列表的图片优先用drawable目录里的整体资源 id 而不是字符串动态查找或者在 Adapter 的构造器里把int imageRes转成imageResId缓存成数组避免在onBindViewHolder里执行getIdentifier()。如果图片过大用BitmapFactory.Options做采样压缩inSampleSize设为 2 或 4把内存消耗降到原来的四分之一或十六分之一。5.3 Adapter 刷新后界面不更新notifyDataSetChanged 失效现象加购一道菜后底部购物车角标变了但列表里对应菜品的“已选数量”还是空的或者滑一下列表才更新。原因notifyDataSetChanged只对列表数据源与 View 的绑定关系做全量刷新如果你改了 List 里对象的某个属性但没有重新set到 Adapter 持有的 List 中你会以为自己改了数据源但实际没有。另一种情况是你在子线程调用了notifyItemChangedRecyclerView 不允许非主线程直接操作视图。解决所有数据修改必须发生在主线程修改的是 Adapter 内部持有的同一个 List 对象不是重新 new List 赋给 adapter。你调notifyItemChanged(i)时i必须是当前位置如果数据集发生了增删位置索引会错乱。最安全的做法是先找到dishId在 List 中的位置修改对象属性再notifyItemChanged(position)不要notifyDataSetChanged一把梭。5.4 浮点价格错误0.10.20.30000000000000004现象三样菜价格分别是 0.1、0.2、0.3购物车总价显示 0.6000000000000001或者支付金额多了一分。原因Java 的double和float都是 IEEE 754 浮点数二进制无法精确表示 0.1。累加多次后误差会累积显示层 String.format 兜底时看似正常但如果你把这个总价存进数据库或传给另一个 Activity就会出现奇怪的尾数。解决实体类里用int priceInFen存“分”界面显示时再格式化。购物车CartItem下单时 DAO 层做乘法后保持在整数域运算。如果嫌改造量大可以在总价计算方法里用BigDecimal.valueOf(double).add()做加法最后setScale(2, RoundingMode.HALF_UP)。我建议课设直接上分单位因为这个项目还要给老师演示演示时出现浮点尾巴会非常尴尬。5.5 旋转屏幕导致购物车丢失Activity 重建后状态没恢复现象加购几道菜后用户旋转屏幕购物车清空了或者直接崩了。原因默认配置下 Android 旋转屏幕会销毁当前 Activity 并重建CartManager是 Activity 持有的普通对象销毁后跟着没了。解决至少做两层处理。第一层给CartManager实现Serializable在onSaveInstanceState里putSerializable(cart, cartManager)在onCreate的savedInstanceState里取出来恢复。第二层更好的方案是把CartManager拿到 Application 里做成全局单例这样无论 Activity 怎么重建购物车数据都在。课设选后者更省事但老师可能会问“全局单例有没有内存泄漏风险”你需要解释购物车是短期会话数据在onTerminate或退出时清空即可不持有 Context 引用就不会泄漏。6. 把单机点餐做成可以演示结课的版本导出、备份与一条验证清单6.1 数据库导出到本地文件够用且直观的备份方案期末作业验收时老师可能会问“你的数据能导出来看看吗”。一个实用技巧是在订单列表页加一个“导出订单”按钮把数据库文件复制到应用的getExternalFilesDir目录下或者导出 CSV 到 Downloads。前者操作简单后者更方便直接在电脑上打开看数据。public boolean exportDatabase(Context context) { File dbFile context.getDatabasePath(ordering.db); File exportDir new File(context.getExternalFilesDir(null), export); if (!exportDir.exists()) { exportDir.mkdirs(); } File exportFile new File(exportDir, ordering_backup_ System.currentTimeMillis() .db); try (FileInputStream fis new FileInputStream(dbFile); FileOutputStream fos new FileOutputStream(exportFile)) { byte[] buffer new byte[1024]; int len; while ((len fis.read(buffer)) 0) { fos.write(buffer, 0, len); } return true; } catch (Exception e) { return false; } }注意这里导出的 db 文件在getExternalFilesDir下不需要申请存储权限因为它属于应用专属外部目录不会触发运行时权限弹窗。导出到公共的 Downloads 目录则需要写权限课设没有必要冒险。如果要导出 CSV 查看就查询orders和order_item表用 StringBuilder 拼接注意把换行符和逗号转义掉否则 Excel 打开会错列。6.2 本地自动化验证用 JUnit 跑通 DAO 层的核心用例一个容易被老师加分的设计是“你的 DAO 层有单元测试”。你不需要写复杂的测试框架代码只要建一个androidTest下的测试类验证两条最关键的业务规则下单成功后库存减一库存不足时下单失败且订单不落库。Test public void testCreateOrder_DeductsStock() { Context context ApplicationProvider.getApplicationContext(); OrderDao dao OrderDao.get(context); ListCartItem items new ArrayList(); items.add(new CartItem(1, 宫保鸡丁, 28.0, 1, 0)); boolean success dao.createOrder(A01, items); assertTrue(success); Cursor cursor dao.queryStock(1); cursor.moveToFirst(); assertEquals(49, cursor.getInt(0)); }测试的目的不是证明代码没有 bug而是让你加班加点改动 DAO 时有一个立即反馈的安全网。课设答辩时如果老师问“你怎么保证下单和扣库存是一致的”你就可以直接说“我写了一个事务测试验证了库存扣减和订单写入的原子性”。这句话比空口解释有力得多。6.3 一条演示前自查清单从安装到下单只需要五步演示翻车大部分不是因为功能复杂而是因为没有按固定路径走一遍。我总结了一个演示前必查清单你可以直接照做第一步卸载旧应用全新安装一次确认种子数据正常出现第二步依次点击 3 道菜加购观察列表角标与购物车角标同步变化第三步进入结算页输入桌号确认总价与手算一致第四步确认下单成功后购物车清空菜单上对应菜品库存减一第五步进入历史订单列表打开刚下的单确认明细里的菜名、单价、数量与下单时一致。每次演示前我把这五步走完基本能过滤掉九成的低级问题。此外留一个习惯性的补充检查在设置里把系统字体大小调到最大看看列表有没有文字被截断——我见过不止一次因为字体缩放导致按钮文字变“...”的现场翻车。做完这个项目之后我最大的感受是安卓单机应用的根本难度不在界面多华丽而在于你把“界面状态”和“持久化数据”之间的同步关系想清楚了其他的都是熟能生巧。这门课设是我第一次觉得“数据库不是黑匣子而是我可以控制的一块存储区域”你已经走到这一步了环境问题、依赖冲突、界面卡顿这些都是正常的学习成本别在编译报错面前耗太久先跑通再优化希望帮到你。本文还有配套的精品资源点击获取