简介:面向Android初学者的购物商城界面源码,基于Android Studio与ListView组件搭建,解决了从零构建商品列表展示页面的常见需求,适合课程设计、期末项目或入门练手,整体代码结构简单直观,便于初学者快速上手与二次修改。资源包共五百一十八个文件,约九点零五兆字节,其中布局文件、程序源码、构建脚本、配置信息与图片素材等类型齐全,压缩包结构清晰,便于直接导入开发环境或按需查阅。目前已有一万三千五百四十二人学习下载。通过研读项目,可掌握ListView的基本工作原理、自定义BaseAdapter时getCount、getItem、getItemId、getView四个核心方法的重写,以及利用OnScrollListener实现上滑下滑加载更多商品的交互逻辑;这些知识点可进一步迁移到下拉刷新、异步加载图片等进阶功能中,帮助初学者快速积累安卓界面开发的实战经验。
1. 用 Android Studio 搭购物商城界面:为什么 ListView 仍是初学者的最佳选择
在 Android Studio 里做购物商城界面,很多人的第一反应是直接用 RecyclerView。但我拆过几份教学项目后发现,ListView 反而是最适合起步的那一个:它对 Adapter 的要求更直白,每个回调方法都能对应到一个实际 UI 行为。本文要拆的这份购物商城界面源码,就是基于 ListView + BaseAdapter + 自定义 item 布局的经典组合,适合刚学完四大组件、想拿一个完整项目练手的初学者。整份资源能解决一个问题:让你看明白商品列表数据是怎么一步步变成屏幕上的商品卡片。需要提醒的是,这套源码侧重界面与交互逻辑,不含真实的支付、登录和后台接口。
2. 把架子搭起来:工程结构、布局文件和 ListView 的三个关键属性
2.1 新建工程时该选哪个模板,以及工程里需要留意的四个文件
用 Android Studio 新建项目时,常见做法是选 Empty Activity 模板,因为自带一个MainActivity 和一份可运行的 manifest,省掉手工建目录的麻烦。这里有个容易被新手忽略的点:模板生成后会附带一个content_main.xml或activity_main.xml,不同 Studio 版本生成的布局结构不一样,但最终我们只需要在一个主布局文件里操作。
这份源码里,你需要关心的文件我一般会圈定成四个:
| 文件 | 作用 |
|---|---|
activity_main.xml | 主界面布局,放 ListView |
item_product.xml | 商品条目的自定义布局,一个条目对应一件商品 |
ProductAdapter.java | 继承 BaseAdapter 的自定义适配器,负责数据到视图的转换 |
MainActivity.java | 初始化数据、实例化 ListView 和 Adapter 的入口 |
新建项目后,AS 默认会帮你生成一个activity_main.xml,里面可能是一个ConstraintLayout包着一个TextView。我们要做的事情就是把这个默认布局里的TextView删掉,换成ListView。
2.2 写 activity_main.xml:ListView 的尺寸、滚动条和分割线配置
主布局是整个商城界面的地基。一个购物商城的主界面,通常顶部是一个搜索栏或 Banner,下面才是商品列表。但为了聚焦核心知识点,这份源码把主布局简化成了纯 ListView,这样能快速跑通列表逻辑,不被其他 UI 干扰。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <ListView android:id="@+id/list_view" android:layout_width="match_parent" android:layout_height="match_parent" android:divider="#E0E0E0" android:dividerHeight="1dp" android:scrollbars="vertical" android:fadeScrollbars="true" /> </LinearLayout>这一段布局里,divider是列表项之间的分隔线颜色,dividerHeight是分隔线高度。把分隔线设成 1dp 的浅灰色,模拟出电商 App 里常见的细分割线效果。fadeScrollbars设为true表示滚动条在不滚动时自动隐藏,这个细节对视觉观感影响很大,很多新手不知道有这一项,导致右侧一直杵着一根滚动条。
android:scrollbars="vertical"的含义是只显示垂直滚动条。ListView 本身支持垂直滚动,这是它作为列表组件的核心能力之一。这里不需要设置android:stackFromBottom之类的属性,那是聊天界面用的,商品列表应该从头开始排列。
2.3 设计 item_product.xml:商品卡片需要哪些视图元素
每个商品条目能承载的信息量,取决于 item 布局怎么设计。购物商城里一个标准的商品卡片需要三样东西:商品图片、商品名称、商品价格。这是最简配置,也是这个项目的边界——它不做多图轮播,不做 SKU 选择,专注把列表项渲染明白。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="8dp"> <ImageView android:id="@+id/img_product" android:layout_width="80dp" android:layout_height="80dp" android:scaleType="centerCrop" android:contentDescription="@string/product_image" /> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:paddingStart="12dp" android:paddingEnd="8dp"> <TextView android:id="@+id/tv_name" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:textColor="#333333" android:maxLines="2" android:ellipsize="end" /> <TextView android:id="@+id/tv_price" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#FF5722" android:textStyle="bold" android:layout_marginTop="4dp" /> </LinearLayout> </LinearLayout>这里有个布局技巧值得注意:右边名称和价格所在的LinearLayout用了layout_width="0dp"配合layout_weight="1",这样它就会填满 ImageView 右侧剩余空间。如果不用weight而是直接写match_parent,会直接把 ImageView 挤出去,这是新手最容易翻车的地方之一。
maxLines="2"和ellipsize="end"一起使用,保证商品名称超过两行时以省略号结尾,不会撑高条目。实际商城里的商品名长短不一,这两行属性是必须的。
2.4 ListView 的三个关键配置:复用缓存、滚动监听和空视图
在 Java 代码里拿到 ListView 之后,有几个配置项值得养成习惯:
ListView listView = findViewById(R.id.list_view); listView.setEmptyView(findViewById(R.id.tv_empty)); // 数据为空时显示 listView.setDivider(new ColorDrawable(Color.parseColor("#E0E0E0"))); listView.setDividerHeight(1); listView.setSelector(R.drawable.selector_item_bg); // 点击时的背景反馈setEmptyView是个少有人用的方法,它允许你指定一个 TextView 作为空数据提示,比如“暂无商品”。setSelector控制的是条目被按下时的背景色变化,商城列表如果没有点击反馈,用户会怀疑自己有没有点到。这个 selector 可以是一个StateListDrawable,也可以用系统自带的?android:attr/selectableItemBackground。
3. 让数据流动起来:BaseAdapter 的四个重写方法与 getView 的绑定逻辑
3.1 Product 数据模型:图片、名称、价格三个字段怎么定义
写 Adapter 之前,必须先有数据模型。商城里的商品对象,在真实项目中对应后端返回的 JSON 字段,但这里只做界面展示,不接网络,所以一个简单的 Java Bean 就够用。
public class Product { private String imageUrl; // 商品图片地址,本地资源时是 resId 对应的字符串 private String name; // 商品名称 private String price; // 商品价格,保留字符串类型避免精度问题 public Product(String imageUrl, String name, String price) { this.imageUrl = imageUrl; this.name = name; this.price = price; } public String getImageUrl() { return imageUrl; } public String getName() { return name; } public String getPrice() { return price; } }价格字段我用String而不是double,这是刻意为之。电商场景里价格需要精确展示“¥99.00”这种格式,如果用浮点数参与运算,容易出现精度误差。在这个项目里价格只做展示不参与计算,所以字符串最稳妥。
3.2 BaseAdapter 的 getCount、getItem、getItemId 三个方法不能偷懒
BaseAdapter 是一个抽象类,四个方法必须全部实现。其中getItemId是最容易被糊弄的方法,新手经常会随手返回position,这在多数场景下没毛病,但如果你之后要用CursorAdapter或者涉及到 ListView 的选中态管理,返回真正的 ID 会更安全。常见做法是把 Product 对象的 hash 或自增 ID 放进去。
public class ProductAdapter extends BaseAdapter { private List<Product> productList; private LayoutInflater inflater; public ProductAdapter(Context context, List<Product> productList) { this.productList = productList; this.inflater = LayoutInflater.from(context); } @Override public int getCount() { return productList == null ? 0 : productList.size(); } @Override public Object getItem(int position) { return productList.get(position); } @Override public long getItemId(int position) { return position; } }getCount返回的是列表条目的总数,ListView 在测量自身高度和计算滚动范围时依赖它。getItem返回当前位置的数据对象,在滚动监听里要用它拿数据。getItemId当前返回 position 够用,但要意识到它的语义是“这条数据的唯一标识”,不是“位置”。
3.3 getView 方法详解:convertView 判空和 ViewHolder 的必要性
getView是整个 Adapter 里最核心的方法,它决定了一个位置的商品数据如何变成一棵视图树。网上关于 ListView 优化的文章几乎都在围绕这个方法做文章,原因在于它会被频繁调用——滑动列表时,每个新出现的条目都要执行一次getView。
@Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; // 关键:如果 convertView 不为 null,说明这个视图曾经被创建过,直接复用 if (convertView == null) { convertView = inflater.inflate(R.layout.item_product, parent, false); holder = new ViewHolder(); holder.imgProduct = convertView.findViewById(R.id.img_product); holder.tvName = convertView.findViewById(R.id.tv_name); holder.tvPrice = convertView.findViewById(R.id.tv_price); convertView.setTag(holder); } else { // 从缓存里取回 ViewHolder,避免重复 findViewById holder = (ViewHolder) convertView.getTag(); } // 拿到当前位置的商品数据 Product product = productList.get(position); // 绑定视图数据 holder.tvName.setText(product.getName()); holder.tvPrice.setText(product.getPrice()); // 图片加载:本地资源直接 setImageResource,网络图片走异步加载 if (product.getImageUrl().startsWith("http")) { // 实际项目用 Glide/Picasso,这里留接口 // Glide.with(context).load(product.getImageUrl()).into(holder.imgProduct); } else { int resId = Integer.parseInt(product.getImageUrl()); holder.imgProduct.setImageResource(resId); } return convertView; } static class ViewHolder { ImageView imgProduct; TextView tvName; TextView tvPrice; }这段代码里最容易被忽视的是inflate方法的第三个参数:parent。传parent的目的是让 LayoutInflater 知道这个视图将来会被放进哪个父容器,从而正确解析layout_width和layout_height。如果传null,条目会丢失布局参数,显示尺寸异常。
ViewHolder 的意义在于避免每次滑动都执行findViewById。这个方法的开销虽然不大,但列表滑动时每秒可能调用几十次,累积起来就是肉眼可见的卡顿。从写 Adapter 的第一天起就养成用 ViewHolder 的习惯,后面写 RecyclerView 时也是同一套思路。
3.4 在 MainActivity 里把 Adapter 挂到 ListView 上
写完了 Adapter,最后一步是在 Activity 里把它们组装起来。这一步的代码量不大,但顺序不能乱:先有数据,再创建 Adapter,最后setAdapter。
public class MainActivity extends AppCompatActivity { private List<Product> productList = new ArrayList<>(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 1. 准备模拟数据 initProductData(); // 2. 创建适配器并设置给 ListView ListView listView = findViewById(R.id.list_view); ProductAdapter adapter = new ProductAdapter(this, productList); listView.setAdapter(adapter); } private void initProductData() { // 模拟 20 条商品数据,图片用本地 drawable 资源 for (int i = 1; i <= 20; i++) { productList.add(new Product( String.valueOf(R.drawable.product_default), "商品名称示例,第 " + i + " 件商品", "¥" + (99 + i) + ".00" )); } } }setAdapter一旦被调用,ListView 就会立刻执行getCount和若干次getView来绘制首屏可见的条目。所以 Adapter 里的productList不能是空的,否则界面上什么都不会显示。这也是新手最容易踩的坑:Activity 里先setAdapter后给列表添加数据,界面空白一片。
4. 从界面到交互:模拟数据、滚动加载和异步图片的接入方式
4.1 模拟数据为什么要用 List 而不是数组
在很多课程里,商品数据被写成一个Product[]数组。数组在编译期长度就固定了,但商城列表的数据量是动态变化的——用户下拉加载更多,或筛选后列表变短,数组都处理不了。所以这里统一用List<Product>。
在真实项目里,这个 List 通常来自网络请求的 JSON 解析,比如用 Retrofit + Gson 把后端返回的商品数组转换成 List。本项目的边界就是不接网络,用本地写死的模拟数据跑通完整流程。这也意味着你拿到源码后,替换数据源的位置十分清晰:只需改initProductData()里的逻辑即可。
4.2 配合 OnScrollListener 实现滚动到底部加载更多
滚动加载是商城列表最基础的交互方式。ListView 的OnScrollListener里有onScrollStateChanged和onScroll两个回调。实现加载更多的常见做法是:在onScroll里判断当前滚动位置是否接近底部,再在onScrollStateChanged里触发加载动作。
listView.setOnScrollListener(new AbsListView.OnScrollListener() { @Override public void onScrollStateChanged(AbsListView view, int scrollState) { // 只有在停止滚动时才加载更多,避免滚动过程中频繁触发 if (scrollState == SCROLL_STATE_IDLE && view.getLastVisiblePosition() >= adapter.getCount() - 2) { loadMoreData(); } } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { // 这里可以记录滚动位置,或者触发预加载 } });getLastVisiblePosition()返回的是当前屏幕最底部那个条目的下标。当它大于等于getCount() - 2时,说明用户已经滚到了倒数第 2 条附近,此时触发loadMoreData()比较合适。SCROLL_STATE_IDLE的判断是为了防止用户快速滑动时触发多次加载。
loadMoreData()里的实现也很固定:往productList里追加新数据,然后调用adapter.notifyDataSetChanged()。这个方法是 Adapter 和 ListView 之间的通信桥梁,不调用它,ListView 不会知道数据变了,界面就停留在旧状态。
4.3 异步加载图片的三条路:Glide、Picasso、手写线程
ListView 里的图片加载是基础也是最容易翻车的点。项目里如果直接用setImageResource加载 drawable 资源,在数据量小的时候没问题,但一旦换成网络图片 URL,就必须引入异步加载方案。
常见的路线有三条:
第一是用 Glide。这是目前最主流的方案,用法一句话,内部管理了缓存、线程池和生命周期。
Glide.with(context) .load("https://example.com/product/001.jpg") .placeholder(R.drawable.placeholder) .error(R.drawable.error) .into(holder.imgProduct);第二是用 Picasso。它和 Glide 类似,但缓存策略和 GIF 支持有差异。如果项目里只需要静态图,Picasso 更轻量。注意 Glide 和 Picasso 对图片 URL 中的重定向处理不同,同一个 URL 在这两个库里的表现可能不一样,选定一个就不要中途混用。
第三是自己写一个简单的异步加载工具类。核心思路是:子线程下载图片 → 通过 Handler 切回主线程 → 设置到 ImageView。但这里面有两个绕不开的坑:一是 ImageView 复用时图片错位,二是缓存管理要自己实现。在没有足够经验之前,不建议手写图片加载,直接用 Glide 是这个项目里最稳的选择。
5. 常见问题排查:ListView 开发中五个绕不开的坑
5.1 图片错位:列表快速滑动后条目显示成别的商品图
现象:快速上下滑动 ListView,屏幕上出现明显的图片闪烁,甚至某一行的图片变成了另一行的商品图。
原因:ListView 的条目视图是复用的。当某个 ImageView 被复用去显示新数据时,新图片还在异步加载中,此时 ImageView 上残留的是上一个商品的旧图片。用户看到的就是错位。
解决:用 Glide 时调用.override()指定加载尺寸,同时让加载完成前显示占位图。更彻底的方案是在图片加载前做一次 URL 比对,但 Glide 内部已经处理了大部分情况;手写异步加载时,必须在回调里检查 ImageView 的 Tag 是否还指向当前商品。
5.2 item 点击没反应:Selector 被 TextView 挡住
现象:点击条目时没有任何视觉反馈,setOnItemClickListener里的回调不执行。
原因:item 布局里的子控件,尤其是 TextView,默认是“可点击”的,会拦截点击事件。在一些 ROM 上,onItemClick会被吞掉。
解决:布局根LinearLayout上设置android:descendantFocusability="blocksDescendant",或者给 ListView 设置android:listSelector="@drawable/selector_item_bg"。另外检查 item 布局里的控件是否设置了clickable或focusable属性,只要有这些属性就可能在表象上“吃掉”点击事件。
5.3 ScrollView 嵌套 ListView 高度异常
现象:把 ListView 放在 ScrollView 里,结果 ListView 只显示了一行,其余条目都看不见。
原因:ScrollView 没有限制子 View 的高度,ListView 测量高度时只算了首屏的高度。这是一个经典的老问题,本质上是两个可滚动组件嵌套时的高度测量冲突。
解决:常见做法是给 ListView 设置android:nestedScrollingEnabled="true",或者重写onMeasure让它一次性把所有条目都测量出来。但更推荐的做法是从架构上避免这种嵌套——商城首页用 NestedScrollView 或直接改用 RecyclerView 配合 NestedScrolling 机制。
5.4 数据更新后界面没刷新:忘了调用 notifyDataSetChanged
现象:执行了productList.add(...)后,界面上没有任何变化。
原因:Adapter 和 ListView 之间没有自动同步机制。修改了数据源后,必须手动通知 Adapter,ListView 才会重新执行getCount和getView。
解决:每次修改productList后,立刻补上adapter.notifyDataSetChanged()。注意productList必须和 Adapter 构造函数里传的是同一个对象引用,如果你在 Activity 里重新 new 了一个ArrayList再塞给 Adapter,那 Adapter 里持有的还是旧引用。
5.5 布局加载太慢导致滑动卡顿
现象:列表能正常显示,但快速滑动时明显掉帧,FPS 掉到 40 以下。
原因:大概率是在getView里做了耗时操作,比如直接加载本地大图、执行复杂的字符串拼接,或者没使用 ViewHolder 导致频繁findViewById。
解决:把findViewById移到 ViewHolder 里,用convertView.setTag()缓存;图片统一走 Glide 并指定缩略图尺寸;不要在getView里做任何 IO 操作或复杂计算。还有一个细节:避免在getView里创建匿名内部类,每次new OnClickListener()都会增加内存分配频率。
6. 让列表跑得更顺:ViewHolder 复用机制与局部刷新技巧
ListView 的性能优化,本质上就一句话:尽量少创建新对象,尽量少执行耗时代码。ViewHolder 解决的是“少创建 View”的问题,但还有一处细节容易被忽略——notifyDataSetChanged()是全量刷新,会导致当前可见的所有 item 全部重新执行getView。如果列表有 20 个可见条目,那一次数据更新就会触发 20 次视图重建。
更好的做法是局部刷新。ListView 提供了getChildAt(index)方法拿到当前可见的某个 item 视图,再通过getTag()取出 ViewHolder 直接改数据。
private void updateItemAt(int position, Product newProduct) { // 计算目标条目在屏幕上的相对位置 int firstVisible = listView.getFirstVisiblePosition(); int lastVisible = listView.getLastVisiblePosition(); if (position >= firstVisible && position <= lastVisible) { // 条目在可见范围内,直接复用现有视图 View itemView = listView.getChildAt(position - firstVisible); ViewHolder holder = (ViewHolder) itemView.getTag(); holder.tvName.setText(newProduct.getName()); holder.tvPrice.setText(newProduct.getPrice()); } else { // 条目在屏幕外,更新数据源即可,下次滚到时自然生效 productList.set(position, newProduct); } }这个技巧在购物车修改数量、商品收藏状态切换等场景下非常有用。它可以避免因为一个条目的变化,导致整个列表重新绘制。需要注意的一点是:getChildAt的索引是相对屏幕的,不是数据下标,所以必须用position - firstVisible做换算。如果目标位置在屏幕之外,直接改数据源就行,因为 ListView 滚动到那里时会自动调用getView拉取最新数据。
从那以后,我每次写完一个 ListView 项目,都会强制自己走一遍这个检查清单:getView里有没有做耗时操作,convertView是否真的复用了,数据更新后有没有调用刷新方法,图片加载有没有指定占位图。这四个问题如果都处理干净,这个列表的性能和稳定性基本就有了保障。这份购物商城界面源码的价值在于把 Adapter 模式完整地走了一遍,你把它跑通之后,再上手 RecyclerView 时会发现套路几乎一模一样——替换掉 ListView 的对应 API,就是一份全新的知识点。希望帮到你。
本文还有配套的精品资源,点击获取