冲突检测与坐标查询:ArkTS 排课逻辑在鸿蒙的实战


实例:课程表管理(Course)|技术:二维坐标查询、冲突检测、网格数据流
一、课程表查询的三个层次
课程表的查询体系是**「坐标定位」**的:queryAll(全量排序)→ queryByDay(按天)→ checkConflict(坐标查重)。本篇文章逐一深入,重点讲 冲突检测 与 二维坐标查询——排课系统的核心逻辑。
二、queryAll:课表数据源(双键排序)
课表渲染需要全部课程按「周几 + 节次」排序:
static async queryAll(context: common.Context): Promise<Course[]> {
const store = await CourseDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(CourseDao.TABLE);
predicates.orderByAsc('weekday').orderByAsc('section');
const result = await store.query(predicates);
return CourseDao.collect(result);
}
SELECT * FROM course ORDER BY weekday ASC, section ASC;
双键排序:先按 weekday(周一~周日)再按 section(第 1~8 节)——排序结果天然是「课表扫描顺序」。为什么页面还要 filter? queryAll 给出全局有序数据,页面按 c.weekday === currentDay && c.section === section 过滤当前格子的课程——**「数据库排序 + 页面过滤」**分工:排序数据库做(高效),过滤页面做(数据已在内存)。
queryAll 的双键排序 + 页面 filter 的模式:数据量小(14 门)时一次取回全部、页面自由筛选——比「每次切换星期都查库」流畅(无网络/磁盘延迟,纯内存 filter)。
三、queryByDay:按天查询(等值 + 节次升序)
static async queryByDay(context: common.Context, weekday: number): Promise<Course[]> {
const store = await CourseDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(CourseDao.TABLE);
predicates.equalTo('weekday', weekday).orderByAsc('section');
const result = await store.query(predicates);
return CourseDao.collect(result);
}
SELECT * FROM course WHERE weekday = 1 ORDER BY section ASC;
等值 + 排序:equalTo('weekday', weekday) 过滤某天 + orderByAsc('section') 按节次排——「周一课程按节次列出来」。命中联合索引:idx_course_grid (weekday, section)——weekday 等值定位 + section 排序都在索引内(覆盖排序)。
queryByDay 与页面的 dayCourses 的关系:落地页面用 dayCourses(内存 filter)而非 queryByDay(查库)——因为数据已在 queryAll 里。queryByDay 保留为「按天拉取」的独立 API(未来数据量大时分页加载某天)。
四、checkConflict:冲突检测(核心方法)
冲突检测是课程表区别于所有前面实例的独特方法:
/** 冲突检测:同一天同一节次是否已有课 */
static async checkConflict(context: common.Context, weekday: number, section: number, excludeId: number): Promise<boolean> {
const store = await CourseDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(CourseDao.TABLE);
predicates.equalTo('weekday', weekday).equalTo('section', section);
if (excludeId > 0) {
predicates.notEqualTo('id', excludeId);
}
const result = await store.query(predicates);
const has = result.goToNextRow();
result.close();
return has;
}
-- 排除自身(编辑场景)
SELECT * FROM course WHERE weekday = 1 AND section = 3 AND id != 5;
逐行拆解:
1. 同坐标查重:equalTo('weekday', weekday).equalTo('section', section)——查「(weekday, section) 这个格子有没有记录」。坐标查重 = 二维等值 AND:两个坐标都相等才算同一格。
2. excludeId 排除自身:编辑课程换时间段时(把周一第 1 节移到周二第 2 节),查询会命中「课程自己原来占的格子」——不排除会永远报冲突。excludeId > 0 守卫:新增传 0(不排除),编辑传自身 id(排除)——一个方法兼顾新增与编辑。
3. 返回 boolean:goToNextRow() 有行即冲突——「查第一行即可」:冲突检测不需要遍历全部,一行命中即 true。
4. 为什么用「查询」而非「UNIQUE 约束」? 两种方案都能防冲突:
- UNIQUE (weekday, section) 约束:数据库硬性拒绝,插入抛异常——但编辑换时间也会被旧记录挡住(需先删旧记录或事务),灵活度低、报错不友好;
- 应用层 checkConflict:查询 → 页面判断 → Toast 提示「该时段已有课程,冲突!」——「检测-提示-阻止」的友好体验,编辑可自由换时间(excludeId 排除自己)。
落地页面的调用:
async onSave(): Promise<void> {
if (!this.fName.trim()) {
promptAction.showToast({ message: '课程名必填' });
return;
}
const conflict = await CourseDao.checkConflict(this.context, this.fWeekday, this.fSection, 0);
if (conflict) {
promptAction.showToast({ message: '⚠️ 该时段已有课程,冲突!' });
return;
}
// ... insert
}
保存前先检测:课程名校验(必填)→ 冲突检测(坐标查重)→ 通过才 insert——「校验链」:先格式后业务。
五、insert/update:全量写
static async insert(context: common.Context, c: Course): Promise<number> {
const store = await CourseDao.getStore(context);
const values: relationalStore.ValuesBucket = {
name: c.name, teacher: c.teacher, location: c.location,
weekday: c.weekday, section: c.section, color: c.color,
weeks: c.weeks, remark: c.remark,
};
return await store.insert(CourseDao.TABLE, values);
}
insert 不内置冲突检测——检测在页面层(checkConflict 调用),DAO 只做「写入」。「检测与写入分离」:checkConflict 是查询方法(可单独复用),insert 是写方法(不做业务判断)——职责单一,页面组合它们。
update 全量更新(编辑课程全部字段覆盖)——与便签/影音同款模式。
六、count 与网格数据流
static async count(context: common.Context): Promise<number> {
const store = await CourseDao.getStore(context);
const result = await store.querySql(`SELECT COUNT(*) AS c FROM ${CourseDao.TABLE}`);
let total = 0;
if (result.goToNextRow()) {
total = result.getLong(result.getColumnIndex('c'));
}
result.close();
return total;
}
完整数据流(页面):
aboutToAppear → refresh
→ initSeedData(首启填 14 门)
→ queryAll(全部课程,weekday+section 排序)
→ courses 状态
切换星期 → currentDay 变化 → 页面 filter(dayCourses / 格子 filter)
点格子 → showCourseMenu(详情/删除)
添加 → 校验链 → checkConflict → insert → refresh
核心链路:queryAll 一次拉全 → 页面按坐标 filter——课表网格的渲染完全在内存进行,切换星期零延迟。「一次拉取 + 内存过滤」是网格类页面的标准数据流(数据量中等时)。
七、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 坐标查重 | 双 equalTo AND | 冲突检测 |
| 排除自身 | notEqualTo(‘id’, excludeId) | 编辑不误报 |
| 双键排序 | weekday + section ASC | 课表顺序 |
| 联合索引 | idx_course_grid | 坐标查询加速 |
| 检测与写分离 | checkConflict + insert 独立 | 职责单一 |
| 校验链 | 必填 → 冲突 → 写入 | 友好拦截 |
八、常见问题 FAQ
Q1:冲突检测为什么不用 UNIQUE 约束?
A:UNIQUE 是「硬拒绝」(插入抛异常),应用层是「软提示」(Toast 告知)。课程表需要编辑换时间的场景——UNIQUE 会挡住编辑(旧记录占着格子),应用层用 excludeId 优雅排除。**「软提示 + 灵活编辑」优先于「硬约束」**是交互型应用的选择;纯数据完整性场景才用 UNIQUE。
Q2:连堂课(两节连上)怎么处理冲突?
A:连堂是两条记录(section=1 和 section=2)——冲突检测分别对两个格子查重。「连堂 = 相邻格子的多条记录」,每格独立检测,天然无冲突问题(除非两门课抢同一格)。
Q3:为什么页面用 filter 而不用 queryByDay?
A:数据已由 queryAll 一次拉全在内存,filter 是纯内存操作(微秒级);queryByDay 是数据库查询(毫秒级 + 返回 Promise 异步)。切换星期用 filter 零延迟、无闪烁。「数据已在手就内存处理」——queryByDay 留给「数据量大需按天拉取」的未来。
Q4:checkConflict 返回 boolean,如果还想知道是哪门课冲突呢?
A:boolean 够用(页面只要「有没有冲突」)。想显示「与高等数学冲突」可改返回 Course|null——查询逻辑不变,返回第一行实体。按需求调整返回粒度,boolean 是「只需判断」的最小返回。
Q5:excludeId 传 0 的语义是什么?
A:0 是「无排除」的哨兵值(id 从 1 自增,不存在 id=0)——excludeId > 0 守卫让新增(传 0)不排除任何课程,编辑(传自身 id)排除自己。哨兵值 0 表示「不启用该参数」——与健康数据 status=-1 同模式。
Q6:课表数据量大了(几百门课)怎么办?
A:queryAll 全拉 + filter 会变慢(每次渲染全量过滤)。优化方向:切换星期改 queryByDay(数据库 WHERE 过滤)+ 列表懒加载。「一次拉取」适用于中小数据量,大数据量回退「按需查询」——架构预留了 queryByDay。
九、文章小结
本篇文章深入讲解了课程表的数据层核心:冲突检测(同坐标双等值查重 + excludeId 排除自身的哨兵模式)、坐标查询(双键排序 + 联合索引加速)、「检测与写入分离」的职责划分、「一次拉取 + 内存 filter」的网格数据流。核心心法是「二维坐标 = 双等值 AND」与「应用层软提示优于硬约束」——排课系统的通用逻辑。
下一篇(15-4)展示 14 门课程种子数据,让课表网格一开屏就格子饱满。
十、checkConflict 深入:为什么不用 UNIQUE 约束
UNIQUE 约束的方案长什么样? 建表时加 UNIQUE(weekday, section),由数据库硬性保证「同一天同一节次唯一」:
CREATE TABLE course (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
weekday INTEGER NOT NULL,
section INTEGER NOT NULL,
UNIQUE (weekday, section)
);
插入冲突时 store.insert 抛 SqliteException(SQLITE_CONSTRAINT_UNIQUE)。表面更「安全」,实战却有三个痛点:
| 痛点 | UNIQUE 约束 | 应用层 checkConflict |
|---|---|---|
| 新增冲突 | 抛异常,需 try/catch 解析 | Toast 直接提示「该时段已有课程」 |
| 编辑换时间 | 旧记录占原格,插新坐标必冲突,需先删旧再插(两步 + 事务) | excludeId 排除自身,一步到位 |
| 报错体验 | 异常信息对用户不可读 | 提示文案完全可控 |
excludeId 的两个编辑场景数据流:
场景 A(坐标不变,只改课程名):课程 A(id=3)在 (2,2)
checkConflict(weekday=2, section=2, excludeId=3)
SQL: WHERE weekday=2 AND section=2 AND id != 3
→ 不排除会命中 A 自己 → 误报冲突,改个名字都保存不了
场景 B(换时间):(1,1) → (2,2)
→ 检测发生在 update 前后任一时刻,记录可能已在新格
→ excludeId 让「检测」与「被编辑记录」解耦,两种场景都不误报
excludeId 的本质:检测的是「除当前编辑记录外的其他记录」——「排除自己再查重」是编辑场景冲突检测的通用范式。
十一、坐标查询的联合索引命中分析
idx_course_grid (weekday, section) 的 B+ 树按「weekday 升序、同 weekday 内 section 升序」组织——索引结构本身就是课表顺序。逐条分析本篇的查询:
| 查询 | 谓词/排序 | 索引命中 | 分析 |
|---|---|---|---|
| queryByDay | WHERE weekday=1 ORDER BY section | ✅ 完全命中 | 等值定位周一子树,section 已有序,覆盖排序、零回表 |
| checkConflict | WHERE weekday=? AND section=? | ✅ 完全命中 | 双等值在索引内定位到单叶子,取第一行即止 |
| queryAll | ORDER BY weekday, section | ✅ 索引扫描 | 索引顺序 = 排序顺序,顺序扫描即有序结果 |
| 反例:只看节次 | WHERE section=3 | ❌ 未命中 | 违反最左前缀,section 非首列,退化为全表扫描 |
最左前缀原则:联合索引 (weekday, section) 只能加速「以 weekday 开头」的查询。所以冲突检测传参刻意是 (weekday, section) 而非 (section, weekday)——参数顺序对齐索引列序,查询才能命中。这也是为什么 checkConflict 签名把 weekday 放前面、queryByDay 也按此顺序组织。
十二、queryAll 双键排序与页面 filter 的数据流
DB 层:queryAll 按 (weekday ASC, section ASC) 返回全局有序数组
↓ courses: Course[](一次拉全,仅一次排序)
页面层:currentDay 状态驱动纯内存过滤
↓ dayCourses = courses.filter(c => c.weekday === currentDay)
↓ 格子 filter:courses.filter(c => c.weekday === d && c.section === s)
渲染层:网格按坐标取数,空格显示「无课」占位
| 层 | 职责 | 数据形态 |
|---|---|---|
| 数据库 | 排序(ORDER BY 双键,一次) | 14 条有序记录 |
| 页面状态 | 缓存全部 + 当前星期 | courses / currentDay |
| filter | 坐标过滤(纯内存、无异步、微秒级) | dayCourses / 格子课程 |
| 渲染 | 按格取值 | 色块 + 课程名 |
分层的意义:数据库只做一次排序,页面反复 filter 无成本;切换星期只改 currentDay 一个状态——「排序下沉到 DB、过滤上浮到页面」,中间零二次查询、零闪烁。
十三、FAQ 补充
Q7:联合索引能改成 (section, weekday) 吗?
A:不能。本应用所有查询都是「按天定位」(weekday 在前),(section, weekday) 会让 queryByDay、checkConflict 全部退化为全表扫描。索引列序必须对齐最频繁的查询形态,而不是反过来迁就查询。
Q8:编辑换时间后,旧坐标的课程会残留吗?
A:不会。update 是全量覆盖——旧坐标字段被新坐标直接覆盖,同一条记录换位置。「换坐标 = 更新两个字段」,无需删除重建;而 UNIQUE 方案必须「先删旧记录再插新坐标」,中间有空窗,这正是放弃 UNIQUE 的另一个原因。
Q9:checkConflict 只取第一行,为什么不用 COUNT?
A:goToNextRow() 命中一行即返回 true,代价是「读到一行」;COUNT 要扫完所有命中行。「存在性判断取第一行,数量统计才用 COUNT」——这是 SQL 存在性查询的通用写法,同样适用于 count() 方法之外的所有「有没有」判断。
更多推荐




所有评论(0)