
简介面向移动开发课程学生的安卓期末大作业——仿外卖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.10.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解析你已经把期末作业的核心功能跑通了。老师如果问“为什么不用网络”你就说“考虑到作业重点是安卓界面与本地存储我先把业务闭环做好后续可以接RetrofitRESTful接口。”这句回答本身就是加分项。另外登录状态我建议用SharedPreferences存不要建session表。存一个boolean值为true就跳转到主界面值为false就留在登录页。简单、稳定、好讲。购物车数据如果不想临时放在内存里也可以用SharedPreferences存成JSON字符串但更稳妥的是定义一个CartManager单例在App运行期间维护一个Map重启后购物车清空这完全符合外卖App的真实表现。3. 在Android Studio里跑通一个仿外卖AppJava版最小可运行结构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的写法也有讲究继承FragmentStateAdapterandroidx.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.AdapterMerchantAdapter.VH { private ListMerchant dataList; private OnItemClickListener listener; public void setData(ListMerchant 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菜多了就出现同一道菜好几条重复记录。正确做法是用Mapkey是dishIdvalue是count。加菜时如果key不存在就put进去置为1减菜时把count减到0就直接remove。我一般会在项目里写一个CartManager单例public class CartManager { private static CartManager instance; private MapInteger, Integer dishMap new HashMap(); private MapInteger, 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 MapInteger, Integer getDishMap() { return dishMap; } public double getTotalPrice() { double total 0.0; for (Map.EntryInteger, 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表示已完成。状态流转的规则是用户提交订单后status0在订单列表点击“去取餐”按钮更新为status1再过几秒或者手动点击“确认完成”更新为status2。更新代码放在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设成21targetSdk设成老师要求的版本compileSdk用你本地的SDK版本。minSdk设21能让你的App覆盖绝大多数手机同时又不至于因为适配Android 10的权限分区而增加额外工作。如果你把minSdk设成30老师自己的旧手机可能装不上设成16你又需要适配非常古早的写法不划算。屏幕适配不要写死dp多用LinearLayout加weight。比如底部购物车栏左边显示总价右边一个结算按钮。如果你把左边TextView的宽度写成200dp换成大屏手机会偏换小屏又会挤压。用layout_width0dp、layout_weight1让左边的总价占剩余空间按钮用wrap_content这样任何屏幕看起来都正常。还有一个小坑很多手机字体设置了“超大字体”后你的TextView可能显示不全。在布局里给关键TextView加android:maxLines1和android:ellipsizeend不要让它换行。字体大小用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 orderSQLite会认为你在写一个命令然后告诉你“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然后到CartManagerCartManager更新Map最后通过接口刷新UI。这样讲代码老师听的是数据流而不是文件清单。哪怕某个类写得不太好只要数据流清晰分数就不会低。6.3 记住五句“为什么这样选”的回答模板我在答辩前会给自己准备几个“为什么”的回答为什么用SQLite因为演示不需要网络本地存储稳定且能讲清SQL。为什么用ViewPager2因为它是新版支持库能避免FragmentPagerAdapter过时问题。为什么用单例管理购物车因为Activity重建后数据不丢。为什么价格用REAL因为浮点精度比FLOAT好。为什么状态用int不用String因为排序和条件查询更方便。这五个问题答上来答辩基本稳了。最后说句实在话我读书时第一次做这种App也栽在“需求做太满”上最后只能熬夜删功能。你现在把这个仿外卖App控制在可演示范围内把购物车和数据存储这两个硬骨头啃透比做一个半吊子大项目要划算得多。希望帮到你。本文还有配套的精品资源点击获取