ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android购物商城源码解析:从数据链路到订单闭环的移动电商实践

Android购物商城源码解析:从数据链路到订单闭环的移动电商实践 简介这是一份基于Android平台的购物商城毕业设计项目面向移动开发初学者及需完成Android课题的学生。项目完整实现商品浏览、分类查询、订购商品与购物车管理等核心电商功能客户端与服务端代码齐全附有shoppingdb.sql数据库脚本和Android Studio安装说明可还原从界面搭建到网络通信、数据持久化的全链路开发过程。压缩包共1447个文件大小21.17MB主要包含Java源码、XML布局、PNG/JPG图片、GIF演示、JAR依赖库、JSP服务端页面及SQL脚本等工程结构清晰便于按模块查阅学习。目前已有555人学习适合作为毕业设计参考或Android实战练习对理解移动电商系统的前后端交互、购物车同步与订单处理流程很有帮助。1. 基于 Android 平台的购物商城一套能真正跑起来的移动电商骨架这套基于 Android 平台购物商城的源码包解压后会看到一串 gradlew.bat、taskHistory.bin、fileHashes.bin这些是 Gradle 构建产生的中间产物说明工程被完整编译过不是拿几个空 Activity 拼出来的样子货。目录里 ShopClient 是 Android 客户端ShopService 是服务端shoppingdb.sql 是初始化数据库脚本商品浏览、分类查询、加购、结算这条链路是通的。它适合拿来当 Android 毕业设计交付也适合改造成二手交易、小规模商品橱窗的底座。下面从数据链路、列表性能、购物车状态、构建调试四个方向拆开讲尽量做到拿到手能复现、能跑通、能自己续写。2. 从 ShopClient 到 ShopService商城分层与数据链路设计2.1 客户端与服务端为什么要分开压缩包里同时出现 ShopClient 和 ShopService 两个目录名字已经点明架构边界客户端只负责渲染和交互服务端管商品、订单、购物车的业务规则。如果揉进同一个 APK早期页面少看不出问题等分类、促销、库存扣减这些逻辑叠进来每次发版都要连服务端一起动维护成本立刻上来。实际生产里前后端拆分是常态这个项目保留了同样的结构对理解工程边界很有帮助。两端通信走 HTTP 接口数据格式以 JSON 为主。接口地址不要散落在 Activity 里集中放在一个 ApiConfig 类中换测试服务器只改这一处。后面几节先落数据层再落客户端网络层顺序和数据流方向一致。2.2 用 shoppingdb.sql 还原核心表shoppingdb.sql 是整套业务的数据地基。按摘要里提到的功能范围商品表至少要有商品 ID、名称、价格、库存订单表要记录用户 ID、商品 ID、数量再配合分类表、购物车表和订单明细表基本能覆盖一次完整购买行为。下面是整理后的建表脚本CREATE TABLE category ( category_id INT PRIMARY KEY AUTO_INCREMENT, category_name VARCHAR(50) NOT NULL, parent_id INT DEFAULT 0, sort_order INT DEFAULT 0 ); CREATE TABLE product ( product_id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, product_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, image_url VARCHAR(255), description TEXT, status TINYINT DEFAULT 1, created_at DATETIME, KEY idx_category (category_id), CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES category(category_id) ); CREATE TABLE cart ( cart_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, checked TINYINT DEFAULT 1, updated_at DATETIME ); CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_no VARCHAR(32) NOT NULL UNIQUE, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, created_at DATETIME ); CREATE TABLE order_item ( item_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100), price DECIMAL(10,2), quantity INT, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders(order_id) );product 表里的 category_id 指向 category形成一对多关系。价格字段必须用 DECIMAL(10,2)商品单价涉及金额计算不要用 FLOAT否则累加会出现 0.10.2 的精度问题。orders 表用 order_no 唯一索引同一订单重复提交时会被数据库挡住这是防并发下单的关键。order_item 里冗余存了 product_name 和 price表面看违反范式但电商业务必须这样商品改价后历史订单照样保留下单时刻的快照。user_id 关联的 users 表在演示项目里通常不做登录逻辑可以写死一个默认用户。客户端请求的接口可以按下面这张表设计对应 ShopService 里需要实现的五个端点接口方法参数返回/product/listGETcategoryId、page、size商品列表/product/detailGETproductId商品详情/cart/addPOSTuserId、productId、quantity操作结果/cart/listGETuserId购物车数据/order/createPOSTuserId、items、totalAmount订单号第 3 章的商品列表页会用到前两个接口第 4 章的购物车和结算会用到后三个。字段命名保持统一风格客户端 JSON 解析时能少写很多转换逻辑。2.3 网络层与 JSON 字段命中客户端没有引入重型框架时常见做法是用 OkHttp 包一个 HttpUtil。网络请求必须在子线程执行Android 主线程一旦出现网络操作会直接抛 NetworkOnMainThreadException。下面是最精简的 POST JSON 封装public class HttpUtil { private static final int CONNECT_TIMEOUT 10; private static final int READ_TIMEOUT 15; private static OkHttpClient client new OkHttpClient.Builder() .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) .build(); public static String postJson(String url, String json) throws IOException { RequestBody body RequestBody.create( MediaType.parse(application/json; charsetutf-8), json); Request request new Request.Builder().url(url).post(body).build(); try (Response response client.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException(Unexpected code response.code()); } return response.body() ! null ? response.body().string() : ; } } }connectTimeout 是建立连接的最长等待readTimeout 是拿到响应体的最长等待。慢网络下 readTimeout 一般要比 connectTimeout 大否则弱网场景会频繁 SocketTimeoutException。BASE_URL 集中放在 ApiConfig 里真机调试时换成电脑在局域网里的 IP模拟器里才用 10.0.2.2 指向宿主机。拿到 JSON 后解析最省事的方案是 Gson 反射映射public static Product parseProduct(String json) { Gson gson new Gson(); return gson.fromJson(json, Product.class); }Gson 按字段名反射映射JSON 里的 key 与 Product 类字段名不一致时int 字段会静默变成 0String 变成 null不报错。所以服务端返回的字段名、类型都要和实体类对死这类问题在联调阶段很难定位。3. 商品流与分类筛选RecyclerView 适配层怎么做才不卡3.1 ListView 换 RecyclerView 之后得到了什么老式商城项目还在用 ListView不是不能跑而是 RecyclerView 把复用、布局、动画三者解耦了。最直观的差别是强制 ViewHolderListView 靠 convertView 复用开发者忘判空就会重复 findViewByIdRecyclerView 在 onCreateViewHolder 里建立 ViewHolderonBindViewHolder 只做数据绑定。数据变化时还能用 DiffUtil 局部刷新不会像 ListView 一把 notifyDataSetChanged 全量重绘。LayoutManager 可以在 LinearLayoutManager、GridLayoutManager、StaggeredGridLayoutManager 之间切换商品瀑布流不用另写一套列表。对比项ListViewRecyclerView复用回收convertView 手动判空ViewHolder 强制持有多类型getViewTypeCount 手写按 getItemViewType 返回布局能力垂直列表Linear/Grid/Staggered 可切换局部刷新约等于全量DiffUtil 精确到 item如果要把这个商城改造成双列宫格或瀑布流RecyclerView 方案几乎不用动数据层只换 LayoutManager 和 item 布局就行这是它最值钱的地方。3.2 商品流的 Adapter 与点击回调商品列表页的核心是 ProductAdapter。常见结构是三个方法onCreateViewHolder 加载 item_recycler_product.xmlonBindViewHolder 把 Product 数据填进控件getItemCount 返回数据长度。再配一个点击回调把事件抛给 Activity 处理跳转详情页public class ProductAdapter extends RecyclerView.AdapterProductAdapter.ProductHolder { private final ListProduct products new ArrayList(); private OnItemClickListener listener; public interface OnItemClickListener { void onItemClick(Product product); } public void setOnItemClickListener(OnItemClickListener listener) { this.listener listener; } Override public ProductHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_recycler_product, parent, false); return new ProductHolder(view); } Override public void onBindViewHolder(ProductHolder holder, int position) { Product product products.get(position); holder.tvName.setText(product.getProductName()); holder.tvPrice.setText(¥ product.getPrice()); holder.itemView.setOnClickListener(v - { if (listener ! null) { listener.onItemClick(product); } }); } Override public int getItemCount() { return products.size(); } public void refresh(ListProduct data) { products.clear(); products.addAll(data); notifyDataSetChanged(); } static class ProductHolder extends RecyclerView.ViewHolder { TextView tvName; TextView tvPrice; ImageView ivProduct; ProductHolder(View itemView) { super(itemView); tvName itemView.findViewById(R.id.tv_product_name); tvPrice itemView.findViewById(R.id.tv_product_price); ivProduct itemView.findViewById(R.id.iv_product); } } }onCreateViewHolder 只负责创建 View频繁执行的是 onBindViewHolder所以 findViewById 不要写在 onBindViewHolder 里这是新手最容易写反的地方。点击回调里直接把 Product 对象抛出去Activity 拿 productId 做详情跳转Adapter 就不需要关心业务跳转逻辑。refresh 方法目前是全量刷新第 3.5 节会替换成 DiffUtil 版本。商品数量上千时getItemCount 直接返回 products.size() 就行不要在这里做多余计算。3.3 Glide 加载图片与内存控制商品图直接用 ImageView.setImageBitmap 加载内存很容易被大图击穿列表滚动时还会掉帧。常见做法是用 Glide 这类图片库做采样和缓存。注意 with(context) 要传 Activity 上下文这样 Glide 会绑定生命周期页面销毁时自动取消未完成的加载。在 ProductAdapter 的 onBindViewHolder 里调用Glide.with(context) .load(product.getImageUrl()) .placeholder(R.drawable.ic_placeholder) .error(R.drawable.ic_error) .override(400, 400) .centerCrop() .into(holder.ivProduct);override(400, 400) 让 Glide 先采样再解码避免把一张 1920 宽的原图直接塞进 ImageViewcenterCrop 在控件里居中等比裁剪比 stretch 更像电商列表的观感。placeholder 和 error 分别对应加载中、加载失败的状态没有这两项弱网下会看到一片空白。如果服务端返回的是相对路径记得在请求前拼接完整域名Glide 不认识不带协议头的地址。3.4 分类筛选与首页 Banner 联动分类查询的服务端逻辑不复杂本质是 WHERE category_id ? 的过滤再加 status 1 排除下架商品SELECT product_id, product_name, price, stock, image_url FROM product WHERE category_id ? AND status 1 ORDER BY product_id DESC;客户端拿到分类 ID 后重新请求 /product/list替换列表数据即可。接口带 page/size服务端用 LIMIT offset, size 分页返回体里带 totalPage 字段列表才好判断有没有下一页。分类菜单本身用横向 RecyclerView 就能实现一级分类、二级分类就是两个 RecyclerView 联动。首页顶部要放 Banner 轮播时很多商城会把 Banner 放在 CoordinatorLayout 的 AppBarLayout 区域让 Banner 随列表向上滚动先收起来留下分类栏固定这种交互我在几个二手交易项目里验证过配置量不大但记得给 RecyclerView 设置嵌套滚动标志否则会出现顶栏和列表各滚各的。3.5 DiffUtil 更新列表避免闪烁分类切换和下拉刷新时如果直接 notifyDataSetChanged全部 item 会重绘滚动位置、图片缓存全部被打断视觉上明显闪一下。DiffUtil 会比较新旧 List只更新变化项DiffUtil.DiffResult diffResult DiffUtil.calculateDiff(new DiffUtil.Callback() { Override public int getOldListSize() { return oldList.size(); } Override public int getNewListSize() { return newList.size(); } Override public boolean areItemsTheSame(int oldItem, int newItem) { return oldList.get(oldItem).getId() newList.get(newItem).getId(); } Override public boolean areContentsTheSame(int oldItem, int newItem) { return oldList.get(oldItem).equals(newList.get(newItem)); } }); diffResult.dispatchUpdatesTo(adapter);areItemsTheSame 比较商品 ID决定这一项是复用还是重建areContentsTheSame 比较价格、库存、标题这些业务字段决定已复用项要不要重新绑定。dispatchUpdatesTo 之后 RecyclerView 会执行 ItemAnimator 动画视觉上是平滑过渡。DiffUtil 的计算在纯 Java 环境是同步执行的数据量大时挪到后台线程算拿到 DiffResult 再回主线程派发。4. 购物车与订购链路从本地暂存到订单落库4.1 购物车为什么需要“本地 服务端”两份状态购物车是电商模块里最容易写出脏数据的部分。用户加购时大概率处于弱网、跳页面的场景每次点击都同步等服务器返回体验会很差。客户端要先落本地再异步同步到 ShopService。从摘要里的描述看本地暂存主要是 SharedPreferences 或 SQLite 两条路线SharedPreferences 适合存勾选状态、件数这些轻量键值购物车条目这种结构化列表用 SQLite 更稳字段可以随意组合查询批量改数量也不受键值存储的限制。存储方案适合场景不适合场景SharedPreferences记住勾选状态、登录账号存列表、按条件过滤SQLite购物车条目、订单草稿需要手写 SQLRoom新项目首选编译期校验 SQL老工程引入成本高这套代码的年代和工程结构基本对应 SQLiteOpenHelper 手写 DAO 的路线下面按这个展开。4.2 SQLite 实现购物车增改客户端本地购物车表和服务端 shoppingdb.sql 里的 cart 表结构保持一致方便同步。addToCart 的常见实现是先尝试 update影响行数为 0 说明购物车里还没有这件商品再走 insertpublic void addToCart(ShoppingCartItem item) { SQLiteDatabase db dbHelper.getWritableDatabase(); ContentValues values new ContentValues(); values.put(user_id, item.getUserId()); values.put(product_id, item.getProductId()); values.put(quantity, item.getQuantity()); values.put(checked, 1); int rows db.update(cart, values, user_id? AND product_id?, new String[]{String.valueOf(item.getUserId()), String.valueOf(item.getProductId())}); if (rows 0) { db.insert(cart, null, values); } db.close(); }update 用 user_id product_id 做联合条件唯一索引保证同一用户对同一商品只保留一行rows 0 表示不存在走 insert。insert 的第二个参数传 null 是 ContentValues 永远有值时的调用约定。db.close() 放在方法末尾同一个数据库连接不要跨线程共用否则会有 SQLiteDatabaseLockedException。4.3 数量、总价与事务边界购物车页面改数量和算总价是成对出现的操作。先 UPDATE 数量再 SELECT 总额两步之间进程被杀就会出现数量变了总价没变的脏状态。SQLite 的事务可以把两步捆成原子操作db.beginTransaction(); try { ContentValues values new ContentValues(); values.put(quantity, newQuantity); db.update(cart, values, user_id? AND product_id? AND checked?, new String[]{userId, productId, 1}); Cursor cursor db.rawQuery( SELECT SUM(p.price * c.quantity) FROM cart c JOIN product p ON c.product_id p.product_id WHERE c.user_id ? AND c.checked 1, new String[]{userId}); if (cursor.moveToFirst()) { totalPrice cursor.getDouble(0); } cursor.close(); db.setTransactionSuccessful(); } finally { db.endTransaction(); }beginTransaction 之后事务没提交前其他连接读不到中间状态。setTransactionSuccessful 标记成功endTransaction 时统一提交try 里任意一步抛异常endTransaction 会回滚全部变更。总额计算用 SQL JOIN 关联 product 表以服务端商品价格为准不要在本地 cart 表里冗余存钱否则商品改价后购物车总额会算错。提交订单时服务端要重新核对一次价格不要信任客户端传过来的金额。4.4 结算链路与订单状态机点击结算后客户端把勾选商品聚合成 items 数组POST 到 /order/create。服务端要做三件事重新读商品价格、校验库存、在事务里插入 orders 和 order_item。这两张表必须一起提交否则会出现主表有了、明细丢了的半截订单。订单状态用 status 字段表示一组够用的状态定义如下status含义触发点0已提交未支付/order/create 成功1已支付支付结果回调2已发货商家后台操作3已完成用户确认收货4已取消用户取消或超时关闭status 不要用字符串字符串状态在接口对接时容易出现大小写不一致整型枚举在 Java 端对应常量即可。购物车清空动作放在订单创建成功之后不要提前清否则接口失败用户还要重新加购。4.5 防重复提交与等待动画提交按钮被双击是订单模块最常见的事故来源。客户端可以先做 500ms 的点击防抖但这不是唯一保障服务端还要靠 order_no 唯一索引兜底重复数据long lastClickTime 0; btnSubmit.setOnClickListener(v - { if (System.currentTimeMillis() - lastClickTime 500) { return; } lastClickTime System.currentTimeMillis(); submitOrder(); });500ms 只是普通按钮的安全阈值如果提交动作本身耗时超过 500ms用户仍可能触发第二次真正的幂等还要靠服务端唯一约束。提交期间要给页面加一个等待提示进度条动画不能阻塞主线程否则商品列表和购物车滚动都会卡死。提交成功后再清理本地购物车对应条目避免用户误以为没提交成功而重复下单。5. 构建、装进手机与隐藏的坑位5.1 用 Android Studio 打开并同步工程拿到源码后在 Android Studio 里直接 Open 解压目录等 Gradle 同步完成再编译。包里的 resources-debug.ap_、taskHistory.bin、classAnalysis.bin 都是构建中间产物不用手动删。同步卡住时先确认 JDK 版本和 gradle-wrapper.properties 里声明的 Gradle 版本匹配AGP 和 Gradle 差太远会报一堆让人摸不着头脑的插件错误包里那份 AS软件下载说明.docx 对环境版本也有对应说明。5.2 真机调试怎么把小米手机接进 AS以小米手机为例进入设置连击 MIUI 版本号直到进入开发者模式然后打开 USB 调试和 USB 安装。USB 连接电脑后在手机弹出的 USB 用途里选“传输文件”Android Studio 的设备列表就会出现该机型。设备列表空白时检查数据线是否只支持充电以及驱动有没有装好。小米手机第一次安装应用会弹未知来源应用确认框需要在安全中心里允许该应用安装很多新手卡在这一步以为工程编译有问题其实只是安装被系统拦了。5.3 两个常被问的坑SDK 勾选与 BuildConfig新装 AS 时 SDK Manager 里各项显示已安装但灰选基本是 SDK 目录权限或缓存识别问题。试试用管理员身份运行 AS或者在 Settings 里重新指定一次 SDK 目录再删掉用户目录下的 .android 缓存重新扫描。另一个高频问题是老项目升到新 AGP 后代码里 BuildConfig.VERSION_CODE 取不到值这是 AGP 8 之后默认关闭了 buildConfig 生成开关在模块的 build.gradle 里打开即可android { buildFeatures { buildConfig true } }buildConfig true 之后 BuildConfig 类才会重新生成VERSION_CODE 和 VERSION_NAME 都能正常读取。升级老工程时顺手检查一遍 buildFeatures不只 buildConfigviewBinding 是否打开也一并确认省得后面在 findViewById 和 ViewBinding 之间反复横跳。5.4 启动服务端后按什么顺序验证功能先把 shoppingdb.sql 导入 MySQL改好 ShopService 的数据库连接配置再启动服务端。然后修改客户端 ApiConfig 里的 BASE_URL真机调试不能写 localhost要填电脑的局域网 IP手机和电脑连同一个 Wi-Fi 才能访问。按下面顺序完整走一遍进入商品列表确认图片和价格能加载切换两个分类看列表是否按分类过滤加购两件商品回购物车改数量确认总价联动提交订单确认服务端 orders 表新增记录且 order_item 有明细把订单状态从 0 改为 1模拟支付成功。如果服务端返回 404先别查代码用 Postman 单独请求接口返回 500 再看 ShopService 日志里的 SQL 报错多半是表结构没建全或者数据库密码没对上。本文还有配套的精品资源点击获取
返回列表