简介:Android Studio购物商城界面源码包,专为Android初学者与需要快速搭建商品列表场景的开发者设计。资源围绕ListView与BaseAdapter展开,完整演示从布局编写、自定义item_product.xml到ProductAdapter四个核心方法重写,再到Activity中绑定Adapter的整套流程,并包含滚动监听与无限加载思路,适合作为课程设计或练手项目参考。压缩包共518个文件,大小约9.05MB,以xml布局与配置、json数据、flat资源及png/jpg图片为主,另含少量java源码与gradle构建文件,解压后可直接导入Android Studio查看。资源已有13538人学习下载,说明其在入门UI与数据绑定方面具有较高参考价值。通过阅读代码注释与项目结构,可快速掌握ListView列表适配、自定义item及简单商城界面的实现技巧,为进一步扩展网络图片加载、下拉刷新等功能打下基础。
1. 为什么购物商城的第一个实战项目都绕不开 ListView
现在打开任何一份商城教程,开篇基本都是 RecyclerView。但把 Android Studio 里的商城界面拆到底层,ListView 反而更适合作为第一个完整实战项目:它的 Adapter 机制把数据和视图的绑定逻辑集中在一个类里,四个抽象方法的职责边界比 RecyclerView 的 RecyclerView.Adapter 更直观;滚动监听、条目复用这些性能关键点在 ListView 上是「必须手动处理」的,处理过一次,后面再看 RecyclerView 的 LayoutManager 和 DiffUtil 立刻有参照物。这篇用一份完整可运行的购物商城界面源码,把 layout 拆分、BaseAdapter 实现、滚动加载到真机调试点串一遍。适合刚学完四大组件的入门者,也适合手头恰好要维护老项目里 ListView 列表页、想把复用机制和边界条件讲清楚的开发。
2. ListView 的界面骨架:layout 拆分与条目控件设计
2.1 activity_main.xml 里 ListView 的摆放与预览参数
新建项目时选 Empty Activity 模板,包名按自己习惯起,minSdk 选 21 就足够覆盖主流设备。activity_main.xml 里不需要任何外围容器,直接让 ListView 占满根布局:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" 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:scrollbars="vertical" android:divider="#EDEDED" android:dividerHeight="1dp" android:listSelector="@android:color/transparent" tools:listitem="@layout/item_product" /> </LinearLayout>几个属性的实际作用值得说清楚。divider控制条目之间分割线颜色,dividerHeight控制分割线高度,这两项配合可以省掉在条目布局里画分割线的冗余代码;listSelector去掉 ListView 默认的橙色高亮块,改成透明后,点击反馈交给条目布局自己控制。tools:listitem只影响 Android Studio 设计视图的预览显示,不会打包进 APK,写上它就能在预览窗口直接看到条目长什么样,而不是面对一个空白列表,对调 UI 效率提升明显。
tools:listitem引用的是@layout/item_product,这个布局还没建,编辑器里会标红,下一步就是把条目布局文件创建出来。
2.2 item_product.xml 条目布局的控件层级与图片尺寸
商品条目至少包含三部分:左侧商品缩略图、右侧商品名称和价格。布局用垂直的 LinearLayout 包一层,内部再放水平排列的子容器,控件层级不要超过三层,层级越深,getView 里 findViewById 的开销越大:
<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="12dp"> <ImageView android:id="@+id/iv_product" android:layout_width="80dp" android:layout_height="80dp" android:scaleType="centerCrop" android:src="@drawable/ic_product_placeholder" /> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginStart="12dp" android:orientation="vertical" android:gravity="center_vertical"> <TextView android:id="@+id/tv_product_name" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#333333" android:maxLines="1" android:ellipsize="end" /> <TextView android:id="@+id/tv_product_price" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="6dp" android:textSize="16sp" android:textColor="#FF3B30" android:textStyle="bold" /> </LinearLayout> </LinearLayout>商品图固定 80dp × 80dp,scaleType用centerCrop,图片会被等比裁剪填满整个区域,不会因为图片来源宽高比不同导致条目高度参差不齐。右边信息区用layout_weight="1"占满剩余宽度,价格写成电商常见的促销红,视觉上信息层级就分开了。
2.3 条目布局里值得留意的属性边界
| 控件 | 关键属性 | 作用 | 后续替换方案 |
|---|---|---|---|
| ImageView | scaleType="centerCrop" | 图片等比裁剪填满固定区域 | 接入 Glide 时占位图可沿用同一布局 |
| 商品名 TextView | maxLines=1 + ellipsize=end | 长名称单行省略,保持条目高度一致 | 想展示两行就改 maxLines=2 |
| 价格 TextView | textColor + textStyle=bold | 强化价格视觉权重 | 可改用 SpannableString 拼接货币符号 |
条目高度由内容决定,商品名限单行、图片固定尺寸,整个列表条目高度一致,ListView 的测量、布局和复用都会更高效。如果商品名不限制行数,不同商品的名称长度不同会导致条目高度抖动,滑动时视觉上会明显不齐整。
3. BaseAdapter 四个方法逐个拆:适配器模式在列表中的落地
3.1 为什么选 BaseAdapter 而不是 ArrayAdapter
ArrayAdapter擅长那种一行 TextView 的简单列表,商城条目是图片、名称、价格三个控件,直接用 ArrayAdapter 还是要重写 getView,不如直接用 BaseAdapter,少一层泛型约束。BaseAdapter 把「数据源如何映射成视图」这件事完全交给开发者,四个抽象方法职责非常清晰:
getCount():告诉 ListView 数据一共有多少条,ListView 据此计算可滚动范围getItem(int position):返回 position 位置对应的数据对象,供外部通过列表位置取数据getItemId(int position):返回这一行的稳定 ID,通常返回 position 索引getView(int position, View convertView, ViewGroup parent):把 position 的数据渲染成一个 View 交给 ListView
先把商品数据类定义出来,字段按布局需要的来,少做多余的封装:
public class Product { private String name; private String price; private int resId; // 商品图占位资源 ID public Product(String name, String price, int resId) { this.name = name; this.price = price; this.resId = resId; } public String getName() { return name; } public String getPrice() { return price; } public int getResId() { return resId; } }3.2 ProductAdapter 完整实现与 convertView 复用
public class ProductAdapter extends BaseAdapter { private final Context context; private final List<Product> data; public ProductAdapter(Context context, List<Product> data) { this.context = context; this.data = data; } @Override public int getCount() { return data == null ? 0 : data.size(); } @Override public Object getItem(int position) { return data == null ? null : data.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView == null) { convertView = LayoutInflater.from(context) .inflate(R.layout.item_product, parent, false); holder = new ViewHolder(); holder.iv = convertView.findViewById(R.id.iv_product); holder.tvName = convertView.findViewById(R.id.tv_product_name); holder.tvPrice = convertView.findViewById(R.id.tv_product_price); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.getTag(); } Product p = data.get(position); holder.iv.setImageResource(p.getResId()); holder.tvName.setText(p.getName()); holder.tvPrice.setText(p.getPrice()); return convertView; } static class ViewHolder { ImageView iv; TextView tvName; TextView tvPrice; } }getView 是整个列表的性能核心。ListView 滚动时会把滑出屏幕的条目 View 传回convertView参数,if 判空分支里才做 inflate 和 findViewById,非空时直接复用并绑定新数据。ViewHolder 把三个控件引用缓存进 Tag,避免每次 getView 都执行三次findViewById,这两层优化叠加后滑动性能差别明显,是 ListView 高性能复用的标准姿势。RecyclerView 后来把 ViewHolder 变成了强制要求,本质是同一套思路。
getItemId在这里返回 position 就够用。只有当数据存在业务上的稳定唯一 ID(比如数据库主键)时才有必要返回它,ListView 内部不会拿这个 ID 做增删或排序,返回 position 是常见做法,不会出错。
3.3 数据绑定时的边界与空指针隐患
getView 里data.get(position)之前,getCount 已经做了 null 判断,正常流程不会越界。真正容易踩的坑是:在 Activity 里先 setAdapter,再往 data 里追加数据,忘记调adapter.notifyDataSetChanged(),结果界面一动不动。另外setImageResource传入的资源 ID 如果对应 drawable 不存在,编译期不报错,运行到这一行才崩,Logcat 里会出现Resources.NotFoundException,看到先检查 drawable 文件名和拼写。
4. MainActivity 装配数据源与上滑加载更多
4.1 模拟数据与 setAdapter 的时序
Activity 里先准备 20 条模拟数据。数据量太少滚不起来,看不到复用效果;太多又没必要,20 条刚好能覆盖一个屏幕加半屏滚动:
public class MainActivity extends AppCompatActivity { private ListView listView; private ProductAdapter adapter; private final List<Product> productList = new ArrayList<>(); private int currentPage = 1; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); listView = findViewById(R.id.list_view); loadData(currentPage); adapter = new ProductAdapter(this, productList); listView.setAdapter(adapter); } private void loadData(int page) { // 模拟分页数据,真实项目这里换成 Retrofit/OkHttp 请求结果 for (int i = 0; i < 20; i++) { productList.add(new Product( "商品 " + page + "-" + i, "¥" + (int) (Math.random() * 100 + 50), R.drawable.ic_product_placeholder )); } } }loadData里生成带页码前缀的商品名,价格用随机数模拟。真实项目里这里是网络接口返回的 JSON 解析结果,但 Adapter 不关心数据来自哪里,它只认List<Product>这个契约。如果需要商品图用网络图片,就把 Product 里的 resId 换成图片 URL 字段,getView 里改用 Glide 加载,这一步替换不影响 Adapter 结构。
4.2 OnScrollListener 实现分页加载
ListView 设置滚动监听,滑到底部时追加一页数据:
listView.setOnScrollListener(new AbsListView.OnScrollListener() { @Override public void onScrollStateChanged(AbsListView view, int scrollState) { // scrollState: 0 停止滚动, 1 手指拖动, 2 惯性滑动 if (scrollState == SCROLL_STATE_IDLE && view.getLastVisiblePosition() >= adapter.getCount() - 1) { currentPage++; loadData(currentPage); adapter.notifyDataSetChanged(); } } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { // 可在这里做列表项曝光埋点 } });判断条件里lastVisiblePosition等于adapter.getCount() - 1,表示最后一个可见条目的索引已经到达数据末尾,说明用户滑到了底部。必须在SCROLL_STATE_IDLE下触发,避免惯性滑动过程中连续加载多次;SCROLL_STATE_IDLE对应手指离开屏幕且滚动停止的瞬间。加上页码字段后,每次加载的 20 条数据不会与前几页重复。这段逻辑就是分页加载的最小原型,很多开源库的上拉加载更多组件的核心判断与此一致,只是外面多包了一层 footer 视图。
4.3 真机运行时的典型问题定位
真机或模拟器跑起来,常见三类问题。第一,商品图不显示但整体不报错,多半是 resId 传成了 0,检查 drawable 资源是否存在。第二,列表打开就闪退,去 Logcat 里看FATAL EXCEPTION,重点找Caused by那一行:Unable to find activity是 Activity 没在 AndroidManifest 里注册;ClassCastException是类型转换写错;NullPointerException在 getView 里出现,八成是 item_product.xml 的控件 ID 和 findViewById 里写的对不上。第三,模拟器上列表滑动掉帧明显,优先检查 getView 里是不是每次都 inflate、有没有在 getView 里做文件读写或者网络请求。顺带一提,Android Studio 里 SDK Platform 没勾选全导致的编译失败,去 SDK Manager 核对当前 compileSdkVersion 对应的平台版本是否已安装即可,不用重装 IDE。
5. 从「能跑」到「像个商城」:三个立即可用的增强技巧
5.1 轮播图直接挂进 ListView 头部
ListView 有一个 RecyclerView 没有的特性——addHeaderView和addFooterView。需要加广告轮播位,可以不改 Adapter,直接把 Banner 的根布局 inflate 后添加为列表头部:
View header = getLayoutInflater() .inflate(R.layout.banner_header, listView, false); // 注意:必须在 setAdapter 之前调用 listView.addHeaderView(header);addHeaderView必须在setAdapter之前调用,否则 ListView 内部视图结构已经固定,头部添加不进去且条目索引错乱。添加之后条目 position 从 1 开始,原来的第 0 个商品变成了第 1 个,点击事件里取数据要记得处理这个偏移。这是老项目里改商城首页时很常见的隐藏坑,理解了 header 机制就能一眼看穿。
5.2 点击条目跳详情页并传参
listView.setOnItemClickListener((parent, view, position, id) -> { // 如果加了 header,position 需要减 1 才能取到正确商品 Product p = productList.get(position); Intent intent = new Intent(MainActivity.this, ProductDetailActivity.class); intent.putExtra("product_name", p.getName()); intent.putExtra("product_price", p.getPrice()); startActivity(intent); });有 header 时实际商品索引是position - 1,没 header 才是直接取 position。传参用 Intent 的基本类型即可,商品对象实现 Serializable 或 Parcelable 属于后续优化项,商城首页这种轻量跳转先传基本类型最稳妥。
5.3 notifyDataSetChanged 的刷新粒度
一次加载 20 条数据后整体 notifyDataSetChanged 没问题,但如果做「收藏」「加购」这类单条状态变更,建议先修改对应 Product 对象的字段再调用 notifyDataSetChanged,几百条数据内完全够用,不会有性能问题。不要试图用局部刷新绕过整体刷新,ListView 的局部刷新手段有限,刻意优化反而容易引起条目闪烁和 index 错位。数据量真正过千以后再考虑切换 RecyclerView + DiffUtil,那一步的迁移成本是另一个话题。
至此,列表展示、条目复用、分页加载、点击跳转全部串通,这套代码在此基础上叠加下拉刷新、Glide 图片加载、多类型条目,每个扩展点都有明确对应的修改位置,购物商城界面的基本盘算是立住了。
本文还有配套的精品资源,点击获取