课堂签到App源码解析:Android+SpringBoot+MySQL全栈实现
2026/9/16 3:53:47 网站建设 项目流程

简介:基于安卓系统的课堂管理应用项目,主要面向毕业设计学生及需要构建课堂互动应用的开发者,覆盖安卓客户端、服务端框架及小程序联动场景。压缩包共二十一个文件,大小约一百五十五兆,包含七张界面截图、五段操作演示视频(如学生签到、教师端测试)、两个数据库脚本、两个源码压缩包、两份文档及配置说明,可完整支撑从数据库设计到前后端联调的学习过程。包内源码结构清晰,包含客户端模块、后端接口、数据库脚本等,并结合演示视频与截图,便于按步骤复现课堂签到、举手、测试等功能。目前已有四十五人学习,适合用于毕业设计参考、项目实战训练或教学演示,能帮助读者理解前后端交互方式及小程序集成思路。

1. 一个课堂签到App的源码包里到底装了什么

把压缩包解压后,你会发现它不是那种只有Activity和布局文件的半成品,而是一条完整的链路:安卓客户端负责签到、举手和测试,SpringBoot服务端处理业务,qiandao2.sql把课堂、师生、位置和时间点全部落到表里。教师端录像是真实跑通后的演示,学生端录像则展示了签到、答题、举手的操作路径。对一个正在做毕业设计的学生来说,这套东西的参考价值在于前后端接口是真实对齐的,数据库脚本也能直接导入MySQL跑起来。对刚入行Android的开发者,它刚好是一个中间层项目——比Todo Demo复杂,又比商城项目好读。

2. 架构选型与工程结构:安卓端、SpringBoot、MySQL如何分工

2.1 为什么是Java原生安卓而不是跨平台框架

课堂签到这种高频、短交互的场景,对性能并不敏感,真正重要的是系统API的调用深度:定位、通知栏、前台Service、悬浮窗,这些都是原生安卓的看家本领。QiandaoClientNew2选择了Java实现,而不是Kotlin或者Flutter,直接的好处是课程设计答辩时,老师问到Activity生命周期、BroadcastReceiver、Handler消息机制,你能从字节码层面讲清楚来龙去脉。另一个实际原因是定位和网络请求在原生环境下处理权限更直接,<uses-permission>配置后,运行时动态授权走的是系统原生对话框,不存在Flutter平台通道的中间层损耗。

跨平台方案在打包签名、混淆、依赖版本对齐上也有不少隐性成本。一个课堂管理App,安卓端要实现签到、举手、答题三个核心动作,用原生三件套——Activity承载页面、Service跑定位、OkHttp发请求——逻辑链路最短。这个包里的QiandaoClientNew2工程目录也是按这个思路拆的:ui包放界面,api包放网络接口,model包放Gson映射的JavaBean,utils包放经纬度计算和本地缓存。

2.2 服务端为什么选SpringBoot扛单点并发

课堂场景的并发量级很清晰:一个教室四五十人,同一瞬间的签到请求撑死百来个,远没到需要微服务或者消息队列的程度。SpringBoot在这个量级下优势明显:默认支持并发请求,内嵌Tomcat启动即用,配合MyBatis操作MySQL,写接口的效率比SSM架构快一大截。更关键的是,SpringBoot的RestController天然支持JSON序列化,安卓端用Retrofit发POST请求,服务端用@RequestBody接,两边字段对上就直接跑通,不需要额外配置视图解析器。

选择SpringBoot还有一个容易被忽略的原因:毕业设计通常要同时交付代码和论文,SpringBoot的自动配置机制让项目在换电脑、换数据库环境时,改动仅集中在application.yml一处。数据源地址、端口、连接池大小,改完就起,这对评审演示阶段的安全感很重要。

2.3 压缩包内文件与工程角色对照

拿到压缩包后先别急着开IDE,把文件按角色归类,能少走很多弯路。下面是我拆解这个包时的对照关系:

压缩包内文件工程角色实际用途
QiandaoClientNew2.rar安卓客户端工程学生端签到、举手、测试的完整项目
qiandao2.rar / qiandao2 (1).sql服务端与数据库SpringBoot后端代码加初始化SQL脚本
学生签到.mp4、学生举手.mp4功能演示录像提交答辩或评审时的功能凭证
教师端签到.mp4、教师端测试.mp4教师端操作路径展示签到统计和测验发布流程
数据库设计.docx、开发lw ppt.zip论文支撑材料数据库E-R图、接口设计、流程图的素材

这个对照关系验证了一件事:源码、数据库、演示录像三者是一一对应的,不是网上那种代码一套、演示另一套的拼凑资源。打开qiandao2.sql能看到的表结构,和视频里操作出来的业务,是能对上的。

3. 数据库设计:从qiandao2.sql拆解核心表结构

3.1 用户域:教师与学生的字段取舍

课堂管理App的用户模型不复杂,但字段设计上有讲究。教师表和学生表分开建,而不是共用一个user表加role字段,这是为了在课程表里做外键关联时语义更清晰。学生表的主键直接用学号字符串,而不是自增id,这样安卓端登录时不用额外查询一次id映射,请求参数就是学号本身。

CREATE TABLE `t_student` ( `student_id` VARCHAR(20) NOT NULL COMMENT '学号主键', `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', `class_name` VARCHAR(50) DEFAULT NULL COMMENT '班级名称', `password` VARCHAR(64) NOT NULL COMMENT '登录密码MD5', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`student_id`), KEY `idx_class` (`class_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表';

这里把student_id直接做字符串主键,少一次自增id到业务字段的映射;索引打在class_name上是因为教师端经常按班级筛选签到名单。password字段用MD5是因为演示项目里没有引入Spring Security,但至少要做到不可明文存储。created_at用数据库默认时间,避免安卓端传入的本地时间与服务器时间不一致导致的数据错乱,这个点在后面第六章还会重点讲。

3.2 签到域:时间、位置、状态三要素

签到表是整个数据库的核心,设计上围绕时间、位置、状态三个维度展开。时间字段要同时保留业务时间和服务器接收时间,位置字段用经纬度而不是具体地址文本,状态的取值要覆盖正常的签到全流程。

CREATE TABLE `t_signin_record` ( `record_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', `student_id` VARCHAR(20) NOT NULL COMMENT '学号', `course_id` INT NOT NULL COMMENT '课程ID', `sign_time` DATETIME NOT NULL COMMENT '客户端签到时间', `server_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '服务器接收时间', `latitude` DECIMAL(10, 6) DEFAULT NULL COMMENT '纬度', `longitude` DECIMAL(10, 6) DEFAULT NULL COMMENT '经度', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2迟到 3缺勤', PRIMARY KEY (`record_id`), UNIQUE KEY `uk_stu_course` (`student_id`, `course_id`, `sign_time`), KEY `idx_course_time` (`course_id`, `sign_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到记录表';

UNIQUE KEY加在学号、课程、签到时间三列上,是为了防止学生端点击签到按钮时,因为网络抖动重试导致一条业务数据被插入两次。server_time的默认值是数据库当前时间,它和sign_time的差值就是判断学生是否修改本地时间的依据:如果差值超过五分钟,基本可以判定客户端时间被刻意调整过。经纬度字段允许为空,因为有些老机型在室内拿不到GPS定位,此时可以降级到基站定位或者直接放行签到,状态字段仍然记录为正常。

3.3 测试域与课程关联表

测试功能依赖的是一张题目主表和一张作答记录表。题目表存题干、选项、正确答案、分值,作答表存学生ID、题目ID、学生答案、得分。这里注意一个设计细节:正确答案一定存在题目表里,但作答表只存学生答案和最终得分,查询时再联合题目表取正确答案做对比,目的是让判分逻辑集中在服务端,不在安卓端。

课程关联表的角色是连接教师、学生和课程三者的枢纽。课堂管理里教师端要查“某门课哪些学生选了”,学生端要查“我有哪些课”,这两类高频查询都落在t_course_student上。外键在这个项目里没有强制开,因为MySQL在删除课程时的级联操作容易误伤历史签到记录,更合理的做法是用应用层逻辑控制,保留历史数据用于期末统计。

4. 安卓端签到与举手模块的实现细节

4.1 AndroidManifest与运行时权限配置

安卓端第一道坎是权限。签到需要网络、定位和通知这三类权限,其中定位属于危险权限,Android 6.0以上必须运行时动态申请。在AndroidManifest.xml中先声明,之后在Activity的onCreate里检查授权状态。

<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

ACCESS_FINE_LOCATION提供GPS精确定位,ACCESS_COARSE_LOCATION提供基于Wi-Fi和基站的粗略定位。签到业务里我一般两个都申请,GPS信号弱的时候自动降级到基站定位,保证签到流程不中断。POST_NOTIFICATIONS是Android 13新增的运行时通知权限,如果不申请,学生端签到成功后的通知栏提示会被系统静默丢弃。

申请的逻辑放在onResume而不是onCreate更好,因为用户可能从系统设置页回来重新开启权限,onResume可以捕捉到授权结果的变化。拒绝授权时也不要直接退出App,降级为只传时间和学号,位置字段留空,让服务端决定是否允许签到。

4.2 Retrofit接口定义与OkHttp拦截器

签到请求用Retrofit封装,接口定义只关心业务字段,底层网络交给OkHttp。接口方法返回Call<ResultBean>,泛型里包一层统一响应体,这样服务端返回的code、message、data三个字段就能被统一解析。拦截器的作用是往Header里塞Token,JWT在下单时可以由登录接口换取。

public interface SignInApi { @POST("sign/in") Call<ResponseBody> signIn(@Body SignInRequest request); @POST("hand/up") Call<ResponseBody> handUp(@Body HandUpRequest request); @GET("test/questions") Call<ResponseBody> getQuestions(@Query("courseId") int courseId); }

@Body注解让Retrofit把SignInRequest对象序列化成JSON,配合@POST的路径,最终请求地址是http://服务器IP:8080/sign/in@Query用于GET请求拼接参数,适合拉取题目列表这种幂等操作。三个接口分别对应学生端三大动作:签到、举手、答题,服务端也按这个路径命名风格对齐,排查问题时能从日志里直接看出在调哪个接口。

4.3 签到按钮如何拿到经纬度和时间并提交

签到的核心逻辑不是点击事件里调一次接口那么简单。点击瞬间要同时做三件事:获取当前GPS位置、格式化本地时间、组装请求体。经纬度获取用LocationManager,时间戳用System.currentTimeMillis()转换格式。这里的时间不是最终请求参数,还要配合服务端时间做校准,但客户端这里先带上。

btnSignIn.setOnClickListener(v -> { if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, 100); return; } Location loc = locationManager.getLastKnownLocation( LocationManager.GPS_PROVIDER); if (loc == null) { loc = locationManager.getLastKnownLocation( LocationManager.NETWORK_PROVIDER); } String signTime = new SimpleDateFormat( "yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(new Date()); SignInRequest req = new SignInRequest(); req.setStudentId(sharedPrefs.getString("studentId", "")); req.setCourseId(courseId); req.setLatitude(loc == null ? null : loc.getLatitude()); req.setLongitude(loc == null ? null : loc.getLongitude()); req.setSignTime(signTime); signInApi.signIn(req).enqueue(new Callback<ResponseBody>() { @Override public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) { if (response.isSuccessful()) { Toast.makeText(MainActivity.this, "签到成功", Toast.LENGTH_SHORT).show(); } } @Override public void onFailure(Call<ResponseBody> call, Throwable t) { Toast.makeText(MainActivity.this, "网络异常,请检查连接", Toast.LENGTH_SHORT).show(); } }); });

GPS优先、网络定位兜底,这是安卓定位的标准降级策略。getLastKnownLocation拿到的可能是几分钟前的缓存位置,但对教室签到来说精度完全够用,因为判断的是学生是否在教室方圆几百米内,而不是精确到座位。时间格式化用Locale.CHINA避免在英文系统下输出Jan 1, 2025这种格式,JSON解析时还要额外处理一遍,不如一开始就统一格式。

4.4 举手队列的本地实现

举手功能的实现思路是RecyclerView列表加一个本地维护的状态位。学生点击举手按钮后,请求发送到服务端,教师端定时轮询获取当前举手学生列表。客户端收到成功后,把按钮置灰并显示“已举手”,列表用SparseBooleanArray记录每条学生数据的举手状态,适配器刷新时根据状态切换按钮文案。

private final SparseBooleanArray handUpStatus = new SparseBooleanArray(); public void toggleHandUp(int position) { boolean newStatus = !handUpStatus.get(position, false); handUpStatus.put(position, newStatus); notifyItemChanged(position); HandUpRequest req = new HandUpRequest(); req.setStudentId(currentStudentId); req.setCourseId(courseId); req.setAction(newStatus ? "raise" : "cancel"); handUpApi.handUp(req).enqueue(...); }

SparseBooleanArray是Android为节省内存设计的key为int的布尔数组,比HashMap<Integer, Boolean>省一半空间。notifyItemChanged只刷新当前item,不做全列表刷新,学生多的时候列表滚动不会卡顿。举手状态存内存即可,App杀掉重进后举手状态失效,需要重新点击,这个行为在课堂场景是合理的——中途退出的学生再次进入课堂,教师端不应该还挂着他之前的举手状态。

5. SpringBoot接口设计与随堂测试闭环

5.1 Controller签名与前端的对齐

服务端接口设计要解决的核心问题是和安卓端的契约对齐。Controller层的入参不直接用实体类,而是新建一个SignInRequest作为DTO,避免数据库实体暴露给前端。字段类型和JSON的key严格遵守小写驼峰,和安卓端Gson解析的配置一致。

@RestController @RequestMapping("/sign") public class SignInController { @PostMapping("/in") public ResultBean signIn(@RequestBody SignInRequest req) { if (req.getStudentId() == null || req.getCourseId() == null) { return ResultBean.error(400, "参数缺失"); } SignResult result = signInService.doSignIn(req); return ResultBean.success(result); } }

@RequestMapping("/sign")和类方法上的@PostMapping("/in")拼出的完整路径是/sign/in,和安卓端Retrofit里的@POST("sign/in")一一对应,这就是前后端对齐的锚点。ResultBean里放code、message、data三个字段,安卓端解析时先判断code,再取data,错误信息直接Toast展示,避免每个接口单独写异常处理。

5.2 Service里迟到判定的时间窗口

签到判定的核心在Service层,不在Controller里。规则是:课程开始前10分钟到课程开始后10分钟内签到为正常,之后30分钟内为迟到,超过30分钟直到课程结束记为缺勤。这个时间窗口放在application.yml里配置,而不是写死在代码里,因为不同学校对迟到时长的定义不一样。

@Service public class SignInServiceImpl implements SignInService { @Value("${sign.early-minutes:10}") private int earlyMinutes; @Value("${sign.late-minutes:30}") private int lateMinutes; @Override public int calcStatus(LocalDateTime courseStart, LocalDateTime signTime) { if (signTime.isBefore(courseStart.plusMinutes(earlyMinutes)) && signTime.isAfter(courseStart.minusMinutes(earlyMinutes))) { return 1; } if (signTime.isAfter(courseStart.plusMinutes(earlyMinutes)) && signTime.isBefore(courseStart.plusMinutes(lateMinutes))) { return 2; } return 3; } }

@Value注解把配置项注入到Service里,调整签到规则只需要改配置文件重启,不用重新打包。courseStart从课程表里取,signTime取的是服务端接收到请求的时间,而不是安卓端传上来的req.getSignTime(),这个设计是防止学生修改手机时间绕过迟到判定。如果项目打算做成多教室版本,还可以把这个窗口时间挪到课程表里每节课单独配置。

5.3 随堂测试的发布、作答与统计

测试模块的逻辑比签到复杂在状态流转:待发布、进行中、已截止三个状态。教师端发布测试题时,服务端把题目插入题目表并生成一个测试批次,记录开始和截止时间。学生端拉取题目列表,作答后提交答题记录,服务端逐题判分并入库。

@Transactional(rollbackFor = Exception.class) public TestResult submitTest(TestSubmitRequest req) { List<AnswerItem> answers = req.getAnswers(); int score = 0; for (AnswerItem item : answers) { Question question = questionMapper.selectById(item.getQuestionId()); boolean correct = question.getCorrectAnswer().equals(item.getStudentAnswer()); if (correct) { score += question.getScore(); } testRecordMapper.insertAnswer(req.getStudentId(), item.getQuestionId(), item.getStudentAnswer(), correct ? 1 : 0); } TestResult result = new TestResult(); result.setScore(score); result.setTotal(score); return result; }

@Transactional保证判分和写答题记录在同一个事务里,如果中途有一题写入失败,整个提交操作回滚,学生可以重新作答。判分逻辑放在循环里逐题对比,一个测试批次的题目量在几十道量级,性能不会成为瓶颈。correctAnswer字段只存在于服务端,接口返回题目列表时把这个字段置null,防止学生直接抓包看到正确答案。

6. 三招现场验证签到链路:抓包、adb、服务器时间

6.1 用Charles抓包确认请求参数

签到链路不通,八成是请求参数和服务端接收对不上。在模拟器里把Charles代理设置为10.0.2.2:8888,手机连同一Wi-Fi后设代理为电脑IP加8888端口。打开签到按钮后,Charles的Structure面板里能看到POST请求的完整JSON报文,检查studentIdcourseIdlatitudesignTime这几个key的命名是否和SignInRequest的字段完全一致。Gson在解析时遇到多余的字段会忽略,但如果少字段就会直接反序列化失败,返回400错误。

6.2 用adb查安卓端SQLite缓存

如果服务端日志显示请求到了但业务异常,问题可能出在客户端本地缓存。签到App在学生端一般会缓存最近的签到记录,方便在无网络时先存本地,网络恢复后补传。用adb进到App的私有目录直接看SQLite文件:

adb shell run-as com.example.qiandao ls databases/ adb shell run-as com.example.qiandao cat databases/qiandao.db

run-as能以App身份读取/data/data下的私有目录,不需要root权限,调试阶段非常实用。如果这条命令报错,检查AndroidManifest.xmldebuggable属性是否为true,正式包是不可调试的。看缓存表里是否有待补传的记录,再核对server_timesign_time的差值,如果差距超过5分钟,就得回到服务端检查时间校准逻辑。

6.3 以服务器时间为唯一基准

最后提醒一个最容易被忽视的坑:客户端时间和服务器时间的一致性。学生的手机可能开了自动同步,也可能没有,甚至被手动改到两小时前。最可靠的做法是服务端收到签到请求后,里那个calcStatus方法判断迟到时只用LocalDateTime.now()这个服务端时间,安卓端传上来的signTime只作为业务展示字段,不参与判定。这样即使学生端时间错得离谱,服务端的签到记录依然可信,教师端统计迟到率时拿到的是准确数据。

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

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

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

立即咨询