最近整理电脑里的旧项目时翻出了一个之前做的Android Studio点菜系统成品项目,顺手又跑了一遍,发现这个小项目虽然代码量不大,但把Android开发里最常碰到的知识点都串起来了:界面布局、列表渲染、数据库存储、页面跳转、数据回传,甚至还有一些权限和文件处理的坑。如果正在学Android开发,或者需要一个能交作业、能演示、能二次改写的完整项目,这个点菜系统会是个不错的参考。
这个项目当时是帮一家小餐厅做的演示版本,业务链路很简单:顾客看菜单、点菜、加入购物车、结算下单,老板在后台维护菜品。老规矩,先把话说在前面:这是一个本地单机版的项目,数据全部存在手机自己的SQLite数据库里,不依赖服务器,所以不需要网络配置,导入Android Studio就能跑。源码到手之后,既能直接编译成APK安装到手机上演示,也可以当成课设、毕设的基础框架去改造。
1. 整体设计思路:点菜系统这个选题,为什么值得做
很多新手学完Android基础之后会卡在一个问题:单个知识点都会,但不知道一个完整App是怎么拼起来的。点菜系统恰恰是一个把知识点“串起来”的好选题,因为它有一条非常完整的业务链路:看菜单 -> 选菜品 -> 加购物车 -> 提交订单 -> 查看历史订单。这条链路覆盖了Android开发里最重要的几块内容,而且每一项都有很强的实用性。
1.1 技术选型:为什么用原生Java和SQLite
这个项目用的是经典的原生方案:Java语言 + XML布局 + SQLite数据库,没有引入第三方网络库和数据库框架。选这个方案的原因很直接:作为成品项目,它的首要目标是让任何人拿到源码都能看懂、能改、能跑。如果一上来就上Jetpack Compose、Room、Retrofit,很多刚入门的朋友光是在理解框架上就得花掉大量时间,反而看不到业务本身是怎么跑的。
界面方面用的是传统XML布局,搭配RecyclerView做菜品列表和购物车列表。XML布局的学习资料最多,遇到问题随手能搜到解法;SQLite则是Android内置的轻量级数据库,零配置,适合单机数据管理。这里多说一句,如果是想把这个项目作为进阶练习,完全可以往MVVM架构、Room数据库方向改造,后面第6章我会专门讲扩展思路。
1.2 功能模块划分与数据流
整个项目按功能可以拆成三大块:
- 顾客端核心流程:菜品分类浏览、菜品搜索、加入购物车、购物车数量调整、结算下单。
- 订单管理:订单生成、历史订单列表、订单详情(包含订单里的每道菜和数量)。
- 后台管理:菜品的新增、编辑、删除,菜品上下架。
数据流更清晰:菜品数据和管理员操作都落在SQLite数据库的几张表里,购物车状态在Activity之间传递用的是内存对象,订单提交时再把购物车数据写进数据库。这样设计的好处是,每个模块彼此独立,改起来不牵扯其他部分。早期版本我犯过一个错误,把购物车数据也存到数据库里,结果每次增删改查都要处理同步问题,反而更麻烦。后来干脆购物车只活在内存里,Activity回收了就清空,简单直接。
2. 核心功能拆解:菜单浏览、购物车、结算这三块怎么实现
2.1 菜品分类与列表展示:分类Tab + RecyclerView组合
菜品展示是点菜系统的门面。我的做法是顶部用TabLayout做分类切换,下面用RecyclerView展示对应分类的菜品卡片。每个菜品卡片包含菜品图片、名称、价格、销量,以及“加入购物车”按钮。
这里有个细节值得说:分类数据来自数据库里的category表,包括“热菜”“凉菜”“汤品”“主食”“饮品”等。菜品表里通过category_id外键关联分类。这样做的好处是,以后想加一个“新品推荐”分类,只需要在数据库里插入一条分类记录,App不用改代码。
-- 分类表 CREATE TABLE category ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL ); -- 菜品表 CREATE TABLE dish ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, name TEXT NOT NULL, price REAL NOT NULL, image TEXT, sales INTEGER DEFAULT 0, is_available INTEGER DEFAULT 1, FOREIGN KEY (category_id) REFERENCES category(id) );点击“加入购物车”按钮时,核心逻辑是判断购物车里有没有同款菜。如果有,把数量加1;如果没有,新增一个购物车条目。这个判断看着简单,但最容易出bug的地方在于对象的比较:两个菜品对象只要id相同,就是同一个菜,不能仅仅因为name相同或者价格相同就合并。
2.2 购物车设计与金额计算:内存态用着最顺手
购物车我用的是一个单例模式的CartManager,内部维护一个List<CartItem>。CartItem里面包含菜品信息、数量和选中状态。为什么用单例?因为购物车需要在菜品列表页、购物车页面、结算确认页等多个界面之间共享数据,单例可以避免每次跳转页面都把购物车数据通过Intent传来传去,省事也安全。
public class CartManager { private static CartManager instance; private List<CartItem> cartItems; private CartManager() { cartItems = new ArrayList<>(); } public static synchronized CartManager getInstance() { if (instance == null) { instance = new CartManager(); } return instance; } // 添加菜品、修改数量、计算总价等方法 }总价计算有一个效率问题,很多人喜欢每次点“+”或“-”的时候立刻重算总价并刷新UI。在菜品数量少时没问题,但购物车一旦超过10个条目,重复刷新会造成肉眼可见的卡顿。我改成只在两种情况下刷新:一是添加新菜品时,二是点击结算按钮时。购物车页面本身还是逐项刷新,但列表之外的合计金额只在关键节点算一次,这样流畅度提升明显。
2.3 结算流程与订单表设计:事务是底线
结算页做的事情不算复杂:展示购物车里的所有菜品、算总价、让顾客填写备注、确认下单。真正要注意的是订单数据的持久化。
订单需要存两张表:订单主表(order)和订单明细表(order_item)。订单主表记录订单编号、总金额、下单时间、备注;订单明细表记录每个订单里的菜品名称、单价、数量。一个订单对应多条明细,这是典型的一对多关系。
这里必须强调一个原则:写入订单主表和明细表要放在同一个数据库事务里。如果先写了主表、再写明细表,中间一旦出错,会出现“有订单但订单里没菜”的脏数据。用db.beginTransaction()包起来,要么全部成功,要么全部回滚,这才是可靠的做法。
下单成功之后,清空购物车,跳转到订单详情页或订单列表页。模拟支付我做得比较保守,直接弹了一个“支付成功”的对话框,没有接入真实的支付SDK。如果要把这个项目用于商用,支付环节需要单独对接支付宝或微信的SDK,那又是另一个故事了。
3. 环境准备与项目导入:从拿到源码到跑出APK的完整链路
这个部分写给首次接触Android Studio的朋友。成品项目拿到手之后,第一件事不是看代码,而是先把环境跑通。经常有人问我“明明代码没问题,为什么一运行就报错”,十有八九是环境版本不匹配造成的。
3.1 Android Studio版本与SDK配置建议
我的开发环境用的是Android Studio 2023.1.1,JDK 17,Gradle 8.0以上。如果你的Android Studio版本比较新,导入项目时可能会提示升级Gradle版本,这里建议优先用项目自带的Gradle版本跑通,不要直接点升级,因为升级Gradle有概率引发兼容性连锁反应。
项目打开后,第一步是做三件事:确认SDK路径、确认SDK版本、等待Gradle同步完成。在build.gradle里可以看到我配置的compileSdk 34、minSdk 21、targetSdk 34。
android { compileSdk 34 defaultConfig { applicationId "com.example.restaurant" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" } }minSdk 21意味着Android 5.0以上的手机都可以运行,覆盖了绝大多数设备。新手如果是为了在模拟器上测试,这个配置没有任何问题。同步Gradle时需要联网下载依赖,初次同步通常很慢,我用的是阿里云镜像仓库,把repositories节点替换成国内镜像能节省大量时间。
3.2 gradle下载慢的解决方式与中文界面设置
如果你发现Gradle一直卡在下载依赖,多半是访问Maven Central不顺畅。解决方法很简单:打开项目根目录的build.gradle,把仓库地址改成阿里云公共仓库。
buildscript { repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } } }顺便提一句Android Studio的中文问题。很多新手问“Android Studio怎么设置中文”,实际上是担心英文界面影响操作。当前版本的Android Studio可以在Settings -> Plugins里搜索“Chinese Language Pack”安装中文语言包,重启之后界面就会变成中文。不过我个人建议代码和报错信息还是习惯英文为好,因为网上的技术资料和报错搜索基本都是英文,过度依赖中文界面反而不利于查问题。
3.3 编译APK和模拟器、真机调试
项目跑通后,编译APK很简单:菜单栏点Build -> Build Bundle(s) / APK(s) -> Build APK(s),编译完成后在app/build/outputs/apk/debug/目录下能找到debug版的APK,直接传到安卓手机上就能安装。
模拟器是另一个容易踩坑的地方。Windows电脑上启动模拟器如果报错“Intel HAXM安装失败”或者“WHPX启动失败”,先到Settings -> SDK Manager -> SDK Tools里确认是否安装了“Android Emulator Hypervisor Driver”。如果还是不行,最稳的办法是开启Windows的Hyper-V功能,并在Android Studio的虚拟设备配置里改一下硬件加速选项。说实话,我自己测试这种小项目时更推荐用真机,插上线开USB调试直接跑,没有模拟器的那些性能损耗和冷启动时间。
4. 数据存储与关键代码解析:数据库、适配器、页面跳转
4.1 SQLite数据库的封装思路
这个小项目里我封装了一个DBHelper类,继承自SQLiteOpenHelper。数据库名为restaurant.db,版本号从当前是1,后续如果改动表结构,记得升级版本号并在onUpgrade里做数据迁移逻辑。最常见的坑是:改了表结构但没升级版本,老用户安装新APK时直接闪退。
public class DBHelper extends SQLiteOpenHelper { public static final String DB_NAME = "restaurant.db"; public 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_CATEGORY_TABLE); db.execSQL(CREATE_DISH_TABLE); db.execSQL(CREATE_ORDER_TABLE); db.execSQL(CREATE_ORDER_ITEM_TABLE); initData(db); // 预置分类和几道菜品,方便首次打开就能演示 } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 这里要写迁移逻辑,不能直接DROP TABLE,否则用户数据全丢 } }initData是我专门放的一个方法,在创建数据库的同时插入默认的分类和菜品数据。这样项目第一次启动后,界面上就有内容可以看,不用手动去后台添加数据。这个思路很适合做演示项目,评委或使用者拿到手就能看到效果。
4.2 RecyclerView适配器与列表刷新
列表适配器是列表页的核心。我写了一个DishAdapter继承RecyclerView.Adapter,内部用ViewHolder缓存控件引用,在onBindViewHolder里把菜品数据绑定到界面上。
有几个细节值得记录一下:
- 图片加载:菜品图片用的是本地资源ID和文件路径结合的方式,没有接入第三方图片库,减少依赖。
- 点击事件:适配器里定义了一个
OnItemClickListener接口,把点击事件抛给Activity处理。不要在onBindViewHolder里写具体的跳转逻辑,不然适配器会越来越臃肿。 - 数据更新:修改购物车数量后,我调用的是
notifyItemChanged(position)而不是notifyDataSetChanged()。前者只会刷新当前这一项,性能更好,动画也更自然。
4.3 Activity跳转与数据回传
菜品列表页进入结算页时,需要携带订单信息,我用Intent的putExtra传值。从结算页回到菜品列表页时,需要知道下单是否成功,用startActivityForResult(在新版Android Studio中推荐使用ActivityResultLauncher,但我在这个项目里保留了传统写法,便于理解)。
Intent intent = new Intent(MainActivity.this, SettleActivity.class); startActivityForResult(intent, REQUEST_CODE_SETTLE);然后在onActivityResult里根据结果码判断下单结果,成功就清空购物车并提示用户,失败则保留购物车,方便用户重新结算。ActivityResultLauncher的写法虽然更规范,但底层逻辑和Intent一致,先理解传统写法,再迁移过去是很自然的路径。
5. 常见问题与避坑实录:编译、权限、界面交互的手动排雷
5.1 onBackPressed无效问题
新版Android Studio创建的项目里,传统的onBackPressed重写方法已经废弃了,如果你按老代码写上@Override public void onBackPressed(),会发现根本没反应。这是因为从Android 13开始,系统强制推荐用OnBackPressedCallback。
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { // 在这里处理返回键逻辑 } }); }我在购物车页面用这个回调做了“按返回键时弹出确认对话框”,防止顾客误触返回丢弃购物车内容。这个改动很小,但体验细节提升很明显。
5.2 运行时权限与读取文件的问题
做点菜系统时如果涉及到从SD卡读取菜品图片,很容易碰到权限问题。Android 6.0以上需要动态申请权限,Android 11以后还有分区存储限制,直接访问SD卡根目录往往会失败。有朋友问过“StorageManager读取文件一直报错”以及“无法对SD卡根目录授权”,这基本都是分区存储策略导致的,不是代码写错了。
我的处理方式是:菜品图片优先放在App私有目录(getExternalFilesDir或getFilesDir),完全不需要额外的存储权限,目录由系统托管,卸载App时自动清除。如果非要读取公共存储目录里的图片,推荐用系统文件选择器Intent去获取URI,然后通过ContentResolver读取,而不是直接操作文件路径。这里给一个通用模板:
public void pickImage() { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("image/*"); startActivityForResult(intent, REQUEST_PICK_IMAGE); }拿到URI之后,再用ContentResolver配合openInputStream读取输入流解析Bitmap,全程不需要申请存储权限。如果你的SDK版本是29以上,这条路最省心。
5.3 编译报错速查表
| 报错现场 | 常见原因 | 解决方向 |
|---|---|---|
| Gradle同步一直卡住 | 依赖下载慢或网络不通 | 换阿里云镜像源 |
| 找不到SDK目录 | 未配置Local SDK路径 | Settings里设置Android SDK Location |
| duplicate value for resource | 资源名冲突 | 检查res/values下是否存在同名id或string |
| Unsupported class file major version | JDK版本过新或过旧 | 统一到JDK 17 |
| Manifest merger failed | 依赖库与主工程AndroidManifest冲突 | 根据提示添加tools:replace属性 |
| 模拟器无法启动 | WHPX/HAXM未启用 | 开启Windows的Hyper-V功能或更换模拟器镜像 |
5.4 一个隐藏的内存泄漏点
有段时间我发现这个项目长时间操作后会出现卡顿,排查后发现是购物车和菜品列表之间频繁创建对象,并且没有消除对Activity的引用。其实在Adapter里写holder.itemView.setOnClickListener(v -> startActivity(...))的时候,如果外部类是非静态内部类的匿名对象,它会隐式持有外部Activity的引用。退出界面后,如果后台线程或单例还持有Adapter,Activity就回收不了,这就是内存泄漏。
我后来把购物车的Click事件统一通过回调接口往外抛,不让Adapter直接感知Activity的存在,这个问题的可能性就大大降低了。单例模式里的CartManager同理,它只有纯数据和计算方法,完全不引用Context,就不会泄漏Activity。如果你要在单例里用Toast或数据库,建议用getApplicationContext()而不是Activity。
6. 二次开发与扩展建议:单机版怎么进化成联网版、MVVM版
这个成品项目目前的定位是本地单机版,适合学习、演示和交作业。如果哪天你想拿它做真正的商用系统,有几个明确的方向可以走。
6.1 架构升级:从传统写法到MVVM
整个项目目前是标准的Activity + Adapter + DAO结构,如果开始感觉代码乱了、Activity越来越胖,推荐向MVVM迁移。用ViewModel承载菜品列表和购物车的状态,用LiveData通知UI更新,用Repository统一管理数据来源。迁移的每个步骤都可以独立进行,不需要推翻重来。先给数据库操作套上Repository层,再让Activity只保留界面更新逻辑,逐渐就能改造成MVVM。
6.2 数据联网:引入网络层和后端
本地版的数据全部存在SQLite里,意味着顾客换手机数据就没了,老板也没法远程维护菜单。联网化改造的第一步是引入Retrofit和OkHttp,把“新增菜品”“获取菜单列表”“提交订单”等操作从DBHelper换成网络请求。后端可以选Spring Boot之类的传统服务端,也可以选免费的BaaS平台,结合Firebase或Supabase快速实现账号体系和云端数据库同步,工作量会小很多。
这里有个经验:业务逻辑层先定义一个接口,比如MenuDataSource,然后把SQLiteMenuDataSource和RemoteMenuDataSource都实现它,界面层只依赖接口。这样切换数据源时,基本不用动界面代码。
6.3 功能扩展:扫码点餐、桌台管理、多端同步
如果往商用方向走,可以加的功能实在太多,比如每个餐桌一个二维码,顾客扫码进入点餐页面,后台分桌管理订单;比如菜品销量统计、营业报表;比如老板在Web端管理菜单,手机App实时拉取更新。每个方向单拎出来都是一个不错的深化练习。我个人建议优先做桌台管理,因为它是把单机版变成多用户版本的最关键一步:订单表里增加一个table_id字段,用于区分不同桌台的订单,后续所有运营功能都可以围绕桌台展开。
结尾再分享一个我踩过的坑
这个项目做完之后,我最有感触的不是某个具体功能,而是“内存态与持久态边界”的把握。早期版本我把整个点菜过程都往数据库里塞,每点一个菜就写一条记录,结果数据库越来越大,逻辑也越来越绕。后来才想明白,像购物车这种临时数据,就该活在内存里;像订单这种需要追溯的数据,才需要落库。做任何项目都先把这个边界画清楚,代码会清爽很多。
另外,如果你遇到过“拿到成品项目却跑不起来”的尴尬,可以先冷静检查三件事:Gradle版本和JDK是否匹配、本地SDK路径是否配置、依赖仓库能否访问。这三件事占我排查项目导入问题的八成以上。点菜系统这个小项目,麻雀虽小但五脏俱全,用来练手和学习Android开发的完整流程,是真的够用。