在这里插入图片描述
在这里插入图片描述

目录

  1. 引言
  2. 需求分析与产品规划
  3. 数据模型设计
  4. 架构设计与组件划分
  5. 课表网格视图实现
  6. 课程管理列表实现
  7. 教学统计模块实现
  8. 表单浮层与 CRUD 操作
  9. 自定义组件体系
  10. ArkTS 编译约束与常见错误
  11. 色彩体系与视觉设计
  12. 从数据到可视化的设计思维
  13. 总结与扩展方向

一、引言

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 的 MapRecord 在 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.etsManageView.etsStatsView.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 中不允许声明变量(constletvar)。因此我们只能通过 ! 非空断言多次调用方法。这虽然不够优雅,但这是 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; })
      })
    }
  }
  // 节次选择(结构类似)
}

采用「按钮组」风格的选择器而非下拉列表。原因:

  1. 选择项数量少(5 天 / 7 节),按钮组更直观
  2. 点击一次即可完成选择,操作路径最短
  3. 选中态颜色反转(蓝底白字 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 更新。但使用 pushfilter 等创建新数组的方法则可以。


九、自定义组件体系

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:独立作用域,通过 @Propprivate 接收参数

十、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 触发更新

注意pushfilter 都会修改数组的引用,从而触发 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 渐进式复杂度

教师课时表应用的信息复杂度是之前应用中最高的。为了让用户逐步适应:

  1. 默认展示课表视图:最直观,一眼看懂
  2. 管理列表提供操作入口:需要时才使用
  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 文件。


Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐