安卓图书管理系统开发实战:从SQLite表设计到RecyclerView实现
发布时间:2026/9/25 4:43:52来源:尧图网络
简介安卓图书管理系统Android Studio版本是一套完整的图书数据管理实战项目面向Android初学者、移动应用开发学习者及有课程设计需要的学生主要解决图书信息的存储、筛选与维护问题。项目基于SQLite数据库设计通过SQLiteOpenHelper类管理数据库创建与版本升级利用SQLiteDatabase类执行插入、删除、修改、查询操作并展示了数据持久化、列表展示、按书名/作者等条件过滤、二维码扫码入库等扩展功能能够帮助读者快速掌握Android中SQLite的标准用法。压缩包约23.26MB可直接导入开发工具查看工程结构并运行调试。目前已有4060人学习浏览是实践性很强的教学参考资料。读者可从中学到如何设计录入表单、绑定列表适配器、刷新列表数据、完成增删改查逻辑同时了解扫码入库、索引优化与真机调试等进阶思路形成从界面到数据层的完整开发链。1. 这个“安卓图书管理系统”到底要做什么先分清课程设计和生产系统看到“安卓图书管理系统(Android Studio版本)”这个标题我敢打赌你多半是在做课程设计、毕业设计或者想拿一个完整的 Android Studio 项目练手。这个标题背后是一个很典型的单体 App 需求用户能登录、查书、借书、还书管理员能增删改图书、看借阅记录。教学场景里最常见的做法是“Android Studio SQLite 单机版”不依赖后端服务器所有数据都存在手机本地数据库里。这个方案能跑通但它和真正上线的图书管理系统差着一个“多端数据同步”的距离——明确这一点你就知道这个标题里哪些功能值得做深哪些功能只是点缀。适合读这篇的人有三类第一次做安卓课设的学生想用最短路径把功能全部点亮接手别人工程后改得一头雾水的开发者以及打算在课设基础上继续扩展成毕设的读者。下面我会按“功能拆解、数据库设计、工程骨架、页面实现、踩坑记录、后续升级”的顺序把这个事情讲透代码直接照着敲就能跑。2. 功能拆分与表设计先把“借书-还书-查书”变成三张表2.1 功能清单先于代码图书管理的四个模块边界很多第一次做这个题目的人打开 Android Studio 就开始拖控件结果做到一半发现“借阅记录”没地方放或者管理员和普通用户的权限根本分不开。我的习惯是先在纸上把功能边界画出来。一个标准的课设版图书管理系统功能可以收敛成四块用户模块注册、登录、退出。普通用户能查书、借书、还书管理员额外拥有图书入库、编辑、下架和查看全部借阅记录的权限。图书模块图书列表展示、按书名或作者检索、图书详情、新增/编辑/删除仅管理员。借阅模块借书扣减库存、还书归还库存并计算是否超期、借阅历史查询。统计模块在首页展示“馆藏总数、已借出数、在架数”这通常是课设答辩时最容易加分的点代码量却很小。模块边界怎么影响表设计举个例子如果“借阅记录”里没有单独存“应还日期”而是靠“借书日期固定借期”现场计算那么以后想改借期就会让历史记录全部乱掉。所以在建表阶段就要把“借阅记录”做成独立表而不是挂在图书表或者用户表下面。数据表就是整个系统的地基地基歪了后面所有页面都会跟着别扭。2.2 三张核心表用户表、图书表、借阅记录表的建表 SQLSQLite 是这个阶段最稳的选择不需要装 MySQL不需要配网络手机本地一个文件就搞定。下面是我在这个标题下会直接使用的三张表结构-- 用户表 CREATE TABLE tb_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, role INTEGER DEFAULT 0, -- 0 普通用户1 管理员 create_time TEXT DEFAULT (datetime(now,localtime)) ); -- 图书表 CREATE TABLE tb_book ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_name TEXT NOT NULL, author TEXT, isbn TEXT, category TEXT, price REAL DEFAULT 0.0, stock INTEGER DEFAULT 1, -- 总库存 borrowed_count INTEGER DEFAULT 0, -- 当前已借出数量 cover_path TEXT, -- 封面图片路径不存二进制 create_time TEXT DEFAULT (datetime(now,localtime)) ); -- 借阅记录表 CREATE TABLE tb_borrow ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, user_id INTEGER NOT NULL, borrow_time TEXT DEFAULT (datetime(now,localtime)), due_time TEXT, -- 应还时间由程序计算后写入 return_time TEXT, -- 实际归还时间NULL 表示未还 status INTEGER DEFAULT 0 -- 0 借出中1 已归还2 超期未还 );建表 SQL 里的几个关键设计我现在解释一下tb_user的role用 INTEGER 存角色0 和 1 的语义写在注释里这样 SQLite 这种本身没有原生布尔类型的数据库存起来最省事。tb_book把“总库存”和“当前已借出”拆成两个字段而不是在借阅时去数记录条数——后者在数据量大了以后会让列表页变卡。tb_borrow里的due_time必须在借出时就算好并写进去不要等查询时再算。2.3 这版表设计为什么这么定类型选择与冗余字段可能你会问price为什么不用 TEXT图书价格可能带小数REAL 直接支持浮点运算后期做“按价格区间筛选”时不用转型。cover_path为什么不存图片字节这是我见过最多人踩的坑——把图片转成 byte[] 塞进 SQLite一个几十 KB 的图片会让数据库文件膨胀到几十 MB而且读取时会卡主线程。正确做法是图片存到getExternalFilesDir()或files目录数据库里只存相对路径显示时再通过Glide或BitmapFactory按需加载。还有一个容易被忽略的点tb_borrow里的user_id和book_id没有写外键约束。SQLite 默认不强制外键课设阶段靠应用层代码保证数据一致性足够了但如果你在 Android Studio 里用Room它会把外键和索引这些事重新管起来。表设计到这一步填空题已经完成接下来的问题是怎么在 Android Studio 里把这些建表 SQL 变成代码。3. Android Studio 项目骨架与数据库访问层从 New Project 到 SQLiteOpenHelper3.1 新建工程与依赖配置minSdk、JDK 版本与 Material 控件新建工程时建议选择Empty Views Activity而不是 Compose 模板。原因很实际课设和大多数现有教程都基于 XML 布局 Activity 写法你搜索“安卓图书管理系统”拿到的参考代码也基本是这个套路选 Compose 会让抄作业成本变高。Language选 Java 还是 Kotlin 看你的教材我这里用 Kotlin 写但所有概念对 Java 同样成立。build.gradle模块级里需要确认几件事minSdk设 24 或 26 就够别追新compileSdk用你本机 Android Studio 默认建议的版本依赖里加上 Material 组件和 RecyclerView因为列表页用 ListView 虽然简单但 RecyclerView 在性能和后续扩展上明显更稳android { namespace com.example.library compileSdk 34 defaultConfig { applicationId com.example.library minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } } dependencies { implementation androidx.appcompat:appcompat:1.7.0 implementation com.google.android.material:material:1.12.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 implementation androidx.recyclerview:recyclerview:1.3.2 }这段配置里有三个参数最容易出问题我分别说明一下namespace必须和你的包名一致不一致会直接编译失败targetSdk 34意味着系统权限行为按 Android 14 处理读写外部存储时的权限申请逻辑不太一样课设里我一般建议所有文件读写都走getExternalFilesDir()这样连存储权限都可以不用申请material库会带来一套新的主题父类如果原来的主题是Theme.AppCompat.Light.DarkActionBar换成Theme.Material3.DayNight更省心。3.2 编写 DBHelper 与 BookDao把增删改查封装成可调用方法数据库访问层是整个项目最值得认真写的部分。很多人把所有 SQL 直接写在 Activity 里后期改一个表名要全局搜索替换。正确姿势是写一个继承SQLiteOpenHelper的类负责建表和升级再写一个 DAO 类把增删改查封装成方法class DBHelper(context: Context) : SQLiteOpenHelper(context, library.db, null, 1) { override fun onCreate(db: SQLiteDatabase) { db.execSQL( CREATE TABLE tb_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, role INTEGER DEFAULT 0, create_time TEXT DEFAULT (datetime(now,localtime)) ) ) // 预置管理员账号 admin / admin123避免第一次启动无法登录 db.execSQL( INSERT INTO tb_user (username, password, role) VALUES (admin, admin123, 1) ) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { // 生产环境要写迁移逻辑课设阶段很多直接 drop 重建 db.execSQL(DROP TABLE IF EXISTS tb_user) // 其他表同理 onCreate(db) } }这段代码里有三个地方需要注意。数据库文件名library.db决定了 SQLite 文件在手机里的实际名字你可以用 Android Studio 自带的App Inspection工具直接查看这个文件里的数据比在代码里 Log 打印所有查询结果方便得多。构造方法最后一个参数是version1它直接对应onUpgrade的触发条件——只要你改大这个版本号系统就会调用升级方法。onCreate里预置的admin/admin123是课设答辩刚需不然评委第一次打开应用连登录页都进不去。接下来是 BookDAO 的写法。注意所有数据库操作都要在子线程执行或者至少用简单的方式避免阻塞主线程class BookDao(private val dbHelper: DBHelper) { fun addBook(book: Book): Boolean { val db dbHelper.writableDatabase val values ContentValues().apply { put(book_name, book.bookName) put(author, book.author) put(isbn, book.isbn) put(category, book.category) put(price, book.price) put(stock, book.stock) } return db.insert(tb_book, null, values) ! -1L } fun searchBook(keyword: String): ListBook { val db dbHelper.readableDatabase val cursor db.rawQuery( SELECT * FROM tb_book WHERE book_name LIKE ? OR author LIKE ?, arrayOf(%$keyword%, %$keyword%) ) val list mutableListOfBook() while (cursor.moveToNext()) { list.add( Book( id cursor.getInt(cursor.getColumnIndexOrThrow(id)), bookName cursor.getString(cursor.getColumnIndexOrThrow(book_name)), author cursor.getString(cursor.getColumnIndexOrThrow(author)), stock cursor.getInt(cursor.getColumnIndexOrThrow(stock)) ) ) } cursor.close() return list } }rawQuery里的?占位符是重点一定不要用字符串拼接把用户输入直接拼进 SQL否则输入一个单引号就能让语句崩掉。LIKE ?配合%keyword%是 SQLite 里做模糊搜索最朴素也最可靠的写法中文按单字索引也能正常工作。getColumnIndexOrThrow比getColumnIndex安全后者查不到列名时返回 -1再往下取数据会抛异常前者至少能让你立刻定位到是 SQL 写错了列名。3.3 Manifest 注册与权限一笔带过的后台任务与网络限制AndroidManifest.xml 里需要确认三件事所有 Activity 都要注册漏掉一个会导致运行时崩溃而不是编译错误application节点里配android:theme用 Material3 主题如果没接网络接口不需要申请INTERNET权限。这个项目是单机版不要往 Manifest 里加一堆用不上的权限——每加一个权限应用商店审核和用户信任度都会打折扣。最后要提一个很多人忽略的点SQLite 默认在应用私有目录里不需要存储权限。如果你非要把数据库导出到手机公共目录才需要处理 Android 11 以后的MANAGE_EXTERNAL_STORAGE申请而这会让整个工程的权限逻辑复杂一个量级。课设阶段千万别碰这个功能实在想展示数据用App Inspection连模拟器看就行。4. 从登录页到图书列表三条落地路线和它们的代码骨架4.1 登录页先写校验再写查询避免把空账号发给数据库登录页是这个系统里第一个交互页面也是评委必看的页面。逻辑不复杂拿输入框的账号密码去tb_user表里比对匹配则跳主页不匹配则弹 Toast。但有一个细节会暴露代码水平——输入校验必须在前端先做掉fun login(view: View) { val username etUsername.text.toString().trim() val password etPassword.text.toString().trim() if (username.isEmpty() || password.isEmpty()) { Toast.makeText(this, 账号和密码不能为空, Toast.LENGTH_SHORT).show() return } val db DBHelper(this).readableDatabase val cursor db.rawQuery( SELECT id, role FROM tb_user WHERE username ? AND password ?, arrayOf(username, password) ) if (cursor.moveToFirst()) { val role cursor.getInt(cursor.getColumnIndexOrThrow(role)) startActivity(Intent(this, MainActivity::class.java)) finish() // 把登录状态保存到 SharedPreferences供后续页面判断角色 getSharedPreferences(login, MODE_PRIVATE) .edit() .putInt(role, role) .putString(username, username) .apply() } else { Toast.makeText(this, 账号或密码错误, Toast.LENGTH_SHORT).show() } cursor.close() }这里trim()是第一个关键动作EditText 里常见的首尾空格会让人误以为自己输错了密码排查半天结果发现是换行符的问题。第二个关键动作是登录成功后的SharedPreferences保存——这个工具类不落数据库、不占内存是最轻量的状态保存方式。第三个关键动作是cursor.close()随手关闭游标是防止内存泄漏的职业习惯Android 的 SQLite 游标不关闭下一次查询打开时可能因为连接被占满直接崩。密码存储这里我多说一句明文存密码在课设里很常见但不妨碍你用一个MessageDigest做 SHA-256 再入库代码只多三行答辩时却能从“能跑”变成“有安全意识”的评语。这就是典型的小成本加分项。4.2 图书列表与 AdapterListView 到 RecyclerView 的替换逻辑图书列表页是这个系统信息量最大的页面。你要展示书名、作者、库存、封面这几个字段同时还要支持点击跳详情、长按删除。用 RecyclerView 是现在的主流写法它比 ListView 多的东西主要是 ViewHolder 复用机制让列表滚动时不再反复findViewByIdclass BookAdapter( private val books: ListBook, private val onClick: (Book) - Unit ) : RecyclerView.AdapterBookAdapter.BookViewHolder() { class BookViewHolder(val binding: ItemBookBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookViewHolder { val binding ItemBookBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return BookViewHolder(binding) } override fun onBindViewHolder(holder: BookViewHolder, position: Int) { val book books[position] holder.binding.tvBookName.text book.bookName holder.binding.tvAuthor.text 作者${book.author} holder.binding.tvStock.text 库存${book.stock} holder.binding.root.setOnClickListener { onClick(book) } } override fun getItemCount() books.size }ViewBinding是这里第一个值得注意的东西ItemBookBinding由 Android Studio 根据item_book.xml自动生成省掉了findViewById的类型转换出错问题。setOnClickListener放在onBindViewHolder里是常规做法数据量到几百条时性能差别不大但如果你把点击事件在onCreateViewHolder里加就必须用holder.bindingAdapterPosition取当前位置否则复用后点错行。item_book.xml里的根布局建议用CardView自带圆角和阴影效果视觉上比纯LinearLayout精致很多而且不需要额外引入第三方库。4.3 新增/编辑/删除一个 EditText 表单提交到入库的完整路径表单页是另一个高频页面。新增图书时你需要用AlertDialog或者单独的Activity收集数据。课设阶段我推荐直接用AlertDialog因为代码短、逻辑集中不太容易写出多余页面fun showAddBookDialog() { val dialogView layoutInflater.inflate(R.layout.dialog_add_book, null) AlertDialog.Builder(this) .setTitle(新增图书) .setView(dialogView) .setPositiveButton(保存) { _, _ - val name dialogView.findViewByIdEditText(R.id.etBookName).text.toString() val author dialogView.findViewByIdEditText(R.id.etAuthor).text.toString() val stock dialogView.findViewByIdEditText(R.id.etStock).text.toString().toIntOrNull() ?: 1 if (name.isEmpty()) { Toast.makeText(this, 书名不能为空, Toast.LENGTH_SHORT).show() returnsetPositiveButton } BookDao(DBHelper(this)).addBook( Book(0, name, author, stock stock) ) refreshBookList() } .setNegativeButton(取消, null) .show() }这段代码唯一的坑在toIntOrNull() ?: 1用户输入非数字时toIntOrNull()返回 null?: 1兜底成默认库存。这是 Kotlin 里处理用户输入最常见的模式换成 Java 你得写 try-catch代码会多好几行。还有一个隐藏细节refreshBookList()必须在对话框关闭后调用而不是在setPositiveButton里直接调——因为此时BookDao的写操作已经同步执行完了再刷新才能看到最新数据。如果这时发现列表没更新检查是不是查询用的DBHelper实例和插入用的不是同一个——SQLite 在多实例下会出现可见性延迟这个问题我放在第 5 章细说。5. 安卓图书管理系统最常见的 5 个坑从编译失败到静默崩溃这里写的每条都是我见过真实翻车的记录覆盖从“工程跑不起来”到“跑起来但数据错乱”的问题按排查顺序排列。5.1 坑 1SDK/AGP 版本与源程序不匹配一编译就上百个红叉现象从网上下载的课设工程导入 Android StudioGradle 同步转圈半天然后报一堆类似Failed to resolve: com.android.support:appcompat-v7或The android.defaults has been removed的错误。原因老工程用的是旧版 Android Support Library你本机 SDK 已经不带这个依赖了或者工程配置的compileSdk版本高于你实际安装的 SDK 平台。解决优先去build.gradle里把compileSdk改成你机器上已安装的版本同时把com.android.support依赖改成androidx.appcompat的对应替代。还有一个我常用的土办法新建一个空工程把它自动生成的build.gradle内容覆盖到老工程上只保留namespace、applicationId和依赖列表这样版本冲突会少很多。5.2 坑 2SQLite 升级不写版本号加字段后老用户闪退现象开发阶段改过表结构比如给tb_book加了category字段重新跑 App 后一进列表页就崩Logcat 里报no such column: category。原因SQLiteOpenHelper只有在构造器传入的版本号变大时才会触发onUpgrade。你建表 SQL 改了但DBHelper(context)里的最后一个参数还写的 1App 安装过一次后数据库文件不会重建。解决把构造器版本号从 1 改成 2在onUpgrade里执行ALTER TABLE tb_book ADD COLUMN category TEXT不要图省事直接 DROP 重建——答辩时老师如果问“升级后原数据还在吗”重建是答不上来的。开发期还有一个更狠的兜底办法卸载 App 重装但交作业前一定要验证一遍升级路径因为评委可能直接在你演示时改数据。5.3 坑 3onBackPressed 重写不生效按返回键直接退出程序现象想在图书列表页按返回键弹一个“确认退出”对话框重写了onBackPressed方法结果按键后没有任何反应或者直接退出了。原因Android 13targetSdk 33 及以上开始老的onBackPressed回调被系统废弃改由OnBackPressedDispatcher管理。解决不要重写 Activity 的onBackPressed而是在onCreate里注册回调onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { AlertDialog.Builder(thisMainActivity) .setMessage(确认退出图书管理系统) .setPositiveButton(退出) { _, _ - finish() } .setNegativeButton(取消, null) .show() } })这里OnBackPressedCallback(true)的第一个参数表示回调是否启用。如果你在子页面用了enable(false)来暂时屏蔽返回键注意在页面onResume时重新置回true否则会出现“返回键彻底失灵”的诡异现象。这是我在适配 Android Studio 新版模板时踩过最典型的“文档没说清”的坑。5.4 坑 4ListView 加载封面图片直接 OOM图片字节千万别进数据库现象图书表加了封面功能后列表页一滚动就卡顿偶尔直接闪退Logcat 报OutOfMemoryError。原因两种典型错误。一是把图片压缩成byte[]存进 SQLite列表加载时读出来再解码一张 2MB 的图就能撑爆堆内存二是不管屏幕尺寸直接用BitmapFactory.decodeFile加载原图。解决数据库里只存cover_path字符串图片本身放到 app 私有目录加载时用BitmapFactory.Options的inSampleSize做采样压缩每本书的实际封面撑死显示 200dp 宽采样值至少设 4val options BitmapFactory.Options().apply { inSampleSize 4 } val bitmap BitmapFactory.decodeFile(coverPath, options) holder.binding.ivCover.setImageBitmap(bitmap)如果你在这个项目里用了 Glide 这类图片加载库那么上面这段代码都可以不写——它内部会按 View 尺寸自动采样压缩这也是我为什么在依赖里建议直接加implementation com.github.bumptech.glide:glide:4.16.0的原因。5.5 坑 5软键盘把输入框挡死adjustResize 失效的排查顺序现象在新增图书的表单页点击底部输入框时软键盘弹出输入框被完全遮住看不到自己打了什么字。原因AndroidManifest 里给 Activity 配了windowSoftInputModeadjustPan或没配键盘弹出时系统不会把布局顶上去。另一个常见原因是根布局用了ScrollView但高度配了match_parent滚动区域没有余量让键盘避让。解决先把 Manifest 里对应 Activity 配成android:windowSoftInputModeadjustResize然后确认根布局是ScrollView且有fillViewporttrue。如果仍然被遮挡检查主题里是否用了WindowTranslucentStatus之类的沉浸式状态栏配置——这个配置会和adjustResize打架。这套排查顺序从最简改动到最少见原因能覆盖八成以上的键盘遮挡场景。6. 把 SQLite 裸写换成 Room三步迁移与后续扩展走到这一步你的“安卓图书管理系统(Android Studio版本)”已经是一个功能完整的可运行项目了。但如果你想在毕设或者简历项目里拔高一个档次我建议花半天时间把数据库访问层从SQLiteOpenHelperCursor迁到Room。原因不是 Room 更快而是它把 SQLite 的很多运行时错误提前到了编译期表结构写错、字段名拼错、查询语句不合法编译时直接报错不用等 App 跑起来才闪退。这相当于给数据层加了一个强类型保险丝。迁移只分三步。第一步加依赖把room-runtime和room-compiler引入build.gradle。第二步把实体类和 DAO 接口化Entity(tableName tb_book) data class Book( PrimaryKey(autoGenerate true) val id: Int 0, ColumnInfo(name book_name) val bookName: String, val author: String , val stock: Int 1 ) Dao interface BookDao { Query(SELECT * FROM tb_book WHERE book_name LIKE % || :keyword || %) fun search(keyword: String): ListBook Insert fun insert(book: Book): Long }第三步在Application或Activity里建Room.databaseBuilder(...).allowMainThreadQueries()拿实例。注意allowMainThreadQueries()这个名字很反直觉——它允许你在主线程跑查询语句但我建议你在正式设计里配合LiveData用让数据库操作自动回到子线程这才体现出 Room 和裸写 SQLite 的真正差别它自带线程切换和数据观察机制。当你把这三步做完回头看这个项目你会发现之前写的DBHelper和BookDao里的重复模板代码全部被框架收编了。这也是我每次接手类似课设工程时的收尾习惯先让功能全跑通再挑一个最痛的点做一次结构性升级而不是把所有功能都推倒重写。这一次升级的经验比照着教程敲十遍代码都更能解释清楚 Android 的数据库体系是怎么回事。希望这篇笔记能让你少走几个我已经踩过的弯路。本文还有配套的精品资源点击获取
网站建设高端定制企业官网