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



职学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 的核心原因有三:
- 开发效率:ArkTS 基于 TypeScript 语法,前端开发者零成本上手,静态类型检查减少运行时错误。
- UI 性能:ArkUI 声明式框架采用 C++ 引擎渲染,列表滚动流畅度相比 Web 方案提升 30%~60%。
- 生态红利: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 加载首页,后续页面通过
routerAPI 栈式导航,避免多 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 共享 StatCard、ProfileStat、EmptyState 等构建函数,避免重复的 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; }
设计考量:将 CourseItem 和 CourseFull 分离而非合并为一个类型。列表场景仅需摘要信息(无章节数据),详情场景需要完整的章节树。分离后:
- 列表接口返回轻量数据,减少数据传输
- 各自场景类型精确,避免误用
- 未来接入真实 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 轮播、分类入口、学习路径、技能雷达和继续学习五个模块。所有模块共享同一个数据源 enrolledIds 和 ALL_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"个性化功能的核心入口。用户选择目标岗位后,应用展示当前技能水平与目标岗位能力要求的差距分析,并推荐学习路径。
交互流程:
- 用户选择目标岗位(如"高级前端工程师"、"全栈工程师"等)
- 页面展示 Canvas 绘制的雷达图(蓝色实线=当前水平,橙色虚线=目标水平)
- 技能详情列表逐项展示当前值、目标值和差距百分比
- 能力差距报告给出具体改进建议
- 一键生成学习路径
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 实现的三个关键技术要点:
-
坐标计算:使用极坐标转直角坐标公式
x = cx + r * cos(angle)/y = cy + r * sin(angle),angle从-π/2(顶部)开始顺时针分布。 -
虚实线对比:当前水平用实线 + 半透明填充,目标水平用虚线(
setLineDash),视觉上明确区分"已掌握"和"待提升"。 -
分层绘制顺序:网格 → 轴线 → 当前多边形 → 目标多边形 → 标签 → 图例,后绘制的图层覆盖先绘制的图层,确保标签在最上层不受干扰。
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; // 切换课时后暂停
}
逻辑边界覆盖:
- 同章内推进:
lessonIdx + 1 - 跨章推进:
chapterIdx + 1, lessonIdx = 0 - 最后一节:
return无操作 - 切换后暂停:
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 空状态设计
当用户没有报名任何课程时,展示引导性的空状态:
📚
还没有报名任何课程
去首页挑选感兴趣的课程吧
[ 去选课 ]
空状态设计三要素:
- 图标:大号 emoji(
fontSize: 48),传递情感 - 文案:说明原因 + 提供行动指引
- 按钮:一键跳转到课程 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 推荐引擎可以从以下维度规划:
- 技能差距分析:将用户当前技能向量与目标岗位技能向量对比,计算差距矩阵
- 路径优化算法:基于先修关系图,使用拓扑排序生成最优学习序列
- 学习行为调整:根据用户学习速度、完成率动态调整路径强度
- 端侧推理:利用 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 调试工具链
- 预览器(Previewer):无需启动模拟器即可快速预览 UI 变化
- ArkUI Inspector:查看 UI 组件树和各组件属性
- 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 技术收获
-
ArkTS 声明式编程:
@State+@Builder的组合极大提升了 UI 开发效率。一个MainPage.ets约 720 行代码实现 4 个 Tab 的全部功能,这在传统 Android 开发中需要 5-6 个文件。 -
Canvas 雷达图绘制:通过
CanvasRenderingContext2D实现了真正的自定义绘制组件,掌握了极坐标转直角坐标、图层叠加、虚实线对比等图形学基础。 -
响应式思维转变:从"如何操作 DOM"到"数据变了 UI 自动更新"的思维转变。ArkUI 的响应式系统虽然不如前端框架灵活,但在原生平台上的性能表现令人满意。
-
边界情况处理:路由参数缺失、模拟数据回退、数组响应式更新陷阱(必须创建新引用)、定时器生命周期管理等,这些经验的积累对后续开发至关重要。
13.3 未来规划
- AI 推荐引擎:基于端侧 NUIE 引擎实现技能差距分析和个性化路径规划
- 真实视频播放:集成 HarmonyOS Video 组件,支持倍速、清晰度切换
- 离线下载:利用 ArkData 实现课程离线缓存
- 社区互动:增加问答、笔记、学习小组等社交化功能
- 多设备适配:适配折叠屏和平板,优化大屏学习体验
- 企业版:团队管理、定制课程、学习数据看板
- 导师 1v1:预约系统、视频连麦、评价体系
13.4 写给开发者的话
通过"职学Pro"这个项目,我们完整走过了从需求分析、架构设计、编码实现到性能优化的全流程。HarmonyOS ArkTS 为我们提供了一种高效、直观、类型安全的应用开发方式——如果你有 TypeScript 或前端开发经验,上手成本极低;如果你是 Android 开发者,你会发现声明式 UI 的思维方式虽然不同,但一旦掌握会极大提升开发效率。
ArkTS 开发的五个核心原则:
- 状态驱动 UI:永远通过修改状态来更新界面,不要直接操作 UI 元素
- 单一数据源:每个状态有且只有一个控制源,避免多份副本
- 组件化思维:识别可复用的 UI 模式,提取为 @Builder 函数
- 防御式编程:对路由参数、异步数据、边界条件做防御性检查
- 生命周期意识:合理利用
aboutToAppear/aboutToDisappear管理资源和副作用
在线教育是一个有广阔前景的赛道,而 HarmonyOS 正在快速增长的生态中。希望这篇技术博客能为正在探索 HarmonyOS 应用开发的你提供有价值的参考。
更多推荐



所有评论(0)