简介基于Android的学生成绩管理系统是一套面向移动开发初学者的完整项目资源覆盖SQLite数据库操作、Java业务逻辑与原生UI设计三大核心模块。系统实现了学生信息及成绩的增删改查、条件查询、排序以及基础异常处理并针对本地存储和异步加载做了相应优化适合课程设计、毕业设计或自学练手。资源包共1641个文件以class、dex、jar、java、xml、json、apk等类型为主其中java与xml用于查看源码和界面布局class与dex为编译产物jar、json等涉及依赖与配置信息压缩包整体约18.93MB便于下载后直接导入Android工程分析。配套资料包含完整项目结构、数据库表设计思路、ContentProvider数据访问方式、关键SQL语句和UI实现方案可帮助读者快速理解AndroidSQLite开发流程。目前已有4242人学习对于希望系统掌握移动端成绩管理场景的开发者具有较高参考价值。1. 这个系统到底在解决什么问题一个学期末的真实场景基于Android的学生成绩管理系统听起来像个课设题目但把它放到学期末的教务办公室里就是刚需几百份成绩单要录入、按班级算平均分、按课程算及格率、让学生随时随地查分。用Excel做这些事录到一半就不知道错在哪一行用学校现成的教务系统申请个账号要排半个月队。这套系统要解决的正是这个矛盾老师用手机录入和修改成绩学生用手机查自己的分数与排名管理员在后台维护课程和账号整个链路跑在Android客户端加一套轻量后端上。适合拿来做课程设计、毕业设计也适合给小型培训机构或社团做内部工具——技术门槛不高但五脏俱全能练到网络请求、数据库设计、列表展示和权限控制这些Android开发的核心能力。2. 动手前的技术选型为什么我劝你不要一上来就写代码很多新手拿到这个题目第一反应是打开Android Studio新建项目然后花两周时间写了个只能在本机上看的Demo最后联调后端时发现接口对不上、数据存不进去、权限全乱套。问题不在编码能力而在选型。这一章先把路选好后面写起来会顺很多。2.1 三条路线横向对比单机SQLite、云后端、自建服务常见做法是三种方案里选一条方案数据同步部署成本适合场景短板方案A纯 SQLite 单机存储无零快速出Demo课程设计凑功能换手机就丢数据多端无法共享方案BBaaS 云后端有低想绕过后端开发平台绑定数据导出和自定义接口受限方案C自建后端 MySQL完全可控中毕业设计、真实小范围使用要花时间部署和维护后端我的建议是选方案C即使你是纯Android方向后端只写最简的接口就行——几个Servlet或者一个Express应用加起来不到两百行却能把“成绩管理”这个业务闭环做完整。毕设答辩时老师问“数据怎么存的接口怎么设计的”你有东西可讲课程设计里方案C比方案A多不了多少代码量但完成度完全是两个档次。2.2 数据模型设计用户表、课程表、成绩表怎么定选型定下来之后第一步不是写代码是定表结构。成绩管理系统的核心就三张表用户表、课程表、成绩表。用户表用来承载三类角色。我在做的时候会用role字段区分teacher、student、admin而不是建三张几乎一样的表。课程表记录课程基本信息和任课老师成绩表则把学生和课程关联起来。下面是建表SQL按MySQL语法写-- 用户表学生、老师、管理员统一存放用 role 区分 CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, real_name VARCHAR(50) NOT NULL, role VARCHAR(20) NOT NULL, -- teacher / student / admin class_name VARCHAR(50) -- 学生必填老师可空 ); -- 课程表课程信息和任课老师 CREATE TABLE courses ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, teacher_id INT NOT NULL, -- 指向 users.id credit INT DEFAULT 2 ); -- 成绩表一条记录就是一个学生某门课的成绩 CREATE TABLE scores ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, -- 指向 users.id course_id INT NOT NULL, -- 指向 courses.id score DECIMAL(5,1) NOT NULL, -- 支持 88.5 这样的分数 term VARCHAR(20) NOT NULL, -- 学期如 2024-2025-1 remark VARCHAR(255), -- 补考、缓考等备注 created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这里有几个字段是新手容易忽略的。DECIMAL(5,1)而不是INT因为现实中成绩经常有0.5分的精度term字段必须单独存否则不同学期的同一门课成绩会互相覆盖remark字段看似可以不要但补考、缓考、缺考这些状态真实存在留一个备注字段能省掉后面改表结构的麻烦。角色控制上我一般会在接口层做两层校验第一层根据role判断用户能不能调用这个接口第二层根据业务逻辑判断操作对象是否合法。比如学生只能查自己的成绩老师只能录自己教的课程这个逻辑不复杂但必须在设计表时就想到而不是等代码写完了再补。2.3 接口协议先约定好JSON格式再写两端前后端联调时最常见的翻车现场是后端返回{status: 1}Android端解析code字段于是永远走不进成功分支。所以我在动手前会先定一个统一的返回结构所有接口都遵守它{ code: 0, msg: success, data: {} }code为 0 表示成功非 0 表示各种业务错误比如 1001 表示密码错误、1002 表示无权限。这样Android端只需要在拦截器里对code做一次判断错误提示统一弹Toast不用每个调用处单独处理。核心接口也就五个登录、获取课程列表、获取成绩列表、录入成绩、获取统计结果。每个接口的请求参数和返回结构先写在接口文档里哪怕只是一个Markdown文件也要写清楚——这能省掉联调阶段至少一半的沟通成本。3. 把工程搭起来从零到能登录的最小可用项目选型和接口都定好了这一章开始写Android端。目标很明确跑通一个从登录到展示学生姓名的最小闭环。这一步通了后面的成绩录入和查询就是在同一个骨架上加肉。3.1 工程初始化与Gradle依赖我习惯用Kotlin Jetpack Compose或者传统的XML布局都行但如果你是第一回做这种完整项目建议用XML布局因为网上能查到的资料最多出问题时排查路径最短。Gradle里需要引入的东西如下// app/build.gradle 核心依赖 dependencies { // 网络请求Retrofit 2.9 OkHttp 拦截器 implementation com.squareup.retrofit2:retrofit:2.9.0 implementation com.squareup.retrofit2:converter-gson:2.9.0 implementation com.squareup.okhttp3:logging-interceptor:4.12.0 // 本地缓存Room 2.6.x配合 Kotlin 协程用 implementation androidx.room:room-runtime:2.6.1 implementation androidx.room:room-ktx:2.6.1 kapt androidx.room:room-compiler:2.6.1 // 生命周期与ViewModel implementation androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 implementation androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 }依赖版本不要随便配retrofit和converter-gson必须版本一致room的运行时和编译器也必须一致。新手最容易血泪踩坑的就是这两个地方版本错一位编译报错能把你绕晕。AndroidManifest里还需要加网络权限和明文流量许可后者是新手最容易漏的uses-permission android:nameandroid.permission.INTERNET / !-- Android 9 默认禁止明文HTTP开发阶段必须放开 -- application android:usesCleartextTraffictrue ... /usesCleartextTraffictrue这句在调试阶段写上正式上线前再根据后端是否走HTTPS决定要不要去掉。忘了加这句话你会发现所有接口都报“Cleartext HTTP traffic not permitted”而模拟器上有时又正常非常迷惑。3.2 数据层搭建Retrofit接口定义与统一返回结构先建一个ApiResponse包裹类对应后端那个统一的JSON结构// 统一响应体泛型T代表data字段的实际类型 data class ApiResponseT( val code: Int, val msg: String, val data: T? ) { val isSuccess: Boolean get() code 0 }然后定义接口。登录接口和获取成绩列表接口是必须的我一般会把所有接口先定义好再逐个实现interface ApiService { // 登录POST 提交 JSON返回用户信息 POST(api/login) suspend fun login(Body request: LoginRequest): ApiResponseLoginResponse // 成绩列表按学生ID查成绩老师和管理员可以不传studentId查全部 GET(api/scores) suspend fun getScores( Query(studentId) studentId: Int?, Query(courseId) courseId: Int?, Query(term) term: String? ): ApiResponseListScoreItem // 录入成绩老师端调用 POST(api/scores) suspend fun addScore(Body request: AddScoreRequest): ApiResponseUnit // 统计平均分、最高最低分、及格率 GET(api/stats) suspend fun getStats( Query(courseId) courseId: Int, Query(term) term: String ): ApiResponseStatsResult }suspend fun是关键配合协程使用能避免回调地狱。Query与Body的使用场景要注意区分查询参数用前者对象体用后者。返回类型里的ListScoreItem直接对应JSON数组Gson会帮你完成转换。接着创建Retrofit客户端。建议把它封装成单例object RetrofitClient { private const val BASE_URL http://10.0.2.2:8080/ // 模拟器访问宿主机 private val okHttpClient by lazy { OkHttpClient.Builder() .addInterceptor(HttpLoggingInterceptor().apply { level HttpLoggingInterceptor.Level.BODY // 打印完整请求和响应 }) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build() } val apiService: ApiService by lazy { Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() .create(ApiService::class.java) } }10.0.2.2是模拟器访问你电脑本机的专用IP替换掉localhost。如果是真机调试这里要改成你电脑在局域网里的IP比如192.168.1.8否则会连不上服务端。我在后面避坑章会单独讲这个。3.3 把登录流程跑通ViewModel 协程 SharedPreferences登录流程是整个系统第一个完整的业务闭环。用户输入用户名和密码点击登录请求后端接口成功后保存token和用户信息跳转主界面。class LoginViewModel : ViewModel() { private val api RetrofitClient.apiService private val _loginState MutableStateFlowLoginUiState(LoginUiState.Idle) val loginState: StateFlowLoginUiState _loginState fun login(username: String, password: String) { // 防止重复点击按钮 if (_loginState.value is LoginUiState.Loading) return viewModelScope.launch { _loginState.value LoginUiState.Loading try { val response api.login(LoginRequest(username, password)) if (response.isSuccess) { // 登录成功保存用户信息到本地 val user response.data saveUserInfo(user) _loginState.value LoginUiState.Success(user) } else { _loginState.value LoginUiState.Error(response.msg) } } catch (e: IOException) { // 网络异常要单独捕获不能走统一错误 _loginState.value LoginUiState.Error(网络连接失败请检查服务器地址) } } } private fun saveUserInfo(user: LoginResponse) { // SharedPreferences 存轻量用户信息足够 val sp getApplicationApplication().getSharedPreferences(user, MODE_PRIVATE) sp.edit() .putInt(userId, user.id) .putString(role, user.role) .putString(realName, user.realName) .apply() } }StateFlow用来管理UI状态Activity只观察这个Flow更新界面不用自己处理线程切换。这里有几个细节值得注意用MutableStateFlowLoginUiState而不是普通的LiveData是因为它是协程原生支持写法更顺畅try-catch里只捕获IOException业务错误由code分支处理两者不要混在一起。登录是所有其他功能的前置条件。这个闭环跑通等于把网络层、数据层、UI层之间的连接方式都验证了一遍。后面加新的功能模块只需要照着这个模式复制即可。4. 核心功能三件套成绩录入、查询与统计的实现登录闭环通了之后剩下的工作就是在这个骨架上填充业务。成绩管理系统的核心功能就三块录入、查询、统计。看着简单每一块都有几个容易被忽略的细节。4.1 成绩录入表单校验与防重复提交录入成绩是使用频率最高的操作也是用户容忍度最低的操作——老师录了一半突然App闪退或者网络超时之前录的就全没了这是最让人崩溃的场景。所以录入这块的代码要同时做三件事前端校验、异步提交、失败重试。class AddScoreViewModel : ViewModel() { private val api RetrofitClient.apiService // 返回校验失败信息null表示通过 private fun validateInput(studentId: Int, courseId: Int, scoreStr: String): String? { if (studentId 0) return 请选择学生 if (courseId 0) return 请选择课程 val score scoreStr.toFloatOrNull() ?: return 成绩必须是数字 if (score 0 || score 100) return 成绩必须在0-100之间 if (scoreStr.contains(.) scoreStr.split(.)[1].length 1) { return 成绩最多保留1位小数 } return null } fun submitScore(studentId: Int, courseId: Int, scoreStr: String, term: String) { val error validateInput(studentId, courseId, scoreStr) if (error ! null) { _submitState.value SubmitUiState.Error(error) return } viewModelScope.launch { _submitState.value SubmitUiState.Loading try { val response api.addScore( AddScoreRequest( studentId studentId, courseId courseId, score scoreStr.toFloat(), term term ) ) if (response.isSuccess) { _submitState.value SubmitUiState.Success } else { _submitState.value SubmitUiState.Error(response.msg) } } catch (e: IOException) { _submitState.value SubmitUiState.Error(网络异常数据未保存请重试) } } } }校验逻辑必须写在客户端但不能只写在客户端。这里的前端校验是为了给用户即时反馈减少无效请求真正的合法性校验比如这个学生是否真的选了这门课、老师是否有权给这个班录成绩必须由后端在写入数据库前再查一次。只做前端校验等于给绕过App直接调接口的人留了门。防误触的细节放在UI层提交按钮点击后立即置灰同时“Loading”状态要体现在按钮文字上比如变成“提交中…”。不然在网络慢的时候用户连点三次就会提交三份一样的成绩。4.2 成绩查询列表展示与多条件筛选查询功能看起来只是把ListScoreItem展示到RecyclerView里但实际使用时用户会有很多查询诉求学生想看自己的所有成绩老师想看某个班某门课的成绩管理员想看某个学期的全部成绩。所以查询接口必须支持组合筛选。我在前面接口里定义了三个可选参数studentId、courseId、term。服务端根据传入参数的组合动态拼接SQL// ApiService 里的查询方法组合条件查询 GET(api/scores) suspend fun getScores( Query(studentId) studentId: Int?, Query(courseId) courseId: Int?, Query(term) term: String? ): ApiResponseListScoreItemAndroid端用class_name筛选班级这个字段在users表里通常需要联表查询。后端返回的教学班成绩列表里每个项要包含studentName和courseName这两个冗余字段前端展示时直接绑定TextView省得在客户端再做一次关联。这算是一种以空间换时间的做法——多传两个字符串省掉客户端联表逻辑。列表适配器里要注意分数颜色区分及格显示正常色不及格显示红色。这个逻辑属于纯展示规则写在bind方法里就行// ScoreListAdapter.kt 关键绑定逻辑 override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item scores[position] holder.tvCourseName.text item.courseName holder.tvScore.text item.score.toString() // 不及格标红直观提醒用户 if (item.score 60) { holder.tvScore.setTextColor(Color.RED) } else { holder.tvScore.setTextColor(Color.BLACK) } }下拉刷新的做法比较统一用SwipeRefreshLayout包住RecyclerView刷新时重新请求接口拿到数据后整体替换。这里有个容易踩坑的点刷新接口返回的数据不要addAll到旧数据后面要先清空再添加否则列表会越来越长。4.3 成绩统计服务端聚合SQL与Android端展示统计报表是整个系统里“看起来很专业”的部分。平均分、最高分、最低分、及格率这些指标如果用List在客户端算数据量大时会卡顿而且每家手机性能不一样结果可能不一致。正确做法是让后端用SQL聚合Android端只负责展示。后端统计接口对应的SQL大致长这样-- 统计某门课某学期的整体情况 SELECT c.name AS course_name, COUNT(*) AS total_count, AVG(s.score) AS avg_score, MAX(s.score) AS max_score, MIN(s.score) AS min_score, SUM(CASE WHEN s.score 60 THEN 1 ELSE 0 END) / COUNT(*) AS pass_rate FROM scores s JOIN courses c ON s.course_id c.id WHERE s.course_id ? AND s.term ? GROUP BY c.id, c.name;SUM(CASE WHEN...) / COUNT(*)这个写法比单独查及格人数再除以总数的办法要省一次数据库往返实现也简单。注意AVG返回的是高精度小数后端转成BigDecimal后保留两位再传给前端避免浮点数精度损失。如果还想要每个分数段的分布情况可以再加一个查询-- 分数段统计60以下、60-69、70-79、80-89、90以上 SELECT SUM(CASE WHEN score 60 THEN 1 ELSE 0 END) AS below_60, SUM(CASE WHEN score BETWEEN 60 AND 69 THEN 1 ELSE 0 END) AS s60_69, SUM(CASE WHEN score BETWEEN 70 AND 79 THEN 1 ELSE 0 END) AS s70_79, SUM(CASE WHEN score BETWEEN 80 AND 89 THEN 1 ELSE 0 END) AS s80_89, SUM(CASE WHEN score 90 THEN 1 ELSE 0 END) AS s90_100 FROM scores WHERE course_id ? AND term ?;Android端拿到StatsResult后用一个简单的LinearLayout把几行指标展示出来即可不必上图表库。图表虽好看但引入第三方库同时引入版本兼容问题先把功能做完整视觉优化放后面。如果确实想要柱状图效果放在进阶章再说。5. 踩坑清单五个让新手熬夜的典型问题这个项目我在不同阶段带人做过好几轮下面这些都是见过不止一次的真实翻车现场。每条都按现象、原因、解决的顺序写希望你能跳过这些坑。5.1 真机连不上服务器模拟器却一切正常某次联调A同学用模拟器跑程序一切正常换成真机后所有接口都报超时他以为是自己手机有问题。原因几乎可以肯定是URL的问题模拟器里10.0.2.2指向宿主机真机上10.0.2.2这个地址不存在必须换成电脑在局域网中的实际IP。解决办法把BASE_URL改成电脑的局域网IP比如http://192.168.1.8:8080/同时确保手机和电脑连同一个Wi-Fi。开发阶段我一般把BASE_URL写在BuildConfig里配合buildConfigField按构建类型区分模拟器用10.0.2.2真机用局域网IP免得每次手动改。5.2 下拉刷新后列表数据翻倍下拉刷新恢复正常了但成绩列表越来越长退出界面再进来才恢复。原因很简单刷新回调里用了addAll而不是整体替换旧数据没有被清掉。// 错误的写法旧数据残留每次刷新都翻倍 adapter.addAll(newData) // 正确的写法先清空再添加 adapter.setData(newData) // RecyclerView.Adapter 里对应的方法 fun setData(newList: ListScoreItem) { scores.clear() scores.addAll(newList) notifyDataSetChanged() }这个坑的本质是“更新”和“追加”两个概念没有分清。下拉刷新的语义是“拿最新数据替换旧数据”加载更多的语义才是“把下一页数据接在后面”。把语义想清楚代码就不容易写错。5.3 接入Room后改数据库字段直接崩溃本地缓存第一版只存了几条成绩后来想加一个“备注”字段改了Entity和SQL语句重新安装App后打开就崩溃报错信息指向Room的IllegalStateException。原因Room在启动时会校验数据库版本和Entity定义是否一致不一致会直接抛异常。解决方式有两种一是开发阶段开启fallbackToDestructiveMigration数据库结构变化时直接清空重建简单粗暴二是写正式Migration保留旧数据适合已经对外发布的场景。自己开发时用第一种Database(entities [ScoreCache::class], version 2) abstract class AppDatabase : RoomDatabase() { companion object { fun getInstance(context: Context): AppDatabase Room.databaseBuilder(context, AppDatabase::class.java, grades.db) // 开发期省心选择版本升级直接重建 .fallbackToDestructiveMigration() .build() } }5.4 中文用户名传上去变成乱码登录时输入中文用户名后端日志里看到一堆问号。原因HTTP请求头里的Content-Type没有指定charsetutf-8Retrofit默认按ISO-8859-1编码。解决方式是在GsonConverterFactory之外加一个自定义编码转换器或者在服务端设置统一字符编码。我这边最常用的办法是在后端入口统一处理编码问题。如果后端改用Spring Boot它默认就是UTF-8基本遇不到这个问题如果是裸Servlet需要手动设置。5.5 新手把登录接口写在了Activity里有个同学把Retrofit调用直接写在Activity的onClick里回调里又要更新UI代码越写越乱转屏后状态全丢。正确做法是把所有网络请求放到ViewModel里Activity只做观察。这不是风格偏好而是Android系统机制决定的Activity在旋转时会销毁重建ViewModel能存活网络请求是异步的等它返回时Activity可能已经没了ViewModel可以安全地把状态交给新的Activity。6. 进阶离线缓存、CSV导出与权限细化基础功能全部跑通后如果还想继续打磨这章给你三个投入产出比最高的进阶方向。第一个是离线缓存。用Room把最近一次的成绩列表缓存到本地启动时先展示缓存数据再从网络拉新数据覆盖。代码模式很固定Repository层先查本地数据库有数据就直接返回同时发起网络请求拿到结果后先写库再更新UI。用户在地铁、电梯这种信号差的地方打开App至少有上次的数据可以看体验会比白屏好很多。第二个是导出CSV报送Office。期末成绩最终要提交给教务处通常格式是Excel或CSV。在Android端生成CSV文件然后用系统分享面板发送比让老师对着手机抄一遍靠谱得多。CSV的格式极简单字段用逗号分隔、换行区分记录注意用FileProvider生成URI不然高版本Android会因文件Uri暴露报错。做成按钮放在统计页顶端老师点一下就能发给电脑这属于用了就回不去的功能。第三个是权限细化。当前的权限模型比较粗老师可以录所有成绩学生只能查自己的。更接近真实业务的做法是任课老师只能录入和修改自己教的课程辅导员可以看他带的班级管理员才能动用户和课程数据。实现方式并不复杂后端在收到录入请求时检查courseId对应的teacher_id是否等于当前登录用户ID即可。多一行查询但这套系统的可解释性就完全不一样。我自己当年交课程设计时第一版没有做分页成绩列表一次加载全部几百条数据还能跑但切页面时会有肉眼可见的卡顿。后来加了最简单的分页参数page和pageSize配合RecyclerView的滚动到底自动加载流畅度立刻不一样。一个系统从“能跑”到“好用”往往就差这种细节。希望这篇笔记能帮你少走几个弯各个环节一次跑通。本文还有配套的精品资源点击获取