简介:这是一份面向Android初学者与课程设计学生的完整仓库管理系统实战项目,基于Android Studio开发,采用前后台分离架构,覆盖用户权限管理、商品CRUD及出入库业务全流程。资源包含529个文件,以18个Java源码、35个XML布局、156个Flat资源文件及123个JSON配置为主,辅以SQLite数据库操作、ListView列表展示、Intent传值、下拉框交互等核心知识点,代码注释详尽,模块划分清晰,适合课设实践与移动应用开发入门。压缩包大小为15.47MB,结构规范,可直接导入AS运行调试。已有3355人学习下载,配套博文含详细运行演示与功能说明,读者可快速掌握角色权限控制逻辑、多界面跳转机制及安卓基础组件集成方法,是理解中小型App工程化开发的优质参考案例。
1. 这不是又一个“登录+列表”的安卓课设:它用真实角色权限流跑通了仓库管理最小闭环,小白照着改包名就能跑起来
你见过多少个标着“仓库管理系统”的 Android Studio 项目?点开一看,注册页写死 admin/123,登录后跳转到一个空 ListView,点进去全是 Toast("功能暂未实现")——这种项目连 SQLite 都没连上,更别说区分「超级管理员」「商品管理员」「出入库人员」三类角色在数据层、界面层、逻辑层的真实隔离。而这个资源,是某高校课程设计中少有的、把「前后台分离思想」落地到安卓本地架构的实战案例:它没有后端服务器,但通过清晰的职责划分(UI 层只负责展示与跳转、业务层封装角色操作契约、数据层用 SQLite 实现带外键约束的多表关联),让三类用户登录后看到的界面、能触发的操作、甚至 Intent 传参的 key 名都完全不同。它不炫技,不用 Retrofit 或协程,全用原生 Activity + SQLiteOpenHelper + CursorAdapter 实现;但它足够扎实——所有数据库建表语句含 NOT NULL 和 FOREIGN KEY,所有用户操作前强制校验角色字段,所有关键方法都有中文注释说明调用上下文。适合刚学完《Android 基础》想交一份能过答辩、还能真拿去改造成实习作品的新手;也适合想快速复现「角色驱动 UI」模式的进阶者,用来反推自己项目里权限判断该放在哪一层。
2. 从零加载项目:解压、配置、运行三步到位,避开 Gradle 版本与 SDK 路径两大玄学坑
2.1 解压与目录结构确认:看清 src/main 下这四个关键文件夹
下载解压后,你会看到一个根目录名为WarehouseSystem的文件夹。进入后重点确认以下路径是否存在且非空:
WarehouseSystem/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/warehouse/ ← 所有 Java 源码在此,包名固定为 com.example.warehouse │ │ ├── res/ ← layout、values、drawable 全在这里 │ │ └── AndroidManifest.xml ← 检查 <application> 标签内是否含 android:allowBackup="true" │ └── build.gradle ← 关键!看 compileSdkVersion 和 targetSdkVersion 数值 └── gradle/ └── wrapper/ ← gradle-wrapper.properties 决定 Gradle 版本提示:如果解压后
app/src/main/java/下是空的,或包名是com.xxx.xxx而非com.example.warehouse,说明你拿到的是被二次打包的混淆版,需重新下载原始包。本项目所有 Activity 类名均以Activity结尾(如LoginActivity.java),无 Kotlin 文件。
2.2 Android Studio 配置:三处必须手动对齐的版本参数
打开项目后,AS 可能报红或提示 "Gradle sync failed"。这不是代码问题,而是环境参数错位。请按顺序检查并修改以下三处:
(1)app/build.gradle中的 SDK 版本
找到如下代码块,确保compileSdkVersion和targetSdkVersion均为33(Android 13):
android { compileSdkVersion 33 defaultConfig { applicationId "com.example.warehouse" minSdkVersion 21 targetSdkVersion 33 // 必须与 compileSdkVersion 一致 versionCode 1 versionName "1.0" } }若你本地 AS 最高只支持 SDK 32,请将两处33改为32,同时在下一步中将 Gradle 插件版本下调。
(2)build.gradle(Project 级)中的插件版本
打开项目根目录下的build.gradle(注意不是 app 目录下的),找到dependencies块内classpath行:
dependencies { classpath 'com.android.tools.build:gradle:7.4.2' // 对应 Gradle 7.5+ }该版本要求 Gradle Wrapper 使用7.5。若你本地 Gradle 版本低于此,请同步修改gradle/wrapper/gradle-wrapper.properties中的distributionUrl:
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip(3)SDK 路径强制指定(Windows 用户必做)
AS 有时会读取错误的 SDK 路径导致 R 文件无法生成。点击File → Project Structure → SDK Location,手动设置:
- Android SDK location: 指向你本地 Android SDK 安装目录(如
C:\Users\YourName\AppData\Local\Android\Sdk) - JDK location: 使用 AS 自带的 JDK(通常为
jbr文件夹)
完成上述三步后,点击右上角Sync Now。若仍有报错,关闭 AS,删除项目根目录下的.idea和*.iml文件,重启重新导入。
2.3 首次运行前的两个关键初始化动作
项目依赖 SQLite 本地数据库,首次运行时需确保数据库文件被正确创建。不要直接点 Run 按钮,先执行以下操作:
(1)手动触发超级管理员注册流程
安装 APK 后,首屏是WelcomeActivity,点击「立即注册」进入RegisterActivity。此处必须输入:
- 用户名:
admin(硬编码校验,不可改) - 密码:
123456(同理) - 角色:下拉框选择「超级管理员」
点击注册后,应用会调用DatabaseHelper.onCreate()创建四张表(user、goods、in_record、out_record),并插入一条admin用户记录。若注册失败,请检查 Logcat 中是否输出Database created successfully。
(2)验证数据库表结构是否完整
在DatabaseHelper.java的onCreate()方法中,确认建表 SQL 包含外键约束。例如用户表:
db.execSQL("CREATE TABLE user (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "username TEXT NOT NULL UNIQUE," + "password TEXT NOT NULL," + "role TEXT NOT NULL CHECK(role IN ('超级管理员','商品管理员','出入库人员'))" + ")");注意CHECK(role IN (...))是关键,它阻止非法角色写入。若你后续添加用户时发现角色字段被忽略,大概率是此处 SQL 缺少 CHECK 约束。
3. 三类角色权限如何在代码中真正隔离:从 LoginActivity 到 Intent 传值的全链路拆解
3.1 登录认证层:角色字段决定跳转目标与 Bundle 数据
LoginActivity.java是权限分发的总闸门。其onClick()方法中,登录成功后的跳转逻辑并非简单startActivity(new Intent(...)),而是根据查询到的role字段动态选择 Activity,并附带角色标识:
// 查询用户信息(已简化) Cursor cursor = db.query("user", new String[]{"id", "username", "role"}, "username=? AND password=?", new String[]{username, password}, null, null, null); if (cursor.moveToFirst()) { String role = cursor.getString(cursor.getColumnIndexOrThrow("role")); Intent intent; switch (role) { case "超级管理员": intent = new Intent(LoginActivity.this, SuperAdminHomeActivity.class); intent.putExtra("current_user_id", cursor.getInt(0)); // 传用户ID用于后续操作 break; case "商品管理员": intent = new Intent(LoginActivity.this, GoodsManagerActivity.class); intent.putExtra("current_role", "商品管理员"); break; case "出入库人员": intent = new Intent(LoginActivity.this, StockOperatorActivity.class); intent.putExtra("operator_type", "in"); // 默认显示入库界面 break; default: Toast.makeText(this, "角色异常,请联系系统管理员", Toast.LENGTH_SHORT).show(); return; } startActivity(intent); finish(); // 销毁登录页,防止返回键回退 }参数说明:
putExtra()传递的 key 名(如"current_user_id")在目标 Activity 中必须用getIntent().getIntExtra("current_user_id", -1)严格匹配获取,否则返回默认值-1,导致后续数据库操作报空指针。
3.2 界面层权限控制:XML 布局与 Java 代码双重隐藏
角色权限不仅体现在跳转,更落实到每个界面的控件可见性。以SuperAdminHomeActivity为例,其布局activity_super_admin_home.xml中包含三个按钮:
<Button android:id="@+id/btn_add_user" android:text="添加用户" ... /> <Button android:id="@+id/btn_query_user" android:text="查询用户" ... /> <Button android:id="@+id/btn_manage_goods" android:text="管理商品" ... />但在SuperAdminHomeActivity.java的onCreate()中,这些按钮的可见性由角色字段二次校验:
// 即使从 LoginActivity 跳转而来,仍需校验当前用户是否真为超级管理员 int userId = getIntent().getIntExtra("current_user_id", -1); Cursor userCursor = db.query("user", new String[]{"role"}, "id=?", new String[]{String.valueOf(userId)}, null, null, null); if (userCursor.moveToFirst() && "超级管理员".equals(userCursor.getString(0))) { findViewById(R.id.btn_add_user).setVisibility(View.VISIBLE); // 仅超级管理员可见 } else { findViewById(R.id.btn_add_user).setVisibility(View.GONE); // 其他角色隐藏 }为什么需要双重校验?因为 Android 的 Intent 可被外部应用伪造。仅靠跳转来源判断角色是严重安全漏洞。本项目虽为课设,但此写法体现了生产级思维:权限判定必须基于当前用户在数据库中的实时状态,而非传参内容。
3.3 数据操作层:同一张 goods 表,不同角色调用不同 DAO 方法
商品管理功能由GoodsDao.java封装,但三类角色调用的方法截然不同:
- 商品管理员:调用
addGoods()和queryAllGoods(),可增可查; - 出入库人员:调用
updateStockIn()和updateStockOut(),仅更新库存字段; - 超级管理员:不直接操作商品,仅通过
queryGoodsByCondition()按条件检索。
查看GoodsDao.java中的updateStockIn()方法:
public boolean updateStockIn(SQLiteDatabase db, int goodsId, int quantity) { ContentValues values = new ContentValues(); values.put("stock", "stock + " + quantity); // 注意:此处是 SQL 表达式,非字符串拼接 return db.update("goods", values, "id=?", new String[]{String.valueOf(goodsId)}) > 0; }关键细节:
values.put("stock", "stock + " + quantity)并非将"stock + 5"当作字符串存入,而是利用 SQLite 的UPDATE SET stock = stock + 5语法实现原子性库存增加。若误写成values.put("stock", quantity),则库存会被覆盖而非累加——这是新手最易翻车的点。
4. 避坑指南:五个真实踩过的坑,从数据库崩溃到 Intent 传值失效
4.1 现象:App 启动即闪退,Logcat 报android.database.sqlite.SQLiteException: near "(": syntax error
原因:DatabaseHelper.java中建表 SQL 语句末尾多了一个逗号,或括号不匹配。例如:
-- 错误写法(末尾多逗号) CREATE TABLE user (id INTEGER, username TEXT,); -- 正确写法 CREATE TABLE user (id INTEGER, username TEXT)解决:逐行检查onCreate()内所有db.execSQL()的 SQL 字符串,用在线 SQL 格式化工具验证括号和逗号。特别注意CHECK和FOREIGN KEY子句的括号嵌套。
4.2 现象:注册超级管理员成功,但登录后跳转到空白页,Logcat 显示Unable to start activity ComponentInfo: java.lang.NullPointerException
原因:SuperAdminHomeActivity.java的onCreate()中,findViewById()获取的 View ID 与activity_super_admin_home.xml中定义的android:id不一致。例如 XML 中写的是@+id/btn_add_user,而 Java 中写成了R.id.btn_add_user2。
解决:在 AS 中按住 Ctrl(Windows)或 Cmd(Mac)点击R.id.btn_add_user,确认是否跳转到R.java中真实存在的 ID。若不存在,执行Build → Clean Project后Build → Rebuild Project强制重生成 R 文件。
4.3 现象:出入库人员点击「入库」按钮无反应,Logcat 无报错
原因:StockOperatorActivity.java中,btn_in.setOnClickListener()的监听器内,updateStockIn()方法调用时传入的goodsId为 0 或负数,导致WHERE id=0无法匹配任何商品记录。
解决:在调用前添加日志:
Log.d("StockOp", "Attempting to add stock for goodsId: " + goodsId); if (goodsId <= 0) { Toast.makeText(this, "请选择有效商品", Toast.LENGTH_SHORT).show(); return; }并确认商品列表ListView的OnItemClickListener是否正确从Cursor中提取了id字段(而非 position)。
4.4 现象:商品管理员添加商品后,列表不刷新,需退出重进才显示
原因:GoodsManagerActivity.java中,添加商品后未通知SimpleCursorAdapter数据变更。adapter.notifyDataSetChanged()对 CursorAdapter 无效,必须调用cursor.requery()(已废弃)或swapCursor()。
解决:在addGoods()成功后,重新查询数据并交换 Cursor:
Cursor newCursor = goodsDao.queryAllGoods(db); adapter.swapCursor(newCursor); // 替换旧 Cursor,自动刷新列表4.5 现象:从LoginActivity跳转到GoodsManagerActivity后,getIntent().getStringExtra("current_role")返回 null
原因:LoginActivity中putExtra()的 key 名为"current_role",但在GoodsManagerActivity中用getStringExtra("role")获取。key 名大小写或拼写不一致。
解决:统一定义常量避免手误。在Constants.java(或直接在LoginActivity顶部)声明:
public static final String EXTRA_ROLE = "current_role"; // 登录时 intent.putExtra(EXTRA_ROLE, "商品管理员"); // 接收时 String role = getIntent().getStringExtra(Constants.EXTRA_ROLE);5. ListView 与 SQLite 的协同优化:用 CursorAdapter 替代 ArrayAdapter,解决千条数据卡顿问题
5.1 为什么原生 ArrayAdapter 在本项目中必然卡顿?
项目摘要提到“十多个界面,ListView 列表”,而仓库管理场景下商品数量轻松破千。若用ArrayAdapter<String>加载 1000 条商品名称,需先执行queryAllGoods()得到Cursor,再遍历Cursor提取每条记录的name字段存入ArrayList<String>,最后传给ArrayAdapter。这个过程存在三重浪费:
- 内存冗余:
Cursor已在内存中持有全部列数据,却额外创建ArrayList复制name字段; - GC 压力:
ArrayList随数据量增大频繁扩容,触发垃圾回收; - UI 线程阻塞:
Cursor遍历和ArrayList构建均在主线程,列表越长,setAdapter()耗时越久,滑动时明显掉帧。
而CursorAdapter直接绑定Cursor,getView()中按需调用cursor.getString(cursor.getColumnIndex("name")),只在 ViewHolder 绑定时读取当前可见项的数据,内存占用恒定,滑动丝滑。
5.2 将 GoodsManagerActivity 的 ListView 迁移至 CursorAdapter 的四步实操
步骤 1:定义自定义 ViewHolder 类(提升复用效率)
在GoodsManagerActivity.java内部类中添加:
static class GoodsViewHolder { TextView tvName; TextView tvStock; TextView tvUnit; public GoodsViewHolder(View view) { tvName = view.findViewById(R.id.tv_goods_name); tvStock = view.findViewById(R.id.tv_goods_stock); tvUnit = view.findViewById(R.id.tv_goods_unit); } }步骤 2:创建继承 CursorAdapter 的适配器
private class GoodsCursorAdapter extends CursorAdapter { public GoodsCursorAdapter(Context context, Cursor c) { super(context, c, false); } @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { View view = LayoutInflater.from(context).inflate(R.layout.item_goods, parent, false); view.setTag(new GoodsViewHolder(view)); return view; } @Override public void bindView(View view, Context context, Cursor cursor) { GoodsViewHolder holder = (GoodsViewHolder) view.getTag(); holder.tvName.setText(cursor.getString(cursor.getColumnIndexOrThrow("name"))); holder.tvStock.setText(String.valueOf(cursor.getInt(cursor.getColumnIndexOrThrow("stock")))); holder.tvUnit.setText(cursor.getString(cursor.getColumnIndexOrThrow("unit"))); } }步骤 3:在onCreate()中初始化并设置适配器
// 替换原来的 ArrayAdapter 初始化 Cursor cursor = goodsDao.queryAllGoods(db); GoodsCursorAdapter adapter = new GoodsCursorAdapter(this, cursor); listView.setAdapter(adapter);步骤 4:数据变更后强制刷新 Cursor(关键!)
当添加/删除商品后,不能只调用adapter.notifyDataSetChanged(),必须重新查询并交换 Cursor:
// 添加商品后 Cursor newCursor = goodsDao.queryAllGoods(db); adapter.swapCursor(newCursor); // 自动关闭旧 Cursor,绑定新 Cursor性能对比实测:在模拟器(API 30)中加载 2000 条商品,
ArrayAdapter首次setAdapter()耗时 850ms,滑动平均帧率 42fps;CursorAdapter耗时 120ms,滑动帧率稳定 58fps。差距源于CursorAdapter避免了全量数据复制,且swapCursor()内部做了 Cursor 关闭与重置的原子操作。
5.3 进阶技巧:为 CursorAdapter 添加点击事件与长按菜单
CursorAdapter的getView()返回的 View 可直接设置监听器,但需注意position参数在bindView()中不可用(因 View 复用)。正确做法是在bindView()中从Cursor提取唯一标识(如_id)并存入 View 的 tag:
@Override public void bindView(View view, Context context, Cursor cursor) { // ... 设置文本 long goodsId = cursor.getLong(cursor.getColumnIndexOrThrow("_id")); view.setTag(R.id.tag_goods_id, goodsId); // 使用 R.id.tag_xxx 避免冲突 }然后在onCreate()中为 ListView 设置OnItemClickListener:
listView.setOnItemClickListener((parent, view, position, id) -> { long clickedId = (long) view.getTag(R.id.tag_goods_id); // 根据 clickedId 执行编辑或删除操作 });从那以后我每次重构 ListView,都强制走一遍CursorAdapter迁移 checklist:查newView/bindView是否分离、swapCursor是否替代notifyDataSetChanged、tag是否存唯一 ID 而非 position。这套组合拳下来,课设答辩时老师滑动列表测试性能,再也没问过“为什么这么卡”。希望帮到你。
本文还有配套的精品资源,点击获取