为什么又做一个课表应用
我是哈尔滨工程大学的一名学生。我试过的每一个课表应用,要么被广告塞得水泄不通——开屏广告三秒钟,信息流里插着推广,底部还挂着横幅——要么核心功能被付费墙锁死,想从学校教务系统导入课表还得买会员,要么压根儿就读不懂我们学校教务系统吐出来的数据格式,导了个寂寞。于是我动手做了 Sleepy——一个干净的、完全开源的、遵循 Material You 设计规范的课表应用,全部用 Kotlin 加上 Jetpack Compose 写成。从第一个 commit 到现在一共 119 次提交,发布了 21 个正式版本,仓库里躺着 68 个 Kotlin 源文件,加起来总共 16,460 行代码。没有接入任何第三方 SDK。没有广告。没有付费墙。代码就在这里,你用也行,改也行。
技术栈全景
在深入具体实现之前,先把整个技术栈摊开来看一眼,这样后面讲到每个模块的时候你知道它在整体架构中的位置:
- UI 层:Jetpack Compose 配合 Material 3 设计系统。整个项目里找不到一个 XML 布局文件,没有一个 Fragment 类。
MainActivity直接继承ComponentActivity,在onCreate里调用setContent { }把整棵 UI 树以 composable 函数的形式托管给 Compose 运行时。页面之间的导航使用 Compose Navigation 组件,所有的路由参数通过 URL query string 的方式在页面间传递,不需要手动维护 Fragment 回退栈。 - 架构层:标准的 MVVM 模式。
ScheduleViewModel内部持有一个ScheduleState数据类,这个类包含了 11 个字段——currentWeek(当前是第几周)、selectedDate(用户选中的日期)、courses(当前周的课程列表)、tables(所有课表)、activeTable(当前活跃的课表)等等——外加 2 个派生属性,通过 Kotlin 的 getter 按需计算。ScheduleRepository是整个应用唯一的数据源,它封装了 Room 数据库的所有 DAO 操作,对外暴露 suspend 函数,所有的数据库查询都在Dispatchers.IO协程上下文中执行,绝对不会阻塞主线程。 - 持久化层:Room 2.6.1。
CourseEntity定义了课程的 23 个字段——从课程名称、授课教师、上课教室,到 startWeek、endWeek、dayOfWeek、startNode、step 这些排课参数,再到 type(1 表示单周上课、2 表示双周上课、0 表示每周都上)、color(存储计算出的颜色值)、groupId(一个 UUID 字符串,用于将同一门课的不同时间块归组批量操作)、tableId(外键,指向它所属的课表)。TimeTableEntity定义了课表的 8 个字段——name(课表名称)、startDate(学期起始日期,以 "yyyy-MM-dd" 格式的字符串存储)、maxWeek(最大教学周数)、timeJson(12 个节次的作息时间表,以 JSON 数组形式存储)、smartConfigJson(SmartPeriodConfig 引擎推导出的配置,同样是 JSON 字符串)、isDefault 标志位等等。数据库的 schema 经历了从 version 1 到 version 3 的迁移——version 2 增加了 color 和 groupId 字段,version 3 增加了 smartConfigJson 字段。两个 DAO 接口(CourseDao 有 12 个方法,TimeTableDao 有 10 个方法)覆盖了所有增删改查操作。 - 小组件层:桌面上可以放置四种不同尺寸和用途的小组件。其中三种——Today(3×2 格,显示今天最多 3 节课)、TwoDay(4×2 格,显示今天和明天的课程)、WeekList(4×2 格,显示一周七天每天的课程数量统计)——使用 Jetpack Glance 1.1.0 实现,这是一种类似 Compose 的声明式小组件 API。第四种 WeekGrid(4×5 格,完整的时间网格视图)则完全不走 Glance,直接使用 Android 原生的 RemoteViews 配合手动 Canvas Bitmap 渲染来实现,原因马上会讲到。四个小组件统一由 WorkManager 调度,每 15 分钟(
REPEAT_MINUTES = 15L)自动刷新一次内容。 - 通知层:两个通知渠道——
sleepy_daily(每日课程提醒)和sleepy_before_class(每节课前的提醒)。系统使用 AlarmManager 来实现精确闹钟,但在 Android 12 及以上版本中,精确闹钟需要用户手动授权,所以代码里做了一个双路降级:先调用canScheduleExactAlarms()检查权限,如果用户没给权限就退而求其次使用非精确的set()方法。设备重启后需要重新注册所有的闹钟——通过监听BOOT_COMPLETED和MY_PACKAGE_REPLACED(应用更新)两个系统广播,在 BroadcastReceiver 中重新走一遍调度流程。重试机制最多尝试 3 次,每次重试前等待 500 毫秒。 - 国际化:支持五种语言——简体中文、繁体中文、英语、日语、西班牙语。全部通过 Android 标准的
strings.xml资源文件来实现,没有使用第三方 i18n 库。 - 最低 SDK 版本:24,也就是 Android 7.0。这是 2016 年发布的系统版本,覆盖到今天 99% 以上的活跃设备。
1. ScheduleParser:从六种格式中自动检测
所有课程的导入都从一个函数开始:ScheduleParser.parse()。这个文件有 737 行代码,做的事情说起来也很简单——看一眼用户粘贴进来或者从文件读取进来的那段文本的开头几个字符,判断它属于哪一种格式,然后把它交给对应的解析器去处理。但这件事要做得可靠并不容易,因为不同格式之间可能长得有点像,而且用户可能从任何来源复制文本过来。六种格式的检测器按顺序依次执行,一旦某个检测器认出了格式特征,就立即接管后续的解析工作,不再继续尝试后面的检测器:
| 格式 | 如何识别 |
|---|---|
| WakeUp 分享文本 | 文本以 [WakeUp Schedule] 这个前缀开头——这是从 WakeUp 课表 App 的分享菜单中复制出来的标准格式,WakeUp 在分享时会自动在文本最前面加上这个标记 |
| WakeUp / Sleepy JSON | 文本以 {"course 或者 [{" 开头,并且整段文本能够被成功解析为合法的 JSON——这是 Sleepy 自己的标准交换格式,也是 WakeUp App 导出 JSON 时使用的格式 |
| ICS iCalendar | 文本中包含 BEGIN:VEVENT 这样的行——ICS 是通用的日历交换格式,苹果日历、Google 日历、Outlook 都能导出这种格式,很多学校的教务系统也支持导出 ICS |
| CSV | 文本是由制表符(Tab)分隔的行。检测器会先读第一行判断表头——支持中文表头(如"课程名称"、"教师"、"教室")也支持英文表头(如"Course Name"、"Teacher"、"Room")。周次字段支持多区间写法,比如 2-5,7-9,11-14 表示这门课在第 2 到第 5 周、第 7 到第 9 周、第 11 到第 14 周上课,中间跳过的第 6 周和第 10 周可能是考试周或者假期 |
| HTML 表格 | 文本中包含 <table 标签——这种格式通常来自从教务系统网页上直接复制的课程表。解析使用 jsoup 1.18.1 这个 Java HTML 解析库来处理 |
| 纯文本 | 文本是由制表符或者空格分隔的行,但不匹配上面任何一种格式的特征——这是兜底解析器,当所有更精确的检测器都认不出来的时候,就按照最基本的制表符分隔格式来解析 |
每一个解析器成功解析后都会返回一个 ParseResult 对象,里面包含三个东西:解析出的课程列表(List<Course>)、从文本中检测到的课表名称(比如文本中可能写着"2025-2026 学年第二学期课表")、以及学期起始日期。注意这里有一个细节:parse() 函数不是把所有检测器都跑一遍然后选最好的,而是按顺序尝试,第一个成功的就直接返回。所以检测器的排列顺序是有讲究的——越具体的格式(比如 WakeUp 的专用格式)要排在越前面,越通用的格式(比如纯文本)要排在最后面,否则一个通用格式可能会抢在不那么通用但更合适的格式前面错误地"识别成功"。
ICS 格式中有一个特殊的计算需要提一下:ICS 用具体的时间来表示课程(比如"08:00-08:45"),但 Sleepy 内部用节次编号来表示(比如"第 1 节")。这个转换的公式是 startNode = ((startMin - 480) / 55) + 1——startMin 是从午夜零时开始计算的分钟数(08:00 = 480 分钟),减去 480 对齐到早上 8 点作为第 1 节的起点,除以 55(每节课 45 分钟加 10 分钟课间休息),再加 1 就得到了节次编号。这个公式假设了每节课的时长是 55 分钟(包括课间休息),这是中国大学最常见的排课节奏。
parseDay 函数负责把星期几的文本表示转换成数字:支持中文("星期一"→1)、英文("Monday"→1)、纯数字("1"→1)三种输入格式。parseType 函数负责解析单双周标记:当输入是"单"、"d"、"1"或"odd"时返回 type=1(单周上课),当输入是"双"、"e"、"2"或"even"时返回 type=2(双周上课),其他情况返回 type=0(每周都上)。
2. 大学教务系统的逆向工程
中国的大学使用的教务系统五花八门,没有统一的标准。不同学校、甚至同一学校的不同校区都可能使用完全不同的系统。Sleepy 目前支持 5 个大家族下的 9 种协议变体,这些协议由 11 个不同的解析器类来实现——其中 6 个解析器共享 JwQzParser 这个基类,在基类中处理通用的 HTML 表格解析逻辑,各自的子类只负责处理各校特有的差异部分。
2.1 Wisedu——哈尔滨工程大学的协议
在所有这些协议中,Wisedu 是最难逆向的一个。哈尔滨工程大学使用的是一套较新版本的 Wisedu 教务系统。要获取课表数据,Sleepy 需要向 /jwapp/sys/wdkb/modules/xskcb/xskcb.do 这个地址发送 POST 请求。服务器返回的 JSON 载荷中包含 7 个关键字段:KCM 是课程名称,SKJS 是授课教师的姓名,JASMC 是上课教室的名称,SKXQ 是星期几(用数字表示,1 到 7 对应周一到周日),KSJC 是课程开始于第几节,JSJC 是课程结束于第几节,SKZC 是周次 bitmap。
这里最有意思的是 SKZC 这个字段。它不是直接告诉你"第 1 到第 16 周",而是一个从第 0 位(对应第 1 周)开始的由字符 '0' 和 '1' 组成的字符串——比如 "11111111111111110000" 就表示第 1 到第 16 周有课,第 17 到第 20 周没课。每一位代表一个教学周,'1' 表示那一周要上这门课,'0' 表示不上。前端代码拿到这个 bitmap 之后,会把它压缩成 weekRuns 这种更紧凑的表示——把连续的一段 '1' 合并成一个"区间",比如上面那个例子会变成 [1-16] 这样一个区间,大大减少了需要显示的文字量。
但真正困难的地方不在解析返回数据,而在于怎么让教务系统把数据返回给你。Wisedu 系统有登录保护,Sleepy 不能在代码里直接发 HTTP 请求——没有登录态服务器根本不理你。这里的解决方案是使用 WebView:在 App 内部开一个 WebView 窗口,让用户在 WebView 里正常登录教务系统。登录成功后,Sleepy 注入一段自己写的 JavaScript 桥接代码(名字就叫 WiseduBridge),然后通过这个桥接执行一条 5 步的 fetch 请求链——先拿到主页,再跳转到 iframe 内的子页面,再进入课表查询页面,再触发查询,最后从返回的 DOM 中提取课表数据。每一步都要处理 iframe 嵌套和 frame 元素,确保捕获到完整的数据。
2.2 URP——JSON 注入的变体
有一些学校使用的是新版的 URP 教务系统。URP 的做法比 Wisedu 粗暴得多——它直接把课表数据以 JavaScript 变量的形式硬编码在了页面源码里:var kbxx_json = { ... };。Sleepy 要做的就是找到这行 JavaScript,把花括号里面的 JSON 提取出来解析就行了。不过 dateList 字段里面的 weekBits 又是一个 20 位的 0/1 字符串(同样是 bitmap),需要使用一个步长探测算法来解析——遍历这个字符串,找到所有连续的 '1' 组成的区间,把它们合并成人类可读的周次范围。
2.3 Qiangzhi——六个解析器的共享基类
在 11 个解析器类中,有 6 个是以 JwQzParser 作为基类的。这些学校的教务系统虽然各有各的小差异,但底层都是强智(Qiangzhi)科技的产品。JwQzParser 做的事情是解析带有 class="displayTag" 这个 CSS 类名的 HTML 表格。课表数据位于 tableName = "kbcontent" 的这个表格中——"kbcontent" 大概就是"课表内容"的拼音缩写。
解析 HTML 表格的时候有一个容易出错的点:HTML 表格的每一行并不直接对应一个节次。因为每个星期标题("星期一"、"星期二"……)本身也占一行。所以需要用一个公式来换算:node = nodeCount * 2 - 1。意思是从 HTML 表格行号到节次编号,每两个 HTML 行对应一个实际的节次(一行是星期标题,一行是课程内容)。
还有一个细节:同一时间格内如果有两门不同的课(比如单周上 A 课、双周上 B 课),它们在 HTML 的同一个 <td> 单元格内是用 "-----"(五个短横线)来分隔的。解析器需要用这个分隔符把同一个单元格的内容拆成多门独立的课程。另外还有一个 JwQzCrazyParser 变体,处理某些学校那完全不按套路出牌的 HTML 结构。
3. 黄金角 HSL 颜色分配
Sleepy 中每一门课都有一个独一无二的颜色。你在周视图里看到的那些彩色色块——粉色的高数、绿色的英语、橙色的物理——这些颜色不是设计师一个一个手调的,也不是随机生成的。它们全部由一段数学公式自动计算出来,这段公式写在 CourseTableView.kt 里:
val hue = (courseId * 137.508) % 360
137.508° 叫做黄金角——它是 360 / φ² 的计算结果,其中 φ 是黄金比例 (1 + √5) / 2 ≈ 1.618。为什么要用它?因为黄金角有一个非常特殊的性质:用它乘以一个递增的整数序列,然后取模 360,得到的角度序列会在色环上均匀分布,而且永远不会出现两个角度过于接近的情况。具体来说,对于最多 13 门课程的情况(大多数大学生一学期的课程不会超过 13 门),任意两门课之间的色相间隔至少约 27°。这意味着不会有两门课的颜色撞色,你的课表看起来永远是色彩分明、一目了然的。
色相算出来之后,还需要搭配饱和度和明度才能构成完整的颜色。这两个参数不是固定的——在深色模式下(大多数人使用课表 App 的场景),饱和度会调得更高一些,颜色看起来更鲜艳;在浅色模式下,饱和度会降低,避免在白色背景上过于刺眼。这些都是自动适配的。
但是,纯数学公式有一个问题:它不知道"英语课对应蓝绿色"、"军事理论课对应橄榄绿"这种人类约定俗成的颜色联想。所以在黄金角计算出颜色之后,还有一个 8 条关键字规则的匹配器会覆盖计算结果。这些规则硬编码在 WidgetContent.kt 里——英语 → 蓝绿,军事理论 → 橄榄色,物理 → 橙色,历史 → 棕色,心理 → 紫色,体育 → 红色等等。这些映射在 App 内的所有视图和桌面的所有小组件上统一生效,确保同一门课无论在哪个地方看到,都是同一个颜色。如果一门课既没有被黄金角算法匹配到(这是不可能的,因为黄金角覆盖了所有情况),也没有被关键字规则覆盖到(比如"量子计算导论"这种不在规则列表里的课),还有一个 6 色的哈希兜底调色板来接住它。
4. 混合小组件系统:Glance + RemoteViews
Sleepy 提供了 4 个桌面小组件,由 WorkManager 每 15 分钟自动触发一次刷新。为什么是 15 分钟?因为 Android 系统对小组件的刷新频率有限制,低于 15 分钟的间隔系统可能不会真正执行——而且说实话,课表内容也不太可能在 15 分钟内发生变化。
前三个小组件使用 Jetpack Glance 1.1.0 来实现:
- Today(3×2 格):只显示今天要上的课,最多显示 3 节。长按可以进入小组件配置页面。
- TwoDay(4×2 格):显示今天和明天两天的课,每天一列,每列最多 3 节课。这是最实用的小组件——你今天就知道明天的安排。
- WeekList(4×2 格):显示一周七天每天各有几节课,用数字统计的形式呈现。一眼就能看出哪几天课多、哪几天课少。
第四个小组件 WeekGrid(4×5 格)是技术难度最高的一个。它需要在一块 4×5 的桌面空间里画出一张完整的时间网格——左边是时间标签(第 1 节、第 2 节……),上边是星期标题(周一、周二……),中间是彩色的课程色块,就像 App 里面看到的周视图的缩小版。Glance 本身是支持这种布局的,理论上用 Glance 的 Column 和 Row 组合就能搭出来。但 Glance 1.1.0 有一个已知的 bug:当它把声明式的 UI 转换成底层 RemoteViews 的时候,LinearLayout 在某种特定条件下会静默地丢弃第 11 个及以后的子 View。对于一个有 12 节(从早 8 点到晚 9 点)的课表来说,这意味着第 11 节和第 12 节的课程色块根本不会显示。
这个 bug 绕不过去。所以 WeekGrid 小组件没有使用 Glance,而是走了一条更原始但也更可靠的路线——在 WeekGridWidgetProvider 里面,手动创建一个 Canvas,在上面一笔一笔地把整个周网格画出来,画成一个 360×600dp 的 Bitmap,然后把这个 Bitmap 塞进一个 RemoteViews 的 ImageView 里,最后通过 APPWIDGET_UPDATE 广播发送给系统。整个渲染管线:
- 通过
ScheduleRepository向 Room 数据库查询当前周的所有课程——注意这一步是在 WorkManager 的后台线程中执行的,不会影响 UI - 确定要显示哪一张课表:
WidgetTableResolver按三级优先级来决定——优先用用户在小组件设置中手动指定的那一张课表,如果没有指定就用系统默认的课表(如果里面有课程的话),如果默认课表是空的就随便找一张有课程的课表 - 在 Canvas 上画出网格的结构线——8 列(时间标签占一列,7 天各占一列),每行的高度是 52dp(
CELL_H = 52.dp),时间标签列的宽度是 36dp,行间距和列间距各 2dp - 对于每一门课,使用和 App 内完全相同的黄金角颜色算法计算出它的颜色,然后在网格对应的位置上画一个圆角色块。跨多节课的课程画成更高的矩形
- 课程色块上的文字颜色(白色还是黑色)不是写死的——通过
isDarkOn()检查当前系统的深色模式设置来决定,浅色背景上的深色课程卡片用白色文字,深色背景上的浅色课程卡片用黑色文字 - 把画好的 Bitmap 塞进 RemoteViews,广播出去。系统收到广播后会更新桌面上的小组件
WidgetUpdater 是负责触发刷新的调度器。它必须同时刷新 Glance 小组件(调用 .update() 方法)和 RemoteViews 小组件(发送广播)——如果只更新了其中一条路径,另一条路径的小组件就会静默地显示着过期数据,用户看到的就是上周的课表。另外还有一个 PinWidgetActivity 处理 Android 12 以上系统的小组件首次固定流程——从 Android 12 开始,用户在桌面上添加小组件之前系统会弹出一个配置对话框,这个 Activity 就是用来处理这个对话框的。
5. SmartPeriodConfig:作息时间的自动推导
一提到设置课表的作息时间,大多数 App 的做法是让用户一个节次一个节次地手动填写。12 个节次,每个节次要填开始时间和结束时间两个字段,那就是 24 个输入框。填过的人都知道这有多痛苦。
Sleepy 换了一种思路:你只需要告诉我 4 个参数,剩下的事情让程序来算:
- 每天总共有几节课?——比如说 12 节
- 第一节课几点开始?——比如说 "08:00"
- 每节课多长时间?——比如说 45 分钟
- 课间休息多久?——比如说 5 分钟
有了这四个参数,SmartPeriodConfig 引擎就可以自动推导出完整的时间表——第 1 节 08:00-08:45,课间休息 5 分钟,第 2 节 08:50-09:35,以此类推,一直到第 12 节。这 4 个参数覆盖了 90% 的使用场景。
但有些学校的安排没那么规整——比如第 4 节和第 5 节之间是午休时间,可能需要 15 分钟甚至更长,而不是默认的 5 分钟。所以 SmartPeriodConfig 还支持设置"特殊休息"——你可以指定在第几节课之后额外加多长休息时间。系统会把这些特殊休息叠加到默认课间休息之上,计算出准确的时间。
计算完成后,整个时间表被序列化为 JSON 字符串,存入 TimeTableEntity.smartConfigJson 字段持久化。下次打开 App 的时候直接读这个字段,不需要重新计算。在 UI 上,TimeSlotEditor 这个 composable 提供了一个顶部 Tab 栏,你可以在"手动模式"和"自动模式"(也就是 SmartPeriod)之间随时切换——切到手动模式之后你也可以微调自动生成的结果。
6. CourseTableLayout:用 Box 加 Offset 搭出来的周网格
周网格视图是整个 Sleepy 项目中最具挑战性的 Compose 布局。按照直觉,你会想用一个 LazyColumn,每一行是一个节次,每个 cell 里面放对应的课程卡片。但这个方案在实际运行中会崩溃成一个只有一个格子的布局。原因出在 Compose 的测量机制上——当你对一个 LazyColumn 的子项使用 fillMaxSize() 或者测量高度等于父容器高度时,内容高度就等于视口高度,LazyColumn 认为"内容已经全部可见了,不需要滚动",于是剩下的课程卡片全部被裁切掉。
最终的解决方案是 CardsGridView——它包裹一个 BoxWithConstraints,然后对每一张课程卡片都使用绝对定位的 Modifier.offset(x, y)。整个网格的结构不是靠 Row 和 Column 的嵌套来排列的,而是靠数学计算出来的。布局系统需要 5 个常量来预先确定网格的尺度:表头高度(星期标题那行)、时间列宽度、每个节次的高度(52dp)、行间距(2dp)、列间距(2dp)。有了这些常量,再加上从 BoxWithConstraints 获取的实际容器宽度除以 8 列,就可以精确计算出每个卡片应该放在哪个 (x, y) 坐标。
跨多个节次的课程卡片——比如一门从第 3 节上到第 5 节的课——会被渲染成一个更高的矩形,高度 = 节次数 × 52dp + (节次数 - 1) × 2dp。卡片内部的文字使用自适应字号(autoFitCourseFontSize),在 6sp 到 12sp 之间根据可用空间动态调整。这个方案的代价是每个卡片的位置都需要手动做坐标数学,但换来的好处是布局永远不会被 Compose 的测量机制压扁。代码里有一行注释 "v8a990ea two-layer 方案",标记了从失败的 LazyColumn 方案切换到这套 Box+Offset 方案的那个重构节点。
7. Room 数据库的 Schema 设计
Sleepy 的数据库设计不复杂,但很精确。两个实体表,外加一个以 JSON 字符串形式存储在表内的配置字段,覆盖了所有需要持久化的数据:
- CourseEntity(23 个字段):课程名称(courseName)、教师姓名(teacher)、教室名称(room)、课程备注(note),排课相关字段——起始周(startWeek)、结束周(endWeek)、星期几(dayOfWeek,1 到 7)、起始节次(startNode)、持续节数(step),自定义时间标记(ownTime 布尔值——如果为 true 则使用 startTime 和 endTime 而非按节次计算),单双周类型(type,1=单周/2=双周/0=每周),视觉相关字段——颜色值(color)、群组 ID(groupId),以及外键 tableId 指向所属的课表。注意这里的 groupId 不是从课程名称派生出来的——它是
java.util.UUID.randomUUID().toString()生成的一个随机 UUID 字符串。这意味着同一门课在不同课表之间不会被错误地关联到一起,也让批量操作(比如一键删除同一门课的所有时间块)可以不依赖名称匹配。 - TimeTableEntity(8 个字段):课表名称(name)、学期起始日期(startDate,String 类型,格式 "yyyy-MM-dd")、最大教学周数(maxWeek)、作息时间表(timeJson,一个 JSON 数组,每个元素包含节次编号和对应的开始/结束时间)、SmartPeriodConfig 配置(smartConfigJson,同样是 JSON 字符串)、是否是默认课表(isDefault 布尔值)。
8. 导入管线:预览、冲突检测、应用
从任何渠道导入课程数据,不管是从 WakeUp 分享过来的、从教务系统抓取下来的、还是从 CSV 文件读取的,都会经过一个统一的三阶段处理流程:
- 解析阶段:
ScheduleParser.parse()根据前文描述的六种格式检测逻辑,解析输入的文本,得到一个ParseResult对象。这个对象包含了所有解析出来的课程、检测到的课表名称(比如文本中可能包含的学期信息)、以及学期起始日期。如果这个阶段失败了——比如文本格式完全不认识,或者 JSON 格式不对——导入就会在这里终止并提示用户。 - 预览阶段:在真正把数据写入数据库之前,Sleepy 会先生成一个预览。预览阶段的核心工作是冲突检测——用
buildImportPreview()函数,把即将导入的每一门课和当前课表中已经存在的每一门课逐一比对。比对的标准是时间重叠——如果两门课在同一天的同一时间段(比如都是周一第 3-4 节),那就构成了一次冲突。比对函数coursesConflict(a, b)的具体逻辑是:先检查两门课的星期几是否有交集(如果一门在周一、另一门在周三,那就完全不冲突直接跳过),再检查两门课的周次范围是否有重叠(通过检查 startWeek 和 endWeek 的交叉区间),最后检查节次范围是否有重叠。如果三个条件都满足,才判定为冲突。 - 应用阶段:用户看了预览结果之后,有三种导入模式可以选择:
ReplaceCurrent(完全替换——先清空当前课表中所有已有的课程,然后把新课程全部导入)、ImportAsNew(另存为新课表——在数据库中创建一张全新的课表,把新课程导入这张新课表中,原有的课表不受影响)、AppendNonConflict(追加不冲突的课程——只导入那些和现有课程没有时间冲突的课程,冲突的课程直接跳过不导入)。
预览的结果是一个 ImportPreview 对象,里面有三个数字指标:incomingCount(总共要导入多少门课)、conflictCount(其中有多少门课和现有课程有时间冲突)、cleanCount(其中有多少门课是完全不冲突的,可以安全导入)。这三个指标会以卡片的形式展示给用户——冲突数用红色标注,干净的可导入数用绿色标注。
整个导入预览界面是在 ImportSheet.kt(894 行)中实现的。它还包含一个细节功能:当导入的课表名称和已有的课表重名时(比如你从教务系统反复导入同一学期的数据),系统会自动给重名的课表加上序号后缀——"2025 春课表"变成"2025 春课表 (1)","2025 春课表 (1)"变成"2025 春课表 (2)",以此类推。这个去重逻辑实现在 uniqueImportedTableName 函数中。
另外,在 Debug 构建中(判断条件是 BuildConfig.DEBUG),ImportSheet 还支持一个自动化测试用的快捷入口:通过 SharedPreferences 中一个名叫 "debug_import" 的键值对,可以直接注入一段导入文本,跳过整个 UI 交互流程,直接触发解析和导入。这个功能在 Release 构建中会被完全剪枝掉,普通用户永远不会看到它——它是用来跑 CI 自动化测试的。
9. ThemePresets 与 ColorPicker
Sleepy 提供了 5 套预设主题配色方案——Default(默认)、Spring(春天)、Ocean(海洋)、Peach(蜜桃)、Slate(石板)。不是之前以为的 6 套,就是这 5 套。每一套主题在浅色模式和深色模式下各有 31 个颜色值,全部基于 Material 3 官方的 tonal palette 色阶系统来生成。此外还支持一个 KEY_SYSTEM 选项——走 Material You 的动态取色机制,从用户的系统壁纸中自动提取主题色。
在 AddCourseScreen.kt(这个文件有 1255 行,是项目中最长的单个文件之一)中,用户还可以为每一门课单独选择一个自定义颜色。这里用了一个从零手写的 HSV 拾色器——全部用 Canvas 绘制,没有任何第三方颜色库。拾色器包括两部分:一个 SVPanel(饱和度-明度二维平面,用 Canvas 的线性渐变和叠加层画出完整的 HSV 色空间),一个 HueSlider(色相滑条,使用 6 个停止点的渐变色带覆盖整个色相环)。课程的颜色跟前面讲到的黄金角算法是两个独立的系统——用户手动选择的颜色会覆盖自动计算的颜色。
课程的时间配置使用 MeetingInputMode 枚举,有两种模式:ByNode 模式——你选择"第几节到第几节",系统自动根据作息时间表换算成具体时间;ByClock 模式——你直接选择"几点几分到几点几分",不管作息时间表怎么设置。这两种模式可以混用。同一门课的不同时间段(比如理论课在周一第 1-2 节、实验课在周三第 5-6 节)通过一个私有的 MeetingBlockDraft 数据类来管理,每个时间块有一个独立的 groupId = UUID.randomUUID().toString()。
10. 通知系统
Sleepy 有两种类型的通知,分别走不同的通知渠道:
- 每日课程提醒(
sleepy_daily渠道):在用户设定的时间推送一条汇总通知,列出今天有哪些课、分别在哪个教室、几点到几点。默认触发时间是每天早上 00:05——设置成 00:05 而不是 00:00 是故意的,因为在 00:00 这个时间点系统会集中处理大量的定时任务(各种 App 的每日重置逻辑都喜欢挂在零点),在 00:00 发通知很容易被淹没或者延迟。拖到 00:05 就避开了这个拥堵时段。 - 课前提醒(
sleepy_before_class渠道):在每节课开始前 N 分钟推送一条提醒(N 由用户在设置中自由输入,5 分钟、10 分钟都可以)。每一节课的提醒使用独立的 AlarmManager 闹钟——闹钟的 requestCode 从RC_BEFORE_CLASS_BASE = 100这个基值开始,加上课程的唯一 ID 来做偏移,保证每门课的闹钟 ID 都是全局唯一的,不会互相覆盖。
在 Android 12(API 31)及以上版本中,系统对精确闹钟引入了新的权限限制——App 必须拥有 SCHEDULE_EXACT_ALARM 权限才能使用 AlarmManager.setExact() 设置精确到毫秒的闹钟。Sleepy 的处理逻辑是:先用 canScheduleExactAlarms() 检查当前是否拥有这个权限,如果用户已经授权了,就走精确闹钟(setExact()),课前提醒可以在设定时间的 ±1 秒内触发;如果用户没有授权,就降级为非精确闹钟(set()),系统可能会在目标时间附近的某个时间点触发(通常在目标时间之后几分钟内),虽然不是完全准时但也比没有提醒好。
闹钟有一个让人头疼的特性:设备重启之后,所有已经设置好的闹钟都会被系统清除。Sleepy 通过注册两个 BroadcastReceiver 来解决这个问题——一个监听 BOOT_COMPLETED 广播(设备重启完成),一个监听 MY_PACKAGE_REPLACED 广播(App 自身被更新)。当这两个广播中的任意一个被触发时,Receiver 会重新走一遍完整的闹钟调度流程,把所有课前提醒和每日提醒全部重新注册一遍。这个过程最多重试 3 次,每次重试前等待 500 毫秒,防止在系统刚启动资源紧张的时候调度失败。
开源
Sleepy 使用 GPL-3.0 开源协议发布。目前已经发布了 21 个正式版本,支持三种 CPU 架构的 APK——arm64-v8a(覆盖 2020 年以后几乎所有 Android 手机的 64 位 ARM 架构)、armeabi-v7a(覆盖一些老旧的 32 位 ARM 设备)、x86_64(覆盖 Android 模拟器和少部分 x86 平板)。所有 APK 都通过 GitHub Releases 分发。不上架任何应用商店——没有 Play Store,没有华为应用市场,没有酷安。没有广告,没有埋点,没有用户追踪。代码就在这里,你用也行,改也行,拿去学也行。
参见:Sleepy 操作文档——涵盖安装、导入课表、桌面小组件配置、主题设置和常见问题排查的分步用户指南。