☰
Android图书管理系统实战:SQLite存储与ListView列表实现
2026/9/25 6:27:28 网站建设 项目流程

简介:基于安卓开发环境打造的图书管理系统项目,面向想要入门安卓应用开发或学习SQLite数据库操作的开发者。系统实现了图书信息的增加、删除、修改与查询,完整展示了如何通过数据库辅助类管理数据库的创建和版本升级,如何使用数据库操作类执行结构化查询语句,以及如何利用界面组件构建友好的交互页面。同时,项目介绍了二维码扫码入库等可扩展功能,为后续增强实用性和用户体验提供了思路。压缩包以zip格式封装,整体大小约23.26兆,目前已有四千余人学习下载。资源中包含项目源码与知识点梳理,读者能够对照练习安卓原生数据库编程、界面布局和性能调优,并在此基础上继续拓展,整体设计简洁,代码结构清晰,便于二次开发。

1. 安卓图书管理系统:为什么我劝你先用 SQLite 把这条链路走通

图书管理系统在校园里是最常见的课设题,可真正能跑、能演示、能二次修改的版本不多。前几年做课设的人喜欢拿它练手“安卓 sqlite 数据库”,因为需求足够清晰:录入书、查书、借书、还书,总共四件事,却能把 Android 的四大组件、数据存储、列表适配全部串起来。这个 Android Studio 版本的图书管理系统,我反复拆解过不止一遍,整体走的是原生 Java + SQLiteOpenHelper 的路线,不依赖第三方网络库,数据全部落在本机。它适合正在赶课程设计的学生,也适合刚把 Android Studio 环境配好、想找一个完整项目练手的开发者。先把这套原生 SQLite 读写链路吃透,以后再换 Room 或 GreenDao 才有底气,而不是开头就掉进注入框架的坑里。

2. 数据层设计:表结构、类型亲和性与 SQLiteOpenHelper 的选型细节

2.1 实体关系与三张表的核心字段:别上来就只建一张表

我第一次做图书管理系统时只建了一张 book 表,后果是“借阅记录”完全没地方放,每次想看谁借了哪本书,只能在图书表上加一个状态字段,最后连“这本书借出去几天”都算不出来。正确做法是至少拆三张表:图书表 book、分类表 category、借阅记录表 borrow_record。

图书表的核心字段不能只有书名和作者。书名会重复,作者也会重复,真正能定位到唯一一本书的是 ISBN,所以 ISBN 建议建唯一索引。价格字段用 REAL 类型,因为有的书会有 39.8 这样的定价;库存用 INTEGER;封面路径用 TEXT。还有一个很容易忽略的字段是 location,也就是书架位置,没有它,盘点时只能靠肉眼在整间屋子里翻书。

CREATE TABLE category ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, create_time TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE book ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT NOT NULL, isbn TEXT NOT NULL UNIQUE, category_id INTEGER, price REAL DEFAULT 0.0, stock INTEGER DEFAULT 1, location TEXT, cover_path TEXT, create_time TEXT DEFAULT (datetime('now', 'localtime')), FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE SET NULL ); CREATE TABLE borrow_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, borrower_name TEXT NOT NULL, borrow_time TEXT DEFAULT (datetime('now', 'localtime')), return_time TEXT, FOREIGN KEY (book_id) REFERENCES book(id) ON DELETE CASCADE );

这里给 book 表加了外键和唯一约束,借阅记录表则用 book_id 关联图书。参数方面要特别注意:book.isbn 用 TEXT 而不是 INTEGER,因为有些 ISBN 以 0 开头,存成数字会丢首位;borrow_record 的 return_time 允许为空,空值代表这本书还在外面。category 表先建,book 表后建,否则外键关联会报错。SQLite 默认外键约束是关闭的,执行PRAGMA foreign_keys = ON;才能生效,后面避坑章里会提。

2.2 Android 本地存储三选一:SharedPreferences、SQLite、Room 的取舍

做这个项目之前,我先回答了一个问题:为什么不用 SharedPreferences,也不用 Google 官方推荐的 Room?SharedPreferences 本质是 XML 文件,适合保存开关状态和登录 token,不适合做条件查询,你想查“author = 鲁迅”的数据,得把整个 XML 读到内存里手动遍历,数据一多就会卡。Room 当然更好,它有编译期 SQL 校验、支持协程和 Flow,但 Room 需要注解处理器依赖,很多刚配好 Android Studio 的人卡在 kapt 配置上,一报错就是红一整片工程。

SQLiteOpenHelper 是 Android 原生提供的数据库帮助类,不引入额外依赖,SDK 自带的 SQLite 引擎直接可用。取舍参数列在下面。

方案依赖成本查询能力升级难度适合场景
SharedPreferences无弱,只能全量读无概念开关、小数据量 KV
SQLiteOpenHelper无强,支持 SQL中等,需自己写课设、中小型单机应用
Room需要 kapt/ksp强低,Migration 机制大型项目、团队协作

如果是课程设计,选 SQLite 的收益比 Room 高一截:答辩时老师问起底层原理,你能讲清楚 SQLite 的读写过程,而不是只会说“Room 帮我封装了”。从后期维护看,SQLite 的 SQL 语句可以直接迁移到 MySQL 或 PostgreSQL,思维方式是通用的。我一般会直接把 SQLiteOpenHelper 作为首选,只有等项目需要多表复杂查询且团队成员都熟 Room 时才换。

2.3 SQLiteOpenHelper 的建库建表代码,以及版本升级参数

DBHelper 是整个系统的地基。这里要强调一个参数:version。很多人的数据库版本永远写 1,后面加字段时直接改 CREATE TABLE 语句,卸载重装才生效,这是最大的坑。正确操作是把 version 从 1 升到 2,并在 onUpgrade 里补 ALTER TABLE 语句。

public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "book_manager.db"; private static final int DB_VERSION = 1; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE category (...)"); db.execSQL("CREATE TABLE book (...)"); db.execSQL("CREATE TABLE borrow_record (...)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE book ADD COLUMN location TEXT"); } if (oldVersion < 3) { db.execSQL("CREATE INDEX idx_book_title ON book(title)"); } } }

onCreate 只在数据库文件第一次创建时执行,之后的建表更新全部走 onUpgrade。数据库文件名固定为 book_manager.db,存在/data/data/包名/databases/下。super(context, DB_NAME, null, DB_VERSION) 的第三个参数是 CursorFactory,传 null 即可,表示使用默认工厂。onUpgrade 里每段升级脚本都加了 if 判断,保证旧版本从 1 升到 3 时不会遗漏中间步骤。

3. 在 Android Studio 里把功能跑通:ListView 与 CRUD 的完整调用链

3.1 工程结构与 Gradle 配置:SDK 版本和依赖参数怎么设

下载下来的工程导入 Android Studio 时,最容易翻车的不是代码,而是 Gradle 版本与本地 SDK 不匹配。这个图书管理系统是老牌的 Android Studio 版本,用的还是传统 View 体系,没有 Compose。我习惯把 compileSdk 设为 33,minSdk 设为 21,targetSdk 设为 33,这样既能覆盖绝大多数手机,又不会碰到太新的权限政策。

android { compileSdk 33 defaultConfig { applicationId "com.example.bookmanager" minSdk 21 targetSdk 33 versionCode 1 versionName "1.0" } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' }

minSdk 21 意味着 Android 5.0 以上都能跑,覆盖了绝大多数老机型。targetSdk 33 意味着需要处理运行时权限,后面导出备份会用到。compileOptions 把 Java 版本固定在 1.8,lambda 表达式可用即可,不需要升级到 17。这里没有引入 RecyclerView 依赖,因为项目原本用 ListView 实现列表,我建议初期先沿用,等你能熟练改写后再换 RecyclerView 不迟。Android Studio 首次导入工程后,如果一直卡在 Gradle sync,可以在 gradle-wrapper.properties 里把 distributionUrl 改成你的 Android Studio 自带的 Gradle 版本。

3.2 ListView + 自定义适配器:图书列表最常见写法

图书列表展示是用户看到的第一个界面,也是“安卓开发如何将搜索到的蓝牙设备显示到 listview 上”这类问题的同款模型。ListView 本身不存数据,只负责滚动显示,数据源是 Cursor 或 List,中间层靠适配器把数据映射到每一项布局上。下面这段是 BookAdapter 的核心逻辑。

public class BookAdapter extends BaseAdapter { private List<Book> bookList; private LayoutInflater inflater; public BookAdapter(Context context, List<Book> bookList) { this.bookList = bookList; this.inflater = LayoutInflater.from(context); } @Override public int getCount() { return bookList == null ? 0 : bookList.size(); } @Override public Object getItem(int position) { return bookList.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView == null) { convertView = inflater.inflate(R.layout.item_book, parent, false); holder = new ViewHolder(); holder.tvTitle = convertView.findViewById(R.id.tv_title); holder.tvAuthor = convertView.findViewById(R.id.tv_author); holder.tvStock = convertView.findViewById(R.id.tv_stock); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.getTag(); } Book book = bookList.get(position); holder.tvTitle.setText(book.getTitle()); holder.tvAuthor.setText(book.getAuthor()); holder.tvStock.setText("库存: " + book.getStock()); return convertView; } static class ViewHolder { TextView tvTitle; TextView tvAuthor; TextView tvStock; } }

getCount 决定列表长度,getView 里的 convertView 复用是重中之重:不复用的话,每次滚动都 inflate 一个新布局,列表超过 50 条会明显掉帧。ViewHolder 用 setTag 缓存了三个 TextView,省去重复 findViewById。position 参数是当前数据下标,注意它不是数据库里的 id,删除和修改操作要拿 position 去 bookList 取对象,再用对象的 id 去操作数据库。TextView 显示价格时,如果值是 39.8,直接拼字符串会显示 39.8;但如果是整数 39,SQLite 的 REAL 类型读出来可能是 39.0,建议用String.format("%.2f", price)格式化。

3.3 新增、修改、删除和借还书背后的完整调用链

列表只是读操作,真正要跑通的是增删改。新增和修改都离不开 ContentValues,它是 SQLite 插入和更新的值载体,底层还是键值对,但比拼 SQL 字符串安全得多,能有效避免 SQL 注入。下面这段是新增图书和删除图书的 DAO 方法。

public long insertBook(Book book) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("title", book.getTitle()); values.put("author", book.getAuthor()); values.put("isbn", book.getIsbn()); values.put("category_id", book.getCategoryId()); values.put("price", book.getPrice()); values.put("stock", book.getStock()); long id = db.insert("book", null, values); db.close(); return id; } public int deleteBook(long bookId) { SQLiteDatabase db = dbHelper.getWritableDatabase(); int rows = db.delete("book", "id = ?", new String[]{String.valueOf(bookId)}); db.close(); return rows; } public int updateStock(long bookId, int newStock) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("stock", newStock); int rows = db.update("book", values, "id = ?", new String[]{String.valueOf(bookId)}); db.close(); return rows; }

db.insert 的第二个参数 nullColumnHack 传 null 即可,只有在 values 为空时才需要指定一个可空列名。delete 和 update 的第三个参数是 whereClause,里面用?占位,第四参数是占位符的值,这是标准的防注入写法,不要用字符串拼接方式传书号。每次操作完都必须 db.close(),否则会一直占用数据库连接,后续操作可能出现 database is locked 异常。借书和还书不能只改图书表的 stock,还必须往 borrow_record 里写记录,这需要放在一个事务里执行。

public void borrowBook(long bookId, String borrower) { SQLiteDatabase db = dbHelper.getWritableDatabase(); db.beginTransaction(); try { db.execSQL("UPDATE book SET stock = stock - 1 WHERE id = ?", new Object[]{bookId}); db.execSQL("INSERT INTO borrow_record(book_id, borrower_name) VALUES(?, ?)", new Object[]{bookId, borrower}); db.setTransactionSuccessful(); } catch (Exception e) { e.printStackTrace(); } finally { db.endTransaction(); db.close(); } }

beginTransaction 开启事务后,如果中途任何一条语句失败,catch 里不调用 setTransactionSuccessful,endTransaction 会自动回滚。这样不会出现“库存减了但借阅记录没写”的脏数据。如果项目里还要做还书,逻辑就是 return_time 写入当前时间,同时 stock 加 1。把 stock 的增减直接写死在 SQL 里,而不是先查再改,能避免并发修改时的数据不一致。

4. 避坑 / 常见问题:跑安卓图书管理最容易翻车的五个点

4.1 数据库创建成功,ListView 却一片空白

现象:控制台没有报错,logcat 也不打印异常,Data 目录里能看到 book_manager.db 文件,但 ListView 界面就是空的。原因:查询用的游标没有调用 moveToFirst,或者数据源根本没加载,Adapter 拿到的就是一个空 List。还有一个隐蔽点:数据库写操作成功了,但查询时机在写操作之前。解决:查询前检查 cursor.getCount(),Adapter 的 setData 之后必须调用 notifyDataSetChanged()。如果是异步查询,回主线程后 setAdapter 要放在查询结果返回后执行。

4.2 改了表结构不升版本号,一启动就崩

现象:开发阶段给 book 表加了字段,重新运行 App 后闪退,logcat 里报 no such column。原因:数据库文件已存在,onCreate 不会重新执行;版本号还停留在 1,onUpgrade 也没触发。解决:每次改表结构,必须同时把 DB_VERSION 的数值加 1,并在 onUpgrade 里写对应的 ALTER TABLE 语句。注意升级脚本必须是增量式的,不能只写一条最新的建表语句,否则老用户从 1 升到 2 时会把不该删的字段清掉。

4.3 主线程跑大查询,数据过千就 ANR

现象:本地数据库只有两三百条书时很流畅,导入一千条后界面卡死,过一会儿弹出“系统无响应”。原因:getWritableDatabase 和查询全部跑在 UI 线程,SQLite 在低端机上每次磁盘 I/O 耗时可能达到几十毫秒,叠加 ListView 的滑动加载后就出问题。解决:数据量小的时候可以直接在主线程跑,超过一千条建议改用线程池或 AsyncTask,查询完成后通过 Handler 回主线程更新 UI。至少把搜索和统计这类重查询移到子线程,列表的直接查询可以在开发环境中先顶住。

4.4 图书标题中文乱码,以及 ISBN 变成科学计数法的坑

现象:从 CSV 批量导入时,中文书名变成“???”;ISBN 数字显示成 9.78074E+11。原因:CSV 文件用 GBK 编码读取,Android 默认用 UTF-8 解析导致乱码;ISBN 在导入时被解析成数字类型,存进 DOUBLE 后自动转成科学计数法。解决:批量导入统一用 UTF-8 读流,new InputStreamReader(new FileInputStream(file), "UTF-8"),不要用 FileReader。ISBN 在表结构和 Bean 类里都用 String,解析时先 trim,如果 Excel 导出的内容带了不可见字符,需要手动清洗。

4.5 目标 SDK 33 后,导出的备份文件在手机里看不到

现象:代码里写了 exportDatabase,用 Environment.getExternalStorageDirectory() 拼接路径,Toast 提示导出成功,但打开文件管理器找不到文件。原因:从 Android 10 开始,分区存储生效,应用不能直接往公共目录写文件,getExternalStorageDirectory 访问的是应用限定目录。解决:targetSdk 29 以上,导出推荐用 MediaStore.Downloads 或 ACTION_CREATE_DOCUMENT。后者弹系统保存框,用户自己选择保存位置,最省心。这也是我在第五章里给的方案,避免 WRITE_EXTERNAL_STORAGE 权限申请一堆但仍旧写不进去的尴尬。

5. 进阶:给图书管理加一个全文搜索和备份导出的“售后能力”

5.1 用 LIKE 还是 FTS4:数据量决定策略

图书管理系统的搜索框,最常见的需求是“按书名模糊查”。数据量在 5000 条以内,一个 LIKE 查询完全够用。SELECT * FROM book WHERE title LIKE '%' || ? || '%',性能可以接受,代码也最好维护。但如果你导入的是学校几万册馆藏,LIKE 的隐患就出来了:前导通配符会让索引失效,全表扫描耗时指数上升。这时我建议建 FTS4 虚拟表,用 SQLite 内置的全文索引。

CREATE VIRTUAL TABLE book_fts USING fts4( title, author, category, content=book );

创建虚拟表后,通过触发器把 book 表的增删改同步到 book_fts,查询用 MATCH,速度比 LIKE 快一个量级。这里有个坑:FTS4 的 content 表是外部内容表,必须先有基础表才能建。如果你用的 Android 自带 SQLite 版本较老,FTS4 一般已内置,而 FTS5 需要看系统版本,兼容性不如 FTS4。课设阶段不用追求这个,知道边界在哪里就够。

5.2 给用户后悔药:CSV 导出到 Download 目录

本地数据库最大的风险是卸载重装,数据全清。所以我会习惯性地在设置页放一个“导出备份”按钮。代码上最省心的不是直接写文件,而是用系统文件选择器让用户自己决定存放位置。

private void exportDatabase() { File dbFile = new File(getDatabasePath("book_manager.db").getPath()); Uri contentUri = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); ContentValues values = new ContentValues(); values.put(MediaStore.Downloads.DISPLAY_NAME, "book_manager_backup.csv"); values.put(MediaStore.Downloads.MIME_TYPE, "text/csv"); Uri fileUri = getContentResolver().insert(contentUri, values); try { OutputStream os = getContentResolver().openOutputStream(fileUri); // 从 SQLite 读出所有 book 表数据,按 CSV 格式写入 os } catch (IOException e) { e.printStackTrace(); } }

这段代码省去了读写权限申请,只要用户在系统选择器里确认保存位置即可。恢复备份时反过来读 CSV,逐行解析并插入数据库,注意用事务包裹整个恢复过程。我一般会导出 CSV 而不是直接拷贝 .db 文件,因为 CSV 能被 Excel 直接打开,老师检查也好,用户自留也好,都更方便。

5.3 从安装到验收:一次完整的复现清单

工程下载后,我建议你按下面顺序走一遍,而不是直接打开就乱点。首次运行先添加一个分类“文学”,再录入三本书,其中一本设库存为 2;然后发起借书操作,去列表确认库存减 1;回到详情页,把书的库存手动改成 0,触发一次借书,看它是否允许借出;再测搜索框输入书名关键词,确认返回结果;最后卸载 App 前做一次导出备份,重装后导入回来,确认数据完整。这一套走完,这个项目的安全边界和心理预期也就摸清了。

最后说一句我的习惯。第一次做这个项目时,我在验证搜索功能时直接点了搜索键,结果界面卡住,那时我才意识到主线程查询的隐患有多大。从此以后,每次写完 SQLite 相关代码,我都会强制自己过一遍事务、版本号、游标关闭和主线程耗时这四件事,项目翻车率明显下降。这次拆解的 Android 图书管理系统,完整代码包括数据库脚本、适配器、增删改查界面和导出备份功能,都在工程包内,直接导入 Android Studio 就能跑。希望帮到你。

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

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

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

立即咨询