
简介ScuTimetable是一款专为四川大学学生打造的安卓课程表应用通过模拟登录官方教务系统安全获取个人课表并自动同步课程信息省去手动更新的麻烦同时以清晰直观的界面帮助用户快速查看和筛选课程。资源包整体打包了完整Android工程、附赠的学习辅助材料及详细说明文档共423个文件压缩包约4.75MB文件类型涵盖java源码、xml界面布局、json/txt配置、gradle构建脚本、c/c原生编译配置以及png/webp图片资源能较完整地反映应用开发全貌。目前已有31人学习浏览适合川大学生使用也适合Android学习者研究模拟登录、数据解析和课表展示等项目实践。通过该资源可快速搭建并运行应用掌握从教务系统数据抓取到界面呈现的完整链路同时借助说明文档和目录结构理解工程组织方式为二次开发提供基础。附带的使用指南和常见问题说明更可帮助用户快速上手。1. 川大课程表 ScuTimetable把「模拟登录、课表解析、安卓展示」串成一条完整链路查课表的痛点从来不是课表数据本身而是每次都要打开教务系统、输一遍账号密码再在手机上放大缩小一个 PC 端网页。ScuTimetable 这个项目标题把解法拆得很清楚在安卓端模拟登录川大官方教务系统只读地拿到个人课程表数据解析成结构化课表后展示出来。它适合两类人——川大学生想要一个能自动同步的课表工具安卓开发者想找一个「登录态保持 HTML 解析 本地缓存」的完整样例。这个项目的工程难点不在界面画得多好看而在模拟登录的稳定性和课表解析的正确率这两块恰恰是最容易翻车的地方。2. 模拟登录教务系统先把认证链路和 Cookie 机制搞清楚2.1 为什么第一步是抓包教务系统的登录不是一个 POST 就完事很多第一次做这类项目的人以为模拟登录就是对着登录接口发一个账号密码 POST结果第一步就翻车。高校教务系统川大这类统一身份认证体系也走同一套模式的登录流程通常是先访问登录页页面里埋着几个隐藏字段常见的有 lt、execution、_eventId提交表单时要把这些字段连同用户名密码一起 POST 给认证中心认证通过后返回一个 ticket浏览器再拿 ticket 跳回教务系统教务系统再种下一个新的会话 Cookie。中间任何一步字段名对不上登录就静默失败而且失败原因经常不是「密码错」而是「参数不齐」。所以我做这类对接的习惯是别急着写安卓代码先拿 Postman 把登录流程手动走一遍。你可以把登录页完整打开看表单里到底有哪些 input再模拟一遍提交。这一步在调试接口时特别有用——把请求头和表单字段原样搬出来就知道哪些参数是每次登录都会变的哪些是写死的。会变的一般是 token 一类每次打开登录页都会重新生成代码里要先 GET 一次登录页拿到它再拼进 POST。这一步做完后面写 OkHttp 就是顺水推舟。注意一点Postman 里登录成功只能证明接口链路通Cookie 的存储和携带规则还得回到代码里验证因为不同客户端的 Cookie 策略有差异。2.2 用 OkHttp 实现登录与会话保持CookieJar 是整个登录的地基搞清楚链路之后代码就分三件事配置一个带 CookieJar 的 OkHttpClient先 GET 登录页提取动态字段再 POST 表单跟随重定向。下面这段 Kotlin 代码是登录主干// 1. 自定义 CookieJar把服务端种下的 Cookie 按域名存进内存 Map class InMemoryCookieJar : CookieJar { private val cookieStore HashMapString, ListCookie() override fun saveFromResponse(url: HttpUrl, cookies: ListCookie) { cookieStore[url.host] cookies } override fun loadForRequest(url: HttpUrl): ListCookie { return cookieStore[url.host] ?: emptyList() } } // 2. 构建复用同一个 CookieJar 的 OkHttpClient val client OkHttpClient.Builder() .cookieJar(InMemoryCookieJar()) .followRedirects(true) // 登录成功后会有 302 跳转必须跟随 .followSslRedirects(true) .build() // 3. 先 GET 登录页解析隐藏字段具体字段名以抓包结果为准 val loginPage client.newCall( Request.Builder().url(https://auth.example.edu.cn/login).build() ).execute() val html loginPage.body?.string() val lt Regex(name\lt\ value\([^\])\).find(html)?.groupValues?.get(1) val execution Regex(name\execution\ value\([^\])\).find(html)?.groupValues?.get(1) // 4. 构造表单并 POST val formBody FormBody.Builder() .add(username, studentId) .add(password, password) .add(lt, lt ?: ) .add(execution, execution ?: ) .add(_eventId, submit) .build() val loginResp client.newCall( Request.Builder().url(https://auth.example.edu.cn/login) .post(formBody) .build() ).execute()代码逻辑拆开讲第一步的 CookieJar 是整个模拟登录的地基所有请求产生的 Set-Cookie 都会按 host 存进去后续请求自动带上登录之后拉课表靠的就是这个会话第二步的 followRedirects 必须开着因为认证成功之后不是直接返回课表而是先 302 到教务系统域名教务系统下发新的会话 Cookie这一步如果关掉登录就断在跳转环节第三步用正则提取登录页里动态生成的隐藏字段这是很多系统安全校验的关键第四步把隐藏字段和用户名密码一起提交。参数说明里有几个容易踩的地方studentId 和 password 不要硬编码用 SharedPreferences 或加密存储保存execution 字段名不同学校差异很大解析不到就先回 2.1 重新抓包看表单结构。还有一个细节登录页和教务系统课表接口大概率不同域CookieJar 按 host 存反而能避免不同域名之间 Cookie 串号。把请求封装到 suspend 函数里用协程的 withContext(Dispatchers.IO) 跑网络避免在主线程做网络请求。2.3 验证码和登录失败的三种分支处理教务系统大概率有验证码这块我的血泪经验是不要迷信 OCR。常见做法是先把验证码图片下载到本地在 App 里弹一个输入框让用户手动填一次一天只需要输一次等登录态有效期过了再填一次。自己训练识别模型不是不行但训练集不够时识别率上不去登录一次失败三次体验很差。有的系统还有滑块验证那就只能在 WebView 里引导用户手动完成一次拿到授权后再把 Cookie 导出给 OkHttp 用。登录失败的分支要提前设计不能只弹一个「登录失败」。第一种是账号密码错误服务端会返回错误文案从响应体里解析出来给用户看第二种是验证码错误通常不会报密码错而是重新返回登录页需要重取验证码再试第三种是网络抖动超时OkHttp 默认不重试业务层自己包一层重试。建议给登录接口包一个密封类结果区分「成功」「密码错误」「验证码错误」「网络异常」四种状态UI 层才能给用户明确的下一步动作。登录态保持时长也值得记录——有的系统 session 只有 30 分钟有的能撑一天这个值决定了后面刷新策略的节奏。3. 课表数据解析从 HTML 表格到结构化课程对象3.1 先判断课表格式HTML 表格、XML 还是 JSON登录成功之后下一个问题是「课表数据长什么样」。不同版本教务系统差别很大老系统返回的是整张 HTML 表格新系统可能走独立 JSON 接口还有部分是 XML 结构。抓一次包就知道如果课表嵌在 HTML 的 table 标签里就走 HTML 解析如果有独立的课程表 API 返回 JSON那直接用 JSON 解析。这个判断别偷懒我见过有人在 HTML 页面里硬抠 JSON 串结果页面一改版整个解析全崩。如果走 HTML 表格这就是一个典型的文档结构化解析任务把「星期」当列「节次」当行表格里每一个单元格是一门课或一段空白。解析之前先看表格里有没有合并单元格——比如一门课占两行或者一门课同时跨周一和周二两个单元格。合并单元格用 rowspan 和 colspan 控制解析代码必须处理否则课程名会串到旁边的格子位置全错。另外注意页面里可能有多个 table比如顶部导航一个、页脚一个要按 id 或 class 精确定位课表所在的那张表。3.2 用 Jsoup 把表格解析成课程模型Jsoup 是安卓上解析 HTML 最顺手的库没有之一。解析课表的思路是定位表格、跳过表头、逐行逐列读单元格每个非空单元格解析成课程对象。核心代码如下// 课程模型一个对象正好对应课表单元格里的一门课 data class Course( val name: String, val teacher: String, val location: String, val dayOfWeek: Int, // 1周一 ... 7周日 val startPeriod: Int, // 开始节次 val endPeriod: Int, // 结束节次 val startWeek: Int, // 起始周 val endWeek: Int, // 结束周 val weekType: Int // 0每周 1单周 2双周 ) fun parseTimetable(html: String): ListCourse { val doc Jsoup.parse(html) // 教务系统课表通常是一个 id 明确的 table val table doc.selectFirst(table#timetable) ?: return emptyList() val courses mutableListOfCourse() table.select(tr).forEachIndexed { rowIndex, tr - // 第一行是表头星期一 ~ 星期日跳过 if (rowIndex 0) returnforEachIndexed tr.select(td).forEachIndexed { colIndex, td - val text td.text().trim() if (text.isEmpty()) returnforEachIndexed // 单元格里通常是 课程名\n教师\n地点\n周次说明 的多行文本 val lines text.split(\n).filter { it.isNotBlank() } if (lines.size 3) returnforEachIndexed val course Course( name lines[0], teacher lines[1], location lines[2], dayOfWeek colIndex 1, startPeriod rowIndex, // 行号要换算成节次见下方说明 endPeriod rowIndex, startWeek 1, endWeek 16, weekType 0 ) courses.add(course) } } return courses }逻辑说明Jsoup 的 select(tr) 拿到的行集合里第一行是「星期一」到「星期日」的表头必须跳过后续每一行对应一个节次区间每一列对应星期几。单元格内多行信息按换行拆分前三个字段分别映射为课程名、教师、上课地点。这里 startPeriod 直接用 rowIndex 是偷懒写法真实课表里行号跟节次通常差一个偏移比如第 2 行对应第 3-4 节一定要先解析出节次行的「第 X-Y 节」文本再建立行号到节次的映射。参数说明dayOfWeek 用 colIndex 1 是因为表格列索引从 0 开始rowspan 的存在会影响 td 的数量解析时处理合并单元格的方法是在遍历行时记录当前行被上方课程占用的列位跳过这些被合并占用的格子否则会出现课程错位甚至数组越界。Jsoup 的 text() 已经处理过 HTML 实体课程名里出现 、 之类的转义符时不需要手动再解一次。3.3 周次解析单双周和跨周课程是最容易漏的坑解析课表最隐蔽的坑是周次。教务系统里课程分两种每周都上的常规课和「1-16 周单周」「2-16 周双周」这样带条件的课。周次在 HTML 里经常是一段文本比如「1-16周(单)」或「第3-12周」解析时必须拆成 startWeek、endWeek、weekType。周次解析函数如下// raw 形如 1-16周 / 1-16周(单) / 第2-16周(双) fun parseWeeks(raw: String): TripleInt, Int, Int { val match Regex((\\d)\\s*-\\s*(\\d)).find(raw) ?: return Triple(1, 1, 0) val start match.groupValues[1].toInt() val end match.groupValues[2].toInt() val type when { raw.contains(单) - 1 raw.contains(双) - 2 else - 0 } return Triple(start, end, type) } // 判断某一门课在给定周次是否上课 fun isCourseInWeek(course: Course, currentWeek: Int): Boolean { if (currentWeek course.startWeek || currentWeek course.endWeek) return false return when (course.weekType) { 1 - currentWeek % 2 1 // 单周课 2 - currentWeek % 2 0 // 双周课 else - true // 每周都上 } }正则把「起始周-结束周」抠出来再判断单双周。这段逻辑看着简单真正要小心的是「单」「双」这两个字出现在课程名里比如课程名叫「单双周体育实践」如果对整合单元格文本做 contains 判断就会误判所以周次说明要单独从文本的最后一行或括号内提取不要对整段字符串盲查。展示层用 isCourseInWeek 过滤当前周该渲染哪些课尤其要记得跨周课程的起止周判断比如第 3 周开课、第 12 周结课不在这个区间内的周次一律不显示。3.4 解析结果的自检写一个对拍函数兜底每次教务系统改版解析代码首当其冲。我习惯在解析完课程列表后跑一个自检函数统计每个星期几有多少节课和网页端显示的「本周共 XX 节次」做对比对不上就说明解析有遗漏。这个自检在 DEBUG 构建下打印日志正式版不跑不影响性能。另一个自检是字段级检查课程名不能为空周次范围必须在 1-25 之间一个学期最多 25 周左右节次范围必须在 1-12 之间出现非法值直接标记该条记录方便排查是哪个单元格解析异常。4. 安卓端展示与数据刷新课表界面的工程化落地4.1 课表界面选型RecyclerView 网格还是自定义 View课表界面是每个做这个项目的人都会纠结的问题。RecyclerView 配 GridLayoutManager 是数据驱动思路每周 7 列每天按节次分行课程作为跨行 item 填充格子点击事件天然支持缺点是合并单元格要额外做 item 合并代码绕一点。自定义 View 的做法是在一个 View 里按坐标直接绘制课程方块和文字最接近真实课表视觉效果好但点击热区、滚动、无障碍都得自己写。两者的取舍如下对比项RecyclerView GridLayoutManager自定义 View 绘制数据驱动强Adapter 直接绑定课程列表弱每次 draw 都要重算坐标合并单元格需要自定义 SpanSizeLookup天然支持按矩形坐标绘制点击事件现成 item 点击易处理需自己算触摸点命中哪个课程滚动和复用RecyclerView 自带复用全部手写长列表容易卡顿开发周期短适合第一版长适合精调视觉我一般建议课表主体用 RecyclerView按「星期 × 节次」铺网格课程作为跨行 item。项目标题要的是一个「清晰直观的界面」GridLayoutManager 完全够用后续加课程详情弹窗、周次切换也更顺手。等做到第二版如果确实需要双指缩放、长按拖动这些重交互再迁到自定义 View。不要第一版就上自定义 View开发周期长还容易在低端机上卡顿。4.2 数据落地Room 缓存与先显示后更新课表数据不建议每次打开 App 都现拉。教务系统接口稳定性一般频繁请求对服务端也是压力。我用 Room 做本地缓存把解析好的 Course 列表存进 SQLiteApp 启动先读缓存立即展示再后台静默刷新。用户体验上「先看到旧数据再更新」比「转圈等网络」好得多。Room 用法分实体、DAO、数据库三块实体直接复用第 3 章的 Course 加注解Entity(tableName courses) data class Course( PrimaryKey(autoGenerate true) val id: Long 0, ColumnInfo(name name) val name: String, ColumnInfo(name teacher) val teacher: String, ColumnInfo(name location) val location: String, ColumnInfo(name dayOfWeek) val dayOfWeek: Int, ColumnInfo(name startPeriod) val startPeriod: Int, ColumnInfo(name endPeriod) val endPeriod: Int, ColumnInfo(name startWeek) val startWeek: Int, ColumnInfo(name endWeek) val endWeek: Int, ColumnInfo(name weekType) val weekType: Int ) Dao interface CourseDao { Query(SELECT * FROM courses WHERE dayOfWeek :day ORDER BY startPeriod) fun getByDay(day: Int): ListCourse Insert(onConflict OnConflictStrategy.REPLACE) suspend fun insertAll(courses: ListCourse) Query(DELETE FROM courses) suspend fun clearAll() }逻辑说明getByDay 按星期分组RecyclerView 渲染某一天只用查一次刷新时先 clearAll 再 insertAll保证缓存和服务器一致如果以后要做增量更新可以把主键从自增 id 改成「课程名星期节次」的业务键配合 REPLACE 策略就不会重复插入。首次启动时数据库为空界面要显示「正在同步」状态同步完成再切到课表主界面。4.3 刷新策略下拉刷新、启动刷新与登录态失效静默重登课表数据不是实时数据学期内基本不变但也不能完全不刷新——教务处偶尔会调课。我的方案是三段式下拉刷新、App 启动刷新、切换前台时检查距上次刷新超过 24 小时才拉一次。下拉刷新用 SwipeRefreshLayout 包住列表启动刷新在 MainActivity 里触发一次静默刷新前后台切换时用 SharedPreferences 记录上次刷新时间戳太频繁的请求对教务系统不礼貌也容易被限流。这里最关键的工程细节是登录态失效后的恢复。教务系统 session 一般几十分钟到几小时就过期过期后再请求课表接口会 302 跳回登录页。代码里要识别这种跳转请求完成后检查最终 URL 的 host 是否变成了认证中心域名是则说明登录态失效。这时候不要弹「请重新登录」而应该用缓存的账号密码静默走一遍第 2 章的登录流程再重新拉课表静默重登失败才提示用户手动登录。这个分支想清楚App 的体验才算过关。如果是在安卓模拟器里联调注意模拟器访问宿主机局域网地址的方式和真机不同接口地址不要写成 127.0.0.1 这种只在模拟器内有效的地址。5. 模拟登录和课表解析的 5 个避坑记录5.1 登录页字段名会变硬编码字段名是最大的脆点现象代码写完没几天某次登录突然报「用户不存在」但网页上手动登录正常。原因教务系统升级了登录页隐藏字段从 lt/execution 改成别的名字或者新增了一个必传项。解决不要对字段名做全局静态常量登录前先 GET 登录页提取表单里所有 input 的 name 和 value动态组装表单再 POST。这样字段改名时只改提取规则不用重新发布版本。排查时用 Postman 重新走一遍登录流程把新旧字段名对比一下马上能找到差异。5.2 OkHttp 跟随重定向导致会话判断失效现象登录返回成功但下一步请求课表还是 401或者又跳到登录页。原因OkHttp 默认 followRedirects 开着302 中间响应被自动处理掉你在最终响应里取 Cookie 时取到的是跳转后的中间那一次关键 Set-Cookie 被吞了。解决先关掉自动跟随手动跟踪每个 302 的 Location每跳转一步收集一次 Set-Cookie 合并进 CookieJar全部跳完再发课表请求。调试时把每一步的 URL、状态码、Set-Cookie 打出来链路一目了然。5.3 表格结构改版导致解析结果错位现象新增的一节课显示在错误的星期和节次格子里或者课程名和老师对不上。原因教务系统改版把原本「一行一节次」的表格改成「按星期分行」的布局行号和节次的固定映射关系被打破。解决不要依赖硬编码的行号映射先解析节次表里「第 X-Y 节」那一列建立行号到节次号的映射表再解析课程单元格。把 getPeriodByRow(rowIndex) 封装成独立函数以后行结构变化只改这一个函数。排查时打开网页版课表对比 App 渲染结果哪个格子错位就把那一行的 HTML 单独拎出来看。5.4 安卓 9 以上拦截明文 HTTP 流量现象App 在调试机器上一切正常装到别人手机上网络全挂报错 cleartext traffic not permitted。原因安卓 9 开始默认禁止明文 HTTP校内很多教务系统还没上 HTTPS。解决在 AndroidManifest 里配置 networkSecurityConfig只对教务系统域名放行明文流量其他域名仍走系统默认安全策略。不建议全局打开 usesCleartextTraffic上架审核会盯着这个配置看。调试时如果网络请求来自安卓虚拟机先确认模拟器网络模式能访问宿主机所在的网段很多「真机没问题、模拟器连不上」的怪问题都出在网络环境上。5.5 多账号切换时 Cookie 串号现象A 账号登录成功切到 B 账号后拉课表返回的还是 A 的课表。原因单例 OkHttpClient 里的 CookieJar 是全局的A 的会话没清掉B 登录时带着 A 的 Cookie 请求同一个 URL服务端优先认了旧会话。解决切换账号前主动清空 CookieJar再走登录流程。更好的方式是为每个账号创建独立的 OkHttpClient 实例各自持有自己的 CookieJar互不干扰。排查时在切换账号的代码路径里打日志确认新客户端和旧客户端不是同一个对象。6. 进阶用法桌面小组件、周次校验和「按周判课」课表主体做完还有两个性价比很高的进阶点。第一个是桌面小组件安卓桌面放一个只读课表插件不用进 App 就能看到下一节课对每天踩点上课的人很有用。实现逻辑不复杂AppWidgetProvider 在更新周期里读 Room 缓存把当天课程渲染到 RemoteViews点击小组件通过 PendingIntent 跳回对应课程详情。注意小组件只读本地缓存不依赖登录态所以缓存数据的可靠性是前提——如果你的解析在某个周次错了小组件会帮你把这个错误放大到桌面上。第二个是「按周判课」的校验函数。切换周次后要把单双周课的显示逻辑跑一遍保证当前是第 3 周就不会渲染第 2 周的课。我习惯用第 3.3 节的 isCourseInWeek 写一个测试用例喂一段真实课表 HTML断言课程总数、每门课的星期和节次再断言当前周次下该显示的课程集合。教务系统改版后跑一遍测试哪个字段解析挂了立刻暴露不用等用户反馈。最后说一个我自己踩过的教训。有一学期的「形势与政策」是单周课第一版解析代码里没做单双周判断前两周显示正常到第三周它突然出现在课表里而我已经按旧课表安排了事情差点旷课。从那以后我每次改解析逻辑第一个测试就是「当前周为 3 时单周课出现、双周课不出现」。模拟登录、课表解析这类项目就是这样用户卸载 App 往往不是因为界面不够好看而是因为数据在某个特定周悄悄错了。把登录态兜住、把周次校验跑好ScuTimetable 才能真正经得起一个学期的使用。希望帮到你。本文还有配套的精品资源点击获取