首页 / 资讯中心 / 文章详情

基于Android的水果蔬菜销售APP设计与实现:从数据库到订单状态机

基于Android的水果蔬菜销售APP设计与实现:从数据库到订单状态机 ★ FEATURED ARTICLE
简介一份基于安卓平台的蔬菜水果销售APP设计与实现毕业设计文档适合计算机相关专业学生及安卓开发者参考。内容系统完整从选题目的与意义、国内外研究现状、系统要实现的基本目标与研究内容出发覆盖前台功能、后台管理、拍照上传、图片处理以及经济、技术、操作可行性等模块可作为课程设计或毕业答辩的写作模板。整份资源仅含1个docx文档大小约892KB便于直接阅读与修改目前已有79人学习下载。文档包含中英文摘要、关键词及详细目录可帮助读者理解安卓客户端与Web后台协同的果蔬销售系统整体架构以及智能终端拍照上传、信息传输等关键模块的设计思路能用于明确功能边界与开发流程。借助文档中的章节安排能够规范撰写设计报告、梳理系统逻辑同时为毕业设计答辩准备提供有益参考适合正在完成同类移动电商系统课题的学生高效搭建论文框架。1. 这个标题在讲什么一个能过答辩也能上架的水果蔬菜电商APPAndroid方向的项目清单里带“设计与实现”后缀的标题十有八九是毕业设计。“基于Android水果蔬菜销售APP设计与实现”就是这个方向里最标准的一道题用Android原生技术栈做一个本地电商应用覆盖注册登录、商品展示、加购、下单、订单列表这些全流程。它不是要你做出一个生鲜配送平台而是用一套不复杂的工程练熟Activity、RecyclerView、SQLite这三板斧顺带把购物车和订单状态管理这类“越想越乱”的业务逻辑理清楚。打算用这套题开题的同学、想把手头半成品代码重新搭一遍的初级开发者都能从中拿到一条可复现的路径。先给一个反直觉的结论这个项目的工作量大头不在界面而在数据库表设计和订单状态流转。很多第一次做的人把首页和商品详情做得花里胡哨结果做到购物车和订单就卡住——购物车要不要单独建表下单算钱和清空购物车不在一个事务里怎么办订单状态用int硬编码还是枚举这些问题在docx开题报告里都不会写却是答辩评委最常问的。下面直接按“需求与数据模型 → Android Studio工程搭建 → 购物车与订单实现 → 踩坑记录 → 验证与进阶”的顺序来读者可以是正为开题发愁的毕业生也可以是练手的开发者。期末验收或答辩时你拿出来的不是一页PPT而是一个能在真机上跑完整下单流程的APK。2. 把水果蔬菜APP拆成可落地的需求与数据模型6张表撑起整个业务2.1 需求边界先砍到能答辩再谈扩展谈设计和实现之前先画一条功能边界。水果蔬菜销售APP的常见做法是只做C端不做商家端和骑手端。C端范围控制在6个页面注册登录、首页商品分类与列表、商品详情、购物车、订单确认与支付模拟、订单列表。管理端的商品上架、库存调整直接用数据库工具改数据来模拟不单独做管理页面。这样划分不是偷懒而是把工作量集中到Android开发者必须会的知识上Activity与Fragment切换、RecyclerView列表、本地数据库读写。等这些跑通了再加Banner轮播、搜索框、接入支付SDK都有余地。以下是这套C端功能模块的常规拆分方式对应到界面就是底部导航栏的“首页 / 分类 / 购物车 / 我的”四个Tab加上二级页面。功能模块的边界划得越清楚后面建表越省事。功能模块页面/入口核心要点注册登录启动页 → 登录页本地数据库校验密码做SHA-256哈希商品分类与列表首页分类Tab RecyclerView商品卡片商品详情点商品卡片进入图片、价格、单位、库存、加购按钮购物车底部导航购物车Tab勾选、数量加减、合计金额实时刷新订单确认与支付模拟购物车点结算展示商品明细点击支付只改订单状态订单列表我的 → 我的订单按状态筛选展示商品名与价格快照这里有一个答辩时经常被追问的点为什么不把购物车直接存在SharedPreferences里。答案是购物车需要按用户隔离、按商品聚合还要支持数量加减和勾选态这些操作用SQL做比用JSON字符串反复序列化可靠得多。需求边界定下来之后数据模型的设计就是整个项目的地基。2.2 数据库选型Room还是手写SQLiteOpenHelper数据存储这块常见毕业设计里有两种写法手写SQLiteOpenHelper和用Room。手写SQLiteOpenHelper需要自己管理SQLiteDatabase、Cursor、字段映射登录注册这些简单场景还好做到购物车联表查询和订单明细快照时代码会变得又臭又长。Room是Google在SQLite之上封装的ORM框架选它有两个决定性理由。第一是编译期SQL检查。手写SQL写错表名或字段名只有跑到那一行才会崩Room会在编译时检查Query里的SQL语法错误直接编译失败。第二是Room可以配合LiveData做响应式更新数据库表变化后界面自动收到通知刷新不用手动在Adapter上调用notifyDataSetChanged。对一个人完成的课程设计来说这两点能省下大量排错时间。整个项目的表结构一般按下面6张表设计每张表负责一个职责边界。订单明细单独抽表是为了在订单里保存“商品名称、单价”的快照否则商品改价后历史订单显示的数字也会跟着变这在电商里是不能接受的。表名作用关键字段user用户账号id, username, password_hash, nicknamecategory商品分类id, nameproduct商品id, category_id, name, price, unit, image_res, stockcart购物车user_id, product_id, count, checkedorders订单主表id, user_id, total_price, status, create_timeorder_item订单明细id, order_id, product_id, product_name, price, count关于外键约束我的建议是只在逻辑上关联不要在Room里配ForeignKey。演示项目里删除订单和商品的路径很随意真配了外键加级联删除反而容易在答辩演示时触发外键冲突崩溃。后面代码里也统一不写外键靠业务方法保证数据一致性。2.3 建表SQL商品实体与订单实体的字段说明先看商品实体的写法注意字段类型和命名规范。用Java实现时字段建议用驼峰命名表名列名用下划线靠ColumnInfo做映射。Entity(tableName product) public class Product { PrimaryKey(autoGenerate true) private int id; ColumnInfo(name category_id) private int categoryId; private String name; private double price; private String unit; // 单位斤 / 份 / 个 ColumnInfo(name image_res) private String imageRes; // 图片资源名如 apple private int stock; // 省略 getter / setter }id用自增主键categoryId关联分类表imageRes存的是drawable资源名而不是网络URL。这样离线演示时图片永远能显示不会被网络环境卡住。price用double在演示项目里够用但如果后面要对接支付建议换成以“分”为单位的int或在计算层改用BigDecimal避免浮点误差。订单主表和明细表的实体如下重点看明细表如何做快照。Entity(tableName orders) public class Order { PrimaryKey(autoGenerate true) private long id; ColumnInfo(name user_id) private int userId; ColumnInfo(name total_price) private double totalPrice; private int status; // 0待支付 1已支付 2已发货 3已完成 4已取消 ColumnInfo(name create_time) private long createTime; } Entity(tableName order_item) public class OrderItem { PrimaryKey(autoGenerate true) private long id; ColumnInfo(name order_id) private long orderId; ColumnInfo(name product_id) private int productId; ColumnInfo(name product_name) private String productName; // 商品名称快照 private double price; // 单价快照 private int count; }OrderItem里存productName和price两份快照是面试和答辩都爱问的设计点。商品资料可以随便改但订单一旦生成明细必须定格在下单那一刻否则“买了5斤苹果三个月后看订单发现单价变了”这种问题没法解释。createTime用毫秒时间戳long存展示时用SimpleDateFormat格式化比存字符串更适合做排序和筛选。3. 用Android Studio把工程搭起来从登录到商品列表3.1 工程配置Gradle依赖、最低版本和编译版本的取舍Android Studio新建工程时选择Empty Views Activity而不是Empty Activity后者默认带Compose依赖和这里讲的Java XML写法不一致。工程创建后第一件事是配置build.gradle我一般把依赖精简到最小可用集避免引入没用到的库导致构建变慢。android { compileSdk 34 namespace com.example.fruitapp defaultConfig { applicationId com.example.fruitapp minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } } dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.11.0 implementation androidx.recyclerview:recyclerview:1.3.2 implementation androidx.room:room-runtime:2.6.1 annotationProcessor androidx.room:room-compiler:2.6.1 implementation com.github.bumptech.glide:glide:4.16.0 }minSdk选24意味着最低支持Android 7.0覆盖率足够毕业演示。Room 2.6.1需要annotationProcessor声明如果你的工程用Kotlin写要换成kapt或ksp插件否则编译期不会生成DAO实现类运行时会报“Cannot find implementation for AppDatabase”。Glide用4.16.0主要用来加载商品图片同时承担占位图职责。如果编译时提示Java版本冲突在android块里加一行compileOptions把sourceCompatibility和targetCompatibility设为Java 1.8即可。Room 2.6.x要求的Java版本不算高这张配置表对新手常见的构建失败已经能覆盖绝大部分情况。3.2 登录与注册密码怎么存才不算白写登录注册在本地做校验不请求服务端。用户表DAO和密码哈希工具要一起写密码不能明文入库这在答辩现场经常被老师翻出来问。先写UserDao接口Dao public interface UserDao { Insert long insert(User user); Query(SELECT * FROM user WHERE username :name AND password :pwd LIMIT 1) User login(String name, String pwd); }注册时把密码哈希后再插入登录时也对输入做同样哈希再比对。这里用SHA-256做单向摘要public class HashUtil { public static String sha256(String input) { try { MessageDigest md MessageDigest.getInstance(SHA-256); byte[] digest md.digest(input.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : digest) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } }登录按钮回调里的关键点是不能在主线程直接调Room的查询方法。Room默认禁止在主线程跑数据库操作这里用一个单线程Executor切到后台拿到结果再通过runOnUiThread回到主线程更新UI。public void login(View view) { String name etUsername.getText().toString().trim(); String pwd etPassword.getText().toString().trim(); if (name.isEmpty() || pwd.isEmpty()) { Toast.makeText(this, 请输入账号和密码, Toast.LENGTH_SHORT).show(); return; } executor.execute(() - { User user db.userDao().login(name, HashUtil.sha256(pwd)); runOnUiThread(() - { if (user null) { Toast.makeText(this, 用户名或密码错误, Toast.LENGTH_SHORT).show(); } else { startActivity(new Intent(this, MainActivity.class)); finish(); } }); }); }executor的初始化用Executors.newSingleThreadExecutor()即可整个Activity生命周期内复用。更规范的做法是把执行器收进ViewModel但课程设计重点在功能跑通Activity持有执行器可以接受。登录成功后用startActivity跳走并finish保证按返回键不会回到登录页。3.3 商品列表RecyclerView、Glide与本地图片的兼容商品列表是RecyclerView的核心应用场景。Adapter的onBindViewHolder里要做三件事绑定名称、绑定价格、绑定图片。图片这块用了一个兼容方案imageRes字段既可能是本地drawable名也可能是网络URL用前缀判断后分别交给Glide加载。Override public void onBindViewHolder(NonNull ViewHolder holder, int position) { Product item list.get(position); holder.name.setText(item.getName()); holder.price.setText(¥ item.getPrice() / item.getUnit()); String imageRes item.getImageRes(); RequestOptions options new RequestOptions() .placeholder(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder); if (imageRes.startsWith(http)) { Glide.with(holder.itemView.getContext()) .load(imageRes) .apply(options) .into(holder.image); } else { int resId holder.itemView.getContext().getResources() .getIdentifier(imageRes, drawable, holder.itemView.getContext().getPackageName()); Glide.with(holder.itemView.getContext()) .load(resId) .apply(options) .into(holder.image); } }placeholder占位图和error占位图都用一张本地图图片还在加载或加载失败时列表不会白屏。getIdentifier通过资源名动态拿drawable的id这在演示项目里比写死R.drawable.xxx要灵活新增商品只要在drawable里放同名图片就能显示。列表首次加载时页面顶部可以加一个ProgressBar做加载中提示等数据填充完再隐藏——这个转圈动画要跟Adapter的notifyDataSetChanged配合不然会出现进度条一闪而过但列表迟迟不刷新的假象。商品列表按分类过滤时不要每次切换Tab都重新查全表。常见做法是给categoryDao加一个带分类条件的查询方法切换分类时重新提交查询并把结果set给Adapter即可。这里的数据刷新只涉及RecyclerView不用引入复杂的DiffUtil但把Adapter的list替换后记得调用notifyDataSetChanged。4. 购物车与订单用状态机理清本地电商核心流程4.1 购物车数据模型联合主键避免重复记录购物车表的设计是第一个容易翻车的点。如果按常规方式加一个自增id做主键同一用户对同一商品加购两次就会插入两行结算时还得先去重。正确做法是用userId和productId做联合主键确保同一用户对同一商品只有一条记录。Entity(tableName cart, primaryKeys {userId, productId}) public class Cart { ColumnInfo(name user_id) private int userId; ColumnInfo(name product_id) private int productId; private int count; private boolean checked; // 省略 getter / setter }count表示数量checked表示结算时是否被勾选。checked用boolean字段而不是在查询时过滤是因为用户在购物车页勾选后临时切到别页再回来勾选态必须保留这个状态需要持久化。对应的DAO方法如下Dao public interface CartDao { Insert(onConflict OnConflictStrategy.REPLACE) void insert(Cart cart); Query(SELECT * FROM cart WHERE user_id :userId) ListCart getCartByUser(int userId); Query(UPDATE cart SET count count 1 WHERE user_id :userId AND product_id :productId) void increaseCount(int userId, int productId); Query(UPDATE cart SET count count - 1 WHERE user_id :userId AND product_id :productId AND count 1) void decreaseCount(int userId, int productId); Query(DELETE FROM cart WHERE user_id :userId AND checked 1) void deleteChecked(int userId); }加购的完整逻辑分两步先查购物车是否存在该商品存在则把count加1不存在则插入一行。用自定义Query做数量增减而不是读出来再update是为了减少一次Java层到数据库层的往返。deleteChecked是结算完成后清理已勾选条目的专用方法按user_id和checked两个条件删避免误删未勾选商品。4.2 订单状态流转枚举加状态机别在Activity里到处if-else订单状态是整个项目里最值得认真设计的部分。很多人的写法是在Activity里用int变量存状态支付按钮里if (status 0)改成1确认收货再if (status 2)改成3。初看没问题等加了取消订单、退款状态后Activity里会堆满判断而状态之间的非法跳转根本拦不住——比如已取消的订单还能被点成已完成。写状态流转的时候别用散落的if-else到处改封装成一个状态机类才是靠谱做法。这里用的就是一种精简版状态模式用Java实现起来也不复杂。public class OrderStatus { public static final int PENDING_PAYMENT 0; // 待支付 public static final int PAID 1; // 已支付 public static final int SHIPPED 2; // 已发货 public static final int COMPLETED 3; // 已完成 public static final int CANCELLED 4; // 已取消 private static final boolean[][] TRANSITIONS { {true, true, false, false, true}, // 待支付可去支付、取消 {false, true, true, false, false}, // 已支付可去发货 {false, false, true, true, false}, // 已发货可去完成 {false, false, false, true, false}, // 已完成是终态 {false, false, false, false, true} // 已取消是终态 }; public static boolean canTransit(int from, int to) { if (from 0 || from TRANSITIONS.length || to 0 || to TRANSITIONS.length) { return false; } return TRANSITIONS[from][to]; } }这个二维布尔数组比一堆“if-else return true”清晰得多。每次点击支付、发货、完成前先调canTransit判断合法性不合法就Toast提示并return。这样无论界面怎么改、按钮怎么加非法状态跳转都进不了数据库。之前提到“设计模式java实现”里的状态模式思想在课程设计里做到这个程度已经够用再往深做就要引入状态模式类和Context解耦了。4.3 下单事务订单生成、明细快照、清空购物车一步到位下单涉及三张表生成orders主表、生成order_item明细、删除购物车已勾选记录。这三步必须在一个数据库事务里完成。否则可能出现订单建了但明细没写或者明细写了购物车没清。Room的runInTransaction就是干这个的。public void checkout(int userId, ListCart checkedItems, CheckoutCallback callback) { AppDatabase db AppDatabase.getInstance(context); ExecutorService executor Executors.newSingleThreadExecutor(); executor.execute(() - { try { db.runInTransaction(() - { double total 0; Order order new Order(); order.setUserId(userId); order.setStatus(OrderStatus.PENDING_PAYMENT); order.setCreateTime(System.currentTimeMillis()); long orderId db.orderDao().insert(order); for (Cart cart : checkedItems) { Product product db.productDao().findById(cart.getProductId()); OrderItem item new OrderItem(); item.setOrderId(orderId); item.setProductId(product.getId()); item.setProductName(product.getName()); item.setPrice(product.getPrice()); item.setCount(cart.getCount()); total product.getPrice() * cart.getCount(); db.orderItemDao().insert(item); } order.setTotalPrice(total); db.orderDao().updateTotalPrice(orderId, total); db.cartDao().deleteChecked(userId); }); callback.onSuccess(); } catch (Exception e) { callback.onError(e); } }); }注意事务里的操作顺序先插入Order拿到自增orderId再插入明细。Room自增主键在insert后会把id回填到传入的实体里所以这里直接用order.getId()也行。totalPrice在明细插入完成后才更新避免事务中途崩溃导致订单金额是0。整个事务在子线程执行回调里的onSuccess和onError要通过runOnUiThread切回主线程刷新UI。下单事务做完后订单列表页只需要按user_id查询orders表再按order_id查明细。这里有一个常见误区不要在订单列表里直接join商品表。因为明细表里已经存了商品名和价格快照订单界面不该再依赖product表的当前数据。快照的存在意义就是让订单变成只读的历史记录。5. 常见问题排查5条让新手翻车的踩坑记录5.1 数据库与生命周期Room的null、线程和界面刷新踩坑记录第一条是Room查询返回null导致空指针崩溃。现象是登录时输入正确账号密码程序仍然闪退查看日志抛NullPointerException。原因出在登录查询上Room的Query在查不到数据时返回null而登录方法把null用户直接往下传下面的user.getId()就崩了。解决方式是在拿到查询结果后先判空再决定走成功分支还是失败分支User user db.userDao().login(name, hashed); if (user ! null) { // 登录成功逻辑 } else { // 提示用户名或密码错误 }第二条踩坑是子线程操作数据库、主线程直接刷新UI导致的崩溃。现象是异步查询完成后页面数据没有更新再点按钮就闪退日志提示“Only the original thread that created a view hierarchy can touch its views”。原因是Room查询跑在子线程查询结果直接在子线程里调了textView.setText()。Android的View只能在主线程修改。解决方式统一用runOnUiThread或LiveData。注意Room配合LiveData时Activity里要在onCreate观察一次不要在onResume重复观察否则界面会多次刷新并且可能叠加数据。第三条是Activity销毁后回调还在执行界面操作挂在已销毁的Activity上。现象是退出登录页后偶尔还会闪一下Toast或崩溃。原因是异步线程的runOnUiThread没有判断Activity状态。在回调开头加一行Activity.isFinishing()判断已销毁就直接return。这个过程叫生命周期意识的编程在Room LiveData方案下LiveData本身会自动处理这种场景所以能上LiveData的地方尽量用LiveData别手动来回传。5.2 列表与打包图片卡顿、通知权限和签名不一致第四条是RecyclerView加载大图导致列表滚动卡顿。现象是商品列表滑动时明显掉帧图片闪烁视觉效果很惨。原因是商品图片直接放了高清大图ImageView没有做尺寸约束Glide每帧都在重新解码。解决方式是所有商品图片统一走Glide加载并压缩到合理尺寸。这里要注意Glide的默认缓存策略已经比直接decodeResource好很多但遇到超大图仍会卡在RequestOptions里加override指定目标尺寸即可。第五条是Android 13及以上真机运行App内通知不弹窗。现象是下单完成后主动推送的“支付成功”通知死活不显示。原因是Android 13引入了POST_NOTIFICATIONS运行时权限必须在代码里动态申请清单文件上写了权限也只是声明不弹窗不代表有权限。解决方式如下if (Build.VERSION.SDK_INT 33) { requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1001); }最后一条是Debug包在真机上安装失败提示“应用未安装”。现象是同一台手机之前装过旧版App新构建的APK装不上去。原因绝大多数是签名不一致比如之前装的是别人签过名的包或者Android Studio的Debug签名在电脑迁移后变了。解决方式是先卸载旧应用再安装新包或者统一用正式签名打包。顺便说一句targetSdk 30以上安装覆盖时还涉及包可见性问题不同应用间互相覆盖安装会被系统拦截这也是“提示未安装”的常见隐藏原因。6. 验证与进阶从答辩Demo到能上架内测版还差这三步工程写完不等于能交差先按下面的清单把业务路径完整走一遍每一步都要在真机上操作而不是只在模拟器上演示。模拟器触摸和重力感应的表现和真机差距很大尤其是购物车列表点击手感。测试项操作路径预期结果注册登录注册新账号 → 退出 → 登录登录成功跳首页商品列表切换分类列表按分类过滤且图片正常加购商品详情点加购 → 进购物车同商品数量累加不重复插入结算勾选商品 → 结算 → 支付订单生成、购物车清空已选订单状态支付后模拟发货 → 确认收货状态按预期流转且不可逆跳变横竖屏旋转手机数据不丢RecyclerView位置不回顶路径跑通后往进阶方向走三步。第一步是给商品加搜索Room写一条WHERE name LIKE %:keyword%就能实现这是性价比最高的功能增强。第二步是把订单状态和数据源从本地模拟改成接口对接常见做法是接Bmob或自建Spring Boot后端工作量大但简历可以写。第三步是正式打包在Build菜单里生成Signed Bundle或APK配置正式签名、设置自定义的android图标避免默认机器人图标再做一次屏幕适配把主要布局在720p和1080p两种分辨率上验证一遍。最后说一个我的血泪教训做这套题时我在界面美化上花了太多时间把库存和订单状态机留到了答辩前三天才补。结果演示时连续两次出现“先加购后改库存再下单库存变负”的场面幸好状态机提前写了不然整个流程会直接崩在评委面前。功能完整度永远比圆角阴影重要。把这个项目按上面五章的路子做一遍并自测完把订单状态、快照、事务这三个设计点讲顺答辩基本就稳了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站