一、技术前言

在表达力教育领域,演讲口才训练正从线下小班走向数字化、智能化的移动端体验。从开场破冰的即兴表达,到金字塔逻辑的结构拆解,从语速节奏的精准控制,到眼神交流的巡航训练,每一项技能的习得都需要持续的练习反馈、多维的数据可视化和沉浸式的舞台感设计。传统口才训练应用面临三大痛点:练习过程缺乏舞台沉浸感导致用户难以进入状态、镜头跟拍模糊导致表情与手势细节丢失、字幕与语速反馈割裂导致复盘效率低下。与此同时,口才训练的数字化还面临着更深层的工程挑战——如何将"舞台表演"这一高度感性的场景抽象为可量化、可追踪、可反馈的数据模型?如何在移动端有限的屏幕空间内同时呈现课程推荐、能力评估、练习记录和进度激励四类信息而不导致信息过载?如何在相机预览、字幕渲染和雷达图绘制三大异构渲染管线之间协调资源分配和生命周期管理?这些问题共同构成了口才训练平台架构设计的核心难题。

HarmonyOS ArkUI 框架以其声明式 UI 范式为这些痛点提供了系统级的解决方案。ArkUI 基于 TypeScript 扩展的 ArkTS 语言,通过 @Component 装饰器封装可复用组件,通过 @State@Observed 等状态管理装饰器实现数据驱动渲染,通过 @Builder 方法将复杂 UI 结构拆分为可组合的构建块。声明式渲染的核心思想是:开发者只需描述界面"是什么"而非"怎么构建",框架通过虚拟 DOM diff 算法自动完成最小化 DOM 更新,使 UI 状态与数据模型保持同步。组件化架构通过 @Component 装饰器将界面拆分为独立可复用的组件单元,每个组件拥有自己的状态管理和生命周期,组件间通过参数传递和回调函数实现松耦合通信。状态驱动机制中,@State 装饰器监听变量变化并自动触发关联 UI 的重渲染,@Observed 装饰器使类的实例具备可观察性,当对象属性变更时通知所有引用处刷新,实现数据到视图的单向流动。这种架构天然适合口才训练场景中"练习-反馈-复盘"闭环流转的需求——状态变量的统一声明让跨 Tab 数据共享无需额外通信机制,@Observed 类的属性级监听让练习记录的增删改实时同步到所有引用视图。

本平台深度融合了 HarmonyOS 6.1.1 的三大前沿特性。Camera Kit 提供了 VideoSession 的 AUTO_FRAMING(影随人动)能力链——通过 isControlCenterSupportedgetSupportedEffectTypesenableControlCenter 三步实现演讲跟拍时演讲者始终居中;同时 PhotoSession 的手动对焦三接口 isFocusDistanceSupportedsetFocusDistancegetFocusDistance 实现从铭牌近拍到全场舞台的精确对焦控制。这两条 Camera Kit 能力链共享同一颗后置摄像头和同一个 XComponent 预览 Surface,通过 sessionMode 在 idle/video/photo 三态间互斥切换,避免会话资源冲突。Speech Kit 实现了 AI 字幕的四维定制链路——通过 AICaptionComponent 组件的 sourceLanguagetargetLanguagefontSizeAICaptionFontSize 枚举)、fontColor 四字段实现演讲字幕的语言、字体、颜色全维度定制,配合 AICaptionControllerwriteAudio 接口实现实时音频流转字幕。源语言与目标语言之间存在联动约束:中文源锁定 zh 无翻译方向可选,英文源可选中文、英文或中英双语三种目标——这一联动逻辑通过 switchSourceLang 方法封装,确保语言组合始终合法。Canvas 通过 CanvasRenderingContext2D 绘制表达力五维雷达图,配合呼吸动画实现数据微波动,让能力评估可视化随时间律动。雷达图覆盖台风、逻辑、感染力、语速、眼神五个评估维度,三层背景多边形提供刻度参照,数据多边形以半透明紫色填充配合金色数据点,整体形成"舞台聚光灯下能力投影"的视觉隐喻。

二、整体架构流程图

数据模型层

弹窗系统

HarmonyOS特性层

Tab内容层

页面层

Page1281 主组件

headerStage 头部舞台横幅

内容区 6 Tab 切换

tabBar 底部导航

弹窗遮罩系统

Tab0 舞台
金句卡+横滑课程大卡+雷达图+课程清单

Tab1 相机
授权卡+XComponent预览+影随人动能力链

Tab2 对焦
能力查询+三档预设+Slider+读回校验

Tab3 字幕
AICaptionComponent+语言联动+字号颜色

Tab4 复盘
统计三卡+练习进度条清单

Tab5 我的
学员渐变大卡+勋章行+月度柱状图

Camera Kit
AUTO_FRAMING影随人动

Camera Kit
手动对焦三接口

Speech Kit
AI字幕四维定制

Canvas
表达力五维雷达图

panelAdd 新增练习记录

panelEdit 编辑练习记录

panelDel 删除确认弹窗

LessonItem 课程模型

FocusRecord 对焦记录

CaptionScene 字幕场景

PracticeItem 练习记录

整体架构以主组件为根,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部横幅 + 内容区 + 底部 Tab 栏,顶层是全屏弹窗遮罩。内容区通过 currentTab 状态索引在 6 个 @Builder 方法间切换,每个 Tab 拥有完全独立的布局结构。三大特性(Camera Kit 影随人动、手动对焦、Speech Kit AI 字幕)分别挂载在相机、对焦、字幕三个 Tab 上,而 Canvas 雷达图则嵌入舞台 Tab 展示表达力评估。所有状态变量统一声明在组件顶层,实现跨 Tab 数据共享——例如 focusRecords 对焦记录时间线在相机和对焦两个 Tab 间共享,practiceList 练习记录在复盘和我的两个 Tab 间同步。

架构设计的核心思想是"状态集中声明、视图分散构建"。状态变量虽然在组件顶层集中声明,但它们的消费方分布在 6 个 Tab 的 @Builder 方法中,@State 的响应式机制确保任何一处修改都会自动触发所有引用视图的增量刷新。@Observed 类(LessonItemFocusRecordCaptionScenePracticeItem)进一步将响应式粒度从数组级别细化到对象属性级别——修改 practiceList[3].score 只会刷新引用了该属性的那一处 UI,而非整个列表重渲染。这种"状态集中+视图分散+属性级监听"的三层架构,使平台在 6 Tab 切换时无需手动传递数据,在弹窗 CRUD 时无需手动刷新列表,在呼吸动画时无需手动重绘 Canvas,极大降低了状态管理的认知负担。

三、色彩体系设计

3.1 ColorPalette 接口定义

平台采用深色舞台主题,通过 ColorPalette 接口集中声明全部颜色字段,构建了一套从背景到强调色的完整语义层:

interface ColorPalette {
  bg: string;      // 全局背景(深夜舞台)
  card: string;    // 卡片底色
  title: string;   // 主标题
  sub: string;     // 副标题
  text3: string;   // 弱化文字
  purple: string;  // 舞台紫主色
  purpleD: string; // 舞台紫深色
  gold: string;    // 聚光金
  blue: string;    // 信息蓝
  red: string;     // 警示红
  green: string;   // 通过绿
  line: string;    // 分割线
  tabOn: string;   // 底部 Tab 选中色
  mask: string;    // 弹窗遮罩
}

这段接口定义体现了 ArkTS 的类型安全优势。与普通 JavaScript 动态添加属性不同,ColorPalette 接口在编译期即约束所有颜色字段必须是 string 类型,任何拼写错误或类型不匹配都会在编译阶段暴露。接口注释采用"字段名 + 用途"的格式,使每个颜色的语义角色一目了然,后续维护者无需追踪代码即可理解色彩用途。注意接口定义中未包含 dark 字段,但在常量实现中补充了它——这是因为 dark 作为次级容器底色(统计格、进度条底色),在开发阶段发现需要一个介于 cardline 之间的中间色,于是按需扩展。接口的灵活性允许这种增量扩展而不影响已有字段的类型安全。

3.2 COLORS 常量逐色分析

const COLORS: ColorPalette = {
  bg: '#171226',      // 舞台紫黑,模拟深夜聚光灯下的舞台环境
  card: '#221B38',    // 卡片底色,比背景略亮一档
  dark: '#2C2347',    // 次级容器底色(统计格/进度条底)
  title: '#F2EDFA',   // 暖白标题,高对比度保证暗光可读
  sub: '#C0B4DC',    // 紫灰副标题,层次柔和过渡
  text3: '#857AA8',  // 暗紫弱文本,辅助信息不抢视觉
  purple: '#8B5CF6',  // 舞台紫主色,渐变起点与按钮强调
  purpleD: '#6D3FD6', // 深紫渐变终点,头部横幅到背景过渡
  gold: '#F5C04E',    // 聚光金,等级标识与评分高亮
  blue: '#4EA3E3',    // 信息蓝,读回值与语言标签
  red: '#E85B6E',     // 警示红,失败状态与删除操作
  green: '#4EC98A',   // 通过绿,已授权与已生效状态
  line: '#35294F',    // 分割线,低对比度不干扰内容
  tabOn: '#F5C04E',   // Tab 选中色,与聚光金一致
  mask: 'rgba(0,0,0,0.6)' // 半透黑遮罩
};

色彩设计遵循"舞台聚光"原则,每一色都有明确的语义角色。bg#171226 舞台紫黑,模拟深夜聚光灯下的舞台环境,通过极暗的紫色调降低屏幕整体亮度,使内容区域在暗光环境下不刺眼,适合长时间练习场景。card#221B38 卡片紫,比背景略亮一档(亮度差约 8%),卡片与背景形成柔和对比,保证信息区块的清晰边界但不产生硬切感。dark#2C2347 次级容器色,用于统计格、进度条底色和弹窗内 TextInput 的背景,比卡片底再亮一档,形成三级亮度层次。

title#F2EDFA 暖白,主标题文字色,采用偏暖的白色而非纯白,降低在暗色背景上的眩光感,同时保持高对比度确保可读性。sub#C0B4DC 紫灰,副标题色,在标题与弱文本之间架起层次过渡,从暖白到紫灰到暗紫形成三级文字层次。text3#857AA8 暗紫,三级弱文本,用于辅助说明、时间戳和单位标注,视觉权重最低但暗色环境下仍可辨识。

purple#8B5CF6 舞台紫,平台主色,贯穿渐变横幅起点、按钮背景和 Toggle 开关选中色,其高饱和度紫色象征舞台的神秘与仪式感。purpleD#6D3FD6 深紫,渐变终点色,用于头部横幅和学员大卡的渐变收尾,比主色更深一档,模拟聚光灯由中心向边缘扩散的衰减效果。gold#F5C04E 聚光金,平台视觉锚点,用于等级标识、评分高亮、Tab 选中态和雷达图数据点——用户在深色环境下凭金色即可快速定位核心信息,如同聚光灯打在舞台上最亮的那个光点。blue#4EA3E3 信息蓝,用于对焦读回值和语言标签,与主紫色形成冷暖对比,暗示"参考数据"而非"核心数据"。red#E85B6E 警示红,仅用于失败状态和删除操作,通过低频使用强化警示语义。green#4EC98A 通过绿,用于已授权、已生效等确认状态,传递"可以放心操作"的安全感。line#35294F 分割线色,同时复用为雷达图背景网格,低对比度不干扰内容。tabOngold 同值,保证 Tab 选中态与聚光金视觉一致。mask 为半透黑 rgba(0,0,0,0.6),弹窗遮罩使用 RGBA 格式实现 60% 透明度,与深色主题协调。

四、Tab 元数据与常量定义

4.1 底部导航 Tab 定义

interface TabMeta {
  icon: string;
  label: string;
}

const TAB_LIST: TabMeta[] = [
  { icon: '🎤', label: '舞台' },
  { icon: '📷', label: '相机' },
  { icon: '🎯', label: '对焦' },
  { icon: '🗣', label: '字幕' },
  { icon: '📊', label: '复盘' },
  { icon: '👤', label: '我的' }
];

TabMeta 接口定义了底部导航项的最小数据结构:icon 为 emoji 字符串,label 为中文标签文字。TAB_LIST 常量数组按顺序声明六个 Tab 项,分别对应舞台、相机、对焦、字幕、复盘和我的。6 个 Tab 单排排列,从舞台展示到个人中心覆盖训练全流程。每个 Tab 的图标与其功能语义紧密对应:🎤 代表舞台演讲,📷 代表相机跟拍,🎯 代表对焦精准,🗣 代表字幕输出,📊 代表数据复盘,👤 代表学员中心。这种将导航元数据与 UI 渲染分离的设计使 Tab 配置可独立维护,新增或调整 Tab 只需修改数组而无需触碰 @Builder 方法。底部导航栏在 tabBar() 构建器中通过 ForEach 遍历此数组渲染,选中态通过 currentTab 索引与 index 比较判断,键值生成器使用 `tab-${tab.label}-${idx}` 确保 Tab 项的唯一标识。

4.2 ControlCenterEffectType 效果枚举

interface EffectInfo {
  type: number;
  name: string;
  desc: string;
}

const EFFECT_INFOS: EffectInfo[] = [
  { type: 0, name: 'BEAUTY', desc: '美颜 · since 20' },
  { type: 1, name: 'PORTRAIT', desc: '人像 · since 20' },
  { type: 2, name: 'AUTO_FRAMING', desc: '影随人动 · 6.1.1 新增' }
];

EffectInfo 接口定义了三项字段:type 为效果枚举数值,name 为效果英文名,desc 为中文描述和版本标注。三效果枚举展示了 ControlCenterEffectType 的完整谱系。BEAUTY(type=0)和 PORTRAIT(type=1)自 API 20 起就存在,AUTO_FRAMING(type=2)是 6.1.1 新增能力,本平台正是利用这一新特性实现演讲跟拍时演讲者始终居中构图。在相机 Tab 的效果枚举卡中,通过 ForEach(EFFECT_INFOS) 逐行渲染三种效果,每行以等宽字体显示类型数值(聚光金)、效果名称(暖白)和描述(暗紫),AUTO_FRAMING 行额外显示"本机已声明"或"待查询"标记——该标记由 framingSupported 布尔态驱动,在能力链查询完成后更新。这种设计让用户直观看到 Camera Kit ControlCenter 通道支持的全部效果类型,以及本机对 AUTO_FRAMING 的支持情况。

4.3 对焦预设三档

interface FocusPreset {
  label: string;
  distance: number;
  scene: string;
}

const FOCUS_PRESETS: FocusPreset[] = [
  { label: '近拍', distance: 0.1, scene: '0.1 · 铭牌演讲稿' },
  { label: '中距', distance: 0.5, scene: '0.5 · 半身演讲' },
  { label: '远距', distance: 0.9, scene: '0.9 · 全场舞台' }
];

FocusPreset 接口定义了对焦预设的三字段结构:label 为中文景别标签,distance 为 0.0~1.0 的对焦距离值,scene 为场景描述文案。对焦距离值范围 0.0(最近)到 1.0(最远),三档预设覆盖演讲取景的三个典型景别:0.1 用于拍摄铭牌和演讲稿特写(近距离平面物体),0.5 用于半身演讲构图(胸像取景),0.9 用于收纳全场舞台环境(广角远景)。在对焦 Tab 中,三档预设以三列等宽布局展示,当前选中的预设以深紫为底、聚光金为字高亮——选中态通过 Math.abs(this.focusDistance - preset.distance) < 0.02 判断,即对焦距离与预设值的差值小于 0.02 时视为选中。点击调用 applyFocus(preset.distance) 方法,该方法先调用 setFocusDistance 设置焦距,再调用 readBackFocus 进行读回校验,形成"设定-执行-验证"的完整闭环。

4.4 AI 字幕语言与外观选项

interface LangOption {
  code: string;
  name: string;
}

const SRC_LANGS: LangOption[] = [
  { code: 'zh', name: '中文' },
  { code: 'en', name: '英文' }
];
const TGT_LANGS_EN: LangOption[] = [
  { code: 'zh', name: '中文' },
  { code: 'en', name: '英文' },
  { code: 'zh-en', name: '中英双语' }
];

LangOption 接口定义了语言选项的两字段结构:code 为语言码(‘zh’/‘en’/‘zh-en’),name 为中文展示名。源语言支持中文和英文两种。当源语言为英文时,目标语言通过 TGT_LANGS_EN 数组提供三个选项:中文(翻译方向)、英文(原文直显)和中英双语(对照显示)。当源语言为中文时,目标语言锁定为 zh(无翻译方向可选),此时字幕 Tab 显示金色提示"中文源 targetLanguage 仅支持 zh,已自动锁定"。这种联动约束由 switchSourceLang 方法封装:当 code === 'zh' 时强制设 tgtLang = 'zh',当 code === 'en' 时默认设 tgtLang = 'zh-en'(双语对照),用户可再选 zhen

interface SizeOption {
  size: AICaptionFontSize;
  name: string;
}

const SIZE_OPTIONS: SizeOption[] = [
  { size: AICaptionFontSize.SMALL, name: '小号' },
  { size: AICaptionFontSize.NORMAL, name: '标准' },
  { size: AICaptionFontSize.BIG, name: '大号' },
  { size: AICaptionFontSize.LARGE, name: '超大' }
];
const CAPTION_FONT_COLORS: string[] = ['#FFFFFF', '#FFE9B0', '#9CE8B5', '#9CD0FF', '#FFB3C1'];

SizeOption 接口的 size 字段类型为 AICaptionFontSize 枚举而非 number,这体现了 ArkTS 类型安全的优势——编译器在编译阶段即约束字号取值只能是 SMALL/NORMAL/BIG/LARGE 四个枚举成员,任何越界赋值都会被拦截。字号取 AICaptionFontSize 枚举四档而非数字,字体颜色预设五色卡覆盖白、暖黄、薄荷绿、天蓝、粉红五种高可读性配色。在字幕 Tab 的外观设置区,字号四档以四个胶囊按钮呈现,选中态为舞台紫底、暖白字;颜色五卡以五个圆形色块呈现,选中态边框加粗为聚光金(2px),未选中为分割线色(1px)。点击直接修改 captionSizecaptionColor 状态,由于 options 每次渲染都由 buildCaptionOptions() 动态组装,状态变更自动反映到字幕组件。

4.5 雷达图与柱状图数据

const RADAR_LABELS: string[] = ['台风', '逻辑', '感染力', '语速', '眼神'];
const RADAR_VALUES: number[] = [0.82, 0.74, 0.66, 0.88, 0.58];

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const PRACTICE_VAL: number[] = [42, 55, 61, 58, 73, 84];

表达力五维雷达图覆盖台风、逻辑、感染力、语速、眼神五个评估维度,数据值范围 0~1。当前学员五维数据为:台风 0.82(上台经验丰富,仪态自然)、逻辑 0.74(金字塔结构基本掌握,偶有跳跃)、感染力 0.66(情绪调动尚可,情感共鸣待加强)、语速 0.88(节奏控制优秀,停顿得当)、眼神 0.58(交流巡航术初学,视线覆盖不足)。五维数据中语速最高(0.88)、眼神最低(0.58),雷达图形状呈现"语速突出、眼神凹陷"的不规则五边形,直观反映学员的优势与短板。

近 6 个月练习时长数据呈持续上升趋势(42 到 84 分钟),反映训练坚持度持续增长。从 03 月的 42 分钟到 08 月的 84 分钟,练习时长翻倍,中间虽有 05 到 06 月的小幅回落(61 到 58),但整体趋势向上,柱状图随呼吸动画在柱高系数 0.66 与 0.72 间交替波动,形成"数据仍在活跃更新"的视觉暗示。MONTH_IDX 为纯索引数组 [0,1,2,3,4,5],用于 ForEach 遍历时访问 PRACTICE_VALMONTH_NAME 的对应下标。

4.6 勋章清单与每日金句

interface BadgeItem {
  icon: string;
  name: string;
  got: boolean;
}

const BADGE_LIST: BadgeItem[] = [
  { icon: '🏆', name: '百场练习', got: true },
  { icon: '🔥', name: '21天连训', got: true },
  { icon: '🎤', name: '即兴之星', got: true },
  { icon: '🎯', name: '对焦大师', got: false },
  { icon: '🌍', name: '双语演说', got: false },
  { icon: '⭐', name: '五星认证', got: false }
];
const STAGE_QUOTE: string = '好的演讲不是背诵,而是把思想放进听众的口袋。';

BadgeItem 接口定义了勋章项的三字段结构:icon 为 emoji 图标,name 为勋章名称,got 为是否已解锁的布尔态。6 枚勋章中已解锁 3 枚(百场练习、21天连训、即兴之星),未解锁勋章以 0.35 透明度展示,形成"灰度锁定"的视觉激励效果——已解锁勋章金光闪闪,未解锁勋章暗淡期待解锁,驱动用户持续练习。勋章墙顶部显示已解锁数量"已解锁 3 / 6"。每日金句以聚光金高亮呈现,强化平台的价值观输出:“好的演讲不是背诵,而是把思想放进听众的口袋”——这句话点明了口才训练的核心:不是机械背诵而是思想传递,与平台"表达力教育"的定位高度契合。

五、工具函数分析

本平台在组件外部定义了 9 个纯函数,覆盖状态文案映射、距离景别映射、评分颜色映射、枚举文案转换、语言码转换、时间格式化和数据汇总等场景。这些函数均为无副作用的纯函数,可安全地在 @Builder 方法中直接调用而不引发状态变更。

5.1 状态文案与颜色映射

function framingStateColor(state: string): string {
  if (state === '影随人动已启用') { return COLORS.green; }
  if (state === '已交还手动构图') { return COLORS.blue; }
  if (state.indexOf('失败') >= 0 || state.indexOf('被拒') >= 0 || state.indexOf('不支持') >= 0) {
    return COLORS.red;
  }
  if (state === '未查询') { return COLORS.text3; }
  return COLORS.gold;
}

影随人动状态文案到颜色的映射函数通过字符串模式匹配实现五态分流:已启用映射通过绿(COLORS.green),表示系统已接管构图,演讲者居中跟拍生效;已交还映射信息蓝(COLORS.blue),表示用户主动关闭了影随人动,回到手动构图模式;含"失败"“被拒”“不支持"的文案映射警示红(COLORS.red),表示能力链执行过程中出现了异常——可能因为设备不支持 ControlCenter、AUTO_FRAMING 未声明或权限被拒;未查询映射弱化紫(COLORS.text3),表示能力链尚未启动查询,初始态;默认映射聚光金(COLORS.gold),覆盖"控制中心不支持”"AUTO_FRAMING 未声明"等中间态。这种设计让相机能力链路的每一步状态在 UI 上都有明确的颜色语义反馈,用户无需阅读文字即可从颜色判断当前状态是成功(绿)、信息(蓝)、警告(金)还是失败(红)。

5.2 距离景别与读回校验映射

function distanceLabel(d: number): string {
  if (d < 0.3) { return '近拍景别 · 铭牌演讲稿特写'; }
  if (d < 0.7) { return '中距景别 · 半身演讲构图'; }
  return '远距景别 · 全场舞台收纳';
}

function readOkColor(ok: string): string {
  if (ok === '已生效') { return COLORS.green; }
  if (ok === '偏差') { return COLORS.gold; }
  return COLORS.red;
}

distanceLabel 将 0~1 的对焦距离值分为三段景别文案:小于 0.3 为"近拍景别·铭牌演讲稿特写",适合拍摄桌面上的演讲稿或铭牌特写;0.3~0.7 为"中距景别·半身演讲构图",适合胸像取景,展示表情和手势;0.7 以上为"远距景别·全场舞台收纳",适合广角远景,收纳整个舞台环境。这让 Slider 的连续调节获得实时场景描述,用户拖动滑杆时下方文案实时变化,提供"所见即所得"的景别预览。

readOkColor 将对焦读回校验结果映射为三色:已生效映射通过绿(COLORS.green),表示 getFocusDistance 读回值与设定值差值小于 0.01,焦距已精确执行;偏差映射聚光金(COLORS.gold),表示读回值与设定值有偏差但非失败——可能是镜头物理限制导致无法精确到达指定距离;失败映射警示红(COLORS.red),表示 getFocusDistance 调用抛出异常或会话未启动。三色梯度"绿-金-红"对应"成功-警告-失败"的语义层次,在对焦记录时间线中每条记录右侧显示对应颜色。

5.3 等级与评分颜色映射

function levelColor(level: string): string {
  if (level === '入门') { return COLORS.green; }
  if (level === '进阶') { return COLORS.blue; }
  if (level === '大师') { return COLORS.gold; }
  return COLORS.purple;
}

function scoreColor(score: number): string {
  if (score >= 90) { return COLORS.gold; }
  if (score >= 75) { return COLORS.green; }
  if (score >= 60) { return COLORS.blue; }
  return COLORS.red;
}

课程等级颜色映射形成"绿-蓝-金"的进阶梯度,分别对应入门、进阶、大师三个难度。入门课程映射通过绿(COLORS.green),传递"容易上手"的安全感;进阶课程映射信息蓝(COLORS.blue),暗示"需要一定基础";大师课程映射聚光金(COLORS.gold),标识"高难度挑战"。这种颜色梯度让用户在课程清单中一眼识别难度层次,无需阅读文字即可从颜色判断课程适合的段位。默认返回 COLORS.purple 作为兜底,应对未预期的等级字符串。

练习评分颜色映射以 90/75/60 为三道分水岭,90 分以上聚光金(COLORS.gold),标识"大师级表现";75 分以上通过绿(COLORS.green),标识"良好水平";60 分以上信息蓝(COLORS.blue),标识"及格通过";60 分以下警示红(COLORS.red),标识"需要加强"。在复盘 Tab 的练习记录进度条清单中,每条记录的评分文字和进度条颜色均由 scoreColor 映射——评分 95 分的 TED 式大师结构显示金色进度条,评分 64 分的眼神交流巡航术显示红色进度条,视觉对比强烈,激励用户提升低分项。

5.4 枚举文案与语言码转换

function sizeLabel(size: AICaptionFontSize): string {
  if (size === AICaptionFontSize.SMALL) { return 'AICaptionFontSize.SMALL'; }
  if (size === AICaptionFontSize.BIG) { return 'AICaptionFontSize.BIG'; }
  if (size === AICaptionFontSize.LARGE) { return 'AICaptionFontSize.LARGE'; }
  return 'AICaptionFontSize.NORMAL';
}

function langName(code: string): string {
  if (code === 'en') { return '英文'; }
  if (code === 'zh-en') { return '中英双语'; }
  return '中文';
}

sizeLabelAICaptionFontSize 枚举值转换为完整枚举名文案,避免直接打印枚举数字导致 UI 语义缺失。在 ArkTS 中,枚举值在运行时可能被序列化为数字(如 AICaptionFontSize.SMALL 可能为 0),直接显示数字对用户无意义。通过 sizeLabel 转换为 'AICaptionFontSize.SMALL' 这样的完整枚举名,既保留了类型信息又提供了人类可读的展示。该函数虽然在本平台的 UI 中未直接调用(字幕 Tab 使用 SIZE_OPTIONS 数组的 name 字段显示中文标签),但作为调试和日志辅助函数保留了枚举到字符串的转换能力。

langName 将语言码(‘zh’/‘en’/‘zh-en’)转换为中文展示名,用于字幕场景卡和语言设置区域的展示。语言码 'zh' 映射"中文",'en' 映射"英文",'zh-en' 映射"中英双语"。在字幕 Tab 的场景卡中,每行右侧显示 `${langName(scene.src)}->${langName(scene.tgt)}` 的语言组合标签(信息蓝色),让用户直观看到场景推荐的语言流向,如"英文->中文"表示英文源转中文字幕。

5.5 时间格式化与数据汇总

function nowTime(): string {
  const d = new Date();
  const pad = (v: number): string => {
    if (v < 10) { return '0' + v; }
    return '' + v;
  };
  return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}

nowTime 生成 HH:mm:ss 格式时间戳,用于对焦记录时间线的每条记录。函数内部定义了 pad 辅助函数,将小于 10 的数字前补零——例如 9 秒补为"09"。最终拼接 pad(时) + ':' + pad(分) + ':' + pad(秒),输出如"10:12:03"的格式化时间字符串。在对焦 Tab 中,每次执行 readBackFocusapplyFocus 方法时,都会通过 nowTime() 获取当前时间并写入 FocusRecordtime 字段,时间线中每条记录以等宽字体显示该时间戳,便于用户追踪操作时序。

function totalMinutes(list: PracticeItem[]): number {
  let sum = 0;
  for (const p of list) {
    sum += p.duration;
  }
  return sum;
}

function avgScore(list: PracticeItem[]): number {
  if (list.length === 0) { return 0; }
  let sum = 0;
  for (const p of list) {
    sum += p.score;
  }
  return Math.round(sum / list.length);
}

totalMinutesavgScore 分别汇总练习总时长和平均评分,为复盘 Tab 的统计三卡提供数据源。totalMinutes 遍历 PracticeItem 数组累加 duration 字段,返回总分钟数。avgScore 先判空(list.length === 0 返回 0 防止除零异常),再累加 score 字段求均值后 Math.round 四舍五入。这两个函数在 @Builder tabReview() 中被直接调用——当 practiceList 通过 unshift 新增或 splice 删除时,@State 的响应式机制触发 tabReview 重新渲染,两个函数自动重新计算,实现统计三卡的实时同步。三个函数均为纯函数无副作用,可安全地在 @Builder 方法中直接调用。

六、数据模型层

6.1 LessonItem 课程模型

@Observed export class LessonItem {
  icon: string;      // 课程图标
  name: string;      // 课程名称
  level: string;     // 难度等级(入门/进阶/大师)
  duration: string;  // 课程时长
  rate: string;      // 课程评分
  constructor(icon: string, name: string, level: string, duration: string, rate: string) {
    this.icon = icon;
    this.name = name;
    this.level = level;
    this.duration = duration;
    this.rate = rate;
  }
}

LessonItem 使用 @Observed 装饰器声明为可观察类,其属性变更将自动触发引用了该实例的 UI 刷新。课程模型包含五个字段:icon 为 emoji 课程图标,name 为课程名称,level 为难度等级(入门/进阶/大师),duration 为课程时长字符串(如"12分钟"),rate 为课程评分字符串(如"4.9")。构造函数依次赋值五个参数到实例属性。

const LESSON_LIST: LessonItem[] = [
  new LessonItem('🎤', '开场三分钟破冰术', '入门', '12分钟', '4.9'),
  new LessonItem('🧠', '金字塔表达逻辑', '进阶', '18分钟', '4.8'),
  new LessonItem('🔥', '情绪感染力点燃', '进阶', '15分钟', '4.7'),
  new LessonItem('⚡', '语速节奏控制法', '入门', '9分钟', '4.6'),
  new LessonItem('👁', '眼神交流巡航术', '入门', '11分钟', '4.8'),
  new LessonItem('🏆', 'TED 式大师结构', '大师', '25分钟', '5.0'),
  new LessonItem('🌐', '中英双语答辩训练', '进阶', '20分钟', '4.7'),
  new LessonItem('🎯', '即兴演讲应变课', '大师', '16分钟', '4.9')
];

LESSON_LIST 常量数组包含 8 门课程,覆盖入门、进阶、大师三个难度等级。入门课程 3 门(开场三分钟破冰术、语速节奏控制法、眼神交流巡航术),适合新手建立基础;进阶课程 3 门(金字塔表达逻辑、情绪感染力点燃、中英双语答辩训练),适合有一定基础的学员提升技巧;大师课程 2 门(TED 式大师结构、即兴演讲应变课),适合高级学员挑战高难度。课程评分从 4.6 到 5.0,最高分是 TED 式大师结构(5.0 满分)。课程模型在舞台 Tab 中以两种形态呈现:横滑大卡(图标/名称/等级/时长/评分/跟练按钮)和清单行(图标/名称/等级/时长/评分/播放箭头),两种形态共享同一数据源但布局完全不同,体现了"一数据多视图"的声明式 UI 优势。

6.2 FocusRecord 对焦记录模型

@Observed export class FocusRecord {
  time: string;      // 操作时间
  distance: number;  // 设定对焦距离
  readback: number;  // getFocusDistance 读回值
  ok: string;        // 校验结果(已生效/偏差/失败)
  constructor(time: string, distance: number, readback: number, ok: string) {
    this.time = time;
    this.distance = distance;
    this.readback = readback;
    this.ok = ok;
  }
}

对焦记录模型捕获每次对焦操作的时间、设定值、读回值和校验结果四元组。time 为 HH:mm:ss 格式时间戳,distance 为 0.0~1.0 的设定对焦距离,readbackgetFocusDistance 方法的读回值(-1 表示读回失败),ok 为校验结果文案(“已生效”/“偏差”/“失败(code)”)。@Observed 装饰器确保对焦记录的属性变更能触发时间线的刷新。

const FOCUS_RECORD_INIT: FocusRecord[] = [
  new FocusRecord('10:12:03', 0.50, 0.50, '已生效'),
  new FocusRecord('10:08:47', 0.10, 0.11, '偏差'),
  new FocusRecord('10:03:21', 0.90, 0.90, '已生效')
];

初始数据包含三条记录,分别对应三种典型场景:中距已生效(0.50 到 0.50,差值 0 判已生效),近拍偏差(0.10 到 0.11,差值 0.01 判偏差——可能因镜头物理最小对焦距离限制),远距已生效(0.90 到 0.90,差值 0 判已生效)。这三条记录在对焦 Tab 的时间线中展示,每行以等宽字体显示时间、设定值、箭头、读回值和校验结果,校验结果颜色由 readOkColor 函数映射。运行时新增的记录通过 unshift 置顶插入数组,超过 20 条时 pop 移除末尾,保持时间线长度可控。

6.3 CaptionScene 字幕场景模型

@Observed export class CaptionScene {
  scene: string;  // 场景名
  desc: string;   // 场景说明
  src: string;    // 推荐源语言
  tgt: string;    // 推荐目标语言
  constructor(scene: string, desc: string, src: string, tgt: string) {
    this.scene = scene;
    this.desc = desc;
    this.src = src;
    this.tgt = tgt;
  }
}

字幕场景模型包含四个字段:scene 为场景名称,desc 为场景说明,src 为推荐源语言码,tgt 为推荐目标语言码。@Observed 装饰器使场景实例可观察。

const CAPTION_SCENE_LIST: CaptionScene[] = [
  new CaptionScene('英文演讲跟练', '跟练英文演讲,实时出中文字幕', 'en', 'zh'),
  new CaptionScene('中文即兴播报', '中文即兴训练,原文直显不翻译', 'zh', 'zh'),
  new CaptionScene('双语答辩模拟', '英文源双语对照,逐句校对发音', 'en', 'zh-en'),
  new CaptionScene('英文原文精听', '纯英文字幕磨耳朵练语感', 'en', 'en'),
  new CaptionScene('中文稿复盘转写', '练习录音转中文字幕逐句复盘', 'zh', 'zh')
];

字幕场景模型预置了五种典型演讲字幕配置:英文演讲跟练(en 到 zh,英文源转中文字幕,适合跟练英文 TED 演讲)、中文即兴播报(zh 到 zh,中文源原文直显无翻译,适合即兴训练录音转写)、双语答辩模拟(en 到 zh-en,英文源双语对照,逐句校对发音,适合学术答辩准备)、英文原文精听(en 到 en,纯英文字幕磨耳朵练语感,适合听力训练)、中文稿复盘转写(zh 到 zh,练习录音转中文字幕逐句复盘,适合演讲录像复盘)。点击场景卡即可调用 applyScene(scene) 一键应用推荐的语言组合——该方法先调用 switchSourceLang(scene.src) 联动源语言,再根据源语言是否为中文决定目标语言(中文源强制锁定 zh,英文源按场景推荐设置 tgt),大幅降低用户的配置门槛。

6.4 PracticeItem 练习记录模型

@Observed export class PracticeItem {
  date: string;     // 练习日期
  topic: string;    // 练习主题
  duration: number; // 练习时长(分钟)
  score: number;    // AI 评分(0~100)
  constructor(date: string, topic: string, duration: number, score: number) {
    this.date = date;
    this.topic = topic;
    this.duration = duration;
    this.score = score;
  }
}

练习记录模型是复盘 Tab 的核心实体,包含四个字段:date 为练习日期(MM-DD 格式),topic 为练习主题,duration 为练习时长(分钟),score 为 AI 评分(0~100)。@Observed 装饰器使该类的属性级变更可被监听——在编辑弹窗中调用 confirmEdit 直接修改 practiceList[editIdx].score 等属性时,引用了该属性的 Progress 进度条和 Text 评分文字会自动刷新,无需手动通知。

const PRACTICE_LIST: PracticeItem[] = [
  new PracticeItem('08-28', '开场三分钟破冰术', 18, 92),
  new PracticeItem('08-26', '金字塔表达逻辑', 25, 85),
  new PracticeItem('08-24', '情绪感染力点燃', 12, 78),
  new PracticeItem('08-21', '语速节奏控制法', 9, 88),
  new PracticeItem('08-19', '眼神交流巡航术', 15, 64),
  new PracticeItem('08-16', '即兴演讲应变课', 22, 71),
  new PracticeItem('08-13', 'TED 式大师结构', 30, 95),
  new PracticeItem('08-10', '中英双语答辩训练', 27, 80)
];

8 条初始数据覆盖了从 08-10 到 08-28 的练习历史,评分从 64 分(眼神交流巡航术)到 95 分(TED 式大师结构)不等。评分分布覆盖了 scoreColor 函数的全部四档:95 分聚光金(大师级)、88 分通过绿(良好)、78 分信息蓝(及格)、64 分警示红(需加强),为进度条清单提供丰富的展示素材。练习时长从 9 分钟(语速节奏控制法)到 30 分钟(TED 式大师结构),累计 158 分钟,平均评分约 82 分,这些汇总数据由 totalMinutesavgScore 函数在统计三卡中实时计算。

七、组件主体结构

7.1 组件声明与状态体系

主组件使用 @Entry@Component 装饰器声明为入口组件。组件内部的状态变量分为六大集群,每一集群服务于特定的功能域:

Tab 与弹窗状态集群:这是最基础的状态集群,控制页面的导航和弹窗交互。

@State currentTab: number = 0;      // 当前 Tab 索引
@State addModal: boolean = false;   // 新增练习记录弹窗
@State editModal: boolean = false;  // 编辑练习记录弹窗
@State delModal: boolean = false;   // 删除练习记录弹窗
@State editIdx: number = -1;        // 编辑目标下标
@State delIdx: number = -1;         // 删除目标下标
@State breath: boolean = false;     // 呼吸动画开关
timer: number = -1;                 // 呼吸动画定时器(非 @State)

currentTab 初始为 0,用户点击底部 Tab 栏时修改为对应索引,触发内容区的 if-else 分支重新渲染。addModal/editModal/delModal 三个布尔值分别控制三个弹窗的显隐,初始均为 falseeditIdxdelIdx 记录操作目标的下标,初始均为 -1 表示未选中。breath 布尔值由 1 秒间隔的定时器驱动交替翻转,联动雷达图数据微波动、柱状图柱高交替和头部麦克风波动三处视觉元素。timer 为非 @State 成员,仅存储定时器 ID 不参与响应式。

业务数据集群:持有三个 @Observed 数组,数组的增删改自动触发引用视图刷新。

@State lessonList: LessonItem[] = LESSON_LIST;
@State practiceList: PracticeItem[] = PRACTICE_LIST;
@State sceneList: CaptionScene[] = CAPTION_SCENE_LIST;

lessonList 持有 8 门课程数据,在舞台 Tab 的横滑大卡和清单行中共享。practiceList 持有 8 条练习记录,在复盘 Tab 的统计三卡和进度条清单中引用,同时被弹窗系统(新增/编辑/删除)操作。sceneList 持有 5 个字幕场景,在字幕 Tab 的场景卡列表中渲染。三个数组均初始化为模块级常量数组。

弹窗表单临时状态:作为弹窗内 TextInput 的双向绑定源,在弹窗打开时预填、关闭时清空。

@State formDate: string = '';
@State formTopic: string = '';
@State formDuration: string = '';
@State formScore: string = '';

四个字符串字段初始为空,新增弹窗打开时通过 clearForm() 清空,编辑弹窗打开时通过 openEdit(idx)practiceList[idx] 预填。TextInputonChange 回调实时更新对应字段,保存按钮调用 confirmAdd()confirmEdit() 消费这些字段值。

Camera Kit 成员集群:这是最复杂的状态集群,管理影随人动和手动对焦双宿主的完整句柄链。

private previewController: XComponentController = new XComponentController();
private cameraInput?: camera.CameraInput;
private previewOutput?: camera.PreviewOutput;
private videoSession?: camera.VideoSession;
private photoSession?: camera.PhotoSession;
@State surfaceReady: boolean = false;
@State sessionMode: string = 'idle';
@State cameraGranted: boolean = false;
@State framingState: string = '未查询';
@State framingSupported: boolean = false;
@State framingOn: boolean = false;
@State focusSupported: boolean = false;
@State focusDistance: number = 1.0;
@State focusReadback: number = -1;
@State focusResult: string = '未验证';
@State focusRecords: FocusRecord[] = FOCUS_RECORD_INIT;

previewControllerXComponentController 实例,管理 Surface 预览的生命周期。cameraInput 为相机输入句柄,同时只能绑定一个 session(VideoSession 或 PhotoSession)。previewOutput 为预览输出句柄。videoSession 为影随人动宿主(ControlCenter 通道),photoSession 为手动对焦宿主(ManualFocus 通道)。五个私有成员均以 ? 可选类型声明,在 releaseSession 中统一置为 undefinedsurfaceReady 标记 Surface 就绪态,sessionMode 在 idle/video/photo 三态间切换,framingState/framingSupported/framingOn 记录影随人动三步状态,focusSupported/focusDistance/focusReadback/focusResult/focusRecords 记录手动对焦五项状态。

Speech Kit 成员集群:管理 AI 字幕四件套和组件控制。

private captionController: AICaptionController = new AICaptionController();
@State captionShown: boolean = false;
@State srcLang: string = 'zh';
@State tgtLang: string = 'zh';
@State captionSize: AICaptionFontSize = AICaptionFontSize.NORMAL;
@State captionColor: string = CAPTION_FONT_COLORS[0];
@State captionReady: boolean = false;
@State captionErrMsg: string = '';
@State captionFed: number = 0;

captionControllerAICaptionController 实例,持有字幕控制器用于 writeAudio 写入音频流。captionShown 双向绑定 AICaptionComponentisShown 属性控制字幕显隐。srcLang/tgtLang/captionSize/captionColor 四个状态分别对应字幕四维定制字段——源语言初始 'zh',目标语言初始 'zh'(中文源锁定),字号初始 NORMAL,颜色初始白色。captionReady/captionErrMsg/captionFed 三个状态记录字幕的就绪态、错误信息和音频写入计数。

Canvas 雷达图上下文

private radarCtx: CanvasRenderingContext2D =
  new CanvasRenderingContext2D(new RenderingContextSettings(true));

radarCtx 作为私有非 @State 成员,持有 CanvasRenderingContext2D 实例。RenderingContextSettings 的参数 true 启用抗锯齿。该上下文在 aboutToAppear 中以 1 秒间隔定时重绘,在 Canvas 组件的 onReady 回调中首次绘制。由于 radarCtx 不是 @State,它的变更不会触发 UI 刷新——但 drawRadar() 方法通过 breath 状态的变化间接驱动重绘(定时器中先翻转 breath 再调用 drawRadar()),实现"状态变更-方法调用-Canvas 重绘"的间接联动。

7.2 生命周期管理

aboutToAppear() {
  this.timer = setInterval(() => {
    this.breath = !this.breath;
    this.drawRadar();
  }, 1000);
}

aboutToDisappear() {
  clearInterval(this.timer);
  this.releaseSession();
}

aboutToAppear 在组件即将出现时启动一个 1 秒间隔的定时器,每次 tick 执行两个操作:翻转 breath 布尔值(this.breath = !this.breath)和调用 drawRadar() 重绘雷达图。breath 的交替翻转驱动三处视觉元素的呼吸效果:雷达图数据值的正负 0.03 微波动(wob = this.breath ? 0.03 : -0.03),柱状图柱高系数的 0.66/0.72 交替(this.breath ? 0.72 : 0.66),头部麦克风的 opacity 1/0.55 交替(this.breath ? 1 : 0.55)。这三处微动效果以最低成本实现"页面正在活跃"的感知暗示,用户在浏览时不会感到页面是静止的。

aboutToDisappear 在组件即将消失时清除定时器(clearInterval(this.timer))并调用 releaseSession() 释放相机会话。releaseSession 方法会依次停止并释放 session、preview 和 input 三层资源,防止后台占用摄像头——这是移动端资源管理的关键,避免组件销毁后摄像头仍被持有导致其他应用无法使用。

7.3 根构建方法

build() {
  Stack() {
    Column() {
      this.headerStage()
      Divider().strokeWidth(1).color(COLORS.line)
      Scroll() {
        Column() {
          if (this.currentTab === 0) {
            this.tabStage()
          } else if (this.currentTab === 1) {
            this.tabCamera()
          } else if (this.currentTab === 2) {
            this.tabFocus()
          } else if (this.currentTab === 3) {
            this.tabCaption()
          } else if (this.currentTab === 4) {
            this.tabReview()
          } else {
            this.tabMine()
          }
        }.width('100%').padding({ left: 12, right: 12, top: 10, bottom: 12 })
      }.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
      this.tabBar()
    }.width('100%').height('100%')

    if (this.addModal) { this.panelAdd(() => { this.addModal = false; }) }
    if (this.editModal) { this.panelEdit(() => { this.editModal = false; }) }
    if (this.delModal) { this.panelDel(() => { this.delModal = false; }) }
  }.alignContent(Alignment.Center).backgroundColor(COLORS.bg).height('100%')
}

根构建方法 build()Stack 为最外层容器,实现页面层叠布局。Stack 内部分为两层:底层是 Column 纵向布局的功能层(头部 + 分割线 + 可滚动内容区 + 底部 Tab 栏),顶层是三个条件渲染的弹窗遮罩。

Column 内部自上而下依次为:headerStage() 头部横幅,1px 分割线(Divider().strokeWidth(1).color(COLORS.line)),可滚动内容区(Scroll().layoutWeight(1)),底部 Tab 栏(tabBar())。内容区的 Scroll 容器使用 layoutWeight(1) 占据头部和 Tab 栏之间的全部剩余空间,scrollBar(BarState.Off) 隐藏滚动条,align(Alignment.Top) 确保内容从顶部开始排列。内容区内部通过 if-else 分支根据 currentTab 索引调用对应的 @Builder 方法。每次 currentTab 变更时,ArkUI 的 diff 算法会卸载旧 Tab 的组件树并挂载新 Tab 的组件树,由于各 Tab 的布局完全独立,切换时不会产生状态泄漏。

弹窗层通过三个 if 条件渲染实现:当 addModaltrue 时渲染 panelAddeditModaltrue 时渲染 panelEditdelModaltrue 时渲染 panelDel。每个弹窗接收一个 onClose 回调函数,遮罩层点击时调用该回调将对应布尔态设为 falseStackalignContent(Alignment.Center) 确保弹窗居中显示。

八、头部详解

@Builder headerStage() {
  Column({ space: 10 }) {
    Row() {
      Column({ space: 2 }) {
        Text('口才进阶').fontSize(18).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text('聚光灯下的表达力训练场').fontSize(10).fontColor(COLORS.text3)
      }.alignItems(HorizontalAlign.Start)
      Blank()
      Row({ space: 6 }) {
        Text('🎤').fontSize(14)
        Text('Lv.6 舞台新星').fontSize(10).fontColor(COLORS.gold)
      }.padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .backgroundColor(COLORS.dark).borderRadius(14)
    }.width('100%')
    Row({ space: 12 }) {
      Column({ space: 4 }) {
        Text('今日简报').fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
        Text('已完成 1 次跟练 · 表达力 +2.4').fontSize(12).fontColor(COLORS.title)
        Text('连续训练 21 天,保持舞台手感').fontSize(9).fontColor(COLORS.sub)
      }.alignItems(HorizontalAlign.START).layoutWeight(1)
      Column() {
        Text('🎙').fontSize(26).opacity(this.breath ? 1 : 0.55)
      }.padding(8)
    }.width('100%').padding(14).borderRadius(14)
    .linearGradient({ angle: 135, colors: [[COLORS.purple, 0.0], [COLORS.purpleD, 1.0]] })
  }.width('100%').padding({ left: 12, right: 12, top: 10, bottom: 10 })
    .backgroundColor(COLORS.bg)
}

头部横幅由上下两层构成。上层是标题行:左侧"口才进阶"主标题(18px 加粗暖白 COLORS.title)搭配"聚光灯下的表达力训练场"副标题(10px 暗紫 COLORS.text3),右侧是等级徽章——以 dark 色为底、14px 圆角的胶囊标签内嵌麦克风 emoji 和"Lv.6 舞台新星"金色等级文字。Blank() 组件在中间撑开空间,实现标题左对齐、徽章右对齐的弹性布局。

下层是今日简报渐变横幅:使用 linearGradient 从舞台紫 #8B5CF6 到深紫 #6D3FD6 以 135 度角渐变,模拟聚光灯从上方打下的光效。横幅左侧三行文字:简报标题"今日简报"(10px 聚光金加粗)、完成数据"已完成 1 次跟练 · 表达力 +2.4"(12px 暖白)、连续天数"连续训练 21 天,保持舞台手感"(9px 紫灰)。横幅右侧麦克风 emoji 的 opacitybreath 在 1 与 0.55 间交替翻转,形成麦克风波动的呼吸效果——这是整个页面唯一由呼吸动画驱动的头部元素,用最低成本的透明度变化暗示"正在录音"的状态。

九、各 Tab 分析

9.1 Tab0 舞台:课程展示与能力评估

舞台 Tab 是用户进入应用后的首屏,由四个区块纵向堆叠组成,以 Column({ space: 12 }) 为容器。

每日金句卡:以灯泡 emoji 和金句文字构成,金句使用 maxLines(2)TextOverflow.Ellipsis 确保长文本优雅截断。金句以暖白色 12px 呈现,标签"每日金句"以暗紫 10px 弱化,形成"标签-正文"的阅读层次。卡片以 card 色为底、14px 圆角,内边距 12px。

精选课程横滑大卡:通过 Scroll().scrollable(ScrollDirection.Horizontal) 实现横向滑动,内部 ForEach 遍历 lessonList 渲染课程大卡。每张大卡宽 132px,包含课程图标(30px)、课程名称(13px 加粗)、等级标签(9px 带背景色)、时长(9px 暗紫)、评分(11px 聚光金加粗)和跟练按钮。首张课程卡片的跟练按钮以聚光金为底、舞台紫黑为字,标注"热练中";其余卡片以暗紫为底、紫灰为字,标注"跟练"——通过颜色差异引导用户关注当前推荐课程。

@Builder lessonBigCard(lesson: LessonItem, idx: number) {
  Column({ space: 8 }) {
    Text(lesson.icon).fontSize(30)
    Text(lesson.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    Row({ space: 6 }) {
      Text(lesson.level).fontSize(9).fontColor(levelColor(lesson.level))
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .backgroundColor(COLORS.dark).borderRadius(6)
      Text(lesson.duration).fontSize(9).fontColor(COLORS.text3)
    }.width('100%')
    Row({ space: 4 }) {
      Text('⭐').fontSize(10)
      Text(lesson.rate).fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
      Blank()
      Text(idx === 0 ? '热练中' : '跟练').fontSize(10)
        .fontColor(idx === 0 ? COLORS.bg : COLORS.sub)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10)
        .backgroundColor(idx === 0 ? COLORS.gold : COLORS.dark)
    }.width('100%')
  }.width(132).padding(12).backgroundColor(COLORS.dark).borderRadius(12)
}

课程大卡的布局从上到下依次为:课程图标(30px emoji)、课程名称(13px 加粗暖白,单行截断)、等级与时长行(等级标签带 levelColor 颜色映射和 dark 底色圆角徽章 + 时长暗紫文字)、评分与按钮行(星标 + 聚光金评分 + Blank 撑开 + 跟练按钮)。首张卡片(idx === 0)的跟练按钮以聚光金底 + 舞台紫黑字呈现"热练中",其余卡片以暗紫底 + 紫灰字呈现"跟练"。

表达力五维雷达图:通过 Canvas 组件绑定 radarCtx 上下文,在 onReady 回调中调用 drawRadar() 绘制。雷达图顶部标注"AI 复盘中"状态,以呼吸动画驱动的圆点交替暗示正在实时分析。

@Builder radarCard() {
  Column({ space: 8 }) {
    Row() {
      Text('表达力五维评估').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Blank()
      Text(this.breath ? 'AI 复盘中 ●' : 'AI 复盘中 ○').fontSize(10)
        .fontColor(this.breath ? COLORS.gold : COLORS.text3)
    }.width('100%')
    Row() {
      Canvas(this.radarCtx).width(230).height(230)
        .onReady(() => { this.drawRadar(); })
    }.width('100%').justifyContent(FlexAlign.Center)
  }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(14)
}

雷达图卡片顶部标题行显示"表达力五维评估"(14px 加粗暖白),右侧状态标签随 breath 在"AI 复盘中 ●"(聚光金)和"AI 复盘中 ○"(暗紫)间交替,暗示 AI 正在实时分析。Canvas 组件宽 230px 高 230px,onReady 回调在 Canvas 就绪后调用 drawRadar() 首次绘制。

课程清单行:通过 List + ListItem + ForEach 渲染课程列表,每行包含图标(20px)、名称(12px)、等级(9px 带颜色映射)、时长(9px)、评分(13px 聚光金加粗)和播放箭头(12px 舞台紫)。与横滑大卡共享 lessonList 数据源但布局完全不同,体现了"一数据多视图"的声明式 UI 优势。

@Builder lessonRows() {
  Column({ space: 8 }) {
    Row() {
      Text('课程清单').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Blank()
      Text('按热度排序').fontSize(10).fontColor(COLORS.text3)
    }.width('100%')
    List({ space: 8 }) {
      ForEach(this.lessonList, (lesson: LessonItem, idx: number) => {
        ListItem() {
          Row({ space: 10 }) {
            Text(lesson.icon).fontSize(20)
            Column({ space: 3 }) {
              Text(lesson.name).fontSize(12).fontColor(COLORS.title)
                .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              Row({ space: 6 }) {
                Text(lesson.level).fontSize(9).fontColor(levelColor(lesson.level))
                Text(`· ${lesson.duration}`).fontSize(9).fontColor(COLORS.text3)
              }
            }.alignItems(HorizontalAlign.START).layoutWeight(1)
            Text(lesson.rate).fontSize(13).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
            Text('▶').fontSize(12).fontColor(COLORS.purple)
          }.width('100%').padding(12).backgroundColor(COLORS.dark).borderRadius(12)
        }
      }, (lesson: LessonItem, idx: number) => `row-${lesson.name}-${idx}`)
    }.width('100%').scrollBar(BarState.Off)
  }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(14)
}

清单行与横滑大卡共享 lessonList 但布局差异显著:清单行以 List 纵向排列,每行以 Row 横向布局——左侧课程图标(20px)、中间名称+等级+时长列(layoutWeight(1) 自适应宽度)、右侧评分(13px 聚光金加粗)和播放箭头(12px 舞台紫)。

9.2 Tab1 相机:影随人动能力链

相机 Tab 是 Camera Kit 影随人动特性的主要展示区,由五个区块组成。

授权状态卡:显示 ohos.permission.CAMERA 权限的授权状态。当 cameraGranted 为 true 时显示"已授权"(通过绿),为 false 时显示"未授权"(暗紫)。按钮文字动态切换为"申请相机权限"或"重新申请权限",点击调用 requestCameraPermission() 异步申请——该方法通过 abilityAccessCtrl.createAtManager() 创建权限管理器,以 requestPermissionsFromUser 发起 user_grant 级动态申请,通过 result.authResults[0] === 0 判断授权结果。权限申请是 Camera Kit 所有功能的前置条件。

XComponent 预览区:通过 XComponent 创建 Surface 类型预览组件,宽 100% 高 240px。onLoad 回调在 Surface 就绪后设 surfaceReady = true。下方双按钮"开启影随人动"和"停止跟拍"分别调用 startVideoMode()releaseSession(),按钮的 enabled 状态和背景色根据 sessionMode 动态切换——当会话处于 video 模式时,开启按钮禁用且背景色切换为深紫,停止按钮启用。

会话三态状态卡:以三列等宽布局展示会话模式(IDLE/VIDEO/PHOTO)、AUTO_FRAMING 声明状态(已声明/未声明)、构图接管状态(系统接管/手动),每列使用等宽字体 monospace 增强数据感,状态值颜色根据布尔态在通过绿/暗紫或聚光金/暗紫间切换。

效果枚举卡:遍历 EFFECT_INFOS 展示三种 ControlCenterEffectType 效果,每行包含类型数字(聚光金等宽)、效果名称(暖白等宽)、描述(暗紫)和本机声明状态。AUTO_FRAMING(type=2)行额外显示"本机已声明"或"待查询"标记。底部嵌入 Toggle 开关,onChange 回调调用 toggleFraming(isOn)——该方法仅在 videoSession 存活时调用 enableControlCenter(isOn),成功时更新 framingOnframingState,失败时回滚开关状态。

能力链三步状态卡:通过 chainRow Builder 方法渲染三行能力链步骤,每行以圆点标识通过/待验证状态。三步分别对应 isControlCenterSupported()getSupportedEffectTypes().includes(AUTO_FRAMING)enableControlCenter(true),通过布尔态驱动颜色和状态文案。

@Builder chainRow(label: string, ok: boolean) {
  Row({ space: 8 }) {
    Text(ok ? '●' : '○').fontSize(10).fontColor(ok ? COLORS.green : COLORS.text3)
    Text(label).fontSize(10).fontColor(COLORS.sub).fontFamily('monospace')
      .layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    Text(ok ? '通过' : '待验证').fontSize(9).fontColor(ok ? COLORS.green : COLORS.text3)
  }.width('100%')
}

chainRow 是一个参数化 @Builder,接收 label(步骤文案)和 ok(通过布尔态)两个参数。通过态显示绿色圆点和"通过",待验证态显示暗紫圆点和"待验证"。

9.3 Tab2 对焦:手动对焦三接口

对焦 Tab 是 Camera Kit 手动对焦特性的完整展示区,由五个区块组成。

能力查询卡:显示 isFocusDistanceSupported() 的查询结果。双按钮"启动拍照会话"和"重新查询能力"分别调用 switchToPhotoMode()queryFocusSupport()——前者在切换前先释放可能存在的 VideoSession,然后创建 PhotoSession 并在 commitConfig + start 后查询对焦能力;后者直接调用 PhotoSession 的同步方法 isFocusDistanceSupported()

三档景别预设:以三列等宽布局展示近拍/中距/远档预设,每档显示标签和场景描述。当前选中的预设以深紫为底、聚光金为字高亮,选中态判断使用 Math.abs(this.focusDistance - preset.distance) < 0.02。点击调用 applyFocus(preset.distance)——该方法设置 focusDistance,调用 setFocusDistance(distance),然后调用 readBackFocus() 进行读回校验。

对焦距离滑杆Slider 组件绑定 focusDistance 状态,范围 0~1 步长 0.01。onChange 回调中同步更新 focusDistance 并调用 setFocusLive()——该方法在每次 Slider 值变化时实时调用 setFocusDistance 但不写入时间线,实现连续对焦调节的流畅体验。滑杆下方通过 distanceLabel() 函数显示当前景别文案,并标注"0.0 = 镜头最近可对焦距离,1.0 = 最远"。

读回验证卡:以三列等宽布局展示设定值、读回值和校验结果。设定值取自 focusDistance,读回值取自 focusReadback(初始 -1 显示"—“),校验结果取自 focusResult。双按钮"设置并读回"和"仅读回校验"分别调用 applyFocus(this.focusDistance)readBackFocus()——后者调用 getFocusDistance() 获取读回值,与设定值差值小于 0.01 判"已生效”,否则标记"偏差",并将记录 unshiftfocusRecords 时间线。

对焦记录时间线:通过 Scroll + Column + ForEach 渲染 focusRecords,每行包含时间(10px 等宽暗紫)、设定值(11px 等宽暖白)、箭头、读回值(11px 等宽信息蓝)和校验结果(10px 带颜色映射)。时间线高度固定 150px,超出部分滚动,最多保留 20 条记录(超过时 pop() 移除末尾)。

9.4 Tab3 字幕:AI 字幕四维定制

字幕 Tab 是 Speech Kit AI 字幕特性的完整展示区,由四个区块组成。

AICaptionComponent 实时预览:通过 AICaptionComponent 创建字幕组件,宽 100% 高 110px。isShown 绑定 captionShown 控制显隐,controller 绑定 captionController 用于 writeAudio 写入音频流,optionsbuildCaptionOptions() 动态组装——该方法将 srcLang/tgtLang/captionSize/captionColor 四个状态填入 AICaptionOptions,同时注册 onPreparedonError 回调实现就绪态和错误态兜底。下方按钮切换 captionShown 显隐,并显示已写入的音频块数。当 captionErrMsg 非空时以警示红显示错误信息。

语言设置区:源语言以两个胶囊按钮(中文/英文)呈现,选中态为聚光金底、舞台紫黑字。点击调用 switchSourceLang(code)——当源语言切换为中文时目标语言自动锁定为 zh,切换为英文时默认设为 zh-en。当源语言为英文时,目标语言以三个胶囊按钮呈现,选中态同样为聚光金底。中文源时显示金色提示"中文源 targetLanguage 仅支持 zh,已自动锁定"。

外观设置区:字号四档以四个胶囊按钮呈现,选中态为舞台紫底、暖白字。颜色五卡以五个圆形色块呈现,选中态边框加粗为聚光金(2px),未选中为分割线色(1px)。点击直接修改 captionSizecaptionColor 状态,由于 options 每次渲染都由 buildCaptionOptions() 动态组装,状态变更自动反映到字幕组件。

字幕场景卡:通过 List + ForEach 遍历 sceneList 渲染场景列表,每行包含场景名、场景说明和语言组合标签。点击调用 applyScene(scene)——该方法调用 switchSourceLang(scene.src) 并按场景推荐设置 tgtLang。底部"写入演示音频"按钮调用 feedAudioStream()——该方法生成 640 字节 PCM 块(16kHz/16bit/单声道,约 20ms),以 440Hz 正弦波填充,通过 captionController.writeAudio(audioData) 写入字幕引擎,并递增 captionFed 计数。

9.5 Tab4 复盘:统计与练习记录管理

复盘 Tab 是数据汇总和记录管理的核心区,由两个区块组成。

统计三卡:以三列等宽布局展示累计练习次数(聚光金)、累计时长分钟数(舞台紫)和平均评分(通过绿)。三个数值均使用 20px 等宽加粗字体,下方以 9px 暗紫标注维度名称。累计时长通过 totalMinutes(this.practiceList) 遍历求和,平均评分通过 avgScore(this.practiceList) 求均值后四舍五入——当 practiceList 变更时,两个函数在 @Builder 重新渲染时自动重新计算,实现统计数据的实时同步。

练习记录进度条清单:通过 List + ForEach 遍历 practiceList,每条记录是一个 Column 包含三行:信息行(日期/主题/时长/评分)、进度条行(Progress 组件绑定 score 值,颜色由 scoreColor() 映射)、操作行(AI 点评/编辑/删除)。进度条高度 6px,背景暗紫,进度颜色根据评分在聚光金/通过绿/信息蓝/警示红间映射。编辑按钮调用 openEdit(idx) 预填表单并打开编辑弹窗,删除按钮设置 delIdx 并打开删除确认弹窗。

// 练习记录进度条清单核心结构
List({ space: 8 }) {
  ForEach(this.practiceList, (p: PracticeItem, idx: number) => {
    ListItem() {
      Column({ space: 6 }) {
        Row({ space: 8 }) {
          Text(p.date).fontSize(10).fontColor(COLORS.text3).fontFamily('monospace')
          Text(p.topic).fontSize(12).fontColor(COLORS.title)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
          Text(`${p.duration}分钟`).fontSize(10).fontColor(COLORS.sub)
          Text(`${p.score}`).fontSize(12).fontColor(scoreColor(p.score)).fontWeight(FontWeight.Bold)
        }.width('100%')
        Progress({ value: p.score, total: 100, type: ProgressType.Linear })
          .width('100%').height(6).color(scoreColor(p.score)).backgroundColor(COLORS.dark)
        Row({ space: 8 }) {
          Text('AI 点评').fontSize(9).fontColor(COLORS.text3)
          Blank()
          Text('编辑').fontSize(10).fontColor(COLORS.blue)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.dark).borderRadius(8)
            .onClick(() => { this.openEdit(idx); })
          Text('删除').fontSize(10).fontColor(COLORS.red)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.dark).borderRadius(8)
            .onClick(() => { this.delIdx = idx; this.delModal = true; })
        }.width('100%')
      }.width('100%').padding(12).backgroundColor(COLORS.dark).borderRadius(12)
    }
  }, (p: PracticeItem, idx: number) => `practice-${p.date}-${p.topic}-${idx}`)
}.width('100%').scrollBar(BarState.Off)

9.6 Tab5 我的:学员档案与数据图表

我的 Tab 是学员个人信息和数据可视化的展示区,由三个区块组成。

学员渐变大卡:使用 linearGradient 从舞台紫到深紫以 135 度角渐变,包含头像(56px 圆形暗紫底麦克风 emoji)、姓名"星野 · 口才进阶学员"(15px 加粗暖白)、等级副标题"Lv.6 舞台新星 · 已坚持 21 天"(10px 紫灰)和三列数据格(表达力指数 86/累计分钟 124/已获勋章 8),三列以 1px 宽的分割线隔开,数值均以 16px 聚光金加粗呈现。

勋章清单行:通过 Scroll 横向滑动展示 BADGE_LIST,每枚勋章以 72px 宽的暗紫圆角容器呈现,包含图标(24px)和名称(9px)。已解锁勋章 opacity 为 1,未解锁为 0.35,形成"灰度锁定"的视觉激励效果。顶部显示已解锁数量"已解锁 3 / 6"。

月度柱状图:通过 ForEach 遍历 MONTH_IDX 渲染 6 根柱子,每根柱子包含数值文本(9px 紫灰)、柱体(22px 宽,高度由 16 + PRACTICE_VAL[mi] * (breath ? 0.72 : 0.66) 计算)和月份标签(9px 暗紫)。柱体颜色在舞台紫和深紫间交替(mi % 2 === 0 ? COLORS.purple : COLORS.purpleD),高度随呼吸动画在系数 0.66 与 0.72 间交替波动——这是整个页面唯一以呼吸动画驱动的数据可视化,用柱高的微弱变化暗示"数据仍在更新"的活跃状态。

@Builder chartCard() {
  Column({ space: 10 }) {
    Row() {
      Text('近 6 个月练习时长(分钟)').fontSize(13)
        .fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Blank()
      Text('柱高呼吸联动').fontSize(9).fontColor(COLORS.text3)
    }.width('100%')
    Row({ space: 10 }) {
      ForEach(MONTH_IDX, (mi: number) => {
        Column({ space: 6 }) {
          Text(`${PRACTICE_VAL[mi]}`).fontSize(9).fontColor(COLORS.sub)
          Column().width(22)
            .height(16 + PRACTICE_VAL[mi] * (this.breath ? 0.72 : 0.66))
            .borderRadius({ topLeft: 4, topRight: 4 })
            .backgroundColor(mi % 2 === 0 ? COLORS.purple : COLORS.purpleD)
          Text(MONTH_NAME[mi]).fontSize(9).fontColor(COLORS.text3)
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }, (mi: number) => `month-${mi}`)
    }.width('100%').height(150).alignItems(VerticalAlign.Bottom)
  }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(14)
}

十、Canvas 雷达图绘制详解

drawRadar() 方法是整个平台 Canvas 绘制的核心,通过 CanvasRenderingContext2D 在 240x240 的画布上绘制表达力五维雷达图。绘制过程分为五个阶段:

drawRadar() {
  const ctx = this.radarCtx;
  const cx = 115;
  const cy = 115;
  const r = 68;
  const n = RADAR_LABELS.length;
  const wob = this.breath ? 0.03 : -0.03;
  ctx.clearRect(0, 0, 240, 240);
  // 背景多边形(3 层)
  for (let layer = 1; layer <= 3; layer++) {
    const lr = r * layer / 3;
    ctx.beginPath();
    for (let i = 0; i < n; i++) {
      const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
      const x = cx + Math.cos(angle) * lr;
      const y = cy + Math.sin(angle) * lr;
      if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
    }
    ctx.closePath();
    ctx.strokeStyle = COLORS.line;
    ctx.lineWidth = 1;
    ctx.stroke();
  }
  // 轴线
  for (let i = 0; i < n; i++) {
    const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.lineTo(cx + Math.cos(angle) * r, cy + Math.sin(angle) * r);
    ctx.strokeStyle = COLORS.line;
    ctx.lineWidth = 1;
    ctx.stroke();
  }
  // 数据多边形
  ctx.beginPath();
  for (let i = 0; i < n; i++) {
    const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
    let val = RADAR_VALUES[i] + wob;
    if (val < 0.1) { val = 0.1; }
    if (val > 1) { val = 1; }
    const x = cx + Math.cos(angle) * r * val;
    const y = cy + Math.sin(angle) * r * val;
    if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
  }
  ctx.closePath();
  ctx.fillStyle = COLORS.purple;
  ctx.globalAlpha = 0.3;
  ctx.fill();
  ctx.globalAlpha = 1;
  ctx.strokeStyle = COLORS.purple;
  ctx.lineWidth = 2;
  ctx.stroke();
  // 数据点
  for (let i = 0; i < n; i++) {
    const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
    let val = RADAR_VALUES[i] + wob;
    if (val < 0.1) { val = 0.1; }
    if (val > 1) { val = 1; }
    const x = cx + Math.cos(angle) * r * val;
    const y = cy + Math.sin(angle) * r * val;
    ctx.beginPath();
    ctx.arc(x, y, 4, 0, Math.PI * 2);
    ctx.fillStyle = COLORS.gold;
    ctx.fill();
  }
  // 标签
  ctx.font = '10px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillStyle = COLORS.sub;
  for (let i = 0; i < n; i++) {
    const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
    const x = cx + Math.cos(angle) * (r + 14);
    const y = cy + Math.sin(angle) * (r + 14) + 3;
    ctx.fillText(RADAR_LABELS[i], x, y);
  }
}

背景多边形(3 层):以画布中心 (115, 115) 为原点,半径 68px,遍历 5 个顶点以 -Math.PI / 2 为起始角度(正上方),按 2*PI / 5 等分计算坐标。三层多边形半径分别为 68/3(约 22.7px)、136/3(约 45.3px)、68px,以 COLORS.line 分割线色描边、1px 线宽,形成由外到内的等比缩放网格。每层遍历 5 个顶点,第一个顶点用 moveTo 移动画笔起点,后续顶点用 lineTo 连线,最后 closePath 闭合路径。

轴线:从原点到外圈顶点画 5 条径向轴线,同样以分割线色 1px 描边,让雷达图形成清晰的"轮辐"结构。每条轴线从中心 (cx, cy) 到外圈顶点 (cx + cos(angle) * r, cy + sin(angle) * r)

数据多边形:遍历 5 个维度,数据值 = RADAR_VALUES[i] + wobwobbreath 在 +0.03 和 -0.03 间交替),值域限制在 [0.1, 1]。以舞台紫 fillStyle 填充,globalAlpha 设为 0.3 实现半透明效果,填充后恢复为 1 再以 2px 线宽描边——先填充后描边的顺序确保描边不被半透明填充覆盖。数据多边形的顶点坐标为 (cx + cos(angle) * r * val, cy + sin(angle) * r * val)val 越大顶点越靠近外圈。

数据点:在每个维度顶点处以 4px 半径绘制金色实心圆点,作为数据的精确锚点。ctx.arc(x, y, 4, 0, Math.PI * 2) 绘制完整圆,fillStyle 为聚光金 COLORS.gold

标签:在外圈半径 +14px 处以 10px 无衬线字体居中绘制维度标签,textAlign 设为 centerfillStyle 为紫灰副标题色,y 坐标偏移 +3 实现垂直居中微调。五个标签分别对应台风、逻辑、感染力、语速、眼神,均匀分布在雷达图外圈。

十一、底部 Tab 栏

@Builder tabBar() {
  Row() {
    ForEach(TAB_LIST, (tab: TabMeta, idx: number) => {
      Column({ space: 3 }) {
        Text(tab.icon).fontSize(18).opacity(this.currentTab === idx ? 1 : 0.6)
        Text(tab.label).fontSize(9)
          .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
          .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 6, bottom: 6 })
        .onClick(() => { this.currentTab = idx; })
    }, (tab: TabMeta, idx: number) => `tab-${tab.label}-${idx}`)
  }.width('100%').backgroundColor(COLORS.card)
    .border({ width: { top: 1 }, color: COLORS.line })
}

底部 Tab 栏以 Row 等宽布局排列 6 个 Tab,每个 Tab 通过 layoutWeight(1) 等分宽度。选中态的图标 opacity 为 1、标签为聚光金(COLORS.tabOnCOLORS.gold 同值)加粗;未选中态的图标 opacity 为 0.6、标签为暗紫(COLORS.text3)常规字重。onClick 直接修改 currentTab 索引触发内容区切换。Tab 栏背景为卡片色(COLORS.card),顶部以 1px 分割线与内容区隔开(border({ width: { top: 1 }, color: COLORS.line })),视觉上形成"舞台地板"的效果——底部是稳固的导航基座,上方是可切换的舞台内容。键值生成器使用 `tab-${tab.label}-${idx}` 确保每个 Tab 的唯一标识,ForEachcurrentTab 变更时只更新选中态相关的属性(opacity、fontColor、fontWeight)而非重建整个 Tab 项。

十二、弹窗系统

12.1 通用遮罩层

@Builder modalOverlay(onClose: () => void) {
  Column().width('100%').height('100%').backgroundColor(COLORS.mask)
    .onClick(() => { onClose(); })
}

通用遮罩层以半透黑(rgba(0,0,0,0.6),即 COLORS.mask)覆盖全屏,点击任意区域触发 onClose 回调关闭弹窗。所有弹窗均以 Stack 为容器,遮罩层在底、弹窗内容在顶,通过 alignContent(Alignment.Center) 实现弹窗居中。onClose 参数类型为 () => void 的箭头函数,调用方在传入时绑定对应弹窗的关闭逻辑(如 () => { this.addModal = false; })。

12.2 新增练习记录弹窗

@Builder panelAdd(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 12 }) {
      Text('新增练习记录').fontSize(15)
        .fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
      TextInput({ placeholder: '日期(如 08-30)', text: this.formDate })
        .fontSize(12).height(40).backgroundColor(COLORS.dark).borderRadius(10)
        .fontColor(COLORS.title)
        .onChange((v: string) => { this.formDate = v; })
      TextInput({ placeholder: '主题(如 即兴演讲应变)', text: this.formTopic })
        .fontSize(12).height(40).backgroundColor(COLORS.dark).borderRadius(10)
        .fontColor(COLORS.title)
        .onChange((v: string) => { this.formTopic = v; })
      TextInput({ placeholder: '时长(分钟,如 20)', text: this.formDuration })
        .fontSize(12).height(40).backgroundColor(COLORS.dark).borderRadius(10)
        .fontColor(COLORS.title)
        .onChange((v: string) => { this.formDuration = v; })
      TextInput({ placeholder: 'AI 评分(0-100,如 86)', text: this.formScore })
        .fontSize(12).height(40).backgroundColor(COLORS.dark).borderRadius(10)
        .fontColor(COLORS.title)
        .onChange((v: string) => { this.formScore = v; })
      Row({ space: 10 }) {
        Button('取消').fontSize(12).height(38).backgroundColor(COLORS.dark)
          .fontColor(COLORS.sub).layoutWeight(1)
          .onClick(() => { onClose(); })
        Button('保存记录').fontSize(12).height(38).backgroundColor(COLORS.purple)
          .fontColor(COLORS.title).layoutWeight(1)
          .onClick(() => { this.confirmAdd(); onClose(); })
      }.width('100%')
    }.width('86%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
  }.alignContent(Alignment.Center).width('100%').height('100%')
}

新增弹窗包含标题、四个 TextInput(日期/主题/时长/评分)和取消/保存双按钮。TextInputtext 参数绑定 formDate/formTopic/formDuration/formScore 四个临时状态,onChange 回调实时更新。保存按钮调用 confirmAdd()——该方法将表单值解析为 PracticeItemunshiftpracticeList 置顶,对空值和非法值提供默认值兜底(空日期默认"08-29",空主题默认"自由练习",非法时长默认 15 分钟,非法评分默认 70 分),然后调用 clearForm() 清空表单。弹窗宽度 86% 居中,内边距 16px,以卡片色为底、14px 圆角。

12.3 编辑练习记录弹窗

编辑弹窗的结构与新增弹窗一致,但通过 openEdit(idx) 预填当前记录的四个字段值。保存按钮调用 confirmEdit()——该方法通过 @Observed 的属性级更新机制,直接修改 practiceList[editIdx]date/topic/duration/score 属性。由于 PracticeItem@Observed 类,属性变更自动触发引用了该实例的 Progress 进度条和 Text 文本的重新渲染,无需手动通知。编辑逻辑中空值不覆盖原值(if (this.formDate !== '') { target.date = this.formDate; }),确保用户只修改部分字段时其他字段保持不变。

confirmEdit() {
  if (this.editIdx < 0 || this.editIdx >= this.practiceList.length) { return; }
  const target = this.practiceList[this.editIdx];
  if (this.formDate !== '') { target.date = this.formDate; }
  if (this.formTopic !== '') { target.topic = this.formTopic; }
  const dur = Number(this.formDuration);
  if (!Number.isNaN(dur) && dur > 0) { target.duration = dur; }
  const score = Number(this.formScore);
  if (!Number.isNaN(score) && score >= 0) { target.score = score; }
  this.clearForm();
}

12.4 删除确认弹窗

@Builder panelDel(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 14 }) {
      Text('删除练习记录').fontSize(15)
        .fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
      Text(this.delIdx >= 0 && this.delIdx < this.practiceList.length
        ? `确认删除「${this.practiceList[this.delIdx].topic}」吗?删除后不可恢复。`
        : '记录已不存在,请返回刷新。')
        .fontSize(11).fontColor(COLORS.sub).width('100%')
      Row({ space: 10 }) {
        Button('再想想').fontSize(12).height(38).backgroundColor(COLORS.dark)
          .fontColor(COLORS.sub).layoutWeight(1)
          .onClick(() => { onClose(); })
        Button('确认删除').fontSize(12).height(38).backgroundColor(COLORS.red)
          .fontColor(COLORS.title).layoutWeight(1)
          .onClick(() => { this.confirmDel(); onClose(); })
      }.width('100%')
    }.width('86%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
  }.alignContent(Alignment.Center).width('100%').height('100%')
}

删除弹窗以警示红按钮呈现"确认删除",标题显示待删除记录的主题名。当 delIdx 越界时显示兜底文案"记录已不存在,请返回刷新"。确认按钮调用 confirmDel()——该方法通过 splice(delIdx, 1)practiceList 中移除目标记录,并重置 delIdx 为 -1。删除后 ForEach 的键值生成器自动回收对应 ListItem,无需手动刷新。取消按钮文字为"再想想",以暗紫底色呈现更柔和的取消语义,避免用户误操作。确认删除按钮以警示红 COLORS.red 为底色,视觉上强化删除操作的不可逆性。

十三、核心方法深度解析

13.1 影随人动能力链:startVideoMode 与 queryFraming

async startVideoMode() {
  if (!this.surfaceReady) { this.framingState = 'Surface 未就绪'; return; }
  if (this.sessionMode === 'video') { return; }
  if (this.sessionMode === 'photo') { await this.releaseSession(); }
  const granted = await this.requestCameraPermission();
  if (!granted) { this.framingState = '权限被拒'; return; }
  try {
    const ctx = this.getUIContext().getHostContext();
    if (ctx === undefined) { this.framingState = '上下文缺失'; return; }
    const manager = camera.getCameraManager(ctx);
    let device: camera.CameraDevice | undefined = undefined;
    const cameras = manager.getSupportedCameras();
    for (const d of cameras) {
      if (d.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK) {
        device = d; break;
      }
    }
    if (device === undefined) { this.framingState = '未发现后摄'; return; }
    const capability = manager.getSupportedOutputCapability(device, camera.SceneMode.NORMAL_VIDEO);
    const profile = capability.previewProfiles.length > 0 ? capability.previewProfiles[0] : undefined;
    if (profile === undefined) { this.framingState = '无预览Profile'; return; }
    this.cameraInput = manager.createCameraInput(device);
    await this.cameraInput.open();
    this.previewOutput = manager.createPreviewOutput(profile,
      this.previewController.getXComponentSurfaceId());
    this.videoSession = manager.createSession<camera.VideoSession>(camera.SceneMode.NORMAL_VIDEO);
    this.videoSession.on('error', (err: BusinessError) => {
      console.error(`video session error: ${err.code}`);
    });
    this.videoSession.beginConfig();
    this.videoSession.addInput(this.cameraInput);
    this.videoSession.addOutput(this.previewOutput);
    await this.videoSession.commitConfig();
    this.queryFraming(this.videoSession);
    await this.videoSession.start();
    this.sessionMode = 'video';
  } catch (e) {
    const err = e as BusinessError;
    this.framingState = `会话失败(${err.code})`;
    await this.releaseSession();
  }
}

startVideoMode 方法是影随人动能力链的入口,执行流程分为七步:前置检查(Surface 就绪、模式互斥、权限申请),上下文获取,后摄查找,能力配置获取,CameraInput 创建与打开,PreviewOutput 创建,VideoSession 创建与配置,能力链查询与启动。每一步失败都有明确的状态文案反馈(“Surface 未就绪”/“权限被拒”/“上下文缺失”/“未发现后摄”/“无预览Profile”/“会话失败(code)”),确保用户了解失败原因。特别注意 if (this.sessionMode === 'photo') { await this.releaseSession(); } 这一步——如果当前处于拍照模式,需要先释放 PhotoSession 再创建 VideoSession,因为同一 CameraInput 不能同时绑定两个 session。

queryFraming(session: camera.VideoSession) {
  try {
    if (!session.isControlCenterSupported()) {
      this.framingState = '控制中心不支持';
      this.framingSupported = false;
      return;
    }
    const effects = session.getSupportedEffectTypes();
    this.framingSupported = effects.includes(camera.ControlCenterEffectType.AUTO_FRAMING);
    if (!this.framingSupported) {
      this.framingState = 'AUTO_FRAMING 未声明';
      return;
    }
    session.enableControlCenter(true);
    this.framingOn = true;
    this.framingState = '影随人动已启用';
  } catch (e) {
    this.framingState = `接管失败(${(e as BusinessError).code})`;
    this.framingOn = false;
  }
}

queryFraming 方法实现三步能力链:第一步调用 isControlCenterSupported() 检查设备是否支持 ControlCenter,不支持则状态文案设为"控制中心不支持"并返回;第二步调用 getSupportedEffectTypes() 获取支持的效果列表,检查是否包含 AUTO_FRAMING,不包含则状态文案设为"AUTO_FRAMING 未声明"并返回;第三步调用 enableControlCenter(true) 请求系统接管构图,成功后设 framingOn = trueframingState = '影随人动已启用'。三步均通过 try-catch 包裹,异常时状态文案设为"接管失败(code)"。

13.2 手动对焦三接口:switchToPhotoMode 与 readBackFocus

async switchToPhotoMode() {
  if (this.sessionMode === 'photo') { return; }
  if (this.sessionMode === 'video') { await this.releaseSession(); }
  if (!this.surfaceReady) { this.focusResult = 'Surface 未就绪'; return; }
  const granted = await this.requestCameraPermission();
  if (!granted) { this.focusResult = '权限被拒'; return; }
  try {
    const ctx = this.getUIContext().getHostContext();
    if (ctx === undefined) { this.focusResult = '上下文缺失'; return; }
    const manager = camera.getCameraManager(ctx);
    let device: camera.CameraDevice | undefined = undefined;
    const cameras = manager.getSupportedCameras();
    for (const d of cameras) {
      if (d.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK) {
        device = d; break;
      }
    }
    if (device === undefined) { this.focusResult = '未发现后摄'; return; }
    const capability = manager.getSupportedOutputCapability(device, camera.SceneMode.NORMAL_PHOTO);
    const profile = capability.previewProfiles.length > 0 ? capability.previewProfiles[0] : undefined;
    if (profile === undefined) { this.focusResult = '无预览Profile'; return; }
    this.cameraInput = manager.createCameraInput(device);
    await this.cameraInput.open();
    this.previewOutput = manager.createPreviewOutput(profile,
      this.previewController.getXComponentSurfaceId());
    this.photoSession = manager.createSession<camera.PhotoSession>(camera.SceneMode.NORMAL_PHOTO);
    this.photoSession.on('error', (err: BusinessError) => {
      console.error(`photo session error: ${err.code}`);
    });
    this.photoSession.beginConfig();
    this.photoSession.addInput(this.cameraInput);
    this.photoSession.addOutput(this.previewOutput);
    await this.photoSession.commitConfig();
    await this.photoSession.start();
    this.sessionMode = 'photo';
    this.queryFocusSupport();
  } catch (e) {
    const err = e as BusinessError;
    this.focusResult = `拍照会话失败(${err.code})`;
    await this.releaseSession();
  }
}

switchToPhotoMode 方法与 startVideoMode 结构相似,关键差异在于:使用 camera.SceneMode.NORMAL_PHOTO 而非 NORMAL_VIDEO,创建 PhotoSession 而非 VideoSession,启动后调用 queryFocusSupport() 查询手动对焦能力而非 queryFraming。如果当前处于 video 模式,同样需要先释放 VideoSession(if (this.sessionMode === 'video') { await this.releaseSession(); }),因为 CameraInput 不能同时绑定两个 session。

readBackFocus() {
  if (this.photoSession === undefined) {
    this.focusResult = '会话未启动';
    return;
  }
  try {
    const back = this.photoSession.getFocusDistance();
    this.focusReadback = back;
    this.focusResult = Math.abs(back - this.focusDistance) < 0.01 ? '已生效' : '偏差';
    this.focusRecords.unshift(new FocusRecord(nowTime(), this.focusDistance, back, this.focusResult));
    if (this.focusRecords.length > 20) { this.focusRecords.pop(); }
  } catch (e) {
    const err = e as BusinessError;
    this.focusResult = `失败(${err.code})`;
    this.focusRecords.unshift(new FocusRecord(nowTime(), this.focusDistance, -1, this.focusResult));
  }
}

readBackFocus 方法是手动对焦三接口的读回验证核心:调用 getFocusDistance() 获取实际焦距读回值,与设定值 focusDistance 比较——差值小于 0.01 判"已生效",否则判"偏差"。无论成功还是失败,都会通过 unshift 将一条 FocusRecord 记录插入时间线顶部,超过 20 条时 pop 移除末尾。失败时读回值记为 -1,校验结果记为"失败(code)"。

13.3 会话释放:releaseSession

async releaseSession() {
  const session = this.videoSession ?? this.photoSession;
  const preview = this.previewOutput;
  const input = this.cameraInput;
  this.videoSession = undefined;
  this.photoSession = undefined;
  this.previewOutput = undefined;
  this.cameraInput = undefined;
  this.framingOn = false;
  try {
    if (session !== undefined) {
      session.off('error');
      await session.stop();
      await session.release();
    }
    if (preview !== undefined) { await preview.release(); }
    if (input !== undefined) { await input.close(); }
    this.sessionMode = 'idle';
  } catch (e) {
    console.error(`release failed: ${(e as BusinessError).message}`);
  }
}

releaseSession 方法是会话资源管理的统一出口,在模式切换和组件销毁时均调用此方法。方法首先将所有句柄引用保存到局部变量(因为后面要将成员变量置为 undefined),然后依次释放 session(off('error') 取消错误监听、stop() 停止、release() 释放)、preview(release() 释放)、input(close() 关闭),最后将 sessionMode 设为 'idle'videoSession ?? photoSession 使用空值合并运算符——优先取 VideoSession,若不存在则取 PhotoSession,确保两种会话模式都能正确释放。framingOn 重置为 false 确保影随人动状态同步重置。整个方法包裹在 try-catch 中,即使释放过程出错也不会阻塞后续操作,仅记录错误日志。

13.4 字幕控制方法群

buildCaptionOptions(): AICaptionOptions {
  const opts: AICaptionOptions = {
    initialOpacity: 1,
    sourceLanguage: this.srcLang,
    targetLanguage: this.tgtLang,
    fontSize: this.captionSize,
    fontColor: this.captionColor,
    onPrepared: () => {
      this.captionReady = true;
      this.captionErrMsg = '';
    },
    onError: (error: BusinessError) => {
      this.captionErrMsg = '字幕服务异常 ' + error.code + ':' + error.message;
    }
  };
  return opts;
}

buildCaptionOptions 方法动态组装 AICaptionOptions 对象,将 srcLang/tgtLang/captionSize/captionColor 四个状态填入,同时注册 onPrepared 回调(字幕引擎就绪时设 captionReady = true 并清空错误信息)和 onError 回调(字幕引擎出错时填充错误文案)。该方法在 AICaptionComponentoptions 参数中被调用,由于每次渲染都重新组装,状态变更自动反映到字幕组件。

switchSourceLang(code: string) {
  this.srcLang = code;
  if (code === 'zh') {
    this.tgtLang = 'zh';
  } else {
    this.tgtLang = 'zh-en';
  }
}

applyScene(scene: CaptionScene) {
  this.switchSourceLang(scene.src);
  if (scene.src === 'zh') {
    this.tgtLang = 'zh';
  } else {
    this.tgtLang = scene.tgt;
  }
}

switchSourceLang 封装了源语言切换时的目标语言联动逻辑:中文源锁定 zh,英文源默认 zh-enapplyScene 在此基础上按场景推荐设置目标语言——中文源仍强制锁定 zh,英文源则使用场景的 tgt 字段值(可能是 zhenzh-en)。两个方法共同确保语言组合始终合法。

feedAudioStream() {
  const block = new Uint8Array(640);
  for (let i = 0; i < 640; i += 2) {
    const t = (i / 2) / 16000;
    const v = Math.round(Math.sin(2 * Math.PI * 440 * t) * 6000);
    block[i] = v & 0xFF;
    block[i + 1] = (v >> 8) & 0xFF;
  }
  try {
    const audioData: AudioData = { data: block };
    this.captionController.writeAudio(audioData);
    this.captionFed++;
  } catch (e) {
    this.captionErrMsg = '音频写入失败:' + (e as BusinessError).message;
  }
}

feedAudioStream 方法生成 640 字节 PCM 音频块并写入字幕引擎。640 字节对应 16kHz 采样率、16bit 位深、单声道配置下约 20ms 的音频数据。循环以步长 2 遍历 Uint8Array(每 2 字节为一个 16bit 采样点),计算 440Hz 正弦波在该采样点的振幅值(乘以 6000 缩放),将 16bit 值拆分为低字节和高字节写入。生成的 PCM 块通过 captionController.writeAudio(audioData) 写入字幕引擎,成功后递增 captionFed 计数。虽然生成的是纯音正弦波而非真实语音,但足以演示 writeAudio 接口的工作机制和字幕引擎的音频处理链路。

13.5 弹窗 CRUD 方法群

confirmAdd() {
  const score = Number(this.formScore);
  const dur = Number(this.formDuration);
  this.practiceList.unshift(new PracticeItem(
    this.formDate === '' ? '08-29' : this.formDate,
    this.formTopic === '' ? '自由练习' : this.formTopic,
    Number.isNaN(dur) || dur <= 0 ? 15 : dur,
    Number.isNaN(score) || score < 0 ? 70 : score));
  this.clearForm();
}

openEdit(idx: number) {
  this.editIdx = idx;
  const p = this.practiceList[idx];
  this.formDate = p.date;
  this.formTopic = p.topic;
  this.formDuration = `${p.duration}`;
  this.formScore = `${p.score}`;
  this.editModal = true;
}

confirmEdit() {
  if (this.editIdx < 0 || this.editIdx >= this.practiceList.length) { return; }
  const target = this.practiceList[this.editIdx];
  if (this.formDate !== '') { target.date = this.formDate; }
  if (this.formTopic !== '') { target.topic = this.formTopic; }
  const dur = Number(this.formDuration);
  if (!Number.isNaN(dur) && dur > 0) { target.duration = dur; }
  const score = Number(this.formScore);
  if (!Number.isNaN(score) && score >= 0) { target.score = score; }
  this.clearForm();
}

confirmDel() {
  if (this.delIdx >= 0 && this.delIdx < this.practiceList.length) {
    this.practiceList.splice(this.delIdx, 1);
  }
  this.delIdx = -1;
}

clearForm() {
  this.formDate = '';
  this.formTopic = '';
  this.formDuration = '';
  this.formScore = '';
}

五个方法构成弹窗 CRUD 的完整闭环:confirmAdd 将表单值解析为 PracticeItemunshift 置顶插入 practiceList,空值和非法值有默认兜底;openEditpracticeList[idx] 预填四个表单字段并打开编辑弹窗;confirmEdit 通过 @Observed 属性级更新直接修改目标记录的字段,空值不覆盖原值;confirmDel 通过 splice 移除目标记录并重置 delIdxclearForm 清空四个表单临时字段。这些方法配合 @Observed 装饰器的响应式机制,确保 CRUD 操作后所有引用视图自动刷新——新增后进度条清单顶部出现新行,编辑后进度条和评分文字实时更新,删除后对应 ListItem 自动回收。

十四、功能模块对比表

功能模块 宿主 Tab HarmonyOS 特性 核心 API 状态变量数 关键设计
影随人动 相机 Camera Kit · VideoSession isControlCenterSupported / getSupportedEffectTypes / enableControlCenter 5 三步能力链 + Toggle 开关 + 会话三态卡
手动对焦 对焦 Camera Kit · PhotoSession isFocusDistanceSupported / setFocusDistance / getFocusDistance 6 三档预设 + Slider 连续调节 + 读回校验 + 时间线
AI 字幕 字幕 Speech Kit · AICaptionComponent sourceLanguage / targetLanguage / fontSize / fontColor 7 四维定制 + 语言联动 + 场景一键应用 + 音频写入
雷达图 舞台 Canvas · CanvasRenderingContext2D clearRect / beginPath / fill / stroke / arc 1 五维评估 + 3 层背景 + 呼吸微波动
课程展示 舞台 ArkUI · ForEach + Scroll ForEach / Scrollable / layoutWeight 1 横滑大卡 + 清单行双视图共享数据源
练习管理 复盘 ArkUI · @Observed + List @Observed / Progress / unshift / splice 4 统计三卡 + 进度条清单 + 增删改弹窗
学员档案 我的 ArkUI · linearGradient linearGradient / ForEach 0 渐变大卡 + 勋章灰度锁定 + 柱状图呼吸联动
弹窗系统 全局 ArkUI · Stack + Builder Stack / @Builder / modalOverlay 4 通用遮罩 + 表单双向绑定 + @Observed 属性级更新

十五、总结与展望

本平台以"舞台紫 + 聚光金"的深色主题为视觉基调,通过 6 个布局完全独立的 Tab 构建了从课程展示到数据复盘的完整口才训练闭环。在技术层面,平台深度融合了 HarmonyOS 6.1.1 的三大前沿特性:Camera Kit 的影随人动能力链通过 VideoSession 的 ControlCenter 通道实现演讲跟拍时人物自动居中,三步能力查询(isControlCenterSupported -> getSupportedEffectTypes -> enableControlCenter)确保特性在每台设备上的优雅降级;手动对焦三接口通过 PhotoSession 的 ManualFocus 通道实现从铭牌近拍到全场舞台的精确对焦控制,Slider 连续调节配合读回校验形成"设定-执行-验证"的完整闭环;Speech Kit 的 AI 字幕通过 AICaptionComponent 的四维定制字段实现语言、字体、颜色的全维度配置,配合 writeAudio 接口实现实时音频流转字幕。

在架构层面,平台的 @State 状态集群分为 Tab/弹窗、业务数据、表单临时、Camera Kit、Speech Kit、Canvas 六大集群,所有状态统一声明在组件顶层实现跨 Tab 共享。@Observed 类的属性级监听让练习记录的增删改无需手动通知即可实时同步到统计三卡和进度条清单。@Builder 方法群将 6 个 Tab 和 3 个弹窗的复杂 UI 结构拆分为可组合的构建块,每个 Builder 职责单一、易于维护。呼吸动画通过 aboutToAppear 中的 setInterval 驱动 breath 布尔值翻转,联动雷达图数据微波动、柱状图柱高交替和头部麦克风波动三处视觉元素,以最低成本实现"页面正在活跃"的感知暗示。

在交互设计层面,平台遵循"颜色即语义"原则:聚光金标识等级和评分高亮,通过绿确认已授权和已生效,信息蓝标注读回值和语言标签,警示红仅用于失败和删除。工具函数群将状态文案、景别、评分、等级、枚举、语言码等映射逻辑封装为纯函数,使 @Builder 方法保持简洁可读。弹窗系统通过通用遮罩层和 Stack 层叠实现三个弹窗的统一管理,onClose 回调模式使弹窗的关闭逻辑由调用方控制,实现了弹窗组件与业务逻辑的解耦。

展望未来,平台可在以下方向持续演进:一是引入 Speech Kit 的实时语音评估能力,将 AI 评分从静态数据升级为演讲过程中的实时反馈——通过 AICaptionControllerwriteAudio 接口将真实演讲音频写入字幕引擎,配合端侧 AI 模型分析语速、停顿、音量变化等特征,生成实时表达力评分。二是利用 Camera Kit 的 PORTRAIT 人像效果实现演讲跟拍的人像虚化,增强舞台沉浸感——通过 enableControlCenter 启用 PORTRAIT 效果(type=1),使演讲者背景虚化,突出人物主体,模拟真实舞台聚光灯效果。三是通过 distributedScheduler 实现跨设备训练数据同步,让用户在手机、平板、智慧屏间无缝切换练习场景——平板大屏适合课程观看和雷达图复盘,手机便携适合相机跟拍和对焦练习,智慧屏大屏适合全家演讲模拟。四是接入 MindSpore Lite 端侧推理,将表达力五维雷达图从预设数据升级为基于音频和视频特征的实时 AI 评估——通过端侧模型分析演讲视频中的面部表情、手势幅度、视线方向等视觉特征,以及音频中的语速、音调、停顿等声学特征,自动计算五维评分并实时更新雷达图。五是引入 AVPlayer 播放演讲示范视频,配合 SoundEffect 实现音效反馈,让口才训练从"数据驱动"升级为"多模态沉浸"。这些方向的探索将进一步缩小数字训练与真实舞台之间的体验鸿沟,让每一位演讲者都能在聚光灯下找到自己的表达节奏。

附录:DevEco Studio 创建新项目与查看 SDK 版本

本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。


一、创建新项目

1.1 进入欢迎界面

启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:

  • 新建项目:从头创建新项目
  • 打开项目:打开本地已有项目
  • 克隆仓库:从 Git 等版本控制拉取代码

点击 “新建项目” 按钮,进入项目创建向导。

在这里插入图片描述

1.2 选择项目模板

在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:

类型 说明
应用(Application) 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期
元服务(Atomic Service) 开发轻量级的原子化服务,无需安装即可使用

选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

在这里插入图片描述

1.3 配置项目信息

点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:

配置项 示例值 说明
项目名称(Project name) rollboat 应用的项目名称,建议使用英文命名
包名(Bundle name) com.rollboat.myapplication 应用唯一标识,采用反向域名格式
保存路径(Save location) D:\CodeFactory\rollboat 项目本地存储路径,避免使用中文和空格
兼容 SDK(Compatible SDK) 6.1.1(24) 目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异
模块名称(Module name) entry 主模块名称,默认 entry 为应用入口模块
设备类型(Device types) ☑ Phone 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV

右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

在这里插入图片描述

1.4 完成创建

确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法 作用
@Entry 标记为页面入口,可用于路由跳转
@Component 声明为自定义组件
@State 状态变量,数据变更时自动触发 UI 刷新
RelativeContainer 相对布局容器,替代传统线性布局
.onClick() 点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称 阶段 状态
HarmonyOS 6.1.1 Release ✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本 SDK 版本号 阶段 状态
API Version 24 6.1.1.100 Release ✅ 已安装
API Version 23 6.1.0.28 Beta1 未安装
API Version 22 6.0.2.112 Release 未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤 操作 关键点
创建项目 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 使用 Stage 模型 + ArkTS 语言
查看 SDK 设置 → HarmonyOS SDK SDK 已内置,无需手动安装
跨平台扩展 设置 → ArkUI-X 根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


Logo

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

更多推荐