拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android校园二手App开发:Room数据库+RecyclerView闭环实践

简介这是一套基于Android Studio开发的校园二手交易App毕业设计项目源码面向计算机类专业本科生毕设选题、课程设计及移动开发初学者解决校园场景下闲置物品高效流转与本地化交易闭环问题。资源包共188个文件含38个Java业务逻辑与Activity页面代码、78个XML布局与资源定义文件、36个PNG与20个JPG图像素材辅以Gradle构建脚本、mail.jar依赖库及可直接安装的APK成品整体压缩包仅18.27MB轻量易部署。已有846人学习下载说明其在实践教学中具备较强参考价值。用户可获得完整可运行系统已获98分高分评价、清晰的模块化目录结构含登录、发布、搜索、聊天、个人中心等核心功能、配套项目说明文档及调试通过的工程配置无需额外适配即可导入Android Studio编译运行大幅降低毕设启动门槛与环境踩坑成本。1. 用 Android Studio 做校园二手交易 App不是堆功能而是跑通「用户发帖→浏览→私聊→状态闭环」这四步很多计算机专业同学拿到“校园二手交易系统”毕业设计选题时第一反应是猛查 GitHub 上的开源项目、急着找现成源码改包名——结果卡在登录页跳转失败、图片上传报空指针、SQLite 插入数据不生效。其实这个题目真正考察的不是你能不能抄出一个界面而是能否在 Android Studio 环境下用原生 Java/Kotlin SQLite或 Room 简易本地通信机制把「学生 A 发一条教材转让帖 → 学生 B 在列表看到 → 点击进入详情页 → 发送站内消息 → 双方确认交易完成」这条主链路完整走通。它面向的是大三下到大四上、已学完《Android 移动开发》《数据库原理》但尚未接触真实工程协作的实践者。重点不在云服务、不在高并发而在本地数据建模是否合理、Activity/Fragment 生命周期处理是否到位、RecyclerView 滚动复用是否正确、以及最关键的——SQLite 表结构设计能否支撑“未读消息计数”“帖子状态流转”“多图本地缓存”这三个高频需求。下面我们就从零开始用最轻量、最可控的方式落地。2. 用 Android Studio 创建最小可运行项目从新建工程到启动首页 Activity2.1 新建 Empty Activity 工程并配置基础依赖打开 Android Studio建议使用 Giraffe 或 Hedgehog 版本避免旧版对 JDK 17 兼容问题选择New Project → Empty Activity包名设为com.example.campussecondhandMinimum SDK 选 API 21Android 5.0语言选 KotlinGradle 插件会自动匹配 Kotlin 1.8。创建完成后先检查app/build.gradle中是否已包含以下核心依赖// app/build.gradle dependencies { implementation androidx.core:core-ktx:1.12.0 implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.10.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 implementation androidx.recyclerview:recyclerview:1.3.2 implementation androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 implementation androidx.lifecycle:lifecycle-livedata-ktx:2.7.0 // Room 数据库支持替代原始 SQLiteOpenHelper implementation androidx.room:room-runtime:2.6.1 implementation androidx.room:room-ktx:2.6.1 kapt androidx.room:room-compiler:2.6.1 // 图片加载避免 Glide 太重先用 Coil implementation io.coil-kt:coil:2.5.0 }提示Room 是 Android 官方推荐的数据库抽象层比手写SQLiteOpenHelper更安全、更易维护。kapt是 Kotlin 的注解处理器必须启用才能生成 DAO 实现类。若编译报错kapt not found请确认build.gradleProject 级中已启用plugins { id org.jetbrains.kotlin.kapt version 1.9.20 apply false }。2.2 设计首页布局RecyclerView 列表承载二手商品卡片在res/layout/activity_main.xml中用ConstraintLayout包裹一个RecyclerView并设置 ID 为rvItemList!-- res/layout/activity_main.xml -- 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 androidx.recyclerview.widget.RecyclerView android:idid/rvItemList android:layout_width0dp android:layout_height0dp app:layout_constraintTop_toTopOfparent app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent / /androidx.constraintlayout.widget.ConstraintLayout接着创建item_post.xml作为列表项布局包含商品图、标题、价格、发布时间、发布人昵称五要素!-- res/layout/item_post.xml -- androidx.cardview.widget.CardView xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_margin8dp app:cardCornerRadius8dp app:cardElevation4dp LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding12dp ImageView android:idid/ivCover android:layout_widthmatch_parent android:layout_height160dp android:scaleTypecenterCrop android:contentDescription商品封面 / TextView android:idid/tvTitle android:layout_widthmatch_parent android:layout_heightwrap_content android:textSize16sp android:textStylebold android:layout_marginTop8dp / TextView android:idid/tvPrice android:layout_widthmatch_parent android:layout_heightwrap_content android:textSize14sp android:textColor#FF6B35 android:layout_marginTop4dp / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:layout_marginTop4dp TextView android:idid/tvAuthor android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:textSize12sp android:textColor#666 / TextView android:idid/tvTime android:layout_widthwrap_content android:layout_heightwrap_content android:textSize12sp android:textColor#999 / /LinearLayout /LinearLayout /androidx.cardview.widget.CardView2.3 编写 RecyclerView Adapter绑定数据并响应点击事件创建PostAdapter.kt继承RecyclerView.Adapter关键点在于使用ViewBinding替代findViewById更安全、更简洁onCreateViewHolder中 inflateitem_post.xml并返回ItemPostBindingonBindViewHolder中填充数据并为整个 itemView 设置setOnClickListener跳转到详情页。// adapter/PostAdapter.kt class PostAdapter( private val onItemClick: (Post) - Unit ) : RecyclerView.AdapterPostAdapter.PostViewHolder() { private val posts mutableListOfPost() inner class PostViewHolder(val binding: ItemPostBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(post: Post) { binding.tvTitle.text post.title binding.tvPrice.text ¥${post.price} binding.tvAuthor.text 发布人${post.author} binding.tvTime.text formatTime(post.createdAt) // 使用 Coil 加载封面图假设图片路径为本地资源 ID 或 assets 路径 binding.ivCover.load(post.coverResId) { crossfade(true) placeholder(R.drawable.ic_image_placeholder) } binding.root.setOnClickListener { onItemClick(post) } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder { val binding ItemPostBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return PostViewHolder(binding) } override fun onBindViewHolder(holder: PostViewHolder, position: Int) { holder.bind(posts[position]) } override fun getItemCount() posts.size fun submitList(newList: ListPost) { posts.clear() posts.addAll(newList) notifyDataSetChanged() } private fun formatTime(timestamp: Long): String { return SimpleDateFormat(MM-dd HH:mm, Locale.getDefault()).format(Date(timestamp)) } }注意Post是一个简单的数据类需定义title: String,price: Double,author: String,createdAt: Long,coverResId: Int字段。submitList()方法用于后续从数据库查询后批量刷新 UI避免逐条notifyItemInserted()导致闪烁。3. 用 Room 构建校园二手交易数据库建表、增删改查与状态字段设计3.1 定义 EntityPost 和 Message 两个核心实体校园二手交易系统的核心业务对象是“帖子”和“消息”。Post表需支持状态流转待售/已售/下架Message表需记录发送方、接收方、内容、已读状态。Room 要求 Entity 必须有主键且字段命名需明确语义// entity/Post.kt Entity(tableName posts) data class Post( PrimaryKey(autoGenerate true) val id: Long 0, val title: String, val description: String, val price: Double, val authorId: Long, // 发布人 ID可关联用户表此处简化为 Long val authorName: String, // 发布人昵称冗余存储避免 JOIN val coverResId: Int, // 封面图资源 ID如 R.drawable.book_math val status: Int 0, // 0待售, 1已售, 2下架 val createdAt: Long System.currentTimeMillis(), val updatedAt: Long System.currentTimeMillis() ) // entity/Message.kt Entity(tableName messages) data class Message( PrimaryKey(autoGenerate true) val id: Long 0, val senderId: Long, val receiverId: Long, val content: String, val isRead: Boolean false, val createdAt: Long System.currentTimeMillis() )提示status字段用整型而非字符串便于 SQL 查询和索引优化createdAt/updatedAt由代码自动赋值避免依赖数据库时间函数authorName冗余存储是为了在列表页快速展示省去 JOIN 用户表的开销——这是毕业设计场景下的合理妥协。3.2 编写 DAO 接口封装常用操作方法DAOData Access Object是 Room 的核心接口所有数据库操作都通过它声明。注意Query中的 SQL 必须严格匹配字段名Insert/Update注解的方法名可任意但参数必须是对应 Entity// dao/PostDao.kt Dao interface PostDao { Insert(onConflict OnConflictStrategy.REPLACE) suspend fun insert(post: Post): Long Update suspend fun update(post: Post) Query(SELECT * FROM posts WHERE status 0 ORDER BY createdAt DESC) suspend fun getAvailablePosts(): ListPost Query(SELECT * FROM posts WHERE id :id) suspend fun getPostById(id: Long): Post? Query(UPDATE posts SET status :newStatus, updatedAt :time WHERE id :id) suspend fun updateStatus(id: Long, newStatus: Int, time: Long) Query(DELETE FROM posts WHERE id :id) suspend fun deleteById(id: Long) } // dao/MessageDao.kt Dao interface MessageDao { Insert(onConflict OnConflictStrategy.IGNORE) suspend fun insert(message: Message): Long Query(SELECT * FROM messages WHERE (senderId :userId AND receiverId :targetId) OR (senderId :targetId AND receiverId :userId) ORDER BY createdAt ASC) suspend fun getChatHistory(userId: Long, targetId: Long): ListMessage Query(UPDATE messages SET isRead 1 WHERE receiverId :userId AND senderId ! :userId) suspend fun markAllAsRead(userId: Long) Query(SELECT COUNT(*) FROM messages WHERE receiverId :userId AND isRead 0) suspend fun getUnreadCount(userId: Long): Int }注意getChatHistory查询使用OR条件实现双向聊天记录拉取markAllAsRead仅标记“别人发给我的未读消息”不包含自己发出的消息getUnreadCount返回整型直接用于 Badge 显示。3.3 构建 Database 类并初始化单例模式 预填充测试数据RoomDatabase 是数据库入口必须用Database注解声明并指定 Entity 和 DAO。为方便调试可在首次创建时预填充几条测试帖子// database/AppDatabase.kt Database( entities [Post::class, Message::class], version 1, exportSchema false ) abstract class AppDatabase : RoomDatabase() { abstract fun postDao(): PostDao abstract fun messageDao(): MessageDao companion object { Volatile private var INSTANCE: AppDatabase? null fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: buildDatabase(context).also { INSTANCE it } } } private fun buildDatabase(context: Context): AppDatabase { return Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, campus_secondhand.db ).addCallback(object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) // 首次创建时插入测试数据 CoroutineScope(Dispatchers.IO).launch { val postDao getInstance(context).postDao() listOf( Post( title 《高等数学》第七版同济, description 几乎全新笔记极少附赠课后习题答案, price 15.0, authorId 1001, authorName 张同学, coverResId R.drawable.book_math, status 0 ), Post( title MacBook Pro 2018 13寸, description i5/8GB/256GB无划痕电池健康度92%, price 4200.0, authorId 1002, authorName 李学长, coverResId R.drawable.laptop_mac, status 0 ) ).forEach { postDao.insert(it) } } } }).build() } private fun getInstance(context: Context) getDatabase(context) } }提示addCallback().onCreate()是唯一安全的预填充时机CoroutineScope(Dispatchers.IO)确保数据库操作在 IO 线程执行R.drawable.*资源需提前放入res/drawable目录否则coverResId加载失败。4. 实现核心业务流程从发帖到私聊再到状态更新的端到端链路4.1 发帖页面使用 Material Design 组件构建表单并保存到数据库创建activity_add_post.xml包含TextInputLayout包裹的TextInputEditText标题、描述、价格、ImageView封面图选择、Button提交!-- res/layout/activity_add_post.xml -- ScrollView xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding16dp com.google.android.material.textfield.TextInputLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:hint商品标题 com.google.android.material.textfield.TextInputEditText android:idid/etTitle android:layout_widthmatch_parent android:layout_heightwrap_content / /com.google.android.material.textfield.TextInputLayout com.google.android.material.textfield.TextInputLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:hint详细描述 android:layout_marginTop8dp com.google.android.material.textfield.TextInputEditText android:idid/etDescription android:layout_widthmatch_parent android:layout_heightwrap_content android:lines3 / /com.google.android.material.textfield.TextInputLayout com.google.android.material.textfield.TextInputLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:hint价格元 android:layout_marginTop8dp com.google.android.material.textfield.TextInputEditText android:idid/etPrice android:layout_widthmatch_parent android:layout_heightwrap_content android:inputTypenumberDecimal / /com.google.android.material.textfield.TextInputLayout ImageView android:idid/ivCover android:layout_widthmatch_parent android:layout_height200dp android:scaleTypecenterCrop android:layout_marginTop16dp android:contentDescription封面图 android:srcdrawable/ic_image_placeholder / Button android:idid/btnSubmit android:layout_widthmatch_parent android:layout_heightwrap_content android:text发布商品 android:layout_marginTop24dp / /LinearLayout /ScrollView在AddPostActivity.kt中获取输入值、构造Post对象、调用postDao.insert()// activity/AddPostActivity.kt class AddPostActivity : AppCompatActivity() { private lateinit var binding: ActivityAddPostBinding private lateinit var postDao: PostDao override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityAddPostBinding.inflate(layoutInflater) setContentView(binding.root) postDao AppDatabase.getDatabase(this).postDao() binding.btnSubmit.setOnClickListener { val title binding.etTitle.text.toString().trim() val description binding.etDescription.text.toString().trim() val priceStr binding.etPrice.text.toString().trim() if (title.isEmpty() || description.isEmpty() || priceStr.isEmpty()) { Toast.makeText(this, 请填写完整信息, Toast.LENGTH_SHORT).show() returnsetOnClickListener } val price priceStr.toDoubleOrNull() ?: 0.0 if (price 0) { Toast.makeText(this, 价格必须大于 0, Toast.LENGTH_SHORT).show() returnsetOnClickListener } val post Post( title title, description description, price price, authorId getCurrentUserId(), // 模拟当前登录用户 ID authorName 王同学, // 实际应从用户表查 coverResId R.drawable.book_math // 此处简化为固定资源 ) lifecycleScope.launchWhenStarted { try { val id postDao.insert(post) Toast.makeText(this, 发布成功ID$id, Toast.LENGTH_SHORT).show() finish() // 返回首页 } catch (e: Exception) { Toast.makeText(this, 发布失败${e.message}, Toast.LENGTH_SHORT).show() } } } } private fun getCurrentUserId(): Long 1003 // 模拟登录态实际应从 SharedPreferences 或 AccountManager 获取 }注意lifecycleScope.launchWhenStarted确保协程在 Activity 启动后执行避免内存泄漏getCurrentUserId()是占位符毕业设计中可用SharedPreferences存储简单用户 ID无需接入 AccountManager。4.2 详情页与私聊页联动用 Intent 传递数据并实时更新未读数详情页PostDetailActivity需显示帖子全部信息并提供“联系卖家”按钮。点击后跳转至ChatActivity同时将postId和sellerId传过去// 在 PostDetailActivity.kt 中 binding.btnContactSeller.setOnClickListener { val intent Intent(this, ChatActivity::class.java).apply { putExtra(SELLER_ID, post.authorId) putExtra(POST_ID, post.id) } startActivity(intent) }ChatActivity接收参数初始化messageDao并在onResume()中调用markAllAsRead()更新未读状态// activity/ChatActivity.kt class ChatActivity : AppCompatActivity() { private lateinit var binding: ActivityChatBinding private lateinit var messageDao: MessageDao private lateinit var postDao: PostDao private var sellerId: Long 0 private var postId: Long 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) messageDao AppDatabase.getDatabase(this).messageDao() postDao AppDatabase.getDatabase(this).postDao() sellerId intent.getLongExtra(SELLER_ID, 0) postId intent.getLongExtra(POST_ID, 0) binding.btnSend.setOnClickListener { val content binding.etMessage.text.toString().trim() if (content.isNotEmpty()) { val message Message( senderId getCurrentUserId(), receiverId sellerId, content content ) lifecycleScope.launchWhenStarted { messageDao.insert(message) binding.etMessage.setText() // 刷新聊天记录此处简化为重新加载 loadChatHistory() } } } } override fun onResume() { super.onResume() // 进入聊天页时标记所有来自该卖家的未读消息为已读 lifecycleScope.launchWhenStarted { messageDao.markAllAsRead(getCurrentUserId()) loadChatHistory() } } private fun loadChatHistory() { lifecycleScope.launchWhenStarted { val history messageDao.getChatHistory(getCurrentUserId(), sellerId) // 更新 UI此处省略 Adapter 绑定逻辑 } } }提示onResume()是标记已读的最佳时机因为用户可能从通知栏或最近任务进入loadChatHistory()应使用ListAdapter实现高效 Diff避免全量刷新。4.3 状态更新机制在详情页提供“已售出”按钮并同步数据库在PostDetailActivity底部添加一个MaterialButton文本为“标记为已售出”点击后更新Post.status并刷新 UI// activity/PostDetailActivity.kt binding.btnMarkSold.setOnClickListener { lifecycleScope.launchWhenStarted { try { post.status 1 // 1已售 post.updatedAt System.currentTimeMillis() postDao.update(post) binding.btnMarkSold.text 已售出 binding.btnMarkSold.isEnabled false Toast.makeText(this, 状态已更新, Toast.LENGTH_SHORT).show() } catch (e: Exception) { Toast.makeText(this, 更新失败${e.message}, Toast.LENGTH_SHORT).show() } } }注意btnMarkSold状态变更需持久化到 UI如禁用按钮、改变文字颜色否则用户可能重复点击postDao.update()要求post.id不为 0因此Post实体必须从数据库查询后修改不能新建对象直接 update。5. 毕业设计交付关键技巧数据库 ER 图绘制、APK 打包与答辩演示要点5.1 用 draw.io 绘制清晰可读的校园二手交易系统 ER 图毕业论文要求的 ER 图不是越复杂越好而是要准确反映核心实体关系。针对本项目只需画清Post与Message两个实体以及它们与隐含的User用authorId/senderId/receiverId关联的关系。draw.io 操作步骤如下访问 https://app.diagrams.net 新建空白图从左侧“Entity Relation”栏拖出三个“Entity”形状分别命名为Post、Message、User在Post中添加字段id (PK),title,price,authorId (FK),status,createdAt在Message中添加字段id (PK),senderId (FK),receiverId (FK),content,isRead,createdAt用“Crows Foot”连接线从User.id分别连向Post.authorId、Message.senderId、Message.receiverId标注“1:N”导出为 PNG插入论文“数据库设计”章节配文字说明“Post表存储商品信息status字段支持待售/已售/下架三种状态Message表实现点对点通信isRead字段用于未读消息统计”。提示不要画User表的全部字段如密码、邮箱只保留id和nickname即可体现“够用就好”的工程思维status字段旁加注释“0待售,1已售,2下架”方便答辩老师快速理解。5.2 生成 Release APK 并验证安装包完整性Debug 版 APK 无法上交必须生成签名 Release 包。步骤如下在 Android Studio 顶部菜单选择Build → Generate Signed Bundle / APK选择APK点击 Next若无密钥库点击Create new...填写Key store path选一个安全路径如D:\gradle\mykey.jksPassword / Confirm设强密码如Campus2024!AliascampuskeyKey password同上或单独设Validity25 年满足毕业设计长期存档需求Key store password 和 Key password 均填入第 3 步密码Build Type 选releaseSignature Versions 勾选V1(Jar Signature)和V2(Full APK Signature)Finish生成app-release.apk。验证方式将 APK 拖入真机或模拟器安装启动后检查首页能否正常加载预置的两条测试帖子点击任一帖子进入详情页底部“标记为已售出”按钮可点击且状态实时更新点击“联系卖家”跳转聊天页输入消息可发送并显示在对话中返回首页再次进入同一帖子详情页“已售出”按钮保持禁用状态。注意若安装失败提示INSTALL_FAILED_NO_MATCHING_ABIS说明 APK 架构与设备不匹配可在app/build.gradle的defaultConfig中显式指定ndk { abiFilters armeabi-v7a,arm64-v8a }。5.3 答辩演示话术设计聚焦“我做了什么”而非“系统有多好”答辩时老师最关心的是你是否真正动手、是否理解技术选型理由。建议按以下节奏陈述限时 5 分钟开场30秒“各位老师好我的毕业设计是‘基于 Android Studio 的校园二手交易系统’目标是实现一个可在本校学生间使用的轻量级 App核心链路是发帖→浏览→私聊→状态更新。”技术选型1分钟“我选用 Room 而非原始 SQLite因为它的注解编译能避免 SQL 拼写错误LiveData 支持 UI 自动刷新选用 Coil 而非 Glide因其更轻量、Kotlin 友好数据库只建了posts和messages两张表status字段用整型编码便于状态机扩展。”亮点实现2分钟“我实现了三个关键点第一首页 RecyclerView 使用 ViewBinding 和 ListAdapter滚动流畅无闪烁第二私聊页进入时自动调用markAllAsRead()确保未读数准确第三发帖时封面图用R.drawable资源 ID 存储规避了网络图片权限和路径问题——这是针对毕业设计环境的务实方案。”演示与总结1.5分钟”“现在我演示发布一本《高等数学》首页立即出现新帖点击进入详情页标记为已售出按钮变灰再点联系卖家发送消息‘请问还在吗’对方收到后未读数1。整个过程全部在本地完成无需服务器。我的工作集中在架构搭建、数据流贯通和边界 case 处理代码已整理为规范工程结构README.md 包含运行说明。”提示演示前务必关闭手机“开发者选项”中的“窗口动画缩放”避免点击响应延迟准备一张打印的Post表字段截图被问及时可快速指出status和updatedAt的作用。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门