简介:面向移动开发课程学生的安卓期末大作业——仿外卖App,基于Android Studio开发,适合需要完成期末项目或入门安卓实战的初学者。压缩包内含完整项目源码和可直接安装的APK文件,覆盖商品列表、购物车、订单管理、用户登录等外卖应用核心模块,从首页推荐到下单结算全流程均有实现,帮助读者快速掌握安卓项目从界面搭建到功能实现的整体思路。资源共1065个文件,以XML布局文件、JSON数据文件、PNG图片资源和Java/Kotlin源码为主,另有Gradle构建配置、GIF演示录屏、APK安装包和Android导入Word文档说明,串联起开发、构建、运行到部署的完整链条,包体约191.55MB,目录结构清晰便于按模块检索。目前已有6915人学习下载,适合安卓移动开发入门者学习项目架构设计、借鉴界面布局与交互逻辑,或作为期末大作业的完整参考范本。
1. 仿外卖安卓期末作业:不是“随便一个app”,是能讲清购物车状态的小项目
临近期末最后两周,如果老师布置的是“安卓期末作业-仿外卖app-简单app”,恭喜你选了一个好做又好答辩的题目。外卖App不像即时通信、短视频那样需要大量服务端能力,它把安卓开发里最典型的知识点都装在一个小壳里:列表、页面切换、状态更新、本地存储。标题里的“简单”是关键——你不必做商家后台、支付网关、骑手定位,只需要把“逛店加菜、购物车算钱、下单”这条路走通,就是一个能演示的完整项目。这篇内容按我能交差的标准,把这个仿外卖App拆成数据模型、界面结构、购物车逻辑和答辩技巧,给你一条不加班也能写完的路。适合刚学完安卓基础课、第一次做完整App的同学。
2. 先把需求缩到能做完:四个必备模块与数据表设计
2.1 四个模块:登录注册、商家列表、点餐购物车、订单列表
很多同学拿到这个题目第一反应是“我要做一个美团”。等打开Android Studio新建项目后,看着空白的Activity,又开始纠结要不要做优惠券、评价、会员中心。这里我建议你先在纸上画一条用户操作路径:打开App → 看到商家列表 → 点进某个商家 → 把菜加入购物车 → 提交订单 → 在订单列表里看到这笔单。这条路径就是你的主流程,其他功能全部按“能不能让这个主流程更完整”来决定做不做。
我一般会把仿外卖App拆成四个模块:登录注册、商家列表、点餐购物车、订单列表。登录注册用SharedPreferences存一个“已登录”标记,不用做密码找回;商家列表用RecyclerView加载本地SQLite里的数据,点击卡片进入菜单页;菜单页展示该商家的菜品,每个菜品旁边有“加号”和“减号”,实时更新购物车;订单列表展示已提交的订单,至少要有状态字段,能区分“待取餐”和“已完成”。这四个模块全部做完,你的App已经比相当一部分期末作业完整了。
为什么要缩到这四个模块?因为期末答辩老师更关注“你能不能讲清楚自己写的代码”,而不是“功能多不多”。购物车状态变化、页面传参、数据库读写,这些才是安卓课的核心考点。优惠券、定位、支付这些如果起了个头又做不完,反而会在答辩时给自己挖一堆坑。先做窄,再做通,这个顺序不会错。
2.2 数据模型:五张表怎么建,为什么价格用REAL不用FLOAT
数据模型是仿外卖App的地基。我建议你不要把所有数据都写死在界面里,而是用SQLite建五张表:用户表、商家表、菜品表、订单表、订单明细表。商家和菜品是一对多关系,订单和订单明细也是一对多关系。这样你在写RecyclerView的时候,根本不用在代码里硬编码一堆假数据,而是从数据库里查出来,顺便还能在答辩时讲一句“数据层和UI层是分离的”。
建表SQL我放在下面,你可以直接抄到自己的DBHelper里。
CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, phone TEXT UNIQUE NOT NULL, password TEXT NOT NULL ); CREATE TABLE merchant ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, notice TEXT DEFAULT '', min_price REAL DEFAULT 0.0, delivery_fee REAL DEFAULT 0.0 ); CREATE TABLE dish ( id INTEGER PRIMARY KEY AUTOINCREMENT, merchant_id INTEGER NOT NULL, name TEXT NOT NULL, price REAL NOT NULL, image_res INTEGER DEFAULT 0, FOREIGN KEY (merchant_id) REFERENCES merchant(id) ); CREATE TABLE "order" ( id INTEGER PRIMARY KEY AUTOINCREMENT, total_price REAL NOT NULL, create_time TEXT NOT NULL, status INTEGER DEFAULT 0 ); CREATE TABLE order_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, dish_id INTEGER NOT NULL, count INTEGER DEFAULT 1, price REAL NOT NULL );这里有几个细节必须说明。第一,订单表名我用的是"order",因为ORDER是SQLite的保留字,不加双引号会直接报“near ORDER: syntax error”。第二,价格字段用REAL而不是FLOAT,因为FLOAT在Java里对应的float类型精度不够,算总价的时候容易出现0.1+0.2不等于0.3的情况,期末答辩最怕被问这种问题。第三,菜品表里的image_res存的是drawable资源的整型ID,这样图片不会因为网络问题加载失败。
建完表之后,你还需要在onCreate里插几条商家和菜品的演示数据,不然列表是空的。我一般会写上“沙县小吃”和“黄焖鸡米饭”两个商家,每个商家配4到6个菜品,名字用“招牌黄焖鸡”“香辣鸡腿饭”这类一看就懂的词。插入数据用db.insert()会比拼接SQL字符串更安全,也能顺口说出“我用了ContentValues来封装字段”。
2.3 没有后端也能演示:为什么我用SQLite而不是网络请求
期末作业最大的坑不是代码难,而是“做完之后没法演示”。如果项目依赖后端,老师验收时你打开App,服务器却在你的笔记本上没启动,整个演示就垮了。所以我建议仿外卖App的数据全部走本地SQLite,网络请求这层以后再加。这不是偷懒,而是把演示风险降到最低。
常见做法是封装一个DBHelper类,提供getMerchantList()、getDishListByMerchantId()、createOrder()这些方法。Activity在onCreate里直接调用,拿到List就去刷新RecyclerView。没有Retrofit、没有OkHttp、没有JSON解析,你已经把期末作业的核心功能跑通了。老师如果问“为什么不用网络”,你就说:“考虑到作业重点是安卓界面与本地存储,我先把业务闭环做好,后续可以接Retrofit+RESTful接口。”这句回答本身就是加分项。
另外,登录状态我建议用SharedPreferences存,不要建session表。存一个boolean,值为true就跳转到主界面,值为false就留在登录页。简单、稳定、好讲。购物车数据如果不想临时放在内存里,也可以用SharedPreferences存成JSON字符串,但更稳妥的是定义一个CartManager单例,在App运行期间维护一个Map,重启后购物车清空,这完全符合外卖App的真实表现。
3. 在Android Studio里跑通一个仿外卖App:Java版最小可运行结构
3.1 工程结构:单Activity + Fragment + ViewPager2
很多初学者喜欢把每个页面都做成一个Activity,结果点餐页面和商家列表之间传值传得很痛苦。我不会这么做。仿外卖App这种页面层级,最适合用单Activity多Fragment来做:MainActivity只负责搭壳,里面放一个BottomNavigationView,下面是一个ViewPager2,三个Fragment分别是“首页”“订单”“我的”。点击商家卡片后,通过FragmentTransaction切换到菜单页,返回时用popBackStack。
这样设计的好处是:购物车数据可以统一放在MainActivity或一个全局单例里,Fragment之间不用通过Intent传大对象。你想想,如果购物车在A Activity,菜单页在B Activity,你把购物车里的菜传到B,然后又要把加好的菜传回A,这个Intent要写多少字段?用Fragment,我只需要写CartManager.getInstance().addDish(dish)一行代码。
MainActivity的核心代码长这样:
public class MainActivity extends AppCompatActivity { private ViewPager2 viewPager; private BottomNavigationView bottomNav; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); viewPager = findViewById(R.id.viewPager); bottomNav = findViewById(R.id.bottomNav); MainPagerAdapter adapter = new MainPagerAdapter(this); viewPager.setAdapter(adapter); bottomNav.setOnItemSelectedListener(item -> { int id = item.getItemId(); if (id == R.id.nav_home) { viewPager.setCurrentItem(0); } else if (id == R.id.nav_order) { viewPager.setCurrentItem(1); } else { viewPager.setCurrentItem(2); } return true; }); } }MainPagerAdapter的写法也有讲究:继承FragmentStateAdapter(androidx.viewpager2.adapter),而不是传统FragmentPagerAdapter。ViewPager2强制使用这个新Adapter,如果你按老教程写FragmentPagerAdapter,要么deprecated警告,要么直接编译失败。注意FragmentStateAdapter的构造函数传的是FragmentActivity,所以你用new MainPagerAdapter(this)没问题。
如果你不想用ViewPager2,用FragmentTransaction手动切换也完全可以。但我的经验是,主流教材都开始讲ViewPager2了,你把它用起来,至少证明你关注新技术。这里有个容易踩的坑:ViewPager2默认会预加载左右Fragment,导致你在“首页”里写getActivity().findViewById时会因为视图还没created而空指针。我一般不用findViewById,而是给每个Fragment一个onViewCreated里的根布局引用,需要传值就放在onResume里处理。
3.2 商家列表与菜单列表的RecyclerView实现要点
商家列表是一个横向?不,我建议纵向的线性列表,每行显示商家名称、公告、起送价和配送费。用RecyclerView加LinearLayoutManager,是最标准的写法。里面最关键的是Adapter的ViewHolder写法,不要为了省事在onBindViewHolder里反复findViewById,那样滑动起来会掉帧。
下面是我常用的商家列表Adapter骨架:
public class MerchantAdapter extends RecyclerView.Adapter<MerchantAdapter.VH> { private List<Merchant> dataList; private OnItemClickListener listener; public void setData(List<Merchant> list) { this.dataList = list; notifyDataSetChanged(); } @Override public VH onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_merchant, parent, false); return new VH(view); } @Override public void onBindViewHolder(VH holder, int position) { Merchant merchant = dataList.get(position); holder.name.setText(merchant.getName()); holder.notice.setText(merchant.getNotice()); holder.itemView.setOnClickListener(v -> { if (listener != null) listener.onClick(merchant); }); } public interface OnItemClickListener { void onClick(Merchant merchant); } static class VH extends RecyclerView.ViewHolder { TextView name, notice; VH(View v) { super(v); name = v.findViewById(R.id.tvMerchantName); notice = v.findViewById(R.id.tvMerchantNotice); } } }这里最容易被忽略的是setData方法里的这一行:notifyDataSetChanged()。很多同学更新完数据忘记叫刷新,列表界面死活不变。更细一点讲,你如果想追求优雅,可以用DiffUtil,但对期末作业来说notifyDataSetChanged完全够用,面试或者答辩时再提一句“我知道DiffUtil可以做差异化更新”就足够了。
菜单列表跟商家列表几乎一样,只是每条菜品要显示菜名、价格和加号减号。你需要给Adapter传两个东西:当前商家ID和购物车管理器。加号按钮的点击回调不要写在Activity里做循环查找,而是直接在Adapter里调用一个回调接口,把Dish对象传回去。这样逻辑集中在MainActivity或容器Fragment,菜单页如果想要“购物车已加了多少件”,也可以通过回调取到最新的Map。
3.3 购物车逻辑:用Map记“菜数量”,用接口通知刷新
购物车是仿外卖App最容易写成一团浆糊的地方。常见错误是:用一个ArrayList每次add,菜多了就出现同一道菜好几条重复记录。正确做法是用Map,key是dishId,value是count。加菜时如果key不存在,就put进去置为1;减菜时把count减到0就直接remove。
我一般会在项目里写一个CartManager单例:
public class CartManager { private static CartManager instance; private Map<Integer, Integer> dishMap = new HashMap<>(); private Map<Integer, Dish> dishInfoMap = new HashMap<>(); public static CartManager getInstance() { if (instance == null) instance = new CartManager(); return instance; } public void addDish(Dish dish) { int count = dishMap.getOrDefault(dish.getId(), 0); dishMap.put(dish.getId(), count + 1); dishInfoMap.put(dish.getId(), dish); } public void subDish(int dishId) { Integer count = dishMap.get(dishId); if (count == null) return; if (count <= 1) { dishMap.remove(dishId); } else { dishMap.put(dishId, count - 1); } } public Map<Integer, Integer> getDishMap() { return dishMap; } public double getTotalPrice() { double total = 0.0; for (Map.Entry<Integer, Integer> entry : dishMap.entrySet()) { Dish dish = dishInfoMap.get(entry.getKey()); if (dish != null) total += dish.getPrice() * entry.getValue(); } return Math.round(total * 100) / 100.0; } }这个CartManager用什么数据结构?dishMap存数量,dishInfoMap存菜品对象,避免每次算总价都要重新查数据库。getTotalPrice里做了一个Math.round处理,防止出现6.6000000000000005这种浮点结果。算总价时如果用float,这里很容易出问题,所以评分题里你用double加上四舍五入,已经超过一半同学。
购物车界面怎么更新?我的做法是在菜单ContainerFragment里定义一个内部回调接口CartChangeListener,当Adapter里点击加号或减号时,先调CartManager更新数据,再通知Activity刷新底部购物车栏的总价和角标。这里有一个关键点:不要在每个Fragment里直接getActivity().findViewById去改另一个Fragment的控件,而是通过接口把数据变化传出去,让目标Fragment自己更新自己的UI。这样做的好处是,旋转屏幕后Activity重建时,接口重新绑定,UI能跟着新数据走。
4. 把作业做成“能答辩”:输入校验、订单状态机与界面细节
4.1 订单状态流转:0待取餐、1进行中、2已完成
很多仿外卖App的订单列表就是个死列表,下单后永远显示“已支付”。老师一看就知道你没有业务逻辑。给订单加一个状态字段,让它在不同的点击操作下发生变化,这个设计本身就是答辩亮点。我定义的状态很简单:0表示待取餐,1表示进行中,2表示已完成。
状态流转的规则是:用户提交订单后status=0;在订单列表点击“去取餐”按钮,更新为status=1;再过几秒或者手动点击“确认完成”,更新为status=2。更新代码放在OrderRepository里,比如:
public void updateOrderStatus(int orderId, int newStatus) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("status", newStatus); db.update("\"order\"", values, "id=?", new String[]{String.valueOf(orderId)}); }这里注意,update的第三个参数是where子句,不能用拼接字符串写法"id=" + orderId,而是用占位符?加参数数组。原因有两个:一是防止SQL注入,二是让SQLite内部的预编译语句复用。虽然期末作业没人攻击你,但养成这个习惯,代码审查时看着专业。
订单列表的每一项,除了显示总价和时间,还要根据状态显示不同的按钮和颜色。你可以在RecyclerView的onBindViewHolder里做switch判断:status为0时显示“去取餐”按钮,status为1时显示“完成订单”按钮,status为2时不显示按钮,只显示“已完成”。这需要你在OrderAdapter里再写一个事件回调接口。不用怕麻烦,这种switch判断代码正是老师想看到的“业务逻辑”。
4.2 输入校验和空态设计:不要让老师点几下就崩
期末验收时,老师会像测试员一样乱点。他会不填手机号直接点登录,会在购物车为空时狂点结算,会在订单列表下拉刷新。你不需要做一个无懈可击的商业App,但至少要在这些常见操作里不闪退。我建议你在三个地方做输入校验。
第一,登录注册页。手机号用正则校验^1[3-9]\d{9}$,密码长度至少6位。校验不通过时,用setError()在EditText下方显示红色提示,而不是弹Toast。倒不是说Toast不能用,而是setError这个细节能体现你对Material控件熟悉。
第二,购物车为空时,结算按钮要禁用。最好实时更新购物车数量,当数量为0时,把按钮设为setEnabled(false)并换成灰色背景。第三,列表为空时显示空态布局,不要只显示一个空白RecyclerView。空态组件就是一个LinearLayout包着ImageView和TextView,你可以把它写成一个include布局,在Adapter无数据时设置可见性。
空态的文案要具体,比如“还没有订单,去下一单吧”。这个细节对视觉评估分很有帮助,因为大部分同学的空页面就是一片白。
4.3 影响评分的两个参数:minSdk与屏幕适配
打开项目的build.gradle,里面有个defaultConfig块,很多同学放着不管。我建议你把minSdk设成21,targetSdk设成老师要求的版本,compileSdk用你本地的SDK版本。minSdk设21能让你的App覆盖绝大多数手机,同时又不至于因为适配Android 10+的权限分区而增加额外工作。如果你把minSdk设成30,老师自己的旧手机可能装不上;设成16,你又需要适配非常古早的写法,不划算。
屏幕适配不要写死dp,多用LinearLayout加weight。比如底部购物车栏,左边显示总价,右边一个结算按钮。如果你把左边TextView的宽度写成200dp,换成大屏手机会偏,换小屏又会挤压。用layout_width="0dp"、layout_weight="1"让左边的总价占剩余空间,按钮用wrap_content,这样任何屏幕看起来都正常。
还有一个小坑:很多手机字体设置了“超大字体”后,你的TextView可能显示不全。在布局里给关键TextView加android:maxLines="1"和android:ellipsize="end",不要让它换行。字体大小用sp,而且不要小于12sp,否则在部分手机上会因为字体缩放而读不清。
5. 仿外卖App常见翻车点:五个必看的避坑记录
5.1 现象:购物车加了菜,列表角标不动
这个Bug我见过不止一次。菜单页点加号,底部购物车小圆点没有从0变成1,但内存里的Map明明已经更新了。原因通常是你在Activity里刷新了UI,但Fragment里的购物车栏还保留着旧引用,或者你调用的notifyDataSetChanged出现在子线程。解决思路是:把购物车栏做成一个独立的View,放在MainActivity的布局里,Fragment通过接口回调通知MainActivity更新。不要试图在菜单页直接操作底部栏,那样你需要在Fragment里getActivity()强转,一旦Fragment重建,回调就没有了。
5.2 现象:旋转屏幕后购物车直接清空
Android默认情况下旋转屏幕会销毁并重建Activity。如果你的购物车数据只放在Activity的成员变量里,转个屏就全没了。老师演示时最喜欢转屏幕,因为这是最容易暴露问题的地方。解决办法有两个:一是把购物车放进CartManager单例,单例不随Activity销毁而消失;二是在MainActivity的onSaveInstanceState里保存购物车Map的key列表,然后恢复时重新查数据库。推荐用第一个,代码改动最小。
5.3 现象:用“order”做表名,一跑SQLite就报错
这个问题我在数据模型那一节已经埋过伏笔。如果你直接写CREATE TABLE order,SQLite会认为你在写一个命令,然后告诉你“near ORDER: syntax error”。原因是ORDER是SQL关键字。解决方式就是用双引号包住表名:CREATE TABLE "order",写Java代码时update语句同样要写"order"。这个坑很小,但一旦踩中,查半天查不出来,因为报错信息里没有提示关键字冲突。提前换个表名,比如t_order,也能绕开,只是双引号更标准。
5.4 现象:模拟器上照片不显示,真机上却很正常
如果你的菜品用网络图片,Android Studio自带的模拟器经常因为网络代理或者图片域名不安全而加载失败,但真机却没事。为了期末作业能顺利演示,建议菜品图片全部用本地drawable资源。你可以在drawable里放几个不同颜色的圆角矩形,或者用矢量图形,然后用ImageView设置背景色。演示场景下,图片不是重点,稳定显示才是重点。如果你非要显示真实图片,就用Glide加载,但要在主页里加上占位图,避免加载失败时一片空白。
5.5 现象:加了拍照/定位权限后,安装时直接闪退
有的同学想让App显得高级,给“我的”页面加了“拍照上传头像”,或者在订单页加了“查看配送员位置”。结果在AndroidManifest里没加CAMERA或ACCESS_FINE_LOCATION权限,运行时一调用摄像头就SecurityException闪退。解决方式是在Manifest声明权限,并且在Android 6.0以上动态申请,不能在onCreate里直接调camera.open()。我的建议更简单:既然是“简单App”,就不要碰拍照定位功能,等期末做完再自己加。每个权限都是一道维护负担,尤其在模拟器上,很多摄像头和定位场景不可用,演示风险极高。
6. 答辩前我用这套方法自检:三个能救场的收尾技巧
6.1 做一个隐藏“重置数据”入口,演示前清掉脏数据
期末演示最怕上一轮测试留下的订单、被改得乱七八糟的购物车。我会在“我的”页面加一个隐藏入口,连点版本号5次弹出确认框,点击后清空数据库并重新插入演示数据。执行语句就是db.delete("order", null, null),然后重新跑一遍种子数据的插入逻辑。这样老师上台前,你只要点几下就能回到全新状态。
6.2 按“用户操作路径”讲代码,别按文件顺序讲
答辩时很多同学喜欢从MainActivity第一个文件开始讲,讲到Adapter就卡住,时间不够。我习惯反过来,从老师手指下的操作讲:你点了一个“加号”,这个事件先到Adapter,然后到CartManager,CartManager更新Map,最后通过接口刷新UI。这样讲代码,老师听的是数据流,而不是文件清单。哪怕某个类写得不太好,只要数据流清晰,分数就不会低。
6.3 记住五句“为什么这样选”的回答模板
我在答辩前会给自己准备几个“为什么”的回答:为什么用SQLite?因为演示不需要网络,本地存储稳定且能讲清SQL。为什么用ViewPager2?因为它是新版支持库,能避免FragmentPagerAdapter过时问题。为什么用单例管理购物车?因为Activity重建后数据不丢。为什么价格用REAL?因为浮点精度比FLOAT好。为什么状态用int不用String?因为排序和条件查询更方便。这五个问题答上来,答辩基本稳了。
最后说句实在话:我读书时第一次做这种App,也栽在“需求做太满”上,最后只能熬夜删功能。你现在把这个仿外卖App控制在可演示范围内,把购物车和数据存储这两个硬骨头啃透,比做一个半吊子大项目要划算得多。希望帮到你。
本文还有配套的精品资源,点击获取