ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android Studio购物商城界面开发:Fragment+RecyclerView实战

2026/10/3 14:02:59 拓冰建站 浏览量
Android Studio购物商城界面开发:Fragment+RecyclerView实战 简介这是一份面向Android初学者的购物商城界面实现源码基于Android Studio构建核心使用ListView组件与自定义Adapter完成商品列表的数据绑定与展示。项目包含完整的布局文件、Java逻辑代码与资源文件压缩包共518个文件约9.05MB其中xml布局与清单文件、java源码、gradle构建脚本、png/jpg图片资源及json配置齐全导入IDE即可运行。配套讲解覆盖了item_product.xml商品条目布局设计、ProductAdapter中getCount、getView等核心方法的重写、Activity中ListView与Adapter的关联并给出了通过OnScrollListener实现滚动加载的思路。已有13541人学习下载特别适合刚接触Android列表开发的读者可作为ListView应用模板后续可进一步扩展图片异步加载、下拉刷新、商品详情等功能。1. 一个简单的购物商城界面为什么要从搭骨架开始做用 Android Studio 写一个购物商城界面是很多初学者的第一个“完整作品”因为它把 Fragment、RecyclerView、BottomNavigationView、ViewPager2 这些最常被问到的技术点全串起来了。我接手过不少这种标题的源码也帮人调过课设最大的感受是这项目难点不在“购物”而在把商品列表、详情、购物车三个界面用一套清晰的结构串起来让任何一个页面都能独立改、独立测。它适合课程设计、毕业设计演示也适合准备就业时需要一个能当场跑起来说清楚的界面型作品。下面从项目骨架开始写照着做就能得到一个能安装到手机的完整应用。2. 搭商城主框架底部导航配 Fragment 容器的两个关键选择2.1 创建项目时的依赖选型与 SDK 版本常见做法是新建一个 Empty Activity 项目语言选 Java最低 SDK 选 24 或更高。不要选 Empty Views Activity 以外的模板模板自带的代码反而碍事。创建好之后先在 app/build.gradle 里把依赖补齐这是整个项目的地基。dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 implementation androidx.recyclerview:recyclerview:1.3.0 implementation androidx.fragment:fragment:1.5.7 implementation androidx.viewpager2:viewpager2:1.0.0 }依赖版本的第一个原则跟着 compileSdk 走而不是追最新。compileSdk 34 时 material 1.9.0、appcompat 1.6.1 是稳定的组合直接抄这份配置基本不会报错。第二个原则是不要把所有 androidx 库都塞进来用不到的库会在后续依赖冲突时变成黑匣子出问题根本不知道是哪两个库在打架。如果下载依赖很慢把仓库地址换成国内 Maven 镜像一行配置的事能省下大量等待时间。2.2 用 BottomNavigationView 把四个页面串起来主界面的布局文件 activity_main.xml 只做两件事放一个 FrameLayout 作为 Fragment 容器再放一个 BottomNavigationView。这样做的好处是容器只负责占位页面内容全交给 Fragment后续替换页面不需要改动布局结构。?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical FrameLayout android:idid/fragment_container android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / com.google.android.material.bottomnavigation.BottomNavigationView android:idid/bottom_nav android:layout_widthmatch_parent android:layout_heightwrap_content app:menumenu/bottom_nav_menu / /LinearLayout底部导航的菜单文件放在 res/menu/bottom_nav_menu.xml 里建议四个页签首页、分类、购物车、我的。每个 item 要同时配 icon 和 title缺 icon 时 BottomNavigationView 会显示成一条大粗条很难看。?xml version1.0 encodingutf-8? menu xmlns:androidhttp://schemas.android.com/apk/res/android item android:idid/nav_home android:icondrawable/ic_home android:title首页 / item android:idid/nav_category android:icondrawable/ic_category android:title分类 / item android:idid/nav_cart android:icondrawable/ic_cart android:title购物车 / item android:idid/nav_mine android:icondrawable/ic_mine android:title我的 / /menu图标可以去 Google 官方的 Material Icons 站点下载 PNG放进 drawable 目录即可。新手最容易在这里卡住图标下载后叫 ic_home.xml实际可能是 Vector Drawable放错目录会在编译时直接报资源找不到。MainActivity 里的代码要做的事很明确初始化四个 Fragment 实例监听底部导航点击切换到对应 Fragment。这里我建议用 add 加 show/hide 的方式而不是用 replace理由在下一节展开。public class MainActivity extends AppCompatActivity { private final ListFragment fragments new ArrayList(); private Fragment currentFragment; private int currentIndex 0; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); fragments.add(new HomeFragment()); fragments.add(new CategoryFragment()); fragments.add(new CartFragment()); fragments.add(new MineFragment()); BottomNavigationView bottomNav findViewById(R.id.bottom_nav); bottomNav.setOnItemSelectedListener(item - { int id item.getItemId(); if (id R.id.nav_home) { switchFragment(0); return true; } else if (id R.id.nav_category) { switchFragment(1); return true; } else if (id R.id.nav_cart) { switchFragment(2); return true; } else if (id R.id.nav_mine) { switchFragment(3); return true; } return false; }); switchFragment(0); } private void switchFragment(int index) { if (currentFragment fragments.get(index)) { return; } FragmentTransaction transaction getSupportFragmentManager().beginTransaction(); if (currentFragment ! null) { transaction.hide(currentFragment); } Fragment target fragments.get(index); if (!target.isAdded()) { transaction.add(R.id.fragment_container, target, fragment_ index); } else { transaction.show(target); } transaction.commitAllowingStateLoss(); currentFragment target; currentIndex index; } }这段代码的关键在transaction.add与transaction.show的组合首次切换时 Fragment 被 add 进容器之后一直在 Activity 的 FragmentManager 里存活切走只是 hide切回来是 show。你问我为什么不用 replace因为 replace 每次都会销毁上一个 Fragment 的视图切回来要重新 inflate 布局、重新加载数据。购物车页面如果用户加了几件商品切到首页再切回来数据还在但视图重建导致列表滚动位置回到顶部体验非常断裂。commitAllowingStateLoss这里要解释一下用户在快速切换页签时如果 Activity 刚好处于 onSaveInstanceState 之后的状态普通 commit 会抛 IllegalStateException用 commitAllowingStateLoss 可以避免闪退。代价是极端情况下可能丢失一次切换动作但对这个项目而言完全可接受。2.3 Fragment 切换用 show/hide 还是 replace很多人照着网上的代码用 replace 写完了也能跑但会在两个场景翻车。第一个是“连续快速点底部导航”replace 的事务还没执行完又提交新事务控制台报 “Fragment already added” 或者直接闪退第二个是“切走再切回来”页面重新创建导致状态丢失。show/hide 没有这两个问题代价是四个 Fragment 的视图创建后一直驻留内存但商城首页的列表、购物车的商品都是轻量界面一次创建、永久复用更划算。如果以后页面数量增加到十几个show/hide 会让内存明显上涨那时再考虑用 Fragment 的setMaxLifecycle控制状态或者改用单 Activity 加 Navigation 组件。现阶段“简单”比“优雅”更重要能用一套代码跑通就别上太多框架。另外建议在 MainActivity 里处理一下返回键的逻辑。默认行为是点返回直接退出应用用户从首页进详情页再点返回会觉得很突兀。常见做法是Fragment 容器里层级大于 0 时先回退否则提示“再按一次退出”。这个逻辑写在OnBackPressedDispatcher里不要在 onBackPressed 里写后者在 Android 13 上已经过时了。getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { Override public void handleOnBackPressed() { if (getSupportFragmentManager().getBackStackEntryCount() 0) { getSupportFragmentManager().popBackStack(); } else { finish(); } } });这段代码不复杂但是很多源码里漏掉的部分。加上它之后整个应用的返回逻辑才算闭环用户不会按一次返回键就直接回到桌面。3. 首页三件套轮播图、双列商品流和假数据填充3.1 轮播图ViewPager2 加上自动播放的 Handler首页的轮播图是这个界面项目里最有存在感的部件它会让你的源码看起来比普通 ListView 项目高一个档次。实现思路用 ViewPager2 加一个无限循环的 Adapter再用 Handler 发延迟消息实现自动播放。先写布局放在 HomeFragment 的布局文件里用 RecyclerView 作为整个页面的滚动容器轮播图作为列表的第一个 item 塞进去。这个做法比“轮播图单独放布局顶部再套 NestedScrollView”要稳得多不会出现两个可滚动容器互相抢手势的问题。androidx.recyclerview.widget.RecyclerView android:idid/home_rv android:layout_widthmatch_parent android:layout_heightmatch_parent android:clipToPaddingfalse android:paddingTop8dp /BannerAdapter 的代码和普通 RecyclerView.Adapter 没有本质区别但要注意图片资源的加载方式。如果只用本地 drawable 资源直接用imageView.setImageResource()就行不需要引入 Glide。如果以后要加载网络图再把 Glide 加进来避免一开始就把依赖搞复杂。public class BannerAdapter extends RecyclerView.AdapterBannerAdapter.BannerHolder { private final ListInteger imageList; public BannerAdapter(ListInteger imageList) { this.imageList imageList; } NonNull Override public BannerHolder onCreateViewHolder(NonNull ViewGroup parent, int viewType) { View view LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_banner, parent, false); return new BannerHolder(view); } Override public void onBindViewHolder(NonNull BannerHolder holder, int position) { int realPosition position % imageList.size(); holder.imageView.setImageResource(imageList.get(realPosition)); } Override public int getItemCount() { return Integer.MAX_VALUE; } static class BannerHolder extends RecyclerView.ViewHolder { ImageView imageView; BannerHolder(View itemView) { super(itemView); imageView itemView.findViewById(R.id.iv_banner); } } }这里有个重要参数getItemCount()返回Integer.MAX_VALUE而不是图片数量配合position % imageList.size()实现伪无限循环。这样用户往左滑永远滑不到头自动轮播到最后一帧后也能自然跳回第一帧不会露馅。自动轮播需要在 HomeFragment 里维护一个 Handler把逻辑写在onResume和onPause里private final Handler bannerHandler new Handler(Looper.getMainLooper()); private final Runnable bannerTask new Runnable() { Override public void run() { if (bannerViewPager ! null) { int next bannerViewPager.getCurrentItem() 1; bannerViewPager.setCurrentItem(next, true); bannerHandler.postDelayed(this, 3000); } } }; Override public void onResume() { super.onResume(); bannerHandler.postDelayed(bannerTask, 3000); } Override public void onPause() { super.onPause(); bannerHandler.removeCallbacks(bannerTask); }延时参数 3000 毫秒是一个行业默认值太短显得急躁太长用户会觉得卡。改这个参数时注意onPause里的removeCallbacks必须和postDelayed成对出现否则 Fragment 切到后台后 Handler 还在跑切回来时会出现两个轮播任务同时执行画面跳帧非常明显。3.2 商品列表GridLayoutManager 与 item 卡片商品列表用 RecyclerView 加 GridLayoutManager 是最常见的做法。双列网格的适配器写法和普通列表几乎一样空手道区别只在 LayoutManager 的参数上。RecyclerView recyclerView view.findViewById(R.id.home_rv); GridLayoutManager layoutManager new GridLayoutManager(getContext(), 2); recyclerView.setLayoutManager(layoutManager); ProductAdapter adapter new ProductAdapter(productList); recyclerView.setAdapter(adapter);商品 item 的布局我用 CardView 包一层里面放商品图、商品名、价格文字。CardView 自带圆角和阴影比单独写 shape drawable 省事而且视觉上更接近真实电商应用。注意 item 根布局的宽度必须写成match_parent因为 GridLayoutManager 会自动把每个 item 分配到一半宽度如果写成 wrap_content双列会挤成两列但中间间距忽大忽小。androidx.cardview.widget.CardView xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_margin6dp app:cardCornerRadius8dp app:cardElevation2dp LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical ImageView android:idid/iv_product android:layout_widthmatch_parent android:layout_height120dp android:scaleTypecenterCrop / TextView android:idid/tv_name android:layout_widthmatch_parent android:layout_heightwrap_content android:padding8dp android:textSize14sp android:textColor#333333 android:maxLines1 android:ellipsizeend / TextView android:idid/tv_price android:layout_widthwrap_content android:layout_heightwrap_content android:paddingLeft8dp android:paddingBottom8dp android:textSize16sp android:textColor#FF4444 android:textStylebold / /LinearLayout /androidx.cardview.widget.CardViewCardView 的默认阴影在小屏手机上会显得很重cardElevation建议控制在 2dp 到 4dp 之间不要超过 6dp。商品图高度我固定成 120dp 而不是用比例因为双列卡片宽度是动态的如果图片高度也动态会导致同一行两张卡片高度不一致整体非常乱。要做出瀑布流效果就得换 StaggeredGridLayoutManager但它有个经典问题——item 高度不一致时滚到底部再回滚会出现位置漂移。一个简单界面项目不值得为这点视觉效果惹上这个麻烦。3.3 假数据与价格格式化的细节首页商品数据用内存里的 List 填充不要直接写死在 Adapter 里。定义一个 Product 类后面详情页和购物车都要用它一次定义、到处复用。public class Product implements Serializable { private String name; private double price; private int imageRes; public Product(String name, double price, int imageRes) { this.name name; this.price price; this.imageRes imageRes; } public String getName() { return name; } public double getPrice() { return price; } public int getImageRes() { return imageRes; } }实现 Serializable 是因为详情页跳转时要通过 Intent 把这个对象传过去。如果以后数据字段变多建议换成 Parcelable性能更好但代码量翻倍。界面项目用 Serializable 够了。在 HomeFragment 里造一批数据图片直接用 R.mipmap 里的自带图或者自己放几张图到 drawableListProduct products new ArrayList(); products.add(new Product(无线蓝牙耳机, 199.0, R.drawable.img_headset)); products.add(new Product(运动手环, 149.0, R.drawable.img_band)); products.add(new Product(便携充电宝, 89.0, R.drawable.img_power_bank));价格格式化和数字本身有个隐藏坑直接用String.format(%.2f, price)在某些语言环境下会输出199.00没问题但遇到 199.0 这种值时有概率格式化成199.00后人民币符号前面多一个空格。保险写法是指定 Locale而且价格计算在购物车总价汇总时还会遇到浮点精度问题。这里给一个我自己一直沿用的写法NumberFormat format NumberFormat.getCurrencyInstance(Locale.CHINA); holder.tvPrice.setText(format.format(product.getPrice()));这样输出的是199.00符号和数字之间不会有空格也不会因为 double 的浮点误差出现199.0000000001。你现在觉得这个函数多余到购物车里做加法时就知道后悔药在哪买了。4. 详情页与购物车跨界面传状态的三步走4.1 详情页跳转Serializable 传参点击首页商品卡片跳到一个独立的 DetailActivity这是给源码加分的第二步。跳转用的是 Intent 加 Serializable 传参Product 类已经实现了 Serializable所以代码很直接。// ProductAdapter 里设置点击事件 holder.itemView.setOnClickListener(v - { if (listener ! null) { listener.onItemClick(product); } });在 HomeFragment 里设置这个监听器productAdapter.setOnItemClickListener(product - { Intent intent new Intent(getContext(), DetailActivity.class); intent.putExtra(product, product); startActivity(intent); });DetailActivity 里取数据时注意一定要判断 Intent 是否为 null否则在“从桌面图标冷启动直接进入详情页”这种场景下会闪退Product product (Product) getIntent().getSerializableExtra(product); if (product null) { finish(); return; }详情页布局不需要太复杂一张大图、一个商品名、一个价格、一个“加入购物车”按钮就足够撑起一个完整的页面流。DetailActivity 的布局建议直接拷一份商品 item 的布局改大尺寸看起来不费力逻辑上也连贯。4.2 购物车数据单例 CartManager 就够了购物车是全项目里唯一需要跨页面共享数据的地方。首页加购购物车页显示这两个页面没有直接引用关系中间也没有 Activity 做中转最合适的方案是单例。public class CartManager { private static CartManager instance; private final ListProduct cartList new ArrayList(); private CartManager() {} public static synchronized CartManager getInstance() { if (instance null) { instance new CartManager(); } return instance; } public void add(Product product) { for (Product item : cartList) { if (item.getName().equals(product.getName())) { item.setCount(item.getCount() 1); return; } } Product copy new Product(product.getName(), product.getPrice(), product.getImageRes()); copy.setCount(1); cartList.add(copy); } public ListProduct getCartList() { return cartList; } public void remove(Product product) { cartList.remove(product); } public double getTotalPrice() { double total 0; for (Product item : cartList) { total item.getPrice() * item.getCount(); } return total; } }解释一下为什么add里要new Product复制一份而不是直接存原对象首页商品列表里的 Product 对象是列表的成员如果购物车直接持有它并修改 count首页同一个对象的数量也被改了。两个界面共享同一份可变数据排查问题时非常难定位界面项目的底线就是“页面间不要共享可变引用”。用getName()做去重是一个粗糙但够用的方案因为没有商品 ID 字段。如果你照着这个源码继续扩展第一件事应该是给 Product 加上 id并把去重逻辑改成 id 判断。这算是我在这个项目上为数不多的血泪经验。单例方案也有边界应用进程被杀后购物车数据清空重启应用购物车是空的。对一个演示型界面项目这不算问题。如果以后要持久化优先用 Room 或 DataStore不要在单例里做文件读写那个复杂度完全不在一个量级。4.3 数量加减与总价刷新购物车页面的布局就是一个 RecyclerView 加一个底部结算栏。item 里有数量加减按钮、商品名、单价和单行小计。数量加减是购物车最容易写出糟糕体验的地方——大多数人直接用notifyDataSetChanged()刷新整个列表结果每次点击加减整屏列表都闪一下滚动位置还可能丢。标准做法是只更新当前 item 里的 TextView。holder.btnMinus.setOnClickListener(v - { Product item cartList.get(holder.getBindingAdapterPosition()); int count item.getCount(); if (count 1) { return; // 数量下限是1不处理删除逻辑删除交给单独按钮 } item.setCount(count - 1); holder.tvCount.setText(String.valueOf(item.getCount())); holder.tvSubTotal.setText(formatPrice(item.getPrice() * item.getCount())); updateTotalPrice(); }); holder.btnPlus.setOnClickListener(v - { Product item cartList.get(holder.getBindingAdapterPosition()); int count item.getCount(); item.setCount(count 1); holder.tvCount.setText(String.valueOf(item.getCount())); holder.tvSubTotal.setText(formatPrice(item.getPrice() * item.getCount())); updateTotalPrice(); });这里用holder.getBindingAdapterPosition()而不是getAdapterPosition()。老方法在 notify 动画期间可能返回RecyclerView.NO_POSITION导致取到空对象闪退。新方法是 API 24 之后加入的适配器位置和布局位置分离动画执行中也安全。这个细节很多老教程里还是旧的照着写必踩坑。总价更新可以抽取成一个方法在updateTotalPrice()里直接遍历 CartManager 重新计算。因为购物车 item 数量一般不超过 20 个性能没有压力不要为了优化去做增量计算纯属给自己添乱。private void updateTotalPrice() { double total CartManager.getInstance().getTotalPrice(); tvTotal.setText(合计 formatPrice(total)); }5. 商城界面避坑指南五个高频问题的现象与处理办法5.1 商品图片闪烁或加载错乱现象是 RecyclerView 快速滚动时图片会短暂显示成上一行的内容或者滚动回顶部时图片位置对不上。原因是列表 item 被回收复用ImageView 显示的还是上一个 item 的图片新数据还没绑上。本地资源的加载同样会触发这个问题只是概率比网络图低。解决的办法是给 ImageView 设置一个默认占位图并把资源更新放在 onBindViewHolder 里执行holder.ivProduct.setImageResource(R.drawable.img_placeholder); holder.ivProduct.setImageResource(product.getImageRes());先用占位图覆盖旧内容再加载真实图。如果你以后换成 Glideplaceholder()方法做的是同一件事原理还是要懂。不要用holder.setIsRecyclable(false)来绕过复用它会让 RecyclerView 回收机制失效内存直接飙升。5.2 切换 Tab 后 Fragment 状态丢失或布局错乱现象是从首页切到购物车再切回来首页的列表滚动位置回到顶部或者轮播图停住了。原因多半是用了replace切换 Fragment页面视图被销毁重建。另一个可能是在onCreateView里每次重新加载数据即使 Fragment 没有被销毁。解决方式就是第 2 章写的 show/hide 方案。如果已经用了 replace也不用推翻重写只需要在 Fragment 的onSaveInstanceState里保存列表滚动位置在onViewCreated中恢复private int lastScrollPosition; Override public void onSaveInstanceState(NonNull Bundle outState) { super.onSaveInstanceState(outState); if (recyclerView ! null) { LinearLayoutManager lm (LinearLayoutManager) recyclerView.getLayoutManager(); lastScrollPosition lm.findFirstVisibleItemPosition(); } outState.putInt(scroll_position, lastScrollPosition); }这个方案能保住滚动位置但图片重新加载、页面重新渲染的问题依然存在所以根治法还是换回 show/hide。这条算是我带课设时最常说的劝告简单界面项目别用 replace。5.3 首页滚动卡顿与过渡绘制现象是快速滑动商品列表时掉帧一帧渲染超过 16 毫秒肉眼能看到明显迟滞。原因是布局嵌套过深、item 里有多层无必要容器以及自定义 View 的 onDraw 里做了太多计算。排查方法很简单手机上打开“开发者选项 - 显示布局边界”和“GPU 过度绘制”一眼就能看到哪个页面红了一片。商城首页最常见的问题是LinearLayout 三层嵌套加 CardView 再加 ConstraintLayout一层套一层。减少层级比优化代码更有效优先把 item 布局压到两层以内根布局 CardView里面一个垂直 LinearLayout完事。这个做法能解决八成卡顿问题。5.4 沉浸式状态栏导致布局顶栏错位现象是详情页和主界面顶部内容被状态栏遮住或者状态栏是刺眼的黑色。原因是默认主题下状态栏颜色和页面背景不一致代码也没做适配。解决方法是给 MainActivity 和 DetailActivity 设置状态栏颜色注意 API 级别的差异。Android 5.0 到 Android 11 之间的方案不一样最简单的统一做法是用 Material 组件库提供的方法getWindow().setStatusBarColor(getColor(R.color.white)); getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);LIGHT_STATUS_BAR是让状态栏文字变深色配合白色背景才看得清。在 Android 11 以上系统会自动忽略这个 flag但向下兼容时需要它。如果你看到状态栏文字是白色、背景也是白色那就是少了第二个方法。模拟器上正常不代表真机正常这条必须真机验证。5.5 依赖版本不匹配直接编译失败现象是同步 Gradle 时报错错误信息里能看到Attribute applicationicon value冲突或者AndroidX dependencies的不一致。原因是手动加了一个不在 material 库版本约束内的依赖新旧库之间出现资源覆盖。解决方式是不要手动管理版本号。在 app/build.gradle 里定义一个版本目录Version Catalog或者把依赖版本统一放在项目的 gradle/libs.versions.toml 里让所有 androidx 库版本互相对齐。一个最粗暴的土办法是新加依赖之前先去 Google 的 Maven 仓库看它标注的“最低 compileSdk 要求”低于当前 compileSdk 的直接选对应版本高于的就升级 compileSdk。不要在一个项目里同时用 material 1.9.0 和 appcompat 1.3.0 这种跨代组合编译能过运行时大概率在某个特性上崩给你看。这种问题最常见的触发点在“你搜索到一篇三年前的教程照着复制了一个老版本库”。版本冲突没有通用解法最快的处理是全局搜索项目里所有implementation行把版本号有嫌疑的依赖一次性注释掉分批加回来定位到具体哪一行导致编译不过。6. 用 Material 组件和数量动画把界面质感再提一档6.1 一个 MaterialCardView 的细节界面项目的边界很清晰功能不多就是列表、详情、购物车三个页面。既然没有复杂业务逻辑那竞争力就在视觉细节上。把商品 item 的 CardView 换成 MaterialCardView质感会明显提升一个档次com.google.android.material.card.MaterialCardView android:layout_widthmatch_parent android:layout_heightwrap_content app:cardCornerRadius12dp app:cardElevation0dp app:strokeColor#EEEEEE app:strokeWidth1dp我把cardElevation设成 0只保留描边这是很多主流 App 的卡片风格——不用投影用一条浅色描边来区分卡片和背景。阴影在低端机上渲染成本高而且和浅色主题不搭。如果你项目底色是纯白描边方案比投影方案干净得多。这个样式只改一行strokeColor就可以适配夜间模式。6.2 一个数量变更的提示动画购物车数量加减时数字跳变太生硬。这里加一个小动画代码量很少但体验提升明显。给数量 TextView 注册一个缩放动画private void playCountAnimation(View view) { view.animate() .scaleX(1.2f) .scaleY(1.2f) .setDuration(80) .withEndAction(() - view.animate() .scaleX(1.0f) .scaleY(1.0f) .setDuration(80) .start()) .start(); }在加减按钮里调用playCountAnimation(holder.tvCount)每次点击数字会“弹”一下再复位。缩放比例 1.2 是试出来的超过 1.3 会显得廉价低于 1.15 又几乎看不见。动画时长总和 160 毫秒符合 Material Design 的运动曲线节奏不会让人觉得拖沓。这个动画是我自己在项目里留到最后才加的一步也是我最想早做的一件事。界面项目就是这样骨架和逻辑写完只算完成了七成剩下三成都是这种细节。回头看那个早期版本功能全都能跑但总感觉“像没做完”差的就是这些不会被写进需求文档的小东西。如果你照着这套代码把界面搭完下一步不是继续加花哨功能而是把 Product 类补上 id 字段把购物车的去重逻辑从“按名称比较”换成“按 id 比较”再把假数据换成真实的接口返回。到那时候你会发现因为最初骨架用的是 Fragment 加 ViewModel 加 RecyclerView 的标准结构接后端只是改数据来源界面层完全不用动。这也是我做这个项目最想让你带走的习惯一个简单界面项目也值得按正式项目的边界来搭。希望帮到你。本文还有配套的精品资源点击获取