简介这是一份基于Android Studio开发的课堂学生点名签到App源码及配套资料包面向需要实现课堂考勤管理的师生也适合Android开发初学者用于课程设计或实训项目。资源分为教师端与学生端涵盖名单导入、账号身份自动匹配、添加学生与班级、局域网内点名签到等功能教师端还支持缺勤统计与记录文件导出覆盖课堂考勤的完整闭环。包体共含1631个文件压缩包约85MB。其中xml为界面布局与配置文件java与class为源码及编译产物png为界面截图与图标资源json、so等涉及数据配置与底层依赖另有apk安装包、演示视频mp4、运行文档docx及安装环境说明结构清晰便于按需取用。该资源已有3243人浏览学习。除完整可运行源码外还附有演示视频、基础安装环境、运行文档以及核心代码注释和模块截图可帮助读者快速理解点名签到业务流程与关键算法便于二次开发或毕业设计参考。1. 点名签到App到底难在哪这不是一个按钮加一个列表的事“基于Android Studio开发的安卓的课堂学生点名签到app”这类需求我接过不止一次每次对方都觉得很简单一份名单、两个按钮、一个勾选状态。真做下来才发现点名App的核心难点根本不在UI而在状态流转——点名中能不能重复点、补签怎么记、迟到和缺勤怎么分、日期按哪个时区算、App被系统杀掉之后数据还在不在。这篇文章就按一次完整的落地过程来讲先定点名方案再搭工程再落数据库最后把高频踩坑点一条条摆出来。它适合独立开发者、课程设计团队也适合想在教学场景里快速搭一套可运行点名工具的人。点名App真正解决的是三个问题让课堂出勤记录从纸面变成结构化数据让随机提问看起来“公平”让老师课后拿到一张能直接存档的出勤统计表。单说技术栈Android Studio加Kotlin加Room数据库就够了不需要引入服务端这也是很多学校环境里最务实的做法——一台安卓平板就能跑起来断网也不影响点名。2. 点名方案先定好普通点名、随机点名与出勤口径的选择写代码之前我会先把点名这件事拆成“模式、状态、统计口径”三层否则后面每改一个需求都要动数据库表结构返工成本很高。2.1 四种点名模式的语义差异与数据建模课堂点名常见做法是四种模式混着用花名册点名、随机点名、扫码签到、定位签到。很多新手一上来就做“花名册点名”等老师提出“我要随机抽人回答问题”时才发现名单界面上还得加一个抽选逻辑。花名册点名是最基础的模式本质是老师对着名单逐个人工确认操作路径就是一个列表加两个按钮出勤、缺勤数据上只需要记录“谁在哪个时间点被点了”。随机点名解决的是“课堂提问公不公正”的问题它的数据要求是从当前未点名名单里无放回抽取已经点过的学生不能再被抽到。扫码签到把点名动作交给学生自己完成每个学生生成一个二维码或者全班扫同一个二维码后输入学号防代签效果比人工点名好但需要教室有网络或局域网。定位签到则是在学生端获取位置比对预设教室的经纬度范围实现成本最高而且真机上定位权限、室内GPS漂移都容易翻车我一般只在课程设计演示里推荐。四种模式的实现成本差异很大我在方案评审时通常用这张表直接拍板模式需要的数据防代签能力实现成本适用场景花名册点名学生名单弱低小班课老师手动控场随机点名名单已点标记中低课堂提问活跃气氛扫码签到二维码/学号中中大班课快速签到定位签到GPS/室内定位中高需要限定教室范围的场景数据建模上无论选哪种模式底层都是“学生表”和“出勤记录表”。学生表存学号、姓名、班级出勤记录表存课程ID、学生ID、日期、状态。点名模式只是生成记录的方式不同记录表结构不用变这一点想清楚后面需求切换时就不用重写数据库。2.2 状态流转与交互设计点名中、已点名、补签点名不是“点一下按钮就结束”的单次动作它有一个生命周期。同一节课里老师可能先发起点名学生陆续签到临下课发现有学生刚进教室还得补签。所以点名会话至少要区分“未开始、进行中、已结束”三种状态学生个体则要有“未点名、出勤、迟到、缺勤、请假、补签”几种记录状态。我通常用一个枚举把个人状态固定下来存数据库时存英文枚举名而不是中文这样统计口径不会因为显示文案混乱enum class AttendanceStatus(val displayName: String) { UNMARKED(未点名), PRESENT(出勤), LATE(迟到), ABSENT(缺勤), LEAVE(请假), MAKEUP(补签); companion object { fun fromValue(value: String): AttendanceStatus entries.firstOrNull { it.name value } ?: UNMARKED } }代码逻辑很简单关键是“缺勤”和“未点名”必须分开。未点名是还没轮到缺勤是老师明确记了“没来”这两者在统计出勤率时含义完全不同。如果你用同一个值表示统计时只能靠猜。补签则一定要带备注字段否则事后说不清为什么这个人状态变了。时间窗口也在这里定义我习惯把“发起签到后10分钟内到的”算迟到超过10分钟算缺勤补签只能在点名结束后5分钟内由老师手动操作。这些参数不写死在代码里而是在设置页做成可调项后续老师提“我们课间只有5分钟”时改配置就行不用改逻辑。2.3 出勤率统计口径分母到底是谁出勤率是个看起来简单、实际最容易扯皮的数字。分母用全班人数还是应到人数请假算不算缺勤迟到算不算出勤每个老师答案都不同。我的默认口径是出勤率 出勤人数 迟到人数 补签人数/ 应到人数其中应到人数 全班总人数 - 长期请假人数。长期请假特指病假、休学这类整学期不来的和单次请假分开处理。fun calcAttendanceRate( totalCount: Int, longLeaveCount: Int, recordMap: MapLong, AttendanceStatus ): Double { val shouldAttend totalCount - longLeaveCount val present recordMap.values.count { it in setOf( AttendanceStatus.PRESENT, AttendanceStatus.LATE, AttendanceStatus.MAKEUP ) } return if (shouldAttend 0) 0.0 else (present.toDouble() / shouldAttend) }这段统计逻辑里的关键参数是longLeaveCount很多开发者在数据库里没有单独标识长期请假的学生导致分母总是全班人数出勤率永远偏低。单次请假和长期请假在数据上必须区分否则同一个学生本周请假三次每次都被算进分母统计就失真了。这个口径我建议在需求阶段就和老师确认清楚写入设置项而不是自己拍脑袋定。3. 用Android Studio搭出签到工程包名、SDK版本与项目结构方案定完进入工程搭建。Android Studio新建项目这一步本身没难度但有几个配置项会直接影响后面开发效率值得展开说。3.1 新建工程必改的三个配置compileSdk、minSdk与包名新建项目时我选“Empty Views Activity”语言选Kotlin不用Compose。点名应用表单密集、列表刷新频繁Compose 也能做但团队里如果有人没接触过Compose协作成本会高。模板生成后第一件事是改app/build.gradle.kts里的版本参数plugins { id(com.android.application) id(org.jetbrains.kotlin.android) id(kotlin-kapt) } android { namespace com.example.classroom compileSdk 34 defaultConfig { applicationId com.example.classroom minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } } dependencies { implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.appcompat:appcompat:1.6.1) implementation(com.google.android.material:material:1.11.0) implementation(androidx.recyclerview:recyclerview:1.3.2) implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0) implementation(androidx.room:room-runtime:2.6.1) implementation(androidx.room:room-ktx:2.6.1) kapt(androidx.room:room-compiler:2.6.1) }这里要说的参数有三个minSdk 24表示最低支持Android 7.0教室里的旧平板大多在这个版本之上再低的话一些新API用不了applicationId是要上架或多台设备安装时区分的唯一身份一个人开发时经常忘改等两台机器装同一个调试包互相覆盖才反应过来compileSdk 34对应Android 14如果你电脑上Android Studio版本较新SDK Manager里没下载34的话改成已安装的33或35也行原则是compileSdk不低于targetSdk。界面语言不习惯英文的可以在Android Studio的Settings里搜索“Language”直接切简体中文这不影响代码。3.2 包结构划分从model到ui的分层点名App规模不大但包结构我建议按功能分层而不是把一个页面当一层。一个典型结构是这样com.example.classroom/ ├── data/ │ ├── model/ # Student.kt, AttendanceRecord.kt │ ├── dao/ # StudentDao.kt, AttendanceDao.kt │ ├── repository/ # StudentRepository.kt, AttendanceRepository.kt │ └── db/ # AppDatabase.kt ├── ui/ │ ├── attend/ # 点名页面 │ ├── random/ # 随机点名页面 │ ├── statistics/ # 统计页面 │ └── viewmodel/ # 各页面对应的ViewModel └── util/ # DateUtils.kt, CsvExporter.ktdata层负责数据来源ui层只做展示和事件分发util层放日期格式、CSV导出这类与业务无关的工具。很多课程设计项目喜欢把所有Activity放一个包、Adapter放一个包这样前期写起来快但一旦要加随机点名或统计页面一个类的依赖会迅速膨胀。按这个结构分层单个类职责清晰后面第4章加Room数据库时也能直接对号入座。3.3 界面框架一个列表、两个按钮、一个状态栏点名主界面我通常是一个RecyclerView横向铺满顶部一条状态栏显示当前课程和出勤进度底部两个按钮分别做“标记缺勤”和“随机抽取”。布局文件不贴全量XML关键是用DiffUtil做列表刷新而不是每次都notifyDataSetChangedclass StudentDiffCallback( private val oldList: ListStudentItem, private val newList: ListStudentItem ) : DiffUtil.Callback() { override fun getOldListSize() oldList.size override fun getNewListSize() newList.size override fun areItemsTheSame(oldPos: Int, newPos: Int) oldList[oldPos].studentId newList[oldPos].studentId override fun areContentsTheSame(oldPos: Int, newPos: Int) oldList[oldPos] newList[oldPos] }这一段的重点是areItemsTheSame用studentId判断而不是用列表下标。点名过程中状态刷新频繁如果只看下标RecyclerView复用组件时会出现“这一行的状态跳到那一行”的视觉错乱。列表数据要从LiveData或StateFlow里来UI层不维护任何可变名单副本这样数据源和界面永远保持一致。4. 落地Room数据库名单表、签到记录表与DAO设计点名数据必须持久化否则老师在课堂上点了一半App被系统回收重开就全丢了。Android里做本地存储常见选择有SharedPreferences、SQLite和Room我的结论是点名这种多表关联场景直接用Room。4.1 为什么选Room而不是SharedPreferencesSharedPreferences本质是KV存储适合存设置项比如迟到判定阈值、班级名称。把几百个学生的出勤记录塞进去读写都是整个文件操作更麻烦的是没法回答“这个学生本周缺勤了几次”这类查询。手写SQLite能用但容易出低级错误SQL语句里少写一个条件统计结果错了还不好排查。Room在SQLite之上提供编译期SQL检查、LiveData/Flow响应式查询和简单到不需要写Helper的DAO接口这是三个最实在的价值。4.2 建表、DAO与Repository的完整代码先看两个实体类。学生表和出勤记录表分开建不要试图把所有字段塞进一张表Entity(tableName student) data class Student( PrimaryKey(autoGenerate true) val studentId: Long 0, ColumnInfo(name student_no) val studentNo: String, ColumnInfo(name name) val name: String, ColumnInfo(name course_id) val courseId: Long? null, ColumnInfo(name long_leave) val longLeave: Boolean false ) Entity( tableName attendance_record, indices [Index(value [course_id, student_id, date_str], unique true)] ) data class AttendanceRecord( PrimaryKey(autoGenerate true) val recordId: Long 0, ColumnInfo(name course_id) val courseId: Long, ColumnInfo(name student_id) val studentId: Long, ColumnInfo(name date_str) val dateStr: String, ColumnInfo(name status) val status: String, ColumnInfo(name remark) val remark: String )实体类的设计有几个值得注意的参数longLeave字段就是第2章统计口径里说的长期请假标记这一步不加后面算应到人数就得靠外部逻辑补救status字段存英文枚举名比如“PRESENT”而不是中文“出勤”避免不同版本显示文案不同导致数据对不上date_str是“yyyy-MM-dd”格式的字符串不是时间戳因为统计按天分组字符串既能直接展示又能排序。DAO层是Room的使用核心放三个接口方法就够初版使用Dao interface AttendanceDao { Insert(onConflict OnConflictStrategy.IGNORE) suspend fun insertRecord(record: AttendanceRecord): Long Query(SELECT * FROM attendance_record WHERE course_id :courseId AND date_str :dateStr) fun observeRecords(courseId: Long, dateStr: String): FlowListAttendanceRecord Query(SELECT * FROM attendance_record WHERE student_id :studentId ORDER BY date_str DESC) fun observeStudentRecords(studentId: Long): FlowListAttendanceRecord }insertRecord返回Long主键冲突时返回-1observeRecords返回Flow数据表变化时自动推给界面层。写DAO时我踩过的一个坑是Room的查询语法是编译期检查的表名和列名大小写不一致会直接编译失败所以建议实体类里的表名全部用小写加下划线查询语句原样对应不要混用驼峰。最后把它们组装进Database和RepositoryDatabase( entities [Student::class, AttendanceRecord::class], version 1, exportSchema false ) abstract class AppDatabase : RoomDatabase() { abstract fun studentDao(): StudentDao abstract fun attendanceDao(): AttendanceDao companion object { Volatile private var INSTANCE: AppDatabase? null fun getInstance(context: Context): AppDatabase INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, classroom_db ).build().also { INSTANCE it } } } }exportSchema false只是跳过Schema导出不影响运行。单例获取数据库实例时传applicationContext而不是Activity实例这是防止内存泄漏的标准操作很多人在这一步图省事传入Activity导致整个页面卡死。4.3 签到幂等用唯一索引挡住重复点击出勤记录表的indices里我给course_id、student_id、date_str三个字段加了联合唯一索引。这个索引唯一的用途就是处理“学生连点了两次签到”和“老师手动重复标记”的情况。配合DAO里的OnConflictStrategy.IGNORE同一节课同一个学生同一个日期只可能有一条记录重复插入直接静默忽略不会报错。如果不加这个唯一索引靠代码里先查再插会遇到并发问题两个请求同时查都说不存在然后都插进去了。真机上这个概率不高但团队演示时最容易翻车。加了唯一约束之后逻辑上就把“幂等”这件事下放给数据库业务层不需要再维护一个已签到ID集合。5. 课堂点名App的5个高频踩坑点现象、原因与解决办法开发中最花时间的往往不是写新功能而是排旧坑。这5个问题我在不同项目里反复遇到过按“现象→原因→解决”列出来参照着排查能省不少时间。5.1 签到按钮连点两次记录表里多了一行现象学生点击签到后数据库出勤记录出现两条相同数据统计时一个人算两次出勤。 原因按钮点击事件里没有做防抖或者DAO层的插入走的是先查询再插入的方式并发时判断失效。 解决第4章里的联合唯一索引加OnConflictStrategy.IGNORE是数据库层面的兜底UI层面再加一道保险在点击回调里用SystemClock.elapsedRealtime()做500毫秒防抖双击事件被拦截在入口处两层双保险之后这个问题不会再出现。5.2 当天日期错位时区导致的“昨天”和“今天”混乱现象凌晨0点到2点之间签到时记录被存到了前一天统计页里“今天”的名单是空的。 原因用了System.currentTimeMillis()直接转默认格式化器默认时区可能不是设备本地时区或者用了UTC时间格式化导致东八区凌晨的日期落在UTC的昨天。 解决使用java.time.LocalDate.now()获取日期它直接按设备时区生成当天日期字符串fun today(): String LocalDate.now().toString() // 输出 2025-01-12已按本地时区不要在代码里手动拼接“年-月-日”也不要用SimpleDateFormat(yyyy-MM-dd, Locale.getDefault())配合Date()那个组合在时区切换时会露出各种边界问题。5.3 RecyclerView滚动后点名状态被重置现象滚动列表再滚回来刚才点的出勤状态不见了页面恢复正常刷新后又出现。 原因ViewHolder被复用了但状态没有存进数据模型而是临时存在itemView的tag上或只是改了背景色滚动后ViewHolder被系统回收状态跟着丢。 解决把所有签到状态写进ViewModel层的MapLong, AttendanceStatus列表数据每次刷新都从这条Map映射生成并用第3章的DiffUtil做差量更新。只要数据源里有状态UI层怎么复用都不会丢。5.4 改了表结构忘了写Migration升级后App闪退现象开发阶段改了实体类加了一个字段重新装调试包没问题但在旧版本基础上升级安装就直接闪退日志提示IllegalStateException: Room cannot verify the data integrity。 原因数据库版本号没升级或者升级时没提供Migration策略。开发期很多人用fallbackToDestructiveMigration()偷懒但装到老师手机上时旧数据被清空是事故。 解决生产级做法是版本1并写一个Migrationval MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE attendance_record ADD COLUMN remark TEXT DEFAULT ) } }然后在Room.databaseBuilder里调用.addMigrations(MIGRATION_1_2)这样旧数据还在新字段带默认值升级不再闪退。5.5 ViewModel持有Activity引用屏幕旋转后内存泄漏现象旋转屏幕后App响应变慢反复旋转几次就OOMLeakCanary报MainActivity泄漏。 原因ViewModel里直接存了Activity或View引用。ViewModel生命周期比Activity长Activity销毁时ViewModel还持有它GC无法回收。 解决ViewModel只存数据类、Flow、Repository这类与UI无关的引用。需要弹Toast或改UI时通过LiveData或StateFlow把事件发给ActivityActivity在onViewCreated里订阅。我遇到过更隐蔽的情况是在ViewModel里放了Context溯源发现是写了一个getSharedPreferences的辅助方法传进了Activity后来统一改用AndroidViewModel(application)再从Application取上下文。6. 真机验证五步法与三个进阶动作代码写完不等于能用我习惯在教室真机上过一遍完整流程而不是只在Android Studio模拟器里看效果。6.1 真机必须跑通的五项检查检查项操作预期结果冷启动恢复点名中途杀进程重新打开App已点记录还在点名状态回到“进行中”重复点击对同一学生连点10次签到记录表只有一条数据补签流程点名结束后对缺勤学生补签记录状态变MAKEUP备注可写日期切换跨过零点继续点名新日期下出勤记录与昨天分离统计口径造1个长期请假、2个单次请假出勤率分母正确单次请假不影响应到人数6.2 进阶一一键导出CSV给老师存档老师课后要的是能交到教务处或发进家长群的表格不需要打开App翻半天。我习惯在统计页做一个“导出CSV”按钮生成文件后用Intent发出去fun exportCsv(context: Context, records: ListAttendanceRecord): Uri { val sb StringBuilder() sb.append(学号,姓名,状态,日期,备注\n) records.forEach { sb.append(${it.studentNo},${it.name},${it.status},${it.dateStr},${it.remark}\n) } val file File(context.getExternalFilesDir(null), attendance_${LocalDate.now()}.csv) file.writeText(sb.toString()) return FileProvider.getUriForFile(context, ${context.packageName}.fileprovider, file) }这里有个参数容易出错getExternalFilesDir是应用私有目录不需要存储权限但发给微信或邮件时要用FileProvider生成content://的Uri直接用file://会被很多应用拒绝。不要把CSV存到公共Download目录那反而要申请存储权限纯属自找麻烦。CSV文件用Excel和WPS都能打开编码建议用带BOM的UTF-8否则Windows上的Excel打开中文会乱码。6.3 进阶二签名打包与课堂投屏的只读模式开发调试签名的App只能在开发者手机安装换一台教室平板就得重新连电脑。正式使用建议用Android Studio的“Build — Generate Signed Bundle/APK”生成一个正式签名包同一个keystore后面所有版本都要用否则覆盖安装会报签名冲突。这个keystore我一般放在项目外的目录并写进.gitignore它比源码本体更不能丢。如果教室有大屏常见做法是再加一个“投屏模式”开关开启后把点名状态页做成只能看不能点的展示页用手机投屏或Android TV盒子投到大屏上。这个功能不复杂但很能提升真实课堂的体验。我最后想说一个习惯这类工具做完我总会守着老师实际用一节课因为只有真实课堂上才会出现一个学生帮室友代点、两个学生同用一个学号、老师自己误操作这种数据模拟器里永远测不出来。点名这件事技术难点不在实现而在让数据在真实混乱的课堂里依然可信。希望帮到你。本文还有配套的精品资源点击获取