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

职学Pro:HarmonyOS ArkTS 个性化职业技能进阶学习平台开发实战

项目代号: api24
技术栈: HarmonyOS Next + ArkTS + ArkUI
SDK版本: HarmonyOS SDK 6.1.0 (API 23 → API 24)
开发工具: DevEco Studio 5.0+
运行环境: HarmonyOS Phone
源码行数: ~1,840 行 (5 个页面)


一、引言

1.1 项目背景

在数字化转型浪潮下,职业技能学习已成为职场人士持续提升竞争力的核心途径。传统的在线教育平台往往采用"一刀切"的课程推荐方式,忽略了学习者个体差异——不同背景、不同目标的用户需要截然不同的学习路径。基于这一洞察,我们设计并实现了"职学Pro":一款面向职场人士的个性化职业技能进阶学习平台。

"职学Pro"的核心理念是:用 AI 规划从"当前能力"到"目标岗位"的最优学习路径。应用覆盖从技能测评、路径规划、课程学习到进度追踪的完整学习闭环,帮助用户在 1-8 年的职场生涯中高效进阶。

本文将从架构设计、数据模型、核心功能实现、UI/UX 设计、性能优化等方面,完整还原"职学Pro"在 HarmonyOS Next 平台上的开发全流程。

1.2 为什么选择 HarmonyOS ArkTS

在技术选型阶段,我们对比了 Hybrid Web、Flutter 和 HarmonyOS 原生方案:

维度 Web Hybrid Flutter HarmonyOS ArkTS
UI 性能 依赖 WebView,滚动卡顿 Skia 引擎,良好 C++ ArkUI 引擎,原生级
包体积 WebView 运行时 ~20MB 引擎 ~6MB 纯原生,无额外运行时
开发效率 前端经验直接迁移 需学习 Dart TS 语法,前端友好
多设备适配 需响应式适配 需自行适配 原生折叠屏/平板支持
学习曲线

最终选择 HarmonyOS ArkTS 的核心原因有三:

  1. 开发效率:ArkTS 基于 TypeScript 语法,前端开发者零成本上手,静态类型检查减少运行时错误。
  2. UI 性能:ArkUI 声明式框架采用 C++ 引擎渲染,列表滚动流畅度相比 Web 方案提升 30%~60%。
  3. 生态红利:HarmonyOS Next 原生提供路由、媒体、网络、存储等 Kit 能力,无需引入第三方库。

1.3 应用功能全景图

职学Pro
├── 首页(四Tab导航)
│   ├── 首页Tab — Banner轮播 / 分类入口 / 学习路径 / 技能雷达 / 继续学习
│   ├── 课程Tab — 分类筛选 / 课程列表 / 进度展示
│   ├── 学习Tab — 统计看板 / 在学/已完成/未开始分组
│   └── 我的Tab — 用户信息 / 数据统计 / 功能菜单
├── 技能评估
│   ├── 目标岗位选择(多岗位)
│   ├── Canvas 雷达图(当前 vs 目标对比)
│   ├── 技能详情列表与差距分析
│   └── 能力差距报告与推荐路径
├── 课程详情
│   ├── 课程头部(渐变背景 / 讲师 / 评分 / 标签)
│   ├── 课程简介与信息统计
│   ├── 章节目录(可展开/折叠 / 免费标识 / 完成状态)
│   └── 底部操作栏(报名/继续学习)
├── 课程播放
│   ├── 视频播放器模拟(播放/暂停占位)
│   ├── 上下节导航与边界处理
│   ├── 课程目录面板(快速跳转)
│   ├── 课时内容展示
│   └── 完成进度统计
└── 学习路径
    ├── 总体进度概览
    ├── 6周里程碑时间线
    ├── 阶段详情展开
    └── 继续学习 / 调整计划操作

二、项目架构设计

2.1 整体架构层次

应用采用经典的单 Ability + 多 Page 架构模式,遵循 HarmonyOS Stage 模型规范。整个应用的组件层级如下:

EntryAbility (UIAbility)
  └── loadContent → pages/Index (App Launcher)
       └── router.pushUrl → vocational_platform/MainPage
            ├── @State currentTab 控制 4 个 Builder 切换
            ├── router → CourseDetail
            ├── router → CoursePlayer
            ├── router → SkillAssessment
            └── router → LearningPathDetail

架构决策亮点

  • 单 UIAbility:使用单个 Ability 管理所有页面,通过 WindowStage 加载首页,后续页面通过 router API 栈式导航,避免多 Ability 的进程开销。
  • 路由注册:所有页面在 main_pages.json 中统一声明,每个 @Entry 组件对应一个路由路径。
  • Builder 化 Tab:四个 Tab 的内容区通过 @Builder 函数独立封装,由 @State currentTab 驱动条件渲染切换。

关键的路由配置如下:

{
  "src": [
    "pages/Index",
    "pages/vocational_platform/MainPage",
    "pages/vocational_platform/CourseDetail",
    "pages/vocational_platform/CoursePlayer",
    "pages/vocational_platform/SkillAssessment",
    "pages/vocational_platform/LearningPathDetail"
  ]
}

2.2 页面路由与参数传递

页面间导航使用 @kit.ArkUI 提供的 router 模块,参数传递采用 params 字典:

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

// 发送方:携带 courseId
goToDetail(course: CourseItem): void {
  router.pushUrl({
    url: 'pages/vocational_platform/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];
  if (found) {
    this.course = found;
    this.isEnrolled = this.enrolledIds.indexOf(courseId) >= 0;
    this.showAllChapters = found.chapters.map(() => true);
  }
}

参数传递的边界处理:当 courseId 不存在于模拟数据中时,UI 展示降级内容——"未找到课程信息"并附带返回按钮,避免白屏。

2.3 模块化设计

每个页面的数据模型、模拟数据和 UI 逻辑都封装在同一个 .ets 文件中,通过 interface 定义类型边界,通过模块级 const 维护模拟数据。这种设计在 MVP 阶段可以快速迭代,后续迁出为独立数据服务层时只需提取接口定义和数据常量。

组件复用通过 @Builder 装饰器实现,四个 Tab 共享 StatCardProfileStatEmptyState 等构建函数,避免重复的 UI 代码。


三、数据模型与类型系统

3.1 强类型定义体系

ArkTS 作为 TypeScript 的超集,全面支持静态类型检查。我们为应用定义了 8 个核心接口,覆盖所有业务场景:

// 课程摘要(列表场景)
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 CourseFull {
  id: number;
  title: string;
  // ... 与 CourseItem 同名字段
  chapters: Chapter[];      // 多出的章节树
}

// 章节与课时
interface Chapter {
  id: number; title: string; lessons: Lesson[];
}
interface Lesson {
  id: number; title: string; duration: string;
  isFree: boolean; isCompleted: boolean;
}

// 其他模型
interface SkillItem { name: string; level: number; color: string; }
interface CategoryItem { id: number; name: string; icon: string; color: string; }
interface BannerItem { title: string; subtitle: string; color: string; }
interface PathNode { name: string; done: boolean; active?: boolean; }

设计考量:将 CourseItemCourseFull 分离而非合并为一个类型。列表场景仅需摘要信息(无章节数据),详情场景需要完整的章节树。分离后:

  • 列表接口返回轻量数据,减少数据传输
  • 各自场景类型精确,避免误用
  • 未来接入真实 API 时可独立优化

3.2 模拟数据层设计

在 MVP 阶段,数据存储在模块级常量中,使用 Record<number, T> 字典结构实现 O(1) 查找:

// 课程字典:key = courseId
const COURSE_DETAILS: Record<number, CourseFull> = {
  1: { id: 1, title: 'Vue3 + TypeScript 企业级实战', ... },
  // 每门课程包含完整的章节树
};

// 播放器数据(独立于详情数据,模拟真实接口隔离)
const PLAY_DATA: Record<number, CoursePlayInfo> = {
  1: { id: 1, title: 'Vue3 + TypeScript 企业级实战', chapters: [...] },
};

防御式设计:对于未覆盖到的 courseId,提供默认回退数据:

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

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

4.1 Tab 切换架构

MainPage.ets 是应用中结构最复杂的页面,采用 底部Tab导航 + Stack内容区切换 的经典布局。核心状态只有一个 @State currentTab: number,驱动四个 @Builder 的条件渲染:

@Entry
@Component
struct VocationalMainPage {
  @State currentTab: number = 0;
  @State bannerIndex: number = 0;
  @State enrolledIds: number[] = [1, 2, 4, 6, 7, 8];
  @State selectedCategory: string = '全部';

  build() {
    Column() {
      // 顶部标题栏(根据 currentTab 显示不同标题)
      Header()

      // 内容区
      Stack() {
        if (this.currentTab === 0) { this.HomePage() }
        else if (this.currentTab === 1) { this.CoursesPage() }
        else if (this.currentTab === 2) { this.LearningPage() }
        else { this.ProfilePage() }
      }
      .layoutWeight(1).width('100%')

      // 底部导航栏
      Row() {
        this.TabItem(0, '🏠', '首页')
        this.TabItem(1, '📚', '课程')
        this.TabItem(2, '📖', '学习')
        this.TabItem(3, '👤', '我的')
      }
      .width('100%').height(60)
      .backgroundColor('#FFF')
      .shadow({ radius: 6, color: '#1A000000', offsetY: -3 })
    }
  }
}

TabItem 实现:通过 @Builder 封装 Tab 按钮,根据 currentTab === index 切换图标大小、文字颜色和粗细,高亮当前选中的 Tab。

4.2 首页Tab的数据驱动设计

首页 Tab 是整个应用的信息聚合中心,包含 Banner 轮播、分类入口、学习路径、技能雷达和继续学习五个模块。所有模块共享同一个数据源 enrolledIdsALL_COURSES,通过计算属性派生不同视图:

// 已报名课程列表
get enrolledList(): CourseItem[] {
  return ALL_COURSES.filter(c => this.enrolledIds.indexOf(c.id) >= 0);
}

// 在学课程(进度 > 0 且 < 100)
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);
}

这种设计遵循单一数据源原则:enrolledIds 是唯一需要维护的状态,所有派生数据由 get 访问器自动计算。当 enrolledIds 变化时,所有依赖它的 UI 自动更新。

4.3 Banner 轮播实现

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

startBannerLoop(): void {
  this.bannerTimer = setInterval(() => {
    this.bannerIndex = (this.bannerIndex + 1) % BANNERS.length;
  }, 3500);
}

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

生命周期管理要点aboutToAppear 启动轮播,aboutToDisappear 清理定时器。如果不清理定时器,页面销毁后定时器仍会运行,尝试更新已销毁的 @State 变量,导致内存泄漏。

Banner 指示器采用动态宽度胶囊圆点设计:

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

高亮圆点宽度是普通圆点的 3 倍多,配合半透明到纯白色的过渡,用户能直观感知当前轮播位置。相比传统固定圆点方案,这种"流动指示器"交互反馈更生动。

4.4 分类网格

首页展示 6 个分类入口,点击后自动切换到课程 Tab 并选中对应分类:

ForEach(CATEGORIES, (cat: CategoryItem) => {
  Column() {
    Text(cat.icon).fontSize(26)
    Text(cat.name).fontSize(11).fontColor('#555')
  }
  .layoutWeight(1).padding({ top: 10, bottom: 8 })
  .backgroundColor('#FFF').borderRadius(12)
  .onClick(() => {
    this.selectedCategory = cat.name;  // 设置分类过滤
    this.currentTab = 1;               // 切换到课程Tab
  })
})

layoutWeight(1) 实现等分布局,6 列在屏幕上均匀排列,适配不同屏幕宽度。

4.5 课程Tab的分类筛选

课程 Tab 实现了横向滚动的分类标签栏 + 纵向课程列表的经典布局。分类标签支持"全部"按钮和 6 个具体分类,选中状态使用蓝底白字高亮:

Scroll() {
  Row({ space: 8 }) {
    // "全部" 按钮
    Column() {
      Text('全部').fontSize(13)
        .fontColor(this.selectedCategory === '全部' ? '#FFF' : '#555')
    }
    .backgroundColor(this.selectedCategory === '全部' ? '#4A6CF7' : '#FFF')
    .borderRadius(16)
    .onClick(() => { this.selectedCategory = '全部' })

    // 分类标签
    ForEach(CATEGORIES, (cat: CategoryItem) => {
      Row({ space: 4 }) {
        Text(cat.icon).fontSize(14)
        Text(cat.name).fontSize(13)
          .fontColor(this.selectedCategory === cat.name ? '#FFF' : '#555')
      }
      .backgroundColor(this.selectedCategory === cat.name ? '#4A6CF7' : '#FFF')
      .borderRadius(16)
      .onClick(() => { this.selectedCategory = cat.name })
    })
  }
}
.scrollable(ScrollDirection.Horizontal)  // 横向滚动

课程列表根据 selectedCategory 动态过滤,显示"共 N 门课程"的统计信息。每张课程卡片展示封面 emoji、标题、讲师、评分、标签和价格,已报名且进度的课程额外显示进度条和学习统计。

4.6 学习Tab的统计看板

学习 Tab 顶部是三张统计卡片(在学/已完成/未开始),使用 @Builder StatCard 复用:

@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(11).fontColor('#888')
  }
  .layoutWeight(1).padding(12)
  .backgroundColor('#FFF').borderRadius(14)
  .alignItems(HorizontalAlign.Center)
}

三种状态使用不同颜色标识——蓝色(在学)、绿色(已完成)、橙色(未开始),视觉上形成清晰的三色对比。下方是详细统计区(总课时/已报名/连续打卡)和在学课程列表,每条显示课程名称和进度条。

4.7 个人Tab

个人中心包含用户信息卡片(渐变背景 + 头像 + 学习时长)、四项数据统计(已报名/已完成/证书/打卡)以及分组功能菜单(学习记录/证书中心/收藏/会员中心/优惠券/消息通知/深色模式/设置)。

设计上采用卡片分组的方式,每组菜单项之间用 #F0F0F0 分割线分隔,底部放置退出登录按钮。


五、技能评估与Canvas雷达图实现

5.1 功能设计

技能评估是"职学Pro"个性化功能的核心入口。用户选择目标岗位后,应用展示当前技能水平与目标岗位能力要求的差距分析,并推荐学习路径。

交互流程

  1. 用户选择目标岗位(如"高级前端工程师"、"全栈工程师"等)
  2. 页面展示 Canvas 绘制的雷达图(蓝色实线=当前水平,橙色虚线=目标水平)
  3. 技能详情列表逐项展示当前值、目标值和差距百分比
  4. 能力差距报告给出具体改进建议
  5. 一键生成学习路径

5.2 Canvas 雷达图实现

雷达图使用 ArkUI 的 Canvas 组件,配合 CanvasRenderingContext2D API 绘制。这是应用中技术含量最高的 UI 组件:

@Builder
RadarChart() {
  Canvas(this.radarContext)
    .width(280).height(280)
    .onReady(() => { this.drawRadar(); })
}

drawRadar(): void {
  const ctx = this.radarContext;
  const cx = 140, cy = 140, r = 110;  // 圆心、半径
  const count = MY_ASSESS.length;
  const angleStep = (2 * Math.PI) / count;
  const startAngle = -Math.PI / 2;  // 从顶部开始

  // 1. 绘制 3 层网格
  for (let layer = 1; layer <= 3; layer++) {
    const radius = (r / 3) * layer;
    ctx.beginPath();
    for (let i = 0; i <= count; i++) {
      const angle = startAngle + i * angleStep;
      const x = cx + radius * Math.cos(angle);
      const y = cy + radius * Math.sin(angle);
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
    ctx.closePath();
    ctx.strokeStyle = '#E0E0E0';
    ctx.lineWidth = 1;
    ctx.stroke();
  }

  // 2. 绘制 6 条轴线(从圆心到顶点)
  for (let i = 0; i < count; i++) {
    const angle = startAngle + i * angleStep;
    const x = cx + r * Math.cos(angle);
    const y = cy + r * Math.sin(angle);
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.lineTo(x, y);
    ctx.stroke();
  }

  // 3. 绘制当前技能多边形(蓝色实线 + 半透明填充)
  ctx.beginPath();
  for (let i = 0; i <= count; i++) {
    const idx = i % count;
    const angle = startAngle + idx * angleStep;
    const level = MY_ASSESS[idx].level / 100;
    const x = cx + r * level * Math.cos(angle);
    const y = cy + r * level * Math.sin(angle);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.closePath();
  ctx.fillStyle = '#4A6CF733';   // 半透明填充
  ctx.fill();
  ctx.strokeStyle = '#4A6CF7';    // 蓝色实线
  ctx.lineWidth = 2;
  ctx.stroke();

  // 4. 绘制目标技能多边形(橙色虚线)
  ctx.beginPath();
  // ... 类似逻辑,使用 target 值
  ctx.strokeStyle = '#F59E0B';
  ctx.lineWidth = 2;
  ctx.setLineDash([5, 3]);  // 虚线效果
  ctx.stroke();
  ctx.setLineDash([]);      // 重置

  // 5. 绘制标签文本
  ctx.font = '12px HarmonyOS Sans';
  for (let i = 0; i < count; i++) {
    // ... 计算标签位置并绘制
  }

  // 6. 图例
  ctx.fillStyle = '#4A6CF7';
  ctx.fillRect(10, 250, 12, 3);
  ctx.fillText('当前', 26, 254);
  // 橙色虚线图例
}

Canvas 实现的三个关键技术要点

  1. 坐标计算:使用极坐标转直角坐标公式 x = cx + r * cos(angle) / y = cy + r * sin(angle)angle-π/2(顶部)开始顺时针分布。

  2. 虚实线对比:当前水平用实线 + 半透明填充,目标水平用虚线(setLineDash),视觉上明确区分"已掌握"和"待提升"。

  3. 分层绘制顺序:网格 → 轴线 → 当前多边形 → 目标多边形 → 标签 → 图例,后绘制的图层覆盖先绘制的图层,确保标签在最上层不受干扰。

5.3 能力差距报告

当用户选择目标岗位后,页面展示能力差距分析报告,用橙色卡片高亮差距最大的技能项,并给出具体改进建议:

⚠️ TypeScript    差距 30%
  重点提升:TS 高级类型、泛型、装饰器

⚠️ Node.js       差距 25%
  补充:Node 后端开发、API 设计与数据库

⚠️ React         差距 18%
  进阶:深入源码、性能优化、状态管理

每项差距使用 #FFF8E1 浅橙背景卡片展示,差距百分比用 #FF6B35 高亮显示。底部提供"生成学习路径"按钮,一键跳转到学习路径详情页。


六、课程详情页实现

6.1 页面结构

CourseDetail.ets 分为四个区域:

  • 顶栏:返回按钮 + 标题 + 分享按钮
  • 课程头部:渐变背景 + 封面 emoji + 标题 + 讲师信息 + 评分 + 标签 + 进度条
  • 内容区:课程简介 + 信息统计(时长/课时/分类)+ 章节目录
  • 底部操作栏:价格 + 报名/开始学习按钮

6.2 动态章节展开/折叠

章节目录是详情页最核心的交互区域,使用 @State showAllChapters: boolean[] 数组控制每个章节的展开状态:

@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;
}

ArkTS 响应式更新的重要原则@State 通过引用比较检测变化。对于数组类型,直接修改元素 this.showAllChapters[idx] = !this.showAllChapters[idx] 不会触发 UI 更新。必须创建新数组 [...this.showAllChapters],修改后重新赋值,让引用发生变化。

每个课时项的 UI 包含三种状态标识:

  • ✅ 已完成(绿色背景 + 勾选图标)
  • 🔓 免费试看(可点击,绿色"免费"标签)
  • 🔒 锁定(未报名且非免费,不可点击)
Row() {
  Text(lesson.isCompleted ? '✅' : lesson.isFree ? '🔓' : '🔒')
  Text(lesson.title).fontSize(13).fontColor('#444')
  Text(lesson.duration).fontSize(11).fontColor('#AAA')
  if (lesson.isFree) {
    Text('免费').fontSize(9).fontColor('#FFF')
      .backgroundColor('#4CAF50').borderRadius(4)
  }
}
.onClick(() => {
  if (this.isEnrolled || lesson.isFree) {
    this.goPlay(lesson.id);  // 已报名或免费课时可播放
  }
})

这是在线教育平台的经典"免费试看"商业模式——免费课时吸引用户体验,付费课时保障内容价值。

6.3 报名与开始学习

底部操作按钮的交互逻辑体现了状态驱动的设计思想:

Button(this.isEnrolled ? '📖 继续学习' : '📚 立即报名')
  .backgroundColor(this.isEnrolled ? '#4CAF50' : '#FF6B35')
  .onClick(() => {
    if (!this.isEnrolled) {
      this.isEnrolled = true;  // 状态变更 → UI 自动更新
    }
    const firstLesson = this.course!.chapters[0]?.lessons[0];
    if (firstLesson) {
      this.goPlay(firstLesson.id);  // 报名后自动跳转
    }
  })

设计要点

  • 按钮文案和颜色随 isEnrolled 状态自动切换,无需手动操作 DOM
  • 未报名显示橙色"立即报名",报名后显示绿色"继续学习"
  • 报名后自动跳转到第一个课时,降低用户操作路径

七、课程播放器实现

7.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;        // 播放状态
}

7.2 课时导航与边界处理

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

nextLesson(): void {
  const ch = this.courseData!.chapters[this.currentChapterIdx];
  if (this.currentLessonIdx < ch.lessons.length - 1) {
    // 情况1:当前章节内还有下一课时 → 同章内推进
    this.currentLessonIdx++;
  } else if (this.currentChapterIdx < this.courseData!.chapters.length - 1) {
    // 情况2:进入下一章节的第一课时
    this.currentChapterIdx++;
    this.currentLessonIdx = 0;
  } else {
    // 情况3:已是最后一节课时 → 不操作(也可设计为循环或提示完成)
    return;
  }
  // 更新当前课时 ID
  this.currentLessonId = this.courseData!.chapters[this.currentChapterIdx].lessons[this.currentLessonIdx].id;
  this.isPlaying = false;  // 切换课时后暂停
}

逻辑边界覆盖

  1. 同章内推进:lessonIdx + 1
  2. 跨章推进:chapterIdx + 1, lessonIdx = 0
  3. 最后一节:return 无操作
  4. 切换后暂停:isPlaying = false

prevLesson() 的逻辑是对称的,先尝试同章内回退,再尝试跨章回退。

7.3 课程目录面板

目录面板(OutlinePanel)提供快速跳转到任意课时的能力。通过 @State showOutline 控制面板的展开/折叠:

// 目录面板中的课时状态标识
Text(lesson.isCompleted ? '✅' :
     lesson.id === this.currentLessonId ? '▶️' : '●')

// 当前播放课时使用蓝色背景高亮
.backgroundColor(lesson.id === this.currentLessonId ? '#F0F4FF' : '#FFFFFF')

三种状态图标:✅ 已完成 / ▶️ 当前播放 / ● 未完成,配合背景色高亮,用户一目了然。点击任意课时立即跳转并关闭面板:

goToLesson(ci: number, li: number): void {
  this.currentChapterIdx = ci;
  this.currentLessonIdx = li;
  this.currentLessonId = this.courseData!.chapters[ci].lessons[li].id;
  this.isPlaying = false;
  this.showOutline = false;  // 跳转后自动关闭面板
}

7.4 视频播放器模拟与演进路径

在 MVP 阶段,视频播放器使用样式占位模拟播放状态:

Stack() {
  Column() {
    if (this.isPlaying) {
      Text('▶ 播放中...')
      Text(this.currentLesson?.title || '')
    } else {
      Text(this.courseData?.cover || '📚').fontSize(56)
      Text('点击播放开始学习')
    }
  }
  // 渐变背景(深色营造影院感)
  .linearGradient({
    direction: GradientDirection.RightBottom,
    colors: [['#1a1a2e', 0], ['#16213e', 1]]
  })
  // 播放/暂停按钮
  Circle().width(56).height(56).fill('#FFFFFF').opacity(0.2)
  Text(this.isPlaying ? '⏸' : '▶').fontSize(24)
    .onClick(() => this.togglePlay())
}

架构预留:模拟播放器保留了替换为真实 Video 组件的接口:

// 生产环境替换方案
Video({
  src: this.currentLesson?.videoUrl,
  controller: this.videoController
})
.width('100%').height(220)
.controls(true)
.onStart(() => { /* 播放开始 */ })
.onFinish(() => { /* 标记课时完成 */ })

这种"先模拟后替换"的策略让 MVP 阶段可以快速验证交互流程,而不必等待视频素材和 CDN 配置就绪。

7.5 学习进度统计

通过计算属性实时统计学习进度,配合 Progress 组件展示:

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;
}

// UI
Progress({ value: this.completedLessons, total: this.totalLessons })
  .width('94%').height(4).color('#667eea').backgroundColor('#E8ECF4')
Text('已完成 ' + this.completedLessons + '/' + this.totalLessons)

"完成并继续"按钮的设计实现了学习行为的闭环——完成当前课时标记后自动跳转到下一节:

Text('完成并继续 >')
  .onClick(() => {
    // 标记当前课时为已完成
    const ch = this.courseData.chapters[this.currentChapterIdx];
    const lesson = ch.lessons[this.currentLessonIdx];
    if (lesson) lesson.isCompleted = true;
    // 自动跳转到下一节
    this.nextLesson();
  })

八、学习路径详情页

8.1 功能设计

LearningPathDetail.ets 展示从"当前能力"到"目标岗位"的完整学习路线。应用内置了一条 6 周的"高级前端工程师"学习路径,包含基础巩固、框架进阶、工程化构建、全栈扩展、项目实战和架构面试六个阶段。

每个阶段(Milestone)的数据结构:

interface Milestone {
  week: number;
  title: string;        // 如"第一周:基础巩固"
  desc: string;         // 如"夯实核心语言与框架基础"
  status: string;       // 'completed' | 'in-progress' | 'locked'
  courses: string[];    // 推荐课程名称列表
}

8.2 时间线 UI 实现

时间线(Timeline)是学习路径页的核心 UI 模式。每行左侧是圆形节点 + 连接线,右侧是阶段详情:

Row() {
  // 左侧:时间轴节点
  Column() {
    Circle().width(24).height(24)
      .fill(ms.status === 'completed' ? '#4CAF50' :
            ms.status === 'in-progress' ? '#F59E0B' : '#E0E0E0')
    if (idx < LEARNING_PATH.length - 1) {
      Column().width(2).layoutWeight(1)
        .backgroundColor(ms.status === 'completed' ? '#4CAF50' : '#E0E0E0')
    }
  }
  .width(30).alignItems(HorizontalAlign.Center)

  // 右侧:阶段内容
  Column() {
    Text('第' + ms.week + '周').fontSize(10)
      .fontColor('#FFF')
      .backgroundColor(ms.status === 'completed' ? '#4CAF50' :
                       ms.status === 'in-progress' ? '#F59E0B' : '#888')
      .borderRadius(6)
    Text(ms.title).fontSize(14).fontColor('#333').fontWeight(FontWeight.Medium)
    Text(ms.desc).fontSize(11).fontColor('#888')
    // 推荐课程标签
    Row({ space: 4 }) {
      ForEach(ms.courses, (c: string) => {
        Text(c).fontSize(10).fontColor('#4A6CF7')
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor('#EEF0FF').borderRadius(6)
      })
    }
    // 展开详情(仅在 completed/in-progress 状态可用)
    if (ms.status === 'in-progress' || ms.status === 'completed') {
      Text('查看详情 ▾').onClick(() => { /* 切换展开状态 */ })
      if (this.selectedWeek === ms.week) {
        // 详细的周学习计划
      }
    }
  }
}

状态颜色编码

  • 已完成:绿色(#4CAF50)节点 + 绿色连接线
  • 进行中:橙色(#F59E0B)节点 + 灰色连接线
  • 锁定:灰色(#E0E0E0)节点 + 灰色连接线

8.3 顶部概览

路径详情页顶部展示总体进度概览,包含已完成/进行中/待完成阶段数量、总进度百分比和进度条。这种"先总览后详情"的信息层级让用户快速了解自己在整个学习路径中的位置。


九、UI/UX 设计

9.1 色彩体系

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

用途 色值 含义
主色 #4A6CF7 专业、信任、沉稳
辅色 #7C3AED 创意、深度、成长
强调色 #F59E0B(暖橙) 高亮、勋章、完成状态
成功色 #4CAF50(绿色) 已完成、免费
报名色 #FF6B35(橙色) 行动、热情
背景色 #F5F7FA 干净、舒适、不刺眼
卡片色 #FFFFFF 内容突出、层次分明

9.2 卡片化设计

所有内容区块使用圆角卡片 + 轻微阴影的 Material Design 风格:

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

阴影参数调优

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

9.3 渐变背景应用

应用中多处使用渐变背景提升视觉层次感:

  • Banner:水平渐变 #667eea → #1a1a2e,从左到右由专业蓝过渡到深沉色
  • 课程头部:对角线渐变 #667eea → #764ba2,蓝紫色过渡
  • 播放器:对角线渐变 #1a1a2e → #16213e,深沉色营造影院感
  • 个人中心:底部渐变 #EEF0FF → #FFF,微弱的蓝色光晕

9.4 空状态设计

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

📚
还没有报名任何课程
去首页挑选感兴趣的课程吧
[ 去选课 ]

空状态设计三要素:

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

9.5 视觉效果与交互细节

  • Banner 指示器:动态宽度胶囊圆点替代固定圆点,当前页更宽
  • Tab 切换:选中态图标放大 + 文字加粗 + 主题色
  • 章节展开/ 图标指示展开/折叠状态
  • 状态标识:三种图标区分课时状态(✅/▶️/●)
  • 加载降级:课程不存在时显示友好的错误提示

十、性能优化与最佳实践

10.1 列表渲染优化

ArkUI 的 ForEach 组件在渲染列表时,第三个参数 keyGenerator 至关重要:

// ✅ 提供稳定的 key,框架仅重建变化的项
ForEach(ALL_COURSES, (course: CourseItem) => {
  this.CourseCard(course)
}, (course: CourseItem) => course.id.toString())

不加 key 时,列表项变化会导致全量重建;加上唯一且稳定的 key(如 course.id),框架通过 key 精确追踪每个列表项的变更,只重建变化的项。

10.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)
    })
  }
}

10.3 内存管理

定时器必须在页面销毁时清理,防止内存泄漏:

aboutToDisappear(): void {
  if (this.bannerTimer >= 0) {
    clearInterval(this.bannerTimer);
  }
}

10.4 避免不必要的重新渲染

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

// ❌ 错误:不会触发 UI 更新
this.showAllChapters[idx] = !this.showAllChapters[idx];

// ✅ 正确:创建新数组,引用变化触发更新
const arr = [...this.showAllChapters];
arr[idx] = !arr[idx];
this.showAllChapters = arr;

10.5 计算属性代替手动状态

利用 get 访问器定义派生状态,保持状态源唯一:

// 在学课程:从 enrolledList 过滤
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);
}

优势:状态源唯一(enrolledIds),派生状态自动同步,避免手动维护多个状态变量导致的不一致问题。


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

11.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[];
}

11.2 状态管理升级

应用规模增长后,可引入跨组件状态共享:

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

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

@StorageLink 实现跨组件状态同步——一个组件修改状态后,所有监听的组件自动更新。

11.3 真实视频播放集成

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

Video({
  src: this.currentLesson?.videoUrl,
  controller: this.videoController
})
.width('100%').height(220)
.controls(true)
.onStart(() => { /* 开始播放 */ })
.onFinish(() => { /* 播放完成 → 自动标记课时完成 */ })

11.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();
}

11.5 AI 推荐引擎

"职学Pro"的 AI 推荐引擎可以从以下维度规划:

  1. 技能差距分析:将用户当前技能向量与目标岗位技能向量对比,计算差距矩阵
  2. 路径优化算法:基于先修关系图,使用拓扑排序生成最优学习序列
  3. 学习行为调整:根据用户学习速度、完成率动态调整路径强度
  4. 端侧推理:利用 HarmonyOS NUIE(Neural Network Inference Engine)在端侧运行轻量模型

十二、调试与测试实践

12.1 hilog 分级日志

使用 hilog 替代 console.log,支持日志级别过滤:

import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN = 0x0000;
hilog.info(DOMAIN, 'VocationalApp', 'Course loaded: %{public}s', course.title);

12.2 DevEco Studio 调试工具链

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

12.3 测试策略建议

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);
    }
  });
});

十三、总结与展望

13.1 项目成果

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

维度 达成情况
功能完整性 覆盖技能测评→路径规划→课程学习→进度追踪全流程
代码量 5 个页面共约 1,840 行 ArkTS 代码
架构设计 单 Ability + 多 Page,@Builder 组件化,get 计算属性
用户体验 卡片化设计、渐变色彩、Canvas 雷达图、时间线
边界处理 空状态引导、数据降级、定时器清理、数组响应式更新

13.2 技术收获

  1. ArkTS 声明式编程@State + @Builder 的组合极大提升了 UI 开发效率。一个 MainPage.ets 约 720 行代码实现 4 个 Tab 的全部功能,这在传统 Android 开发中需要 5-6 个文件。

  2. Canvas 雷达图绘制:通过 CanvasRenderingContext2D 实现了真正的自定义绘制组件,掌握了极坐标转直角坐标、图层叠加、虚实线对比等图形学基础。

  3. 响应式思维转变:从"如何操作 DOM"到"数据变了 UI 自动更新"的思维转变。ArkUI 的响应式系统虽然不如前端框架灵活,但在原生平台上的性能表现令人满意。

  4. 边界情况处理:路由参数缺失、模拟数据回退、数组响应式更新陷阱(必须创建新引用)、定时器生命周期管理等,这些经验的积累对后续开发至关重要。

13.3 未来规划

  • AI 推荐引擎:基于端侧 NUIE 引擎实现技能差距分析和个性化路径规划
  • 真实视频播放:集成 HarmonyOS Video 组件,支持倍速、清晰度切换
  • 离线下载:利用 ArkData 实现课程离线缓存
  • 社区互动:增加问答、笔记、学习小组等社交化功能
  • 多设备适配:适配折叠屏和平板,优化大屏学习体验
  • 企业版:团队管理、定制课程、学习数据看板
  • 导师 1v1:预约系统、视频连麦、评价体系

13.4 写给开发者的话

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

ArkTS 开发的五个核心原则

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

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

Logo

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

更多推荐