Skip to content

Course Model

lingion edited this page Sep 12, 2026 · 1 revision

课程数据模型

一句话 TL;DR:课程和课表在 Room 数据库里怎么存、每个字段什么意思、怎么变成屏幕上的卡片。给想改数据层或写导入导出的开发者看。

本文基于 v1.0.54(versionCode 58,app/build.gradle.kts:16)。路径均相对仓库根。

全景:两张表加一个 JSON

数据库只有两个实体,schema 版本 6(app/src/main/java/com/lingion/sleepy/data/AppDatabase.kt:13)。第三个数据类 SmartPeriodConfig 不建表,以 JSON 字符串存在课表行里。

time_tables(课表/学期)
   id ──────────────┐
                    │ 外键 onDelete = CASCADE
                    ▼
courses(课程行)     ── 同一门课的每个时间段各占一行,靠 groupId 归组

courses 建了三个索引:tableId、day、(startWeek, endWeek)(data/entity/CourseEntity.kt:15)。删课表时,课程行由外键级联删掉(data/entity/CourseEntity.kt:21)。

schema 演进有条硬规矩:禁用 fallbackToDestructiveMigration,改 schema 先在 data/Migrations.kt 登记迁移再升 version(data/AppDatabase.kt:36)。现存迁移三条:3→4、4→5、5→6(data/Migrations.kt:48)。

CourseEntity 的骨架与 WakeUp 原版 CourseBean 兼容(type / day / startNode / step / startWeek / endWeek / color / tableId,data/entity/CourseEntity.kt:10),在此之上加了 teacher / room / note / alias 等显示字段。从 WakeUp 迁数据可以少踩一轮坑。

TimeTableEntity:一张课表就是一个学期

源码:data/entity/TimeTableEntity.kt:12,表名 time_tables。

字段 类型 默认值 语义
id Long 自增 主键,课程行的 tableId 指向它
name String 必填 课表名
startDate String 必填 学期开始日期 yyyy-MM-dd,当前周次由它算出
maxWeek Int 20 学期总周数,翻周上限
nodesPerDay Int 12 一天的节次数
timeJson String DEFAULT_TIME_JSON 每节课的起止时间 JSON,见下节
color String #FF6750A4 课表颜色主题
isDefault Boolean false 是否默认课表
smartConfigJson String "" 智慧节次配置 JSON,空串 = 手动模式(data/entity/TimeTableEntity.kt:41)
createdAt Long 当前毫秒 创建时间,列表按 createdAt DESC 排序返回(data/dao/TimeTableDao.kt:37)

当前周次由 DateUtils.currentWeek(startDate) 算,startDate 归一到所在周周一后按 7 天取商加 1,学期开始前钳制为 1(util/DateUtils.kt:35);学期内外的判定走 DateUtils.semesterStatus(startDate, maxWeek),超过 maxWeek 判为学期已结束(util/DateUtils.kt:20)。

timeJson:节次时间表

格式是 JSON 数组,每节一项:[{"node":1,"start":"08:00","end":"08:45"}, ...](util/TimeTableUtils.kt:13)。

默认值 DEFAULT_TIME_JSON 是 12 节 / 45-50 分钟作息,从第 1 节 08:00-08:45 到第 12 节 21:45-22:30(util/TimeTableUtils.kt:27)。它是 timeJson 的唯一权威默认值,实体默认构造、解析、UI 渲染都从这里走(util/TimeTableUtils.kt:22)。

围绕 timeJson 的工具函数都在 util/TimeTableUtils.kt:

  • timeSlotsFor(timeJson) — JSON 转每节一行的 TimeSlot UI 模型(util/TimeTableUtils.kt:63)
  • courseTimeParts(startNode, step, timeJson, ...) — 课程起止节换成起止时间,找不到节点返回 null(util/TimeTableUtils.kt:91)
  • timeToNode(startTime, endTime, timeJson) — 反方向,自定义时间反算等效节次(util/TimeTableUtils.kt:120)
  • parseTimeSlotRows / buildTimeJsonFromRows — 编辑器行模型与 JSON 互转(util/TimeTableUtils.kt:300、util/TimeTableUtils.kt:316)

编辑用的行模型 TimeSlotRow(node, start, end, edgeClass),删除某节后节点重新编号成连续的 1..N(util/TimeTableUtils.kt:291、util/TimeTableUtils.kt:340)。

SmartPeriodConfig:智慧节次(自动模式)

不想手动逐节填时间,可以切「自动模式」:只给每节时长、总节数、首节开始时间和课间规则,整张作息表推导出来。配置类是 @Serializable data class SmartPeriodConfig(data/entity/SmartPeriodConfig.kt:23),不建表,序列化后存进 TimeTableEntity.smartConfigJson(data/entity/TimeTableEntity.kt:41)。

字段 类型 默认值 语义
startTime String "08:00" 第一节开始时间 HH:mm
periodMinutes Int 45 每节时长(分钟)
totalPeriods Int 12 总节数 N
breaks List<BreakOption> 空 课间模板列表,每项一个分组
transitionAssignments List<Int?> 空 每个课间选哪个 break 索引,长度 = N−1,null 表示 0 分钟连排(data/entity/SmartPeriodConfig.kt:16)

推导公式:第 i 节开始时间 = startTime + i × periodMinutes + Σ第 i 节之前的课间(data/entity/SmartPeriodConfig.kt:19)。

两个保护方法:

  • effectiveAssignments() — 截断/补齐到 totalPeriods - 1 长,越界索引归 null(data/entity/SmartPeriodConfig.kt:35)
  • derive() — 按公式生成全部 N 节的 TimeSlotRow 列表,课间分钟数累加进时钟(data/entity/SmartPeriodConfig.kt:57)

课间模板 BreakOption(minutes, isLong, label):isLong 只影响大小课间的颜色和标签展示,label 可自定义名,缺省显示「小课间 X 分钟」或「大课间 X 分钟」(data/entity/SmartPeriodConfig.kt:92、data/entity/SmartPeriodConfig.kt:97)。

读写路径:编辑课表页用 kotlinx.serialization 做往返——打开时 Json.decodeFromString<SmartPeriodConfig>(table.smartConfigJson),失败则从现有行数推断初值;保存时 Json.encodeToString 写回(ui/screen/mine/EditTableScreen.kt:123、ui/screen/mine/EditTableScreen.kt:283)。导入确认弹层也会从解析出的作息行播种一个 SmartPeriodConfig 初值,避免切自动模式被默认值覆盖(ui/screen/imports/ImportSheet.kt:1124)。

CourseEntity:课程行

源码:data/entity/CourseEntity.kt:25,表名 courses,共 23 个字段。

分组模型:一门课多行

同一门课的每个时间段各占一行——周一 1-2 节一行,周三 3-4 节另一行——靠 groupId 归成一个组。编辑和删除按组操作,DAO 提供 deleteByGroupId(tableId, groupId)(data/entity/CourseEntity.kt:28、data/dao/CourseDao.kt:45)。列表查询按 day, startNode, startWeek 排序返回(data/dao/CourseDao.kt:59)。

字段总表

字段 类型 默认值 语义
id Long 自增 主键
groupId String 必填 课程组 ID,同门课所有节次共享
tableId Long 必填 所属课表 id,外键
courseName String 必填 课程名(身份场景永远用它)
teacher String "" 教师
room String "" 教室
note String "" 备注
alias String "" 课程别名,仅展示场景用,见下
day Int 必填 周几,1-7,周一=1(data/entity/CourseEntity.kt:54)
startNode Int 必填 开始节次,1-based(data/entity/CourseEntity.kt:57)
step Int 必填 持续节数,结束节 = startNode + step − 1(data/entity/CourseEntity.kt:142)
startWeek Int 必填 起始周
endWeek Int 必填 结束周
type Int 0 周次类型,见下节
color String 必填 ARGB 十六进制,如 #FF6750A4(data/entity/CourseEntity.kt:77)
colorMode Int 0 颜色模式 0/1/2,见「颜色三态」(data/entity/CourseEntity.kt:94)
ownTime Boolean false 是否用户自定义时间,与 isIrregularTime 恒同值(data/entity/CourseEntity.kt:100)
isIrregularNode Boolean false 本卡片绑定边缘节次槽位(0/-1/N+1),网格位置 = 槽位编号本身(data/entity/CourseEntity.kt:106)
isIrregularTime Boolean false 本卡片 startTime/endTime 是覆盖值,不受槽位默认时间窗约束(data/entity/CourseEntity.kt:112)
startTime String "" 自定义开始时间 HH:mm,仅自定义时间时使用(data/entity/CourseEntity.kt:115)
endTime String "" 自定义结束时间 HH:mm
credit Float 0f 学分,保留字段,随 schema 兼容存在
level Int 0 同上

alias 的边界要记牢:组级属性,同 groupId 所有行共享,编辑保存时整组覆盖;空串 = 处处显示原名。只有周视图、网格视图、桌面组件这类「展示」场景按各自设置取用,详情页、导入预览、通知、导出一律原名(data/entity/CourseEntity.kt:46)。

周次编码:startWeek / endWeek / type

四个周次字段合起来表达「哪些周上这门课」:

type = 0  每周    [startWeek, endWeek] 区间内每周都上
type = 1  单周    区间内只上奇数周(第 1/3/5... 周)
type = 2  双周    区间内只上偶数周(第 2/4/6... 周)
type = 3  按周次列 区间内都上;解析器把区间收紧到实际列出的周,
                 用于「只在某一两周上」的单次实验课

(离散周「11,13,15」也是解析器先展开成一行一个小区间;实体只按区间+type 判定。)

判定的唯一入口是 inWeek(week)(data/entity/CourseEntity.kt:125):

fun inWeek(week: Int): Boolean {
    if (week < startWeek || week > endWeek) return false
    return when (type) {
        0 -> true
        1 -> week % 2 == 1   // 单周 = 奇数周号
        2 -> week % 2 == 0   // 双周 = 偶数周号
        3 -> true            // 区间即周次列
        else -> true         // 旧数据非法 type,不丢课
    }
}

两个细节:单双周按绝对周号的奇偶判定,与课表起始周无关;教务解析器遇到类型列缺失或不明时默认填 3 而非 0,避免把「周次=6 的单次实验」误存成「每周都上」(data/entity/CourseEntity.kt:70、data/parser/ScheduleParser.kt:1124)。

颜色三态:color + colorMode

colorMode 常量定义在 CourseColorMode(GROUP=0 / AUTO=1 / CUSTOM=2,data/entity/CourseEntity.kt:174),决定 color 字段怎么被解读(data/entity/CourseEntity.kt:79):

  • GROUP(0,默认):整门课共享组色。渲染时先看 color 是否为用户自定义——判定标准是「非空且不等于哨兵值 #FF6750A4」(util/CourseColorUtil.kt:64);没自定义就用黄金角 137.508° 按 groupId 哈希撒色相,同门课永远同色(util/CourseColorUtil.kt:53)。
  • AUTO(1):color 字段无意义,渲染时实时算。行级入口按「组色源的色相 + 行序号 × 137.508°」推进,组色源 = 同组内 colorMode=GROUP 中 id 最小的行(util/CourseColorUtil.kt:298、util/CourseColorUtil.kt:272)。
  • CUSTOM(2):color 存用户选定的十六进制,直接解析使用(util/CourseColorUtil.kt:217)。

取色入口分两族,同一份逻辑两套返回类型(Compose Color / Canvas Int):pickCourseColorCompose / pickCourseColorInt 是整门课一个色的组级入口;pickCourseColorComposeWithGroupRows / pickCourseColorIntWithGroupRows 是三态行级入口,每个 block 独立取色(util/CourseColorUtil.kt:159、util/CourseColorUtil.kt:209)。所有入口都接受 colorless 开关,开了返回调用方传入的中性色(surfaceVariant,util/CourseColorUtil.kt:157)。

色相种子必须是 groupId 而非自增 id——id 随导入顺序漂移,groupId 才是课程身份(util/CourseColorUtil.kt:51)。

非常规节次与自定义时间

ownTime=true 且 startTime/endTime 非空的课,时间就是用户写死的 HH:mm,不再看节次表。渲染进网格前要过一道 normalizeNode(timeJson):调 TimeTableUtils.timeToNode 反算等效的 startNode/step,让卡片落在正确格子里;反算不出就原样返回(data/entity/CourseEntity.kt:163)。

timeToNode 的归位规则(util/TimeTableUtils.kt:106,实现在 120):

  • startNode = 时间表中 start ≤ 课程开始时间的最大节点(向下取)
  • endNode 从 startNode 起沿节点序连续延伸,课程在节间空隙内结束时停在空隙前那一节,不跨空隙吸附下一节
  • 早于第一节用第 1 节,晚于最后一节用最后一节;解析失败返回 null

勾了「非常规节次」(isIrregularNode=true)的卡片跳过这道重映射,网格位置 = 槽位编号本身(data/entity/CourseEntity.kt:165)。

时间文案有两个本地化方法:nodeString(context) 走 course_node_format 字符串(「第%1$s节」,输出如「第3-4节」),自定义时间则直接给「18:30-20:55」;shortNodeString(context) 走 course_period_range(%1$d-%2$d节,输出如「3-4节」)。两者的 KDoc 都标了「推荐 UI 使用」(data/entity/CourseEntity.kt:137、data/entity/CourseEntity.kt:148;app/src/main/res/values/strings.xml:459)。

字段示例值

新建课程的默认行:groupId 为新生成 UUID,courseName 取「新课程」字符串资源,day = 今天,startNode = 1,step = 1,startWeek = 当前周,endWeek = 当前周 + 16,color = "#FF6750A4"(ui/screen/schedule/ScheduleViewModel.kt:272)。

几个典型行:

高等数学, 周一 1-2 节, 1-16 周每周:
  day=1  startNode=1  step=2  startWeek=1   endWeek=16  type=0

大学英语, 周二 3-4 节, 1-16 周单周:
  day=2  startNode=3  step=2  startWeek=1   endWeek=16  type=1

第 6 周一次的单次实验课:
  startWeek=6  endWeek=6  type=3

自定义时间课(18:30-20:55):
  ownTime=true  startTime="18:30"  endTime="20:55"

WakeUp 分享 JSON 里的同款字段叫法略有差异:教室是 position(data/parser/ScheduleParser.kt:19)。导出沿用这套字段名(data/parser/ScheduleExporter.kt:32)。

DAO 提供的查询能力

CourseDao(data/dao/CourseDao.kt:13)

全部插入走 OnConflictStrategy.REPLACE,更新走 @Update 按主键覆盖。按能力分组:

  • 写:insert / insertAll(REPLACE 冲突策略);update / updateAll(批量更新,保留各行 id);insertKeepId(按已有 id 覆盖写入,REPLACE 冲突策略)(data/dao/CourseDao.kt:15、data/dao/CourseDao.kt:21、data/dao/CourseDao.kt:32)
  • 删:deleteById / deleteByIds / deleteByTableId / deleteByGroupId / deleteAll(data/dao/CourseDao.kt:36-data/dao/CourseDao.kt:50)
  • 一次性读:getById、getByTable、getByTableAndDayOnce、getByGroupId、getAll、countByTable、totalCount
  • 响应式读:observeByTable 返回 Flow<List<CourseEntity>>,课程一变就重新发射,UI 自动刷新(data/dao/CourseDao.kt:59);observeByTableAndDay 同理按天过滤
  • 排序契约:observeByTable / getByTable 按 day, startNode, startWeek 升序;按天查询按 startNode 升序(data/dao/CourseDao.kt:62)
  • 两个原子事务:replaceAll(tableId, courses) = 删该表全部课程再插入,整表导入用;replaceGroup(tableId, groupId, newCourses) = 删同组再插入,编辑课程组用(data/dao/CourseDao.kt:81、data/dao/CourseDao.kt:88)

updateAll / insertKeepId / deleteByIds 三个方法专门服务编辑页的行级 diff 写回(仓库层 applyDiff 驱动):只动差异行,不重写整组(data/repository/ScheduleRepository.kt:281)。

TimeTableDao(data/dao/TimeTableDao.kt:12)

标准增删改查:insert / insertAll / update / deleteById / deleteAll / getById / observeById / getAll / count。两个特殊的:

  • observeAll 按 createdAt DESC 返回,新建的表排最前(data/dao/TimeTableDao.kt:37)
  • setDefault(id) 用一条 SQL 把全库 isDefault 重排——UPDATE time_tables SET isDefault = (id = :id),保证任何时刻至多一行是默认表(data/dao/TimeTableDao.kt:46);getDefault 取那一行(data/dao/TimeTableDao.kt:49)

实体到卡片:谁在转换

这层没有独立 DTO。UI 直接消费 CourseEntity:ScheduleState 持有 List<CourseEntity>(ui/screen/schedule/ScheduleViewModel.kt:24),网格组件的签名也是 courses: List<CourseEntity>(ui/component/CourseTableView.kt:105)。「转换」分散成三步,每步都有明确归属:

DB 行 (CourseEntity)
  │ ① inWeek(week) 过滤本周          实体自带方法
  │ ② normalizeNode(timeJson) 归位   实体自带方法,委托 TimeTableUtils.timeToNode
  ▼
本周课程列表
  │ ③ 渲染时按需取:
  │    颜色  → CourseColorUtil(三态决策树)
  │    名字  → CourseDisplayUtil.displayName(alias 开关)
  │    时间  → CourseEntity.nodeString / TimeTableUtils.courseTimeParts
  │    节次表 → TimeTableUtils.timeSlotsFor(timeJson) → TimeSlot
  ▼
屏幕上的卡片

第①②步的典型现场:ScheduleViewModel.currentWeekCourses 先 filter { it.inWeek(selectedWeek) } 再 map { c -> c.normalizeNode(tj) }(ui/screen/schedule/ScheduleViewModel.kt:35);同样的组合出现在周视图分页(ui/screen/schedule/ScheduleScreen.kt:236)、今日页(ui/screen/today/TodayScreen.kt:70)、详情弹层(ui/component/CourseDetailSheet.kt:84)和各桌面组件(widget/WeekViewWidget.kt:100 等)。

唯一的例外是时间栏:数据来自 timeJson 而非课程行,由 timeSlotsFor 转成 UI 模型 TimeSlot(label, start, end, displayStart, displayEnd, nodeStart, nodeEnd)(ui/component/CourseTableView.kt:69)。跨节次空隙的渲染占位行 label 为空(isPlaceholder),只显示时间,绝不写回 timeJson(ui/component/CourseTableView.kt:86)。

别名解析的归属也单一:CourseDisplayUtil.displayName(course, useAlias)——开关关了给原名,开了取 trim 后的别名,空别名回退原名(util/CourseDisplayUtil.kt:18)。

WeekRangeOverlap 的编码规则

两个周次区间(含单双周类型)有没有公共上课周,判定函数是顶层函数 weekRangesOverlap(util/WeekRangeOverlap.kt:5):

fun weekRangesOverlap(
    aStart: Int, aEnd: Int, aType: Int,
    bStart: Int, bEnd: Int, bType: Int
): Boolean

算法分两步(util/WeekRangeOverlap.kt:9):

  1. 取区间交集 [max(aStart, bStart), min(aEnd, bEnd)],交集为空直接 false
  2. 交集中的每个周,双方各自按 type 命中(1=奇数周、2=偶数周、0/3 及其他=区间内皆命中),存在一个周双双命中即 true

type 编码与 CourseEntity.inWeek 完全一致,奇偶同样按绝对周号判定(util/WeekRangeOverlap.kt:4)。

当前唯一调用点在编辑页的冲突校验:两节课先比星期几交集、再比分钟区间重叠,都撞上了才调 weekRangesOverlap——周次不重叠就不算冲突,一门单周配一门双周,时间撞了也放行(ui/screen/edit/AddCourseScreen.kt:879)。

相关页面

Clone this wiki locally