我最初接到这个项目的时候,其实有点不以为然。“基于Android的学生综合测评成绩信息管理系统”,听起来不就是学生表、课程表、成绩表三个表的增删改查?可真动手之后才发现,这个标题里的灵魂词根本不是“管理系统”,而是“综合测评”。普通成绩管理解决的是“考试分数是多少”,综合测评要解决的是“这个学生这一个学期到底该怎么评价”,后者牵扯到权重算法、多维度计分、规则可配置,工作量完全不一样。这篇博文把这个项目的需求边界、技术选型、数据模型、测评引擎和踩坑过程完整拆开讲,适合正在准备课程设计、毕业设计,或者想用Android做一个能写进简历的完整作品的读者。
这个项目最终的形态是一个纯本地的Android应用,不依赖服务器,数据存在SQLite/Room里。管理员可以维护学生和课程信息,教师录成绩、配置测评维度,学生端查自己的综合测评得分和各项明细。整条链路从登录、录入、加权计算,到报表导出全部在手机上跑通。做它的过程中,我对“什么功能值得做、什么功能没必要做”的理解比写十个Demo都深,这篇文章就把这套思路和落地过程一次性讲透。
1. 先分清需求边界:“成绩查询”和“综合测评”是两码事
1.1 为什么多数初稿方案跑偏
我见过不少同学拿到这个题目,第一版数据库就设计成学生表、课程表、成绩表,然后就准备做一个“按学生查成绩”的界面。这种做法不是错,而是只完成了题目的前三成。
综合测评的含义比课程成绩宽得多。在高校和职业院校里,一个学生一学期的综合表现通常包含四个可以量化的方向:
- 学业成绩:各门课程成绩或学分绩点,这是最硬的指标。
- 德育操行:日常出勤、宿舍卫生、违纪记录、参与集体活动等,在不少体系里叫“品德分”或“操行分”。
- 体育健康:体能测试、体育课成绩、课外锻炼情况。
- 创新实践:竞赛获奖、参与科研项目、社会实践、志愿服务等加分项。
综合测评分数就是这四个维度按照一定权重汇总后的结果。比如典型比例是学业占60%、德育占20%、体育占10%、创新实践占10%。如果只做成绩查询,评测流程、权重系数、加分事件这些核心内容都不会出现,题目等于白做了一半。
所以需求分析阶段最重要的一件事,是先去搞清楚“综合测评办法”到底怎么评。不同学校细则不一样,有的把德育和体育合在一起,有的把“创新创业”拆成“科技竞赛”和“社会实践”两个独立维度。我的建议是先找一份真实的测评细则,哪怕只是网上公开的文档,从中抽取指标,再倒推成数据模型,这样设计出来的系统才是“对症”的。
1.2 我这版项目的角色与功能收敛
为了让项目在课程设计或答辩周期里做得完,我把用户角色收敛成三类:
| 角色 | 能做什么 | 不能做什么 |
|---|---|---|
| 管理员 | 维护学生信息、配置测评维度权重、管理账号 | 不直接录入课程成绩 |
| 教师 | 录入所授课程成绩、维护加减分事件、发起综合测评 | 不能修改其他教师的成绩 |
| 学生 | 查看自己的综合测评结果与明细 | 没有写权限 |
功能上,我砍掉了论坛、消息通知、排课选课这类外围模块,把力量集中在一条主链路上:
- 管理员登录后创建班级和学生。
- 教师登录后录入课程成绩、登记德育或创新事件的加减分。
- 管理人员配置本学期各维度权重。
- 教师点击“生成综合测评”,系统按规则自动计算每个学生的总分和等级。
- 学生端查看自己的总分、各维度得分、等级以及加减分明细,教师端可导出全班的CSV测评报表。
这条链路跑通之后,项目的地基就算打稳了。后续想加曲线图、排名、通知,都是锦上添花的事。
1.3 砍掉的东西和砍掉的理由
我一开始也想做“按学期切换”“班级排名线形图”“测评结果短信通知”这些功能。后来一算工作量,发现自己掉进了功能陷阱。这类项目的核心价值是:数据录得进去、指标配得起、成绩算得准、结果看得到。把这条闭环打磨到极致,比七八个半成品页面有用得多。
比如排名曲线图,真实使用场景里老师更关心的是“谁这学期掉队了”,而不是看一条花哨的折线。又比如消息通知,本地数据库存的通知,跟系统公告没有任何区别,反而要在表结构里多加一张表。所以我的原则是:凡是评测闭环之外的模块,一律放到扩展阶段,不在第一版做。
这个取舍在答辩时也很重要。演示的时候评委大概率会问“为什么没有XX功能”,如果你能回答“当前版本先把测评数据链路做扎实了,XX功能我设计了扩展方案,接在哪一层都能做”,这时候一份扩展路线图比多写两个没用的界面更有说服力。
2. 技术栈取舍:Kotlin + Room,不折腾就是最大的靠谱
2.1 原生与跨平台的取舍
这个题目带“Android”字样,本身就在暗示原生方向。跨平台方案如Flutter、React Native虽然也能做Android端,但课程设计和毕业设计场景里有几个现实问题:一是评委通常希望看到Android工程的目录结构、Build配置、原生组件用法,跨平台框架会让评审重点漂移;二是本地SQLite类操作在跨平台框架下反而要填更多胶水代码;三是出问题时查资料的成本比原生高不少。
所以我的选型很朴素:Kotlin + Android原生控件 + Room,不用热门但非必需的依赖。Kotlin相比Java在协程、数据类、空安全上省事很多,代码量肉眼可见地少一截。对于本地数据处理为主的系统,Java最常见的空指针和回调嵌套问题,在Kotlin这里基本不会碰到。
2.2 Room数据库比原生SQLite实用多少
在Android里直接操作SQLite,要写SQLiteOpenHelper、ContentValues、游标遍历,稍微复杂一点的表关联查询,代码能绕成毛线团。Room作为官方ORM封装层,核心优势有三点:
- 编译期校验SQL,表名写错、字段名写错,直接编译报错,而不是运行时崩。
- 可以用data class定义实体类,字段映射完全自动化。
- 天然支持协程和Flow,数据库操作用suspend函数包起来就不会卡UI线程。
下面这个例子能看出差别。用原始SQLite存一个学生,要写onCreate建表、insert方法、ContentValues转换;Room里就是实体加DAO接口:
@Entity(tableName = "student") data class Student( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "student_no") val studentNo: String, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "class_name") val className: String, @ColumnInfo(name = "gender") val gender: String ) @Dao interface StudentDao { @Insert suspend fun insert(student: Student): Long @Query("SELECT * FROM student WHERE class_name = :className ORDER BY student_no") fun getStudentsByClass(className: String): Flow<List<Student>> }配合Repository层,业务代码里根本看不到SQL字符串,可读性和可维护性都提升一个档次。对学生类项目来说,代码清爽本身也是加分项。
2.3 测评结果可视化:用Canvas自绘轻量控件
测评结果如果只是一串数字,演示效果会差很多。但为了一个环形分数图就去引入重量级图表库,我又觉得不划算。我最后用自定义View画了一个“环形进度图”,核心逻辑很简单:
- 先画一个灰色底环,代表满分环。
- 再按总分比例画一个彩色圆弧,从12点钟方向顺时针扫过。
- 中间用大号字显示总分数,下方留一行小字显示等级。
绘制的核心方法加起来不到60行。关键代码是对画笔、扫描角度和文本居中的处理:
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val strokeWidth = 28f val radius = (width.coerceAtMost(height) - strokeWidth * 2) / 2f val centerX = width / 2f val centerY = height / 2f val rect = RectF( centerX - radius, centerY - radius, centerX + radius, centerY + radius ) // 背景环 paint.color = Color.LTGRAY paint.style = Paint.Style.STROKE paint.strokeWidth = strokeWidth canvas.drawArc(rect, 0f, 360f, false, paint) // 进度环 paint.color = color val sweep = (progress * 3.6f).toFloat() // 百分制映射到角度 canvas.drawArc(rect, -90f, sweep, false, paint) // 中央文字 paint.style = Paint.Style.FILL paint.textSize = 48f paint.textAlign = Paint.Align.CENTER val baseY = centerY + paint.textSize / 3 canvas.drawText(String.format("%.1f", score), centerX, baseY, paint) }这种自绘控件的另一个好处是,答辩时你说“用Canvas实现了一个自定义评分环”,比说“我调用了第三方图表库”更能体现基本功。雷达图我后来也实现了,方法类似,只是多画了几组多边形,数据量不大的场景完全够用。
2.4 MVVM组件分工
项目架构我用的是Android官方推荐的MVVM:Activity负责承载Fragment,Fragment只做界面渲染和用户事件转发,ViewModel持有界面状态,Repository统一访问Room数据源。
三层之间的数据流是这样走的:
- UI层接收用户点击,调用ViewModel中的方法。
- ViewModel启动协程,通过Repository读写数据库。
- 数据更新后,ViewModel暴露StateFlow,Fragment用collectLatest订阅并刷新界面。
我自己经历过写MVP的痛苦,接口回调满天飞,一个页面十几个View接口方法。MVVM最舒服的地方是ViewModel不持有Context,页面旋转时数据不会丢,测试数据层逻辑也不需要启动模拟器。对于综合测评这种涉及多表联查、计算量不算小的项目,MVVM拆分逻辑特别合适。
3. 测评引擎:一套可配置的加权计算模型
3.1 综合测评得分构成
测评引擎是整个系统的核心,我把它设计成独立模块,不掺入业务界面逻辑。核心公式一句话就能说清:
综合测评总分 = 学业表现分 × 学业权重 + 德育操行分 × 德育权重 + 体育健康分 × 体育权重 + 创新实践分 × 创新权重
默认权重配置是学业0.6、德育0.2、体育0.1、创新实践0.1,总权重和为1.0。这组权重不能写死在代码里,要存数据库并允许管理员调整。为什么?因为不同学院甚至不同年级的测评办法都可能不同,有的专业可能把学业权重调到70%,有的把创新实践提到15%。权重写死,系统就失去了“可配置”这个关键价值。
为了防止配置出错,保存权重前必须做两个校验:
- 四维权重之和必须等于1.0,考虑到浮点误差,我用整数百分比存储,四者之和必须等于100。
- 每个权重必须在0到100之间,不允许出现负数或超过100的值。
实际开发中还要考虑一种边界:如果某个维度的成绩数据为空怎么办?比如学生没有参加体测,体育健康分就无从谈起。我的策略是:对应维度按0分处理,同时标记“该维度无有效数据”,并在测评明细里提示,避免出现“明明是0分却看不出是缺数据还是真考了0分”的尴尬。
3.2 四个维度的子项计分规则
学业表现分不是一个简单的算术平均,而是学分加权平均。原因很好理解:3学分的专业课和1学分的选修课,对学业水平的代表性不一样。计算公式是:
学业表现分 = Σ(课程成绩 × 课程学分) / Σ(课程学分)
这个逻辑对应数据库里的grade表,查询某学生某学期的全部成绩记录后,按课程学分加权计算即可。
德育操行分我用“基础分 + 事件加减分”的模型。每名学生每个学期有一个默认基础分80分,之后所有德育事件加减分都叠加在这个基础上。例如参加班级集体活动记+2分,宿舍卫生检查不合格记-3分,迟到一次记-1分。这样数据模型很干净,每一条加减分记录都是独立的一行,最终汇总时就查这张事件表求和。
体育健康分相对简单,指向体测综合分或体育课成绩,输入时按百分制分数存,不做复杂折算。创新实践分采用“按事件累计加分,单学期设置上限”的方式,避免个别学生通过堆叠低含金量活动刷分。我在项目里把上限设成了20分,这个值同样做成可配置的。
这里有一个关键设计:加减分事件必须有“类型”字段,比如“德育加分”“德育扣分”“创新加分”,并且要存事件发生日期和简要说明。这样测评明细表才能展示给管理员看,比如学生看到自己德育分为什么变成82分,能立刻看到“×月×日参与志愿活动+2分”这样的说明,否则系统算完分却讲不清依据,成了黑盒。
3.3 核心计算代码与防呆校验
测评计算的核心我封装成了一个AssessmentCalculator对象。输入是学生ID和学期,输出是一个结果数据类,包含总分、各维度分、等级和告警信息。核心计算逻辑如下:
data class AssessmentResult( val studentId: Long, val studentName: String, val totalScore: Double, val academicScore: Double, val moralScore: Double, val sportScore: Double, val innovationScore: Double, val level: String, val warnings: List<String> = emptyList() ) class AssessmentCalculator( private val studentDao: StudentDao, private val gradeDao: GradeDao, private val eventDao: BehaviorEventDao, private val metricDao: AssessmentMetricDao ) { suspend fun calculate(studentId: Long, semester: String): AssessmentResult { val metrics = metricDao.getEnabledMetrics() val weightMap = metrics.associate { it.code to it.weightPercent / 100.0 } require(weightMap.values.sum() == 1.0) { "测评权重之和必须为100%!" } val academic = calcAcademic(studentId, semester) val moral = calcMoral(studentId, semester) val sport = calcSport(studentId, semester) val innovation = calcInnovation(studentId, semester) val total = academic * weightMap["academic"]!! + moral * weightMap["moral"]!! + sport * weightMap["sport"]!! + innovation * weightMap["innovation"]!! val normalizedTotal = roundToTwo(total) return AssessmentResult( studentId = studentId, studentName = studentDao.getById(studentId).name, totalScore = normalizedTotal, academicScore = academic, moralScore = moral, sportScore = sport, innovationScore = innovation, level = mapLevel(normalizedTotal) ) } private fun calcAcademic(studentId: Long, semester: String): Double { val records = gradeDao.getByStudentAndSemester(studentId, semester) if (records.isEmpty()) return 0.0 val totalCredit = records.sumOf { it.credit } return roundToTwo(records.sumOf { it.score * it.credit } / totalCredit) } } fun mapLevel(score: Double): String = when { score >= 90 -> "优秀" score >= 80 -> "良好" score >= 70 -> "中等" score >= 60 -> "合格" else -> "待提升" }这段代码里每个子项方法都先查数据再判断空值。重点提醒一下:学业分算出0分和“没有成绩记录”不能混为一谈,前者可能是真考了0分,后者是数据没录完。所以我保留了warnings列表,数据缺失时列入告警信息,测评结果里单独展示,而不是静默吞掉。
计算结束之后还有一个复核动作:总分不能超过100。理论上权重和是1,各子项满分100,总分一定在0到100之间,但浮点累加可能算出100.0000001这种值。我在submit前统一用roundToTwo方法保留两位小数,确保写入数据库的都是干净数据。
4. 数据模型设计:权重配置与测评结果快照分开存
4.1 核心表结构说明
数据库设计我优化过两版。第一版把所有维度都设计成独立表,一张“德育表”一张“体育表”,结果发现查询汇总时SQL写得极其痛苦。第二版重构为“事件驱动+快照存储”模式,结构反而简单,扩展还容易。
核心表如下:
| 表名 | 主要字段 | 作用 |
|---|---|---|
| user | id, username, password, role | 登录账号,区分管理员、教师、学生 |
| student | id, student_no, name, class_name, gender, user_id | 学生档案,关联登录账号 |
| course | id, name, credit, teacher_id | 课程与学分,eacher_id关联教师 |
| grade | id, student_id, course_id, score, semester | 课程成绩,唯一索引(student_id, course_id, semester) |
| ass_metric | id, code, name, weight_percent, enabled | 测评维度配置,如academic/moral/sport/innovation |
| behavior_event | id, student_id, event_type, points, reason, event_time | 加减分事件,德育、创新实践都复用它 |
| assess_record | id, student_id, semester, total_score, detail_json, created_time | 测评结果快照 |
一个容易被忽略的细节是semester字段。测评都是按学期进行的,所有成绩表、事件表、结果表都要带semester,否则跨学期数据会互相污染。我在界面里做了学期下拉框,默认取“当前学期”,支持切换历史学期查看旧成绩。
4.2 为什么测评结果必须存快照
这是我吃了亏之后才想明白的设计。最开始我打算测评结果不落库,只在查询时动态计算,因为这样代码最省事。但很快就发现一个致命问题:权重值是会被管理员调整的。
设想一下,学期初按“学业60%、德育20%、体育10%、创新10%”发布了一次测评结果。学期末管理员把学业权重改成了70%,如果系统还是实时计算,那学生们看到的历史测评成绩会一夜之间全部变化。这显然不合理,已经公示过的测评结果必须保持原样。
解决方案就是在生成测评结果那一刻,把总分、各维度分、当时的权重配置、主要加分事件摘要全部打包成一条快照记录。detail_json字段我用JSON存了一份完整快照,里面同时包含权重和子项分,这样就算之后权重改得面目全非,历史结果依然可追溯。JSON解析我用的是Gson,内置在Android环境里,不需要额外依赖。
4.3 录入阶段的约束条件
数据校验不能全指望业务代码,数据库约束是最后一道防线,也是最能体现工程素养的地方。我建表时加了这些约束:
- grade表加UNIQUE索引(student_id, course_id, semester),保证同一学期同一学生同一课程不会出现两条成绩。
- score字段用CHECK约束限定在0到100之间,SQLite的CHECK约束对非法值会直接报错,比应用层校验更可靠。
- behavior_event表的points字段规定为整数,加减分都是整数,避免1.5分这种难统计的零碎。
- assess_record表加唯一索引(student_id, semester),同一个人同一个学期只允许存在一条测评结果,若要重新测评,必须覆盖更新而不是追加。
最后一个约束在代码上要做成“先查快照,存在则更新,不存在则插入”,这样重复点击“生成测评”按钮不会产生重复数据。我在演示时就遇到过这种操作,一旦产生重复记录,列表里同一个学生出现两行,看起来非常不专业,这个细节值得提前防住。
5. 核心功能落地:登录、录入、计算、导出一条链
5.1 登录与角色路由
登录模块不用做得太重,本地账号密码校验即可。首次启动时自动创建管理员账号admin/admin123,随后管理员在“用户管理”里给教师和学生创建账号。
登录成功后的路由逻辑很简单:根据用户角色决定显示哪几个底部导航项。
- 管理员:学生管理、测评配置、结果总览。
- 教师:成绩录入、事件登记、发起测评。
- 学生:我的测评、历史记录。
路由代码在MainActivity里通过ViewModel拿到当前登录用户的角色,再动态配置BottomNavigationView的菜单。这个方案改动小,不用做Splash页和复杂的权限框架,却能把三种角色的界面清楚切开。
5.2 成绩录入与校验界面
成绩录入界面有前后两个视角。教师视角按照“选择课程→课程下学生列表→逐个输入分数”的方式录入,页面用RecyclerView展示学生,每行一个EditText,保存时统一提交。学生视角则是只读的,列表展示所有已录成绩。
保存前要做三层校验:分数必须在0到100之间;必修课和选修课都要有对应学分;同一学生同一课程单学期只能录入一次。防重复逻辑在DAO层用INSERT ON CONFLICT REPLACE处理,但这种方式会覆盖旧成绩,所以我在录入页面加了提示“保存后将覆盖该课程同学生成绩”,避免教师误操作还不自知。
录入体验上有个小技巧:EditText用inputType="numberDecimal",弹出数字键盘;光标跳转逻辑用imeOptions="actionNext"配合OnEditorActionListener,录完一格自动跳下一格,大量录成绩时效率很高。
5.3 综合测评计算的任务串联
“开始测评”按钮是教师端最核心的入口。点击后,界面跳转到一个带进度条的页面,后台通过协程逐个学生计算。计算逻辑我上文已经给出,这里重点说任务串联。
整套流程封装在Repository层的一个方法里,用withContext切到Dispatchers.IO,内部流程是:
- 从student表查出所有学生。
- 从ass_metric表读出当前启用的权重配置。
- 遍历学生列表,对每个学生调用AssessmentCalculator计算。
- 把结果写入assess_record表。
- 每完成一个学生,用MutableStateFlow更新进度值。
界面层订阅进度值,用ProgressBar展示“已完成25%”这样的进度。协程的要点是计算期间不能让界面卡死,所以所有Room调用都不能放在主线程。Room的 suspend DAO默认支持主线程安全,但这里为了让进度实时刷新,进度值更新还是在主线程上用StateFlow通知。
计算完成后,结果页直接展示本班所有人的总分和等级,点击某一行进入明细。明细页从快照的detail_json字段还原出四维分数,并展示每个维度的详细事件列表,学生看到的是完整可解释的成绩构成。
5.4 测评报表展示与CSV导出
导出功能我用CSV而不是Excel。原因很简单:Android原生不支持Excel读写,引入POI这种库体积大、兼容性差,而CSV是纯文本格式,WPS、Excel都能直接打开,普通教师完全够用。
导出流程是:
- 在测评结果页点“导出CSV”。
- 收集assess_record表中本学期的所有结果。
- 生成CSV字符串,首行是表头,之后每行一名学生。
- 把字符串写入应用缓存目录下的report.csv文件。
- 用FileProvider获取Uri,配合ACTION_SEND调起系统分享面板,学生可以发给自己,教师可以发到群里。
核心代码大概长这样:
fun generateCsv(results: List<AssessmentResult>): String { val sb = StringBuilder() sb.appendLine("学号,姓名,班级,学业成绩,德育成绩,体育成绩,创新成绩,总分,等级") results.forEach { r -> sb.appendLine( "${r.studentNo},${r.studentName},${r.className}," + "${r.academicScore},${r.moralScore},${r.sportScore}," + "${r.innovationScore},${r.totalScore},${r.level}" ) } return sb.toString() }这里要注意中文逗号和换行问题:CSV里如果字段中包含逗号,需要用双引号包裹。好在我的成绩都是数字,学生名字里出现逗号的概率极低,所以最终版本没有做转义。但如果数据源是从外部导入的,建议还是把字段清洗函数补上,做到“CSV永远能正确打开”。
6. 实测中踩过的坑:从小数精度到列表刷新
6.1 加权汇总的浮点误差问题
第一次跑通全流程后,我发现测评结果偶尔会出现89.9999999或者85.0000001这种数字。一开始以为是除法问题,排查后确认是Float精度导致。比如0.1和0.2在二进制里都是无限循环小数,计算机相加得到的0.30000000000000004就是经典案例。
我在系统里有两个地方要处理这个问题。一个是所有权重和子项分数统一用Double计算,计算完用String.format("%.2f")格式化显示,存数据库时保留两位小数。另一个是总分对比等级阈值时要格外小心,比如真实总分99.9999999,如果直接判断>=90会认为优秀没问题,但万一算出89.9999999,明明四舍五入是90分,却会被判进“良好”档。
解决办法是等级判断前先round到两位小数,再做阈值比较,同时用自定义的roundToTwo方法而不是依赖Float.toString:
fun roundToTwo(value: Double): Double = BigDecimal(value).setScale(2, RoundingMode.HALF_UP).toDouble()这个问题在演示时特别容易被评委抓到。如果总分那一栏显示一串无穷小数,被问到“这个系统算得准不准”时场面会非常尴尬。
6.2 RecyclerView刷新闪屏与DiffUtil方案
测评计算完成之后,结果列表需要更新几十条数据。我图省事直接调用notifyDataSetChanged(),结果列表白屏了一瞬还丢失了滚动位置。在性能一般的低端机上,甚至能看到明显的闪烁。
换成ListAdapter方案后,数据更新只刷差异项,体验好很多。做法是把RecyclerView.Adapter换成ListAdapter,重写areItemsTheSame和areContentsTheSame这两个方法。关键在于业务主键:我以studentId + semester作为item key,同一学生在同一学期的测评结果视为同一个item,总分变化时才判定内容变化。
class AssessmentListAdapter : ListAdapter<AssessmentRecord, AssessmentListAdapter.VH>(DIFF_CALLBACK) { companion object { val DIFF_CALLBACK = object : DiffUtil.ItemCallback<AssessmentRecord>() { override fun areItemsTheSame(oldItem: AssessmentRecord, newItem: AssessmentRecord) = oldItem.studentId == newItem.studentId && oldItem.semester == newItem.semester override fun areContentsTheSame(oldItem: AssessmentRecord, newItem: AssessmentRecord) = oldItem.totalScore == newItem.totalScore } } }这个改动很值得,特别是在录完成绩、重新测评、列表大范围更新的时候,没有白屏,也不会把用户拖回列表顶部。做Android列表展示,DiffUtil属于“一旦用过就回不去”的方案。
6.3 本地数据的备份与导出
纯本地应用最大的痛点是数据库文件藏在应用私有目录里,外部看不到。平时自测没问题,但一到交付演示,如果想换个模拟器展示数据,所有录入的数据就都没了。我为此在设置页加了一个“备份数据库”入口,把db文件复制到Download目录,实现方式是用FileInputStream打开私有目录里的数据库文件,再写入公共存储。
fun backupDatabase(context: Context) { val dbFile = context.getDatabasePath("assessment.db") val destDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val destFile = File(destDir, "assessment_backup_${System.currentTimeMillis()}.db") dbFile.copyTo(destFile, overwrite = true) Toast.makeText(context, "备份完成: ${destFile.absolutePath}", Toast.LENGTH_LONG).show() }加上这个功能,演示前备份、演示后恢复,整个流程顺很多。而且答辩时可以说“系统具备本地数据备份能力,防止误卸载导致数据丢失”,这也算一个非功能需求落地的案例。恢复功能我做了对称的,用一个文件选择器读取备份文件,复制回私有目录并重启应用即可。
另一个容易踩的坑是Android 10及以上的分区存储限制。直接写Download目录在高版本系统上不能想当然,我最终用的是MediaStore API或者FileProvider指向外部存储目录,这些处理在真机和模拟器上行为一致。做本地数据功能时,权限和存储路径一定要在开发前期就确认清楚,等联调阶段再改会非常痛苦。
7. 后面还能怎么扩展:从单机Demo做成完整作品
7.1 从本地到网络的演进
如果想把项目做成完整的前后端作品,扩展方向就是把本地数据搬到服务端。架构上的建议是保留现有Repository层,新增一个RemoteDataSource,在Repository内部做数据源切换。Room的DAO和远程API返回的数据结构保持一致,远程用REST接口,返回JSON,Android端用Retrofit解析。
登录逻辑也要改。本地登录改成后端签发Token,每次请求带着Token走认证。学生、教师、管理员的角色权限在服务端统一检查,而不是只靠客户端隐藏菜单。
需要提醒的是,一旦上云,数据一致性和同步冲突就成了新问题。比如教师离线录了成绩,回到网络环境后要怎么与服务器合并?这种场景建议直接用服务端为唯一数据源,离线录入不做离线缓存,或者只允许在线操作。同步逻辑做得太过复杂,会直接把项目拖垮。
7.2 建议的扩展优先级
如果时间有限,我按实用价值和投入产出比排一个扩展顺序:
- 学期学年管理。数据表都带semester字段,但目前是手输字符串,容易输错。做一个学期配置表,新增学期时自动复制上学期的课程和学生,非常省事。
- 测评结果统计。教师端加一个简单的柱状图或者饼图,展示总分分布,一眼看清优秀率、及格率。
- CSV批量导入学生。管理员创建一个班级时,不用逐个手工录学生,提供一个CSV模板批量导入,实用性大增。
- 个人中心。学生端加一个个人资料页,改密码,查看登录日志。
- 云端同步。如前所述,工程量大,建议放在最后。
7.3 做这类项目的一点个人建议
做完这个项目,我最大的感受是:一个中等体量的管理系统,难点不在某个技巧,而在整条数据链路的闭合。从录成绩到算分数,从算分数到展示明细,从展示明细到导出报表,任意一环断掉都会让项目显得“半成品”。
答辩过程中最容易打动评委的点有三个。一是权重可配置,说明你理解了测评规则会变化、系统需要适应规则而非写死规则。二是快照隔离,说明你考虑到了历史数据不可被后续配置变更污染,这个点很多学生根本想不起来。三是导出数据,说明你不只做展示,还考虑了真实场景里的归档和分发。这三个设计点都在这篇文章里提到了,如果你做同类项目,把它们落地就足够了。
我自己在演示中还遇到过一次“意外加分”:评委临时提出把创新实践维度权重调到20%,其他维度相应减少。因为我做了配置页面,当场改了之后重新生成本学期所有测评,一分钟内数据刷了出来,等级分布随之变化。这种临场应变能力比任何PPT都更有说服力。最后再分享一个小经验:任何配置变动之后,生成测评前都要二次确认弹窗提醒,内容写清楚“本次操作将基于当前权重重新计算本学期所有学生测评结果”,这行字能帮你挡住不少误操作。