ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android商城App实战:Kotlin+Room+Retrofit开发易购

Android商城App实战:Kotlin+Room+Retrofit开发易购 简介本资源为基于Android的购物商城「易购」APP设计与实现论文文档面向计算机、软件工程等专业需要完成毕业设计或课程设计的学生以及想了解移动电商完整开发流程的开发者。文档围绕市场需求、用户需求与功能需求展开分析依次介绍客户端与服务器端架构、用户/商品/订单/支付/物流五大模块设计并说明Android客户端、J2EE服务端与MySQL数据库的技术选型最后给出功能、性能与安全测试结论可作为同类项目选题与写作的参考模板。压缩包内共1个docx文件约1.35MB含中英文摘要、关键词与章节化目录便于按需求分析、系统设计、技术实现、测试等部分拆解阅读与二次引用。目前已有163人学习下载适合需要完整论文结构、模块划分思路与开发工具链说明的读者借鉴。1. 先给“易购”划边界数据层和 UI 层必须切开商城类 APP 翻车最多的时刻不是首页多难看而是商品数据从本地假数据切到真实接口的那一天列表闪退、图片错位、购物车数量对不上、订单被重复提交。做“易购”之前先把商品、购物车、订单三条数据的归属关系定死——谁持有、谁只读、谁负责落盘缓存边界不清后面每个 Activity 都会退化成胶水代码。这套方案适合正在用 Android Studio 做商城类课程设计、毕业设计或者想拿一条完整电商链路练手 Android 开发的人。接下来按最小骨架、商品流与购物车、登录订单与打包发布、联调排错的顺序推进每一段单独拉出来都能跑。2. 用 Android Studio 搭出“易购”的最小可运行骨架2.1 新建工程时最先要定死的三个参数Android Studio 走完 New Project 向导之后很多人直接开始写界面结果在打包或者适配阶段回头改配置。商城类项目建议一开始就按下表定好配置项建议取值理由minSdk24覆盖绝大多数在用机型同时能配合 desugaring 使用java.timetargetSdk跟随当前稳定版太低会在新系统上触发兼容行为且上架审核可能被标记Build configuration languageKotlin DSL与 Kotlin 代码同语法IDE 补全和重构更可靠语言Kotlin协程、Flow、sealed class写状态机省事compileSdk与targetSdk保持一致避免用到新 API 却在旧 SDK 上编译不过。选完向导后先跑一次空壳 APK确认 android sdk、构建工具链和真机调试都没问题再往下写业务这一步省掉的是后面最难定位的环境类报错。2.2 “易购”的分包按功能切不要按类型切把所有 Activity 塞进一个ui包、所有实体塞进一个model包是课程设计里最常见的写法也是最难维护的写法。建议按功能域切分公共能力单独下沉com/example/yigou ├── core │ ├── network # Retrofit 实例、拦截器、统一响应包装 │ ├── db # Room 数据库、通用 DAO │ └── ui # 通用状态、Base 组件 ├── feature │ ├── goods # 商品列表、详情 │ ├── cart # 购物车 │ ├── order # 下单、订单列表 │ └── user # 登录、注册、个人中心 └── widget # 自定义控件、进度条封装按功能切的好处是删掉一个模块时你只需要删一个目录而不用在十几个包里翻找残留引用。core只放被两个以上功能共用的东西只有一处用到的工具类不要提前下沉。2.3 Gradle 依赖一次配齐网络、图片、数据库、协程四类依赖在商城项目里基本跑不掉集中在app/build.gradle.kts里配一次比用到一个加一个更省事// app/build.gradle.kts android { compileSdk 34 defaultConfig { minSdk 24 targetSdk 34 } buildFeatures { viewBinding true } // 商城页面控件多ViewBinding 比 findViewById 稳 } dependencies { implementation(androidx.core:core-ktx:1.13.1) implementation(androidx.recyclerview:recyclerview:1.3.2) // 网络Retrofit 只负责接口声明OkHttp 负责实际连接与拦截 implementation(com.squareup.retrofit2:retrofit:2.11.0) implementation(com.squareup.retrofit2:converter-gson:2.11.0) implementation(com.squareup.okhttp3:logging-interceptor:4.12.0) // 图片加载商城列表必须走缓存否则滑动必然闪 implementation(com.github.bumptech.glide:glide:4.16.0) // 本地库购物车、浏览记录都靠它 implementation(androidx.room:room-runtime:2.6.1) implementation(androidx.room:room-ktx:2.6.1) ksp(androidx.room:room-compiler:2.6.1) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1) }版本号以你本地 Android Studio 模板和仓库实际能解析到的为准不要照抄后不管冲突。room-ktx是必须的它提供suspendDAO 和Flow返回值logging-interceptor只在 debug 构建里开release 包里带上它会拖慢请求并泄露日志。提示如果构建时报 KSP 版本不匹配检查 KSP 插件版本是否与 Kotlin 插件版本严格对应这是 Build 失败里最常被忽略的一类。2.4 商品表先建起来Room 实体和 DAO 一起写购物车和浏览历史本质上都是“本地一张表 一个 DAO”先拿商品缓存把 Room 跑通后面复制结构就行Entity(tableName goods) data class GoodsEntity( PrimaryKey val id: Long, // 商品 ID与后端保持一致不要用自增 val title: String, val priceCents: Long, // 金额统一用“分”存避免浮点误差 val coverUrl: String, val stock: Int, val updatedAt: Long // 本地缓存时间用于判断是否过期 ) Dao interface GoodsDao { Query(SELECT * FROM goods ORDER BY id DESC LIMIT :limit OFFSET :offset) suspend fun page(limit: Int, offset: Int): ListGoodsEntity Insert(onConflict OnConflictStrategy.REPLACE) suspend fun upsertAll(items: ListGoodsEntity) Query(DELETE FROM goods) suspend fun clear() }尺寸最大的坑在priceCents这个字段名上。如果这里写成Double后面购物车求和、优惠券抵扣、订单金额对账时迟早会碰到19.9 * 3 59.699999这类结果。用Long存分展示时再除 100整条链路都干净。updatedAt用来做缓存过期判断比如超过 10 分钟就静默重新拉取。Database(entities [GoodsEntity::class], version 1, exportSchema false) abstract class AppDatabase : RoomDatabase() { abstract fun goodsDao(): GoodsDao }version每改一次表结构就要加一并补Migration。课程设计阶段可以先用fallbackToDestructiveMigration()顶一下但论文里要写清楚这只是开发期妥协。3. 商品列表与购物车Android 上最容易翻车的两块3.1 ListAdapter DiffUtil列表不闪、图片不错位商城首页是刷新最频繁的页面。用裸RecyclerView.Adapter配notifyDataSetChanged()会出现整屏重绘、图片闪烁、滚动位置跳变。正确做法是让 Adapter 自己算差量class GoodsAdapter( private val onClick: (GoodsEntity) - Unit ) : ListAdapterGoodsEntity, GoodsAdapter.VH(DIFF) { companion object { val DIFF object : DiffUtil.ItemCallbackGoodsEntity() { override fun areItemsTheSame(a: GoodsEntity, b: GoodsEntity) a.id b.id override fun areContentsTheSame(a: GoodsEntity, b: GoodsEntity) a b } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val binding ItemGoodsBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return VH(binding) } override fun onBindViewHolder(holder: VH, position: Int) { val item getItem(position) holder.binding.tvTitle.text item.title holder.binding.tvPrice.text ¥%.2f.format(item.priceCents / 100.0) Glide.with(holder.binding.ivCover) // 复用 View 时必须让 Glide 接管生命周期 .load(item.coverUrl) .placeholder(R.drawable.bg_placeholder) .into(holder.binding.ivCover) holder.binding.root.setOnClickListener { onClick(item) } } class VH(val binding: ItemGoodsBinding) : RecyclerView.ViewHolder(binding.root) }areItemsTheSame判id决定这一项是不是同一个东西areContentsTheSame用data class的equals决定内容要不要重绘。这两者写反列表就会表现为“数据更新了但界面不动”或者“整屏闪”。图片用 Glide 加载是硬要求它自带内存和磁盘缓存滑回来不重新下载也能在 View 复用时自动取消上一次请求避免图片错位。3.2 分页加载与 android 进度条的状态机下拉刷新、上拉加载、空数据、加载失败这是四个状态而不是四个布尔值。用sealed class收口sealed interface GoodsUiState { object Loading : GoodsUiState // 首屏显示整页进度条 data class Success(val items: ListGoodsEntity) : GoodsUiState data class Empty(val reason: String) : GoodsUiState data class Error(val msg: String) : GoodsUiState }首屏用ProgressBarindeterminateTint设成主色加载下一页时用底部footer的进度条绝不能让两者共用同一个visibility否则上拉时整屏变白。addOnScrollListener里判断阈值recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { if (dy 0) return val lm rv.layoutManager as LinearLayoutManager val last lm.findLastVisibleItemPosition() val total lm.itemCount // 距底部不足 5 项时预加载等滑到底再请求会明显卡一下 if (total - last 5 !viewModel.isLoadingNext) { viewModel.loadNextPage() } } })预加载阈值设为 5 左右比较合适太小会频繁触发太大则在快速滑动时来不及补数据。isLoadingNext用AtomicBoolean或在主线程内维护防止同一帧内触发两次请求。3.3 购物车的本地表结构购物车建议直接落 Room不要在内存里维护HashMap。加购、改数量、删除、结算清空四条操作都通过 DAO 走一遍数据库进程被杀也不会丢字段类型说明goodsIdLong主键与商品 ID 一致天然去重title/coverUrlString冗余存储避免列表页拉不到商品时显示空白priceCentsLong加购时的单价快照countInt数量最小为 1selectedBoolean是否勾选参与结算Dao interface CartDao { // 重复加购时数量累加而不是插一条新记录 Query( INSERT INTO cart_item (goodsId, title, coverUrl, priceCents, count, selected) VALUES (:goodsId, :title, :coverUrl, :priceCents, 1, 1) ON CONFLICT(goodsId) DO UPDATE SET count count 1 ) suspend fun addOne(goodsId: Long, title: String, coverUrl: String, priceCents: Long) Query(SELECT SUM(priceCents * count) FROM cart_item WHERE selected 1) suspend fun selectedTotalCents(): Long? // 空车时返回 nullUI 层要判空 Query(UPDATE cart_item SET count :count WHERE goodsId :goodsId AND :count 0) suspend fun updateCount(goodsId: Long, count: Int) Query(DELETE FROM cart_item WHERE selected 1) suspend fun clearSelected() }ON CONFLICT ... DO UPDATE是关键它把“加购”做成了幂等操作重复点击不会出现两条相同商品。注意count 0这个条件写在 SQL 里比在 UI 层判断更省事也避免了数量减到 0 还留在表里的脏数据。4. 登录、订单与“易购”的发布链路4.1 OkHttp 拦截器统一挂 Token、控日志商城接口几乎都要带登录态与其在每个 Repository 里手写 header不如在 OkHttp 层统一处理class AuthInterceptor(private val tokenStore: TokenStore) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val original chain.request() val token tokenStore.token val request if (token.isNullOrBlank()) original else { original.newBuilder() .header(Authorization, Bearer $token) .header(X-Client, yigou-android) .build() } return chain.proceed(request) } }组装客户端时把日志拦截器只挂在 debug 上val client OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) // 连接超时弱网下别设太大 .readTimeout(20, TimeUnit.SECONDS) // 读超时图片详情接口可单独放宽 .addInterceptor(AuthInterceptor(tokenStore)) .apply { if (BuildConfig.DEBUG) { addInterceptor(HttpLoggingInterceptor().apply { level HttpLoggingInterceptor.Level.BODY // release 包绝不能留 }) } } .build()连接超时和读超时要分开设。商城首页接口通常很快但请求密集10 秒足够订单详情可能带上物流信息读超时给 20 秒。日志级别用BODY方便对账但释放前务必确认BuildConfig.DEBUG兜住了它否则用户的手机号和地址会明文进日志。4.2 401 统一跳转别在每个页面写一遍Token 过期返回 401 时如果每个页面各自处理会出现“登录页叠了四层”的尴尬。常见做法是在拦截器里抛出统一异常由全局的Authenticator或一层事件总线捕获然后清理本地 Token 并跳登录页// Repository 层统一转换 suspend fun T safeCall(block: suspend () - ResponseT): ResultT try { val resp block() when { resp.isSuccessful - Result.success(resp.body()!!) resp.code() 401 - { tokenStore.clear() // 清登录态触发全局跳转 Result.failure(SessionExpiredException()) } else - Result.failure(ApiException(resp.code(), resp.message())) } } catch (e: IOException) { Result.failure(NetworkException(e)) // 断网单独归一类便于提示“检查网络” }把网络异常和业务异常分成不同分支很重要前者提示“网络不给力请重试”后者提示服务端返回的具体原因用户看到的信息才有意义。4.3 提交订单为什么要带客户端订单号用户在电梯里点“提交订单”请求发出去但响应没回来他一定再点一次。如果服务端没做幂等就会生成两笔待付款订单。解决办法是客户端在下单前生成一个clientOrderNoUUID 即可随请求一起提交服务端对同一号码只落一笔data class CreateOrderReq( val clientOrderNo: String UUID.randomUUID().toString(), // 每次进入确认页生成一次不要每次点击都生成 val addressId: Long, val items: ListOrderItemReq )注意clientOrderNo要在进入确认订单页时生成并缓存在 ViewModel 里重试时复用同一个值如果放在点击事件里生成重试就成了新订单幂等也就失效了。4.4 android 应用签名 sha1 值与正式包接入地图、分享、推送这类第三方 SDK 时几乎都要填应用的签名指纹。Debug 和 Release 的签名不同指纹也不同这是“本地能跑、打包就报错”的高发原因。先用 keytool 取出# 查看 debug 签名指纹默认密码 android keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android # 查看正式签名指纹 keytool -list -v -keystore yigou-release.jks -alias yigou在build.gradle.kts里配置正式签名android { signingConfigs { create(release) { storeFile file(../yigou-release.jks) storePassword System.getenv(YIGOU_STORE_PASS) // 密码走环境变量不要进版本库 keyAlias yigou keyPassword System.getenv(YIGOU_KEY_PASS) } } buildTypes { release { isMinifyEnabled true isShrinkResources true proguardFiles(getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro) signingConfig signingConfigs.getByName(release) } } }混淆开启后Retrofit 的接口声明和 Room 的实体很容易被误删。至少要在proguard-rules.pro里保留数据模型和接口-keep class com.example.yigou.core.network.model.** { *; } -keepattributes Signature, *Annotation* -keepclassmembers class * extends androidx.room.RoomDatabase { init(); }4.5 发布前自检检查项期望结果日志拦截器release 包中不生效签名指纹与第三方后台登记的一致明文流量usesCleartextTraffic为 false或仅在 debug 的 network-security-config 中放开混淆映射文件每次发版留存mapping.txt用于还原线上崩溃栈权限清单只保留实际使用的权限5. 联调与排错让“易购”在真机上跑稳5.1 app 抓包先解决证书信任问题接口对不上时看日志不如直接看报文。把手机网络指向抓包工具监听的地址后Android 7.0 以上默认不信任用户安装的 CA 证书页面会直接报连接失败而不是显示明文。开发期的正确做法是在 debug 包里放开信任而不是去改系统设置!-- app/src/debug/res/xml/network_security_config.xml -- network-security-config debug-overrides trust-anchors certificates srcuser / !-- 仅 debug 构建生效 -- certificates srcsystem / /trust-anchors /debug-overrides /network-security-config在AndroidManifest.xml的application上通过android:networkSecurityConfig引用它且这个文件只放在src/debug目录release 包自然不会带上。抓包时重点看三样请求头里的Authorization是否正常带上、响应体里的字段类型price: 19.9这种浮点值最容易在 Gson 解析成Long时崩、以及是否出现了重复的订单号。5.2 几个能省下大量时间的排查习惯Logcat 加 tag 过滤比全量刷屏高效得多。给每个模块定一个固定 tag比如Yigou-Goods、Yigou-Cart然后在 Logcat 里输入tag:Yigou-一次性看全# 只看易购自己的日志屏蔽系统噪音 adb logcat -s Yigou-Goods:D Yigou-Cart:D Yigou-Order:D图片列表滑动卡顿先查是否在onBindViewHolder里做了主线程耗时操作比如 JSON 解析或者日期格式化其次确认 Glide 的override是否按控件尺寸裁剪加载原图进小控件会白白吃掉内存。下拉刷新时若进度条不消失多半是isRefreshing只在成功回调里置了 false失败分支漏了——把状态收口到GoodsUiState之后这类问题会自动消失因为“结束加载”和“展示结果”变成了同一件事。线上崩溃还原离不开mapping.txt。每次发版把映射文件按版本号归档收到堆栈时用下面这条命令还原成可读类名比对着混淆后的a.b.c猜要快得多retrace.sh -verbose mapping.txt stacktrace.txt把这条命令和映射文件的归档动作一起写进发版脚本比事后翻找签名、版本号和构建产物要省事得多。本文还有配套的精品资源点击获取
返回列表