项目预览
本文将带你使用 HarmonyOS ArkTS​ 和 ArkUI​ 构建一个功能完善的校园课程表应用。不同于简单的静态页面,我们将重点放在状态驱动UI更新上,实现课程的增删查改、动态统计及分类筛选。
核心功能点:
数据录入:课程名、教师、地点、时间的表单绑定。
动态筛选:按星期一键切换视图。
实时统计:本周总数、当日数量、分类计数随数据联动。
交互优化:空状态占位、滑动列表、视觉反馈。
注:发布时请在下方插入应用运行效果图,直观展示界面布局。
前言:为什么选择课程表作为练手项目?
对于 HarmonyOS 初学者而言,课程表是一个极佳的“承上启下”项目。它不像 Hello World 那样简陋,也不像电商应用那样庞大。它涵盖了移动端开发的几大核心要素:
表单处理:多输入框的数据绑定与校验。
列表渲染:List + ForEach 的经典组合。
状态管理:@State 在数据变更时如何触发 UI 重绘。
逻辑分离:利用 @Builder 装饰器封装 UI 组件,保持代码整洁。
通过这个项目,你将真正理解 ArkUI “声明式开发”的精髓:数据变,界面变。
一、技术栈与环境
类别

说明

开发平台​

HarmonyOS NEXT / 标准版

开发语言​

ArkTS

UI 框架​

ArkUI (声明式开发范式)

开发工具​

DevEco Studio

API 版本​

10+ (Stage 模型)

核心机制​

@State, @Builder, 数组高阶函数 (filter, map)
二、设计思路与架构
在动手写代码之前,我们需要理清数据流向。本课程表应用遵循 MVVM(Model-View-ViewModel)的思想,虽然我们没有显式的 ViewModel 层,但 ArkUI 帮我们处理了视图绑定。

  1. 数据模型 (Model)
    首先定义课程的数据结构 CourseItem,这是整个应用的基石。
    interface CourseItem {
    id: number; // 唯一标识,用于删除(避免同名冲突)
    name: string; // 课程名
    teacher: string; // 教师
    place: string; // 地点
    time: string; // 时间
    day: string; // 星期几
    type: string; // 课程类型(用于着色)
    }
  2. 状态定义 (State)
    我们使用 @State 定义页面的响应式数据:
    courses: 存储所有课程的数组(单一数据源)。
    selectedDay: 当前选中的星期,用于控制筛选逻辑。
    各类 Input 绑定的临时变量(courseName, teacherName 等)。
  3. 业务逻辑 (Logic)
    添加:校验非空 -> 构造对象 -> 插入数组头部。
    删除:利用 filter 排除指定 id。
    筛选:利用 filter 匹配 selectedDay。
    统计:基于数组长度或特定条件的 filter 结果。
    三、核心代码实现 (Index.ets)
    以下是项目的完整代码。我将代码进行了逻辑分层,重点在于 @Builder 的使用,它极大地提升了代码的可读性。
    // Index.ets
    // 1. 定义数据类型
    interface CourseItem {
    id: number;
    name: string;
    teacher: string;
    place: string;
    time: string;
    day: string;
    type: string;
    }

@Entry
@Component
struct Index {
// 2. 状态定义
@State courseName: string = ‘’;
@State teacherName: string = ‘’;
@State placeName: string = ‘’;
@State timeText: string = ‘’;
@State selectedDay: string = ‘周一’;
@State courseType: string = ‘专业课’;
@State nextId: number = 5; // 初始ID,配合预置数据

// 预置数据 & 主数据源
@State courses: CourseItem[] = [
{ id: 1, name: ‘数据库原理’, teacher: ‘王老师’, place: ‘教学楼 A203’, time: ‘08:30-10:00’, day: ‘周一’, type: ‘专业课’ },
{ id: 2, name: ‘Java 程序设计’, teacher: ‘李老师’, place: ‘实验楼 B401’, time: ‘10:20-11:50’, day: ‘周一’, type: ‘实验课’ },
{ id: 3, name: ‘大学英语’, teacher: ‘陈老师’, place: ‘教学楼 C105’, time: ‘14:00-15:30’, day: ‘周三’, type: ‘公共课’ },
{ id: 4, name: ‘创新创业基础’, teacher: ‘赵老师’, place: ‘综合楼 D201’, time: ‘15:50-17:20’, day: ‘周五’, type: ‘选修课’ },
];

// 3. 业务逻辑方法
private addCourse(): void {
// 简单的非空校验
if (!this.courseName.trim() || !this.teacherName.trim() || !this.placeName.trim() || !this.timeText.trim()) {
// 实际项目中可替换为弹窗提示
console.info(‘请填写完整的课程信息’);
return;
}

const newCourse: CourseItem = {
  id: this.nextId++,
  name: this.courseName,
  teacher: this.teacherName,
  place: this.placeName,
  time: this.timeText,
  day: this.selectedDay,
  type: this.courseType
};

// 新数据插到最前面,利用展开运算符保持不可变性
this.courses = [newCourse, ...this.courses];
// 清空输入框
this.resetInputs();

}

private resetInputs(): void {
this.courseName = ‘’;
this.teacherName = ‘’;
this.placeName = ‘’;
this.timeText = ‘’;
}

private deleteCourse(id: number): void {
this.courses = this.courses.filter(item => item.id !== id);
}

private clearCurrentDay(): void {
this.courses = this.courses.filter(item => item.day !== this.selectedDay);
}

// 派生数据(Getter逻辑):根据状态计算出的数据
private get currentDayCourses(): CourseItem[] {
return this.courses.filter(item => item.day === this.selectedDay);
}

// 4. UI 构建块 (@Builder 封装)
@Builder StatCard(title: string, value: string, color: string) {
Column() {
Text(value)
.fontSize(22).fontWeight(FontWeight.Bold).fontColor(color)
Text(title)
.fontSize(13).fontColor(‘#6B7280’).margin({ top: 4 })
}
.width(‘31%’).height(78).justifyContent(FlexAlign.Center)
.backgroundColor(Color.White).borderRadius(16)
.shadow({ radius: 10, color: ‘#12000000’, offsetY: 4 })
}

@Builder TagButton(text: string, isSelected: boolean, bgColor: string, onClick: () => void) {
Button(text)
.height(34).fontSize(13)
.fontColor(isSelected ? Color.White : ‘#4B5563’)
.backgroundColor(isSelected ? bgColor : ‘#EEF2F8’)
.borderRadius(17)
.onClick(onClick)
}

@Builder CourseCard(item: CourseItem) {
Row() {
Column() {
Text(item.name).fontSize(17).fontWeight(FontWeight.Bold)
Text(${item.day} ${item.time}).fontSize(13).fontColor(‘#6B7280’).margin({ top: 6 })
Text(${item.teacher} · ${item.place}).fontSize(13).fontColor(‘#9CA3AF’).margin({ top: 6 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)

  Text(item.type)
    .fontSize(12).fontColor(Color.White)
    .backgroundColor(this.getTypeColor(item.type))
    .borderRadius(12).padding({ left: 10, right: 10, top: 5, bottom: 5 })
}
.padding(14).margin({ bottom: 12 })
.backgroundColor(Color.White).borderRadius(16)
.shadow({ radius: 8, color: '#08000000', offsetY: 2 })

}

private getTypeColor(type: string): string {
switch (type) {
case ‘专业课’: return ‘#0A59F7’;
case ‘实验课’: return ‘#10B981’;
case ‘公共课’: return ‘#F59E0B’;
default: return ‘#8B5CF6’; // 选修课
}
}

// 5. 主构建函数
build() {
Column() {
this.Header()
this.Statistics()
this.InputSection()
this.FilterSection()
this.CourseListSection()
}
.width(‘100%’).height(‘100%’)
.padding({ left: 18, right: 18 })
.backgroundColor(‘#F5F7FA’)
}

// 拆分 Header
@Builder Header() {
Column() {
Text(‘校园课程表’)
.fontSize(28).fontWeight(FontWeight.Bold)
Text(‘基于 HarmonyOS ArkTS 的轻量级课程管理工具’)
.fontSize(14).fontColor(‘#6B7280’).margin({ top: 8 })
}.margin({ top: 22, bottom: 20 }).alignItems(HorizontalAlign.Start).width(‘100%’)
}

// 拆分 Statistics
@Builder Statistics() {
Row() {
this.StatCard(‘本周课程’, ${this.courses.length}, ‘#0A59F7’)
this.StatCard(‘当天课程’, ${this.currentDayCourses.length}, ‘#10B981’)
this.StatCard(‘实验课’, ${this.courses.filter(c => c.type === '实验课').length}, ‘#F59E0B’)
}.width(‘100%’).justifyContent(FlexAlign.SpaceBetween)
}

// 拆分 Input Section
@Builder InputSection() {
Column() {
Text(‘新增课程’).fontSize(17).fontWeight(FontWeight.Bold).margin({ bottom: 14 }).width(‘100%’)

  this.TextInputField('课程名称', this.courseName, (v) => this.courseName = v)
  this.TextInputField('任课教师', this.teacherName, (v) => this.teacherName = v)
  this.TextInputField('上课地点', this.placeName, (v) => this.placeName = v)
  this.TextInputField('上课时间', this.timeText, (v) => this.timeText = v)

  Text('选择课程类型').fontSize(14).fontColor('#6B7280').margin({ top: 14, bottom: 10 }).width('100%')
  Row() {
    this.TagButton('专业课', this.courseType === '专业课', '#0A59F7', () => this.courseType = '专业课')
    this.TagButton('公共课', this.courseType === '公共课', '#F59E0B', () => this.courseType = '公共课')
    this.TagButton('实验课', this.courseType === '实验课', '#10B981', () => this.courseType = '实验课')
    this.TagButton('选修课', this.courseType === '选修课', '#8B5CF6', () => this.courseType = '选修课')
  }.width('100%').justifyContent(FlexAlign.SpaceBetween)

  Button(`添加到 ${this.selectedDay}`)
    .height(44).fontSize(16).fontColor(Color.White).backgroundColor('#0A59F7')
    .borderRadius(14).width('100%').margin({ top: 16 })
    .onClick(() => this.addCourse())
}
.padding(16).backgroundColor(Color.White).borderRadius(18).margin({ top: 18 })
.shadow({ radius: 10, color: '#12000000', offsetY: 4 })

}

@Builder TextInputField(placeholder: string, text: string, onChange: (value: string) => void) {
TextInput({ placeholder, text })
.height(42).fontSize(14).backgroundColor(‘#F5F7FA’)
.borderRadius(12).padding({ left: 12, right: 12 })
.margin({ top: 10 })
.onChange(onChange)
}

// 拆分 Filter Section
@Builder FilterSection() {
Column() {
Text(‘星期筛选’).fontSize(17).fontWeight(FontWeight.Bold).margin({ top: 20, bottom: 12 }).width(‘100%’)
Row() { [‘周一’,‘周二’,‘周三’,‘周四’].forEach(day => this.DayButton(day)); }.width(‘100%’).justifyContent(FlexAlign.SpaceBetween)
Row() { [‘周五’,‘周六’,‘周日’].forEach(day => this.DayButton(day)); }.width(‘100%’).margin({ top: 10 }).justifyContent(FlexAlign.Start)

  Row() {
    Text(`${this.selectedDay} 课程`)
      .fontSize(17).fontWeight(FontWeight.Bold)
    Blank()
    Button('清空当天')
      .height(32).fontSize(13).fontColor('#EF4444').backgroundColor('#FEF2F2')
      .borderRadius(14).onClick(() => this.clearCurrentDay())
  }.width('100%').margin({ top: 20, bottom: 12 })
}

}

@Builder DayButton(day: string) {
this.TagButton(day, this.selectedDay === day, ‘#0A59F7’, () => this.selectedDay = day)
}

// 拆分 List Section
@Builder CourseListSection() {
if (this.currentDayCourses.length === 0) {
Column() {
Text(‘当天暂无课程’)
.fontSize(16).fontColor(‘#6B7280’)
Text(‘可以在上方输入课程信息,并添加到当前星期。’)
.fontSize(13).fontColor(‘#9CA3AF’).margin({ top: 8 })
}
.width(‘100%’).padding(24).backgroundColor(Color.White).borderRadius(16)
} else {
List() {
ForEach(this.currentDayCourses, (item: CourseItem) => {
ListItem() {
this.CourseCard(item)
}
}, item => item.id.toString())
}
.width(‘100%’)
.layoutWeight(1)
.sticky(StickyStyle.Header) // 吸顶效果
.edgeEffect(EdgeEffect.Spring) // 弹性边缘
}
}
}
四、关键知识点解析

  1. @Builder:UI 的“函数”
    在初版代码中,build() 函数非常臃肿。优化后的代码大量使用了 @Builder。
    作用:将重复的 UI 结构(如统计卡片、按钮、输入框)封装成独立的构建函数。
    好处:符合单一职责原则,修改某个组件时无需阅读整个 build 方法,极大提高了可维护性。
  2. 派生状态 (Derived State)
    注意这一行代码:
    private get currentDayCourses(): CourseItem[] {
    return this.courses.filter(item => item.day === this.selectedDay);
    }
    我们没有用一个额外的 @State currentDayCourses 来存储筛选结果,而是定义了一个 get 访问器。
    原因:currentDayCourses 完全依赖于 courses 和 selectedDay。如果单独存一份,就需要时刻保证两份数据同步,容易出错。
    优势:每次状态变化,UI 自动读取最新的计算结果,保证了数据的唯一性和准确性。
  3. 数组的不可变性
    在添加和删除课程时,我们使用了展开运算符或重新赋值:
    this.courses = [newCourse, …this.courses]; // 正确
    // this.courses.push(newCourse); // 错误(在 ArkUI 中可能导致 UI 不刷新)

this.courses = this.courses.filter(…);
原因:@State 装饰器监测的是引用地址的变化。直接调用 push 或 splice 修改了数组内部内容,但数组的引用地址没变,ArkUI 可能无法感知到变化。通过重新赋值一个新的数组,确保了 UI 能够强制刷新。
五、踩坑与优化建议
Key 值的重要性:
在使用 ForEach 循环渲染列表时,务必提供唯一的 Key(如 item.id.toString())。如果不提供,或者使用了数组下标作为 Key,在数据发生排序、删除等变化时,会导致 UI 渲染错乱或性能下降。
输入法遮挡:
在真机上运行时,底部的输入框可能会被软键盘遮挡。实际商业应用中,需要使用 window 模块的 setKeyboardAvoidMode 来调整布局,或者使用 Scroll 组件包裹输入区域,确保焦点在输入框时能自动滚动可见。
数据持久化:
目前的课程数据存储在内存中,应用关闭即丢失。进阶练习可以尝试使用 Preferences(轻量级存储)来保存 courses 数组,实现开机自启加载数据。
六、总结
通过本教程,我们不仅完成了一个美观实用的课程表应用,更重要的是掌握了 ArkTS 开发的核心心法:以数据为中心,通过状态驱动视图。
我们学会了如何使用 @State 管理数据,如何利用 @Builder 组织复杂的 UI 结构,以及如何优雅地处理数组数据的增删改查。这些技能是构建任何 HarmonyOS 应用的基础。
下一步挑战:
本地存储:接入 Preferences,让数据永久保存。
动画效果:为列表的添加和删除增加 animateTo 过渡动画。
组件化:将 CourseCard 抽离成独立的 @Component,实现跨页面复用。
希望这篇实战文章能帮助你在 HarmonyOS 开发的道路上更进一步!在这里插入图片描述

Logo

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

更多推荐