☰
基于Android的校园图书共享系统:借阅状态机设计与实现
2026/10/11 12:42:52 网站建设 项目流程

简介:一套基于Android的校园图书共享系统毕业设计项目资源,面向移动应用开发学习者与准备毕业设计的在校生,用于解决校园内图书资源流通不畅、借阅登记效率低的问题。压缩包内共收录2000个文件,以Java源文件、XML界面布局、PNG图片资源和配置文件为主,同时包含MySQL数据库SQL脚本与编译生成的class文件,整体大小57.58MB,能够完整体现从客户端到服务端的工程实现。该资源已有79人学习下载,可作为毕设代码参考、系统设计案例或Android全栈练习素材。项目内提供数据库初始化脚本、后台服务逻辑模块及完整Android工程结构,可重点研究Activity生命周期管理、HTTP网络交互、数据表关联设计与索引优化,并兼顾界面美观性与用户操作便捷性的实践思路,有利于系统化理解校园应用从功能规划到安全落地的全流程。

1. 基于Android的校园图书共享系统:真正的闭环是借阅状态机,不是图书列表

做校园图书共享系统,最常见的是把“共享”做成了“展示”:注册登录、图书列表、图书详情,顶多加个管理员录入。答辩时被问一句“这本书现在在谁手里?你借了之后书主怎么知道?”如果答案是“我做了个留言板”,那这份毕业设计基本过不了关。共享类App的核心不是信息发布,而是一套状态机:图书要经历“可借、申请中、已借出、已归还”的流转,借阅记录要跟着图书一起变,必要时还要有消息通知。这篇笔记把基于Android的校园图书共享系统的技术选型、数据模型、发布与借阅流程,以及真机上的存储和推送坑梳理一遍,适合正在做毕设的学生,也适合拿共享类App当练手项目的Android开发新手。

2. 技术选型与整体架构:先决定后端,再写Android代码

2.1 后端三选一:Bmob 后端云、LeanCloud、自建 Spring Boot

做这个系统,数据层的事情比界面多。图书、用户、借阅记录、消息,四类数据都需要服务端承载,所以第一步不是建 Android 工程,而是决定后端用什么。我见过不少做到一半卡住的人,大多是在 Android Studio 里写了大量界面代码,却没有先想清楚“借阅状态”存在哪里。

如果你的目标是快速出效果、把主要精力放在 Android 端,常见做法是用 Bmob 或 LeanCloud 这类后端云。它们自带用户系统、对象存储、推送和即时消息模块,毕业设计里最费事的注册登录和文件上传都变成接口调用,控制台也能直接看数据,方便答辩时演示。自建 Spring Boot 则需要自己搞 MySQL、Redis 和部署,好处是技术含量高,答辩时有讲不完的设计,坏处是如果之前没写过服务端,光调通借阅并发更新就很折磨人。

维度Bmob 后端云LeanCloud自建 Spring Boot
部署成本免运维免运维需要服务器和域名
用户系统自带自带自己写
消息推送支持支持接入第三方或轮询
答辩加分中等中等高
风险点免费额度有限制免费额度有限制部署和并发容易翻车

我的建议是:如果这是一份要求“完整工程”的题目,且你目前对后端不熟,选后端云更稳;如果你已经有 Spring Boot 或 Spring Cloud 经验,再考虑自建。后面所有代码逻辑,我都按“后端云 + 本地 Room 缓存 + 状态机驱动界面”的套路来讲,这符合大多数共享系统的实际需要。

2.2 Android 端用 MVVM:为什么是 ViewModel 加 Repository

Android 端架构上,最合适的是 MVVM。它不是因为听起来高级,而是因为三个痛点都对应它的三层:界面要刷新,交给 LiveData/Flow;数据要缓存,交给 Room;业务要复用,交给 Repository。纯 Activity 写业务,书列表、借阅记录、消息这三张页面的逻辑会互相拷贝,改一处漏两处,这是最典型的毕业设计翻车现场。

具体分层是:Activity/Fragment 只做视图展示和事件收集;ViewModel 持有界面状态,比如图书列表的加载中、空态、错误提示;Repository 负责把 Room 和网络/云 SDK 接起来,向上层暴露可订阅数据。这样答辩时老师问你“某本书的状态是谁维护的”,你能清楚指到 Repository;他不信,你现场改代码也快。

一个常见的误解是把 ViewModel 当成仓库,直接在 ViewModel 里写 Room 或云调用。一旦换后端或换本地表,ViewModel 就要动,这是不划算的。正确做法是 ViewModel 里只调用 repository.fetchBooks() 这类接口,内部是 Room 还是 Retrofit,不影响界面。

2.3 最小骨架:一个能跑起来的包结构与依赖

在 Android Studio 新建项目后,我习惯先按下面的包结构把工程搭好,再开始写业务。这不是花架子,而是为了让 Room、云 SDK、权限申请这些横切关注点不散落在各个 Activity 里。

com.example.bookshare ├── data │ ├── local # Room 数据库、Dao、Entity │ ├── remote # Bmob/LeanCloud 或 Retrofit 接口封装 │ └── repository # 数据仓库 ├── ui │ ├── login # 登录注册 │ ├── home # 图书列表和筛选 │ ├── publish # 发布图书 │ ├── detail # 图书详情与借阅申请 │ └── message # 借阅消息 └── common ├── status # 图书状态、借阅状态枚举 └── util # 图片、权限工具

依赖方面,不要无脑复制大而全的模板。一个校园图书共享系统如果使用后端云,build.gradle 里大致是这些模块:androidx 的 appcompat、material、constraintlayout;lifecycle-viewmodel-ktx 和 lifecycle-livedata-ktx;room-runtime 和 room-ktx;Glide 用于加载封面;work-runtime 用于轮询通知。如果用 Bmob 或 LeanCloud,再把官方 SDK 依赖加进去,并记得在 AndroidManifest 里配置它要求的 App 初始化参数。

这些依赖里最容易出错的是 Room 和协程。Room 2.4 以后支持 Flow 查询,配合 suspend 方法写起来最顺手;如果你还用 AsyncTask 查数据库,答辩时会被问“为什么不用协程”。另一个容易忽略的是 Glide,看到封面图是 content:// 或网络 URL 时 URI 解析会出问题,后面第 4 章专门讲。

3. 核心数据模型:把 Book 和 BorrowRecord 画成状态机

3.1 Book 实体与字段设计

共享系统里最重要的实体是 Book。除了书名、作者这些展示字段,必须包含两个关键字段:ownerId 表示图书所有者,status 表示图书当前状态。很多人把 status 设计成布尔值(在架/下架),这是不够的——你需要区分“可借、申请中、已借出、待归还”,否则消息界面和借阅记录都写不完整。

字段类型说明
objectIdString后端云主键,也是 Room 主键
titleString书名
authorString作者
publisherString出版社
isbnStringISBN 可留空
coverUrlString封面图地址
ownerIdString发布者/所有者 ID
ownerNameString发布者昵称,冗余存储省一次联表查询
campusString校区,共享系统筛选常用
locationString约定线下交易位置
statusStringAVAILABLE / BORROWING / OFFLINE 等
createTimeLong发布时间时间戳

campus 字段在校园场景里非常有用。共享书籍必须解决“怎么把书从 A 手上到 B 手上”的问题,所以要让用户选择校区和交易位置。这个字段在首页筛选时是高频查询条件,建议在数据库表里加索引。如果你用后端云,控制台可以直接给字段加索引,避免首页列表越查越慢。

对于换书功能,我建议毕设阶段只做借阅闭环。换书要牵一套等价匹配逻辑,而借阅才是核心,先做好借阅,答辩效果反而比功能多而乱更好。

3.2 借阅状态机:一条记录管一本书的来龙去脉

借阅记录 BorrowRecord 才是系统的灵魂。它的字段包括:recordId、bookId、borrowerId、ownerId、status、applyTime、approveTime、returnTime。图书状态和借阅记录状态必须联动,但它们是不同的东西:

图书 Book.status:AVAILABLE(可借)、BORROWING(已借出)、OFFLINE(下架)。 借阅记录 BorrowRecord.status:PENDING(等待书主同意)、APPROVED(已同意,等待取书)、BORROWING(已借出)、REJECTED(拒绝)、CANCELLED(借阅人取消)、RETURNED(已归还)。

流转是这样跑的:发布一本教材后 Book 为 AVAILABLE;同学 A 申请借阅,生成一条 PENDING 的 BorrowRecord,此时 Book 仍然保持 AVAILABLE,因为 A 可能被拒绝,书不应该被锁死;书主同意后,BorrowRecord 变 APPROVED,Book 变 BORROWING;线下交易完成后,书主或 A 把 BorrowRecord 标记为 RETURNED,Book 变回 AVAILABLE。任何一步被拒绝或取消,BorrowRecord 停在对应终态,Book 回到 AVAILABLE。

这套设计答辩时最值得讲。老师问“并发申请怎么办”,你可以答:申请时在服务端按 bookId + status 做条件更新,只允许一条 PENDING 记录存在。老师问“书丢了怎么办”,你可以说 RETURNED 需要书主确认,配合信用分机制做扣分入口。这些不一定要写完,但设计意识要到位。

3.3 Room 本地缓存:让界面先亮出来,再等一下网络

Android 端我习惯用 Room 做本地缓存。理由很直接:校园共享的书量不大,几千条记录而已,Room 一张表足够;同时 Room 的 Flow 查询天然是观察者模式,ViewModel 只需要订阅一次,后续增删改自动往 UI 推。

下面是一张最简 BookEntity 和 BookDao:

@Entity(tableName = "book") data class BookEntity( @PrimaryKey val objectId: String, val title: String, val author: String, val publisher: String, @ColumnInfo(name = "isbn") val isbn: String?, @ColumnInfo(name = "cover_url") val coverUrl: String?, @ColumnInfo(name = "owner_id") val ownerId: String, @ColumnInfo(name = "status") val status: String, @ColumnInfo(name = "campus") val campus: String, @ColumnInfo(name = "create_time") val createTime: Long ) @Dao interface BookDao { @Query("SELECT * FROM book WHERE status = 'AVAILABLE' ORDER BY create_time DESC") fun observeAvailableBooks(): Flow<List<BookEntity>> @Query("SELECT * FROM book WHERE objectId = :bookId") fun observeBookById(bookId: String): Flow<BookEntity?> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(books: List<BookEntity>) @Query("DELETE FROM book WHERE objectId = :bookId") suspend fun deleteById(bookId: String) }

这里有两个设计点要注意。一是主键直接用 objectId,而不是自增 id,因为后端云主键是字符串,Room 支持字符串主键,用 REPLACE 策略不会产生重复记录。二是查询返回 Flow,配合 ViewModel 的 stateIn 或 LiveData 转换,可以做到列表页只在数据库变化时才刷新,避免每次返回首页都重新请求网络。

Repository 层的典型做法:

class BookRepository( private val bookDao: BookDao, private val remoteDataSource: RemoteDataSource ) { fun observeAvailableBooks() = bookDao.observeAvailableBooks() suspend fun refreshBooksFromRemote() { val books = remoteDataSource.fetchAvailableBooks() bookDao.insertAll(books.map { it.toEntity() }) } }

ViewModel 调用 refreshBooksFromRemote 时,先显示本地缓存,再去请求云端,刷新成功由 Room 自动通知界面。这样即便云端请求慢或失败,列表也不会是空白,这是共享类 App 在校园弱网环境下的关键兜底。

4. 发布图书与借阅申请:两个核心流程的落地写法

4.1 选图上传:分区存储和 content URI 的正确姿势

发布图书时最影响体验的是封面图。在 Android 10 之后,直接读 /storage/emulated/0/Android/data/ 下的第三方应用文件会报 Operation not permitted,很多新手在这里卡了几个小时。更稳妥的做法是:用系统文件选择器拿 content:// Uri,再把图片复制到本 App 自己的缓存目录,之后无论压缩还是上传都基于这个本地文件,不依赖其他应用的目录权限。

class PublishBookActivity : AppCompatActivity() { private val pickImageLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { handlePickedImage(it) } } private fun openGallery() { pickImageLauncher.launch("image/*") } private fun handlePickedImage(uri: Uri) { val targetFile = File(cacheDir, "upload/book_${System.currentTimeMillis()}.jpg") targetFile.parentFile?.mkdirs() contentResolver.openInputStream(uri)?.use { input -> FileOutputStream(targetFile).use { output -> input.copyTo(output) } } // 此时 targetFile 是应用自己缓存目录下的文件,可以放心压缩和上传 compressAndUpload(targetFile) } }

代码里的 cacheDir 指向 App 私有缓存目录,不需要任何存储权限。用 GetContent 拿到的 Uri 是一次性授权,如果不把它复制进私有目录,页面切后台再回来可能就失效了。复制完成后,原始 Uri 就用不到了,后续压缩、上传、预览都基于 targetFile。

4.2 图片压缩与上传:减少流量也减少闪退

上传前我会用 inSampleSize 和 JPEG 85% 压缩,不然手机相册原图动辄几 MB,后端云存储和流量很快到限额,列表页用 Glide 加载大图也容易内存抖动。下面这段是压缩函数:

private fun compressTo(targetFile: File, maxWidth: Int = 1280): File { // 第一次解码只读宽高,不加载像素 val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(targetFile.absolutePath, options) // 按 2 的倍数计算采样率,直到不超过 maxWidth var sampleSize = 1 while (options.outWidth / sampleSize > maxWidth || options.outHeight / sampleSize > maxWidth) { sampleSize *= 2 } val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize inJustDecodeBounds = false } val bitmap = BitmapFactory.decodeFile(targetFile.absolutePath, decodeOptions) FileOutputStream(targetFile).use { out -> bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out) } bitmap.recycle() return targetFile }

maxWidth 设 1280,是因为封面在列表里最多是 200dp 左右的缩略图,详情页最大也就 1080p,再大纯浪费流量和内存。inSampleSize 用 2 的倍数,是系统文档推荐的缩放策略,避免奇数采样引入锯齿。JPEG 85 是清晰度和体积之间的常见折中值。压缩完之后,再用后端云的文件字段或 Retrofit multipart 上传,拿到返回的 URL 填进 Book 的 coverUrl。

Glide 加载 coverUrl 时如果发现旧图一直不更新,多半是缓存问题。可以在请求地址后拼一个时间戳参数,或者用 Glide 的 signature,具体见第 5 章。

4.3 借阅申请:先本地拦截,再远程条件更新

借阅申请不是简单往 BorrowRecord 表插一条数据。先要在本地判断这本书状态是否为 AVAILABLE,再创建 PENDING 记录,同时远程要做一次条件更新,防止两个人同时申请同一本书。

class BorrowRepository( private val recordDao: BorrowRecordDao, private val remoteDataSource: RemoteDataSource ) { suspend fun applyForBook(book: BookEntity, currentUserId: String): Result<Boolean> { return runCatching { // 本地先拦截:状态不是 AVAILABLE 直接返回 if (book.status != BookStatus.AVAILABLE.value) { return Result.failure(IllegalStateException("该书当前不可借")) } // 远程条件更新:只有 status 还是 AVAILABLE 才允许生成申请 val updated = remoteDataSource.applyBorrow( bookId = book.objectId, borrowerId = currentUserId ) if (updated) { recordDao.insert( BorrowRecordEntity( recordId = UUID.randomUUID().toString(), bookId = book.objectId, borrowerId = currentUserId, status = BorrowStatus.PENDING.value, applyTime = System.currentTimeMillis() ) ) } } } }

这里的核心是 remoteDataSource.applyBorrow 并不是无条件成功。后端需要在更新 Book 状态时带上 where 条件,比如只更新 status == 'AVAILABLE' 的记录;或者先插入 PENDING 的 BorrowRecord,再更新 Book,两步都要在校验后执行。如果后端云没有事务机制,你要自己组织一个“先插记录再更新”的请求,失败时用状态码提示“这本书刚刚被别人申请了”。

借阅申请的 UI 反馈也要做两件事:申请成功后跳转消息页给书主发一条新通知;失败时弹 Toast 说明是状态冲突还是网络问题,不要只写“申请失败”,否则用户会反复点按钮,造成脏数据。

4.4 消息通知:WorkManager 轮询比推送更稳

借阅申请、同意、拒绝、归还提醒,这些都要触达用户。毕业设计里最容易踩的坑是只做推送,华为、小米的开发者后台配置复杂,推送到达率还受限制。一个很实用的组合是:本地通知 + WorkManager 周期轮询接口,把“借阅消息”拉下来再弹通知。

class MessageSyncWorker( context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { return runCatching { val messages = remoteDataSource.fetchUnreadMessages(currentUserId()) if (messages.isNotEmpty()) { NotificationHelper.showMessageNotification(applicationContext, messages.first()) } Result.success() }.getOrElse { Result.retry() } } companion object { fun schedule(context: Context) { // 周期任务系统最小间隔约 15 分钟,这里设 30 分钟 val request = PeriodicWorkRequestBuilder<MessageSyncWorker>(30, TimeUnit.MINUTES) .setConstraints(Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build()) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( "message_sync", ExistingPeriodicWorkPolicy.KEEP, request ) } } }

doWork 里每 30 分钟拉一次未读消息,有变化就弹本地通知。如果有赶演示的场景,可以临时调成 15 分钟。把拉到的最新消息存到 Room,消息页打开时直接从 Room 读,和图书列表的缓存策略保持一致。

有人问既然有后端云推送,为什么还要轮询。实际使用中,免费版推送的离线消息支持不稳定,而且很多国产 ROM 会杀掉后台服务。轮询的缺点是流量和电量,但 30 分钟一次完全可接受。答辩时可以大大方方说“为了兼容主流国产手机,我用 WorkManager 轮询做兜底”,这是一个很能加分的工程决策。

5. 避坑:分区存储、Room 迁移、后台推送这 5 个问题

5.1 从相册选完图后,读不到文件

现象:在 Android 11 或 12 的真机上,用户从相册选图后回到页面,ImageView 一片空白,Logcat 报 java.io.FileNotFoundException 或 Operation not permitted,targetSdk 30 以上尤为常见。

原因:分区存储让应用只能访问自己的目录、公共媒体文件和用户通过系统选择器授权的单个文件。你以为拿到的是一个 /storage/emulated/0/Android/data 路径,实际系统给的是 content:// 的 Uri;直接当 File 路径解析就是错的。

解决:统一用 ActivityResultContracts.GetContent 或 OpenDocument 拿 Uri,然后复制到应用自身的 cacheDir 或 filesDir。复制完成后不要再持有原始 Uri,后续的压缩、上传、显示全走本地拷贝文件。如果要在同一 App 的另一个模块访问这个文件,用 FileProvider 的 cache-path 映射,不要在 Intent 里传绝对路径。

5.2 封面更新了,列表页却一直显示旧图

现象:发布新书后,封面图上传成功,但首页列表还是之前的占位图或旧封面;换个账号登录又显示正常。

原因:Glide 对相同 URL 的图片有强缓存。后端云的存储 URL 如果没带时间戳或签名参数,Glide 认为图没变,直接走内存缓存;Room 本地表更新成新 URL 时,列表查询却没有触发新数据加载。

解决:上传成功后把时间戳拼到 coverUrl 后面,例如 coverUrl + "?t=" + System.currentTimeMillis();或使用 Glide 的 signature 设置唯一 key。同时检查 Room 的 @Insert(onConflict = REPLACE) 是否真正更新了 BookEntity,如果主键不是 objectId,插入时就会产生重复行,查询语句会先返回旧数据。

5.3 改了实体类字段后,应用一启动就崩溃

现象:开发中期给 BookEntity 加了 grade 字段,运行时报 Room database is corrupted 或 table book has no column named grade。

原因:Room 的数据库 Schema 是编译期生成的。旧应用安装时数据库版本是 1,新实体版本是 2,但没有提供 Migration,Room 不允许把旧表结构和新版结构强拼。

解决:开发期可以在 RoomDatabase.Builder 里临时加 fallbackToDestructiveMigration(),清库重建,适合调试但不能带到答辩版。正式版本要写 Migration,在 migration 里执行 ALTER TABLE,比如加字段就写 ALTER TABLE book ADD COLUMN grade TEXT NOT NULL DEFAULT ''。答辩前先卸载旧 debug 包再安装演示包,避免旧缓存库造成诡异问题。

5.4 通知有时收不到,有时切到前台才出现

现象:借阅申请提交后,书主手机没弹通知;过几分钟切到前台又突然出现。

原因:很多国产 ROM 对后台应用有严格限制,WorkManager 的周期任务被延迟执行;推送通道配置不当也会丢消息。只用推送,几乎是必然遇到延迟或丢失。

解决:推送和轮询双通道。轮询周期不要设太短,15 到 30 分钟即可,Worker 里加网络约束。另外 NotificationChannel 要在 App 启动时创建,Android 8.0 以下和以上的通知写法要分别兼容,否则通知不弹。答辩演示时,点完申请立刻打开消息页,页面从 Room 读数据已经是同步好的,不必等系统通知。

5.5 同一本书能重复申请,状态被盖掉

现象:两个账号同时打开同一本书的详情页,点“借阅”,两次都提示申请成功;刷新后图书状态混乱。

原因:客户端校验 status 只能防单机误操作,防不住并发。你按 bookId 插了两条 PENDING 记录,Book 状态也被后一次更新覆盖。

解决:把状态转换放到后端条件更新:更新 Book 时指定 where status == 'AVAILABLE',更新 BorrowRecord 时检查是否存在该 bookId 的 PENDING 或 APPROVED 记录。无论是后端云的 update 接口还是自建 Spring Boot 的 UPDATE,都改成条件更新,影响行数为 0 就返回“已被申请”。另外在 BorrowRecordEntity 上建唯一约束(bookId + status),从数据库层面兜住重复申请。

6. 打通最小闭环后怎么验证与加分:先跑通再谈创新

验收这份毕业设计,不要直接给老师看一个光鲜的首页。把最小闭环走通才最有说服力。我的验证习惯是:Android Studio 里开一个模拟器当借书人,真机当书主,走完“书主发布《数据库系统概论》→ 借书人浏览列表并申请 → 书主在消息页看到申请并同意 → 借书人收到通知 → 线下交易后标记归还 → 图书在首页恢复为可借”这条完整链路,每走一步记录一次数据库里的 Book.status 和 BorrowRecord.status。如果每一步的状态变化都和设计一致,核心就已经成立了。

加分项不需要多,挑一个切入就好。方向一是信用分,用户归还后双方互相评分,借书人信用低于阈值限制借书,这是共享类产品的第一信任问题。方向二是封面识别,接入 CameraX 扫描图书封面或 OCR 识别 ISBN,发布表单自动填多数字段,毕设里能讲一条“从相机到数据”的链路。方向三是自建后端,把后端云换成 Spring Boot 加 MySQL,在 Repository 层换一个 RemoteDataSource,Android 架构不会被云 SDK 绑架,答辩时能聊到部署和数据库设计。

最后说一个我自己的习惯:给校园图书共享系统这类项目写代码,最容易被忽略的就是“这本书当前在谁手里”这个状态。我的做法是动手前先拿纸把状态图画完,标清楚谁改状态、哪些操作需要二次确认,再打开 Android Studio 搭工程。状态图定了,界面、接口、数据库都好写;反过来先写列表再补状态,几乎一定返工。希望这篇笔记能帮到你,在毕设或练手路上少踩几个我踩过的坑,把时间省下来打磨真正值钱的那部分功能。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询