请添加图片描述
请添加图片描述


第一章 项目架构概览

1.1 项目目录结构

一个规范的鸿蒙项目采用 Stage 模型,其目录组织遵循模块化原则。本项目核心代码集中在 entry/src/main/ets/ 下,按功能分层:

entry/src/main/ets/
├── entryability/
│   └── EntryAbility.ets          # 应用入口,Ability 生命周期
├── pages/
│   ├── Index.ets                 # 主页面:重生 AI 推理大师
│   ├── DatePickerDialogDemo.ets  # DatePickerDialog 日期弹窗演示
│   ├── RunnerPage.ets            # 单键跑酷游戏页面
│   └── AIChatService.ets         # AI 对话服务(SSE 流式网络)
├── components/
│   └── CommonComponents.ets      # 通用可复用组件库
├── model/
│   ├── AppModel.ets              # 数据模型定义
│   ├── SampleData.ets            # 示例数据
│   └── SpacedRepetition.ets      # 间隔重复算法
├── resources/
│   ├── base/
│   │   ├── element/
│   │   │   └── string.json       # 字符串资源
│   │   └── media/                # 图片资源
│   └── en_US/                    # 英文资源
└── module.json5                  # 模块配置

1.2 应用入口分析

EntryAbility.ets 是应用的门户,继承自 UIAbility,负责页面的生命周期管理和窗口创建:

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 加载起始页面
    windowStage.loadContent('pages/DatePickerDialogDemo', (err) => {
      if (err.code) {
        hilog.error(0x0000, 'App', '加载页面失败: %{public}s', JSON.stringify(err));
      }
    });
  }
}

这里的关键 API 是 windowStage.loadContent(),它决定了应用的首页。通过修改第一个参数(页面路径),可以灵活切换不同的演示页面。页面路径需要与 module.json5 中的 pages 配置相匹配。


第二章 弹窗系统深度解析

弹窗是移动应用中最常见的交互模式之一。鸿蒙原生提供了两套弹窗方案:CustomDialog(自定义弹窗)DatePickerDialog(系统日期弹窗)。前者给予开发者完全的 UI 控制权,后者则针对日期选择这一高频场景提供了开箱即用的体验。

2.1 CustomDialog 自定义弹窗

2.1.1 核心概念

CustomDialog 是鸿蒙提供的自定义弹窗容器,它通过 @CustomDialog 装饰器标记弹窗的结构体,通过 CustomDialogController 控制弹窗的生命周期。

其核心工作流程如下:

① 定义 @CustomDialog struct → ② 创建 CustomDialogController → ③ controller.open() 打开
                                                                     ↓
                                                              ④ 用户交互
                                                                     ↓
                                                              ⑤ controller.close() 关闭
                                                                     ↓
                                                              ⑥ 回调数据传回页面
2.1.2 自定义弹窗的完整实现

下面的代码展示了两个完整的 @CustomDialog 弹窗,分别演示了信息展示型和操作列表型两种常见场景:

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

// ========== 弹窗一:信息详情弹窗 ==========
@CustomDialog
struct InfoDetailDialog {
  controller: CustomDialogController;          // 弹窗控制器
  @Link dialogCount: number;                    // @Link 双向绑定
  private dialogTitle: string = '';             // 外部传入数据
  private dialogContent: string = '';
  private dialogIcon: ResourceStr = '';
  private onClose?: (result: string) => void;   // 回调函数
  @State private userFeedback: string = '';     // 弹窗内部状态

  build() {
    Column() {
      // 顶部图标
      if (this.dialogIcon !== '') {
        Text(this.dialogIcon).fontSize(48).margin({ bottom: 8 })
      }
      // 标题
      Text(this.dialogTitle)
        .fontSize(20).fontWeight(FontWeight.Bold)
        .fontColor('#1A1A2E').margin({ bottom: 12 })
      // 可滚动的正文
      Scroll() {
        Text(this.dialogContent).fontSize(15)
          .fontColor('#555555').lineHeight(24)
      }.height(120)
      // 用户输入
      TextInput({ placeholder: '请输入你的反馈...', text: this.userFeedback })
        .height(40).fontSize(14)
        .onChange((value) => { this.userFeedback = value; })
      // 操作按钮
      Row() {
        Button('取消').onClick(() => { this.controller.close(); })
        Button('确认').onClick(() => {
          this.dialogCount++;                    // @Link 同步更新
          if (this.onClose) { this.onClose(this.userFeedback); }
          this.controller.close();
        })
      }
    }.padding(24).backgroundColor(Color.White).borderRadius(16)
  }
}

// ========== 弹窗二:操作列表弹窗 ==========
@CustomDialog
struct ActionSheetDialog {
  controller: CustomDialogController;
  private title: string = '';
  private actions: string[] = [];
  private onSelect?: (index: number, label: string) => void;

  build() {
    Column() {
      Text(this.title).fontSize(18).fontWeight(FontWeight.Bold)
      Divider().height(1).width('100%').color('#E8E8E8')
      // 使用 ForEach 循环渲染操作项
      ForEach(this.actions, (action: string, index: number) => {
        Row() {
          Text(`0${index + 1}`).fontColor('#C9A84C')
          Text(action).fontSize(16).layoutWeight(1)
          Text('›').fontSize(20).fontColor('#CCCCCC')
        }.height(48).onClick(() => {
          if (this.onSelect) { this.onSelect(index, action); }
          this.controller.close();
        })
        if (index < this.actions.length - 1) {
          Divider().height(1).color('#F0F0F0').margin({ left: 24 })
        }
      })
      Button('取消').onClick(() => { this.controller.close(); })
    }.padding(24).backgroundColor(Color.White).borderRadius(16)
  }
}

要点说明:

装饰器 / API 作用 注意事项
@CustomDialog 标记结构体为弹窗组件 必须包含 controller: CustomDialogController
CustomDialogController 控制弹窗的打开和关闭 一个 controller 对应一个弹窗实例,不可复用
controller.open() 打开弹窗 在 build() 或 onPageShow() 中创建 controller
controller.close() 关闭弹窗 安全释放弹窗资源
@Link 弹窗与页面的双向绑定 弹窗内修改会同步到页面
回调函数 弹窗将数据传回页面 通过类属性函数传递
2.1.3 CustomDialogController 的创建与管理

控制器需要在页面生命周期内创建,通常选择 onPageShow()build() 方法中实例化:

@Entry
@Component
struct AlertDialogDemo {
  private infoController: CustomDialogController | null = null;

  initControllers(): void {
    this.infoController = new CustomDialogController({
      builder: InfoDetailDialog({
        dialogCount: this.dialogCount,
        dialogTitle: '系统通知',
        dialogContent: '欢迎体验鸿蒙原生 CustomDialog!',
        dialogIcon: '🚀',
        onClose: (feedback) => {
          if (feedback.trim()) {
            this.feedbackLog = [feedback, ...this.feedbackLog];
          }
        }
      }),
      autoCancel: true,                     // 点击遮罩层自动关闭
      alignment: DialogAlignment.Center,    // 居中显示
      offset: { dx: 0, dy: 0 },
      customStyle: true,                    // 启用自定义样式
      cornerRadius: 16,                     // 弹窗圆角
    });
  }
}

CustomDialogControllerOptions 配置说明:

配置项 类型 说明
builder CustomDialog 实例 传入弹窗结构体,支持传参
autoCancel boolean 是否允许点击遮罩层关闭
alignment DialogAlignment 弹窗对齐方式(Center / Top / Bottom / Left / Right)
offset {dx: number, dy: number} 弹窗偏移量
customStyle boolean 是否启用自定义样式(启用后可以自定义圆角、背景等)
cornerRadius Length 弹窗圆角(customStyle=true 时生效)
onWillDismiss 回调 弹窗即将关闭时触发

2.2 DatePickerDialog 系统日期弹窗

DatePickerDialog 是鸿蒙内置的日期选择弹窗,相比 CustomDialog,它的特点是零 UI 开发——系统提供了完整的年月日滚轮选择器 UI。

2.2.1 基础用法
DatePickerDialog.show({
  selected: new Date(),          // 预选日期(默认当天)
  onDateAccept: (value: Date) => {
    // 用户确认选择
    console.info('选中日期:' + value.toISOString());
  },
  onCancel: () => {
    // 用户取消选择
    console.info('用户取消了选择');
  }
});

就这么简单!一行 DatePickerDialog.show() 即可唤醒一个功能完整的日期选择器。

2.2.2 四种实战场景

在实际项目中,日期选择的需求往往各不相同,下面展示四种典型场景及其代码实现:

场景一:基础日期选择(无范围限制)

showBasicDatePicker(): void {
  DatePickerDialog.show({
    selected: this.selectedDate,
    onDateAccept: (value: Date) => {
      this.selectedDate = value;
      this.chineseDate = `${value.getFullYear()}${value.getMonth() + 1}${value.getDate()}`;
    },
    onCancel: () => {
      console.info('用户取消了选择');
    }
  });
}

场景二:范围限制(仅可选 2020 ~ 2030)

showRangeDatePicker(): void {
  DatePickerDialog.show({
    startDate: new Date(2020, 0, 1),      // 最早可选的日期
    endDate: new Date(2030, 11, 31),       // 最晚可选的日期
    selected: this.selectedDateInRange,
    onDateAccept: (value: Date) => {
      this.rangeDateStr = formatChineseDate(value);
    },
    onCancel: () => { /* 取消处理 */ }
  });
}

通过 startDateendDate 可以限定用户的可选范围,超出范围的日期在滚轮中会显示为灰色不可选状态。

场景三:农历模式显示

showLunarDatePicker(): void {
  DatePickerDialog.show({
    selected: this.selectedLunarDate,
    lunar: true,                           // 开启农历显示
    onDateAccept: (value: Date) => {
      this.lunarDateStr = formatChineseDate(value);
    }
  });
}

设置 lunar: true 后,日期滚轮上会同时显示公历和农历日期,这对于中国用户场景(如农历生日、传统节日选择)非常实用。

场景四:事件倒计时(限定未来日期)

showEventDatePicker(): void {
  const today = new Date();
  DatePickerDialog.show({
    startDate: today,                      // 不能选择过去
    endDate: new Date(today.getFullYear() + 5, 11, 31),
    selected: new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000), // 默认30天后
    onDateAccept: (value: Date) => {
      this.daysUntilEvent = daysBetween(today, value);
      this.addDateLog(`距离事件还有 ${this.daysUntilEvent}`);
    }
  });
}

// 计算日期差值工具函数
function daysBetween(date1: Date, date2: Date): number {
  const d1 = Date.UTC(date1.getFullYear(), date1.getMonth(), date1.getDate());
  const d2 = Date.UTC(date2.getFullYear(), date2.getMonth(), date2.getDate());
  return Math.floor(Math.abs(d2 - d1) / (1000 * 60 * 60 * 24));
}
2.2.3 配套的日期工具函数

围绕 DatePickerDialog,项目封装了一套完整的日期工具函数,展示了 ArkTS 中函数的模块化写法:

// 完整日期格式化:YYYY-MM-DD HH:mm:ss
function formatDate(date: Date): string {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');
  const h = String(date.getHours()).padStart(2, '0');
  const min = String(date.getMinutes()).padStart(2, '0');
  const s = String(date.getSeconds()).padStart(2, '0');
  return `${y}-${m}-${d} ${h}:${min}:${s}`;
}

// 中文日期:YYYY年MM月DD日
function formatChineseDate(date: Date): string {
  return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
}

// 星期中文名
function getWeekdayName(date: Date): string {
  const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
  return weekdays[date.getDay()];
}

// 判断是否为周末
function isWeekend(date: Date): boolean {
  const day = date.getDay();
  return day === 0 || day === 6;
}

// 判断是否为同一天
function isSameDay(date1: Date, date2: Date): boolean {
  return date1.getFullYear() === date2.getFullYear() &&
    date1.getMonth() === date2.getMonth() &&
    date1.getDate() === date2.getDate();
}

第三章 弹性布局:Column + layoutWeight

3.1 layoutWeight 的原理

在鸿蒙的声明式布局体系中,layoutWeight 是最核心的弹性布局手段之一。它的工作机制类似于 Flexbox 中的 flex-grow:在父容器分配完所有固定尺寸的子组件后,将剩余空间按照各子组件的 layoutWeight 值进行比例分配。

其计算公式如下:

子组件高度 = (父容器总高度 - 所有固定组件高度之和) × (该组件 layoutWeight / 所有弹性组件 layoutWeight 之和)

3.2 跑酷游戏的弹性布局实战

RunnerPage.ets 是一个极简的横版跑酷游戏,其布局方案完美诠释了 layoutWeight 的精髓:

build() {
  Column() {                        // 全屏容器,必须有 height('100%')
    // 固定顶部区(不参与弹性分配)
    Row() {
      Text('单键跑酷')
      Text('得分: ' + this.score)
    }
    .height(50)                     // 固定高度 50vp

    // ★ 弹性区 A:游戏主场景(50.0%)
    Canvas(this.gameContext)
      .layoutWeight(1.0)            // weight = 1.0

    // ★ 弹性区 B:游戏状态信息(15.0%)
    Column() {
      Text('最高分: ' + this.highScore)
      Text('速度: ' + this.speed)
    }
    .layoutWeight(0.3)              // weight = 0.3

    // ★ 弹性区 C:跳跃按钮(35.0%)
    Button('跳跃!')
      .layoutWeight(0.7)            // weight = 0.7
  }
  .width('100%')
  .height('100%')                   // 必须设置,否则 layoutWeight 无效
}

布局计算示例:

假设设备屏幕高度为 800vp:

  • 固定区总高度 = 50vp
  • 剩余空间 = 800 - 50 = 750vp
  • 弹性区总 weight = 1.0 + 0.3 + 0.7 = 2.0
  • 弹性区 A 高度 = 750 × (1.0 / 2.0) = 375vp
  • 弹性区 B 高度 = 750 × (0.3 / 2.0) = 112.5vp
  • 弹性区 C 高度 = 750 × (0.7 / 2.0) = 262.5vp

这种布局的优势在于屏幕自适应——不论是在 6.1 英寸的手机上还是 10.4 英寸的平板上,各区域的视觉比例始终保持一致。

3.3 layoutWeight 使用注意事项

  • 父容器必须显式设置 height('100%'),否则弹性布局没有剩余空间可分配
  • layoutWeight 只在 Column 的垂直方向(或 Row 的水平方向)生效
  • 固定高度的子组件应在弹性组件之前定义,确保正确的 Z 轴顺序和空间分配
  • 各弹性区的权重值可以是小数,系统会按比例自动计算

第四章 Canvas 游戏渲染引擎

4.1 Canvas 生命周期与帧动画

RunnerPage.ets 使用 Canvas 实现了完整的 2D 游戏渲染,其核心是 CanvasRenderingContext2D 和基于 setInterval 的游戏循环:

@Component
struct RunnerPage {
  private gameContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
  private gameInterval: number = -1;
  private playerY: number = 0;           // 角色 Y 坐标
  private playerVY: number = 0;          // 角色垂直速度
  private obstacles: Obstacle[] = [];    // 障碍物数组
  private score: number = 0;
  private gameRunning: boolean = false;

  // 启动游戏循环
  startGameLoop(): void {
    this.gameRunning = true;
    this.gameInterval = setInterval(() => {
      this.updateGame();     // 更新游戏状态
      this.renderFrame();   // 渲染画面
    }, 16);                  // 约 60 FPS
  }

  // 更新游戏状态(物理 + 碰撞检测)
  updateGame(): void {
    // 角色物理:重力加速
    this.playerVY += 0.6;
    this.playerY += this.playerVY;
    // 地面碰撞
    if (this.playerY > GROUND_Y) {
      this.playerY = GROUND_Y;
      this.playerVY = 0;
      this.isJumping = false;
    }
    // 障碍物移动
    for (let obs of this.obstacles) {
      obs.x -= this.speed;
    }
    // 碰撞检测
    this.checkCollision();
    // 生成新障碍物
    if (/* 生成条件 */) {
      this.obstacles.push({ x: CANVAS_WIDTH, y: GROUND_Y, width: 30, height: 40 });
    }
  }

  // 渲染画面
  renderFrame(): void {
    const ctx = this.gameContext;
    ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    // 绘制地面
    ctx.fillStyle = '#8B4513';
    ctx.fillRect(0, GROUND_Y, CANVAS_WIDTH, 5);
    // 绘制角色
    ctx.fillStyle = '#FF6600';
    ctx.fillRect(PLAYER_X, this.playerY, PLAYER_SIZE, PLAYER_SIZE);
    // 绘制障碍物
    ctx.fillStyle = '#CC0000';
    for (let obs of this.obstacles) {
      ctx.fillRect(obs.x, obs.y - obs.height, obs.width, obs.height);
    }
  }

  // 跳跃动作
  jump(): void {
    if (!this.isJumping) {
      this.playerVY = -12;   // 向上的初速度
      this.isJumping = true;
    }
  }
}

4.2 Canvas 渲染的性能优化要点

  • 使用 clearRect 局部清除:只清除变化区域,而非整个 Canvas
  • 避免在循环中频繁创建对象:障碍物对象池化复用
  • 控制绘制频率:setInterval 16ms(60FPS)是流畅与性能的最佳平衡
  • 使用整数坐标:避免子像素渲染带来的性能开销

第五章 装饰器体系与状态管理

ArkTS 的装饰器体系是实现响应式 UI 的基石。理解这些装饰器的工作原理,是写出高质量鸿蒙应用的前提。

5.1 装饰器总览

装饰器 适用对象 特点
@Entry struct 标记页面入口,只能有一个
@Component struct 标记可复用的组件
@CustomDialog struct 标记自定义弹窗
@State 成员变量 组件内部状态,变化触发 UI 刷新
@Prop 成员变量 从父组件传入的不可变数据
@Link 成员变量 与父组件双向绑定的可变数据
@Watch 成员变量 监听状态变化,执行回调
@Builder 方法 构建可复用的 UI 片段
@BuilderParam 成员变量 接收外部传入的 @Builder

5.2 @State 状态驱动 UI

@State 是最基础也最常用的状态装饰器。被它修饰的变量发生变化时,框架会自动重新渲染依赖该变量的 UI 部分:

@Entry
@Component
struct Demo {
  @State count: number = 0;           // 状态变量
  @State items: string[] = [];        // 状态数组
  @State isLoading: boolean = false;

  build() {
    Column() {
      Text(`计数: ${this.count}`)      // 依赖 count,自动响应
      Button('增加').onClick(() => {
        this.count++;                  // 状态变化 → UI 自动刷新
      })
    }
  }
}

5.3 @Builder 与 @BuilderParam 实现插槽

在 ArkTS 中,子组件不能像传统前端框架那样使用 children 插槽。取而代之的是 @BuilderParam 装饰器,它允许父组件将一段 UI 构建逻辑注入到子组件中:

// 子组件定义插槽
@Component
export struct Card {
  @Prop cardPadding: number = 16;
  @Prop cardRadius: number = 16;
  @BuilderParam content: () => void = this.defaultContent;

  @Builder
  defaultContent(): void {
    Text('默认卡片内容').fontSize(14).fontColor('#888');
  }

  build() {
    Column() {
      this.content()    // 渲染注入的内容
    }
    .padding(this.cardPadding)
    .backgroundColor('#ffffff')
    .borderRadius(this.cardRadius)
    .shadow({ radius: 4, color: '#1a000000', offsetX: 0, offsetY: 2 })
  }
}

// 父组件使用
@Entry
@Component
struct Parent {
  @Builder
  customCardContent(): void {
    Column() {
      Text('自定义标题')
      Text('自定义描述内容')
      Button('点击操作')
    }
  }

  build() {
    Card({ content: this.customCardContent })
  }
}

@BuilderParam 的实现机制与 React 的 render props 或 Vue 的 slot 有相似之处,但它在编译时类型更安全,且完全由 ArkTS 编译器静态分析。

5.4 @Prop 单向数据流

@Prop 用于从父组件向子组件传递不可变数据。当父组件更新传递的值时,子组件会同步更新;但子组件内部不能修改 @Prop 变量:

@Component
struct ChildComponent {
  @Prop title: string = '';
  @Prop count: number = 0;

  build() {
    Text(`${this.title}: ${this.count}`)
  }
}

@Entry
@Component
struct ParentComponent {
  @State parentCount: number = 0;

  build() {
    ChildComponent({ title: '子组件', count: this.parentCount })
  }
}

5.5 @Watch 状态变化监听

当需要在某个状态变化时执行副作用(如持久化存储、网络请求、日志记录),可以使用 @Watch

@Entry
@Component
struct WatchDemo {
  @State @Watch('onCountChange') count: number = 0;

  onCountChange(): void {
    console.info(`count 变化为: ${this.count}`);
    // 执行副作用:数据持久化、日志上报等
  }

  build() {
    Button('增加').onClick(() => {
      this.count++;
    })
  }
}

第六章 AI 流式交互:SSE 网络请求实战

6.1 SSE 协议与服务架构

Server-Sent Events(SSE)是一种基于 HTTP 的服务器推送协议,AI 聊天场景中广泛应用。与 WebSocket 不同,SSE 是单向的(服务器→客户端),但胜在实现简单、与标准 HTTP 协议兼容。

SSE 的数据格式如下:

data: {"choices":[{"delta":{"content":"部分响应内容"}}]}

data: {"choices":[{"delta":{"content":"更多内容"}}]}

data: [DONE]

6.2 基于 @kit.NetworkKit 的流式请求实现

项目中的 AIChatService.ets 使用 @kit.NetworkKit 实现了完整的 SSE 流式请求:

import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';

export interface ChatMessage {
  role: string;
  content: string;
}

export interface AICallbacks {
  onData: (text: string) => void;      // 每次收到 token
  onDone: () => void;                   // 流式结束
  onError: (errMsg: string) => void;    // 错误处理
}

let httpRequestTask: http.HttpRequest | null = null;

export function queryAI(callbacks: AICallbacks, messages: ChatMessage[]): void {
  const httpRequest = http.createHttp();
  httpRequestTask = httpRequest;

  let buffer = '';
  let isDone = false;

  // 监听 SSE 数据流
  httpRequest.on('dataReceive', (data: ArrayBuffer) => {
    const text = arrayBufferToString(data);
    buffer += text;

    // 按行解析 SSE
    const lines = buffer.split('\n');
    buffer = lines.pop() ?? '';

    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed.startsWith('data:')) continue;
      if (trimmed === 'data:[DONE]') {
        if (!isDone) { isDone = true; callbacks.onDone(); }
        continue;
      }
      const content = parseSSEDataLine(trimmed);
      if (content) callbacks.onData(content);
    }
  });

  httpRequest.on('dataEnd', () => {
    if (!isDone) { isDone = true; callbacks.onDone(); }
    httpRequestTask = null;
  });

  // 发起 POST 请求
  httpRequest.request(
    'https://api.example.com/v1/chat/completions',
    {
      method: http.RequestMethod.POST,
      header: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
        'Accept': 'text/event-stream',
      },
      extraData: JSON.stringify(requestBody),
      connectTimeout: 30000,
      readTimeout: 120000,
    },
    (err, resp) => {
      if (err) { callbacks.onError(`请求失败: ${JSON.stringify(err)}`); return; }
      // 非流式回退逻辑
      if (!receivedAnyData && resp.result) {
        // 解析完整响应体……
      }
    }
  );
}

// 取消请求
export function cancelAI(): void {
  if (httpRequestTask) {
    httpRequestTask.destroy();
    httpRequestTask = null;
  }
}

6.3 流式响应的 UI 渲染

在主页面 Index.ets 中,流式数据通过 onData 回调逐步拼接到页面状态中:

queryAI({
  onData: (text: string) => {
    rawContent += text;              // 逐步拼接
  },
  onDone: () => {
    this.isLoading = false;
    const parsed = JSON.parse(rawContent) as CaseData;
    this.caseData = parsed;          // 状态更新 → UI 自动刷新
    this.revealedHints = new Array(parsed.hints.length).fill(false);
  },
  onError: (errMsg) => {
    this.errorMsg = '请求失败:' + errMsg;
  }
}, chatHistory);

第七章 组件化设计与复用

7.1 通用组件库设计

项目中的 CommonComponents.ets 构建了一套可复用组件库,包含四个通用组件:

组件名 功能 核心 Props
Card 通用卡片容器 cardPadding, cardColor, cardRadius, content(@BuilderParam)
ProgressRing 圆形进度条 ringProgress, ringSize, ringStroke, ringColor
ModuleEntryCard 模块入口卡片 entryIcon, entryLabel, entryColor, onClickAction
AppHeader 顶部标题栏 headerTitle, headerSubtitle, showBack, onBack

7.2 Card 容器的组件化实践

@Component
export struct Card {
  @Prop cardPadding: number = 16;
  @Prop cardMargin: number = 12;
  @Prop cardColor: string = '#ffffff';
  @Prop cardRadius: number = 16;
  @BuilderParam content: () => void = this.defaultContent;

  @Builder
  defaultContent(): void {
    Text('卡片内容').fontSize(14).fontColor('#888');
  }

  build() {
    Column() {
      this.content()
    }
    .width('100%')
    .padding(this.cardPadding)
    .backgroundColor(this.cardColor)
    .borderRadius(this.cardRadius)
    .margin({ bottom: this.cardMargin })
    .shadow({ radius: 4, color: '#1a000000', offsetX: 0, offsetY: 2 })
  }
}

这个 Card 组件通过 @BuilderParam 实现内容插槽,允许外部传入任意 UI 内容,同时又保持卡片的外框样式统一。这种设计模式在大型项目中被广泛使用。

7.3 圆形进度条组件

ProgressRing 组件使用 Canvas 绘制圆形进度条,展示了 Canvas 自定义绘图的组件化封装思路:

@Component
export struct ProgressRing {
  @Prop ringProgress: number = 0;
  @Prop ringSize: number = 80;
  @Prop ringStroke: number = 6;
  @Prop ringColor: string = '#3a7bd5';
  @Prop ringValue: string = '';

  private ringContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();

  drawRing(ctx: CanvasRenderingContext2D, value: number, isBg: boolean): void {
    const cx = this.ringSize / 2;
    const cy = this.ringSize / 2;
    const r = (this.ringSize - this.ringStroke) / 2;
    const startAngle = -Math.PI / 2;
    const endAngle = startAngle + (value / 100) * 2 * Math.PI;

    ctx.beginPath();
    ctx.arc(cx, cy, r, startAngle, endAngle);
    ctx.strokeStyle = isBg ? '#e8ecf0' : this.ringColor;
    ctx.lineWidth = this.ringStroke;
    ctx.lineCap = 'round';
    ctx.stroke();
  }

  build() {
    Stack() {
      Canvas(this.ringContext)
        .width(this.ringSize).height(this.ringSize)
        .onReady(() => { this.drawRing(this.ringContext, 100, true); })
      Canvas(this.progressContext)
        .width(this.ringSize).height(this.ringSize)
        .onReady(() => { this.drawRing(this.progressContext, this.ringProgress, false); })
      Text(this.ringValue).fontSize(16).fontWeight(FontWeight.Bold)
    }
    .width(this.ringSize).height(this.ringSize)
  }
}

第八章 数据层架构

8.1 数据类型定义

AppModel.ets 统一管理项目中的所有数据类型,采用 TypeScript 的 interfaceenum 进行类型约束:

// 难度枚举
export enum Difficulty { EASY = 1, MEDIUM = 2, HARD = 3 }

// 词性枚举
export enum PartOfSpeech { NOUN = 'n.', VERB = 'v.', ADJ = 'adj.', ADV = 'adv.' }

// 单词接口
export interface WordItem {
  id: number;
  word: string;
  phonetic: string;
  translation: string;
  partOfSpeech: string;
  exampleSentence: string;
  exampleTranslation: string;
  difficulty: Difficulty;
  category: string;
  audioPath: string;
}

// 学习记录接口
export interface StudyRecord {
  wordId: number;
  reviewCount: number;
  correctCount: number;
  lastReviewTime: string;
  masteryLevel: number;
}

8.2 间隔重复算法

SpacedRepetition.ets 实现了间隔重复(Spaced Repetition)算法,这是语言学习类应用的核心算法之一:

// 间隔重复算法核心逻辑
export function calculateNextReview(record: StudyRecord): number {
  // 根据掌握程度计算下次复习间隔(小时)
  const mastery = record.masteryLevel;
  if (mastery < 0.3) return 4;           // 不熟练:4小时后
  if (mastery < 0.6) return 24;          // 一般:1天后
  if (mastery < 0.8) return 72;          // 较好:3天后
  return 168;                             // 熟练:7天后
}

数据的合理分层(界面层 → 业务层 → 数据层)是构建可维护应用的关键。在本项目中,model/ 目录承担数据层和部分业务逻辑,pages/ 目录承担界面层和页面逻辑,components/ 承担 UI 复用层。


第九章 资源管理与国际化

9.1 字符串资源

鸿蒙的 string.json 文件集中管理所有字符串文本,便于国际化:

{
  "string": [
    { "name": "module_desc", "value": "鸿蒙英语学习应用" },
    { "name": "home_title", "value": "英语学习" },
    { "name": "word_study", "value": "词汇学习" },
    { "name": "listening", "value": "听力训练" }
  ]
}

在代码中通过 $string('resource_name') 引用资源,实现多语言适配。

9.2 模块配置

module.json5 是 Stage 模型的核心配置文件,定义模块的元信息、能力(Ability)和设备类型:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "mainElement": "EntryAbility",
    "deviceTypes": ["phone"],
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "exported": true,
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["action.system.home"]
          }
        ]
      }
    ]
  }
}

第十章 总结与最佳实践

10.1 核心要点回顾

通过本项目的实践,我们系统掌握了鸿蒙 ArkTS 开发的以下核心能力:

技术领域 核心知识点 应用场景
弹窗系统 CustomDialog + DatePickerDialog 信息展示、日期选择、操作确认
弹性布局 Column + layoutWeight 全屏自适应、游戏布局
状态管理 @State @Prop @Link @Watch 响应式 UI、组件通信
组件化 @Builder @BuilderParam 可复用组件库构建
Canvas 渲染 CanvasRenderingContext2D 游戏、数据可视化
网络请求 @kit.NetworkKit + SSE AI 流式对话

10.2 常见陷阱与避坑指南

问题 1:弹窗控制器重复创建

CustomDialogController 不可复用。每次调用 new CustomDialogController() 都会创建一个新的弹窗实例。如果需要在同一个页面多次打开同一个弹窗,必须在每次打开前重新创建或更新 builder 参数。

问题 2:layoutWeight 不生效

最常见的错误是父容器没有设置 height('100%')layoutWeight 分配的是剩余空间,如果父容器高度不确定(由内容撑开),就没有"剩余空间"可言。

问题 3:状态更新但 UI 不变

检查状态变量是否被正确的装饰器修饰。只有 @State@Prop@Link 等装饰器修饰的变量变化才会触发 UI 重绘。普通成员变量(privatepublic)的变化不会触发更新。

问题 4:弹窗闪烁或显示异常

确保 CustomDialogControllercustomStyle 设置为 true,并显式设置 cornerRadiusbackgroundColor。另外,弹窗内部的最小宽度建议设置为 '100%',避免内容撑不满弹窗区域。

10.3 展望

HarmonyOS NEXT 的 ArkTS 框架正在快速进化。随着 API 版本的迭代,更多的原生能力正在开放:自由流转、多设备协同、元服务卡片等。建议开发者持续关注以下方向:

  • ArkUI 跨平台能力:一套代码运行在手机、平板、车机、智慧屏上
  • TaskPool 多线程:脱离主线程执行计算密集型任务
  • ArkCompiler 性能优化:AOT 编译 + 运行时 PGO,实现原生级性能
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐