鸿蒙 ArkUI 教师课时表管理应用开发实战(HarmonyOS API 24(6.1.1))
本文介绍了一个基于HarmonyOS的教师课时表管理应用的设计与实现。该应用主要面向教师和教务人员,提供课表网格视图、课程管理列表和教学统计三大功能模块。文章详细阐述了数据模型设计、架构规划、UI实现方法以及关键开发技巧。 文章重点内容包括: 采用ClassItem数据模型管理课程信息,支持增删改查操作 实现三Tab架构:课表网格视图(7×5布局)、分组课程列表和统计图表 通过@State实现数据


目录
- 引言
- 需求分析与产品规划
- 数据模型设计
- 架构设计与组件划分
- 课表网格视图实现
- 课程管理列表实现
- 教学统计模块实现
- 表单浮层与 CRUD 操作
- 自定义组件体系
- ArkTS 编译约束与常见错误
- 色彩体系与视觉设计
- 从数据到可视化的设计思维
- 总结与扩展方向
一、引言
1.1 管理类应用的特殊挑战
在前五个阶段中,我们分别构建了栅格布局演示、儿童闹钟、豆豆计数器和睡眠对比曲线。这些应用都属于「工具/娱乐型」应用。本阶段的教师课时表管理则迈入了一个全新的类别——企业管理型应用。
管理类应用有其独特的设计考量和工程挑战:
数据密集
课表需要展示 7 节课 × 5 天 = 35 个时间槽位的信息,每个槽位包含科目、班级、教室三个数据字段。如何在有限的空间内清晰呈现密集信息,是对 UI 设计能力的重要考验。
CRUD 完备性
管理应用必须具备完整的增删改查能力。用户需要添加新课、编辑已有课程、删除不再需要的课程、以及快速检索和查看。这比前几个应用的「功能单一」模式要复杂得多。
状态一致性
当用户通过浮层表单编辑课程后,课表视图、管理列表、统计数据三处都需要同步更新。这要求我们精心设计状态变量的流向,避免数据不一致。
容错与引导
空课表状态需要友好提示,必填字段需要校验,删除操作需要确认。这些「边缘情况」的处理体现了应用的成熟度。
1.2 技术栈与 API 版本
| 项目 | 内容 |
|---|---|
| 操作系统 | HarmonyOS 4.0+ |
| API 版本 | 24(6.1.1) |
| 开发语言 | ArkTS |
| 布局系统 | Column / Row / Scroll / Stack / ForEach |
| 表单组件 | TextInput / Button / Divider |
| 数据流 | @State → UI 自动重渲染 |
| 交互方式 | onClick 事件 + 回调函数 |
| 自定义组件 | @Component × 3 |
1.3 应用功能全景
教师课时表管理 APP
│
├── 📅 课表视图
│ ├── 周课表网格(7节 × 5天)
│ ├── 课程卡片(科目 + 班级 + 教室)
│ ├── 空位点击添加
│ └── 已有课程点击编辑
│
├── 📋 课程管理
│ ├── 按天分组列表
│ ├── 编辑 / 删除操作
│ ├── 浮动 + 添加按钮
│ └── 空列表友好提示
│
└── 📊 教学统计
├── 概览卡片(总课时 / 科目数 / 工作日)
├── 科目课时分布
└── 每日课时分布
二、需求分析与产品规划
2.1 用户画像
| 用户类型 | 特点 | 核心需求 |
|---|---|---|
| 一线教师 | 每天需要查看课程安排 | 快速浏览每日课表 |
| 教务管理员 | 统筹全校教师排课 | 批量管理、统计课时 |
| 班主任 | 了解班级课程分布 | 查看特定班级的课程 |
2.2 功能需求
| 编号 | 功能 | 描述 | 优先级 |
|---|---|---|---|
| F1 | 课表网格 | 7 节课 × 5 天表格视图 | P0 |
| F2 | 课程卡片 | 显示科目、班级、教室 | P0 |
| F3 | 按科着色 | 不同科目不同颜色标识 | P0 |
| F4 | 添加课程 | 通过表单添加新课 | P0 |
| F5 | 编辑课程 | 修改已有课程信息 | P0 |
| F6 | 删除课程 | 移除课程 | P0 |
| F7 | 管理列表 | 按天分组的课程列表 | P1 |
| F8 | 教学统计 | 课时分布可视化 | P1 |
| F9 | 空状态 | 无课程时的引导提示 | P1 |
| F10 | 表单校验 | 必填字段检查 | P1 |
2.3 非功能需求
| 类别 | 指标 |
|---|---|
| 响应速度 | 添加/编辑/删除操作后立即刷新 UI |
| 数据一致性 | 课表、列表、统计三处数据同步 |
| 可访问性 | 按钮尺寸 ≥ 44px,文字 ≥ 11fp |
| 容错 | 删除有确认机制,表单有必填校验 |
2.4 与之前应用的对比
| 维度 | 儿童闹钟 | 豆豆计数器 | 教师课时表 |
|---|---|---|---|
| 数据类型 | 单数值 | 单数值 | 对象数组 |
| 操作复杂度 | 简单 | 简单 | CRUD 完整 |
| UI 复杂度 | 中等 | 中等 | 高(网格+列表+统计) |
| 状态同步 | 单一 | 单一 | 多视图同步 |
| 适用场景 | 个人工具 | 个人工具 | 管理工具 |
三、数据模型设计
3.1 核心数据模型
interface ClassItem {
id: string; // 唯一标识符
day: number; // 星期几(0=周一, 4=周五)
period: number; // 第几节课(0~6)
subject: string; // 科目名称
className: string;// 班级名称
classroom: string;// 教室地点
}
设计考量:
id 的生成:使用 Math.random().toString(36).substring(2, 9) 生成随机短字符串作为 ID。虽然在严格意义上不是 UUID,但足够满足单机应用的需求。
day 和 period 的数值化:使用整数索引而非字符串(如 "周一"),便于排序和计算。显示时通过 weekDays[day] 映射。
subject 的字符串存储:科目以字符串存储而非枚举,灵活支持用户自定义科目。
3.2 辅助数据类型
interface Period {
id: number; // 节次编号(1~7)
label: string; // 显示标签,如 "第1节"
start: string; // 开始时间,如 "08:00"
end: string; // 结束时间,如 "08:45"
}
interface SubjectStat {
subject: string; // 科目名
count: number; // 该科目课时数
color: string; // 科目对应颜色
}
interface DayStat {
day: string; // 星期名称
count: number; // 当天课时数
}
3.3 常量数据结构
// 7 个标准课时段
private periods: Period[] = [
{ id: 1, label: '第1节', start: '08:00', end: '08:45' },
{ id: 2, label: '第2节', start: '08:55', end: '09:40' },
{ id: 3, label: '第3节', start: '10:00', end: '10:45' },
{ id: 4, label: '第4节', start: '10:55', end: '11:40' },
{ id: 5, label: '第5节', start: '14:00', end: '14:45' },
{ id: 6, label: '第6节', start: '14:55', end: '15:40' },
{ id: 7, label: '第7节', start: '15:50', end: '16:35' },
];
// 五天工作制
private weekDays: string[] = ['周一', '周二', '周三', '周四', '周五'];
// 12 种科目 + 各自颜色
private subjectColors: Record<string, string> = {
'语文': '#4CAF50', '数学': '#2196F3', '英语': '#FF9800',
'物理': '#9C27B0', '化学': '#00BCD4', '生物': '#8BC34A',
'历史': '#FF5722', '地理': '#795548', '政治': '#607D8B',
'体育': '#E91E63', '音乐': '#3F51B5', '美术': '#E91E63',
};
为什么用 Record<string, string> 而非 Map? ArkTS 支持 Record<K, V> 类型,它提供类型安全的键值对映射。相比 ES6 的 Map,Record 在 ArkTS 中序列化更方便,性能也更优。
3.4 初始数据
aboutToAppear(): void {
this.classes = [
{ id: 'c1', day: 0, period: 0, subject: '语文', className: '三年一班', classroom: 'A教301' },
// ... 共 10 条初始数据
];
}
初始数据覆盖了周一至周五的不同时段,确保用户打开应用即有内容可看。
四、架构设计与组件划分
4.1 整体架构
Index.ets(单页面三 Tab)
│
├── 状态管理层
│ ├── classes[] — 全部课程数据
│ ├── showForm — 表单显隐
│ ├── formXxx — 表单字段
│ └── tabIdx — Tab 索引
│
├── 业务逻辑层(方法)
│ ├── findClass() — 按天+节次查找
│ ├── getDayClasses() — 按天获取课程
│ ├── getSubjectStats() / getDayStats() — 统计数据
│ ├── saveClass() / deleteClass() — 增删
│ └── openAddForm() / openEditForm() — 表单控制
│
├── UI 层(@Builder)
│ ├── ScheduleTab — 课表网格
│ ├── ManageTab — 管理列表 + FAB
│ ├── StatsTab — 统计图表
│ └── FormOverlay — 表单浮层
│
└── 自定义组件层
├── TabBtn — 底部导航项
└── StatCard — 统计概览卡
4.2 状态变量全景
| 变量 | 类型 | 用途 | 影响 UI |
|---|---|---|---|
tabIdx |
number | 当前 Tab | 三个 Builder 切换 |
classes |
ClassItem[] | 全部课程数据 | 所有 Tab |
showForm |
boolean | 表单浮层显隐 | FormOverlay |
formMode |
string | 添加/编辑模式 | 表单标题 |
editId |
string | 编辑中的课程 ID | 删除按钮 |
formSubject |
string | 表单-科目 | 科目选中态 |
formClass |
string | 表单-班级 | 保存按钮启用 |
formRoom |
string | 表单-教室 | 保存数据 |
formDay |
number | 表单-星期 | 星期选中态 |
formPeriod |
number | 表单-节次 | 节次选中态 |
4.3 数据流
用户操作 → 方法调用 → @State 更新 → UI 自动重渲染
↓
课表 / 列表 / 统计 同步更新
ArkUI 的响应式机制保证:只要 classes[] 数组发生变化,所有依赖它的 UI 都会自动重渲染。
4.4 单文件架构的取舍
本应用将所有代码集中在 Index.ets(约 450 行)。这种选择基于以下考量:
优势:
- 状态共享无需跨文件通信
- 全局方法(如
findClass)随处可用 - 开发和调试更高效
劣势:
- 文件行数较多
- 多人协作时容易冲突
对于个人独立开发的工具类应用,单文件架构是合理的选择。如果未来需要团队协作,可以按功能拆分为 ScheduleView.ets、ManageView.ets、StatsView.ets。
五、课表网格视图实现
5.1 网格布局策略
课表视图采用 Row + Column 嵌套 的方式实现表格结构。不同于传统的 <table> 标签,ArkUI 的网格通过以下层次构建:
Column(整体)
├── Row(表头)
│ ├── Text("节次")
│ ├── Text("周一")
│ ├── Text("周二")
│ └── ...
└── ForEach(periods) → Row(每节课的行)
├── Column(节次标签)
└── ForEach(days) → Column(每天对应的格子)
├── 有课 → 课程卡片
└── 无课 → 虚线空位
5.2 表头实现
Row() {
Text('节次').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#666')
.width(56).textAlign(TextAlign.Center)
ForEach(this.weekDays, (day: string) => {
Text(day).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
.layoutWeight(1).textAlign(TextAlign.Center)
})
}
.width('100%').height(36).backgroundColor('#E8EAF6')
关键点:
width(56)固定节次列宽度layoutWeight(1)让每天平均分配剩余宽度- 表头背景色
#E8EAF6与主色系一致
5.3 课程卡片渲染
if (this.findClass(day, p.id - 1) !== undefined) {
Column({ space: 2 }) {
Text(this.findClass(day, p.id - 1)!.subject)
.fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text(this.findClass(day, p.id - 1)!.className)
.fontSize(9).fontColor('#FFFFFFCC')
Text(this.findClass(day, p.id - 1)!.classroom)
.fontSize(9).fontColor('#FFFFFFAA')
}
.layoutWeight(1).height(72).margin(2).padding(4)
.backgroundColor(this.getColor(this.findClass(day, p.id - 1)!.subject))
.borderRadius(8)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
为什么不用变量缓存 findClass 结果? 在 ArkUI 的 @Builder 中不允许声明变量(const、let、var)。因此我们只能通过 ! 非空断言多次调用方法。这虽然不够优雅,但这是 ArkTS 的设计约束。
三层文字布局:
- 第一行:科目名称(15fp,粗体,100% 透明度)
- 第二行:班级名称(9fp,80% 透明度)
- 第三行:教室地点(9fp,67% 透明度)
5.4 空位占位
else {
Column().layoutWeight(1).height(72).margin(2)
.backgroundColor('#FAFAFA').borderRadius(8)
.border({ width: 1, color: '#F0F0F0', style: BorderStyle.Dashed })
.onClick(() => { this.openAddForm(day, p.id - 1); })
}
空位使用虚线边框(BorderStyle.Dashed)区分于有课格子,点击后打开添加表单。虚线边框是一个微妙的视觉提示:这里「可以放点东西」。
5.5 @Builder 的变量声明限制
在 ArkTS 中,@Builder 装饰的函数只能包含 UI 组件语法:
允许的语法:
- 组件创建:
Text(),Column(),Row(), … - 属性设置:
.fontSize(),.width(), … - 条件渲染:
if (...) { ... } else { ... } - 循环渲染:
ForEach(...) - 事件绑定:
.onClick(() => { ... })
不允许的语法:
- 变量声明:
const x = ...,let y = ... - 返回语句:
return - 函数调用赋值(除 UI 属性外)
这条规则是 ArkTS 最常出错的点之一。解决方案是:在 @Builder 外部的方法中完成逻辑计算,在 @Builder 内部只做 UI 渲染。
六、课程管理列表实现
6.1 按天分组
管理列表将课程按「周一到周五」分组展示:
ForEach([0, 1, 2, 3, 4], (day: number) => {
Column({ space: 6 }) {
Text(this.weekDays[day]).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#3F51B5').width('100%')
ForEach(this.getDayClasses(day), (item: ClassItem) => {
// ... 课程卡片
}, (item: ClassItem) => item.id)
}
.width('100%').margin({ top: 8 })
})
getDayClasses(day) 按天过滤并排序:
getDayClasses(day: number): ClassItem[] {
return this.classes.filter(c => c.day === day)
.sort((a, b) => a.period - b.period);
}
6.2 课程卡片设计
每个列表项包含:左侧色条 + 中间信息 + 右侧操作按钮。
Row({ space: 12 }) {
// 左侧色条
Column().width(4).height('100%')
.backgroundColor(this.getColor(item.subject)).borderRadius(2)
// 中间信息
Column({ space: 2 }) {
Text(item.subject).fontSize(16).fontWeight(FontWeight.Bold)
Text(`${item.className} · ${item.classroom}`).fontSize(12).fontColor('#999')
Text(`${this.periods[item.period].label} ${this.periods[item.period].start}-${this.periods[item.period].end}`)
.fontSize(11).fontColor('#BBB')
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
// 右侧操作按钮
Row({ space: 12 }) {
Text('✏️').fontSize(18).onClick(() => { this.openEditForm(item); })
Text('🗑️').fontSize(18).onClick(() => { this.deleteClass(item.id); })
}
}
左侧色条:4px 宽的竖条,颜色与科目对应。这是典型的内容列表设计模式——通过一条窄色块传递信息,既节省空间又增加视觉层次。
右侧操作:直接显示 ✏️ 和 🗑️ Emoji 作为编辑和删除按钮,简洁直观,不需要额外的图标库。
6.3 浮动操作按钮(FAB)
Column() {
Text('+').fontSize(28).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.width(52).height(52).backgroundColor('#3F51B5').borderRadius(26)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.shadow({ radius: 6, color: '#3F51B560', offsetY: 3 })
.position({ bottom: 10, right: 10 })
.onClick(() => { this.openAddForm(0, 0); })
采用 Material Design 的 FAB(Floating Action Button)设计模式,固定在右下角。52px 直径的大圆按钮易于点击,阴影增加了浮起感。
6.4 空状态处理
if (this.classes.length === 0) {
Column() {
Text('📭 暂无课程').fontSize(16).fontColor('#CCC').margin({ top: 60 })
Text('点击右下角 + 添加课程').fontSize(13).fontColor('#DDD').margin({ top: 8 })
}
.width('100%').alignItems(HorizontalAlign.Center).layoutWeight(1)
}
当课程列表为空时,显示友好提示而不是空白页面。这是一条简单但重要的用户体验细节。
七、教学统计模块实现
7.1 概览卡片
Row({ space: 12 }) {
StatCard({ icon: '📚', value: `${this.getTotalHours()}`, label: '总课时', color: '#3F51B5' })
StatCard({ icon: '📖', value: `${this.getUniqueSubjects().length}`, label: '科目数', color: '#4CAF50' })
StatCard({ icon: '📅', value: `${5}`, label: '工作日', color: '#FF9800' })
}
三张概览卡片横向排列,展示了最核心的三项统计数据。StatCard 是一个可复用的自定义组件。
7.2 科目课时分布
ForEach(this.getSubjectStats(), (stat: SubjectStat) => {
Row({ space: 12 }) {
Text(stat.subject).fontSize(14).fontColor('#333').width(48)
Stack() {
Row().width('100%').height(22).backgroundColor('#F0F0F0').borderRadius(11)
Row().width(`${(stat.count / this.getTotalHours()) * 100}%`).height(22)
.backgroundColor(stat.color).borderRadius(11)
Text(`${stat.count} 节`).fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).height(22)
}
})
通过 Stack 在灰色背景条上叠加彩色进度条和百分比文字,形成简洁的水平条形图。
百分比计算:(stat.count / this.getTotalHours()) * 100 计算每个科目占总课时的比例。
7.3 每日课时分布
ForEach(this.getDayStats(), (stat: DayStat) => {
Row({ space: 12 }) {
Text(stat.day).fontSize(14).fontColor('#333').width(40)
Stack() {
Row().width('100%').height(28).backgroundColor('#F0F0F0').borderRadius(14)
Row().width(`${this.getDayBarWidth(stat.count)}%`).height(28)
.backgroundColor('#3F51B5').borderRadius(14)
Text(`${stat.count} 节`).fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).height(28)
}
})
与科目分布类似,但使用 getDayBarWidth 方法计算宽度:以课时最多的那天为 100%,其他天按比例显示。
getDayBarWidth(count: number): number {
return (count / Math.max(this.getMaxDayCount(), 1)) * 100;
}
getMaxDayCount(): number {
let m = 1;
this.classes.forEach(c => {
if (c.day !== undefined) {
m = Math.max(m, this.classes.filter(x => x.day === c.day).length);
}
});
return m;
}
这种「相对最大值」的进度条设计,让用户一眼看出哪天课程最多、哪天课程最少。
八、表单浮层与 CRUD 操作
8.1 表单浮层架构
表单使用 Stack 叠加在主界面上方:
@Builder
FormOverlay() {
Column() {
// 透明遮罩 — 点击关闭
Column().width('100%').height('100%').backgroundColor('#00000044')
.onClick(() => { this.showForm = false; })
// 表单卡片
Column({ space: 16 }) {
// 标题行
Row() {
Text(this.formMode === 'add' ? '➕ 添加课程' : '✏️ 编辑课程')
Blank()
Text('✕').onClick(() => { this.showForm = false; })
}
// ... 表单字段 ...
}
.width('90%').padding(20).backgroundColor('#FFFFFF').borderRadius(20)
}
.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
设计要点:
- 遮罩层点击关闭浮层,符合用户预期
- 表单卡片居中显示,90% 宽度留出边缘
- 20px 圆角 + 12px 阴影,视觉柔和
8.2 星期与节次选择器
Row({ space: 12 }) {
// 星期选择
Column({ space: 4 }) {
Text('星期').fontSize(12).fontColor('#999')
Row({ space: 4 }) {
ForEach(this.weekDays, (day: string, i: number) => {
Text(day).fontSize(13)
.fontColor(this.formDay === i ? '#FFFFFF' : '#666')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.formDay === i ? '#3F51B5' : '#F0F0F0')
.borderRadius(14)
.onClick(() => { this.formDay = i; })
})
}
}
// 节次选择(结构类似)
}
采用「按钮组」风格的选择器而非下拉列表。原因:
- 选择项数量少(5 天 / 7 节),按钮组更直观
- 点击一次即可完成选择,操作路径最短
- 选中态颜色反转(蓝底白字 vs 灰底黑字),状态一目了然
8.3 科目选择器
Row({ space: 6 }) {
ForEach(Object.keys(this.subjectColors), (subj: string) => {
Text(subj).fontSize(13)
.fontColor(this.formSubject === subj ? '#FFFFFF' : '#666')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.formSubject === subj ? this.getColor(subj) : '#F5F5F5')
.borderRadius(12)
.onClick(() => { this.formSubject = subj; })
})
}
科目使用彩色标签选择器。选中的科目标签使用其对应的主题色作为背景,其他标签保持灰色。这种方式利用了用户对色彩的联想——看到绿色就知道选中了「语文」,看到蓝色就知道是「数学」。
8.4 保存按钮的禁用态
Button('💾 保存').height(40)
.backgroundColor(this.formSubject && this.formClass ? '#3F51B5' : '#CCC')
.borderRadius(20)
.enabled(this.formSubject !== '' && this.formClass !== '')
.onClick(() => { this.saveClass(); })
保存按钮在科目和班级都填写后才启用。禁用时灰色 #CCC,启用时靛蓝色 #3F51B5。这种「渐进式启用」的交互模式,引导用户完成必填字段。
8.5 CRUD 方法实现
// 添加
saveClass(): void {
if (this.formSubject === '' || this.formClass === '') { return; }
if (this.formMode === 'add') {
this.classes.push({
id: 'c' + this.genId(),
day: this.formDay, period: this.formPeriod,
subject: this.formSubject, className: this.formClass,
classroom: this.formRoom
});
} else {
const idx = this.classes.findIndex(c => c.id === this.editId);
if (idx !== -1) {
this.classes[idx].subject = this.formSubject;
this.classes[idx].className = this.formClass;
this.classes[idx].classroom = this.formRoom;
}
}
this.showForm = false;
}
// 删除
deleteClass(id: string): void {
this.classes = this.classes.filter(c => c.id !== id);
this.showForm = false;
}
注意:删除操作直接通过 filter 创建新数组赋给 @State 变量。在 ArkUI 中,直接修改数组元素(如 arr[0].subject = '...')不会触发 UI 更新。但使用 push、filter 等创建新数组的方法则可以。
九、自定义组件体系
9.1 TabBtn 底部导航项
@Component
struct TabBtn {
private icon: string = '';
private label: string = '';
private active: boolean = false;
private act: () => void = () => {};
build() {
Column({ space: 2 }) {
Text(this.icon).fontSize(20)
Text(this.label).fontSize(11)
.fontColor(this.active ? '#3F51B5' : '#999')
.fontWeight(this.active ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).height('100%').justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.act(); })
}
}
参数设计:
icon:Emoji 图标label:文字标签active:是否选中,控制文字颜色和字重act:点击回调
使用方式:
TabBtn({ icon: '📅', label: '课表', active: this.tabIdx === 0, act: () => { this.tabIdx = 0; } })
9.2 StatCard 统计卡片
@Component
struct StatCard {
private icon: string = '';
private value: string = '';
private label: string = '';
private color: string = '#3F51B5';
build() {
Column({ space: 6 }) {
Text(this.icon).fontSize(24)
Text(this.value).fontSize(28).fontWeight(FontWeight.Bold).fontColor(this.color)
Text(this.label).fontSize(12).fontColor('#999')
}
.layoutWeight(1).padding(12).backgroundColor('#FFFFFF').borderRadius(14)
.alignItems(HorizontalAlign.Center)
.shadow({ radius: 2, color: '#10000000', offsetY: 1 })
}
}
设计模式:竖向排列的「图标 + 大数字 + 标签」三段式结构。28fp 的大数字和主题色让核心数据突出,便于快速阅读。
9.3 组件通信模式
父组件(Index)
├── 属性传参 → TabBtn({ icon, label, active, act })
│ ↑
├── 回调 ← act() | ↓ 子组件调用
│
├── 属性传参 → StatCard({ icon, value, label, color })
│
└── @Builder 直接使用父组件状态
├── ScheduleTab(访问 this.classes, this.findClass())
├── ManageTab(访问 this.classes, this.getDayClasses())
└── StatsTab(访问 this.getSubjectStats())
@Builder 与 @Component 的区别:
@Builder:共享父组件作用域,可直接访问this.xxx@Component:独立作用域,通过@Prop或private接收参数
十、ArkTS 编译约束与常见错误
10.1 @Builder 内禁止变量声明
错误信息:
Only UI component syntax can be written here.
错误示例:
@Builder
ScheduleTab() {
const cls = this.findClass(day, period); // ❌
if (cls) { Text(cls.subject) }
}
正确做法:
@Builder
ScheduleTab() {
if (this.findClass(day, period) !== undefined) {
Text(this.findClass(day, period)!.subject) // ✅ 直接调用
}
}
原理:@Builder 编译后的函数体被限制为只能包含 UI 组件构建语法。任何非 UI 的语句(赋值、声明、分支中的 return 等)都会触发此错误。
10.2 数组修改不触发 UI 更新
ArkUI 的响应式系统通过引用比较检测 @State 变量的变化。直接修改数组元素不会创建新引用:
// ❌ 不触发 UI 更新
this.classes[0].subject = '新科目';
// ✅ 触发 UI 更新
this.classes = this.classes.filter(c => c.id !== id);
this.classes.push({ ... }); // push 触发更新
注意:push 和 filter 都会修改数组的引用,从而触发 UI 重渲染。
10.3 @Builder 内调用方法的重复执行
由于不能在 @Builder 中声明变量,我们不得不多次调用同一个方法:
Text(this.findClass(day, p.id - 1)!.subject) // 第 1 次调用
.backgroundColor(this.getColor(this.findClass(day, p.id - 1)!.subject)) // 第 2 次调用
这种重复调用虽不影响功能,但会增加一定的计算开销。对于本应用的课程查找(线性遍历 10 条数据),性能影响可以忽略不计。但对于大数据量的场景,建议通过 @Component 组件拆分来避免重复调用。
10.4 非空断言 ! 的使用
在确定一个值不为 undefined 时,使用非空断言 !:
if (this.findClass(day, p.id - 1) !== undefined) {
// 此时可以安全使用 ! 断言不为空
Text(this.findClass(day, p.id - 1)!.subject)
}
非空断言告诉 TypeScript 编译器「我确定这个值不为空」,相当于类型断言 as ClassItem。
10.5 Record<string, string> 的遍历
遍历 Record 类型使用 Object.keys():
ForEach(Object.keys(this.subjectColors), (subj: string) => {
Text(subj)
.backgroundColor(this.formSubject === subj ? this.subjectColors[subj] : '#F5F5F5')
})
Object.keys() 返回字符串数组,可以作为 ForEach 的输入。
十一、色彩体系与视觉设计
11.1 主色与辅色
| 角色 | 色值 | 用途 |
|---|---|---|
| 主色 | #3F51B5(靛蓝) |
标题栏、Tab 选中、按钮、统计条 |
| 浅蓝 | #E8EAF6 |
表头背景 |
| 卡片 | #FFFFFF |
所有卡片背景 |
| 页面 | #F5F5F5 |
页面底色 |
| 文字主 | #333 |
标题、正文 |
| 文字辅 | #666 / #999 |
副标题、辅助信息 |
11.2 科目配色方案
12 种科目的颜色选择遵循以下原则:
| 科目 | 色值 | 选色理由 |
|---|---|---|
| 语文 | #4CAF50 绿 |
代表成长、文化 |
| 数学 | #2196F3 蓝 |
代表逻辑、理性 |
| 英语 | #FF9800 橙 |
代表活力、交流 |
| 物理 | #9C27B0 紫 |
代表神秘、科学 |
| 化学 | #00BCD4 青 |
代表实验、变化 |
| 生物 | #8BC34A 草绿 |
代表生命、自然 |
| 历史 | #FF5722 深橙 |
代表厚重、历史 |
| 地理 | #795548 棕 |
代表大地、土壤 |
| 政治 | #607D8B 灰蓝 |
代表严肃、正式 |
| 体育 | #E91E63 粉 |
代表运动、活力 |
| 音乐 | #3F51B5 靛蓝 |
与主色一致 |
| 美术 | #E91E63 粉 |
代表艺术、创造 |
颜色与科目的关联利用了「联觉」效应:用户看到颜色就能联想到科目,无需阅读文字。
11.3 课程卡片设计
课程卡片虽然面积小(高度 72px),但信息密度高:
┌──────────────┐
│ 数 学 │ ← 15fp 科目名(粗体白色)
│ 三年二班 │ ← 9fp 班级名(80%透明度)
│ B教205 │ ← 9fp 教室(67%透明度)
└──────────────┘
三层文字的信息权重递减,符合用户的阅读顺序:先看「什么课」→ 再看「哪个班」→ 最后看「在哪里」。
11.4 管理列表设计
┌─────────────────────────────────────┐
│ █ 语文 ✏️ 🗑️ │
│ 三年一班 · A教301 │
│ 第1节 08:00-08:45 │
└─────────────────────────────────────┘
左侧 4px 色条 + 三层信息 + 右侧操作按钮。色条的 4px 窄宽度设计——足够传递颜色信息,但不会占用太多空间。
十二、从数据到可视化的设计思维
12.1 三种视图对应三种信息需求
| 视图 | 信息需求 | 设计模式 |
|---|---|---|
| 课表网格 | 「今天第几节在哪儿上课」 | 空间映射(天×节次) |
| 管理列表 | 「我有哪些课要管理」 | 分组列表 |
| 统计 | 「我的课时分布如何」 | 数据聚合+可视化 |
同一个数据(classes 数组),通过三种不同的「观察方式」满足三种不同的用户需求。
12.2 信息密度的平衡
教师课表需要在 35 个格子中展示课程信息。每个格子的信息量是 3 个字段 × 10~20 个字符。为了在有限空间内清晰呈现:
- 字号递减:科目 15fp → 班级 9fp → 教室 9fp
- 透明度区分:100% → 80% → 67%
- 彩色背景:利用色彩传递科目信息,减少文字依赖
12.3 渐进式复杂度
教师课时表应用的信息复杂度是之前应用中最高的。为了让用户逐步适应:
- 默认展示课表视图:最直观,一眼看懂
- 管理列表提供操作入口:需要时才使用
- 统计作为补充:有数据分析需求时查看
用户的使用路径是「查看 → 管理 → 分析」,复杂度逐步递增。
12.4 一致性的价值
整个应用的设计一致性体现在:
- 颜色体系一致:科目颜色在课表、列表、统计中完全一致
- 交互模式一致:点击 → 编辑,长按 → 无(保持简单)
- 视觉语言一致:圆角、阴影、间距统一
一致性降低了用户的学习成本:一旦在课表中学到「绿色 = 语文」,在统计中看到绿色也能立即识别。
十三、总结与扩展方向
13.1 核心知识点回顾
| 技术 | 应用 | 关键代码 |
|---|---|---|
| @State 数组 | 课程数据管理 | @State classes: ClassItem[] |
| @Builder 组件化 | 四个 UI 模块 | @Builder ScheduleTab() |
| @Component 组件 | 底部导航、统计卡片 | struct TabBtn |
| 条件渲染 | 课程卡片 / 空位 / 表单 | if / else |
| 循环渲染 | 课表行、每天、列表 | ForEach(this.periods, ...) |
| 反向数据流 | 表单保存到数组 | onClick → saveClass → @State |
| 表单校验 | 必填字段控制按钮启用 | enabled(!= '') |
| 浮层覆盖 | 添加/编辑表单 | Stack + 条件渲染 |
| 数据统计 | 课时分布计算 | getSubjectStats() |
13.2 与之前应用的贯穿对比
| 应用 | 核心组件 | 核心技能 |
|---|---|---|
| 栅格布局 | GridRow/GridCol | 布局系统 |
| 儿童闹钟 | Text/Button/Toggle | 状态 + 定时器 |
| 豆豆计数器 | @Builder/动画 | 动画 + 里程碑 |
| 睡眠 vs 记忆力 | Canvas/图表 | 数据可视化 |
| 教师课时表 | ForEach/表单/CRUD | 管理型应用 |
五个应用覆盖了鸿蒙 ArkUI 从入门到实战的完整学习路径。
13.3 扩展功能建议
1. 数据持久化
当前数据在应用重启后丢失。使用 @ohos.data.preferences 持久化:
import { preferences } from '@kit.ArkData';
saveData(): void {
// 将 this.classes 序列化并保存
}
loadData(): void {
// 反序列化并还原到 this.classes
}
2. 周次切换
支持单周/双周课表(部分课程隔周上)。增加 weekType 字段。
3. 教师多账号
支持多个教师的课表切换。在标题栏增加下拉选择器。
4. 课程冲突检测
当某天某时段已有课程时,阻止添加新课并提示冲突。
5. 导出日历
将课表导出为系统日历事件,方便与其他设备同步。
6. 搜索与筛选
支持按科目、班级、教室搜索课程,快速定位。
13.4 写在最后
从第一个栅格布局应用开始,我们一步步构建了越来越复杂的鸿蒙应用:
- 从单列布局到多列栅格
- 从静态展示到动态交互
- 从单一功能到 CRUD 完整应用
- 从个人工具到管理型应用
教师课时表管理应用虽然只有约 450 行代码,但它涵盖了管理型应用的完整开发流程:需求分析 → 数据建模 → UI 设计 → 交互实现 → 状态管理 → 数据持久化。
这是一个「麻雀虽小五脏俱全」的实战项目。掌握了它,你就具备了开发更复杂鸿蒙应用的基础能力。
本文所有代码基于 HarmonyOS API 24(6.1.1),使用 ArkTS 语言编写。完整源码请参考项目中的 Index.ets 文件。
更多推荐

所有评论(0)