简介面向Android初学者的购物商城界面源码工程基于Android Studio与ListView组件开发完整演示了商品列表的构建链路从activity_main.xml中添加ListView、编写item_product自定义条目到创建ProductAdapter并重写getCount、getItem、getItemId、getView四个核心方法覆盖Android UI设计、数据绑定与适配器模式等关键知识点。压缩包共518个文件整体约9.05MB包含xml布局与配置、java/class源码、gradle构建脚本、png/jpg商品图片素材以及可直接安装的apk包目录结构清晰适合按模块阅读和运行调试能够完整支撑从界面到逻辑的对照学习。目前已有13540人学习下载在Android入门类资源中热度较高。透过源码可掌握ListView列表数据绑定、多条目视图复用、滚动监听与模拟数据加载等实用技巧也可继续扩展下拉刷新、异步加载图片等功能既适合初学者系统练习也能作为移动应用课程设计的参考实现。1. 一个购物商城界面为什么值得从源码做起很多人会觉得“Android Studio实现简单的购物商城界面”是个入门练手项目不值得花心思。但真做过的人知道把一个商城主页做到“能看、能点、不闪退、不卡顿”背后是 XML 布局、资源管理、Fragment 生命周期、RecyclerView 复用机制和图片加载这几座大山的综合体。这个标题的价值就在于它逼着你把 Android 界面开发里最高频、最容易翻车的部分用一个完整的场景串起来。本文我会用 Android Studio 从零搭一个可运行的购物商城界面把底部导航、首页轮播、双列商品流、数据模拟这几块的源码思路和参数取舍讲透让你拿到就能改、改完就能跑。2. 工程骨架先行Gradle、清单与主题先把界面运行的底座搭对2.1 用 Empty Activity 起步还是用第三方模板我见过不少同学为了省事直接去下载网上那种“商城完整源码”结果打开项目光同步依赖就花半天跑起来还报一堆版本冲突。其实做购物商城界面起步越干净越好。Android Studio 新建项目时选Empty Views Activity比Empty Activity更合适因为后者默认带的是 Compose 模板而我们这里要控制的是传统 XML 界面。选 Java 还是 Kotlin 都可以但如果是新学我建议直接 Kotlin后面接业务逻辑的时候代码量能少三分之一。项目名建议叫MallDemo包名用com.example.mall。这里有个小细节包名里的example后面要改成自己的域名反写不然以后要上架或接第三方 SDK 时改包名是件非常痛苦的事血泪经验。创建完工程后第一时间确认三个文件存在且正常app/build.gradle、AndroidManifest.xml、res/layout/activity_main.xml。这三样是界面的地基后面所有代码都围绕它们展开。2.2 三个必须提前改的 Gradle 配置打开app/build.gradle默认配置能用但要做界面开发有三个地方必须改。第一个是viewBinding开启它之后就不用写一堆findViewById界面代码会干净很多。第二个是依赖版本把compileSdk和targetSdk保持一致避免出现“编译能用、安装到新手机闪退”的情况。第三个是添加图片加载库商城界面离不开网络图或本地图Glide 是目前最稳的选择。android { namespace com.example.mall compileSdk 34 defaultConfig { applicationId com.example.mall minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } buildFeatures { viewBinding true } } dependencies { implementation androidx.core:core-ktx:1.12.0 implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.11.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 implementation androidx.recyclerview:recyclerview:1.3.2 implementation androidx.viewpager2:viewpager2:1.0.0 implementation com.github.bumptech.glide:glide:4.16.0 }这段配置里viewBinding true是界面开发的关键开关开启后会自动生成与 XML 文件名对应的绑定类比如activity_main.xml会生成ActivityMainBinding。minSdk 24意味着最低支持 Android 7.0商城类项目一般不需要再往下兼容了能省掉很多系统适配的麻烦。Glide 的4.16.0是目前比较稳定的版本不要随手写个最新版不然可能遇到依赖冲突。改完 Gradle 后记得点一下Sync Now。如果同步失败先看错误提示里有没有requires xxx version之类的描述基本能定位到某个库版本不兼容手动降到提示的版本就行。2.3 主题与状态栏商城的“第一屏观感”从这里定商城主页能不能留住用户第一眼是状态栏和标题栏的颜色。默认新建项目的主题是白底黑字谈不上难看但离商城的质感差很远。我一般会定义一个浅色主题把状态栏和导航栏的颜色统一成品牌色。新建res/values/themes.xml内容改成这样resources xmlns:toolshttp://schemas.android.com/tools style nameTheme.MallDemo parentTheme.MaterialComponents.DayNight.NoActionBar item namecolorPrimary#FF4D4F/item item namecolorPrimaryVariant#D9363E/item item namecolorOnPrimary#FFFFFF/item item nameandroid:statusBarColor#FFFFFF/item item nameandroid:windowLightStatusBartrue/item item nameandroid:navigationBarColor#FFFFFF/item /style /resourcesparent选Theme.MaterialComponents.DayNight.NoActionBar是为了去掉自带 ActionBar商城的标题栏一般是用自定义布局做这样更灵活。windowLightStatusBar为true时状态栏图标会变深色配合白色状态栏背景可读性更高。这里用的是接近电商“淘宝红”的色值你也可以换成自己品牌的色号改colorPrimary一处就行。还有一个小技巧商城首页的搜索框很多是顶在状态栏下面的如果你的布局想实现“沉浸式效果”可以在MainActivity里加两行代码WindowCompat.setDecorFitsSystemWindows(window, false)开启后布局会延伸到状态栏后面需要自己在布局里加android:fitsSystemWindowstrue来道让内容避开系统栏。这个属于界面细节新手可以先不开等布局稳定了再折腾。3. 底部导航与 Fragment 容器页面切换的两种写法与我的取舍3.1 底部导航栏的基本实现购物商城界面通常有四个页面首页、分类、购物车、我的。底部导航用 Material 组件库里的BottomNavigationView实现比自定义要稳得多。先创建res/menu/bottom_nav_menu.xml?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_profile android:icondrawable/ic_profile android:title我的 / /menu图标资源需要放在res/drawable目录下可以用 Vector Asset 生成右键res/drawable→New→Vector Asset选系统自带的图标就行不需要自己画。这里有个容易踩的坑矢量图标的fillColor默认是黑色放进BottomNavigationView后会跟着itemIconTint走如果你不改itemIconTint选中的状态就区分不出来。接着在布局文件里把底部导航和 Fragment 容器搭起来。注意这里不推荐用两个LinearLayout把各自包起来而是用ConstraintLayout做根布局约束关系更清晰?xml version1.0 encodingutf-8? androidx.constraintlayout.widget.ConstraintLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightmatch_parent FrameLayout android:idid/fragmentContainer android:layout_width0dp android:layout_height0dp app:layout_constraintTop_toTopOfparent app:layout_constraintBottom_toTopOfid/bottomNav app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent / com.google.android.material.bottomnavigation.BottomNavigationView android:idid/bottomNav android:layout_width0dp android:layout_heightwrap_content app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent app:menumenu/bottom_nav_menu app:itemIconTintcolor/nav_item_color app:itemTextColorcolor/nav_item_color / /androidx.constraintlayout.widget.ConstraintLayoutFrameLayout作为 Fragment 的容器是通用做法因为它没有复杂的测量规则切换 Fragment 时开销小。itemIconTint和itemTextColor指向同一个颜色选择器这个选择器要在res/color/nav_item_color.xml里定义选中和未选中两套颜色否则底部导航的颜色变化会不可控。3.2 用 show/hide 方式管理 Fragment防重建的实用写法页面切换有两种主流方案一种是FragmentTransaction.replace每次切换都销毁旧 Fragment、创建新 Fragment另一种是show/hide所有 Fragment 只创建一次之后只做显示和隐藏。对于商城这类需要保持页面状态的场景我倾向于用show/hide。原因很直接用户从首页滑到一半切去购物车再切回来如果页面重建了滚动位置就丢了还得重新加载图片体验很差。具体实现上可以写一个简单的封装在MainActivity里管理四个 Fragmentclass MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private val homeFragment HomeFragment() private val categoryFragment CategoryFragment() private val cartFragment CartFragment() private val profileFragment ProfileFragment() private var currentFragment: Fragment homeFragment override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) supportFragmentManager.beginTransaction() .add(R.id.fragmentContainer, homeFragment, home) .add(R.id.fragmentContainer, categoryFragment, category) .add(R.id.fragmentContainer, cartFragment, cart) .add(R.id.fragmentContainer, profileFragment, profile) .hide(categoryFragment) .hide(cartFragment) .hide(profileFragment) .commit() binding.bottomNav.setOnItemSelectedListener { item - when (item.itemId) { R.id.nav_home - switchFragment(homeFragment) R.id.nav_category - switchFragment(categoryFragment) R.id.nav_cart - switchFragment(cartFragment) R.id.nav_profile - switchFragment(profileFragment) } true } } private fun switchFragment(target: Fragment) { if (target currentFragment) return supportFragmentManager.beginTransaction() .hide(currentFragment) .show(target) .commit() currentFragment target } }这里有个细节四个 Fragment 在onCreate里一次性全部add进去然后隐藏三个。好处是后面切换时完全没有创建耗时但代价是应用启动时会同时执行四个 Fragment 的初始化逻辑如果里面都有网络请求就会造成流量浪费。所以在写 Fragment 时懒加载是一个重要习惯只在onResume里根据“是否第一次显示”来决定要不要拉数据。3.3 分类页和购物车页先做静态占位别急着填功能很多人在做商城界面时容易犯一个错一上来就想着把购物车的增删改查全做了结果界面没做完心态先崩。建议前两版里分类页、购物车页、我的页先用静态占位布局只保证页面切换不崩、样式统一把精力集中在首页商品流上。占位页用一个TextView居中显示页面名称就好等首页稳定了再逐个填充。购物车可以做个显示角标的小功能不复杂但对“像商城”有立竿见影的效果。底部导航菜单在 XML 里给购物车 item 加app:badgeText3就能显示角标。如果要动态更新可以这样写val badge binding.bottomNav.getOrCreateBadge(R.id.nav_cart) badge.isVisible true badge.number cartCountgetOrCreateBadge是 Material 库提供的接口number超过 99 时会自动显示 99这个细节很符合真实商城的行为。别小看这几行代码它让整个界面从“静态图”变成了“有反馈的页面”用户的观感完全不同。4. 首页内容区轮播图与双列商品流的落地代码4.1 商品数据怎么来先别接后端购物商城的界面开发最怕的就是“等接口”。后端还没好界面就只能干瞪眼。正确的做法是先用本地模拟数据把界面跑通再换接口。我一般在项目里建一个MockData.kt把商品图、名称、价格写死成列表等接口通了只替换数据源就行。data class Product( val name: String, val price: String, val imageUrl: String ) object MockData { val products listOf( Product(无线蓝牙耳机 Pro, 299, https://example.com/headphone.png), Product(智能运动手环, 199, https://example.com/band.png), Product(便携榨汁杯, 89, https://example.com/juicer.png) ) }这里的imageUrl我用了网络图片地址实际开发中图片资源建议先放本地drawable用R.drawable.xxx占位。原因很简单网络图片要等加载加载失败显灰图会干扰你对布局的判断。先用本地图把位置、尺寸调准再换网络图效率高得多。MockData写成object单例是 Kotlin 里管理静态数据的常用方式全局只有一份实例不会产生多余的内存开销。4.2 RecyclerView 双列商品流从布局到适配器商品列表是整个商城界面的核心用RecyclerView加GridLayoutManager实现双列网格是通用做法。先写列表条目布局item_product.xml这里要注意图片和文字的比例关系?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:background#FFFFFF android:padding8dp ImageView android:idid/productImage android:layout_widthmatch_parent android:layout_height0dp android:scaleTypecenterCrop / TextView android:idid/productName android:layout_widthmatch_parent android:layout_heightwrap_content android:maxLines2 android:ellipsizeend android:textSize14sp android:layout_marginTop6dp / TextView android:idid/productPrice android:layout_widthwrap_content android:layout_heightwrap_content android:textColor#FF4D4F android:textSize16sp android:textStylebold android:layout_marginTop4dp / /LinearLayout这里图片高度用0dp是个关键细节。在双列网格里如果图片高度固定不同商品的图比例不同会产生大量空白。0dp配合adjustViewBounds可以让图片按比例撑开但要注意ImageView的宽高比会跟着图片原始比例走导致每行高度不一致。如果想让每张卡片高度统一更稳妥的做法是给图片一个固定的aspectRatio比如用ConstraintLayout的app:layout_constraintDimensionRatio1:1强制图片区域正方形。两种方式各有适用场景商品图一般建议正方形视觉上更整齐。适配器写法如下class ProductAdapter( private val products: ListProduct, private val onClick: (Product) - Unit ) : RecyclerView.AdapterProductAdapter.ProductViewHolder() { class ProductViewHolder(val binding: ItemProductBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder { val binding ItemProductBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ProductViewHolder(binding) } override fun onBindViewHolder(holder: ProductViewHolder, position: Int) { val product products[position] holder.binding.productName.text product.name holder.binding.productPrice.text ¥${product.price} holder.binding.root.setOnClickListener { onClick(product) } } override fun getItemCount(): Int products.size }onCreateViewHolder只负责创建视图onBindViewHolder负责绑数据这是 RecyclerView 复用的核心机制。每次滚动时onBindViewHolder都会被调用所以耗时的操作绝不能放在这里比如从磁盘读大图或者做复杂的字符串拼接。上面代码里价格拼接用字符串模板就可以了不要在这里做任何 IO 操作。在 Fragment 里配置列表binding.recyclerView.layoutManager GridLayoutManager(requireContext(), 2) binding.recyclerView.adapter ProductAdapter(MockData.products) { product - Toast.makeText(requireContext(), 点击了 ${product.name}, Toast.LENGTH_SHORT).show() }GridLayoutManager第一个参数传 2 就是双列。如果想让两列之间有点间距需要自己写ItemDecorationRecyclerView 本身不支持直接设置间距这是新手最容易迷惑的一点。一个简单的间距实现是继承RecyclerView.ItemDecoration在getItemOffsets里根据 position 计算偏移。固定间距的做法是每个 item 四周各4dp这样肉眼看到的就是8dp的列间距视觉均匀。4.3 轮播图塞进列表头部用装饰器实现混合布局购物商城的首页轮播图通常在商品列表的上方而且会跟着列表一起滚动。新手常见的错误是把轮播图放在列表外面再用一个垂直ScrollView包住整个页面。这样做会带来两个致命问题一是ListView或RecyclerView套ScrollView会产生滑动冲突二是所有 item 一次性加载列表长一点就会内存暴涨。正确做法是把轮播图作为商品列表的第一个 item用RecyclerView的addHeaderView思路实现。但RecyclerView原生不支持头部常见方案有两种一种是用ConcatAdapter把两个适配器拼起来另一种是修改适配器的 item 类型。ConcatAdapter的代码更直观val bannerAdapter BannerAdapter(bannerImages) val productAdapter ProductAdapter(MockData.products) { } val concatAdapter ConcatAdapter(bannerAdapter, productAdapter) binding.recyclerView.adapter concatAdapterBannerAdapter内部用ViewPager2实现真正的轮播注意它要占满横向宽度高度建议设置成宽度的一半左右比如屏宽 400dp 时高度 200dp这样轮播图的比例接近 2:1和主流电商风格一致。ViewPager2的自动轮播用Handler.postDelayed实现每 3 秒切换一页切到最后一页后回到第一页循环模式直接设置android:repeatModerestart即可不用自己写取模逻辑。自动轮播的代码放 Fragment 生命周期里要注意释放不然 Fragment 销毁后Handler还在跑轻则内存泄漏重则空指针崩溃。规范写法如下private val handler Handler(Looper.getMainLooper()) private val bannerRunnable object : Runnable { override fun run() { val next if (bannerAdapter.currentItem bannerAdapter.itemCount - 1) { bannerAdapter.currentItem 1 } else { 0 } bannerAdapter.setCurrentItem(next, true) handler.postDelayed(this, 3000) } } override fun onResume() { super.onResume() handler.postDelayed(bannerRunnable, 3000) } override fun onPause() { super.onPause() handler.removeCallbacksAndMessages(null) }removeCallbacksAndMessages(null)会清空该 Handler 所有待执行任务在onPause里调用是安全的不会影响 UI 线程其他消息。轮播的指示器小圆点可以用TabLayoutMediator实现这个类就是为了把指示器和 ViewPager2 绑定而设计的代码量少且稳定。5. 界面开发五大坑现象、原因、解决方案5.1 轮播图和商品列表互相抢滑动页面像卡住了一样现象手指放在轮播图上上下滑动时列表不动或者只有轮播图自己在动体验非常生硬。原因ViewPager2本身处理的是水平滑动垂直方向的触摸事件默认不会传给父级RecyclerView。两者在事件分发上形成了竞争如果不干预列表就感知不到垂直滑动给人“卡界面”的感觉。解决不需要写复杂的事件拦截代码ViewPager2官方就支持嵌套滚动。检查RecyclerView是否设置了android:nestedScrollingEnabledtrue并且ViewPager2所在 item 外部不要包裹会消费触摸事件的布局。如果还冲突就在ViewPager2的根布局上加一行android:overScrollModenever把轮播图越界时的发光效果去掉交互会顺滑很多。这是我在真机上反复对比过的模拟器上表现不明显但低端安卓机上差别很大。5.2 Glide 加载图片失败占位图一直转圈现象商品图加载不出来本地图能显示换成网络图就是一直转圈重试也没用。原因最常见的是网络权限没加AndroidManifest.xml里没有声明INTERNET权限。另一个高发原因是图片 URL 是http://明文协议从 Android 9API 28开始默认禁止明文流量Glide 会直接报错日志里能看到CLEARTEXT communication not permitted。解决先在 manifest 确认权限是否齐全uses-permission android:nameandroid.permission.INTERNET /然后处理明文流量在AndroidManifest.xml的application标签上加一行android:usesCleartextTraffictrue这种做法只适合开发环境上线前还是要换成https://的图片地址否则应用市场审核会有风险。临时调试用明文正式环境切 https这是通用规则。5.3 RecyclerView 条目复用导致内容错乱图片和文字对不上现象列表快速滚动时某几个 item 显示的商品名称和图片不匹配停下来又恢复正常。原因这是 RecyclerView 复用的经典问题。onBindViewHolder只更新了文本和图片但如果图片加载是异步的item 被复用到另一个商品上时前一个商品的图片才加载完成就直接显示在当前 item 上了因为ImageView没有清除旧状态。解决在绑定图片时先清掉旧图再设置新图。Glide 里用override固定尺寸可以加速加载同时保证所有商品图显示尺寸统一Glide.with(holder.itemView.context) .load(product.imageUrl) .placeholder(R.drawable.ic_placeholder) .error(R.drawable.ic_error) .centerCrop() .into(holder.binding.productImage)centerCrop会裁剪图片填满控件规避不同图片比例带来的高度抖动。占位图和错误图一定都要配否则加载失败时显示的是空白区域用户会以为是 bug。5.4 底部导航切换回来页面状态全丢了现象首页商品列表滚到第十几个切到购物车再切回来列表回到了顶部轮播图也重新开始播。原因如果用的是replace方式切换 Fragment每次切换都会销毁页面视图RecyclerView的滚动位置自然保不住。另一个原因是在onCreateView里每次都重新 inflate 布局虽然没有 replace但视图重建了状态一样丢。解决用前面第 3.2 节说的show/hide方式管理 FragmentR布局只 inflate 一次。如果项目里 Fragment 数量多可以考虑用FragmentStateAdapterViewPager2配合offscreenPageLimit做页面的预加载和状态保持viewPager2.offscreenPageLimit 2offscreenPageLimit表示当前页左右各缓存多少页设置成 2 意味着四个页面基本都活着切页时不会触发页面重建。这个参数不是越大越好每多一页就多一份内存占用商城场景 2 是一个平衡值。5.5 深色模式下界面颜色全乱了现象把手机切到深色模式商城主页白底变黑底但文字和图标颜色没跟着变出现浅色文字配深色图片的诡异效果。原因主题继承的是DayNight系列系统切换深色模式后会自动套用夜间主题但布局文件里硬编码了#FFFFFF背景和#333333文字这些颜色不会跟随主题变化。解决在res/values-night/themes.xml里单独定义深色主题下的配色布局中引用颜色资源而不是直接写十六进制。另一个更实用的方案是商城类 app 一般只做浅色模式把主题父类改成不带DayNight的版本并在应用主题里强制关闭深色模式style nameTheme.MallDemo parentTheme.MaterialComponents.Light.NoActionBar item nameandroid:forceDarkAllowedfalse/item /styleforceDarkAllowed设为 false 后系统不会对应用启用力适配深色界面始终保持在浅色设计稿的观感下。这个方案虽然“不先进”但真实商城 app 多数就是这么做的因为深色模式下商品的展示效果很难保证改起来成本高。6. 做完界面之后布局验证与性能收尾技巧界面跑通不算完真正的问题是“在用户手里跑得顺不顺”。我之前吃过一次亏模拟器上一切正常到了一台 4GB 内存的旧手机上列表滑动肉眼可见的掉帧查了半天发现是商品 item 里嵌套了一层多余的LinearLayout导致测量时间翻倍。所以界面做完了一定要用工具验证布局是否有过度绘制、层级是否过深。Android Studio 自带的Layout Inspector是排查布局层级的第一利器。运行项目后点菜单栏Tools → Layout Inspector它会以 3D 视图展示当前界面的所有 View 层级。你要看的核心指标是“树的最大深度”Android 官方建议不超过 10 层超过这个值低端机上的测量和绘制时间会显著上升。常见问题包括ConstraintLayout里嵌套LinearLayout、FrameLayout里再套FrameLayout这种嵌套大多可以合并。布局验证完再用GPU 渲染模式分析做性能体检。在真机开发者选项里开启Profile GPU rendering选On screen as bars然后操作页面 5 分钟。每个柱子的高度代表一帧的渲染耗时如果柱子大面积超过屏幕顶部的绿线说明有帧超时。这时候优先检查是不是有wrap_content的ImageView在滑动时频繁触发重排这类问题比代码逻辑更常见。最后的收尾建议是固定 item 高度。商品列表的item_product.xml里图片区固定为正方形比例文本固定为两行maxLines不要让 item 高度随内容浮动。高度不稳定是列表卡顿的最大原因固定后 RecyclerView 不需要每次滑动都重新测量 item整条列表的渲染开销会明显降下来。这也是为什么我在写 item 布局时强调图片用ConstraintLayout的dimensionRatio而不是让图片自适应比例前者牺牲了一点点布局灵活性换来的却是稳定的滚动性能。界面开发没有太多的玄学大部分问题都能在布局层和数据层找到根因。这个商城界面项目价值不在于它多美观多复杂而在于它把 Android 开发的高频场景全部覆盖了一遍:你把这些坑踩平再做复杂项目会发现基础的布局、复用、主题、适配思维能直接复用。希望这些踩坑记录能帮你在做界面时少交点充电费少熬几个改 bug 的夜。本文还有配套的精品资源点击获取