简介:这是一套基于Android的课堂点名系统完整项目源码包,面向需要完成移动开发课程设计或毕业设计的Java/Android学习者。项目采用Java配合Eclipse构建,包含Android客户端与服务端后台两部分,覆盖快速点名、实时同步、出勤统计、后台管理等核心功能,并附SQL脚本与APK安装包,便于直接部署与二次开发。资源共1374个文件,大小约29.15MB,主要包含Java源码、JSP页面、Class编译文件、XML配置、PNG/GIF界面素材以及依赖Jar包,目录结构完整,适合对照学习客户端与服务器之间的网络通信、权限校验和数据处理流程。目前已有1723人学习下载,对理解Android项目分层架构及服务端交互机制具有较高参考价值。
1. 先说结论:能跑通、能出成果的课堂点名系统源码,适用人群和预期
如果你搜到这个项目,多半是课程设计选了“基于Android的课堂点名系统”这个题,一手拿的是Java基础,另一手拿的是不知道哪个版本的Android开发环境。这套资源的核心价值在于:它是一个完整的Eclipse时代工程,用Java写了从学生花名册管理、课程维护、随机点名、手动签到到出勤统计的全部流程,SQLite做本地存储,导出点名记录用CSV文件。并不是什么高并发、微服务、云端同步的大系统,但它在“交课程设计、毕业设计开题、自己学着拆分一个App”这件事上非常够用。适合三类人:想从零看懂一个Android增删改查项目的初学者、需要源码做课设但不想直接抄还要改功能的在校生、以及想快速参考点名逻辑和数据库设计的一线开发者。
2. 点名系统的四层架构:为什么单机版也要拆model/dao/service/activity
很多刚接触Android的开发者拿到一个项目,习惯先翻界面布局文件和Activity,把代码全堆在onCreate里跑通就算完。这个资源的第一眼印象恰恰相反:工程在包结构上分了model、dao、service、activity四个层级,连数据库帮助类都单独放。单机版点名系统看起来体量不大,这么拆并不是为了凑架构,而是为了让“点名流程”和“数据读写”互相不干扰。
2.1 架构分层:四个包各自扛什么活
先理解这个工程的分包逻辑,后面改功能时才不会迷路。model包放的是实体类,对应数据库里的三张核心表:学生表、课程表、考勤记录表;dao包放SQLiteOpenHelper子类和增删改查封装,所有SQL语句都收敛在这里;service包是点名策略的所在地,随机点名算法、按名单点名的状态流转都放在这一层;activity包只负责界面跳转和用户交互,不在里面写任何SQL。
我当时拆开这个工程后,最直观的感受是:如果只为了跑通,其实两个包就够,一个放Activity、一个放数据库工具。但把点名策略单独抽出来成service,整个改造成本会明显下降。比如老师提需求说“我要按学号段点名”,只需要改service层,Activity和数据库完全不用动。这就是分层最直接的红利。
代码结构上有一个细节值得注意:实体类的字段名和数据库表的列名不是一一对应的,例如数据库列叫student_name,实体类字段叫name,靠Cursor手动映射。这个设计的出发点是避免实体类被SQLite列名绑架,但代价是插入和查询时需要写字段映射代码,后面读取花名册时可以对照着看。
2.2 点名主流程:从“选班级”到“写考勤表”的链路
点名的主流程分为两条链路,一条是拿到班级后开始点名,另一条是把点名结果落库。两条链路的衔接点在于一个“当前点名会话”对象,它在Activity进入点名界面时被创建,保存了课程ID、班级ID和日期。
第一条链路是在点击“开始点名”后,从service层取出该班级的学生列表,传给点名界面的ListView适配器。界面上提供两种点名模式:随机点名和按名单逐个勾选。随机点名走RandomPicker类,按名单点名走CheckListAdapter,这两个类的内部实现会在第3章拆开讲。第二条链路是点名结束后,点“结束点名并保存”,此时系统遍历当前会话里所有学生的状态,批量插入到考勤记录表。
需要特别说明的是批量插入的实现方式。常见做法是循环里一条条insert,这个项目用的是SQLite事务包裹下的批量插入。因为一次点名涉及一个班三四十人,逐条插入遇到中途失败时数据会残缺,而且速度慢。用事务的好处是:一旦某条数据写入异常,整批回滚,不会出现“张三记了缺勤、李四没记上”这种状态。
// dao层考勤记录批量写入的简化写法 public void insertAttendanceBatch(List<Attendance> list) { if (list == null || list.isEmpty()) return; SQLiteDatabase db = this.getWritableDatabase(); db.beginTransaction(); // 开启事务,保证批量写入原子性 try { for (Attendance a : list) { ContentValues values = new ContentValues(); values.put("student_id", a.getStudentId()); values.put("course_id", a.getCourseId()); values.put("att_date", a.getDate()); values.put("status", a.getStatus()); // 0出勤 1缺勤 2迟到 db.insert("tb_attendance", null, values); } db.setTransactionSuccessful(); // 全部成功才提交 } finally { db.endTransaction(); // 不管成功失败都要结束事务 } }参数说明:list是本次点名所有学生的考勤状态集合,getStatus()返回的是int值,这个设计比存字符串“缺勤”更节省空间,但可读性差一些,需要在model里提供状态码到中文的转换方法。事务的关键在beginTransaction和setTransactionSuccessful这对调用,忘了调setTransactionSuccessful,所有数据都会被回滚,这是新手最容易翻车的地方。
2.3 数据表设计:三张表怎么支撑点名和统计
点名系统的数据库没有用很花哨的设计,就是三张表打底:学生表、课程表、考勤记录表。这样设计的一个好处是,点名记录可以按课程维度去重,避免同一个学生在同一节课出现两条记录。这里有一个反直觉的点:表设计其实故意没有设计“班级表”,班级信息直接以字段形式冗余在课程表里,叫做class_name。我第一次看的时候觉得这是设计缺陷,后来才明白单机课程设计的体量里,班级表反而会增加联表复杂度。
课程表是这次设计里最有参考价值的:字段包括课程名称、班级名称、任课老师、上课时间(用星期和节次表示)、总课时数。上课时间这个字段不是字符串日期,而是像“星期一第3-4节”这样的组合标识,这个标识直接决定了点名时如何处理同一天相邻节次的状态覆盖问题,也就是第4章要单独说的一个坑。
至于考勤记录表,主键用的是自增ID,另外有student_id、course_id、att_date和status四个核心字段。这里没有把student_id和att_date设为联合主键,是个留了余地的设计:如果以后要支持上午下午各点一次名,同一学生同一天会有两条记录,联合主键反而成了限制。这种取舍值得在自己做课程设计时参考。
三张表之间的关联关系是:考勤记录表通过student_id关联学生表,通过course_id关联课程表,统计某个学生某门课的出勤情况时,直接按这两个外键过滤即可。由于整个数据库操作集中在dao层,就算后续引入“专业”“班级”等维度,改动也能限制在单一文件内。
3. 核心代码拆解:随机点名器、签到名单、缺勤SQL一条龙
这一章是整套资源里最值得反复读的部分。点名系统听起来简单,但把“随机点名不重复”“手动勾选不误触”“统计结果算得对”三件事同时做好,需要不少细节处理。下面按照实际使用频次来拆:随机点名最常用,签到名单是兜底方案,缺勤统计是最后交作业时的门面。
3.1 随机点名:权重处理与去重实现
随机点名的核心需求有两个:第一,点过名的学生在本节课内不应该立刻被再次点到;第二,老师可以控制某些学生被点中的概率更高。很多人写随机点名就是Random.nextInt加列表长度,本节先给出带权重的实现,并说明为何必须处理“点名后不放回”。
// service层随机点名器核心代码 public class RandomPicker { private List<Student> pool; // 待点名学生池 private Map<Integer, Integer> weightMap; // 学号 -> 额外权重 private Set<Integer> picked; // 已点过的学号集合 public Student pick() { List<Student> candidates = new ArrayList<>(); int totalWeight = 0; for (Student s : pool) { if (picked.contains(s.getId())) continue; // 跳过已点名的 int w = 1 + weightMap.getOrDefault(s.getId(), 0); totalWeight += w; candidates.add(new WeightedStudent(s, w)); } if (candidates.isEmpty()) return null; // 全班点完一轮 int rand = new Random().nextInt(totalWeight); for (WeightedStudent ws : candidates) { // 线性扫描权重区间 rand -= ws.weight; if (rand < 0) { picked.add(ws.student.getId()); return ws.student; } } return candidates.get(0).student; // 兜底,实际不会走到 } }逻辑说明:代码把“总权重”做成累加区间,随机数落在哪个区间就选谁。权重默认1,额外权重由老师通过界面调整,默认值为0。picked集合保证一次点名课内不重复,除非全班都被点过,picked.clear()之后开启下一轮。ui层每次点“下一个”就调用一次pick(),返回null时弹出“本轮已全部点完”的提示。
参数说明:权重表weightMap不是每个学生都必需的,没设置额外权重的人权重就是1,这样默认行为和普通随机完全一致。想在界面上让老师手动调权重,需要给列表每一行加一个数值输入框,资源里是在学生详情页里设置,不占列表空间。这里有一个细节经验:不要用“概率百分比”作为权重接口,老师根本不知道填多少合适;改成“额外点名次数”,比如填2就代表这学生被点中的机会是别人的3倍,语义直观很多。
随机点名还有一个容易忽略的点:点名记录要不要自动写入考勤表。资源的做法是不自动写,只弹窗显示被点到的学生姓名和学号,由老师决定是否标记状态。因为在真实课堂里,随机点名“被点到”不等于“缺勤”,可能是回答问题。所以随机点名器的输出是一个Student对象,后面跟什么操作由界面层控制。
3.2 按名单点名:列表复选与三态签到状态
随机点名解决的是“点谁”,按名单点名解决的是“到没到”。这一节看ListView适配器里的三态切换,这是很多人写签到界面时容易忽略的地方:签到状态不是“到/不到”两个状态,而是“出勤/缺勤/迟到”三个状态。多一个状态麻烦不少,但真实点名时老师经常需要区分这两类,否则统计迟到率时数据完全不准确。
// 点名列表适配器关键逻辑 public class CheckListAdapter extends BaseAdapter { private List<Student> students; private Map<Integer, Integer> statusMap; // 学生ID -> 状态码 @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder vh; if (convertView == null) { vh = new ViewHolder(); // 这里省略inflate和findViewById convertView.setTag(vh); } else { vh = (ViewHolder) convertView.getTag(); } Student s = students.get(position); int status = statusMap.getOrDefault(s.getId(), 0); // 三态按钮组:出勤、迟到、缺勤 vh.btnPresent.setSelected(status == 0); vh.btnLate.setSelected(status == 2); vh.btnAbsent.setSelected(status == 1); return convertView; } }逻辑说明:适配器不直接改数据库,而是维护一个内存里的statusMap,用户怎么点都先记在Map里,直到点击“结束点名并保存”时,才把整张Map转换成Attendance对象列表,一次性写入。这个“先内存、后落库”的设计能避免频繁数据库读写,也让“撤销操作”变成可能——老师误点了一个学生为缺勤,直接再点一次就能覆盖。
实操参数说明:三态按钮组在布局上用三个Button横向排列,选中的按钮高亮,没选中的灰色。这里有一个交互细节:三个按钮之间本身是互斥的,但BaseAdapter复用convertView时如果不重置所有按钮的状态,列表滚动时会看到上一个位置的选中效果残留到新行上。资源里在getView开头对三个按钮统一做一次setSelected(false)清状态,再根据statusMap设置真实状态,这个顺序不能反。
另外convertView的性能优化在这个列表上问题不大,30人左右的班级ListView本身毫无压力,但适配器写法仍然保留了ViewHolder模式,主要是为了代码规范性。如果自己要扩充功能,比如增加“请假”状态,只需要在布局加一个Button、在statusMap里加一个状态码,并在保存时对应映射。
3.3 缺勤统计:一条SQL查出课次、缺勤数、出勤率
点名数据存下来之后,最终要落到“这学生缺了几次课”。资源里统计模块用了一条SQL完成课次和缺勤数查询,这个写法比Java内存里逐条数更利于理解。
SELECT s.student_no, s.name, COUNT(a.id) AS total, SUM(CASE WHEN a.status = 1 THEN 1 ELSE 0 END) AS absent_count, ROUND((SUM(CASE WHEN a.status = 0 OR a.status = 2 THEN 1 ELSE 0 END) * 100.0 / COUNT(a.id)), 2) AS attend_rate FROM tb_student s LEFT JOIN tb_attendance a ON s.id = a.student_id WHERE a.course_id = ? GROUP BY s.id ORDER BY absent_count DESC逻辑说明:这SQL的关键在于CASE WHEN的条件——出勤和迟到都算“到场”,只有status=1是缺勤。所以出勤率的分子是status等于0或2的记录数,分母是总的点名次数。用LEFT JOIN保证一句SQL里全班学生都能查询出来,即使有的人从没被点过名。
参数说明:course_id是占位符参数,统计界面上有课程下拉框,切换课程时重新查询并刷新列表。ORDER BY absent_count DESC让缺勤最多的学生排在最前面,方便老师一眼看出问题学生。ROUND保留两位小数,出勤率显示为90.5这样的数字,界面再拼上百分号。
这里有另一个值得借鉴的点:统计SQL不直接写status字符串比较,用INT状态码比较,查询性能稍微好一点,但更重要的是当服务端存储或导出Excel时,状态码可以灵活映射成不同文案,不用改SQL。如果自己接手项目,想增加“按班级维度统计各班平均出勤率”,只要把GROUP BY换成a.course_id并多加一张课程表关联即可。
4. 常见问题与避坑:Eclipse项目迁移、数据库升级、点名冲突
这套资源最大的历史包袱是Eclipse,而现实是几乎所有人都用Android Studio。把这几点讲清楚,能省掉你至少一个下午的折腾时间,我拆项目过程中踩过的坑按出现频率排列如下。
4.1 坑一:Eclipse工程的gen目录和R文件在Android Studio里消失
现象:用Android Studio打开工程后,所有R.id.xxx的引用全部报错,同步Gradle后错误依然存在。
原因:Eclipse用ADT插件编译时,会自动生成gen目录里的R.java文件;而Android Studio用Gradle构建,在build/generated目录下动态生成R类,不在源码树里。直接导入时Gradle没有正确识别源码目录层级,导致找不到R类。
解决:第一步,删除工程根目录下的gen文件夹和project.properties文件;第二步,打开build.gradle,确认sourceSets指定了src目录;第三步,执行Build > Clean Project,再Rebuild Project。如果R类报错消失但类名前面有红色的import语句,需要在buildFeatures里开启buildConfig,并在依赖里补上androidx.appcompat。
// 迁移后在app/build.gradle里需要确认的配置 android { compileSdk 33 sourceSets { main { manifest.srcFile 'AndroidManifest.xml' java.srcDirs = ['src'] resources.srcDirs = ['src'] aidl.srcDirs = ['src'] res.srcDirs = ['res'] assets.srcDirs = ['assets'] } } }注意:sourceSets里java.srcDirs指到src,是为了兼容Eclipse时代“默认源码在src根目录”的结构。如果工程里还用了libs下的jar包,需要在dependencies里用implementation files加进去,Gradle不会自动扫描libs目录。
4.2 坑二:SQLiteOpenHelper升级时旧数据丢失
现象:用户手机上已经装了旧版App,覆盖安装新版后,点名记录全部消失,打开统计页面什么都没有。
原因:SQLiteOpenHelper的onUpgrade方法里写的是DROP TABLE后再CREATE TABLE,从2.0版本升级到2.1版本时,直接清库重建。
解决:不要把升级逻辑写成无条件删表。需要做的是在onUpgrade里判断oldVersion逐级升级,这里用一个ADD COLUMN是最常见场景。
@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE tb_attendance ADD COLUMN remark TEXT"); } if (oldVersion < 3) { db.execSQL("CREATE INDEX idx_att_course ON tb_attendance(course_id)"); } }逻辑说明:从2升级到3时,先执行oldVersion<2里的语句再执行oldVersion<3里的语句,每段都是幂等且可重复的。关键是不能在onUpgrade里直接execSQL("DROP TABLE"),除非你的App允许升级清空数据并且用户知情。
如果旧版本数据库结构已经乱了,拿不到准确oldVersion,还有一种兜底方案是:在onCreate前先检测表是否存在,但这种方式更适合测试阶段。生产环境建议始终与用户沟通“升级后数据保留”的策略。
4.3 坑三:同一天两节课,两个班点名状态互相覆盖
现象:周一第1-2节给A班上完课,点完名,数据正常;下午第5-6节给B班上课,点完名保存后,A班的记录被覆盖成了一部分B班的数据。
原因:考勤记录表只使用student_id和att_date作为业务标识,没有把course_id放进识别主键。两个班的考勤记录按日期分组时,同一天内不同course_id会产生冲突。
解决:考勤查询和写入都围绕course_id维度做,写入时按course_id过滤删除旧记录,再插入新记录,而不是按日期清理。
public void replaceAttendance(int courseId, int studentId, String date, int status) { SQLiteDatabase db = this.getWritableDatabase(); // 先删同一课程同一学生的旧记录,再插入新状态 db.delete("tb_attendance", "course_id=? AND student_id=? AND att_date=?", new String[]{String.valueOf(courseId), String.valueOf(studentId), date}); ContentValues values = new ContentValues(); values.put("course_id", courseId); values.put("student_id", studentId); values.put("att_date", date); values.put("status", status); db.insert("tb_attendance", null, values); }逻辑说明:replace机制的核心是先删后插。虽然数据库本身没建联合唯一索引,但应用层通过“课程+学生+日期”三重条件保证不产生冲突记录。这里的TODO是:如果点名系统要支持“同一课程同一学生同一天多次点名”,att_date就要升级成att_datetime,否则replace逻辑会吞掉当天较早的记录。
4.4 坑四:点名记录导出CSV文件在Android 11以上打不开
现象:App里已经用startActivityForResult发起创建文件Intent,选择路径并保存成功,但生成的文件在文件管理器里看不到,或者写入时报FileNotFoundException。
原因:Android 10开始强制分区存储,App不能直接往公共目录(比如Download目录)写任意路径,除非使用MediaStore,否则权限在旧代码上失效。
解决:所有导出操作改用SAF(Storage Access Framework),通过Intent让用户自己选保存位置。这个方法适配所有版本,不需要申请存储权限。
// 用系统文件选择器创建导出文件 Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("text/csv"); intent.putExtra(Intent.EXTRA_TITLE, "attendance_export.csv"); startActivityForResult(intent, REQUEST_CODE_EXPORT);在onActivityResult的回调里获取Uri,用contentResolver.openOutputStream(uri)写数据,整个代码里不再出现任何文件绝对路径,这是Android存储适配的标准姿势。另外,导出的CSV文件用Excel打开中文乱码时,需要在写入内容前先输出UTF-8 BOM,也就是三个字节EF BB BF,很多开发者在这上面反复踩坑,我通常会把BOM的写入封装在一个writeCsvWithBom()工具方法里。
5. 让点名数据能交作业:造数据脚本、验证流程和导出技巧
这一章讲的是动手验证这件事。有个现实情况是,课程设计演示时老师不会给你一张真实班级名单,但你又必须演示“点名→统计→导出”的完整体验。所以准备好一套可重复使用的模拟数据提取脚本,比临时往App里一条条添加学生高效得多。
5.1 用SQL脚本批量生成花名册和考勤记录
学生表结构一般是id、student_no、name、class_name四列。我一般会先用一个独立的SQLite文件生成好数据,再把导出文件导入App的数据库,或者直接改数据库文件重命名放进assets目录。
-- 模拟生成某班级35名学生 INSERT INTO tb_student (student_no, name, class_name) VALUES ('2024001', '张晨', '计科2401'), ('2024002', '李启明', '计科2401'), ('2024003', '王思远', '计科2401'); -- 实际使用可以写存储过程或Python脚本生成35条考勤记录则可以用“按次数随机缺勤”的方式生成,比如8次课中让每个学生有10%概率缺勤。这里建议数据量不要太大:35人*8次课=280条记录,统计页面的展示速度是流畅的,也足够演示查询效果。
5.2 导出CSV时保留BOM头,避免Excel中文乱码
资源里导出点名记录的功能封装在工具类里,核心逻辑是拼CSV字符串后写入文件。写入时首先要处理中文编码问题,在开头加上BOM头是很多人的经验之谈。
public void exportCsv(List<Attendance> list, OutputStream os) throws IOException { StringBuilder sb = new StringBuilder(); sb.append("学号,姓名,课程,日期,状态\n"); for (Attendance a : list) { sb.append(a.getStudentNo()).append(",") .append(a.getStudentName()).append(",") .append(a.getCourseName()).append(",") .append(a.getDate()).append(",") .append(statusToText(a.getStatus())).append("\n"); } os.write(0xEF); // BOM头,防止Excel用ANSI解码乱码 os.write(0xBB); os.write(0xBF); os.write(sb.toString().getBytes("UTF-8")); }写完数据一定要调用os.flush(),否则缓冲区内容未落盘,导出文件会变成空文件。另外CSV导出默认不带表头样式,老师在Excel里直接打开就能看到字段名,但如果用WPS打开且文件没有BOM,第一行会变成乱码标题,这个问题设置为UTF-8 BOM后即可根治。
5.3 完整验证流程:从清库到统计的十大步
拿到源码后跑通只是第一步,真正稳妥的操作是:先把App数据清空,用SQL脚本导入学生数据,开启一次点名,分别测试随机点名、名单点名的三种状态,确定状态正确后再保存,最后查看统计页面并导出CSV。如果代码没有大的改动,这套验证流程跑完一遍基本就能确认功能完整。从那以后,我每次接手这类带数据库的Android项目,都会先强制自己走一遍“造数据→跑流程→查结果”的闭环,再动手改代码。这条习惯帮我避免了很多“改完界面忘了同步数据库”的低级问题,希望帮到你。
本文还有配套的精品资源,点击获取