简介:这是一个面向Android开发者学习与借鉴的高仿茶商城项目,模仿电商购物平台的设计与交互,适合用于课程设计、毕业设计或日常技术练习。资源共包含195个文件、压缩包5.38MB,涵盖27个java源文件、41个xml界面/配置布局、68个class编译产物、35张png图片、5个jar库文件以及可直接安装的MyTea.apk,结构完整,可从源码到安装包对照学习。项目覆盖商品浏览、分类筛选、购物车、订单管理、用户注册登录等电商核心模块,界面层运用Material Design与响应式布局,技术层涉及Activity/Fragment、SQLite数据库、XListView列表、网络访问与图片加载等常用Android技术栈。资源提供可运行的APK与完整工程文件,便于拆解电商类App的架构设计与实现细节;已有240人学习下载,是提升UI能力与上手Android实战的不错参考。
1. 高仿茶商城项目到底在仿什么,以及为什么值得做
做“高仿茶商城”这个 Android 项目,最容易被误解的点是:你以为重点在“仿”,其实就是把别人的界面抄一遍、换个 Logo。但真正有信息量的部分,是“商城”——从商品列表、SKU 选择、购物车、下单支付,到订单状态和物流追踪,这一整套电商链路在移动端上的数据流怎么组织,才是一个 5 年以上 Android 工程师愿意往下看的东西。茶这个品类又特别适合练手,因为它有分类层级(绿茶、红茶、普洱、白茶)、有规格(饼茶/散茶/茶砖,357g/200g/50g)、有产地和年份属性,这几个维度凑在一起,天然能把商品系统的设计逼到“不建模就没法做”的程度。
另一个值得做的原因是,高仿项目是检验你在“别人已经定义好的交互规范”下能不能快速落地的试金石。首页轮播、金刚区、瀑布流、商品详情页的 SKU 选择弹窗、购物车全选/反选、订单倒计时,这些都是电商 App 的高频复用模块。你不需要真的去接一套复杂的微服务后端,但你要有能力用 Mock 数据把整套流程跑通。这篇文章我会按我的做法,从架构选型、数据模型、核心页面实现、购物车与订单、再到性能优化和上架前检查,完整讲一遍。你会拿到可直接抄走的代码,也会看到每个参数为什么这么定。
2. 高仿茶商城的技术选型:单 Activity 架构与数据模型先行
2.1 为什么我选择单 Activity + Jetpack Navigation,而不是多 Activity
高仿项目的坑不在于“做不出页面”,而在于页面之间的参数传递经常传乱。比如从商品列表点进详情,你要带着goodsId和sourceType;从详情点进结算页,又要带skuId、count、couponId。如果用多 Activity + Intent 传参,每个页面都要写常量名,时间一长就开始拼错。我现在的做法是单 Activity + Navigation Component,用 Safe Args 自动生成类型安全的参数类。
在app/build.gradle里要配好:
id 'androidx.navigation.safeargs.kotlin'然后定义导航图res/navigation/nav_graph.xml:
<fragment android:id="@+id/goodsDetailFragment" android:name="com.teashop.ui.detail.GoodsDetailFragment"> <argument android:name="goodsId" app:argType="string" android:defaultValue="" /> <argument android:name="sourceType" app:argType="integer" android:defaultValue="0" /> </fragment>跳转时直接用:
Navigation.findNavController(view) .navigate(R.id.action_goodsListFragment_to_goodsDetailFragment, bundleOf("goodsId" to goods.goodsId, "sourceType" to 1))用 Safe Args 之后,连bundleOf都不需要了,编译期会生成GoodsListFragmentDirections.actionGoodsListToGoodsDetail(goodsId, sourceType),参数写错直接编译失败,而不是运行期黑屏。这块对高仿项目的收益是实打实的——你模仿的参考 App 再复杂,页面的回退栈都是可控的。
2.2 商品模型建模:把茶类商品的属性抽成可扩展字段
商品模型是高仿茶商城最值得花时间的部分。因为茶不是标准化的 3C 产品,同一种普洱熟茶,按原料等级、年份、仓储地就能拆出三四个 SKU。我建议你定义字段时不要写成“只够这次项目用”的形态:
data class Goods( val goodsId: String, val title: String, val subtitle: String, val mainImage: String, val price: Long, // 以分为单位,避免浮点数误差 val categoryPath: List<String>, // 例如 ["普洱茶", "熟茶", "饼茶"] val origin: String, // 产地,如 "云南西双版纳" val year: Int, // 年份 val process: String, // 工艺 val skus: List<Sku> ) data class Sku( val skuId: String, val spec: String, // 规格描述:"357g 饼茶" val stock: Int, val price: Long, val image: String )这里有两个细节。第一,price用Long以分为单位,而不是Double。原因是茶商品经常有“满减”“折扣价”,Double在金额计算中会出现0.1 + 0.2 != 0.3的经典问题,高仿项目如果后面要接真实支付,这个坑会放大。第二,categoryPath用List<String>而不是单个categoryId,是因为茶商城的筛选漏斗通常是“品类 → 工艺 → 规格”,列表会展示多级路径,这样前端渲染不需要再查一次分类表。
2.3 本地数据层:用 Room 缓存商品列表和搜索历史
既然是“高仿”,就不必第一时间做后端接口联调。我一般先用 Repository 模式把数据源抽象出来,一个实现是MockRemoteDataSource(读本地 JSON),另一个是RoomDataSource(做缓存)。这样后面切换成 Retrofit 接口,只改 Repository 实现。
Room 的表设计:
@Entity(tableName = "goods") data class GoodsEntity( @PrimaryKey val goodsId: String, val title: String, val price: Long, val categoryId: String, val lastUpdated: Long )DAO 里加一个按关键词模糊查询的方法,用于搜索页:
@Dao interface GoodsDao { @Query("SELECT * FROM goods WHERE title LIKE '%' || :keyword || '%' " + "OR subtitle LIKE '%' || :keyword || '%' ORDER BY lastUpdated DESC") suspend fun search(keyword: String): List<GoodsEntity> }注意LIKE查询在数据量超过 5000 条时性能会下降。高仿项目一般商品量不大(几百条),所以这个方案足够。如果你在做一个真实的大型茶商城,建议引入 FTS4 全文索引,像:
CREATE VIRTUAL TABLE goods_fts USING fts4(content="goods", title, subtitle)但初期这么做会增加同步成本,我一般等搜索卡了再上。
3. 首页倒计时与商品列表:RecyclerView 的混合布局和加载状态
3.1 首页布局拆解:轮播、金刚区和倒计时秒杀,用 ViewStub 控制首屏成本
高仿电商项目第一家要做的就是首页。参考 App 的首页通常长这样:顶部搜索栏、轮播 Banner、金刚区(限时秒杀、分类入口)、一个横向商品推荐流,再往下才是商品瀑布流。全部堆在同一个 Activity 里会让首帧变慢,所以我会把首页拆成一个HomeFragment,用CoordinatorLayout + AppBarLayout + RecyclerView来做,轮播区通过ViewStub懒加载,只有滚到才 inflate。
首页的关键是 RecyclerView 的多类型 item。我给你一个稳定的写法——用ListItem密封类建模:
sealed class HomeItem { data class Banner(val images: List<String>) : HomeItem() data class Channel(val list: List<ChannelInfo>) : HomeItem() data class FlashSale(val endTime: Long, val goodsList: List<Goods>) : HomeItem() data class NormalGoods(val goods: Goods) : HomeItem() }Adapter 里重写getItemViewType(status: Int) = status,并在onCreateViewHolder根据 type 创建不同的 Holder。倒计时这块不要自己用 Handler 每秒刷新,因为会导致内存泄漏。正确做法是让FlashSaleHolder使用CountDownTimer,并且只在 item 可见时启动,不可见时取消。
3.2 用 DiffUtil 高效刷新商品列表,避免 notifyDataSetChanged
商品列表有个常见痛点:点击筛选后刷新整个列表,用notifyDataSetChanged()会导致图片闪烁和滚动位置丢失。我建议你使用ListAdapter+DiffUtil,只做增量更新:
class GoodsListAdapter : ListAdapter<Goods, GoodsListAdapter.GoodsViewHolder>(GoodsDiffCallback()) { class GoodsDiffCallback : DiffUtil.ItemCallback<Goods>() { override fun areItemsTheSame(oldItem: Goods, newItem: Goods): Boolean = oldItem.goodsId == newItem.goodsId override fun areContentsTheSame(oldItem: Goods, newItem: Goods): Boolean = oldItem == newItem } }areItemsTheSame判断是不是同一条记录,areContentsTheSame判断这条记录的内容有没有变化。这样当你在筛选页勾选了“普洱”和“熟茶”后,列表只更新发生了变化的 item,其他 item 维持原状。配合DiffResult,滚动位置也尽量保持。
3.3 商品封面图片加载:Glide 的占位图、渐变与缓存参数
高仿商城一定会用 Glide,但你要注意“高仿”场景下的参数调优。茶商城的图片特点是:背景多是纯色或浅米色,主体是茶叶和茶具,所以占位图用浅色背景比灰色更自然。
Glide.with(imageView) .load(goods.mainImage) .placeholder(R.drawable.bg_placeholder) .error(R.drawable.bg_error) .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .override(480, 480) .centerCrop() .into(imageView)override(480, 480)是给列表缩略图用的,如果你不指定,Glide 会加载原图,在 1080x1080 的手机上会浪费大量内存。DiskCacheStrategy.RESOURCE表示缓存缩放后的图片,而不是缓存原始网络流,这样再次加载同一个缩略图时可以跳过重新解码。
3.4 商品分类筛选:在内存中做的 FlipFilter 会比 SQL 更快
茶商城首页通常有一排分类标签,比如“全部、绿茶、红茶、普洱、白茶”。这些标签切换时,从 Room 重新查询会产生不必要的 IO 开销。我的做法是首次从 Room 拉全量商品放入内存,后续筛选用 Kotlin 的filter:
val filtered = goodsList.filter { goods -> when (selectedCategory) { ALL -> true else -> goods.categoryPath.contains(selectedCategory) } } adapter.submitList(filtered)只有当用户主动下拉刷新或首次进入时才重新查数据库。这里有一个需要注意的业务点:categoryPath是List<String>,但 Room 默认不能直接存 List,你需要在 TypeConverter 里把它转成 String:
class StringListConverter { @TypeConverter fun fromList(value: List<String>): String = value.joinToString(",") @TypeConverter fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(",") }4. 商品详情与 SKU 选择:高仿商城最核心的交互模块
4.1 详情页骨架:微信小程序式底部操作栏 + 可滚动内容区
茶商城的详情页跟服装类电商没本质区别,但它的核心在“规格选择弹窗”的交互密度上。页面结构我建议用ConstraintLayout + NestedScrollView,底部用固定的LinearLayout做“客服、购物车、加入购物车、立即购买”四个按钮。底栏不要和内容一起滚动,所以把底栏放在ConstraintLayout底部,内容区用NestedScrollView的高度约束到顶部和底栏之间。
详情页的“加入购物车”和“立即购买”都要先弹出 SKU 选择弹窗。这个弹窗我推荐用BottomSheetDialogFragment,而不是自己写 Dialog。因为 BottomSheet 天然支持拖拽,而且跟 Activity 生命周期绑定,不会因为旋转屏幕导致 window leak。
4.2 用状态模型驱动 SKU 选择弹窗,不要直接用 View 保存选中状态
以前写 SKU 弹窗,很多人喜欢在LinearLayout里动态addView一堆 TextView,然后监听每个 TextView 的点击去改背景。这么做的后果是:当你有三四个规格维度(规格、年份、包装),组合出来的状态非常难管理。我会在弹窗内部定义一个SkuSelectorViewModel,用StateFlow暴露状态:
data class SkuSelectorState( val specs: List<SpecGroup>, // 规格组列表 val selectedSpecs: Map<String, String>, // key: specName, value: specValue val selectedSku: Sku? = null ) class SkuSelectorViewModel(private val goods: Goods) : ViewModel() { private val _state = MutableStateFlow(SkuSelectorState(goods.specGroups)) val state = _state.asStateFlow() fun onSpecSelected(specName: String, specValue: String) { val newSelected = _state.value.selectedSpecs + (specName to specValue) _state.update { it.copy( selectedSpecs = newSelected, selectedSku = findSkuBySelectedSpec(newSelected) ) } } private fun findSkuBySelectedSpec(selected: Map<String, String>): Sku? { return goods.skus.firstOrNull { sku -> sku.specs.all { (name, value) -> selected[name] == value } } } }在 XML 里我们不需要动态生成 view,而是用RecyclerView加载两个维度的数据:第一层是规格组,第二层是组内的 tag。每个 tag 的选中状态由selectedSpecs决定。这样状态流是单向的:点击 → 更新 StateFlow → 界面自动刷新。当某个 SKU 缺货时,在onSpecSelected里判断库存为 0 的 tag 置灰。
高仿项目里最常见的 bug 是:用户选择了“357g 饼茶”和“2023 年”以后,没有任一 SKU 匹配,此时弹窗需要提示“该商品已失效”并禁用底部按钮。上面的状态模型天然能处理这种情况,因为selectedSku为 null 就表示组合无效。
4.3 购物车数量加减与价格联动:用 BigDecimal 的分单位换算
购物车页面需要实时计算总价。我见过用Double累加导致展示金额多一分钱的情况,所以这里统一用分存储,展示时再转成元:
fun formatPrice(cents: Long): String { val y = cents / 100 val jf = cents % 100 return if (jf == 0L) "$y" else String.format(Locale.CHINA, "%d.%02d", y, jf) }购物车商品选中状态我用MutableStateFlow<Set<String>>记录选中的skuId,当用户点击全选时,set = cartItems.keySet()。每次勾选变化时重新计算总价:
val totalPrice = cartItems .filter { it.skuId in selectedSet } .sumOf { it.price * it.count }注意sumOf返回的是Long,不会溢出,因为一个茶商城购物车里的数量不太可能超过 99。如果做活动价,要把“促销标识”也放进购物车条目模型,这样结算页才能看到价格变化。
4.4 购物车数据持久化:DataStore 与 Room 的取舍
购物车要不要存 Room?这个问题很多人犹豫。我建议:只要你的购物车条目不超过几十条,用SharedPreferences的加密版或DataStore就够。但茶商城购物车可能会存“优惠券”和“满减信息”,我倾向建一张CartEntity表:
@Entity(tableName = "cart") data class CartEntity( @PrimaryKey val skuId: String, val goodsId: String, val title: String, val price: Long, val count: Int, val selected: Boolean, val updateTime: Long )理由很简单:购物车数据量小,但在更新数量时容易发生并发写。Room 的事务能保证一致性,而DataStore做复杂对象序列化会比较痛苦。写事务时:
@Transaction suspend fun increaseCartItem(skuId: String, delta: Int) { val current = cartDao.getBySkuId(skuId) if (current == null) { cartDao.insert(CartEntity(skuId, ...)) } else { cartDao.updateCount(skuId, current.count + delta) } }5. 订单提交与状态机:倒计时取消订单、模拟支付轮询
5.1 创建订单时的地址选择与商品快照
到了下单这一步,高仿项目最容易出问题的点是:订单里不应该只存goodsId + count,而应该存一份商品绝对快照,包括标题、价格、图片、规格描述。因为商品后台随时可能下架或改价,用户历史订单必须看到下单时的状态。所以OrderEntity设计时:
@Entity(tableName = "orders") data class OrderEntity( @PrimaryKey val orderId: String, val status: Int, // 0: 待支付 1: 已支付 2: 已发货 3: 已签收 4: 已取消 val totalAmount: Long, val goodsSnapshot: String, // JSON val addressSnapshot: String, // JSON val createTime: Long, val payTime: Long? = null )goodsSnapshot在生成订单的时候,从购物车选中的 SKU 构建一个 JSON 字符串存进入。这样可以避免 join 很多表。
5.2 倒计时关单:用 WorkManager 还是协程延迟?
茶商城活动里常见“15 分钟未支付自动取消”。这个需求有几种实现方式:
- 客户端写
delay(15 * 60 * 1000)然后调取消接口——这是最不靠谱的,App 一旦被杀或者进程重组,后台协程就没了。 - 服务端定时任务处理——专业的做法,但与高仿项目的“本地 Mock”场景不符。
- 本地用 WorkManager 的
OneTimeWorkRequestBuilder设置延迟 15 分钟执行一次取消操作。
高仿项目我选择第 3 种,原因是可以演示真实 App 的离线自动取消逻辑,又不依赖后端定时任务。代码:
val cancelWork = OneTimeWorkRequestBuilder<CancelOrderWorker>() .setInitialDelay(15, TimeUnit.MINUTES) .setInputData(workDataOf("orderId" to orderId)) .build() WorkManager.getInstance(context).enqueue(cancelWork)CancelOrderWorker内部检查当前订单状态,如果仍是待支付,就更新为已取消。这里要注意 WorkManager 并不是精准的定时器,它可能由于 Doze 模式延迟执行。实际场景中,商家端会以服务端时间为准,客户端这个只是兜底。你在仿写时,倒计时的 UI 剩余时间可以这样计算:
val remainMs = payDeadline - System.currentTimeMillis() binding.tvCountdown.text = formatCountdown(remainMs)在 Activity 的onResume中刷新一次,不靠实时 tick。你觉得呢?其实高仿项目的“倒计时”主要是给用户看的,不需要精确到秒。
5.3 模拟支付成功后回调刷新,复用 ViewModel 的顺序
模拟支付最简单的是在支付页面放一个“确认支付”按钮,点击后调用PaymentRepository.pay(orderId),这个 repository 内部可以随机延迟 1 秒后成功。成功后,我们需要同时:更新本地订单状态、清空购物车对应 SKU、跳转到订单详情页。这个流程如果写在 Activity 里,很容易遗漏清空购物车这一步。
我一般在OrderConfirmViewModel里做:
fun pay(orderId: String) { viewModelScope.launch { repository.pay(orderId) cartRepository.removeSelectedItems() _payState.value = PaySuccess } }支付成功后用Navigation的popBackStack回到订单列表,同时刷新列表页。这是一个标准的数据驱动流程:ViewModel 持有状态,Fragment 观察状态,导航是状态的副作用。
6. 上架前检查:高仿项目最容易翻车的 6 个 Android 细节
6.1 兼容分区存储:图片选择器要按 Android 11 的参数来写
茶商城用户头像、商品图片都可能用到相册选择。如果你直接用MediaStore.Images.Media.EXTERNAL_CONTENT_URI读取,Android 10 之后会崩溃或拿到空值。我建议用系统的PickVisualMedia:
val launcher = rememberLauncherForActivityResult( ActivityResultContracts.PickVisualMedia() ) { uri -> if (uri != null) imageView.setImageURI(uri) } launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))高仿项目如果你使用的是自定义相机或裁剪库,请一定把FileProvider的路径配置对,否则在 Android 7.0 后的content://分享时会报FileUriExposedException。常见的配置是:
<paths> <external-path name="external_files" path="." /> </paths>但注意,如果你的应用要把图片缓存到getExternalFilesDir(),这里应该写<external-files-path>而不是<external-path>,很多项目死在这个区别上。
6.2 沉浸式状态栏与刘海屏的适配
茶商城页面底色多为米白或浅木色,沉浸式状态栏完全可以做。但要做对版本区分:
WindowCompat.setDecorFitsSystemWindows(window, false)同时在View.setOnApplyWindowInsetsListener中把顶部 inset 作为 padding 加到 Toolbar 上。刘海屏设备会在竖屏时返回状态栏高度,不要写死 24dp。高仿参考项目里如果截图上有很漂亮的渐变状态栏,其实就是用surfaceHeader或者 Modifier 兼容处理的。
6.3 列表滑动卡顿:检查 item 的 layout 层级和 draw 频次
茶商城商品列表 item 通常包含:图片、标题、两个价格文本、一个标签。如果你在 item 里用RelativeLayout嵌套LinearLayout再嵌套TextView,绘制层级深了性能就会掉帧。我的做法是用ConstraintLayout打平结构,图片的scaleType用centerCrop,不要用fitCenter后自己去裁。
再检查一下你的Adapter里有没有在onBindViewHolder中做复杂的字符串拼接。建议把“¥ + 价格 + 起”的格式化放到Goods的扩展属性里:
val Goods.priceDisplay: String get() = if (skus.size > 1) "¥${formatPrice(skus.minOf { it.price })} 起" else "¥${formatPrice(price)}"6.4 本地 Mock 数据的唯一约束:关联查询不要用手写 ID
如果你用 JSON 文件提供 Mock 数据,一个非常常见的翻车点是商品分类 ID 和商品 ID 不匹配,导致详情页打开空白。建议 Mock 数据结构与 Room Entity 保持一致,并且用一个小脚本启动时校验:
# 用 jq 检查 goods.json 中的 goodsId 是否全部唯一 jq '[.goods[].goodsId] | length - (unique | length)' goods.json返回 0 才代表没有重复。
6.5 弱网状态的高仿:拦截器模拟延时回包
高仿期间,如果要演示加载动画或者断网重试,可以在 OkHttp 的Interceptor里模拟网络耗时:
class MockNetworkInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { Thread.sleep(800) // 模拟 800ms 延迟 return chain.proceed(chain.request()) } }把Thread.sleep换成随机 300-1200ms,可以更真实地模拟弱网环境。这个技巧对调试 skeleton 加载动画特别有用——你不会一秒钟就闪完,而是能看到从骨架到内容的过渡。
6.6 上架前的打包与混淆注意项
如果你用到了Gson或kotlinx.serialization,记得保留模型字段名的映射规则:
-keep class com.teashop.data.model.** { *; }否则 Release 包运行时出现“字段不存在”的诡异崩溃。另外,如果你的高仿项目用了第三方的轮播图库或图片加载库,请在打包前统一minifyEnabled true跑一遍 monkey 测试,50 分钟无崩溃再考虑上传应用商店。
最后验证一下你自己的高仿项目是否完整:从首页秒杀倒计时点进详情 → 选规格 → 加购物车 → 购物车全选 → 提交订单 → 模拟支付 → 查看订单状态。这一条链路如果全部用内存数据跑通且没有崩溃,就可以把它当作一个合格的茶商城 Android 模板。每个人写高仿项目的方式不同,但把状态管理、存储边界和兼容性问题在动手之前想清楚,会让你的“仿”不止于像,而是可用。
本文还有配套的精品资源,点击获取