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

职业技能学习平台 HarmonyOS ArkTS 应用开发实战

项目代号: api24
技术栈: HarmonyOS Next + ArkTS + ArkUI
SDK版本: HarmonyOS SDK 6.1.0 (API 23)
开发工具: DevEco Studio
运行环境: HarmonyOS Phone


一、引言

1.1 项目背景

在数字化转型浪潮下,职业技能学习已成为职场人士持续提升竞争力的核心途径。为了打造一款跨平台、高性能、用户体验优秀的移动学习应用,我们选择了 HarmonyOS Next 作为开发平台,利用其原生 ArkTS 语言和 ArkUI 声明式框架,构建了"职业技能学习平台"。

该应用涵盖完整的在线学习场景:课程浏览、分类筛选、课程报名、视频学习、进度追踪、个人中心等功能模块,是一个典型的"内容+服务"型应用。

1.2 为什么选择 HarmonyOS ArkTS

维度 优势分析
开发效率 ArkTS 基于 TypeScript 语法,前端开发者零成本上手,静态类型检查减少运行时错误
UI性能 ArkUI 声明式框架采用 C++ 引擎渲染,对比 Web 方案提升 30%~60% 的列表流畅度
多设备适配 一套代码可适配手机、平板、折叠屏等多种设备形态
生态支持 HarmonyOS Next 提供完整的 Kit 能力,如媒体播放、网络请求、本地存储等
包体积 纯原生方案,APK/IPA 无需打包 WebView 运行时,包体积减少 50%+

1.3 应用功能全景图

职业技能学习平台
├── 首页(首页/课程/学习/我的)
│   ├── Banner 轮播推荐
│   ├── 分类快速入口(6大类别)
│   ├── 精选推荐课程列表
│   └── 继续学习(进度追踪)
├── 课程分类(全部课程浏览)
│   ├── 分类网格导航
│   └── 全部课程列表
├── 学习中心
│   ├── 学习统计(在学/已完成/未开始)
│   ├── 空状态引导
│   └── 课程进度分组展示
├── 个人中心
│   ├── 用户信息展示
│   ├── 学习数据统计
│   └── 功能菜单入口
├── 课程详情
│   ├── 课程信息头部
│   ├── 课程简介
│   ├── 章节/课时目录
│   └── 报名/开始学习按钮
└── 课程播放
    ├── 视频播放器模拟
    ├── 课时内容讲解
    ├── 课程目录面板
    └── 上下节导航

二、项目架构设计

2.1 整体架构层次

应用采用经典的单 Ability + 多 Page 架构模式,遵循 HarmonyOS Stage 模型规范:

┌──────────────────────────────────────────────────┐
│                  EntryAbility                     │
│         (UIAbility 生命周期管理)                  │
├──────────────────────────────────────────────────┤
│               WindowStage                         │
│         (loadContent → pages/Index)               │
├──────────────────────────────────────────────────┤
│   ┌──────────┐  ┌──────────┐  ┌──────────────┐   │
│   │ Index.ets │→│CourseDet│→│CoursePlayer   │   │
│   │ (4 Tabs)  │  │ail.ets  │  │.ets           │   │
│   └──────────┘  └──────────┘  └──────────────┘   │
│        │               │              │           │
│        │     router.pushUrl(params)   │           │
│        └───────────────┴──────────────┘           │
├──────────────────────────────────────────────────┤
│              数据层 (Data Layer)                   │
│   ┌──────────────────────────────────────────┐    │
│   │  模拟数据 (Mock Data / 后续替换为 API)     │    │
│   │  Interface 类型定义 (CourseItem/Lesson)   │    │
│   └──────────────────────────────────────────┘    │
└──────────────────────────────────────────────────┘

2.2 Stage 模型要点

HarmonyOS Stage 模型是应用开发的推荐模型,其核心特点:

// EntryAbility.ets - 应用入口
export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 设置颜色模式
    this.context.getApplicationContext()
      .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 加载首页
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'testTag', 'Failed to load content: %{public}s', JSON.stringify(err));
        return;
      }
    });
  }
}

关键设计决策

  • 使用单 UIAbility 而非多 Ability,减少进程开销
  • loadContent() 指定首页 Page,后续页面通过 router API 栈式导航
  • hilog 替代 console.log,提供分级日志能力

2.3 路由与页面栈管理

页面间导航使用 @kit.ArkUI 提供的 router 模块:

import { router } from '@kit.ArkUI';

// 首页 → 课程详情:携带 courseId 参数
goToDetail(course: CourseItem): void {
  router.pushUrl({
    url: 'pages/CourseDetail',
    params: { courseId: course.id }
  });
}

// 课程详情 → 课程播放:携带 courseId + lessonId
goPlay(lessonId: number): void {
  router.pushUrl({
    url: 'pages/CoursePlayer',
    params: { courseId: this.course.id, lessonId }
  });
}

目标页面通过 router.getParams() 接收参数:

aboutToAppear(): void {
  const params = router.getParams() as Record<string, Object>;
  const courseId = params['courseId'] as number;
  // 根据 courseId 加载课程数据
}

路由配置在 resources/base/profile/main_pages.json 中声明:

{
  "src": [
    "pages/Index",
    "pages/CourseDetail",
    "pages/CoursePlayer"
  ]
}

三、数据模型与类型系统

3.1 强类型定义

ArkTS 作为 TypeScript 的超集,全面支持静态类型检查。我们为应用定义了清晰的数据模型:

// 首页用到的课程摘要类型
interface CourseItem {
  id: number;
  title: string;
  category: string;
  cover: string;
  instructor: string;
  avatar: string;
  price: string;
  progress: number;         // 学习进度 0-100
  totalLessons: number;
  learnedLessons: number;
  duration: string;
  students: number;
  rating: number;
  tags: string[];
  description: string;
}

// 分类类型
interface CategoryItem {
  id: number;
  name: string;
  icon: string;
  color: string;
}

// Banner 轮播类型
interface BannerItem {
  title: string;
  subtitle: string;
  color: string;
}

// 详情页章节/课时类型
interface Chapter {
  id: number;
  title: string;
  lessons: Lesson[];
}

interface Lesson {
  id: number;
  title: string;
  duration: string;
  isFree: boolean;
  isCompleted: boolean;
}

// 课程完整信息类型
interface CourseFull {
  id: number;
  title: string;
  cover: string;
  instructor: string;
  avatar: string;
  price: string;
  progress: number;
  totalLessons: number;
  learnedLessons: number;
  duration: string;
  students: number;
  rating: number;
  tags: string[];
  description: string;
  chapters: Chapter[];
}

3.2 接口设计的考量

为什么将 CourseItem 和 CourseFull 分开?

首页的课程列表仅需摘要信息(不需要章节详情),而详情页需要完整的章节树结构。将两者分离可以:

  1. 减少数据传输:列表场景不加载冗余的章节数据
  2. 类型安全:各自场景使用精确的类型定义
  3. 可扩展性:后续接入真实 API 时,列表接口和详情接口可以独立优化

3.3 模拟数据层

在 MVP 阶段,我们使用内置的模拟数据驱动开发。数据存储在模块级别的常量中:

const ALL_COURSES: CourseItem[] = [
  {
    id: 1, title: 'Vue3 + TypeScript 企业级实战',
    category: '编程开发',
    // ... 其他字段
  },
  // 共 8 门课程,覆盖 6 个分类
];

const COURSE_DETAILS: Record<number, CourseFull> = {
  1: { /* ... */ },
  2: { /* ... */ },
  // key 为 courseId,O(1) 查询
};

使用 Record<number, CourseFull> 字典结构存储详情数据,相比数组查找,时间复杂度从 O(n) 降为 O(1)。


四、首页多Tab架构深度解析

4.1 页面结构

首页 Index.ets 是应用最复杂的页面,采用 底部 Tab 导航 + 内容区切换 的经典布局:

┌─────────────────────────┐
│     顶部标题栏 (Header)  │
├─────────────────────────┤
│                         │
│   内容区 (Stack 切换)    │
│  ┌───┐ ┌───┐ ┌──┐ ┌─┐ │
│  │首 │ │课 │ │学│ │我│ │
│  │页 │ │程 │ │习│ │的│ │
│  └───┘ └───┘ └──┘ └─┘ │
│                         │
├─────────────────────────┤
│   底部 Tab 栏 (Footer)   │
│  🏠  📚  📖  👤        │
└─────────────────────────┘

核心状态变量:

@State currentTab: number = 0       // 当前选中的 Tab 索引
@State bannerIndex: number = 0      // 当前 Banner 索引
@State enrolledCourses: number[] = [2, 4, 6, 8]  // 已报名课程 ID 列表

4.2 @State 装饰器与响应式编程

ArkUI 的 @State 装饰器是声明式 UI 的核心机制。当 @State 修饰的变量发生变化时,框架自动重新渲染依赖该变量的 UI 组件:

@Component
struct Index {
  @State currentTab: number = 0

  build() {
    Column() {
      // 当 currentTab 变化时,这里自动重新求值
      if (this.currentTab === 0) {
        this.HomePage()
      } else if (this.currentTab === 1) {
        this.CategoriesPage()
      }
      // ...
    }
  }
}

与传统命令式框架的对比

模式 写法 优点 缺点
命令式(Java XML) findViewById → setText → setVisibility 直观控制每个节点 代码冗长,状态难管理
声明式(ArkTS) @State + if/else 关注"是什么"而非"怎么做" 重新渲染范围需注意
声明式(Flutter) setState + build 统一的重建机制 细粒度控制需额外优化

4.3 @Builder 装饰器与组件复用

@Builder 是 ArkUI 提供的自定义构建函数,用于封装可复用的 UI 片段:

@Builder
CourseCard(course: CourseItem) {
  Column() {
    Row() {
      Text(course.cover).fontSize(40).width(64).height(64)
      // ... 更多 UI
    }
    // 进度条
    if (course.progress > 0) {
      Progress({ value: course.progress, total: 100 })
    }
    // 标签
    if (course.tags.length > 0) {
      Row({ space: 6 }) {
        ForEach(course.tags, (tag: string) => {
          Text(tag).backgroundColor('#EEF0FF').borderRadius(8)
        })
      }
    }
  }
  .onClick(() => { this.goToDetail(course) })
}

@Builder 的设计优势

  • 避免了重复的 UI 代码
  • 可接受参数,灵活适配不同数据
  • 支持条件渲染(如进度条、标签)
  • 代码可读性高,逻辑内聚

4.4 Banner 轮播实现

Banner 使用 setInterval 实现自动轮播,通过 @State bannerIndex 驱动 UI 更新:

startBannerLoop(): void {
  this.bannerTimer = setInterval(() => {
    this.bannerIndex = (this.bannerIndex + 1) % BANNERS.length;
  }, 3500);  // 3.5 秒切换一次
}

aboutToDisappear(): void {
  if (this.bannerTimer >= 0) {
    clearInterval(this.bannerTimer);  // 页面销毁时清除定时器
  }
}

生命周期管理要点

  • aboutToAppear 启动轮播
  • aboutToDisappear 清理定时器,防止内存泄漏

Banner 指示器使用动态宽度的胶囊形圆点,高亮当前索引:

ForEach(BANNERS, (_: BannerItem, i: number) => {
  Circle()
    .width(this.bannerIndex === i ? 24 : 8)  // 当前页更宽
    .height(6)
    .fill(this.bannerIndex === i ? '#FFFFFF' : '#66FFFFFF')
    .borderRadius(3)
})

这种"流动指示器"的交互反馈比固定圆点更生动,用户能直观感知当前轮播位置。

4.5 分类网格

首页展示前 4 个分类入口,点击可切换到课程 Tab:

Row() {
  ForEach(CATEGORIES.slice(0, 4), (cat: CategoryItem) => {
    Column() {
      Text(cat.icon).fontSize(30)
      Text(cat.name).fontSize(12).fontColor('#555')
    }
    .layoutWeight(1)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .onClick(() => { this.currentTab = 1 })  // 切换到课程 Tab
  })
}

layoutWeight(1) 实现等分布局,适配不同屏幕宽度。


五、课程详情页实现

5.1 页面架构

CourseDetail.ets 的结构分为三个区域:

┌─────────────────────────┐
│  ← 返回       课程详情  分享 │  ← 顶部导航栏
├─────────────────────────┤
│  课程头部 (封面/讲师/评分) │
│  课程标签                  │
│  学习进度 (如已报名)       │
├─────────────────────────┤
│  课程简介                  │
├─────────────────────────┤
│  📋 课程目录               │
│  📂 第一章  ▾  4节        │
│    ✅ 1-1 课程介绍  免费   │
│    ▶️ 1-2 环境搭建  15:30  │
│  📂 第二章  ▾  3节        │
├─────────────────────────┤
│  ¥299      📚 立即报名     │  ← 底部操作栏
└─────────────────────────┘

5.2 动态章节展开/折叠

章节列表使用 showAllChapters 状态数组控制每个章节的展开状态:

@State showAllChapters: boolean[] = []

aboutToAppear(): void {
  // 初始化全部展开
  this.showAllChapters = found.chapters.map(() => true)
}

toggleChapter(idx: number): void {
  const arr = [...this.showAllChapters]  // 创建新数组触发响应式更新
  arr[idx] = !arr[idx]
  this.showAllChapters = arr
}

关键细节showAllChapters 必须通过创建新数组来赋值,而不是直接修改原数组。这是因为 ArkTS 的响应式跟踪依赖于引用变化。直接修改 arr[idx] 不会触发 UI 更新。

5.3 课程报名与开始学习

底部操作按钮的交互逻辑:

Button(this.isEnrolled ? '📖 开始学习' : '📚 立即报名')
  .backgroundColor(this.isEnrolled ? '#4CAF50' : '#FF6B35')
  .onClick(() => {
    if (!this.isEnrolled) {
      this.isEnrolled = true  // 报名操作
    }
    // 跳转到第一个课时
    const firstLesson = this.course!.chapters[0]?.lessons[0]
    if (firstLesson) {
      this.goPlay(firstLesson.id)
    }
  })

设计要点

  • 按钮文案和颜色随状态变化,提供清晰的视觉反馈
  • 报名后自动跳转到第一个课时,降低用户操作路径
  • 已报名课程显示绿色"开始学习",未报名显示橙色"立即报名"

5.4 免费课时标识

课时列表中,免费课时会有绿色"免费"标签,且未报名的用户可以试听免费课时:

Row() {
  Text(lesson.title).fontSize(14).fontColor('#444')
  Text(lesson.duration).fontSize(11).fontColor('#AAA')
  if (lesson.isFree) {
    Text('免费').fontSize(10).fontColor('#FFF')
      .backgroundColor('#4CAF50').borderRadius(4)
  }
}
.onClick(() => {
  if (this.isEnrolled || lesson.isFree) {
    this.goPlay(lesson.id)  // 已报名或免费课时可播放
  }
})

这是在线教育平台常见的"免费试看"商业模式,既降低了用户决策门槛,又保护了付费内容的权益。


六、课程播放器实现

6.1 播放器架构

CoursePlayer.ets 是应用中最复杂的页面,管理多个状态维度:

@Component
struct CoursePlayer {
  @State courseData: CoursePlayInfo | null = null
  @State currentLessonId: number = 1
  @State currentChapterIdx: number = 0    // 当前章节索引
  @State currentLessonIdx: number = 0     // 当前课时索引
  @State showOutline: boolean = false      // 是否显示目录
  @State isPlaying: boolean = false        // 播放状态
}

6.2 视频播放器模拟

由于本应用处于 MVP 阶段,视频播放器使用样式占位。但架构设计上保留了后续接入真实播放器的能力:

Stack() {
  // 播放器占位背景
  Column() {
    if (this.isPlaying) {
      Text('▶ 播放中...')
      Text('模拟视频播放 - ' + (this.currentLesson?.title || ''))
    } else {
      Text(this.courseData?.cover || '📚').fontSize(64)
      Text('点击播放开始学习')
    }
  }
  .linearGradient({ /* 渐变背景 */ })

  // 播放/暂停按钮
  Circle().width(56).height(56).fill('#FFFFFF').opacity(0.2)
  Text(this.isPlaying ? '⏸' : '▶')
    .onClick(() => this.togglePlay())
}

架构思考:在 MVP 阶段用模拟占位快速验证交互流程,后续可替换为真实的 Video 组件:

// 未来替换方案
Video({
  src: this.currentLesson?.videoUrl,
  controller: this.videoController
})
.width('100%').height(220)

6.3 课时导航

上下节导航是播放器的核心交互,需要处理边界情况:

nextLesson(): void {
  const ch = this.courseData!.chapters[this.currentChapterIdx];
  if (this.currentLessonIdx < ch.lessons.length - 1) {
    // 当前章节内还有下一课时
    this.currentLessonIdx++;
  } else if (this.currentChapterIdx < this.courseData!.chapters.length - 1) {
    // 进入下一章节的第一课时
    this.currentChapterIdx++;
    this.currentLessonIdx = 0;
  }
  // 更新当前课时 ID
  this.currentLessonId = this.courseData!.chapters[this.currentChapterIdx].lessons[this.currentLessonIdx].id;
  this.isPlaying = false;  // 切换课时后暂停
}

逻辑要点

  1. 优先在当前章节内导航
  2. 章节内播完则进入下一章节
  3. 最后一节课时,"下一节"按钮无操作(也可设计为循环或提示完成)
  4. 切换课时后自动暂停播放

6.4 课程目录面板

目录面板(OutlinePanel)提供快速跳转能力:

@Builder
OutlinePanel() {
  Scroll() {
    Column() {
      ForEach(this.courseData.chapters, (ch: ChapterInfo, ci: number) => {
        Text(ch.title).fontColor('#667eea')  // 章节标题
        ForEach(ch.lessons, (lesson: LessonDetail, li: number) => {
          Row() {
            // 完成状态 + 当前播放标识
            if (lesson.isCompleted) {
              Text('✅')           // 已完成
            } else if (lesson.id === this.currentLessonId) {
              Text('▶️')          // 当前播放
            } else {
              Circle().width(8).height(8).fill('#DDD')  // 未完成
            }
            Text(lesson.title)
              .fontColor(lesson.id === this.currentLessonId ? '#667eea' : 
                         lesson.isCompleted ? '#4CAF50' : '#555')
          }
          .backgroundColor(lesson.id === this.currentLessonId ? '#F0F4FF' : '#FFFFFF')
          .onClick(() => { this.goToLesson(ci, li) })
        })
      })
    }
  }
}

目录面板用三种图标区分课时状态:

  • ✅ 已完成的课时
  • ▶️ 当前播放的课时
  • ● 未完成的课时

配合背景色高亮当前课时,用户一目了然。

6.5 学习进度统计

通过计算属性实时统计学习进度:

get totalLessons(): number {
  return this.courseData.chapters.reduce((s, c) => s + c.lessons.length, 0);
}

get completedLessons(): number {
  let count = 0;
  for (const ch of this.courseData.chapters) {
    for (const l of ch.lessons) {
      if (l.isCompleted) count++;
    }
  }
  return count;
}

ArkTS 的 get 访问器在每次渲染时自动求值,配合 Progress 组件展示进度条:

Progress({ value: this.completedLessons, total: this.totalLessons, type: ProgressType.Linear })
  .value(this.completedLessons)
  .color('#667eea')
  .backgroundColor('#E8ECF4')
Text('已完成 ' + this.completedLessons + '/' + this.totalLessons)

七、UI/UX 设计亮点

7.1 色彩体系

应用采用紫色渐变作为主色调,营造专业、专注的学习氛围:

// Banner 渐变
.linearGradient({
  direction: GradientDirection.Right,
  colors: [['#667eea', 0], ['#764ba2', 1]]
})

// 播放器渐变
.linearGradient({
  direction: GradientDirection.RightBottom,
  colors: [['#1a1a2e', 0], ['#16213e', 1]]
})
用途 色值 含义
主色 #667eea 专业、信任
辅色 #764ba2 创意、深度
价格/报名 #FF6B35 行动、热情
完成/免费 #4CAF50 成功、正向
背景 #F5F7FA 干净、舒适

7.2 卡片化设计

所有课程卡片使用圆角 + 阴影的 Material Design 风格:

.backgroundColor('#FFFFFF')
.borderRadius(14)
.shadow({ radius: 4, color: '#08000000', offsetY: 2 })

阴影参数调优

  • radius: 4 — 柔和阴影,不抢眼
  • offsetY: 2 — 轻微下沉感,暗示可点击
  • color: '#08000000' — 极淡的黑色阴影,适配浅色背景

7.3 空状态设计

当用户没有报名任何课程时,展示引导性的空状态:

Column() {
  Text('📚').fontSize(64).margin({ top: 40 })
  Text('还没有报名任何课程').fontSize(18).fontColor('#999')
  Text('去首页挑选感兴趣的课程吧').fontSize(14).fontColor('#CCC')
  Button('去选课')
    .width(160).height(40)
    .backgroundColor('#667eea')
    .fontColor('#FFF')
    .borderRadius(20)
    .margin({ top: 20 })
    .onClick(() => { this.currentTab = 1 })  // 跳转到课程 Tab
}

空状态设计的三要素:

  1. 图标:大号 emoji,传递情感
  2. 文案:说明原因 + 提供行动指引
  3. 按钮:一键跳转到目标页面,降低用户操作成本

7.4 学习统计卡片

学习 Tab 顶部的统计卡片使用等分布局:

Row() {
  this.StatCard('📚', '在学', this.inProgressCourses.length.toString(), '#667eea')
  this.StatCard('✅', '已学完', this.completedCount.toString(), '#4CAF50')
  this.StatCard('⏳', '未开始', this.notStartedCourses.length.toString(), '#FF9800')
}

@Builder
StatCard(icon: string, label: string, value: string, color: string) {
  Column() {
    Text(icon).fontSize(28)
    Text(value).fontSize(24).fontColor(color).fontWeight(FontWeight.Bold)
    Text(label).fontSize(12).fontColor('#888')
  }
  .layoutWeight(1)
  .backgroundColor('#FFFFFF')
  .borderRadius(14)
}

每种状态使用不同的颜色标识,视觉上形成三色对比,信息层次清晰。


八、性能优化与最佳实践

8.1 列表性能优化

HarmonyOS ArkUI 的列表组件 ForEach 在渲染大量数据时,建议遵循以下优化策略:

// ✅ 正确用法:为每个项提供稳定的 key
ForEach(ALL_COURSES, (course: CourseItem) => {
  this.CourseCard(course)
}, (course: CourseItem) => course.id.toString())  // 第三个参数是 keyGenerator

keyGenerator 的重要性

  • 帮助框架精确追踪每个列表项的变更
  • 避免全量重建,仅更新变化的部分
  • 使用唯一且稳定的 ID(如 course.id)作为 key

8.2 条件渲染优化

合理使用条件渲染可以减少不必要的节点创建:

// ✅ 仅在需要时渲染进度条
if (course.progress > 0) {
  Progress({ value: course.progress, total: 100 })
}

// ✅ 仅在存在标签时渲染标签行
if (course.tags.length > 0) {
  Row({ space: 6 }) {
    ForEach(course.tags, (tag: string) => {
      Text(tag).backgroundColor('#EEF0FF').borderRadius(8)
    })
  }
}

8.3 内存管理

// 页面销毁时清理定时器
aboutToDisappear(): void {
  if (this.bannerTimer >= 0) {
    clearInterval(this.bannerTimer);
  }
}

8.4 避免不必要的重新渲染

// ❌ 错误:直接修改数组元素不会触发更新
this.showAllChapters[idx] = !this.showAllChapters[idx];  // UI 不会更新

// ✅ 正确:创建新数组触发响应式更新
const arr = [...this.showAllChapters];
arr[idx] = !arr[idx];
this.showAllChapters = arr;

核心原则:ArkTS 的响应式系统通过引用比较检测变化。对于数组和对象,必须创建新的引用才能触发 UI 更新。

8.5 使用计算属性

利用 get 访问器定义派生状态,而非手动维护多个状态变量:

// 在学课程:从已报名课程中过滤
get inProgressCourses(): CourseItem[] {
  return this.enrolledList.filter(c => c.progress > 0 && c.progress < 100);
}

// 已完成课程
get completedCourses(): CourseItem[] {
  return this.enrolledList.filter(c => c.progress >= 100);
}

// 已学总课时
get totalLearnedLessons(): number {
  return this.enrolledList.reduce((s, c) => s + c.learnedLessons, 0);
}

优势

  • 状态源唯一(enrolledCourses 是唯一需要维护的状态)
  • 避免状态不一致(inProgressCourses 始终与 enrolledCourses 同步)
  • 代码简洁,可维护性强

九、从 MVP 到生产环境的演进路径

9.1 数据层替换

当前应用使用内置的模拟数据,接入真实 API 时需要:

// 方案一:使用 @ohos.net.http
import { http } from '@kit.NetworkKit';

async function fetchCourses(): Promise<CourseItem[]> {
  const req = http.createHttp();
  const res = await req.request('https://api.example.com/courses', {
    method: http.RequestMethod.GET
  });
  return JSON.parse(res.result as string) as CourseItem[];
}

// 方案二:使用 @kit.NetworkKit 的 Axios 风格封装(如果团队偏好)
// import axios from '@ohos/axios';

9.2 状态管理升级

随着应用复杂度增加,可以引入全局状态管理:

// 全局状态存储(AppStorage / LocalStorage)
AppStorage.SetOrCreate('enrolledCourses', []);

// 在组件中监听
@StorageLink('enrolledCourses') enrolledCourses: number[] = [];

@StorageLink 实现跨组件的状态共享,多个页面之间保持数据同步。

9.3 真实视频播放

// 替换模拟播放器
import { VideoController } from '@kit.ArkUI';

Video({
  src: this.currentLesson?.videoUrl,  // 视频 URL
  controller: this.videoController
})
.width('100%')
.height(220)
.controls(true)  // 显示系统播放控件
.onStart(() => { /* 播放开始回调 */ })
.onFinish(() => { /* 播放完成 → 标记课时完成 */ })

9.4 持久化存储

使用 Preferences 存储用户学习进度:

import { preferences } from '@kit.ArkData';

async function saveProgress(courseId: number, lessonId: number) {
  const store = await preferences.getPreferences(this.context, 'learning_progress');
  await store.put(`${courseId}_${lessonId}`, true);
  await store.flush();
}

十、总结与展望

10.1 项目成果

通过"职业技能学习平台"的开发实践,我们验证了 HarmonyOS ArkTS 在内容型应用开发中的技术可行性:

维度 达成情况
功能完整性 覆盖在线学习全场景:浏览→报名→学习→追踪
代码质量 强类型定义、组件化复用、清晰的生命周期管理
用户体验 卡片化设计、动效反馈、空状态引导
架构可扩展 数据层可替换、状态管理可升级、组件可复用

10.2 技术收获

  1. ArkTS 声明式编程@State + @Builder 的组合极大提升了 UI 开发效率,一页代码即可完成传统 Android 需要数个文件实现的功能。
  2. 类型安全:Interface 定义的数据模型在编译期即可捕获大部分类型错误,减少了运行时的空指针异常。
  3. 响应式思维:从"如何操作 DOM"到"数据变了 UI 自动更新"的思维转变,更接近 React/Vue 的开发体验。
  4. 生命周期管理aboutToAppear / aboutToDisappear 的清晰生命周期,让资源管理更加可控。

10.3 未来规划

  • 接入真实 API:对接后端服务,实现用户注册登录、课程支付等完整业务链路
  • 视频播放:集成 HarmonyOS Video 组件,支持倍速播放、清晰度切换等功能
  • 离线下载:利用 ArkData 实现课程离线缓存,支持无网环境学习
  • AI 推荐:基于用户学习行为,智能推荐个性化课程
  • 多设备适配:适配折叠屏和平板,优化大屏学习体验
  • 社区互动:增加问答、评论、笔记等社交化学习功能

附录:核心代码索引

文件 行数 核心功能
Index.ets ~675 行 首页 4 Tab、Banner、分类、课程卡片、学习统计、个人中心
CourseDetail.ets ~479 行 课程详情、章节目录、报名/学习按钮
CoursePlayer.ets ~392 行 视频播放模拟、课时内容、目录面板、导航
EntryAbility.ets ~48 行 应用入口、WindowStage 加载

十一、模块化设计与组件通信深度分析

11.1 组件树与数据流向

整个应用的组件树和数据流向如下:

EntryAbility
  └── Index (首页)
       ├── Header (标题栏)
       ├── HomePage (首页Tab)
       │    ├── BannerSection (轮播区)
       │    ├── CategoryGrid (分类网格)
       │    ├── SectionTitle (章节标题)
       │    └── CourseCard × N (课程卡片)
       ├── CategoriesPage (课程Tab)
       │    ├── CategoryGrid (完整分类)
       │    └── CourseCard × N
       ├── LearningPage (学习Tab)
       │    ├── StatCard × 3 (统计卡片)
       │    ├── CourseCard (在学课程)
       │    ├── CourseCard (即将开始)
       │    └── CourseCard (已完成)
       ├── ProfilePage (我的Tab)
       │    ├── ProfileStat × 4
       │    └── MenuItem × N
       └── TabBar (底部导航)

在这个组件树中,数据是单向流动的

  • @State currentTab 控制 Tab 切换
  • @State enrolledCourses 作为"已报名课程"的单一数据源
  • 所有过滤、计算逻辑(在学/已完成/统计)都基于这两个核心状态推导

11.2 组件复用的最佳实践

CourseCard 是应用中最核心的复用组件,在三个 Tab 中被复用:

Tab 使用场景 数据显示
首页 精选推荐 + 继续学习 推荐课程/在学课程
课程 全部课程列表 所有课程
学习 在学/未开始/已完成分组 已报名课程

这种设计遵循了 DRY(Don’t Repeat Yourself) 原则。如果后续需要修改课程卡片的样式,只需要修改一处 @Builder CourseCard 即可。

11.3 参数传递的深度分析

router 参数传递的局限性
当前实现中,详情页通过 router.getParams() 接收 courseId,然后根据 courseId 从本地数据中查找详细信息:

// 发送方
router.pushUrl({
  url: 'pages/CourseDetail',
  params: { courseId: course.id }
});

// 接收方
aboutToAppear(): void {
  const params = router.getParams() as Record<string, Object>;
  const courseId = params['courseId'] as number;
  const found = COURSE_DETAILS[courseId];
}

局限性分析

  1. 参数类型限制Record<string, Object> 丢失了具体的类型信息,需要二次转换
  2. 数据耦合:目标页面需要能够通过 courseId 自行获取完整数据
  3. 序列化限制:复杂对象(如函数、循环引用对象)无法传递

改进方案
可以考虑使用全局状态存储(AppStorage)实现跨页面数据共享:

// 方案一:AppStorage 共享状态
AppStorage.SetOrCreate('currentCourse', null);

// 在 Index 中设置
AppStorage.Set('currentCourse', course);

// 在 CourseDetail 中读取
@StorageLink('currentCourse') course: CourseFull | null = null;

11.4 状态管理的边界情况

在实际开发中,我们需要考虑以下边界情况:

情况一:courseId 不存在时的降级处理

aboutToAppear(): void {
  const params = router.getParams() as Record<string, Object>;
  const courseId = params['courseId'] as number;
  const found = COURSE_DETAILS[courseId];
  if (found) {
    this.course = found;
  }
  // 如果 course 为 null,UI 中展示 "未找到课程信息"
}

// UI 中的降级展示
if (this.course) {
  // 正常渲染课程详情
} else {
  Column() {
    Text('😅').fontSize(56)
    Text('未找到课程信息').fontSize(16).fontColor('#999')
  }
}

情况二:模拟数据未覆盖所有课程时的默认处理

播放器页面的数据不一定与详情页的 8 门课程一致,我们通过 getDefaultPlayData 提供回退方案:

function getDefaultPlayData(courseId: number): CoursePlayInfo {
  return {
    id: courseId,
    title: '课程学习中...',
    cover: '📚',
    chapters: [/* 默认章节 */]
  };
}

// 使用
const data = PLAY_DATA[courseId] || getDefaultPlayData(courseId);

情况三:已报名课程的跨页面一致性

首页的 enrolledCourses 在详情页报名后需要同步。当前实现中,详情页的报名状态是独立的:

// 详情页 - 使用硬编码的已报名 ID 列表
const enrolledIds = [2, 4, 6, 8];
this.isEnrolled = enrolledIds.indexOf(courseId) >= 0;

生产环境改进方向
通过 AppStorage 或全局单例共享报名状态:

// 全局状态管理示例
class UserStore {
  static enrolledCourses: number[] = [];

  static enroll(courseId: number): void {
    if (!this.enrolledCourses.includes(courseId)) {
      this.enrolledCourses.push(courseId);
    }
  }

  static isEnrolled(courseId: number): boolean {
    return this.enrolledCourses.includes(courseId);
  }
}

十二、声明式 UI 与命令式 UI 的深度对比

12.1 思维模型差异

命令式思维(传统 Android View 体系)

// 伪代码:命令式 UI
TextView titleView = findViewById(R.id.title);
Button enrollBtn = findViewById(R.id.enrollBtn);

// 点击报名按钮后
enrollBtn.setOnClickListener(v -> {
    isEnrolled = true;
    titleView.setText("已报名");
    enrollBtn.setText("开始学习");
    enrollBtn.setBackgroundColor(Color.GREEN);
    // 手动更新每一个受影响的 UI 元素
});

声明式思维(ArkUI)

// ArkTS 声明式
@State isEnrolled: boolean = false;

build() {
  Text(this.isEnrolled ? '已报名' : '课程详情')
  Button(this.isEnrolled ? '📖 开始学习' : '📚 立即报名')
    .backgroundColor(this.isEnrolled ? '#4CAF50' : '#FF6B35')
    .onClick(() => { this.isEnrolled = true; })
}

核心差异:命令式需要开发者手动维护 UI 与状态的一致性;声明式只需要修改状态,框架自动同步 UI。

12.2 重新渲染范围

声明式框架面临的核心挑战是:状态变更后,重新渲染的最小范围是多少?

ArkUI 的优化策略:

  • 组件级脏检查:只有 @State 装饰的变量对应的组件才会重新渲染
  • 按需重建ForEach 配合 keyGenerator 只重建变化的列表项
  • 静态成员优化:不依赖 @State 的 UI 部分不会被重新评估
build() {
  Column() {
    // 这部分在 rerender 时不会重新计算
    Text('职业技能学习平台').fontSize(18).fontColor('#333')

    // 这部分会在 @State currentTab 变化时重新计算
    if (this.currentTab === 0) {
      this.HomePage()
    }
  }
}

12.3 与 Flutter 的对比

维度 ArkUI (ArkTS) Flutter (Dart)
语言基础 TypeScript Dart
声明式语法 @State + if/else setState + build
组件复用 @Builder 函数 Widget 类
布局 Row/Column/Flex/Stack Row/Column/Flex/Stack
性能 C++ 渲染引擎 Skia 渲染引擎
学习曲线 低(前端开发者友好) 中(需学习 Dart)

值得注意的区别是:ArkUI 的 @Builder 比 Flutter 的 Widget 类更轻量——@Builder 是一个函数而非类,不需要创建对象实例,减少了内存开销。


十三、单元测试与调试实践

13.1 测试策略

HarmonyOS 项目默认集成了 @ohos/hypium 测试框架和 @ohos/hamock Mock 框架:

entry/src/test/
├── List.test.ets       # 列表测试
└── LocalUnit.test.ets  # 本地单元测试

对于本应用的测试策略,建议从以下维度覆盖:

单元测试示例(模拟数据验证)

// 测试数据完整性和一致性
describe('MockDataTests', () => {
  it('ALL_COURSES should have 8 items', 0, () => {
    expect(ALL_COURSES.length).assertEqual(8);
  });

  it('COURSE_DETAILS should cover all courses', 0, () => {
    for (const course of ALL_COURSES) {
      expect(COURSE_DETAILS[course.id]).notCheckEqual(undefined);
    }
  });

  it('all categories should be covered by courses', 0, () => {
    const categories = CATEGORIES.map(c => c.name);
    for (const course of ALL_COURSES) {
      expect(categories.indexOf(course.category)).assertGT(-1);
    }
  });
});

业务逻辑测试示例

describe('IndexLogicTests', () => {
  it('getEnrolledCourses should return correct count', 0, () => {
    const enrolledIds = [2, 4, 6, 8];
    const enrolled = ALL_COURSES.filter(c => enrolledIds.indexOf(c.id) >= 0);
    expect(enrolled.length).assertEqual(4);
  });

  it('progress calculation should be correct', 0, () => {
    const course = ALL_COURSES.find(c => c.id === 2); // Python course, 65%
    expect(course?.progress).assertEqual(65);
    expect(course?.learnedLessons).assertEqual(47);
    expect(course?.totalLessons).assertEqual(72);
  });
});

13.2 常见调试技巧

hilog 分级日志
使用 hilog 替代 console.log,支持日志级别过滤(DEBUG/INFO/WARN/ERROR):

import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN = 0x0000;
hilog.info(DOMAIN, 'LearningApp', 'Course loaded: %{public}s', course.title);

DevEco Studio 调试工具链

  1. 预览器(Previewer):模拟器无需启动即可快速预览 UI 变化
  2. ArkUI Inspector:查看 UI 组件树和各组件属性
  3. Profiler:分析帧率、CPU 和内存使用情况

十四、HarmonyOS 与 Android/iOS 的生态差异分析

14.1 从 Android 迁移到 HarmonyOS

Android 概念 HarmonyOS 对应
Activity UIAbility
Fragment @Component + @Builder
ViewModel @State + 计算属性
RecyclerView + Adapter ForEach + @Builder
Intent + Bundle router.pushUrl + params
SharedPreferences Preferences (ArkData)
Gradle (Groovy/Kotlin DSL) hvigor (TypeScript DSL)
AndroidManifest.xml module.json5
Resource (R.class) $r() / $string() / $media()

14.2 资源引用方式

HarmonyOS 使用 $r() 语法引用资源,与 Android 的 R. 类类似但更简洁:

// ArkTS 资源引用
Text($r('app.string.app_name'))
Image($r('app.media.startIcon'))
.backgroundColor($r('app.color.start_window_background'))

// 对应的 resource 文件路径
// resources/base/element/string.json
// resources/base/media/startIcon.png
// resources/base/element/color.json

14.3 构建配置对比

HarmonyOS 的 build-profile.json5 相当于 Android 的 build.gradle

// build-profile.json5
{
  "app": {
    "products": [{
      "name": "default",
      "targetSdkVersion": "6.1.0(23)",   // 相当于 compileSdkVersion
      "compatibleSdkVersion": "6.1.0(23)" // 相当于 minSdkVersion
    }]
  }
}

十五、项目构建与部署指南

15.1 开发环境要求

组件 版本要求
DevEco Studio 5.0+
HarmonyOS SDK 6.1.0 (API 23)
Node.js 18.x+
ohpm 内置

15.2 构建命令

# 清理构建缓存
hvigorw clean

# Debug 构建
hvigorw assembleHap --mode debug -p product=default

# Release 构建(需要签名配置)
hvigorw assembleHap --mode release -p product=default

# 运行测试
hvigorw test

15.3 HAP 包结构

编译产物是 HAP(HarmonyOS Ability Package)包:

entry-default-debug.hap
├── resources/          # 资源文件
├── ets/                # 编译后的 ArkTS 字节码
├── module.json         # 模块配置
└── pack.info           # 包信息

十六、常见问题与解决方案(FAQ)

Q1: @State 修改后 UI 没有更新?

原因:直接修改了数组/对象的属性,没有创建新引用。

// ❌ 错误
this.showAllChapters[idx] = true;

// ✅ 正确
const arr = [...this.showAllChapters];
arr[idx] = true;
this.showAllChapters = arr;

Q2: router.pushUrl 后目标页面无法获取参数?

原因router.getParams() 返回的类型是 Record<string, Object>,需要强制类型转换。

// ✅ 正确做法
const params = router.getParams() as Record<string, Object>;
const courseId = params['courseId'] as number;

// 如果希望在 TypeScript 中保留类型信息,可以用中间变量
const safeParams = router.getParams() as Record<string, Object>;
const courseId = typeof safeParams['courseId'] === 'number' 
  ? safeParams['courseId'] as number 
  : 0;  // 兜底默认值

Q3: ForEach 中复杂列表项性能问题?

解决方案

  1. 始终提供 keyGenerator 函数
  2. 避免在 ForEach 内部进行复杂计算
  3. 考虑使用 LazyForEach 替代 ForEach(支持懒加载,对长列表更友好):
// LazyForEach 示例(需实现 IDatasource)
LazyForEach(this.courseDataSource, (course: CourseItem) => {
  this.CourseCard(course)
}, (course: CourseItem) => course.id.toString())

Q4: Builder 中的事件处理无法访问外部变量?

原因@Builder 函数内部的 this 指向当前组件实例,可以正常访问组件的状态和方法。但如果 @Builder 定义在组件外部,则无法访问内部状态。

// ✅ 组件内的 @Builder 可以访问 this
@Component
struct Index {
  @State currentTab: number = 0

  @Builder
  HomePage() {
    // ✅ 可以访问 this.currentTab
  }
}

Q5: 如何实现页面间的数据共享?

方案优先级

  1. 简单场景:router params 传参
  2. 跨页面共享:AppStorage / LocalStorage
  3. 复杂全局状态:全局单例 + 观察者模式

十七、代码质量保障

17.1 编码规范

本应用遵循以下编码规范:

  1. 命名规范:使用 camelCase 命名变量和函数(getEnrolledCourses),PascalCase 命名组件(CourseCard
  2. 类型优先:所有变量和函数参数必须标注类型,禁止滥用 any
  3. Builder 粒度:每个 @Builder 函数不超过 80 行,超过则拆分为子 Builder
  4. 状态最小化:能用计算属性(get)推导的状态,不用 @State 维护
  5. 资源外置:字符串使用 $string() 引用,颜色使用 $color() 引用

17.2 代码评审检查清单

提交代码前应检查:

  • 是否存在未使用的 import
  • @State 是否按值类型更新(数组/对象是否创建新引用)
  • 定时器/监听器是否在 aboutToDisappear 中清理
  • 所有 router 跳转是否有兜底的错误处理
  • 模拟数据的 key 是否唯一且稳定
  • 组件 Props 是否使用了正确的类型

十八、总结:ArkTS 开发的最佳实践方法论

18.1 核心原则

  1. 状态驱动 UI:永远通过修改状态来更新界面,不要直接操作 DOM 元素
  2. 单一数据源:每个状态有且只有一个控制源,避免多份副本
  3. 组件化思维:识别可复用的 UI 模式,提取为 @Builder 函数
  4. 防御式编程:对路由参数、异步数据、边界条件做防御性检查
  5. 生命周期意识:合理利用 aboutToAppear / aboutToDisappear 管理资源和副作用

18.2 学习路径建议

对于从其他平台转向 HarmonyOS 的开发者,推荐的学习路径:

JavaScript/TypeScript 基础
     ↓
ArkTS 语法(@Component / @State / @Builder)
     ↓
ArkUI 布局系统(Row / Column / Stack / Flex)
     ↓
页面路由与生命周期(router / aboutToAppear)
     ↓
数据持久化(Preferences / KVStore)
     ↓
网络请求(http / @ohos.net.http)
     ↓
多媒体(Video / Audio / Camera)
     ↓
系统能力(传感器 / 地理位置 / 通知)

18.3 最后的话

通过"职业技能学习平台"这个项目,我们完整走过了从需求分析、架构设计、编码实现到性能优化的全流程。HarmonyOS ArkTS 为我们提供了一种高效、直观、类型安全的应用开发方式——如果你有 TypeScript 或前端开发经验,上手成本极低;如果你是 Android 开发者,你会发现声明式 UI 的思维方式虽然不同,但一旦掌握会极大提升开发效率。

在线教育是一个有广阔前景的赛道,而 HarmonyOS 正在快速增长的生态中。希望这篇技术博客能为正在探索 HarmonyOS 应用开发的你提供有价值的参考。


Logo

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

更多推荐