☰
安卓单机点餐系统开发实战:SQLite事务与Adapter刷新机制解析
2026/10/11 2:12:36 网站建设 项目流程

简介:面向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, List<CartItem> 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 里我强烈建议只持有一个“内存数据源”,也就是List<Dish>,所有对列表的修改(加购、减购、更新库存)先改这个 List,再调用notifyItemChanged或notifyDataSetChanged,而不是在 Adapter 里直接开数据库连接去查。

public class DishAdapter extends RecyclerView.Adapter<DishAdapter.ViewHolder> { private final List<Dish> dishList; private final OnDishClickListener listener; private final Map<Integer, Integer> cartCountMap = new HashMap<>(); // dishId -> 已加购数量 public DishAdapter(List<Dish> 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 到数量”的映射,附带菜品的单价、菜名信息用于结算展示。很多同学会把它设计成List<CartItem>,每次加购就遍历一遍看有没有相同的菜,有就加数量,没有就新增一项。这个逻辑没毛病,但频繁的线性查找既不优雅,也容易在 Adapter 刷新时搞错位置索引。

我一般维护一个LinkedHashMap<Integer, CartItem>,key是dishId,value是 CartItem。加购时直接put,如果已存在就拿到旧值把数量加一;结算时把values()转成 List 传给结算页。这样做的好处是查找复杂度是常量级,而且LinkedHashMap能保持加购顺序,结算页展示时用户看到的就是他自己的点菜顺序。

public class CartManager { private final LinkedHashMap<Integer, 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 List<CartItem> toList() { return new ArrayList<>(cart.values()); } public void clear() { cart.clear(); } }

注意getTotalPrice()里的Math.round是为了把 0.1+0.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; } List<CartItem> 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 上。当然用List<Order>也行,但 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 中,你会以为自己改了数据源但实际没有。另一种情况是你在子线程调用了notifyItemChanged,RecyclerView 不允许非主线程直接操作视图。

解决:所有数据修改必须发生在主线程,修改的是 Adapter 内部持有的同一个 List 对象(不是重新 new List 赋给 adapter)。你调notifyItemChanged(i)时,i必须是当前位置,如果数据集发生了增删,位置索引会错乱。最安全的做法是:先找到dishId在 List 中的位置,修改对象属性,再notifyItemChanged(position),不要notifyDataSetChanged一把梭。

5.4 浮点价格错误:0.1+0.2=0.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); List<CartItem> 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 道菜加购,观察列表角标与购物车角标同步变化;第三步,进入结算页输入桌号,确认总价与手算一致;第四步,确认下单成功后购物车清空,菜单上对应菜品库存减一;第五步,进入历史订单列表,打开刚下的单,确认明细里的菜名、单价、数量与下单时一致。

每次演示前我把这五步走完,基本能过滤掉九成的低级问题。此外留一个习惯性的补充检查:在设置里把系统字体大小调到最大,看看列表有没有文字被截断——我见过不止一次因为字体缩放导致按钮文字变“...”的现场翻车。

做完这个项目之后我最大的感受是:安卓单机应用的根本难度不在界面多华丽,而在于你把“界面状态”和“持久化数据”之间的同步关系想清楚了,其他的都是熟能生巧。这门课设是我第一次觉得“数据库不是黑匣子,而是我可以控制的一块存储区域”,你已经走到这一步了,环境问题、依赖冲突、界面卡顿,这些都是正常的学习成本,别在编译报错面前耗太久,先跑通再优化,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询