简介:面向Android初学者的单机点餐系统期末作业,基于Eclipse开发,采用SQLite存储数据,功能围绕登录、点餐、订单查看三条主线展开,精简但流程完整,适合课程设计、结课汇报或入门练习。压缩包共176个文件,大小约33.53MB,内容涵盖19个Java源文件、29个XML布局与配置、58张PNG界面素材、2个SQLite数据库文件,并附可直接安装的APK与Eclipse工程文件,便于在开发环境中导入查看。已有3133人学习下载。包内单独提供数据库说明文档,标注测试账号ZYS(密码123),运行后能快速进入主流程;同时可对照Java与XML文件,梳理Activity跳转、Adapter列表绑定、SQLite增删改查等关键实现。页面设计相对用心,整体结构清晰,适合作为期末作业的完整参考模板,也可在此基础上扩展联网或购物车功能。
1. 项目速览:期末作业凭什么值得二次开发
如果你翻过几个安卓项目的开源仓库,你会发现“点餐系统”几乎是期末作业的经典保留节目。但这个项目不太一样:它砍掉了网络层、砍掉了后台管理端,只保留一个单机 App 的全部核心闭环——浏览菜品、加购、下单、查历史订单,全程本地 SQLite 落库。换句话说,这是一个你拿到手就能跑、跑起来就能改的安卓练习模板,适合刚学完四大组件和 SQLite 的开发者用来对照自己的作业,也适合想快速产出“能演示完整业务流程”App 的人拿去换皮改造。
我最初拆这个项目时本来没抱太大期望,结果发现它的代码组织比大多数随手写的作业要规范不少:数据库用 helper 统一管理、Adapter 和 Activity 职责分离、下单流程带事务。作为期末作业它已经很能打了,作为二次开发的底子也够用。下面从技术选型开始,逐步把这份资源拆开讲清楚。
2. 技术选型与整体架构:为什么这个项目值得拆着看
2.1 技术选型:单机场景下的务实组合
这个项目没有引入第三方框架,全栈用 Android 原生组件完成,技术栈落在三个点上:Java、SQLite、ListView/RecyclerView 适配器。Java 不用多说,是绝大多数安卓课程的主语言;SQLite 直接使用系统自带的 SQLiteOpenHelper,不引 GreenDAO 也不引 Room,对刚接触数据库的同学来说,暴露出来的 SQL 反而更直观,方便理解建表和增删改查。
界面部分用的不是当下流行的 Jetpack Compose,而是传统 XML + Activity 模式。具体列表控件上,菜品列表和订单列表都可以用 ListView 或 RecyclerView 实现。对期末作业来说,ListView 的代码更短、更容易讲明白;如果作者用了 RecyclerView,则说明代码已经有一层“ViewHolder 复用”的意识,二次改造时扩展卡片样式也更顺手。无论如何,这套组合的学习成本非常低,适合照着抄。
“为什么不联网、不搞服务端?”——这是单机作业最常见的取舍。点餐数据只存在手机本地,食堂也好、小餐厅也好,演示时不需要服务器和网络权限,省掉了大量联调时间,也避开了网络权限、线程切换、接口协议这一堆容易翻车的环节。代价是换手机就丢数据,但这恰恰是期末作业最合理的边界。
2.2 目录结构:先认路再动手
拿到项目后别急着点运行,先把目录结构过一遍。安卓项目的标准结构下,这个 App 的代码集中在app/src/main/java/下,按包名划分出几个职责明确的包:
src/main/java/com/example/orderapp/ ├── activity/ # Activity 层,负责界面跳转与交互 │ ├── MainActivity.java # 主界面,展示菜品分类和菜品列表 │ ├── CartActivity.java # 购物车界面 │ ├── OrderConfirmActivity.java # 订单确认与下单 │ └── OrderHistoryActivity.java # 历史订单列表 ├── adapter/ # ListView/RecyclerView 的适配器 │ ├── ProductAdapter.java # 菜品列表适配器 │ └── OrderAdapter.java # 历史订单适配器 ├── db/ │ ├── DBHelper.java # 数据库创建与版本管理 │ └── OrderDao.java # 订单相关的增删改查操作 ├── model/ │ ├── Product.java # 菜品实体 │ ├── CartItem.java # 购物车条目实体 │ └── Order.java # 订单实体 └── utils/ └── CartManager.java # 购物车内存态管理model包里的实体类对应数据库表和界面展示的数据来源;db包负责 SQLite 的建表和 DAO 操作;adapter包把实体绑定到界面列表上;Activity 只做事件响应和页面跳转。这个分层虽然不是严格的 MVC/MVP,但胜在边界清楚——你改数据库时不必动界面代码,换界面样式时也不影响逻辑层。
2.3 数据库设计:三张表撑起整个闭环
数据库是这个项目的核心资产,打开DBHelper.java就能看到建表语句。整个 App 的数据模型压缩成三张表:菜品表、订单表、订单明细表。
CREATE TABLE product ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price REAL NOT NULL, image_res INTEGER, -- 菜品图片资源 id category TEXT -- 分类,如 "热菜" / "凉菜" / "饮品" ); CREATE TABLE `order` ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_no TEXT NOT NULL, -- 桌号 total_price REAL NOT NULL, create_time TEXT DEFAULT (datetime('now', 'localtime')), status INTEGER DEFAULT 0 -- 0 已下单, 1 已完成 ); CREATE TABLE order_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, product_name TEXT, -- 冗余菜品名,防止菜品删除后订单变“无头案” price REAL NOT NULL, quantity INTEGER NOT NULL );设计上有一个值得注意的细节:order_item表冗余了product_name字段。正常情况下可以通过product_id关联product表查出名称,但一旦菜品下架或者被删除,历史订单的名称会变成空。这里的冗余就像给订单拍了张快照——下单那一刻的菜名、价格都冻结在明细表里,哪怕之后菜品涨价或删除,历史账单依然完整。这个细节在课堂上一般不会讲到,但在真实项目里非常重要。
购物车没有单独建表,而是用内存态管理,放在CartManager里。原因也很简单:购物车是临时数据,App 进程被杀就应该清空,不需要持久化;真正要入库的是下单那一个瞬间的数据。后续如果要做“草稿箱”功能,再加一张cart表即可。
3. 核心模块拆解:一次点餐从点击到落库的完整链路
3.1 菜品列表的加载与展示
主界面MainActivity的职责是加载菜品并绑定到列表上。它的逻辑可以拆成三步:拿数据库实例、查询全量菜品、交给ProductAdapter渲染。代码大致是这样:
public class MainActivity extends AppCompatActivity { private ListView productList; private ProductAdapter adapter; private DBHelper dbHelper; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); dbHelper = new DBHelper(this); productList = findViewById(R.id.product_list); List<Product> products = dbHelper.getAllProducts(); adapter = new ProductAdapter(this, products); productList.setAdapter(adapter); } }getAllProducts()在DBHelper里就是一次最基础的查询:SELECT * FROM product,然后遍历游标把每一行封装成Product对象。这个封装过程看起来平淡,但容易踩坑的地方在于——很多人直接在 Activity 里写while (cursor.moveToNext()),导致界面代码和数据库逻辑纠缠在一起;而这个项目把查询封装到 DBHelper 里,Activity 拿到的是List<Product>而不是游标,界面代码瞬间干净了。这个习惯建议保留。
ProductAdapter是列表渲染的核心。它需要复写getView()(ListView 写法)或onCreateViewHolder()(RecyclerView 写法),把Product的字段映射到item_product.xml的控件上。一个典型的 ListView 适配器实现如下:
@Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView == null) { convertView = LayoutInflater.from(context) .inflate(R.layout.item_product, parent, false); holder = new ViewHolder(); holder.ivImage = convertView.findViewById(R.id.iv_product_image); holder.tvName = convertView.findViewById(R.id.tv_product_name); holder.tvPrice = convertView.findViewById(R.id.tv_product_price); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.getTag(); } Product product = getItem(position); holder.tvName.setText(product.getName()); holder.tvPrice.setText("¥" + product.getPrice()); // 图片用资源 id 直接 setImageResource holder.ivImage.setImageResource(product.getImageRes()); return convertView; }这里有两个细节值得说。第一,convertView == null时才 inflate 布局,否则直接复用——这是 ListView 的性能命脉,不加这个判断,滑动时就会频繁创建视图导致卡顿。第二,item 布局里“加购按钮”的点击事件建议在适配器里通过回调接口抛给 Activity 处理,而不是在 getView 里直接写购物车逻辑,否则适配器会越来越肿,也不好测试。
参数说明:position是当前条目在数据集里的下标;convertView是 ListView 滚动时回收的复用视图;parent是父容器,inflate 时传false表示不立即挂载,由 ListView 自己控制。理解这三个参数,View 复用机制基本就通了。
3.2 购物车的内存态管理与加购逻辑
购物车在这个项目里是全局单例。用单例而不是在 Activity 之间传来传去,是为了避免旋转屏幕或跳转页面时数据丢失——只要 App 进程还在,购物车数据就还在。CartManager大概长这样:
public class CartManager { private static CartManager instance; private Map<Integer, CartItem> cartMap; // key 为 productId private CartManager() { cartMap = new LinkedHashMap<>(); } public static synchronized CartManager getInstance() { if (instance == null) { instance = new CartManager(); } return instance; } public void add(Product product) { CartItem item = cartMap.get(product.getId()); if (item == null) { cartMap.put(product.getId(), new CartItem(product, 1)); } else { item.setQuantity(item.getQuantity() + 1); } } public List<CartItem> getAllItems() { return new ArrayList<>(cartMap.values()); } public double getTotalPrice() { double total = 0; for (CartItem item : cartMap.values()) { total += item.getPrice() * item.getQuantity(); } return total; } public void clear() { cartMap.clear(); } }把购物车设计成Map<productId, CartItem>而不是List<CartItem>,是一个很聪明的选择。按菜品 ID 做 key,加购时就不用遍历列表找“是否已存在”,直接map.get(productId)判断即可,时间复杂度从 O(n) 降为 O(1)。LinkedHashMap还能保持插入顺序,让购物车列表的展示次序跟加购次序一致。
CartItem实体内部持有Product引用和quantity数量。之所以不直接塞一个Product+ 数量字段到 Map 里,是为了后面扩展“口味”“备注”这类附加属性时不用改 Map 结构——把变化封装在实体内部,这是面向对象的基本功,虽然简单但很好用。
下单成功后记得调用cartMap.clear(),否则下次进购物车会看到上一次的残留数据。这个清理动作我见过不少同学漏掉,导致演示时出现“下单成功但购物车还有东西”的尴尬局面。
3.3 下单事务:一个方法保证两张表同时写入
下单是整个 App 里最有“技术含量”的环节。它要同时写order表和order_item表——主表记录订单总额和桌号,明细表记录每一道菜的数量和单价。如果只写主表不写明细,历史订单就是一笔糊涂账;只写明细不写主表,订单就没有总金额可展示。这个“两步写”的场景最适合用 SQLite 事务兜底。看代码:
public long createOrder(String tableNo, List<CartItem> items) { SQLiteDatabase db = getWritableDatabase(); long orderId = -1; 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("status", 0); orderId = db.insert("`order`", null, orderValues); // 第二步:逐条插入订单明细 for (CartItem item : items) { ContentValues itemValues = new ContentValues(); itemValues.put("order_id", orderId); itemValues.put("product_id", item.getProduct().getId()); itemValues.put("product_name", item.getProduct().getName()); itemValues.put("price", item.getPrice()); itemValues.put("quantity", item.getQuantity()); db.insert("order_item", null, itemValues); } db.setTransactionSuccessful(); } catch (Exception e) { e.printStackTrace(); } finally { db.endTransaction(); } return orderId; }核心逻辑有两个。第一,beginTransaction()到setTransactionSuccessful()之间包住两次写入,任何一条 insert 失败都会触发endTransaction()回滚,不会出现“订单主表建了但明细丢失”的状态。第二,总价在 DAO 层用遍历累加计算,而不是信任前端传过来的数值——这避免了“改一下购物车内存数据就能改总价”的低级漏洞。单机应用虽然没有服务器校验,但作为开发者自己要养成“后端/数据层永远重新计算”的习惯。
参数说明:tableNo是用户在确认订单页输入的桌号,直接存字符串;items是购物车快照列表,由CartManager.getAllItems()传入。这里传的是List<CartItem>而不是直接传CartManager实例,耦合度更低。如果后续要把订单同步到云端,这个 DAO 方法基本不需要改。
3.4 历史订单的联表查询与展示
历史订单页需要展示“每一笔订单的桌号、时间、总额、状态”,点击某一笔还能看到明细。这里的查询涉及两张表——查订单列表是单表查询,查明细需要order_item按order_id过滤。订单列表查询典型写法:
public List<Order> getOrders() { List<Order> orders = new ArrayList<>(); SQLiteDatabase db = getReadableDatabase(); Cursor cursor = db.rawQuery( "SELECT id, table_no, total_price, create_time, status FROM `order` ORDER BY id DESC", null ); while (cursor.moveToNext()) { Order order = new Order(); order.setId(cursor.getLong(0)); order.setTableNo(cursor.getString(1)); order.setTotalPrice(cursor.getDouble(2)); order.setCreateTime(cursor.getString(3)); order.setStatus(cursor.getInt(4)); orders.add(order); } cursor.close(); return orders; }按id DESC排序,新订单排前面,符合餐厅“最近订单优先看”的习惯。查询结果直接封装成实体 List,Activity 拿到的还是干净的数据集合。这里有一个必须养成的习惯:cursor.close()一定要执行,否则多次查询后游标资源耗尽,会报CursorWindow相关错误,属于典型的“跑着跑着突然崩溃且不看日志根本找不到原因”的坑——后面避坑章还会再提一次。
明细展示部分通常做成点击订单项弹出对话框或跳转详情页。如果跳详情页,记得把order_id通过Intent.putExtra()传过去,在详情页再执行SELECT * FROM order_item WHERE order_id = ?。还有一种做法是直接在订单列表查询时就用LEFT JOIN把明细拼在同一个结果集里,代码短但数据冗余,二次改造时选前者更清晰。
4. 运行与配置:从 Android Studio 导入到模拟器跑通
4.1 环境要求与项目导入
这个项目是标准的 Android 工程结构,用 Android Studio 直接导入即可。JDK 版本按你安装的 AS 默认版本走,项目本身的编译配置在build.gradle里,不出意外的话 is 的compileSdk和minSdk都写在app/build.gradle里。导入步骤极简:
打开 Android Studio -> File -> New -> Import Project -> 选择项目根目录 -> 等待 Gradle Sync 完成首次同步会下载 Gradle 和依赖,网络通畅的情况下几分钟内完成。如果同步报错,九成原因是 Gradle 版本和本地 AS 版本不匹配——这时候打开gradle-wrapper.properties看distributionUrl指向的版本号,改成你自己 AS 对应能用的版本。别怕改版本号,Gradle 向下兼容性做得不错,一般从高调低或者从低调高都能起得来。
4.2 数据库初始化与模拟数据:没有后端的项目怎么演示
这个项目是单机的,启动后能不能看到菜品,取决于数据库初始化脚本是否执行了。一般有两种做法:一是用SQLiteOpenHelper的onCreate()在数据库首次创建时执行INSERT语句;二是把db文件直接打包进 assets,启动时拷贝到私有目录。我拆的这个项目用的是第一种——代码里能看到一个insertMockData(db)方法,如果数据库文件不存在,onCreate()触发时会顺手插入一批模拟菜品。
@Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE_TABLE_PRODUCT); db.execSQL(CREATE_TABLE_ORDER); db.execSQL(CREATE_TABLE_ORDER_ITEM); insertMockData(db); } private void insertMockData(SQLiteDatabase db) { db.execSQL("INSERT INTO product(name, price, image_res, category) VALUES('宫保鸡丁', 32, " + R.drawable.gongbao + ", '热菜')"); db.execSQL("INSERT INTO product(name, price, image_res, category) VALUES('蒜蓉西兰花', 22, " + R.drawable.broccoli + ", '热菜')"); db.execSQL("INSERT INTO product(name, price, image_res, category) VALUES('酸辣土豆丝', 18, " + R.drawable.potato + ", '热菜')"); }注意一个反直觉的细节:R.drawable.xxx在onCreate()里能直接用,是因为它本质上是一个 int 资源 ID——数据库里存的是图片资源的 ID 而不是图片文件本身,读取时再根据 ID 从资源池里取出来。这个做法很取巧,但也意味着如果你新增菜品,必须在res/drawable下放一张对应图片,否则运行时资源找不到会直接崩。如果不想被这个约束卡住,可以把图片改成网络 URL 字段,但这就不符合“单机”定位了,自己权衡。
4.3 模拟器真机选择与连调要点
这个项目的运行目标可以是模拟器也可以是真机。模拟器推荐用 AS 自带的Pixel系列虚拟设备,API 级别不用刻意选很高的;真机调试则需要打开“开发者模式”和“USB 调试”。无论是哪个环境,跑通的标志都是:App 启动后主界面能看到模拟菜品,点击加购按钮后购物车角标或数量出现变化,到购物车页能改数量,确认下单后历史订单页出现新记录,杀进程重进还在。
真机调试时有一个常被忽略的坑:如果你的手机是某厂商的深度定制系统,可能会默认拦截应用读取“所有文件”的权限,但这个项目只用 SQLite 私有数据库,不涉及外部存储,所以反而很干净,基本插上线就能跑。模拟器方面,冷启动第一次开机会慢,耐心等;启动后安装 APK 的速度也算可接受。
4.4 编译报错的排查路径
如果编译不过,按照工程经验,按优先级排查三层:第一层看build.gradle的依赖引用是否有缺失,比如忘记加implementation 'com.android.support:appcompat-v7';第二层看资源文件,R类报红是资源引用错;第三层才看代码语法,比如Cursor没有close()。实际翻车最多的是第二层——菜单、布局、图片资源多了容易重名,报错信息却只给一个Resource linking failed,看起来极度劝退,实际上就是某个资源名重复或某个 drawable 引用不存在,去res目录扫一眼就能定位。
5. 避坑手册:单机点餐系统最常见的 4 个翻车现场
5.1 表名 order 是 SQL 关键字,直接 insert 报错
现象:db.insert("order", null, values)运行时抛SQLiteException: near "order": syntax error,明明 SQL 语句看着没问题,怎么都执行不过。
原因:order是 SQL 里的排序关键字,不能直接当表名用。哪怕你建表时用了CREATE TABLE order,在 insert 和 select 时同样会被语法解析器当成关键字处理。
解决:把表名改成order_info、t_order这类名字,或者统一用反引号包裹成`order`。这个项目里选的是后者——表名不改,查询时写成SELECT * FROM `order`,注意不是单引号,是数字 1 键左边那个反引号。
5.2 游标没关,跑着跑着突然崩
现象:App 功能看似一切正常,但反复进入订单页十几回后突然卡死,日志里报java.lang.IllegalStateException: attempt to re-open an already-closed object或CursorWindow相关异常。
原因:每执行一次rawQuery()都会在 native 层申请一块内存,长时间不释放会把可用内存耗尽。大多数情况下崩溃只发生在频繁操作之后,所以非常隐蔽。
解决:养成查询后立刻 close()的习惯。一条铁律:cursor用完之后手动cursor.close(),如果写在一个方法里,用try-finally保证无论如何都会关闭。别指望 GC 去管 native 层的游标内存,That 不是它改管的范围。
5.3 图片资源 ID 存数据库?小心 R 类变化
现象:首轮insertMockData里用R.drawable.gongbao存进去,改过一次包名或调整过 Res 目录结构后,历史订单列表图片全变灰或直接崩。
原因:R类中的资源 ID 是一个编译期生成的内存地址常量,不是稳定不变的。每次资源变动后重新编译,ID 值可能变,数据库里存的旧 ID 就失效了。
解决:如果只是作业演示,这个方案可以接受,但最好给product表加一列image_name存原始文件名字符串(如"gongbao"),读取时再通过getIdentifier()动态拿资源 ID。这样换资源文件位置也不用清洗数据库。
5.4 改完数据库结构后旧库不重建
现象:作业交了第一版之后,你加了分类字段category,改成CREATE TABLE product (... , category TEXT),重新运行之后 App 直接崩溃,提示no such column: category。
原因:SQLiteOpenHelper的onCreate()只在数据库文件首次创建时执行。设备上已经存在旧库文件,所以新增字段的建表语句根本没跑,代码却拿新字段去查。
解决:两种情况分别处理——开发期最简单,卸载 App 再重装,或者去设置 -> 应用 -> 存储 -> 清除数据把数据库文件删掉;如果是已经发布、用户手里有旧数据的场景,就必须写onUpgrade(),按版本号判断,执行ALTER TABLE product ADD COLUMN category TEXT。期末作业通常直接清数据重新生成就行,但知道onUpgrade的存在是加分项。
6. 二次开发:三个能写进简历的功能改造方向
6.1 改造方向一:购物车角标和数量加减交互
原始购物车只支持“点一下加一件”,二次开发可以加一个角标和数量编辑。角标放在主界面底部导航栏购物车图标上,用BadgeView或自己画一个TextView叠加。核心逻辑在CartManager和主界面之间加一个OnCartChangedListener回调——CartManager内部维护一个监听器列表,每次add()或remove()后通知界面刷新角标。这套“单例持有状态 + 监听器刷新”的模式,是从玩具项目走向真实项目的关键一步。
6.2 改造方向二:订单状态的懒人管理
order表里的status字段目前是0/1,代表已下单和已完成。建议花半小时把它扩展成三态或多态:0 待接单、1 制作中、2 已完成、3 已取消,在历史订单页用不同的文字颜色或标签背景区分。查询语句从无条件SELECT改成带WHERE status = ?条件,Activity 里加一个筛选的下拉框。这个小改动会让整个 App 从“演示级”变成“可用级”,面试时讲这个数据流转也比只讲 CRUD 有说服力。
6.3 改造方向三:下单即导出 CSV 账单
单机应用有一个天然优势——数据全在自己手里。加一个“导出账单”的功能:把某一天或某一段时间的订单明细拼成 CSV,用Intent(ACTION_CREATE_DOCUMENT)让用户选择保存位置,再把字符串写入输出流。这样不用引入任何第三方库,就能让 App 具备“给财务对账”的能力。核心代码压缩到十几行:
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("text/csv"); intent.putExtra(Intent.EXTRA_TITLE, "orders.csv"); startActivityForResult(intent, REQUEST_CODE_CREATE_FILE); // onActivityResult 里拿到 uri 后: // OutputStream os = getContentResolver().openOutputStream(uri); // os.write(csvData.getBytes(StandardCharsets.UTF_8));这套方案之所以值得做,是因为它把“单机 App”的边界扩展到办公场景——不需要服务器也能完成数据导出和备份,而且用的是系统原生能力,适配性和稳定性都有保障。我当年第一次在项目里引入ACTION_CREATE_DOCUMENT时翻过车,忘记加CATEGORY_OPENABLE导致文件管理器无响应。从那以后每次写文件相关的 Intent,我都会强制复查一遍setType和addCategory两个方法是否齐全。希望这些思路和踩坑记录能帮到你。
本文还有配套的精品资源,点击获取