鸿蒙开发从零到上架

鸿蒙开发从零到上架 —— 完整指南

适用版本:HarmonyOS Next 5.0 (API 12+) | 语言:ArkTS | 工具:DevEco Studio 5.0+ 特点:每个关键字标注中文,每段代码解释框架结构;从 Hello World 起步,逐步构建完整App 作者实战环境:Mac ARM + DevEco Studio 26.0.0 + 真机调试


目录


第1章 鸿蒙生态全景

1.1 什么是 HarmonyOS

HarmonyOS(鸿蒙)是华为自研的全场景分布式操作系统,与 Android/iOS 完全不同——它使用自研内核,不是 Android 套壳。

关键版本节点:

版本 状态 说明
HarmonyOS 4.x 过去式 兼容 Android APK
HarmonyOS Next (5.0) 当前主力 纯血鸿蒙,不兼容 APK,仅支持 HAP

1.2 技术栈一览

┌──────────────────────────────────────────────────┐
│              你的应用包 (HAP 包)                    │
│  ┌────────────────────────────────────────────┐  │
│  │    ArkUI (声明式UI框架)                      │  │
│  │    ├── 组件体系 (component)  Text/Image/...  │  │
│  │    ├── 布局体系 (layout)     Column/Row/...  │  │
│  │    └── 状态管理 (state)      @State/@Prop    │  │
│  ├────────────────────────────────────────────┤  │
│  │    ArkTS (TypeScript 超集)                   │  │
│  │    └── 装饰器 (decorator)   @Component/@Entry │  │
│  ├────────────────────────────────────────────┤  │
│  │    Ability 框架                               │  │
│  │    ├── UIAbility    页面容器                  │  │
│  │    └── ServiceAbility 后台服务                │  │
│  ├────────────────────────────────────────────┤  │
│  │    ArkCompiler 编译器 + 方舟运行时            │  │
│  └────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘

1.3 开发语言对比

特性 (feature) 鸿蒙 ArkTS Android Kotlin iOS Swift
语言基础 TypeScript Kotlin/Java Swift
UI框架 ArkUI Jetpack Compose SwiftUI
声明式 (declarative)
组件化 (component) @Component @Composable View
状态管理 (state) @State mutableStateOf @State

有 Web 前端经验的人学 ArkTS 最快,因为本质是 TypeScript + 装饰器。

1.4 学习路线图(从简单到困难)

┌─────────────────────────────────────────────────────────────┐
│                   🎯 鸿蒙开发学习路线(递进式)                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  第1步:环境搭建 + Hello World                                │
│  ┌──────────────────────────────────────┐                  │
│  │ 装DevEco Studio → 创建项目 → 跑起来   │   ⏱ 半天          │
│  └───────────┬──────────────────────────┘                  │
│              ▼                                              │
│  第2步:理解页面骨架                                          │
│  ┌──────────────────────────────────────┐                  │
│  │ @Entry/@Component/struct/build()     │   ⏱ 1天           │
│  │ 把Hello World改成互动页面             │                  │
│  └───────────┬──────────────────────────┘                  │
│              ▼                                              │
│  第3步:ArkTS 语言基础                                        │
│  ┌──────────────────────────────────────┐                  │
│  │ 类型 → 函数 → 类 → 异步 → 解构       │   ⏱ 2-3天         │
│  └───────────┬──────────────────────────┘                  │
│              ▼                                              │
│  第4步:常用组件 + 布局                                        │
│  ┌──────────────────────────────────────┐                  │
│  │ Text/Image/Button → Column/Row/Scroll │   ⏱ 3天          │
│  └───────────┬──────────────────────────┘                  │
│              ▼                                              │
│  第5步:状态管理(最难)                                       │
│  ┌──────────────────────────────────────┐                  │
│  │ @State→@Prop→@Link→@Provide→@Storage │   ⏱ 3-5天        │
│  └───────────┬──────────────────────────┘                  │
│              ▼                                              │
│  第6步:路由 + 数据存储 + 网络                                │
│  ┌──────────────────────────────────────┐                  │
│  │ router → Preferences → SQLite → HTTP │   ⏱ 3天           │
│  └───────────┬──────────────────────────┘                  │
│              ▼                                              │
│  第7步:实战项目 + 真机调试 + 上架                             │
│  ┌──────────────────────────────────────┐                  │
│  │ 从零写完整App → 真机调试 → 上架      │   ⏱ 7-14天        │
│  └──────────────────────────────────────┘                  │
│                                                             │
│  📦 总计:约 20-30 天可独立开发完整App                        │
└─────────────────────────────────────────────────────────────┘

第2章 环境搭建

2.1 硬件要求

项目 最低要求
CPU ARM Mac (M1+) 或 x86 16GB+
内存 16GB 以上(模拟器很吃内存)
磁盘 SDK 占 20GB+,预留 50GB

2.2 安装 DevEco Studio(开发工具)

  1. 访问 https://developer.huawei.com/consumer/cn/deveco-studio/
  2. 下载 Mac ARM 版
  3. 拖入 /Applications 或外置盘
  4. 首次启动选择 SDK 安装路径

你的 SDK 实际位置:

~/Library/Huawei/Sdk/          ← HarmonyOS SDK    14G
~/Library/OpenHarmony/Sdk/     ← OpenHarmony SDK  3.9G
~/Library/ArkUI-X/Sdk/         ← 跨平台 SDK       1.4G

⚠️ SDK 路径有硬编码依赖,不要手动移动。要换位置只能重装 DevEco Studio 时指定。

2.3 配置 SDK(软件开发工具包)

打开 DevEco Studio → Preferences → SDK:

HarmonyOS SDK Location: ~/Library/Huawei/Sdk
OpenHarmony SDK Location: ~/Library/OpenHarmony/Sdk

需要下载: - API Version 12+ — HarmonyOS Next - System Image — 模拟器镜像 - Toolchains — 编译工具链

2.4 创建第一个项目

File → New → Create Project → Empty Ability

项目的文件夹结构:

MyApp/                           ← 项目根目录
├── AppScope/                    ← 应用全局配置
│   └── app.json5                ← 包名(bundleName)、版本号(version)
├── entry/                       ← 主模块(module)
│   └── src/main/
│       ├── ets/                 ← ArkTS 源码目录
│       │   ├── entryability/    ← 入口Ability
│       │   │   └── EntryAbility.ets
│       │   └── pages/           ← 页面(page)目录
│       │       └── Index.ets    ← 默认首页
│       ├── resources/           ← 资源文件(图片/字符串/颜色)
│       │   ├── base/
│       │   │   ├── element/
│       │   │   ├── media/
│       │   │   └── profile/
│       │   └── rawfile/         ← 原始文件(JSON/数据库)
│       └── module.json5         ← 模块配置(权限/Ability声明)
├── build-profile.json5          ← 构建配置(签名/SDK版本)
└── hvigorfile.ts                ← 构建脚本(hvigor)

第3章 Hello World —— 第一个鸿蒙应用

🎯 本章目标:创建第一个鸿蒙应用,在模拟器/手机上看到”你好,鸿蒙”,理解代码的每一行在做什么。

3.1 创建项目 → 立即运行

1. DevEco Studio → File → New → Create Project
2. 选择 "Empty Ability" 模板
3. 填写:
   - Project name: HelloWorld
   - Bundle name: com.example.helloworld
   - Save location: 选一个目录
   - Compile SDK: API 12+
4. 点击 Finish

项目生成后,打开 entry/src/main/ets/pages/Index.ets,你会看到:

// Index.ets —— 项目自动生成的默认页面

@Entry               // ← @Entry: 表示这是一个"页面入口",可被router跳转
@Component           // ← @Component: 表示这是一个"UI组件"
struct Index {       // ← struct: 结构体(不是class!ArkTS组件必须用struct)

  @State message: string = 'Hello World'
  // @State: 状态变量。当message的值改变时,界面会自动刷新

  build() {          // ← build(): 每个组件必须有的方法,描述界面长什么样
    RelativeContainer() {
      // RelativeContainer: 相对布局容器
      Text(this.message)
      // Text: 文本组件,显示文字
      // this.message: 读取 @State 变量 message 的值
        .fontSize(50)
        // .fontSize(50): 设置字号为50
        .fontWeight(FontWeight.Bold)
        // .fontWeight(): 字体粗细 → Bold 加粗
        .id('HelloWorld')
        // .id(): 给组件一个ID(相对布局中用)
    }
    .height('100%')
    // .height('100%'): 高度占满父容器
    .width('100%')
    // .width('100%'): 宽度占满父容器
  }
}

点击 ▶️ 运行,模拟器上会显示:

┌──────────────────────┐
│                      │
│                      │
│     Hello World      │
│                      │
│                      │
└──────────────────────┘

恭喜!你第一个鸿蒙应用跑起来了! 🎉

3.2 动手改造:Hello World → 你好鸿蒙

现在把代码改一下,看看效果:

@Entry
@Component
struct Index {
  @State message: string = '你好,鸿蒙!'    // 把Hello World改成中文

  build() {
    Column() {                              // 改用Column(Column比RelativeContainer更常用)
      Text(this.message)
        .fontSize(50)
        .fontWeight(FontWeight.Bold)
        .fontColor('#007AFF')              // ← 新增:字体颜色(蓝色)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)      // ← 新增:垂直居中
    .alignItems(HorizontalAlign.Center)    // ← 新增:水平居中
  }
}

改完保存 → 模拟器热重载 → 立刻看到效果。

学到的知识点: | 关键字 | 中文 | 作用 | |——–|——|——| | Column() | 列布局 | 子元素竖着排列 | | .fontColor() | 字体颜色 | 设置文字颜色 | | .justifyContent() | 主轴对齐 | 控制子元素在主轴方向的位置 | | .alignItems() | 交叉轴对齐 | 控制子元素在交叉轴方向的位置 |

3.3 再加一个按钮:让文字能点击变化

@Entry
@Component
struct Index {
  @State message: string = '你好,鸿蒙!'

  build() {
    Column({ space: 30 }) {        // space: 子元素之间的间距=30
      Text(this.message)
        .fontSize(50)
        .fontWeight(FontWeight.Bold)
        .fontColor('#007AFF')

      // 自定义按钮(不用原生Button,用Text + onClick)
      Text('点击我')
        .fontSize(20)
        .fontColor(Color.White)
        .backgroundColor('#007AFF')
        .borderRadius(12)
        .padding({ top: 14, bottom: 14, left: 32, right: 32 })
        .onClick(() => {
          // onClick: 点击事件回调
          this.message = '你点击了按钮!'
          // 修改@State → build()自动重新执行 → UI自动更新
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

运行效果:

初始状态:                   点击后:
┌──────────────┐          ┌──────────────────┐
│              │          │                  │
│  你好,鸿蒙!  │   →→→   │  你点击了按钮!    │
│              │          │                  │
│  [ 点击我 ]  │          │  [ 点击我 ]      │
│              │          │                  │
└──────────────┘          └──────────────────┘

核心概念:UI = f(State) - @State message 是状态 - Text(this.message) 根据状态显示内容 - this.message = 'xxx' 修改状态 - 状态变了 → build() 自动重新执行 → UI 自动刷新 - 你不需要手动操作DOM!这就是声明式UI的核心

3.4 再加一点:计数器

把前面的按钮例子扩展成一个完整的计数器:

@Entry
@Component
struct Index {
  @State count: number = 0       // count 计数,初始=0

  build() {
    Column({ space: 30 }) {
      // 标题
      Text('计数器')
        .fontSize(32)
        .fontWeight(FontWeight.Bold)

      // 当前数字
      Text(`${this.count}`)      // 模板字符串:把count的值嵌入文字
        .fontSize(80)
        .fontWeight(FontWeight.Bold)
        .fontColor(this.count >= 0 ? '#007AFF' : '#FF3B30')
        // 三元表达式: 条件 ? 真时的值 : 假时的值
        // count>=0时蓝色,负数时红色

      // 按钮行
      Row({ space: 16 }) {       // Row: 行布局,子元素横着排
        // -1 按钮
        Text('-1')
          .fontSize(22)
          .fontColor(Color.White)
          .backgroundColor('#FF3B30')
          .width(60).height(60)
          .borderRadius(30)      // 30=宽度一半 → 正圆
          .textAlign(TextAlign.Center)
          .onClick(() => { this.count-- })  // count减1

        // 重置按钮
        Text('重置')
          .fontSize(18)
          .fontColor(Color.White)
          .backgroundColor('#8E8E93')
          .borderRadius(12)
          .padding({ top: 12, bottom: 12, left: 20, right: 20 })
          .onClick(() => { this.count = 0 })  // count归零

        // +1 按钮
        Text('+1')
          .fontSize(22)
          .fontColor(Color.White)
          .backgroundColor('#34C759')
          .width(60).height(60)
          .borderRadius(30)
          .textAlign(TextAlign.Center)
          .onClick(() => { this.count++ })  // count加1
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

本章小结:你已经学会了

技能 说明
创建项目 Empty Ability 模板
@Entry / @Component 页面和组件的声明
struct + build() 组件的标准结构
@State 状态变量,改了UI自动刷新
Text() 文本显示
Column() / Row() 竖排/横排布局
.onClick() 点击事件
链式调用 .xxx() 配置组件属性

🚀 进度:你已经能独立写出”计数器”级别的互动应用了!接下来深入理解每个部分的原理。

3.5 实战练习:猜数字小游戏

学完本章,试试能不能独立完成这个小游戏:

@Entry
@Component
struct GuessNumberGame {
  @State secret: number = Math.floor(Math.random() * 100) + 1  // 秘密数字1~100
  @State guess: string = ''           // 用户输入
  @State hint: string = '猜一个1~100的数字'  // 提示文字
  @State attempts: number = 0         // 猜测次数
  @State gameOver: boolean = false    // 是否猜对

  build() {
    Column({ space: 24 }) {
      Text('猜数字')
        .fontSize(32)
        .fontWeight(FontWeight.Bold)

      Text(this.hint)
        .fontSize(20)
        .fontColor(this.gameOver ? '#34C759' : '#FF9500')

      TextInput({ text: this.guess, placeholder: '输入你的猜测' })
        .type(InputType.Number)
        .width(200)
        .onChange((value: string) => { this.guess = value })

      Row({ space: 16 }) {
        Text('猜!')
          .fontSize(20)
          .fontColor(Color.White)
          .backgroundColor('#007AFF')
          .width(80).height(44)
          .borderRadius(22)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            if (this.gameOver) return
            let num = parseInt(this.guess)
            this.attempts++
            if (num === this.secret) {
              this.hint = `🎉 恭喜!${this.attempts}次就猜对了!`
              this.gameOver = true
            } else if (num < this.secret) {
              this.hint = '📈 太小了,再大一点!'
            } else {
              this.hint = '📉 太大了,再小一点!'
            }
          })

        Text('重来')
          .fontSize(20)
          .fontColor(Color.White)
          .backgroundColor('#8E8E93')
          .width(80).height(44)
          .borderRadius(22)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.secret = Math.floor(Math.random() * 100) + 1
            this.guess = ''
            this.hint = '猜一个1~100的数字'
            this.attempts = 0
            this.gameOver = false
          })
      }

      Text(`已猜 ${this.attempts} 次`)
        .fontSize(14)
        .fontColor('#999')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

练习要点: | 知识点 | 用法 | |——–|——| | Math.random() | 生成随机数 | | parseInt() | 字符串转整数 | | @State gameOver | 控制游戏状态 | | 条件渲染 | 根据gameOver显示不同颜色 |


第4章 认识页面结构

在Hello World中我们已经见过 @Entry @Component struct build(),本章深入理解它们的原理和规则。

4.1 Stage 模型架构

Stage 模型(HarmonyOS Next 专属架构)
┌────────────────────────────────────────┐
│          App 应用 (application)          │
│  ┌──────────────────────────────────┐  │
│  │  HAP 包 (HarmonyOS Application    │  │
│  │        Package)  主模块 entry      │  │
│  │  ┌────────────────────────────┐  │  │
│  │  │  UIAbility 界面能力          │  │  │
│  │  │  ├── WindowStage 窗口阶段    │  │  │
│  │  │  │   └── Page 页面          │  │  │
│  │  │  └── 生命周期回调            │  │  │
│  │  └────────────────────────────┘  │  │
│  └──────────────────────────────────┘  │
│  ┌──────────────────────────────────┐  │
│  │  HSP 包 (HarmonyOS Shared         │  │
│  │          Package)  共享模块       │  │
│  │  └── 被多个HAP共享的代码和资源    │  │
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

4.2 三个核心配置文件

AppScope/app.json5 — 应用全局配置

// app.json5    应用级配置,定义整个应用的基础信息
{
  "app": {                          // app 应用配置
    "bundleName": "com.example.myapp",  // bundleName 包名:全网唯一标识
    "vendor": "example",             // vendor 开发者/厂商名称
    "versionCode": 1000000,          // versionCode 版本号(数字,递增)
    "versionName": "1.0.0",          // versionName 版本名(字符串,给用户看)
    "icon": "$media:app_icon",       // icon 应用图标   $media: 引用资源文件
    "label": "$string:app_name"      // label 应用名称  $string: 引用字符串资源
  }
}

entry/src/main/module.json5 — 模块配置

// module.json5    模块级配置,定义模块有哪些Ability、权限、适配设备
{
  "module": {                       // module 模块配置
    "name": "entry",                // name 模块名称
    "type": "entry",                // type 模块类型: entry(主模块)/feature(特性模块)
    "mainElement": "EntryAbility",  // mainElement 主入口Ability名称
    "description": "$string:module_desc",  // description 模块描述
    "deviceTypes": [                // deviceTypes 支持的设备类型
      "phone",                      // phone 手机
      "tablet"                      // tablet 平板
    ],
    "abilities": [                  // abilities 能力列表(可以有多个Ability)
      {
        "name": "EntryAbility",     // name Ability类名
        "srcEntry": "./ets/entryability/EntryAbility.ets",  // srcEntry 源文件路径
        "launchType": "singleton",  // launchType 启动模式: singleton单实例
        "startWindowIcon": "$media:start_icon",  // 启动页图标
        "startWindowBackground": "$color:start_window_background",  // 启动页背景色
        "skills": [                 // skills 能力列表(定义这个Ability能响应什么)
          {
            "entities": ["entity.system.home"],  // 系统桌面实体
            "actions": ["action.system.home"]    // 系统桌面动作
          }
        ]
      }
    ],
    "requestPermissions": [         // requestPermissions 申请权限
      {
        "name": "ohos.permission.INTERNET",  // 网络权限
        "reason": "$string:internet_reason"  // reason 申请原因说明(必填!)
      }
    ]
  }
}

EntryAbility.ets — 应用入口

// EntryAbility.ets    应用入口Ability,每个应用至少有一个
// 这是整个App的启动入口,相当于main()函数

// import 导入:从SDK工具包导入需要的能力
import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit'
//       ↑ UIAbility 界面能力基类    ↑ Want 跳转意图
//                                    ↑ AbilityConstant 常量
import { hilog } from '@kit.PerformanceAnalysisKit'
//       ↑ hilog 日志工具
import { window } from '@kit.ArkUI'
//       ↑ window 窗口管理

// export default 默认导出:这个类可以被外部引用
export default class EntryAbility extends UIAbility {
  //                                   extends 继承自UIAbility

  // onCreate 创建时回调:Ability被创建时调用一次
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    //      ↑ want 启动意图(谁启动了我,带了什么参数)
    //                           ↑ launchParam 启动参数(冷启动/热启动)
    hilog.info(0x0000, 'EntryAbility', 'Ability被创建了')
    // hilog.info() 打印info级别日志
    // 0x0000 是domain(域),可以自定义
  }

  // onDestroy 销毁时回调:Ability被销毁时调用一次(清理资源)
  onDestroy(): void {
    hilog.info(0x0000, 'EntryAbility', 'Ability被销毁了')
  }

  // onWindowStageCreate 窗口创建回调:准备显示界面
  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 核心:loadContent() 加载哪个页面作为首页
    windowStage.loadContent('pages/Index', (err) => {
      //                   ↑ 页面路径      ↑ err 错误回调
      if (err.code) {
        hilog.error(0x0000, 'EntryAbility', '加载页面失败: %{public}d', err.code)
        return
      }
    })
  }
}

4.3 页面生命周期

onPageShow() ←———→ onPageHide()
    ↑ 页面可见           ↑ 页面不可见(切到后台/跳到其他页面)
    ↑                     ↑
aboutToAppear()      aboutToDisappear()
    ↑ 即将出现           ↑ 即将消失(页面被销毁)
@Entry
@Component
struct MyPage {
  // aboutToAppear 即将出现:页面创建后、显示前调用
  aboutToAppear(): void {
    console.log('1. 页面即将出现')      // 在这里加载数据
  }

  // onPageShow 页面显示:页面完全显示后调用
  onPageShow(): void {
    console.log('2. 页面显示了')        // 在这里刷新数据
  }

  // onPageHide 页面隐藏:页面被覆盖或切到后台时调用
  onPageHide(): void {
    console.log('3. 页面隐藏了')        // 在这里保存草稿、停止轮询
  }

  // aboutToDisappear 即将消失:页面被销毁前调用
  aboutToDisappear(): void {
    console.log('4. 页面即将销毁')      // 在这里清理资源
  }

  // onBackPress 返回键拦截:用户按返回键时触发
  onBackPress(): boolean | void {
    // 返回 true = 拦截返回(留在当前页)
    // 返回 false = 不拦截(正常返回)
    if (this.hasUnsavedChanges) {
      AlertDialog.show({ message: '有未保存的数据,确定退出?' })
      return true  // 拦截
    }
    return false   // 放行
  }

  build() { /* ... */ }
}

4.4 页面骨架七要素

回顾Hello World,每个页面都遵循这个骨架:

┌─────────────────────────────────────────────┐
│  写一个页面的标准框架(7要素):                 │
│                                               │
│  1. @Entry          ← 声明这是页面            │
│  2. @Component      ← 声明这是组件            │
│  3. struct 页面名   ← 结构体定义              │
│  4. @State 变量们   ← 状态数据(在build外声明)│
│  5. build() {       ← 构建方法                │
│  6.   布局组件() {   ← 根节点有且只有一个       │
│  7.     子组件们    ← 嵌套摆放                │
│     }                                         │
│   }                                           │
│  }                                            │
└─────────────────────────────────────────────┘

4.5 组件树:代码怎么变成界面

代码结构                       渲染后的界面
══════════════════════════════════════════════════════

@Component
struct MyPage {                ┌──────────────────┐
                               │    手机屏幕        │
  build() {                    │                  │
    Column() {         ← 根   │  ┌────────────┐  │
      Text('标题')     ← 子1  │  │   标题       │  │
      Row() {          ← 子2  │  ├────────────┤  │
        Text('左')     ← 孙1  │  │ 左  │  右   │  │
        Text('右')     ← 孙2  │  ├────────────┤  │
      }                       │  │  底部文字    │  │
      Text('底部')     ← 子3  │  └────────────┘  │
    }                         │                  │
  }                           └──────────────────┘
}

组件树(嵌套关系):            布局规则:
  Column (垂直排列)            Column → 子元素竖着排
  ├── Text "标题"              Row    → 子元素横着排
  ├── Row (水平排列)           build  → 根节点必须唯一
  │   ├── Text "左"
  │   └── Text "右"
  └── Text "底部"

4.6 build() 方法的铁律

build() {
  // ❌ 铁律1:build()内不能声明任何变量!
  // let title: string = '标题'   ← 编译失败

  // ❌ 铁律2:不能使用索引签名!
  // let obj: Record<string, number> = {}  ← 编译失败

  // ❌ 铁律3:不能用any类型!
  // let x: any = 123  ← 编译失败

  // ✅ 所有数据要么是@State/@Prop等状态,
  //    要么在build()外部提前定义

  Column() {           // ✅ 根节点:有且仅有一个
    Text('第一行')
    Text('第二行')
    Row() {            // ✅ 嵌套布局
      Text('左')
      Text('右')
    }
  }
}

4.7 实战练习:倒计时页面

利用生命周期和页面结构知识,做一个倒计时组件:

@Entry
@Component
struct CountdownPage {
  @State totalSeconds: number = 60       // 总秒数
  @State isRunning: boolean = false      // 是否正在倒计时
  @State isFinished: boolean = false     // 是否结束
  private timerId: number = -1

  // 页面显示时自动开始
  onPageShow(): void {
    console.log('页面显示了,倒计时准备就绪')
  }

  // 页面隐藏时暂停
  onPageHide(): void {
    if (this.isRunning) {
      clearInterval(this.timerId)
      this.isRunning = false
      console.log('页面隐藏,倒计时已暂停')
    }
  }

  // 页面销毁时清理
  aboutToDisappear(): void {
    clearInterval(this.timerId)
  }

  get display(): string {
    let min = Math.floor(this.totalSeconds / 60)
    let sec = this.totalSeconds % 60
    return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
  }

  build() {
    Column({ space: 30 }) {
      Text(this.isFinished ? '⏰ 时间到!' : this.display)
        .fontSize(72)
        .fontWeight(FontWeight.Bold)
        .fontColor(this.totalSeconds <= 10 && this.isRunning ? '#FF3B30' : '#333')

      Progress({ value: this.totalSeconds, total: 60 })
        .width('80%')
        .color(this.totalSeconds <= 10 ? '#FF3B30' : '#007AFF')

      Row({ space: 16 }) {
        Text(this.isRunning ? '暂停' : (this.isFinished ? '重置' : '开始'))
          .fontSize(20)
          .fontColor(Color.White)
          .backgroundColor(this.isRunning ? '#FF9500' : '#007AFF')
          .width(100).height(50)
          .borderRadius(25)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            if (this.isFinished) {
              this.totalSeconds = 60
              this.isFinished = false
              return
            }
            if (this.isRunning) {
              clearInterval(this.timerId)
              this.isRunning = false
            } else {
              this.isRunning = true
              this.timerId = setInterval(() => {
                if (this.totalSeconds > 0) {
                  this.totalSeconds--
                } else {
                  clearInterval(this.timerId)
                  this.isRunning = false
                  this.isFinished = true
                }
              }, 1000)
            }
          })
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

第5章 ArkTS 语言速通(关键字中英对照)

阅读方式:每个代码块中 // 后的中文标注了前面英文关键字的意思。 有 TypeScript/JavaScript 基础可快速过;零基础建议逐段敲一遍。

5.1 基础类型(basic types)

// let [变量名]: [类型] = [初始值]    ← 变量声明标准格式

let name: string = "鸿蒙"     // string 字符串
let age: number = 25           // number 数字(整数+小数)
let isNew: boolean = true      // boolean 布尔值(真/假)
let nothing: null = null       // null 空值
let notFound: undefined = undefined  // undefined 未定义

// const [常量名]: [类型] = [值]    ← 常量声明,值不可改
const PI: number = 3.14159     // const 常量,不可重新赋值
const APP_NAME: string = "中医题库"

5.2 数组与对象(array & object)

// 数组 array:存放多个同类型数据
let items: string[] = ["A", "B", "C"]      // string[] 字符串数组
let scores: number[] = [85, 92, 78]         // number[] 数字数组

// 泛型写法:Array<类型>          ← 和 类型[] 等效
let list: Array<number> = [1, 2, 3]        // Array<number> 数字数组
let users: Array<string> = ["张三", "李四"]  // Array<string> 字符串数组

// 接口 interface:定义对象的"形状"(有哪些字段,各是什么类型)
interface User {               // interface 接口定义
  name: string                  // name 字段,类型是string
  age: number                   // age 字段,类型是number
  email?: string                // ? 表示可选字段,可以不填
}

// 使用接口创建对象
let user: User = {
  name: "张三",                 // object literal 对象字面量
  age: 30
}

// type 类型别名:给类型起个别名
type ID = string | number       // type 类型别名,| 联合类型(或)
let userId: ID = "user123"      // 合法
let productId: ID = 999         // 也合法

5.3 函数(function)

// 函数 function:封装一段可复用的代码

// 普通函数    function [函数名]([参数]: [类型]): [返回类型]
function add(a: number, b: number): number {
  return a + b                   // return 返回值
}
// add(1, 2) → 3

// 箭头函数 arrow function:更简洁的写法,组件中大量使用
const multiply = (a: number, b: number): number => a * b
//       ↑ 函数名  ↑ 参数          ↑ 返回类型  ↑ 函数体

// void 无返回值:函数只执行操作,不返回数据
function logMessage(msg: string): void {
  console.log(msg)              // console.log() 打印日志
}

// 回调函数 callback:把函数当作参数传给另一个函数
type Callback = (data: string) => void   // type 定义一个函数类型
function fetchData(callback: Callback): void {
  callback("数据已加载")          // 执行回调函数
}

// 可选参数 optional parameter
function greet(name: string, title?: string): string {
  // title? 表示这个参数可以不传
  if (title) {
    return `${title}${name},你好`     // 模板字符串 `${变量}`
  }
  return `${name},你好`
}

5.4 类与接口(class & interface)

// 类 class:封装数据 + 行为

interface Animal {              // 接口:定义"能做什么"
  name: string                   // 属性 property:有哪些数据
  makeSound(): void              // 方法 method:能执行什么行为
}

// implements 实现:类必须满足接口的要求
class Dog implements Animal {
  name: string                   // 声明属性类型

  // constructor 构造函数:new Dog() 时自动执行
  constructor(name: string) {
    this.name = name             // this 指当前实例
  }

  makeSound(): void {
    console.log(`${this.name}: 汪汪!`)
  }
}

// extends 继承:子类继承父类的所有属性和方法
class BigDog extends Dog {
  // override 重写:覆盖父类的方法
  makeSound(): void {
    console.log(`${this.name}: 汪!!!`)
  }
}

// 使用类
let dog = new Dog("旺财")       // new 创建实例
dog.makeSound()                  // 输出: 旺财: 汪汪!

5.5 异步编程(async/await)

// Promise 承诺:表示"未来会完成"的操作
// async 异步    await 等待

// 定义一个返回Promise的函数
function delay(ms: number): Promise<void> {
  //              Promise<void> 承诺返回"无值"
  return new Promise((resolve) => {  // new Promise 创建承诺
    setTimeout(resolve, ms)            // setTimeout 定时器,ms毫秒后执行
  })
}

// async/await 写法:让异步代码看起来像同步代码
async function loadData(): Promise<string> {
  // async 声明异步函数    ↓ await 等待异步操作完成
  await delay(1000)                    // 等待1秒
  return "加载完成"                     // 1秒后返回这个字符串
}

// try/catch 异常处理
async function safeLoad(): Promise<void> {
  try {                               // try 尝试执行
    let data = await loadData()       // 等待加载数据
    console.log(data)                 // 成功则打印
  } catch (error) {                   // catch 捕获异常
    console.error("加载失败:", error) // 失败则打印错误
  }
}

5.6 解构与展开(destructure & spread)

// 数组解构:把数组元素取出来赋给变量
let arr = [1, 2, 3]
let [first, ...rest] = arr             // first=1, rest=[2,3]
//    ↑ 第一个  ↑ 剩余全部(展开运算符)

// 对象解构:把对象的某个字段取出来
let config = { url: "/api", method: "GET" }
let { url, method } = config           // url="/api", method="GET"

// 展开:把数组/对象"铺开"
let a = [1, 2]
let b = [...a, 3, 4]                   // b = [1, 2, 3, 4]

let obj1 = { name: "A" }
let obj2 = { ...obj1, age: 20 }        // obj2 = {name:"A", age:20}

第6章 常用组件

从Hello World的 Text() 开始,本章逐个介绍最常用的UI组件。每个组件都以”能跑的小例子”展示。

6.1 Text — 文本组件

// Text 文本:显示文字内容

// 基础用法
Text('普通文本')                  // Text 文本组件

// 富文本(多段不同样式)
Text() {                          // 空Text + 内部Span
  Span('红色文字')                 // Span 文本片段
    .fontColor(Color.Red)        // Color.Red 红色
  Span('蓝色文字')
    .fontColor(Color.Blue)       // Color.Blue 蓝色
  Span('大号粗体')
    .fontSize(24)
    .fontWeight(FontWeight.Bold)
}

6.2 Image — 图片组件

// Image 图片:显示图片

// 本地资源图片
Image($r('app.media.icon'))      // $r() 引用资源,app.media.icon 资源路径
  .width(48).height(48)

// 网络图片
Image('https://example.com/photo.jpg')
  .width('100%')
  .height(200)
  .objectFit(ImageFit.Cover)     // objectFit 填充方式:Cover裁剪铺满/Contain全图显示/Fill拉伸
  .borderRadius(8)               // borderRadius 圆角

// 圆形头像
Image($r('app.media.avatar'))
  .width(64).height(64)
  .borderRadius(32)              // 宽度的一半 = 正圆
  .objectFit(ImageFit.Cover)

6.3 自定义按钮(不用原生Button)

// ⚠️ 铁律:不要用原生Button组件(某些版本有Bug)
// 用Text/Row + onClick 实现按钮

@Component
struct MyButton {
  @Prop label: string              // label 按钮文字
  @Prop bgColor: string = '#007AFF'  // bgColor 背景色,默认蓝色
  onPress?: () => void             // onPress 点击回调函数

  build() {
    Text(this.label)
      .fontSize(16)
      .fontColor(Color.White)
      .textAlign(TextAlign.Center)
      .backgroundColor(this.bgColor)
      .borderRadius(8)
      .padding({ top: 12, bottom: 12, left: 24, right: 24 })
      .onClick(() => {
        this.onPress?.()          // 可选链调用:onPress存在时才执行
      })
  }
}

// 使用自定义按钮
MyButton({
  label: '开始考试',
  bgColor: '#2196F3',
  onPress: () => {
    router.pushUrl({ url: 'pages/ExamPage' })
  }
})

6.4 TextInput — 输入框

// TextInput 文本输入:接收用户输入

@State username: string = ''
@State password: string = ''

build() {
  Column({ space: 16 }) {
    // 普通输入框
    TextInput({ text: this.username, placeholder: '请输入用户名' })
    //                        ↑ text 绑定内容 ↑ placeholder 占位提示文字
      .width('100%')
      .height(44)
      .type(InputType.Normal)     // type 输入类型:Normal普通/Password密码/Number数字
      .onChange((value: string) => {
        this.username = value     // 输入变化时更新状态
      })

    // 密码输入框
    TextInput({ text: this.password, placeholder: '请输入密码' })
      .width('100%')
      .height(44)
      .type(InputType.Password)   // Password 密码模式(内容会被遮掩)
      .onChange((value: string) => {
        this.password = value
      })
  }
  .padding(16)
}

6.5 条件渲染(if/else)

@State isLoggedIn: boolean = false    // boolean 布尔值
// isLoggedIn 是否已登录

build() {
  Column() {
    if (this.isLoggedIn) {            // if 如果:条件为真时显示
      // 登录后的界面
      Text('欢迎回来!')
        .fontSize(24)
      Text('点击退出')
        .fontColor(Color.White)
        .backgroundColor('#FF3B30')
        .borderRadius(8)
        .padding(12)
        .onClick(() => {
          this.isLoggedIn = false     // 点击退出 → UI自动切换到else分支
        })
    } else {                          // else 否则:条件为假时显示
      // 未登录的界面
      Text('请先登录')
        .fontSize(24)
      Text('点击登录')
        .fontColor(Color.White)
        .backgroundColor('#007AFF')
        .borderRadius(8)
        .padding(12)
        .onClick(() => {
          this.isLoggedIn = true      // 点击登录 → UI自动切换到if分支
        })
    }
  }
}

6.6 循环渲染(ForEach)

@State fruits: string[] = ['苹果', '香蕉', '橘子']
// string[] 字符串数组

build() {
  Column() {
    ForEach(                          // ForEach 循环渲染:遍历数组生成UI
      this.fruits,                    // 参数1: 数据源(数组)
      (item: string, index: number) => {  // 参数2: 每个元素的渲染函数
        //  ↑ 当前元素    ↑ 当前索引(从0开始)
        Text(`${index + 1}. ${item}`)
          .fontSize(18)
          .padding(10)
      },
      (item: string) => item          // 参数3: key生成器(必须提供!)
      //                ↑ 返回唯一标识,用于高效更新
    )
  }
}

6.7 样式速查(链式调用)

Text('样式速查')
  // --- 字体相关 ---
  .fontSize(20)                          // fontSize 字号
  .fontColor('#FF3333')                  // fontColor 字体颜色(支持#RRGGBB)
  .fontWeight(FontWeight.Bold)           // fontWeight 字体粗细:Bold加粗/Normal正常/Lighter细
  .fontStyle(FontStyle.Italic)           // fontStyle 字体风格:Italic斜体/Normal正常
  .textAlign(TextAlign.Center)           // textAlign 文本对齐:Center居中/Start左/End右
  .maxLines(2)                           // maxLines 最大行数(超出省略)
  .textOverflow({ overflow: TextOverflow.Ellipsis })  // textOverflow 溢出处理:Ellipsis省略号

  // --- 背景与边框 ---
  .backgroundColor('#F5F5F5')           // backgroundColor 背景色
  .borderRadius(8)                       // borderRadius 圆角半径
  .border({ width: 1, color: '#E0E0E0' })  // border 边框:width宽度 color颜色

  // --- 尺寸与间距 ---
  .width('90%')                          // width 宽度(百分比或数字)
  .height(44)                            // height 高度(数字=vp单位)
  .padding(12)                           // padding 内边距
  .margin(8)                             // margin 外边距
  .layoutWeight(1)                       // layoutWeight 弹性权重(在Row/Column中占剩余空间)

  // --- 交互 ---
  .opacity(0.8)                          // opacity 透明度(0~1)
  .onClick(() => {                       // onClick 点击事件
    console.log('被点击了')
  })

6.8 实战练习:登录页面

把前面学的组件组合起来,做一个完整的登录页面:

@Entry
@Component
struct LoginPage {
  @State username: string = ''
  @State password: string = ''
  @State isLoading: boolean = false
  @State errorMessage: string = ''
  @State rememberMe: boolean = false

  build() {
    Column({ space: 20 }) {
      // Logo区域
      Column({ space: 8 }) {
        Text('🏥')
          .fontSize(64)
        Text('中医题库')
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
        Text('登录后开始学习')
          .fontSize(14)
          .fontColor('#999')
      }
      .margin({ top: 60, bottom: 20 })

      // 输入区域
      Column({ space: 12 }) {
        TextInput({ text: this.username, placeholder: '请输入用户名' })
          .width('100%')
          .height(48)
          .borderRadius(8)
          .backgroundColor('#F5F5F5')
          .onChange((v: string) => {
            this.username = v
            this.errorMessage = ''
          })

        TextInput({ text: this.password, placeholder: '请输入密码' })
          .width('100%')
          .height(48)
          .type(InputType.Password)
          .borderRadius(8)
          .backgroundColor('#F5F5F5')
          .onChange((v: string) => {
            this.password = v
            this.errorMessage = ''
          })
      }
      .width('100%')
      .padding({ left: 24, right: 24 })

      // 错误提示
      if (this.errorMessage) {
        Text(this.errorMessage)
          .fontSize(13)
          .fontColor('#FF3B30')
          .width('100%')
          .textAlign(TextAlign.Center)
      }

      // 记住密码 + 忘记密码
      Row() {
        Text(this.rememberMe ? '☑ 记住密码' : '☐ 记住密码')
          .fontSize(13)
          .fontColor('#666')
          .onClick(() => { this.rememberMe = !this.rememberMe })

        Text('忘记密码?')
          .fontSize(13)
          .fontColor('#007AFF')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 24, right: 24 })

      // 登录按钮
      Text(this.isLoading ? '登录中...' : '登录')
        .fontSize(18)
        .fontColor(Color.White)
        .fontWeight(FontWeight.Bold)
        .width('90%')
        .height(48)
        .textAlign(TextAlign.Center)
        .backgroundColor(this.username && this.password ? '#007AFF' : '#B0BEC5')
        .borderRadius(24)
        .onClick(() => {
          if (!this.username || !this.password) {
            this.errorMessage = '请输入用户名和密码'
            return
          }
          this.isLoading = true
          // 模拟登录
          setTimeout(() => {
            this.isLoading = false
            if (this.username === 'admin' && this.password === '123456') {
              this.errorMessage = ''
              console.log('登录成功')
            } else {
              this.errorMessage = '用户名或密码错误'
            }
          }, 1500)
        })

      // 注册链接
      Row({ space: 4 }) {
        Text('还没有账号?')
          .fontSize(13)
          .fontColor('#999')
        Text('立即注册')
          .fontSize(13)
          .fontColor('#007AFF')
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.White)
  }
}

练习要点: | 组件 | 用法 | |——|——| | TextInput | 用户名/密码输入 | | InputType.Password | 密码遮掩 | | 条件渲染 if | 错误提示显隐 | | .onClick | 按钮点击、记住密码切换 | | 三元表达式 | 按钮颜色/文字动态变化 |


第7章 布局精讲

组件学会了,下一步是把它们摆放整齐。本章从简单的居中开始,到复杂的弹性布局。

7.1 Column — 列布局(竖着排)

// ===== Column 列布局:子元素竖着排列 =====
Column({ space: 12 }) {             // space 子元素间距
  Text('第一行')
  Text('第二行')
  Text('第三行')
}
.width('100%')
.padding(16)
.alignItems(HorizontalAlign.Start)  // alignItems 水平对齐:Start左/Center中/End右
.justifyContent(FlexAlign.Center)   // justifyContent 垂直对齐:Start上/Center中/End下

7.2 Row — 行布局(横着排)

// ===== Row 行布局:子元素横着排列 =====
Row({ space: 8 }) {
  Image($r('app.media.icon'))
    .width(24).height(24)
  Text('标题')
    .layoutWeight(1)               // layoutWeight 弹性权重:占满剩余空间
  Image($r('app.media.arrow'))
    .width(16).height(16)
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.alignItems(VerticalAlign.Center)  // alignItems 垂直对齐(横向布局中)

7.3 Flex — 弹性布局(自动换行)

// ===== Flex 弹性布局:自动换行 =====
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
  //       ↑ direction 主轴方向:Row行/Column列
  //                                    ↑ wrap 换行:Wrap换行/NoWrap不换行
  ForEach(['标签A', '标签B', '标签C', '标签D'],
    (item: string) => {
      Text(item)
        .padding(8)
        .backgroundColor('#E0E0E0')
        .borderRadius(4)
        .margin(4)
    },
    (item: string) => item
  )
}

7.4 Stack — 堆叠布局(层层叠加)

// Stack 堆叠:子元素叠在一起,后写的在上面

Stack({ alignContent: Alignment.Bottom }) {
  //       ↑ alignContent 对齐方式:子元素在Stack中的位置
  //         Alignment.Bottom 底部对齐

  // 背景层(先写在下)
  Image('https://example.com/background.jpg')
    .width('100%')
    .height(200)
    .objectFit(ImageFit.Cover)

  // 前景层(后写在上)
  Column() {
    Text('标题文字')
      .fontSize(24)
      .fontColor(Color.White)
      .fontWeight(FontWeight.Bold)
    Text('副标题')
      .fontSize(14)
      .fontColor(Color.White)
      .opacity(0.8)
  }
  .padding(16)
}

7.5 Scroll — 滚动容器(替代List)

// ⚠️ 铁律:不要用List组件(某些版本有Bug),用Scroll + ForEach替代

@State dataList: string[] = ['项目1', '项目2', '项目3', '项目4', '项目5']

build() {
  Scroll() {                         // Scroll 滚动容器
    Column() {                       // Column 包裹内容
      ForEach(this.dataList,
        (item: string, index: number) => {
          Row() {
            Text(`${index + 1}. ${item}`)
              .fontSize(16)
          }
          .width('100%')
          .padding(16)
          .backgroundColor(Color.White)
          .borderRadius(8)
          .margin({ top: 8 })
        },
        (item: string) => item       // key生成器
      )
    }
    .width('100%')
    .padding(16)
  }
  .width('100%')
  .height('100%')
  .scrollable(ScrollDirection.Vertical)   // scrollable滚动方向:Vertical垂直/Horizontal水平
  .scrollBar(BarState.Auto)               // scrollBar滚动条:Auto自动显示/Off隐藏
}

7.6 弹窗

// CustomDialog 自定义弹窗
@CustomDialog                     // @CustomDialog 自定义弹窗装饰器
struct ConfirmDialog {
  controller: CustomDialogController  // controller 弹窗控制器(必须!)
  title: string = '提示'              // title 弹窗标题
  message: string = ''                // message 弹窗内容
  confirmText: string = '确定'        // confirmText 确认按钮文字
  confirm?: () => void                // confirm 确认回调

  build() {
    Column() {
      Text(this.title)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
      Text(this.message)
        .fontSize(16)
        .margin({ top: 12, bottom: 24 })
      Row({ space: 12 }) {
        Text('取消')
          .padding(12)
          .onClick(() => this.controller.close())  // close 关闭弹窗
        Text(this.confirmText)
          .padding(12)
          .fontColor('#007AFF')
          .onClick(() => {
            this.confirm?.()         // 执行确认回调
            this.controller.close()  // 关闭弹窗
          })
      }
    }
    .padding(24)
  }
}

// 使用弹窗的3步:
// 1. 声明控制器
dialogController: CustomDialogController = new CustomDialogController({
  builder: ConfirmDialog({          // builder 构建器:指定弹窗内容
    title: '删除确认',
    message: '确定要删除这条记录吗?',
    confirm: () => { console.log('执行删除') }
  })
})

// 2. 弹出
this.dialogController.open()        // open 打开弹窗

// 3. 关闭(弹窗内部调用 controller.close())

7.7 综合练习:个人信息卡片

把前面学的组件和布局组合起来:

@Entry
@Component
struct ProfileCard {
  @State name: string = '张三'
  @State bio: string = '这是一段个人简介'

  build() {
    Scroll() {
      Column({ space: 16 }) {
        // 头像 + 名字 (Stack堆叠)
        Stack({ alignContent: Alignment.Bottom }) {
          Image($r('app.media.cover'))
            .width('100%')
            .height(180)
            .objectFit(ImageFit.Cover)
          Column() {
            Image($r('app.media.avatar'))
              .width(80).height(80)
              .borderRadius(40)
              .border({ width: 3, color: Color.White })
            Text(this.name)
              .fontSize(20).fontColor(Color.White)
              .fontWeight(FontWeight.Bold)
          }
          .margin({ bottom: -40 })
        }

        // 简介
        Text(this.bio)
          .fontSize(14).fontColor('#666')
          .margin({ top: 50 })
          .padding(16)

        // 编辑按钮
        Text('编辑资料')
          .fontColor(Color.White)
          .backgroundColor('#007AFF')
          .borderRadius(8)
          .padding({ top: 12, bottom: 12, left: 32, right: 32 })
          .onClick(() => {
            this.name = '李四'
          })
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F0F0F0')
  }
}

9.5 实战练习:带底部Tab的完整App骨架

import { router } from '@kit.ArkUI'

// ===== 底部Tab导航主页 =====
@Entry
@Component
struct MainApp {
  @State currentTab: number = 0

  build() {
    Tabs({ index: this.currentTab }) {
      TabContent() { HomeTab() }
        .tabBar({ title: '首页', icon: $r('app.media.home') })
      TabContent() { StudyTab() }
        .tabBar({ title: '学习', icon: $r('app.media.book') })
      TabContent() { ProfileTab() }
        .tabBar({ title: '我的', icon: $r('app.media.user') })
    }
    .barPosition(BarPosition.End)
    .onChange((index: number) => { this.currentTab = index })
  }
}

// ===== 首页Tab =====
@Component
struct HomeTab {
  @State categories: object[] = [
    { name: '中医基础', icon: '📘', count: 320 },
    { name: '中药学', icon: '🌿', count: 280 },
    { name: '方剂学', icon: '📋', count: 256 },
    { name: '针灸学', icon: '📍', count: 198 },
    { name: '诊断学', icon: '🔍', count: 215 },
    { name: '内科学', icon: '🏥', count: 342 },
  ]

  build() {
    Scroll() {
      Column({ space: 16 }) {
        // 顶部搜索栏
        Row() {
          Text('🔍 搜索题目...')
            .fontSize(14).fontColor('#999')
            .layoutWeight(1)
            .padding({ top: 10, bottom: 10, left: 16 })
            .backgroundColor('#F5F5F5')
            .borderRadius(20)
        }
        .padding(16)

        // 学习统计卡片
        Row({ space: 16 }) {
          Column({ space: 4 }) {
            Text('128').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#007AFF')
            Text('已学题目').fontSize(12).fontColor('#999')
          }
          .layoutWeight(1)

          Column({ space: 4 }) {
            Text('78%').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#34C759')
            Text('正确率').fontSize(12).fontColor('#999')
          }
          .layoutWeight(1)

          Column({ space: 4 }) {
            Text('7天').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FF9500')
            Text('连续学习').fontSize(12).fontColor('#999')
          }
          .layoutWeight(1)
        }
        .width('100%')
        .padding(20)
        .backgroundColor(Color.White)
        .borderRadius(12)
        .margin({ left: 16, right: 16 })

        // 分类网格
        Text('📚 题库分类')
          .fontSize(18).fontWeight(FontWeight.Bold)
          .width('100%').padding({ left: 16 })

        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(this.categories, (cat: object) => {
            Column({ space: 8 }) {
              Text(cat['icon']).fontSize(32)
              Text(cat['name']).fontSize(14).fontWeight(FontWeight.Medium)
              Text(`${cat['count']}题`).fontSize(11).fontColor('#999')
            }
            .width('30%')
            .padding(16)
            .backgroundColor(Color.White)
            .borderRadius(12)
            .margin(6)
            .onClick(() => {
              router.pushUrl({
                url: 'pages/StudyPage',
                params: { category: cat['name'] }
              })
            })
          }, (cat: object) => cat['name'])
        }
        .padding({ left: 10, right: 10 })
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F5F5F5')
  }
}

// ===== 学习Tab =====
@Component
struct StudyTab {
  build() {
    Column({ space: 16 }) {
      Text('📖 学习模式').fontSize(28).fontWeight(FontWeight.Bold).margin(20)

      // 学习模式选择
      ForEach([
        { title: '顺序学习', desc: '按章节顺序刷题', icon: '📝', color: '#007AFF' },
        { title: '随机练习', desc: '随机抽取题目', icon: '🎲', color: '#FF9500' },
        { title: '错题回顾', desc: '复习做错的题目', icon: '❌', color: '#FF3B30' },
        { title: '收藏夹', desc: '查看收藏的重点题', icon: '⭐', color: '#FFD700' },
      ], (mode: object) => {
        Row() {
          Text(mode['icon']).fontSize(28).width(50)
          Column({ space: 4 }) {
            Text(mode['title']).fontSize(17).fontWeight(FontWeight.Medium)
            Text(mode['desc']).fontSize(13).fontColor('#999')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('›').fontSize(24).fontColor('#CCC')
        }
        .width('100%')
        .padding(20)
        .backgroundColor(Color.White)
        .borderRadius(12)
        .margin({ left: 16, right: 16 })
        .onClick(() => {
          router.pushUrl({ url: 'pages/StudyPage', params: { mode: mode['title'] } })
        })
      }, (mode: object) => mode['title'])
    }
    .width('100%').height('100%')
    .backgroundColor('#F5F5F5')
  }
}

// ===== 我的Tab =====
@Component
struct ProfileTab {
  @State name: string = '学习者'
  @State totalStudied: number = 128
  @State accuracy: number = 78

  build() {
    Scroll() {
      Column({ space: 16 }) {
        // 头像 + 名字
        Column({ space: 12 }) {
          Text(this.name.charAt(0))
            .fontSize(36).fontColor(Color.White)
            .width(80).height(80).borderRadius(40)
            .backgroundColor('#007AFF')
            .textAlign(TextAlign.Center)
          Text(this.name).fontSize(22).fontWeight(FontWeight.Bold)
          Text(`正确率 ${this.accuracy}%`).fontSize(14).fontColor('#999')
        }
        .width('100%').padding(30)
        .backgroundColor(Color.White)

        // 数据统计
        Row() {
          Column({ space: 4 }) {
            Text(`${this.totalStudied}`).fontSize(20).fontWeight(FontWeight.Bold)
            Text('已学').fontSize(12).fontColor('#999')
          }
          .layoutWeight(1)

          Column({ space: 4 }) {
            Text('23').fontSize(20).fontWeight(FontWeight.Bold)
            Text('收藏').fontSize(12).fontColor('#999')
          }
          .layoutWeight(1)

          Column({ space: 4 }) {
            Text('45').fontSize(20).fontWeight(FontWeight.Bold)
            Text('错题').fontSize(12).fontColor('#999')
          }
          .layoutWeight(1)
        }
        .width('100%').padding(16)
        .backgroundColor(Color.White)
        .margin({ left: 16, right: 16 })
        .borderRadius(12)

        // 功能列表
        ForEach([
          { icon: '📊', title: '学习报告' },
          { icon: '⭐', title: '我的收藏' },
          { icon: '❌', title: '错题本' },
          { icon: '📥', title: '导入题库' },
          { icon: '⚙️', title: '设置' },
          { icon: '💬', title: '意见反馈' },
        ], (item: object) => {
          Row() {
            Text(item['icon']).fontSize(20).width(40)
            Text(item['title']).fontSize(16).layoutWeight(1)
            Text('›').fontSize(20).fontColor('#CCC')
          }
          .width('100%')
          .padding(16)
          .backgroundColor(Color.White)
          .borderRadius(8)
          .margin({ left: 16, right: 16 })
        }, (item: object) => item['title'])
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F5F5F5')
  }
}

第8章 状态管理(核心中的核心)

状态管理是ArkUI的灵魂。理解了它,就理解了整个框架的运作方式。 我们已经用过 @State(计数器例子),现在系统学习全部状态管理工具。

8.1 装饰器速查表

装饰器 读法 作用 传递方向 典型场景
@State 组件状态 组件自有数据,变化自动刷新UI 不能传 输入框、开关、计数
@Prop 父传子 父组件传值给子组件,子组件不能改 父→子 单向 展示标题、配置项
@Link 双向绑定 父子共享数据,一方改双方更新 父↔︎子 双向 开关、单选框
@Provide 全局提供 祖先向所有后代提供数据 跨层级 主题色、用户信息
@Consume 全局消费 后代读取祖先提供的数据 跨层级 读取主题色
@StorageLink 持久化状态 数据存AppStorage,全局共享 全局 登录态、设置
@ObjectLink 对象绑定 绑定被@Observed装饰的类对象 父→子 复杂数据模型

8.2 @State — 组件内状态(复习)

回顾第3章的计数器,@State count 就是最典型的 @State 用法:

@Component
struct Counter {
  @State count: number = 0       // @State: 改了UI就自动刷新

  build() {
    Column({ space: 20 }) {
      Text(`${this.count}`).fontSize(80)
      Text('+1').onClick(() => { this.count++ })   // 改状态 → UI刷新
      Text('-1').onClick(() => { this.count-- })   // 改状态 → UI刷新
    }
  }
}

这是最常用的父子通信模式。下面用一个完整的”开关列表”例子演示:

// ===== 子组件:一个开关行 =====
@Component
struct ToggleItem {
  @Prop label: string           // @Prop 父传子:子组件接收,不能改
  @Link isOn: boolean           // @Link 双向绑定:子改了父也改

  build() {
    Row() {
      Text(this.label)
        .fontSize(16)
        .layoutWeight(1)

      Toggle({ type: ToggleType.Switch, isOn: this.isOn })
        // Toggle 开关组件
        .onChange((value: boolean) => {
          this.isOn = value     // 子改@Link → 父的@State同步更新
        })
    }
    .width('100%')
    .padding(16)
  }
}

// ===== 父组件:设置页 =====
@Entry
@Component
struct SettingsPage {
  @State wifiOn: boolean = false      // @State 自有状态
  @State bluetoothOn: boolean = true
  @State notificationOn: boolean = true

  build() {
    Column() {
      Text('设置').fontSize(28).fontWeight(FontWeight.Bold).margin(20)

      ToggleItem({
        label: 'WiFi',
        isOn: $wifiOn       // $ 语法:表示双向绑定(把@State变成@Link传下去)
      })
      //      ↑ 加了$,子组件改isOn时,wifiOn也会变

      ToggleItem({
        label: '蓝牙',
        isOn: $bluetoothOn
      })

      ToggleItem({
        label: '通知',
        isOn: $notificationOn
      })

      // 显示当前状态
      Text(`WiFi: ${this.wifiOn ? '开' : '关'} | 蓝牙: ${this.bluetoothOn ? '开' : '关'} | 通知: ${this.notificationOn ? '开' : '关'}`)
        .fontSize(14)
        .fontColor('#999')
        .margin(20)
    }
  }
}

8.4 @Provide/@Consume — 跨层级传递

// 场景:主题色从App层传到任意深层页面,中间组件不需要逐层传递

// ===== 顶层(祖先) =====
@Entry
@Component
struct App {
  @Provide('themeColor') themeColor: string = '#007AFF'
  // @Provide('键名') 提供数据:所有后代都可以用@Consume读取

  build() {
    Column() {
      HomePage()    // 中间层HomePage不需要知道themeColor
    }
  }
}

// ===== 任意深层后代 =====
@Component
struct DeepChildPage {
  @Consume('themeColor') themeColor: string
  // @Consume('键名') 消费数据:直接读取祖先提供的值

  build() {
    Column() {
      Text('主题色按钮')
        .fontColor(this.themeColor)
    }
  }
}
// AppStorage 应用存储:内存级别的全局状态,适合存登录态、设置

// 写入全局状态
AppStorage.setOrCreate('isLoggedIn', true)   // setOrCreate 设置或创建
AppStorage.setOrCreate('userName', '张三')

// 读取 + 绑定全局状态
@Entry
@Component
struct HomePage {
  @StorageLink('isLoggedIn') isLoggedIn: boolean = false
  // @StorageLink('键名') 绑定全局状态:值变了自动刷新

  @StorageLink('userName') userName: string = ''

  build() {
    Column() {
      if (this.isLoggedIn) {
        Text(`你好,${this.userName}`).fontSize(24)
        Text('退出登录')
          .fontColor(Color.White).backgroundColor('#FF3B30')
          .borderRadius(8).padding(12)
          .onClick(() => {
            AppStorage.setOrCreate('isLoggedIn', false)
            // 改全局状态 → 所有绑定了isLoggedIn的组件自动刷新
          })
      } else {
        Text('请登录').fontSize(24)
        Text('登录')
          .fontColor(Color.White).backgroundColor('#007AFF')
          .borderRadius(8).padding(12)
          .onClick(() => {
            AppStorage.setOrCreate('isLoggedIn', true)
          })
      }
    }
  }
}
// @Observed 被观察类:这个类的实例变化时,绑定的组件会刷新

@Observed
class Question {
  // @Observed 声明这个类可被观察
  id: number = 0
  title: string = ''
  isFavorite: boolean = false    // 收藏状态

  toggleFavorite(): void {
    this.isFavorite = !this.isFavorite
  }
}

@Component
struct QuestionItem {
  @ObjectLink question: Question
  // @ObjectLink 对象绑定:绑定@Observed对象,内部属性变化时刷新

  build() {
    Row() {
      Text(this.question.title).layoutWeight(1)
      Text(this.question.isFavorite ? '❤️' : '🤍')  // 收藏状态自动刷新
        .fontSize(24)
        .onClick(() => {
          this.question.toggleFavorite()  // 修改对象属性 → UI自动刷新
        })
    }
  }
}

8.7 状态管理选择指南

需要状态管理时,按这个顺序选:

自己是唯一的用户? → @State
     ↓否
只传给直接子组件? → 子只读? → @Prop
     ↓否              ↓子也要改? → @Link($)
需要跨很多层? → @Provide/@Consume
     ↓否
需要全局共享 + 持久化? → @StorageLink
     ↓是
复杂对象? → @Observed + @ObjectLink

8.8 状态数据流全景图

┌──────────────────────────────────────────────────────────────────┐
│                      🌀 状态数据流全景                              │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│   @StorageLink ────── AppStorage(全局内存) ────── @StorageLink   │
│   (组件A)                                           (组件B)       │
│       │                                                │          │
│       ▼                                                ▼          │
│   ┌────────┐         ┌────────┐         ┌────────┐               │
│   │ @State │──$──→  │ @Link  │──改─→  │ @State │               │
│   │ (数据源)│        │ (双向)  │        │ (同步)  │               │
│   └───┬────┘         └────────┘         └────────┘               │
│       │                                                          │
│       ├──值复制──→ @Prop (子组件只读展示)                          │
│       │                                                          │
│       └──@Provide──→ 跨层级 ──→ @Consume (任意后代读取)           │
│                                                                  │
│   ┌──────────────────────────────────────────────────────┐       │
│   │              数据流向规则                              │       │
│   │                                                      │       │
│   │  "数据向下流动,事件向上冒泡"                           │       │
│   │                                                      │       │
│   │   父组件 ──@Prop/@Link(数据)──→ 子组件                 │       │
│   │   子组件 ──回调函数(事件)──→ 父组件                    │       │
│   │                                                      │       │
│   │   ✅ 好: 父传数据,子回调                              │       │
│   │   ❌ 坏: 子直接改父的数据(不走@Link的情况)              │       │
│   └──────────────────────────────────────────────────────┘       │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

装饰器作用范围图:
                                ┌─────────────────┐
        @Provide ←──────────────│   祖先组件       │
          │                     └────────┬────────┘
          │         @Prop/@Link ←─────── │
          │              │               │
          ▼              ▼               ▼
    ┌─────────┐   ┌──────────┐   ┌──────────┐
    │ @Consume│   │  子组件A  │   │  子组件B  │
    │ (任意层) │   │  @Prop   │   │  @Link   │
    └─────────┘   └──────────┘   └──────────┘

8.9 实战练习:购物车(综合状态管理)

// ===== 数据模型 =====
@Observed
class Product {
  id: number = 0
  name: string = ''
  price: number = 0
  icon: string = ''
}

@Observed
class CartItem {
  product: Product
  quantity: number = 1

  constructor(product: Product) {
    this.product = product
  }

  get subtotal(): number {
    return this.product.price * this.quantity
  }
}

// ===== 顶层App:提供全局购物车状态 =====
@Entry
@Component
struct ShopApp {
  @Provide('cartItems') cartItems: CartItem[] = []
  @Provide('cartCount') cartCount: number = 0

  @State currentTab: number = 0

  build() {
    Tabs({ index: this.currentTab }) {
      TabContent() { ProductListPage() }
        .tabBar('商品')
      TabContent() { CartPage() }
        .tabBar(`购物车(${this.cartCount})`)
    }
    .barPosition(BarPosition.End)
    .onChange((index: number) => { this.currentTab = index })
  }
}

// ===== 商品列表页 =====
@Component
struct ProductListPage {
  @Consume('cartItems') cartItems: CartItem[]
  @Consume('cartCount') cartCount: number

  @State products: Product[] = [
    { id: 1, name: '中医基础理论', price: 39.9, icon: '📘' },
    { id: 2, name: '中药学速记', price: 29.9, icon: '🌿' },
    { id: 3, name: '针灸学图谱', price: 49.9, icon: '📍' },
    { id: 4, name: '方剂学精讲', price: 35.0, icon: '📋' },
    { id: 5, name: '伤寒论导读', price: 42.0, icon: '📜' },
  ]

  addToCart(product: Product): void {
    let existing = this.cartItems.find((item: CartItem) => item.product.id === product.id)
    if (existing) {
      existing.quantity++
    } else {
      this.cartItems.push(new CartItem(product))
    }
    this.cartCount = this.cartItems.length
  }

  build() {
    Column() {
      Text('📚 中医书城')
        .fontSize(28).fontWeight(FontWeight.Bold)
        .width('100%').padding(20)

      Scroll() {
        Column({ space: 12 }) {
          ForEach(this.products, (product: Product) => {
            Row() {
              Text(product.icon).fontSize(36).width(50)
              Column({ space: 4 }) {
                Text(product.name).fontSize(16).fontWeight(FontWeight.Medium)
                Text(${product.price}`).fontSize(14).fontColor('#FF3B30')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('加入购物车')
                .fontSize(12).fontColor(Color.White)
                .backgroundColor('#007AFF')
                .borderRadius(4)
                .padding({ top: 6, bottom: 6, left: 12, right: 12 })
                .onClick(() => this.addToCart(product))
            }
            .width('100%')
            .padding(16)
            .backgroundColor(Color.White)
            .borderRadius(12)
          }, (product: Product) => `${product.id}`)
        }
        .padding({ left: 16, right: 16 })
      }
      .layoutWeight(1)
    }
    .width('100%').height('100%')
    .backgroundColor('#F5F5F5')
  }
}

// ===== 购物车页面 =====
@Component
struct CartPage {
  @Consume('cartItems') cartItems: CartItem[]
  @Consume('cartCount') cartCount: number

  get totalPrice(): number {
    return this.cartItems.reduce((sum: number, item: CartItem) => sum + item.subtotal, 0)
  }

  build() {
    Column() {
      Text('🛒 购物车')
        .fontSize(28).fontWeight(FontWeight.Bold)
        .width('100%').padding(20)

      if (this.cartItems.length === 0) {
        Column({ space: 12 }) {
          Text('🛒').fontSize(64)
          Text('购物车是空的').fontSize(16).fontColor('#999')
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      } else {
        Scroll() {
          Column({ space: 8 }) {
            ForEach(this.cartItems, (item: CartItem, index: number) => {
              Row() {
                Text(item.product.icon).fontSize(28).width(40)
                Column({ space: 4 }) {
                  Text(item.product.name).fontSize(15)
                  Text(${item.product.price}`).fontSize(13).fontColor('#FF3B30')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Row({ space: 12 }) {
                  Text('-')
                    .fontSize(18).width(28).height(28)
                    .textAlign(TextAlign.Center)
                    .backgroundColor('#F0F0F0').borderRadius(14)
                    .onClick(() => {
                      if (item.quantity > 1) {
                        item.quantity--
                      } else {
                        this.cartItems.splice(index, 1)
                        this.cartCount = this.cartItems.length
                      }
                    })
                  Text(`${item.quantity}`)
                    .fontSize(16).width(30).textAlign(TextAlign.Center)
                  Text('+')
                    .fontSize(18).width(28).height(28)
                    .textAlign(TextAlign.Center)
                    .backgroundColor('#007AFF').fontColor(Color.White)
                    .borderRadius(14)
                    .onClick(() => { item.quantity++ })
                }
              }
              .width('100%')
              .padding(16)
              .backgroundColor(Color.White)
              .borderRadius(12)
            }, (item: CartItem, index: number) => `${item.product.id}-${index}`)
          }
          .padding({ left: 16, right: 16 })
        }
        .layoutWeight(1)

        // 底部结算栏
        Row() {
          Column({ space: 2 }) {
            Text(`合计:¥${this.totalPrice.toFixed(2)}`)
              .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FF3B30')
            Text(`共${this.cartItems.length}件商品`)
              .fontSize(12).fontColor('#999')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('去结算')
            .fontSize(16).fontWeight(FontWeight.Bold)
            .fontColor(Color.White)
            .backgroundColor('#FF3B30')
            .borderRadius(22)
            .padding({ top: 12, bottom: 12, left: 32, right: 32 })
            .onClick(() => {
              AlertDialog.show({ message: `总价: ¥${this.totalPrice.toFixed(2)}` })
            })
        }
        .width('100%')
        .padding(16)
        .backgroundColor(Color.White)
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F5F5F5')
  }
}

这个购物车用到了本章全部知识点: | 知识点 | 应用场景 | |——–|———| | @Observed + get | Product/CartItem 模型 + 计算属性 | | @Provide/@Consume | 跨页面共享购物车数据 | | ForEach + key | 列表渲染,key用id保证唯一 | | 条件渲染 if | 空购物车提示 | | @Builder | 复用UI片段(本例可扩展) |


第9章 页面路由与导航

单页面不够用。本章学习多页面跳转和底部Tab导航。

9.0 路由栈可视化

┌────────────────────────────────────────────────────────────┐
│                    🗺 页面路由栈                              │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  操作: router.pushUrl('/detail')                           │
│  ═══════════════════════════════                           │
│                                                            │
│  ┌─────────┐     ┌─────────┐     ┌─────────┐             │
│  │  Index  │ →  │  List   │ →  │ Detail  │ ← 栈顶(当前)  │
│  │ (首页)  │     │ (列表)  │     │ (详情)  │             │
│  └─────────┘     └─────────┘     └─────────┘             │
│                    ↑                    │                   │
│                    └──── back() ────────┘                   │
│                                                            │
│  操作: router.back()                                       │
│  ════════════════════                                      │
│  ┌─────────┐     ┌─────────┐                              │
│  │  Index  │ →  │  List   │ ← 回到栈顶                     │
│  └─────────┘     └─────────┘                              │
│                    │  ↑ 可接收Detail返回的params            │
│                    └──┘                                    │
│                                                            │
│  操作: router.clear() + router.pushUrl('/index')           │
│  ═══════════════════════════════════════════════           │
│  ┌─────────┐                                              │
│  │  Index  │ ← 清空所有中间页,回到首页                     │
│  └─────────┘                                              │
│                                                            │
├────────────────────────────────────────────────────────────┤
│  页面生命周期与路由事件的关系:                               │
│                                                            │
│  pushUrl('/B')         back()                              │
│  ────────────         ─────                                │
│  页面A:               页面B:                                │
│  onPageHide()         aboutToDisappear()                    │
│                       页面A:                                │
│                       onPageShow()                          │
│                                                            │
│  数据传递:                                                  │
│  A ──params: {id:1}──→ B    (pushUrl时传参)                │
│  B ──params: {ret:true}──→ A  (back时带结果)               │
│  A ← getParams() 接收                                      │
└────────────────────────────────────────────────────────────┘

9.1 页面跳转

import { router } from '@kit.ArkUI'   // router 路由器:管理页面栈

// ===== 普通跳转 =====
router.pushUrl({                       // pushUrl 跳转:新页面压入栈顶
  url: 'pages/Detail'                  // url 目标页面路径
})

// ===== 带参数跳转 =====
router.pushUrl({
  url: 'pages/Detail',
  params: {                            // params 参数:传给目标页面的数据
    id: '123',
    title: '详情页',
    fromPage: 'Home'
  }
})

// ===== 目标页面接收参数 =====
let params = router.getParams() as Record<string, Object>
//           getParams 获取参数    ↑ as 类型断言
let id: string = params['id'] as string
let title: string = params['title'] as string

9.2 返回上一页

// 普通返回:返回上一页
router.back()                          // back 返回:弹出栈顶页面

// 带结果返回(页面A跳B,B操作完把结果带回A)
// B页面:
router.back({
  url: 'pages/PageA',
  params: { result: '操作成功' }       // 返回结果
})

// A页面接收返回结果
onPageShow(): void {
  // onPageShow 每次从其他页面返回时调用
  let result = router.getParams()?.['result'] as string
  if (result) {
    console.log('B页面返回了:', result)
  }
}

// 返回到首页(清空中间所有页面)
router.clear()                         // clear 清空页面栈
router.pushUrl({ url: 'pages/Index' }) // 重新压入首页

9.3 Tab 底部导航

// Tabs 标签页:底部导航的标准实现

@Entry
@Component
struct MainPage {
  @State currentTab: number = 0        // currentTab 当前选中tab索引

  build() {
    Tabs({ index: this.currentTab }) { // Tabs 标签页容器
      //         ↑ index 当前选中索引

      TabContent() {                   // TabContent 标签内容
        HomeTab()                      // 首页内容(自定义组件)
      }
      .tabBar('首页')                  // .tabBar 标签按钮文字
      // 也可以传图标: .tabBar(Image($r('app.media.home')))

      TabContent() { StudyTab() }
        .tabBar('学习')

      TabContent() { ProfileTab() }
        .tabBar('我的')
    }
    .barPosition(BarPosition.End)      // barPosition 导航栏位置:End底部/Start顶部
    .onChange((index: number) => {     // onChange 切换回调
      this.currentTab = index
    })
  }
}

9.4 综合练习:通讯录App(列表→详情)

页面A(联系人列表)  ──点击某项──→  页面B(联系人详情)
  - Scroll + ForEach              - 接收 params.id
  - 每行: 头像 + 名字 + 箭头       - 显示详细信息
  - onClick → pushUrl             - back 按钮返回
// ===== 页面A: 联系人列表 pages/ContactList.ets =====
import { router } from '@kit.ArkUI'

interface Contact {
  id: number
  name: string
  phone: string
}

@Entry
@Component
struct ContactList {
  @State contacts: Contact[] = [
    { id: 1, name: '张三', phone: '13800001111' },
    { id: 2, name: '李四', phone: '13800002222' },
    { id: 3, name: '王五', phone: '13800003333' },
  ]

  build() {
    Column() {
      Text('通讯录').fontSize(28).fontWeight(FontWeight.Bold).margin(20)

      Scroll() {
        Column() {
          ForEach(this.contacts, (contact: Contact) => {
            Row() {
              Text(contact.name.charAt(0))  // 头像占位:取名字第一个字
                .fontSize(20).fontColor(Color.White)
                .width(44).height(44)
                .borderRadius(22)
                .backgroundColor('#007AFF')
                .textAlign(TextAlign.Center)

              Column() {
                Text(contact.name).fontSize(17)
                Text(contact.phone).fontSize(13).fontColor('#999')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 12 })
              .layoutWeight(1)

              Text('〉').fontSize(20).fontColor('#CCC')  // 箭头
            }
            .width('100%')
            .padding(16)
            .backgroundColor(Color.White)
            .borderRadius(8)
            .margin({ top: 8 })
            .onClick(() => {
              router.pushUrl({
                url: 'pages/ContactDetail',
                params: { id: contact.id, name: contact.name, phone: contact.phone }
              })
            })
          }, (contact: Contact) => `${contact.id}`)
        }
        .padding(16)
      }
      .layoutWeight(1)
    }
    .width('100%').height('100%')
    .backgroundColor('#F0F0F0')
  }
}

// ===== 页面B: 联系人详情 pages/ContactDetail.ets =====
@Entry
@Component
struct ContactDetail {
  @State name: string = ''
  @State phone: string = ''

  aboutToAppear(): void {
    let params = router.getParams() as Record<string, Object>
    this.name = params['name'] as string
    this.phone = params['phone'] as string
  }

  build() {
    Column({ space: 24 }) {
      // 顶部返回栏
      Row() {
        Text('〈 返回')
          .fontSize(16).fontColor('#007AFF')
          .onClick(() => router.back())
      }
      .width('100%').padding(16)

      // 大头像
      Text(this.name.charAt(0))
        .fontSize(48).fontColor(Color.White)
        .width(100).height(100)
        .borderRadius(50)
        .backgroundColor('#007AFF')
        .textAlign(TextAlign.Center)

      Text(this.name).fontSize(28).fontWeight(FontWeight.Bold)
      Text(this.phone).fontSize(20).fontColor('#666')

      Row({ space: 16 }) {
        Text('📞 打电话').padding(16).backgroundColor('#34C759')
          .fontColor(Color.White).borderRadius(8)
        Text('💬 发消息').padding(16).backgroundColor('#007AFF')
          .fontColor(Color.White).borderRadius(8)
      }
      .margin({ top: 30 })
    }
    .width('100%').height('100%')
    .backgroundColor('#F0F0F0')
  }
}

第10章 数据存储

App需要记住数据。从最简单的键值对到完整的SQLite数据库。

10.0 存储方案选择

┌────────────────────────────────────────────────────────────────┐
│                    💾 数据存储三件套                              │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  Preferences (键值对)          SQLite (关系数据库)               │
│  ┌──────────────────┐        ┌──────────────────────┐         │
│  │  key → value     │        │  表格 ├── 行 ── 列   │         │
│  │  "name": "张三"  │        │       │              │         │
│  │  "age":  "25"    │        │  ┌───┬────┬──────┐  │         │
│  │  "token":"abc"   │        │  │id │题目 │答案   │  │         │
│  └──────────────────┘        │  ├───┼────┼──────┤  │         │
│                              │  │ 1 │脾虚 │食少. │  │         │
│  适用: 设置项、登录态        │  │ 2 │湿热 │身重. │  │         │
│  大小: 小(<100条)            │  └───┴────┴──────┘  │         │
│                              │                      │         │
│                              │  适用: 题库、用户数据 │         │
│                              │  大小: 大(千条以上)   │         │
│                              └──────────────────────┘         │
│                                                                │
│  沙箱文件  (fileIo)                                             │
│  ┌──────────────────────────────────────────┐                 │
│  │  filesDir/  应用专属目录, 其他App不能访问  │                 │
│  │  ├── notes.txt                           │                 │
│  │  ├── cache/   缓存文件                    │                 │
│  │  └── data/    自定义数据                  │                 │
│  │                                          │                 │
│  │  适用: 大文件(图片/音频/数据库文件)        │                 │
│  └──────────────────────────────────────────┘                 │
│                                                                │
└────────────────────────────────────────────────────────────────┘

10.1 Preferences — 键值对存储

// Preferences 偏好存储:存简单键值对,类似前端的localStorage
import { preferences } from '@kit.ArkData'   // preferences 偏好存储

// ===== 初始化 =====
let store = await preferences.getPreferences(
  getContext(),          // getContext 获取上下文(组件内可用)
  'mySettings'           // 存储文件名(多个文件互不干扰)
)

// ===== 写入 =====
await store.put('username', '张三')     // put 存入:键, 值
await store.put('isFirstLaunch', false)
await store.put('fontSize', 18)
await store.flush()                      // flush 刷盘:持久化到磁盘(重要!)

// ===== 读取 =====
let username: string = await store.get('username', '默认用户') as string
//                                     get 读取:键, 默认值     ↑ as类型断言
let isFirst: boolean = await store.get('isFirstLaunch', true) as boolean
let fontSize: number = await store.get('fontSize', 16) as number

// ===== 删除 =====
await store.delete('username')           // delete 删除:按key删除
await store.has('username')  // false     // has 判断是否存在
await store.flush()                      // 删除后也要flush()

10.2 RelationalStore — SQLite关系数据库

import { relationalStore } from '@kit.ArkData'
//       relationalStore 关系数据库:就是SQLite

// ===== 建库 + 建表 =====
let store = await relationalStore.getRdbStore(getContext(), {
  name: 'tcm_exam.db',                        // name 数据库文件名
  securityLevel: relationalStore.SecurityLevel.S1  // securityLevel 安全等级
})

const CREATE_TABLE = `
  CREATE TABLE IF NOT EXISTS questions (      // CREATE TABLE 建表
    id INTEGER PRIMARY KEY AUTOINCREMENT,     // PRIMARY KEY 主键
    title TEXT NOT NULL,                       // NOT NULL 不能为空
    answer TEXT,
    category TEXT,
    difficulty INTEGER DEFAULT 1               // DEFAULT 默认值
  )
`
await store.executeSql(CREATE_TABLE)          // executeSql 执行SQL

// ===== 增 (INSERT) =====
let values: relationalStore.ValuesBucket = {  // ValuesBucket 值桶
  title: '脾虚的临床表现?',
  answer: '食少纳呆,腹胀便溏...',
  category: '脾胃病',
  difficulty: 2
}
let rowId = await store.insert('questions', values)
//        insert 插入:表名, 数据        ↑ 返回插入行的id

// ===== 查 (SELECT) =====
let predicates = new relationalStore.RdbPredicates('questions')
//               RdbPredicates 查询条件:相当于SQL的WHERE
predicates.equalTo('category', '脾胃病')     // equalTo 等于
predicates.orderByDesc('id')                  // orderByDesc 倒序排列
predicates.limit(20)                          // limit 限制行数

let resultSet = await store.query(predicates) // query 执行查询
//               resultSet 结果集:游标方式遍历

while (resultSet.goToNextRow()) {             // goToNextRow 下移一行
  let title: string = resultSet.getString(
    resultSet.getColumnIndex('title')         // getColumnIndex 获取列索引
  )
  let answer: string = resultSet.getString(
    resultSet.getColumnIndex('answer')
  )
  console.log(`${title}: ${answer}`)
}
resultSet.close()                             // close 关闭结果集(重要!)

// ===== 改 (UPDATE) =====
let updateValues: relationalStore.ValuesBucket = {
  difficulty: 3
}
let updatePred = new relationalStore.RdbPredicates('questions')
updatePred.equalTo('id', 1)                   // WHERE id = 1
let affectedRows = await store.update(updateValues, updatePred)
//                 update 更新:新值, 条件

// ===== 删 (DELETE) =====
let deletePred = new relationalStore.RdbPredicates('questions')
deletePred.equalTo('id', 1)
await store.delete(deletePred)
//    delete 删除:条件

10.3 沙箱文件读写

import { fileIo } from '@kit.CoreFileKit'  // fileIo 文件IO

let context = getContext()                  // context 上下文

// ===== 写文件 =====
// openSync 同步打开文件
let file = fileIo.openSync(
  context.filesDir + '/notes.txt',          // filesDir 应用沙箱目录
  fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY
  //               CREATE 创建(不存在则创建)
  //                         | 位或:同时使用两个标志
  //                           WRITE_ONLY 只写
)
fileIo.writeSync(file.fd, '这是文件内容')    // writeSync 写内容
fileIo.closeSync(file)                       // closeSync 关闭(重要!)

// ===== 读文件 =====
let readFile = fileIo.openSync(
  context.filesDir + '/notes.txt',
  fileIo.OpenMode.READ_ONLY                  // READ_ONLY 只读
)
let buf = new ArrayBuffer(1024)              // ArrayBuffer 缓冲区
let len = fileIo.readSync(readFile.fd, buf)  // readSync 读内容
// 转字符串
let content = String.fromCharCode.apply(
  null, new Uint8Array(buf, 0, len) as unknown as number[]
)
fileIo.closeSync(readFile)

10.4 实战练习:备忘录App(Preferences + SQLite + 文件)

import { preferences } from '@kit.ArkData'
import { relationalStore } from '@kit.ArkData'
import { fileIo } from '@kit.CoreFileKit'

// ===== 数据模型 =====
@Observed
class Note {
  id: number = 0
  title: string = ''
  content: string = ''
  category: string = '默认'
  isPinned: boolean = false
  createdAt: string = ''

  get preview(): string {
    return this.content.length > 50 ? this.content.substring(0, 50) + '...' : this.content
  }
}

// ===== 数据库管理 =====
class NoteDatabase {
  private static store: relationalStore.RdbStore | null = null

  static async getStore(): Promise<relationalStore.RdbStore> {
    if (this.store) return this.store
    this.store = await relationalStore.getRdbStore(getContext(), {
      name: 'notes.db',
      securityLevel: relationalStore.SecurityLevel.S1
    })
    await this.store.executeSql(`
      CREATE TABLE IF NOT EXISTS notes (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        content TEXT NOT NULL,
        category TEXT DEFAULT '默认',
        is_pinned INTEGER DEFAULT 0,
        created_at TEXT NOT NULL
      )
    `)
    return this.store
  }

  static async getAll(): Promise<Note[]> {
    let store = await this.getStore()
    let predicates = new relationalStore.RdbPredicates('notes')
    predicates.orderByDesc('is_pinned')
    predicates.orderByDesc('id')
    let rs = await store.query(predicates)
    let notes: Note[] = []
    while (rs.goToNextRow()) {
      let note = new Note()
      note.id = rs.getLong(rs.getColumnIndex('id'))
      note.title = rs.getString(rs.getColumnIndex('title'))
      note.content = rs.getString(rs.getColumnIndex('content'))
      note.category = rs.getString(rs.getColumnIndex('category'))
      note.isPinned = rs.getLong(rs.getColumnIndex('is_pinned')) === 1
      note.createdAt = rs.getString(rs.getColumnIndex('created_at'))
      notes.push(note)
    }
    rs.close()
    return notes
  }

  static async insert(note: Note): Promise<number> {
    let store = await this.getStore()
    let values: relationalStore.ValuesBucket = {
      title: note.title,
      content: note.content,
      category: note.category,
      is_pinned: note.isPinned ? 1 : 0,
      created_at: new Date().toISOString()
    }
    return await store.insert('notes', values)
  }

  static async delete(id: number): Promise<void> {
    let store = await this.getStore()
    let predicates = new relationalStore.RdbPredicates('notes')
    predicates.equalTo('id', id)
    await store.delete(predicates)
  }

  static async togglePin(id: number, current: boolean): Promise<void> {
    let store = await this.getStore()
    let predicates = new relationalStore.RdbPredicates('notes')
    predicates.equalTo('id', id)
    let values: relationalStore.ValuesBucket = { is_pinned: current ? 0 : 1 }
    await store.update(values, predicates)
  }
}

// ===== 备忘录主页面 =====
@Entry
@Component
struct NoteApp {
  @State notes: Note[] = []
  @State showEditor: boolean = false
  @State editingTitle: string = ''
  @State editingContent: string = ''
  @State searchKeyword: string = ''
  @State sortOrder: string = '最新'

  async aboutToAppear(): Promise<void> {
    this.notes = await NoteDatabase.getAll()
  }

  get filteredNotes(): Note[] {
    if (!this.searchKeyword) return this.notes
    return this.notes.filter((n: Note) =>
      n.title.includes(this.searchKeyword) || n.content.includes(this.searchKeyword)
    )
  }

  build() {
    Column() {
      if (this.showEditor) {
        // ===== 编辑页面 =====
        Column({ space: 16 }) {
          Row() {
            Text('取消').fontSize(16).fontColor('#007AFF')
              .onClick(() => { this.showEditor = false })
            Text('新建备忘录').fontSize(17).fontWeight(FontWeight.Bold).layoutWeight(1).textAlign(TextAlign.Center)
            Text('保存').fontSize(16).fontColor('#007AFF')
              .onClick(async () => {
                if (!this.editingTitle) return
                let note = new Note()
                note.title = this.editingTitle
                note.content = this.editingContent
                await NoteDatabase.insert(note)
                this.notes = await NoteDatabase.getAll()
                this.editingTitle = ''
                this.editingContent = ''
                this.showEditor = false
              })
          }
          .width('100%').padding(16)

          TextInput({ text: this.editingTitle, placeholder: '标题' })
            .fontSize(22).fontWeight(FontWeight.Bold)
            .width('100%').height(48)
            .backgroundColor(Color.Transparent)
            .onChange((v: string) => { this.editingTitle = v })

          TextInput({ text: this.editingContent, placeholder: '开始输入...' })
            .fontSize(16)
            .width('100%').layoutWeight(1)
            .backgroundColor(Color.Transparent)
            .onChange((v: string) => { this.editingContent = v })
        }
        .width('100%').height('100%')
        .backgroundColor(Color.White)
      } else {
        // ===== 列表页面 =====
        Column() {
          // 顶部标题 + 搜索
          Text('备忘录').fontSize(28).fontWeight(FontWeight.Bold)
            .width('100%').padding({ left: 16, top: 16, bottom: 8 })

          TextInput({ text: this.searchKeyword, placeholder: '🔍 搜索' })
            .width('90%').height(36).borderRadius(18)
            .backgroundColor('#F0F0F0')
            .margin({ bottom: 8 })
            .onChange((v: string) => { this.searchKeyword = v })

          // 备忘录列表
          Scroll() {
            Column({ space: 1 }) {
              ForEach(this.filteredNotes, (note: Note, index: number) => {
                Row() {
                  if (note.isPinned) {
                    Text('📌').fontSize(14).margin({ right: 4 })
                  }
                  Column({ space: 4 }) {
                    Text(note.title).fontSize(16).fontWeight(FontWeight.Medium)
                    Text(note.preview).fontSize(13).fontColor('#999')
                  }
                  .alignItems(HorizontalAlign.Start)
                  .layoutWeight(1)
                }
                .width('100%')
                .padding(16)
                .backgroundColor(Color.White)
                .onClick(() => {
                  AlertDialog.show({ title: note.title, message: note.content })
                })
              }, (note: Note) => `${note.id}-${note.isPinned}`)
            }
          }
          .layoutWeight(1)
          .backgroundColor('#F5F5F5')

          // 底部工具栏
          Row() {
            Text(`${this.notes.length}个备忘录`)
              .fontSize(13).fontColor('#999')
            Text('+')
              .fontSize(28).fontColor('#007AFF')
              .layoutWeight(1).textAlign(TextAlign.End)
              .onClick(() => { this.showEditor = true })
          }
          .width('100%').padding(16)
          .backgroundColor(Color.White)
        }
        .width('100%').height('100%')
      }
    }
    .width('100%').height('100%')
  }
}

第11章 网络请求

App联了网才真正”活”起来。本章学习HTTP请求的完整流程。

11.0 HTTP 请求全流程

┌────────────────────────────────────────────────────────────────┐
│                    🌐 HTTP 请求全流程                              │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  你的App                        服务器                          │
│  ┌──────────┐                  ┌──────────┐                   │
│  │ createHttp│ ──① 创建实例──→ │          │                   │
│  │    ↓     │                  │          │                   │
│  │ request  │ ──② 发起请求──→ │  API     │                   │
│  │  (URL +  │    GET/POST      │  服务器   │                   │
│  │  method  │                  │          │                   │
│  │  +header │                  │          │                   │
│  │  +body)  │                  │          │                   │
│  │    ↓     │ ←──③ 返回响应── │          │                   │
│  │ response │    statusCode    │          │                   │
│  │   .code  │    + result      │          │                   │
│  │  .result │                  └──────────┘                   │
│  │    ↓     │                                                 │
│  │ JSON.parse│ ──④ 解析数据                                   │
│  │    ↓     │                                                 │
│  │ 更新@State│ ──⑤ UI自动刷新                                 │
│  │    ↓     │                                                 │
│  │ destroy  │ ──⑥ 释放连接(必须!)                             │
│  └──────────┘                                                 │
│                                                                │
│  ⚠️ 关键警告:                                                   │
│  ┌──────────────────────────────────────────────────────┐     │
│  │ 每次请求后必须 request.destroy() 释放连接!             │     │
│  │ 不释放 → 连接泄漏 → App越来越慢 → 可能崩溃             │     │
│  │ 用 try/catch/finally 保证 destroy 一定被执行           │     │
│  └──────────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────────┘

11.1 基础 HTTP 请求

import { http } from '@kit.NetworkKit'       // http HTTP客户端

// ===== GET 请求 =====
async function fetchData(): Promise<string> {
  let request = http.createHttp()             // createHttp 创建HTTP实例

  try {
    let response = await request.request(     // request 发起请求
      'https://api.example.com/data',         // 第1个参数:URL地址
      {
        method: http.RequestMethod.GET,       // method 请求方法:GET获取
        header: { 'Content-Type': 'application/json' },  // header 请求头
        connectTimeout: 10000,                // connectTimeout 连接超时(毫秒)
        readTimeout: 10000                    // readTimeout 读取超时(毫秒)
      }
    )
    // response.responseCode  HTTP状态码(200=成功)
    // response.result        响应体(字符串)
    return response.result as string
  } catch (err) {                            // catch 捕获异常
    console.error('请求失败:', JSON.stringify(err))
    return ''
  } finally {                                // finally 无论成功失败都执行
    request.destroy()                         // destroy 销毁请求(重要!释放资源)
  }
}

// ===== POST 请求 =====
async function submitData(data: object): Promise<boolean> {
  let request = http.createHttp()

  try {
    let response = await request.request(
      'https://api.example.com/submit',
      {
        method: http.RequestMethod.POST,     // POST 提交数据
        header: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer token123' // Authorization 认证令牌
        },
        extraData: JSON.stringify(data),     // extraData 请求体数据(JSON字符串)
        connectTimeout: 10000,
        readTimeout: 10000
      }
    )
    return response.responseCode === 200     // responseCode 200表示成功
  } catch (err) {
    return false
  } finally {
    request.destroy()
  }
}

11.2 封装网络工具类

// HttpClient 网络客户端:封装统一请求方法
// 封装 = 把重复代码抽取出来,每次只需调用一个简单方法

class HttpClient {
  // 单例模式 singleton:全局只有一个实例
  private static instance: HttpClient
  private baseUrl: string = 'https://your-api.com'  // baseUrl 基础地址

  // getInstance 获取实例:保证全局唯一
  static getInstance(): HttpClient {
    if (!HttpClient.instance) {
      HttpClient.instance = new HttpClient()
    }
    return HttpClient.instance
  }

  // GET 请求封装
  async get<T>(path: string): Promise<T | null> {
    //         ↑ T 泛型:调用时可以指定返回类型
    let request = http.createHttp()

    try {
      let response = await request.request(
        this.baseUrl + path,                   // 拼接完整URL
        { method: http.RequestMethod.GET }
      )
      return JSON.parse(response.result as string) as T
      //     JSON.parse 解析JSON  →  as T 类型断言
    } catch (e) {
      console.error(`GET ${path} 失败`)
      return null
    } finally {
      request.destroy()
    }
  }

  // POST 请求封装
  async post<T>(path: string, body: object): Promise<T | null> {
    let request = http.createHttp()

    try {
      let response = await request.request(
        this.baseUrl + path,
        {
          method: http.RequestMethod.POST,
          header: { 'Content-Type': 'application/json' },
          extraData: JSON.stringify(body)       // 自动序列化
        }
      )
      return JSON.parse(response.result as string) as T
    } catch (e) {
      console.error(`POST ${path} 失败`)
      return null
    } finally {
      request.destroy()
    }
  }
}

// 使用示例
let client = HttpClient.getInstance()
let result = await client.get<string[]>('/api/questions')

11.3 实战练习:新闻列表(网络请求 + 下拉刷新 + 列表渲染)

import { http } from '@kit.NetworkKit'

// ===== 数据模型 =====
interface NewsItem {
  id: number
  title: string
  summary: string
  imageUrl: string
  source: string
  publishTime: string
}

// ===== 网络服务 =====
class NewsService {
  private static baseUrl: string = 'https://api.example.com'

  static async getNews(page: number, pageSize: number): Promise<NewsItem[]> {
    let request = http.createHttp()
    try {
      let response = await request.request(
        `${this.baseUrl}/news?page=${page}&size=${pageSize}`,
        {
          method: http.RequestMethod.GET,
          header: { 'Content-Type': 'application/json' },
          connectTimeout: 10000,
          readTimeout: 10000
        }
      )
      if (response.responseCode === 200) {
        let data = JSON.parse(response.result as string)
        return data['list'] as NewsItem[]
      }
      return []
    } catch (e) {
      console.error('获取新闻失败:', JSON.stringify(e))
      return []
    } finally {
      request.destroy()
    }
  }
}

// ===== 新闻列表页面 =====
@Entry
@Component
struct NewsPage {
  @State newsList: NewsItem[] = []
  @State isLoading: boolean = true
  @State isRefreshing: boolean = false
  @State currentPage: number = 1
  @State hasMore: boolean = true
  @State errorMsg: string = ''

  // 模拟数据(实际项目中用上面的API)
  private mockData: NewsItem[] = [
    { id: 1, title: '中医药法实施五周年成效显著', summary: '中医药法自2017年实施以来...', imageUrl: '', source: '新华社', publishTime: '2小时前' },
    { id: 2, title: '新冠防治中医药方案更新', summary: '国家卫健委发布最新诊疗方案...', imageUrl: '', source: '人民日报', publishTime: '3小时前' },
    { id: 3, title: '针灸纳入美国医保体系', summary: '美国CMS宣布将针灸治疗...', imageUrl: '', source: '健康报', publishTime: '5小时前' },
    { id: 4, title: '古代经典名方目录再添新成员', summary: '国家药监局发布第三批古代经典名方...', imageUrl: '', source: '中国中医药报', publishTime: '6小时前' },
    { id: 5, title: '中医体质辨识AI系统上线', summary: '基于深度学习的中医体质辨识系统...', imageUrl: '', source: '科技日报', publishTime: '8小时前' },
  ]

  async aboutToAppear(): Promise<void> {
    await this.loadNews()
  }

  async loadNews(): Promise<void> {
    this.isLoading = true
    this.errorMsg = ''
    // 模拟网络请求延迟
    setTimeout(() => {
      this.newsList = [...this.newsList, ...this.mockData.map((item: NewsItem) => ({
        ...item,
        id: item.id + (this.currentPage - 1) * 5
      }))]
      this.isLoading = false
      this.isRefreshing = false
      this.currentPage++
      if (this.currentPage > 3) this.hasMore = false
    }, 1000)
  }

  async refreshNews(): Promise<void> {
    this.isRefreshing = true
    this.newsList = []
    this.currentPage = 1
    this.hasMore = true
    await this.loadNews()
  }

  build() {
    Column() {
      // 顶部标题栏
      Row() {
        Text('📰 新闻资讯')
          .fontSize(24).fontWeight(FontWeight.Bold)
        Text(this.isRefreshing ? '刷新中...' : '下拉刷新')
          .fontSize(12).fontColor('#999')
          .layoutWeight(1).textAlign(TextAlign.End)
      }
      .width('100%').padding(16)

      if (this.isLoading && this.newsList.length === 0) {
        // 加载中
        Column({ space: 12 }) {
          LoadingProgress().width(48).height(48)
          Text('加载中...').fontSize(14).fontColor('#999')
        }
        .layoutWeight(1).justifyContent(FlexAlign.Center)
      } else if (this.errorMsg) {
        // 加载失败
        Column({ space: 12 }) {
          Text('😢').fontSize(48)
          Text(this.errorMsg).fontSize(14).fontColor('#999')
          Text('点击重试')
            .fontSize(14).fontColor('#007AFF')
            .onClick(() => this.refreshNews())
        }
        .layoutWeight(1).justifyContent(FlexAlign.Center)
      } else {
        // 新闻列表
        Scroll() {
          Column({ space: 12 }) {
            ForEach(this.newsList, (news: NewsItem) => {
              Column({ space: 8 }) {
                // 新闻标题
                Text(news.title)
                  .fontSize(17)
                  .fontWeight(FontWeight.Medium)
                  .maxLines(2)

                // 新闻摘要
                Text(news.summary)
                  .fontSize(14)
                  .fontColor('#666')
                  .maxLines(2)

                // 来源 + 时间
                Row() {
                  Text(news.source)
                    .fontSize(12).fontColor('#007AFF')
                  Text(news.publishTime)
                    .fontSize(12).fontColor('#999')
                    .layoutWeight(1).textAlign(TextAlign.End)
                }
              }
              .width('100%')
              .padding(16)
              .backgroundColor(Color.White)
              .borderRadius(12)
              .onClick(() => {
                AlertDialog.show({ title: news.title, message: news.summary })
              })
            }, (news: NewsItem) => `${news.id}`)

            // 加载更多
            if (this.hasMore) {
              Text('加载更多...')
                .fontSize(14).fontColor('#999')
                .width('100%')
                .textAlign(TextAlign.Center)
                .padding(16)
                .onClick(() => this.loadNews())
            } else {
              Text('没有更多了')
                .fontSize(14).fontColor('#CCC')
                .width('100%')
                .textAlign(TextAlign.Center)
                .padding(16)
            }
          }
          .padding({ left: 16, right: 16 })
        }
        .layoutWeight(1)
        .onReachEnd(() => {
          if (this.hasMore && !this.isLoading) {
            this.loadNews()
          }
        })
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F5F5F5')
  }
}

第12章 真机调试

12.1 开启手机开发者模式

1. 设置 → 关于手机 → 连续点击"软件版本"7次
2. 返回 → 系统和更新 → 开发人员选项
3. 开启 USB 调试
4. 开启 "仅充电"模式下允许 ADB 调试

12.2 签名配置

// build-profile.json5 中的签名配置
"signingConfigs": [
  {
    "name": "debug",                    // debug 调试签名
    "type": "HarmonyOS",
    "material": {
      "storeFile": "debug.p12",         // storeFile 密钥库文件
      "storePassword": "000000",        // storePassword 密钥库密码
      "keyAlias": "debugKey",           // keyAlias 密钥别名
      "keyPassword": "000000",          // keyPassword 密钥密码
      "signAlg": "SHA256withECDSA",     // signAlg 签名算法
      "profile": "debug.p7b",           // profile 签名profile
      "certpath": "debug.cer"           // certpath 证书路径
    }
  }
]

12.3 连接与运行

1. USB 连接手机 → 手机上点"允许USB调试"
2. DevEco Studio 顶部选择设备(应该能看到手机型号)
3. 点击 ▶️ 运行 → 自动编译 → 安装到手机 → 自动启动

12.4 日志调试

import { hilog } from '@kit.PerformanceAnalysisKit'
//       hilog 鸿蒙日志系统

const DOMAIN: number = 0x0001   // DOMAIN 日志域:自定义标识(16进制)
const TAG: string = 'MyApp'     // TAG 日志标签:方便过滤搜索

// 四种日志级别
hilog.debug(DOMAIN, TAG, '调试信息:变量值=%{public}d', count)
//  debug  调试级别——开发时用
//                          %{public}d 格式化数字(必须加public前缀!)
hilog.info(DOMAIN, TAG, '普通信息:用户登录了')
//  info   信息级别——正常流程记录
hilog.warn(DOMAIN, TAG, '警告:配置项缺失,使用默认值')
//  warn   警告级别——可能有问题但不影响运行
hilog.error(DOMAIN, TAG, '错误:网络请求失败,code=%{public}d', errCode)
//  error  错误级别——出了异常

12.5 常见连接问题排查

┌─────────────────────────────────────────────────────────────┐
│                 🔧 真机调试问题排查清单                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  问题1: DevEco Studio 看不到手机                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 排查步骤:                                             │   │
│  │ 1. 检查USB线是否支持数据传输(不是只能充电的线)         │   │
│  │ 2. 手机 → 设置 → 系统和更新 → 开发人员选项              │   │
│  │    → USB调试 → 确认已开启                              │   │
│  │ 3. 手机弹出"允许USB调试"对话框 → 点"允许"              │   │
│  │ 4. 终端运行: hdc list targets                          │   │
│  │    → 有输出=连接成功  无输出=连接失败                   │   │
│  │ 5. 换USB口 / 重启DevEco Studio / 重启手机              │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  问题2: 安装失败 install failed: 9568289                     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 原因: 签名冲突(手机上已有不同签名的同包名App)         │   │
│  │ 解决: hdc uninstall <bundleName>                      │   │
│  │   或: 手机上手动卸载旧版App再重装                       │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  问题3: 编译成功但App闪退                                     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 排查:                                                  │   │
│  │ 1. 查看 HiLog 日志: hdc hilog                        │   │
│  │ 2. 检查 module.json5 中 permissions 是否声明          │   │
│  │ 3. 检查是否使用了真机才有的API(模拟器没有的)          │   │
│  │ 4. 检查 EntryAbility 的 loadContent 页面路径是否正确  │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  问题4: 热重载(Hot Reload)不生效                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 解决:                                                  │   │
│  │ 1. 保存文件后等2秒(热重载有延迟)                     │   │
│  │ 2. 点击 DevEco Studio 的 🔁 按钮手动触发              │   │
│  │ 3. 如果改了 @Entry 或 struct 定义 → 需要重新编译      │   │
│  │ 4. 改了 module.json5 / app.json5 → 必须重新编译       │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  问题5: hdc 命令大全                                          │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ hdc list targets          列出已连接设备               │   │
│  │ hdc install app.hap       安装HAP包                   │   │
│  │ hdc uninstall <包名>      卸载App                     │   │
│  │ hdc hilog                 查看实时日志                 │   │
│  │ hdc shell                 进入设备shell               │   │
│  │ hdc file send local dev   推送文件到设备               │   │
│  │ hdc file recv dev local   从设备拉取文件               │   │
│  │ hdc shell bm dump <包名>  查看App信息                  │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

第13章 实战:中医题库App完整拆解

以真实项目 TCMExamApp 为例,逐文件拆解项目结构、代码写法、数据流。

13.0 App 架构全景图

┌──────────────────────────────────────────────────────────────────────┐
│                      🏗 TCMExamApp 架构全景                             │
├──────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │                     UI 层 (pages/)                            │   │
│  │  ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐  ┌───────┐ │   │
│  │  │ Index  │→│HomePage│→│StudyPage│  │ExamPage│  │Result │ │   │
│  │  │ (登录) │  │ (选科) │  │ (学习) │  │ (考试) │  │(成绩) │ │   │
│  │  └────────┘  └────────┘  └────────┘  └────────┘  └───────┘ │   │
│  │                      ↓ 调用                                   │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                         │                                            │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │                   组件层 (components/)                        │   │
│  │  ┌────────────┐  ┌──────────┐  ┌──────────┐  ┌───────────┐ │   │
│  │  │QuestionCard │  │OptionItem│  │ProgressBar│  │CategoryPicker│ │   │
│  │  │ (题目卡片)  │  │ (选项)   │  │ (进度条)  │  │ (分类选择) │ │   │
│  │  └────────────┘  └──────────┘  └──────────┘  └───────────┘ │   │
│  │                      ↓ 使用                                   │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                         │                                            │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │                  数据模型层 (models/)                         │   │
│  │  ┌────────────────┐    ┌──────────────────┐                  │   │
│  │  │   Question     │    │  UserProgress    │                  │   │
│  │  │ @Observed      │    │ (学习进度)       │                  │   │
│  │  │ - id           │    │ - totalStudied   │                  │   │
│  │  │ - title        │    │ - correctCount   │                  │   │
│  │  │ - options[]    │    │ - accuracy(%)    │                  │   │
│  │  │ - answer       │    └──────────────────┘                  │   │
│  │  │ - isFavorite   │                                          │   │
│  │  └────────┬───────┘                                          │   │
│  │           │ 被操作                                            │   │
│  └───────────┼───────────────────────────────────────────────────┘   │
│              │                                                       │
│  ┌───────────┼───────────────────────────────────────────────────┐   │
│  │           ↓           数据层 (database/ + utils/)              │   │
│  │  ┌────────────────┐  ┌────────────────┐  ┌──────────────┐   │   │
│  │  │DatabaseHelper  │  │   HttpUtil     │  │  Constants   │   │   │
│  │  │ - getStore()   │  │ - get<T>()     │  │ - 颜色常量   │   │   │
│  │  │ - getByCategory│  │ - post<T>()    │  │ - 字号常量   │   │   │
│  │  │ - insert()     │  │ - 单例模式     │  │ - 分类列表   │   │   │
│  │  └───────┬────────┘  └────────┬───────┘  └──────────────┘   │   │
│  │          │                    │                               │   │
│  └──────────┼────────────────────┼───────────────────────────────┘   │
│             │                    │                                    │
│             ▼                    ▼                                    │
│  ┌────────────────┐  ┌────────────────────┐                         │
│  │  SQLite 数据库 │  │  远程 API 服务器    │                         │
│  │  tcm_exam.db   │  │  https://api.xxx   │                         │
│  │  (本地存储)    │  │  (云端同步)         │                         │
│  └────────────────┘  └────────────────────┘                         │
│                                                                      │
├──────────────────────────────────────────────────────────────────────┤
│  数据流总结:                                                          │
│                                                                      │
│  UI层(用户交互) → 组件层(展示数据) → 数据层(CRUD/HTTP) → 存储(DB/远程) │
│       ↑                                                              │
│       └── @State变化 → build()重执行 → UI自动刷新                      │
│                                                                      │
│  关键设计模式:                                                        │
│  ┌──────────────────────────────────────────────────────┐           │
│  │ 1. 单例模式 (Singleton)                                │           │
│  │    DatabaseHelper / HttpClient 全局只有一个实例        │           │
│  │                                                      │           │
│  │ 2. 观察者模式 (Observer)                               │           │
│  │    @Observed + @ObjectLink: 数据变了UI自动刷新         │           │
│  │                                                      │           │
│  │ 3. 分层架构 (Layered)                                  │           │
│  │    UI层 → 数据层 → 存储层,每层只依赖下一层             │           │
│  └──────────────────────────────────────────────────────┘           │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘

13.1 项目目录架构

TCMExamApp/                                  ← 项目根
├── AppScope/
│   └── app.json5                            ← 全局配置(bundleName/version)
├── entry/
│   └── src/main/
│       ├── ets/                             ← 所有ArkTS代码
│       │   ├── entryability/
│       │   │   └── EntryAbility.ets         ← 应用入口(首页加载)
│       │   ├── pages/                       ← 页面目录
│       │   │   ├── Index.ets                ←   启动页/登录
│       │   │   ├── HomePage.ets             ←   首页(学科选择)
│       │   │   ├── StudyPage.ets            ←   学习模式(看题+解析)
│       │   │   ├── ExamPage.ets             ←   考试模式(计时+计分)
│       │   │   └── ResultPage.ets           ←   成绩页(正确率/错题)
│       │   ├── components/                  ← 可复用组件
│       │   │   ├── QuestionCard.ets         ←   题目卡片
│       │   │   ├── OptionItem.ets           ←   选项条目
│       │   │   ├── ProgressBar.ets          ←   进度条
│       │   │   └── CategoryPicker.ets       ←   分类选择器
│       │   ├── models/                      ← 数据模型
│       │   │   ├── Question.ets             ←   题目模型
│       │   │   └── UserProgress.ets         ←   学习进度模型
│       │   ├── database/
│       │   │   └── DatabaseHelper.ets       ←   数据库操作封装
│       │   └── utils/
│       │       ├── Constants.ets            ←   常量(颜色/字号/分类)
│       │       └── HttpUtil.ets             ←   网络请求封装
│       ├── resources/                       ← 资源文件
│       │   ├── base/element/string.json     ←   字符串资源(多语言)
│       │   ├── base/media/                  ←   图标/图片
│       │   └── rawfile/                     ←   原始文件(题库JSON)
│       └── module.json5                     ← 模块配置(权限声明)
├── build-profile.json5                      ← 构建配置(签名)
└── hvigorfile.ts                            ← 构建脚本

13.2 数据模型(models/Question.ets)

// Question 题目模型:定义一道题的完整数据结构

@Observed
export class Question {                // export 导出:其他文件可import使用
  // @Observed 可观察:属性变化时UI自动刷新

  id: number = 0                      // id 题目编号
  title: string = ''                  // title 题干
  options: string[] = []              // options 选项列表(string数组)
  answer: number = -1                 // answer 正确答案索引(0=A, 1=B...)
  explanation: string = ''            // explanation 解析文字
  category: string = ''               // category 分类(脾胃病/肺病...)
  syndrome: string = ''               // syndrome 证型(脾虚/湿热...)
  difficulty: number = 1              // difficulty 难度(1简单~5困难)
  isFavorite: boolean = false         // isFavorite 是否收藏

  // 切换收藏状态
  toggleFavorite(): void {
    this.isFavorite = !this.isFavorite  // ! 取反
  }
}

// UserProgress 学习进度模型
export class UserProgress {
  totalStudied: number = 0            // totalStudied 已学习总数
  correctCount: number = 0            // correctCount 正确数
  wrongIds: number[] = []             // wrongIds 错题ID列表
  studyStreak: number = 0             // studyStreak 连续学习天数
  lastStudyDate: string = ''          // lastStudyDate 最后学习日期

  // 计算属性:正确率
  get accuracy(): number {
    if (this.totalStudied === 0) return 0
    return this.correctCount / this.totalStudied
  }
}

13.3 数据库操作(database/DatabaseHelper.ets)

// DatabaseHelper 数据库助手:封装所有SQL操作

import { relationalStore } from '@kit.ArkData'
import { Question } from '../models/Question'

export class DatabaseHelper {
  private static store: relationalStore.RdbStore | null = null
  //     static 静态属性    | null 可为null(联合类型)     ↑ 初始值

  // 获取数据库实例(懒加载)
  static async getStore(): Promise<relationalStore.RdbStore> {
    // static 静态方法:直接 DatabaseHelper.getStore() 调用
    if (this.store) return this.store       // 已创建则直接返回

    this.store = await relationalStore.getRdbStore(getContext(), {
      name: 'tcm_exam.db',
      securityLevel: relationalStore.SecurityLevel.S1
    })

    // 建表
    await this.store.executeSql(`
      CREATE TABLE IF NOT EXISTS questions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        options TEXT NOT NULL,
        answer INTEGER,
        explanation TEXT,
        category TEXT,
        syndrome TEXT,
        difficulty INTEGER DEFAULT 1,
        is_favorite INTEGER DEFAULT 0
      )
    `)

    return this.store
  }

  // 按分类查询题目
  static async getByCategory(category: string): Promise<Question[]> {
    let store = await this.getStore()
    let predicates = new relationalStore.RdbPredicates('questions')
    predicates.equalTo('category', category)

    let rs = await store.query(predicates)
    let results: Question[] = []

    while (rs.goToNextRow()) {
      let q = new Question()
      q.id = rs.getLong(rs.getColumnIndex('id'))
      q.title = rs.getString(rs.getColumnIndex('title'))
      q.options = JSON.parse(rs.getString(rs.getColumnIndex('options')))
      //              JSON.parse 反序列化:把JSON字符串变成数组
      q.answer = rs.getLong(rs.getColumnIndex('answer'))
      q.explanation = rs.getString(rs.getColumnIndex('explanation'))
      q.category = rs.getString(rs.getColumnIndex('category'))
      q.syndrome = rs.getString(rs.getColumnIndex('syndrome'))
      q.difficulty = rs.getLong(rs.getColumnIndex('difficulty'))
      q.isFavorite = rs.getLong(rs.getColumnIndex('is_favorite')) === 1
      results.push(q)
    }
    rs.close()
    return results
  }

  // 根据ID获取单个题目
  static async getById(id: number): Promise<Question | null> {
    let store = await this.getStore()
    let predicates = new relationalStore.RdbPredicates('questions')
    predicates.equalTo('id', id)
    let rs = await store.query(predicates)
    let question: Question | null = null
    if (rs.goToNextRow()) {
      question = new Question()
      question.id = rs.getLong(rs.getColumnIndex('id'))
      question.title = rs.getString(rs.getColumnIndex('title'))
      question.options = JSON.parse(rs.getString(rs.getColumnIndex('options')))
      question.answer = rs.getLong(rs.getColumnIndex('answer'))
      question.explanation = rs.getString(rs.getColumnIndex('explanation'))
      question.category = rs.getString(rs.getColumnIndex('category'))
      question.syndrome = rs.getString(rs.getColumnIndex('syndrome'))
      question.difficulty = rs.getLong(rs.getColumnIndex('difficulty'))
      question.isFavorite = rs.getLong(rs.getColumnIndex('is_favorite')) === 1
    }
    rs.close()
    return question
  }

  // 更新收藏状态
  static async toggleFavorite(id: number, current: boolean): Promise<void> {
    let store = await this.getStore()
    let predicates = new relationalStore.RdbPredicates('questions')
    predicates.equalTo('id', id)
    let values: relationalStore.ValuesBucket = { is_favorite: current ? 0 : 1 }
    await store.update(values, predicates)
  }

  // 获取收藏题目
  static async getFavorites(): Promise<Question[]> {
    let store = await this.getStore()
    let predicates = new relationalStore.RdbPredicates('questions')
    predicates.equalTo('is_favorite', 1)
    let rs = await store.query(predicates)
    let results: Question[] = []
    while (rs.goToNextRow()) {
      let q = new Question()
      q.id = rs.getLong(rs.getColumnIndex('id'))
      q.title = rs.getString(rs.getColumnIndex('title'))
      q.options = JSON.parse(rs.getString(rs.getColumnIndex('options')))
      q.answer = rs.getLong(rs.getColumnIndex('answer'))
      q.explanation = rs.getString(rs.getColumnIndex('explanation'))
      q.category = rs.getString(rs.getColumnIndex('category'))
      q.syndrome = rs.getString(rs.getColumnIndex('syndrome'))
      q.difficulty = rs.getLong(rs.getColumnIndex('difficulty'))
      q.isFavorite = true
      results.push(q)
    }
    rs.close()
    return results
  }

  // 获取所有分类
  static async getCategories(): Promise<string[]> {
    let store = await this.getStore()
    let predicates = new relationalStore.RdbPredicates('questions')
    let rs = await store.query(predicates, ['category'])
    let categories: string[] = []
    while (rs.goToNextRow()) {
      let cat = rs.getString(rs.getColumnIndex('category'))
      if (cat && !categories.includes(cat)) {
        categories.push(cat)
      }
    }
    rs.close()
    return categories
  }
}

13.4 题目卡片组件(components/QuestionCard.ets)

// QuestionCard 题目卡片:展示一道题 + 四个选项 + 解析
// 这是整个App最核心的UI组件

import { Question } from '../models/Question'

@Component
export struct QuestionCard {
  @Prop question: Question              // 当前题目(父传子)
  @State selectedIndex: number = -1      // 用户选中的选项(-1=未选)
  onAnswer?: (correct: boolean) => void  // 作答回调

  // 判断某个选项的颜色
  getOptionBgColor(index: number): string {
    if (this.selectedIndex === -1) {
      return '#F5F5F5'                   // 未作答:灰色
    }
    if (index === this.question.answer) {
      return '#E8F5E9'                   // 正确答案:绿色
    }
    if (index === this.selectedIndex) {
      return '#FFEBEE'                   // 用户选错:红色
    }
    return '#F5F5F5'                     // 其他:灰色
  }

  build() {
    Column() {
      // ===== 题号 + 题干 =====
      Text(`第${this.question.id}题 · ${this.question.category}`)
        .fontSize(12).fontColor('#999')
      Text(this.question.title)
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .margin({ top: 12, bottom: 24 })
        .width('100%')

      // ===== 四个选项 =====
      ForEach(this.question.options,
        (option: string, index: number) => {
          Row() {
            // 选项字母 A/B/C/D
            Text(String.fromCharCode(65 + index))
            //    String.fromCharCode() ASCII转字符: 65='A', 66='B'
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(index === this.question.answer &&
                         this.selectedIndex !== -1 ? '#4CAF50' : '#333')
              //              ↑ 作答后正确答案变绿
              .width(28)

            // 选项文字
            Text(option)
              .fontSize(16)
              .layoutWeight(1)
          }
          .width('100%')
          .padding(14)
          .margin({ top: 10 })
          .backgroundColor(this.getOptionBgColor(index))
          //                  ↑ 根据选择状态变色
          .borderRadius(10)
          .onClick(() => {
            if (this.selectedIndex === -1) {    // 未作答才能选
              this.selectedIndex = index
              this.onAnswer?.(index === this.question.answer)
              // 触发回调:告知父组件答对还是答错
            }
          })
        },
        (option: string, index: number) => `${index}`
      )

      // ===== 解析(作答后才显示) =====
      if (this.selectedIndex !== -1) {
        Column() {
          Row() {
            Text(this.selectedIndex === this.question.answer ? '✅ 回答正确' : '❌ 回答错误')
              .fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(this.selectedIndex === this.question.answer ? '#4CAF50' : '#F44336')
          }
          .margin({ top: 16 })

          Text('📖 解析')
            .fontSize(14).fontWeight(FontWeight.Bold)
            .margin({ top: 12 })

          Text(this.question.explanation)
            .fontSize(14)
            .fontColor('#666')
            .lineHeight(22)    // lineHeight 行高
        }
        .width('100%')
        .padding(16)
        .margin({ top: 16 })
        .backgroundColor('#FAFAFA')
        .borderRadius(10)
      }
    }
    .width('100%')
    .padding(20)
    .backgroundColor(Color.White)
    .borderRadius(16)
  }
}

13.5 考试页面(pages/ExamPage.ets)

// ExamPage 考试页面:随机出题、计时、计分、提交看结果

import { router } from '@kit.ArkUI'
import { Question } from '../models/Question'
import { UserProgress } from '../models/UserProgress'
import { DatabaseHelper } from '../database/DatabaseHelper'
import { QuestionCard } from '../components/QuestionCard'

@Entry
@Component
struct ExamPage {
  // ===== 状态数据 =====
  @State questions: Question[] = []       // 题目列表
  @State currentIndex: number = 0         // 当前题目索引
  @State correctCount: number = 0         // 正确计数
  @State isFinished: boolean = false      // 是否完成
  @State isLoading: boolean = true        // 是否加载中
  @State secondsLeft: number = 1800       // 剩余秒数(30分钟)

  private timerId: number = -1            // 定时器ID
  private totalCount: number = 50         // 总题数

  // 页面加载
  async aboutToAppear(): Promise<void> {
    // 获取参数
    let params = router.getParams() as Record<string, Object>
    let category = params?.['category'] as string || '全部'

    // 加载题库
    let all = await DatabaseHelper.getByCategory(category)
    this.questions = this.shuffle(all).slice(0, this.totalCount)
    //               ↑ 随机打乱          ↑ 取前50道

    this.isLoading = false
    this.startTimer()
  }

  // 随机打乱数组 Fisher-Yates洗牌算法
  shuffle<T>(array: T[]): T[] {
    let arr = [...array]                   // ...展开:浅拷贝一份
    for (let i = arr.length - 1; i > 0; i--) {
      let j = Math.floor(Math.random() * (i + 1))
      //      Math.floor 向下取整   Math.random 随机数(0~1)
      let temp = arr[i];
      arr[i] = arr[j];
      arr[j] = temp
    }
    return arr
  }

  // 计时器
  startTimer(): void {
    this.timerId = setInterval(() => {      // setInterval 定时循环
      if (this.secondsLeft > 0) {
        this.secondsLeft--
      } else {
        this.finishExam()                   // 时间到,自动提交
      }
    }, 1000)                                // 每1000ms(1秒)执行一次
  }

  // 作答处理
  handleAnswer(correct: boolean): void {
    if (correct) this.correctCount++

    setTimeout(() => {                      // setTimeout 延迟执行
      if (this.currentIndex < this.questions.length - 1) {
        this.currentIndex++                 // 下一题
      } else {
        this.finishExam()                   // 全部完成
      }
    }, 1200)                                // 1.2秒后跳转(给用户看解析)
  }

  // 提交考试
  finishExam(): void {
    clearInterval(this.timerId)             // clearInterval 停止计时
    this.isFinished = true
  }

  // 格式化时间 mm:ss
  get timeDisplay(): string {
    let min: number = Math.floor(this.secondsLeft / 60)
    let sec: number = this.secondsLeft % 60
    return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
    //      padStart 前补零:01, 02... 保证两位数
  }

  build() {
    Column() {
      if (this.isLoading) {
        // 加载中
        LoadingProgress().width(48).height(48)
      } else if (this.isFinished) {
        // 考试完成 → 显示成绩
        this.buildResult()
      } else {
        // 答题中
        this.buildQuestion()
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F0F0F0')
  }

  // 答题界面(抽取为独立Builder方法, 保持build整洁)
  @Builder
  buildQuestion() {
    Column() {
      // 顶部状态栏:进度 + 计时
      Row() {
        Text(`${this.currentIndex + 1}/${this.questions.length}`)
          .fontSize(14).fontColor('#666')
        Text(`⏱ ${this.timeDisplay}`)
          .fontSize(14).fontColor('#FF5722').layoutWeight(1).textAlign(TextAlign.Center)
        Text(`✅ ${this.correctCount}`)
          .fontSize(14)
      }
      .width('100%').padding(12).backgroundColor(Color.White)

      // 进度条
      Progress({
        value: this.currentIndex + 1,
        total: this.questions.length
      })
      .width('100%').height(4).color('#2196F3')

      // 题目卡片
      Scroll() {
        QuestionCard({
          question: this.questions[this.currentIndex],
          onAnswer: (correct: boolean) => this.handleAnswer(correct)
        })
        .margin({ top: 16, left: 16, right: 16 })
      }
      .layoutWeight(1)
    }
    .width('100%').height('100%')
  }

  // 成绩界面
  @Builder
  buildResult() {
    Column({ space: 24 }) {
      Text('考试完成!').fontSize(28).fontWeight(FontWeight.Bold)

      // 正确率环形图
      Text(`${((this.correctCount / this.questions.length) * 100).toFixed(1)}%`)
        //                                    toFixed 保留小数位
        .fontSize(56)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2196F3')

      Text(`答对 ${this.correctCount} 题 / 共 ${this.questions.length} 题`)
        .fontSize(16)
        .fontColor('#666')

      Row({ space: 16 }) {
        Text('查看错题')
          .padding(16)
          .fontColor(Color.White).backgroundColor('#F44336')
          .borderRadius(8)
          .onClick(() => {
            // TODO: 跳转错题页
          })

        Text('返回首页')
          .padding(16)
          .fontColor(Color.White).backgroundColor('#2196F3')
          .borderRadius(8)
          .onClick(() => router.back())
      }
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Center)
  }

  // 页面销毁时清理定时器
  aboutToDisappear(): void {
    clearInterval(this.timerId)
  }
}

13.6 首页(pages/HomePage.ets)

import { router } from '@kit.ArkUI'
import { DatabaseHelper } from '../database/DatabaseHelper'
import { UserProgress } from '../models/UserProgress'

@Entry
@Component
struct HomePage {
  @State categories: string[] = []
  @State progress: UserProgress = new UserProgress()
  @State isLoading: boolean = true

  async aboutToAppear(): Promise<void> {
    this.categories = await DatabaseHelper.getCategories()
    // 从Preferences加载学习进度
    let prefs = await preferences.getPreferences(getContext(), 'userProgress')
    this.progress.totalStudied = await prefs.get('totalStudied', 0) as number
    this.progress.correctCount = await prefs.get('correctCount', 0) as number
    this.isLoading = false
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        // 顶部欢迎 + 统计
        Column({ space: 8 }) {
          Text('🏥 中医题库')
            .fontSize(28).fontWeight(FontWeight.Bold)
          Text('每天进步一点点')
            .fontSize(14).fontColor('#999')
        }
        .width('100%').padding({ top: 20, bottom: 12 })

        // 学习统计卡片
        Row({ space: 0 }) {
          Column({ space: 4 }) {
            Text(`${this.progress.totalStudied}`)
              .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#007AFF')
            Text('已学题目').fontSize(12).fontColor('#999')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 4 }) {
            Text(`${this.progress.accuracy.toFixed(0)}%`)
              .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#34C759')
            Text('正确率').fontSize(12).fontColor('#999')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 4 }) {
            Text(`${this.progress.studyStreak}天`)
              .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF9500')
            Text('连续学习').fontSize(12).fontColor('#999')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').padding(20)
        .backgroundColor(Color.White).borderRadius(16)

        // 快捷入口
        Row({ space: 12 }) {
          Column({ space: 6 }) {
            Text('📝').fontSize(28)
            Text('顺序学习').fontSize(13)
          }
          .layoutWeight(1).padding(16)
          .backgroundColor('#E3F2FD').borderRadius(12)
          .onClick(() => {
            router.pushUrl({ url: 'pages/StudyPage', params: { mode: 'sequential' } })
          })

          Column({ space: 6 }) {
            Text('🎲').fontSize(28)
            Text('随机练习').fontSize(13)
          }
          .layoutWeight(1).padding(16)
          .backgroundColor('#FFF3E0').borderRadius(12)
          .onClick(() => {
            router.pushUrl({ url: 'pages/StudyPage', params: { mode: 'random' } })
          })

          Column({ space: 6 }) {
            Text('⭐').fontSize(28)
            Text('我的收藏').fontSize(13)
          }
          .layoutWeight(1).padding(16)
          .backgroundColor('#E8F5E9').borderRadius(12)
          .onClick(() => {
            router.pushUrl({ url: 'pages/StudyPage', params: { mode: 'favorites' } })
          })

          Column({ space: 6 }) {
            Text('❌').fontSize(28)
            Text('错题本').fontSize(13)
          }
          .layoutWeight(1).padding(16)
          .backgroundColor('#FFEBEE').borderRadius(12)
          .onClick(() => {
            router.pushUrl({ url: 'pages/StudyPage', params: { mode: 'wrong' } })
          })
        }
        .width('100%')

        // 分类列表
        Text('📚 题库分类')
          .fontSize(18).fontWeight(FontWeight.Bold)
          .width('100%').margin({ top: 8 })

        ForEach(this.categories, (category: string) => {
          Row() {
            Column({ space: 4 }) {
              Text(category).fontSize(16).fontWeight(FontWeight.Medium)
              Text('点击查看题目').fontSize(12).fontColor('#999')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('开始练习 →')
              .fontSize(13).fontColor('#007AFF')
              .padding({ top: 8, bottom: 8, left: 12, right: 12 })
              .backgroundColor('#E3F2FD').borderRadius(16)
          }
          .width('100%').padding(16)
          .backgroundColor(Color.White).borderRadius(12)
          .onClick(() => {
            router.pushUrl({
              url: 'pages/StudyPage',
              params: { category: category, mode: 'category' }
            })
          })
        }, (category: string) => category)

        // 模拟考试入口
        Text('🎯 模拟考试')
          .fontSize(18).fontWeight(FontWeight.Bold)
          .width('100%').margin({ top: 8 })

        Column({ space: 8 }) {
          Text('限时30分钟 · 50道题 · 随机抽取')
            .fontSize(14).fontColor('#666')
          Text('开始考试')
            .fontSize(16).fontWeight(FontWeight.Bold)
            .fontColor(Color.White)
            .width('100%').height(44)
            .textAlign(TextAlign.Center)
            .backgroundColor('#FF3B30').borderRadius(22)
            .onClick(() => {
              router.pushUrl({ url: 'pages/ExamPage', params: { category: '全部' } })
            })
        }
        .width('100%').padding(20)
        .backgroundColor(Color.White).borderRadius(16)
      }
      .padding(16)
    }
    .width('100%').height('100%')
    .backgroundColor('#F5F5F5')
  }
}

13.7 学习页面(pages/StudyPage.ets)

import { router } from '@kit.ArkUI'
import { Question } from '../models/Question'
import { DatabaseHelper } from '../database/DatabaseHelper'
import { QuestionCard } from '../components/QuestionCard'

@Entry
@Component
struct StudyPage {
  @State questions: Question[] = []
  @State currentIndex: number = 0
  @State correctCount: number = 0
  @State isLoading: boolean = true
  @State showExplanation: boolean = false

  async aboutToAppear(): Promise<void> {
    let params = router.getParams() as Record<string, Object>
    let mode = params?.['mode'] as string || 'sequential'
    let category = params?.['category'] as string || '全部'

    let all: Question[] = []
    if (mode === 'favorites') {
      all = await DatabaseHelper.getFavorites()
    } else {
      all = await DatabaseHelper.getByCategory(category)
      if (mode === 'random') {
        all = this.shuffle(all)
      }
    }

    this.questions = all.slice(0, 50)
    this.isLoading = false
  }

  shuffle<T>(array: T[]): T[] {
    let arr = [...array]
    for (let i = arr.length - 1; i > 0; i--) {
      let j = Math.floor(Math.random() * (i + 1))
      let temp = arr[i]; arr[i] = arr[j]; arr[j] = temp
    }
    return arr
  }

  handleAnswer(correct: boolean): void {
    if (correct) this.correctCount++
  }

  nextQuestion(): void {
    if (this.currentIndex < this.questions.length - 1) {
      this.currentIndex++
    } else {
      router.pushUrl({
        url: 'pages/ResultPage',
        params: {
          correct: this.correctCount,
          total: this.questions.length
        }
      })
    }
  }

  build() {
    Column() {
      if (this.isLoading) {
        Column({ space: 12 }) {
          LoadingProgress().width(48).height(48)
          Text('加载题目中...').fontSize(14).fontColor('#999')
        }
        .layoutWeight(1).justifyContent(FlexAlign.Center)
      } else if (this.questions.length === 0) {
        Column({ space: 12 }) {
          Text('📚').fontSize(48)
          Text('暂无题目').fontSize(16).fontColor('#999')
          Text('返回首页')
            .fontSize(14).fontColor('#007AFF')
            .onClick(() => router.back())
        }
        .layoutWeight(1).justifyContent(FlexAlign.Center)
      } else {
        // 顶部状态栏
        Row() {
          Text('← 返回').fontSize(14).fontColor('#007AFF')
            .onClick(() => router.back())
          Text(`${this.currentIndex + 1}/${this.questions.length}`)
            .fontSize(14).layoutWeight(1).textAlign(TextAlign.Center)
          Text(`✅ ${this.correctCount}`).fontSize(14)
        }
        .width('100%').padding(12).backgroundColor(Color.White)

        // 进度条
        Progress({ value: this.currentIndex + 1, total: this.questions.length })
          .width('100%').height(3).color('#007AFF')

        // 题目卡片
        Scroll() {
          Column({ space: 16 }) {
            QuestionCard({
              question: this.questions[this.currentIndex],
              onAnswer: (correct: boolean) => this.handleAnswer(correct)
            })

            // 下一题按钮
            Text(this.currentIndex === this.questions.length - 1 ? '查看成绩' : '下一题 →')
              .fontSize(16).fontWeight(FontWeight.Bold)
              .fontColor(Color.White)
              .width('90%').height(44)
              .textAlign(TextAlign.Center)
              .backgroundColor('#007AFF').borderRadius(22)
              .onClick(() => this.nextQuestion())
          }
          .padding(16)
        }
        .layoutWeight(1)
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F5F5F5')
  }
}

13.8 成绩页面(pages/ResultPage.ets)

import { router } from '@kit.ArkUI'
import { preferences } from '@kit.ArkData'

@Entry
@Component
struct ResultPage {
  @State correct: number = 0
  @State total: number = 0
  @State saved: boolean = false

  async aboutToAppear(): Promise<void> {
    let params = router.getParams() as Record<string, Object>
    this.correct = params?.['correct'] as number || 0
    this.total = params?.['total'] as number || 0

    // 保存学习进度
    let prefs = await preferences.getPreferences(getContext(), 'userProgress')
    let prevTotal = await prefs.get('totalStudied', 0) as number
    let prevCorrect = await prefs.get('correctCount', 0) as number
    await prefs.put('totalStudied', prevTotal + this.total)
    await prefs.put('correctCount', prevCorrect + this.correct)
    await prefs.flush()
    this.saved = true
  }

  get percentage(): number {
    if (this.total === 0) return 0
    return Math.round((this.correct / this.total) * 100)
  }

  get grade(): string {
    if (this.percentage >= 90) return '优秀 🏆'
    if (this.percentage >= 80) return '良好 👍'
    if (this.percentage >= 60) return '及格 ✅'
    return '需加油 💪'
  }

  get gradeColor(): string {
    if (this.percentage >= 90) return '#FFD700'
    if (this.percentage >= 80) return '#34C759'
    if (this.percentage >= 60) return '#007AFF'
    return '#FF3B30'
  }

  build() {
    Column({ space: 24 }) {
      Text('考试完成!')
        .fontSize(32).fontWeight(FontWeight.Bold)
        .margin({ top: 60 })

      // 成绩环
      Stack({ alignContent: Alignment.Center }) {
        Progress({ value: this.percentage, total: 100, type: ProgressType.Ring })
          .width(180).height(180)
          .color(this.gradeColor)
          .style({ strokeWidth: 14 })
        Column({ space: 4 }) {
          Text(`${this.percentage}%`)
            .fontSize(48).fontWeight(FontWeight.Bold)
            .fontColor(this.gradeColor)
          Text(this.grade)
            .fontSize(16).fontColor('#666')
        }
      }

      // 统计信息
      Row({ space: 0 }) {
        Column({ space: 4 }) {
          Text(`${this.total}`).fontSize(24).fontWeight(FontWeight.Bold)
          Text('总题数').fontSize(12).fontColor('#999')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)

        Column({ space: 4 }) {
          Text(`${this.correct}`).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#34C759')
          Text('答对').fontSize(12).fontColor('#999')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)

        Column({ space: 4 }) {
          Text(`${this.total - this.correct}`).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FF3B30')
          Text('答错').fontSize(12).fontColor('#999')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }
      .width('100%').padding(20)
      .backgroundColor(Color.White).borderRadius(16)
      .margin({ left: 24, right: 24 })

      // 评语
      Text(this.percentage >= 80 ? '继续保持,你很棒!' : '多做练习,下次更好!')
        .fontSize(16).fontColor('#666')

      // 按钮
      Column({ space: 12 }) {
        Text('再做一次')
          .fontSize(16).fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .width('80%').height(48)
          .textAlign(TextAlign.Center)
          .backgroundColor('#007AFF').borderRadius(24)
          .onClick(() => {
            router.back()
          })

        Text('返回首页')
          .fontSize(16)
          .fontColor('#007AFF')
          .width('80%').height(48)
          .textAlign(TextAlign.Center)
          .border({ width: 1, color: '#007AFF' }).borderRadius(24)
          .onClick(() => {
            router.clear()
            router.pushUrl({ url: 'pages/Index' })
          })
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F5F5F5')
  }
}

第14章 上架 AppGallery

14.1 发布前检查清单

14.2 打包

菜单: Build → Build Hap(s)/App(s) → Build App(s)
输出: build/outputs/default/xxx-release.app

14.3 上传

1. https://developer.huawei.com → AppGallery Connect
2. 我的应用 → 创建应用 → HarmonyOS
3. 填写:应用名称、包名、分类、隐私政策URL
4. 版本管理 → 上传APP包(选上一步生成的.app文件)
5. 填写版本信息(更新日志、截图)
6. 提交审核 → 等待1~3工作日

14.4 拒审常因

原因 解决
隐私政策缺失/404 确保URL可访问
权限未声明原因 module.json5中permission加reason字段
有崩溃 充分测试、修bug
功能不完整 不提交半成品
截图与实际不符 用真机截图

第15章 错误处理与调试技巧

写代码不怕出错,怕的是不知道怎么查错。本章总结鸿蒙开发中最常遇到的坑和解决方法。

15.1 try/catch/finally — 异步操作必须包

// 铁律:所有异步操作(网络/数据库/文件)必须用 try/catch 包裹

// ❌ 错误写法:没有错误处理
async function badExample(): Promise<void> {
  let data = await fetchData()    // 如果这里失败,整个App会崩溃
  processData(data)
}

// ✅ 正确写法:完整的错误处理
async function goodExample(): Promise<void> {
  let request = http.createHttp()    // 创建请求
  try {
    let response = await request.request(url, {
      method: http.RequestMethod.GET
    })

    if (response.responseCode !== 200) {
      // 服务器返回错误
      hilog.error(0x0000, 'API', '请求失败: %{public}d', response.responseCode)
      return
    }

    let data = JSON.parse(response.result as string)
    // 处理数据...
  } catch (err) {
    // 网络异常/解析错误
    hilog.error(0x0000, 'API', '异常: %{public}s', JSON.stringify(err))
  } finally {
    // 无论成功失败,都必须释放资源
    request.destroy()
  }
}

15.2 数据库操作的错误处理

// 完整的数据库操作模式
async function safeQuery(category: string): Promise<Question[]> {
  let store: relationalStore.RdbStore | null = null
  let rs: relationalStore.ResultSet | null = null

  try {
    store = await relationalStore.getRdbStore(getContext(), {
      name: 'tcm_exam.db',
      securityLevel: relationalStore.SecurityLevel.S1
    })

    let predicates = new relationalStore.RdbPredicates('questions')
    predicates.equalTo('category', category)

    rs = await store.query(predicates)
    let results: Question[] = []

    while (rs.goToNextRow()) {
      let q = new Question()
      q.id = rs.getLong(rs.getColumnIndex('id'))
      q.title = rs.getString(rs.getColumnIndex('title'))
      results.push(q)
    }

    return results
  } catch (err) {
    hilog.error(0x0000, 'DB', '查询失败: %{public}s', JSON.stringify(err))
    return []    // 返回空数组,不崩溃
  } finally {
    rs?.close()    // 关闭结果集(重要!)
  }
}

15.3 UI层的防御性编程

@Component
struct SafeComponent {
  @State data: string[] = []
  @State isLoading: boolean = true
  @State errorMsg: string = ''

  async aboutToAppear(): Promise<void> {
    try {
      this.data = await loadData()
      this.errorMsg = ''
    } catch (err) {
      this.errorMsg = '加载失败,请重试'
      hilog.error(0x0000, 'UI', '加载失败: %{public}s', JSON.stringify(err))
    } finally {
      this.isLoading = false
    }
  }

  build() {
    Column() {
      if (this.isLoading) {
        // 加载状态
        LoadingProgress().width(48).height(48)
        Text('加载中...').fontSize(14).fontColor('#999')
      } else if (this.errorMsg) {
        // 错误状态
        Column({ space: 8 }) {
          Text('😢').fontSize(48)
          Text(this.errorMsg).fontSize(14).fontColor('#FF3B30')
          Text('点击重试')
            .fontSize(14).fontColor('#007AFF')
            .onClick(() => {
              this.isLoading = true
              this.aboutToAppear()    // 重新加载
            })
        }
      } else if (this.data.length === 0) {
        // 空数据状态
        Column({ space: 8 }) {
          Text('📭').fontSize(48)
          Text('暂无数据').fontSize(14).fontColor('#999')
        }
      } else {
        // 正常数据
        ForEach(this.data, (item: string) => {
          Text(item).fontSize(16).padding(12)
        }, (item: string) => item)
      }
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

15.4 JSON解析的安全写法

// ❌ 危险:直接解析未知数据
let obj = JSON.parse(userInput)    // 如果格式错误会崩溃

// ✅ 安全:用try/catch包裹
function safeJsonParse<T>(jsonStr: string, fallback: T): T {
  try {
    return JSON.parse(jsonStr) as T
  } catch (e) {
    hilog.warn(0x0000, 'JSON', '解析失败,使用默认值')
    return fallback
  }
}

// 使用
let config = safeJsonParse<UserConfig>(rawJson, { name: '默认', age: 0 })

15.5 组件生命周期中的常见坑

@Entry
@Component
struct CommonPitfalls {
  @State items: string[] = []
  private timerId: number = -1

  // 坑1:忘记清理定时器
  // ❌ 不清理 → 页面切换后定时器还在跑 → 内存泄漏
  startTimer(): void {
    this.timerId = setInterval(() => {
      // do something
    }, 1000)
  }

  // ✅ 必须在aboutToDisappear中清理
  aboutToDisappear(): void {
    clearInterval(this.timerId)    // 清理定时器
  }

  // 坑2:在aboutToAppear中直接访问UI尺寸
  // ❌ aboutToAppear时UI还没渲染,获取的尺寸都是0
  aboutToAppear(): void {
    // let width = this.getComponentWidth()  ← 获取不到,是0
  }

  // ✅ 要在onPageShow或用postTask延迟访问
  onPageShow(): void {
    // 此时UI已渲染完成,可以获取尺寸
  }

  // 坑3:ForEach没有提供key
  // ❌ 不传key → 列表更新时可能渲染异常
  // ForEach(this.items, (item: string) => { Text(item) })

  // ✅ 必须传第三个参数作为key
  // ForEach(this.items,
  //   (item: string) => { Text(item) },
  //   (item: string) => item    ← key
  // )

  build() {
    Column() {
      // ...
    }
  }
}

15.6 状态管理的常见错误

// 错误1:在子组件中修改@Prop
@Component
struct BadChild {
  @Prop count: number = 0

  build() {
    Text(`${this.count}`)
      .onClick(() => {
        // this.count++    ❌ 编译失败!@Prop是只读的
      })
  }
}

// 正确:用回调通知父组件
@Component
struct GoodChild {
  @Prop count: number = 0
  onIncrement?: () => void    // 回调函数

  build() {
    Text(`${this.count}`)
      .onClick(() => {
        this.onIncrement?.()    // ✅ 通知父组件去改
      })
  }
}

// 错误2:@Link传递时忘记加$符号
@Component
struct Parent {
  @State value: number = 0

  build() {
    // ChildComponent({ value: this.value })     ❌ 应该加$
    ChildComponent({ value: $value })             // ✅ 双向绑定
  }
}

// 错误3:在build()中声明变量
@Component
struct BadBuild {
  build() {
    // let x = 5           ❌ build()内不能声明变量
    // let arr = [1,2,3]   ❌ 也不行
    Column() {
      // 只能放置组件和调用@Builder
    }
  }
}

附录A ArkTS 铁律十条

编译不过=装不到手机,以下每条都能让你编译失败:

 1. 类型必声明    → 每个变量都要写 ":类型"
 2. 禁用 any      → let x: any       ❌ 直接编译失败
 3. build()不声明  → build(){ let x=1 ❌ 所有变量声明在外部
 4. 禁用索引签名   → {[key:string]:V} ❌ 编译失败
 5. 不用Button/List → 用Text+onClick / Scroll+ForEach代替
 6. ForEach必传key → 第三个参数不能省略
 7. @Prop是只读    → 在子组件中不能修改@Prop的值
 8. @Builder不声明  → 和build()一样,不能声明变量
 9. 组件用struct   → @Component 后面必须是 struct 不是 class
10. 根节点单唯一   → build()里面只能有一个根布局组件

附录B 常见错误速查

错误信息(英文) 中文意思 原因 解决
Type 'any' is not allowed 不允许any类型 用了any 改为具体类型
Attempted to assign to readonly property 试图给只读属性赋值 改了@Prop 改用@Link或通知父组件
Cannot find module '@kit.xxx' 找不到模块 SDK未安装 SDK Manager安装
install failed: 9568289 安装失败9568289 签名冲突 卸载手机旧版
hdc: no devices connected 没有连接设备 USB调试未开 开启USB调试+重启hdc
Cannot find name 'getContext' 找不到getContext 不在组件内 只能在struct内调用
'string' is not assignable to 'number' string不能赋给number 类型不匹配 修正类型
Expression is not callable 表达式不可调用 函数类型推断错 显式声明函数类型
Property does not exist on type 属性不存在 拼写错误或类型不对 检查属性名和类型
Index signature is not allowed 索引签名不允许 ArkTS不支持索引签名 用interface定义明确字段
Cannot find name 'this' 找不到this 在非组件方法中用了this 确保在struct内部调用
Module not found 模块未找到 import路径错误 检查import路径和SDK安装
Object is possibly null 对象可能为null 没有判空就访问属性 ?.if 判断
Type 'string' is not assignable to type 'number' 类型不匹配 赋值时类型不对 parseInt()as 断言

附录C 关键字中英对照总表

类型相关

英文 中文 说明
string 字符串 文字内容,如 "hello"
number 数字 整数+小数,如 42, 3.14
boolean 布尔值 真/假,true/false
null 空值 明确为空
undefined 未定义 声明了但没赋值
void 无返回值 函数不返回数据
Array<T> 数组 同类元素的集合
interface 接口 定义对象形状
type 类型别名 给类型起名
as 类型断言 告诉编译器”我确定是这个类型”
enum 枚举 一组命名常量

声明相关

英文 中文 说明
let 变量 声明可变变量
const 常量 声明不可变常量
function 函数 定义函数
class 定义类
extends 继承 子类继承父类
implements 实现 类实现接口
new 新建 创建实例
this 当前实例 指当前对象
static 静态 属于类而非实例
export 导出 导出给其他文件用
import 导入 从其他文件导入
default 默认 默认导出/导入

流程控制

英文 中文 说明
if 如果 条件判断
else 否则 条件不成立时
for 循环 计数循环
while 当…时 条件循环
return 返回 函数返回
try 尝试 异常处理开始
catch 捕获 捕获异常
finally 最终 无论成功失败都执行
throw 抛出 抛出异常
async 异步 声明异步函数
await 等待 等待异步完成

ArkUI装饰器

英文 中文 说明
@Entry 入口页 标记页面入口
@Component 组件 标记UI组件
@State 组件状态 组件本地状态
@Prop 父传子 父→子单向传递
@Link 双向绑定 父子双向同步
@Provide 全局提供 跨层级提供
@Consume 全局消费 跨层级读取
@StorageLink 持久化状态 全局+持久化
@Observed 可观察类 类的属性变化可观察
@ObjectLink 对象绑定 绑定@Observed对象
@Builder 构建器 复用构建逻辑
@CustomDialog 自定义弹窗 弹窗组件

常用API

英文 中文 说明
router 路由器 页面跳转
pushUrl 跳转 打开新页面
back 返回 返回上一页
getParams 获取参数 获取页面参数
preferences 偏好存储 键值对存储
relationalStore 关系数据库 SQLite
http.createHttp() HTTP客户端 发网络请求
JSON.parse JSON解析 字符串→对象
JSON.stringify JSON序列化 对象→字符串
hilog 日志 鸿蒙日志
getContext 获取上下文 获取App上下文
AppStorage 应用存储 全局内存存储

附录D 性能优化与最佳实践

D.1 列表渲染优化

// ❌ 错误:大量数据一次性渲染
ForEach(bigData, (item: object) => {
  // bigData有10000条 → 全部渲染 → 卡顿
})

// ✅ 正确:使用LazyForEach懒加载
class MyDataSource extends BasicDataSource {
  private dataArray: string[] = []

  totalCount(): number { return this.dataArray.length }
  getData(index: number): string { return this.dataArray[index] }

  addData(data: string): void {
    this.dataArray.push(data)
    this.notifyDataReload()
  }
}

// 在build中使用
List() {
  LazyForEach(this.dataSource, (item: string) => {
    ListItem() {
      Text(item).fontSize(16).padding(12)
    }
  }, (item: string, index: number) => `${index}`)
}

D.2 图片优化

// ❌ 加载大图不做处理
Image('https://example.com/4k-image.jpg')
  .width(100).height(100)
  // 原图4000x3000,内存占用巨大

// ✅ 指定尺寸 + 压缩 + 缓存
Image('https://example.com/4k-image.jpg')
  .width(100).height(100)
  .objectFit(ImageFit.Cover)         // 裁剪而非拉伸
  .interpolation(ImageInterpolation.Low)  // 低质量插值,省内存
  .syncLoad(false)                    // 异步加载,不阻塞UI

D.3 状态管理优化

// ❌ 频繁更新状态导致多次build
@State items: string[] = []

addItem(): void {
  for (let i = 0; i < 100; i++) {
    this.items.push(`item${i}`)    // 每次push都触发build → 100次渲染
  }
}

// ✅ 批量更新后一次性触发
addItem(): void {
  let newItems: string[] = []
  for (let i = 0; i < 100; i++) {
    newItems.push(`item${i}`)
  }
  this.items = [...this.items, ...newItems]    // 一次赋值 → 1次build
}

D.4 动画优化

// ❌ 用定时器模拟动画(不流畅)
setInterval(() => {
  this.offset += 1    // 不跟随系统刷新率
}, 16)

// ✅ 用系统动画API(60fps)
animateTo({
  duration: 300,
  curve: Curve.EaseInOut,
  onFinish: () => { console.log('动画完成') }
}, () => {
  this.offset = 200    // 系统自动插值,流畅60fps
})

D.5 内存管理

@Component
struct MemoryOptimized {
  private largeData: LargeObject | null = null

  aboutToAppear(): void {
    this.largeData = loadLargeData()
  }

  // ✅ 页面销毁时释放大对象
  aboutToDisappear(): void {
    this.largeData = null    // 释放内存
  }

  // ✅ 网络请求在页面销毁时取消
  private request: http.HttpRequest | null = null

  async loadData(): Promise<void> {
    this.request = http.createHttp()
    try {
      // ...
    } finally {
      this.request.destroy()
      this.request = null
    }
  }

  aboutToDisappear(): void {
    this.request?.destroy()
  }

  build() { /* ... */ }
}

D.6 代码组织最佳实践

推荐的项目结构:

src/
├── entryability/         ← 入口,只管加载首页
├── pages/                ← 页面(按功能划分)
│   ├── Index.ets         ←   启动页/登录
│   ├── HomePage.ets      ←   首页
│   ├── DetailPage.ets    ←   详情页
│   └── SettingsPage.ets  ←   设置页
├── components/           ← 可复用组件(通用UI)
│   ├── MyButton.ets
│   ├── MyCard.ets
│   └── LoadingView.ets
├── models/               ← 数据模型(纯数据结构)
│   ├── User.ets
│   └── Product.ets
├── services/             ← 业务逻辑(数据库/网络)
│   ├── DatabaseHelper.ets
│   └── ApiService.ets
└── utils/                ← 工具类(通用方法)
    ├── Constants.ets
    ├── DateUtil.ets
    └── StringUtil.ets

第16章 真实项目实战:杏林中医题库App

本章基于真实上架项目「杏林中医」拆解,展示一个完整鸿蒙App的架构设计和核心代码。

16.1 项目架构全景

┌──────────────────────────────────────────────────────────────┐
│                    🌿 杏林中医 App 架构                          │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌────────────────────────────────────────────────────┐     │
│  │                   UI 层 (pages/)                    │     │
│  │  ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐  │     │
│  │  │ Index  │  │WebView │  │Settings│  │  ...   │  │     │
│  │  │ (主页面)│  │ (网页) │  │ (设置) │  │        │  │     │
│  │  └────────┘  └────────┘  └────────┘  └────────┘  │     │
│  └────────────────────────────────────────────────────┘     │
│                           ↓                                  │
│  ┌────────────────────────────────────────────────────┐     │
│  │               数据层 (store/ + model/)              │     │
│  │  ┌──────────────┐  ┌──────────────┐                │     │
│  │  │  QuizStore   │  │  DataModel   │                │     │
│  │  │  (数据持久化) │  │  (数据结构)   │                │     │
│  │  └──────────────┘  └──────────────┘                │     │
│  └────────────────────────────────────────────────────┘     │
│                           ↓                                  │
│  ┌────────────────────────────────────────────────────┐     │
│  │               数据源 (model/Constants*.ets)         │     │
│  │  ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐  │     │
│  │  │ 辨证   │  │ 穴位   │  │ 四诊   │  │ 中药   │  │     │
│  │  │ 48病种 │  │ 十四经 │  │ 望闻问 │  │ 方剂   │  │     │
│  │  └────────┘  └────────┘  └────────┘  └────────┘  │     │
│  └────────────────────────────────────────────────────┘     │
│                                                              │
│  核心功能:                                                    │
│  ✅ 多模块学习(辨证/穴位/四诊/中药/自定义)                    │
│  ✅ 智能出题(按范围/题型/题数配置)                            │
│  ✅ 全局搜索(跨模块模糊匹配)                                 │
│  ✅ 数据备份导出                                               │
│  ✅ 管理模式(增删改查)                                        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

16.2 数据模型设计(model/DataModel.ets)

// ===== 真实项目中的数据模型设计 =====
// 关键设计:接口复用 + 可选字段 + 扩展性

/** 病种下证型条目 */
export interface BinganType {
  name: string              // 证型名称(如:风寒犯肺)
  major?: boolean           // 是否为大类标题(用于UI分组显示)
  diagnosis: string         // 主症
  patho: string             // 病机
  treat: string             // 治法
  formula: string           // 方剂
  herbs: string             // 方药
  image?: string            // 可选:舌象/脉象图片(base64)
}

/** 辨证论治 — 病种(分组) */
export interface BinganDisease {
  id: string               // 唯一标识(用于ForEach key)
  name: string             // 病种名称(如:感冒)
  category: string         // 所属分类(如:肺系病证)
  types: BinganType[]      // 证型列表(含major占位标题)
}

/** 穴位条目 */
export interface XueweiPoint {
  name: string             // 穴位名称(如:合谷)
  code: string             // 穴位代码(如:LI4)
  cat: string              // 分类(如:手阳明大肠经)
  location: string         // 定位
  indications: string      // 主治
  method: string           // 刺灸法
  image?: string           // 穴位图
}

/** 经络穴位分组 */
export interface XueweiGroup {
  id: string
  name: string             // 经络名称(如:手太阴肺经)
  cat2: string             // 二级分类(十四经穴/经外奇穴)
  points: XueweiPoint[]
}

/** 四诊/中药条目(共用结构——减少重复定义) */
export interface SizhenItem {
  name: string
  manifest: string         // 表现/内容
  meaning: string          // 意义/说明
  image?: string
}

/** 四诊/中药分组(共用结构) */
export interface SizhenGroup {
  id: string
  name: string
  cat: string
  fields?: FieldDef[]      // 自定义字段标签
  items: SizhenItem[]
}

/** 一道测验题 */
export interface TestQuestion {
  q: string                // 题干
  a: string                // 正确答案
  name: string             // 来源条目名
  group: string            // 来源分组名
  field: string            // 考察字段类型
  hint: string             // 辅助提示
}

/** 用户自定义模块(扩展性设计) */
export interface CustomModule {
  id: string
  name: string
  icon: string
  color: string
  data: BinganDisease[]    // 复用病种结构
  fields?: FieldDef[]      // 自定义字段标签
}

设计亮点: | 技巧 | 说明 | |——|——| | 接口复用 | SizhenGroup同时用于四诊和中药,减少重复代码 | | 可选字段 | image?fields?major? 保证扩展性 | | 统一ID | 每个数据项都有id,用于ForEach key和数据定位 | | 嵌套结构 | Disease→Type[]→字段,清晰的一对多关系 |

16.3 数据持久化(store/QuizStore.ets)

// ===== 真实项目中的数据存储方案 =====
// 策略:内置数据(常量) + 用户数据(Preferences) 分离

import { preferences } from '@kit.ArkData'

const STORE_NAME = 'xinglin_data'

export class Store {
  private _ctx: common.Context | null = null

  setContext(ctx: common.Context): void { this._ctx = ctx }

  // ===== 初始化:从内置常量加载 =====
  initBingan(): BinganDisease[] {
    const bb: BinganDisease[] = []
    for (const d of BINGAN_DATA) bb.push(d)       // 第一批数据
    for (const d of BINGAN_DATA_PART2) bb.push(d)  // 第二批数据
    return this.dedupBingan(bb)                    // 去重
  }

  // ===== 保存:序列化为JSON存入Preferences =====
  async save(bingan: BinganDisease[], xuewei: XueweiGroup[],
             sizhen: SizhenGroup[], zhongyao: SizhenGroup[],
             custom: CustomModule[]): Promise<void> {
    if (!this._ctx) return
    try {
      const prefs = await preferences.getPreferences(this._ctx, STORE_NAME)
      await prefs.put('custom', JSON.stringify(custom))    // 自定义模块
      await prefs.put('bingan', JSON.stringify(bingan))    // 辨证数据
      await prefs.put('xuewei', JSON.stringify(xuewei))    // 穴位数据
      await prefs.put('sizhen', JSON.stringify(sizhen))    // 四诊数据
      await prefs.put('zhongyao', JSON.stringify(zhongyao)) // 中药数据
      await prefs.flush()                                   // 刷盘持久化
    } catch (e) {
      console.error('save failed: ' + JSON.stringify(e))
    }
  }

  // ===== 加载:反序列化JSON,带回调 =====
  async load(callback: (bingan: BinganDisease[], xuewei: XueweiGroup[],
                        sizhen: SizhenGroup[], zhongyao: SizhenGroup[],
                        custom: CustomModule[]) => void): Promise<void> {
    if (!this._ctx) return
    try {
      const prefs = await preferences.getPreferences(this._ctx, STORE_NAME)
      let bingan: BinganDisease[] = []
      let xuewei: XueweiGroup[] = []
      let sizhen: SizhenGroup[] = []
      let zhongyao: SizhenGroup[] = []
      let custom: CustomModule[] = []

      // 安全读取:有值才解析
      const cv = await prefs.get('custom', '')
      if (cv) custom = JSON.parse(cv as string)
      const bv = await prefs.get('bingan', '')
      if (bv) bingan = JSON.parse(bv as string)
      const xv = await prefs.get('xuewei', '')
      if (xv) xuewei = JSON.parse(xv as string)
      const sv = await prefs.get('sizhen', '')
      if (sv) sizhen = JSON.parse(sv as string)
      const zv = await prefs.get('zhongyao', '')
      if (zv) zhongyao = JSON.parse(zv as string)

      // 回调返回去重后的数据
      callback(
        this.dedupBingan(bingan),
        this.dedupXuewei(xuewei),
        this.dedupSizhen(sizhen),
        this.dedupZhongyao(zhongyao),
        this.dedupCustom(custom)
      )
    } catch (e) {
      console.error('load failed: ' + JSON.stringify(e))
    }
  }

  // ===== 去重:防止重复数据 =====
  dedupBingan(arr: BinganDisease[]): BinganDisease[] {
    const seen = new Set<string>()
    return arr.filter((d: BinganDisease) => {
      if (seen.has(d.id)) return false
      seen.add(d.id)
      return true
    })
  }
}

设计亮点: | 技巧 | 说明 | |——|——| | 内置+用户数据分离 | 常量数据编译时内置,用户修改存Preferences | | 回调模式 | load(callback) 避免async/await在组件中的复杂性 | | 安全读取 | if (cv) 判断再JSON.parse,防止空值崩溃 | | 去重机制 | Set去重,防止用户操作导致数据重复 |

16.4 全局搜索实现

// ===== 真实项目中的全局搜索 =====
// 特点:跨模块搜索、高亮匹配、结果限制

interface SearchHit {
  tab: string      // 来源模块(bingan/xuewei/sizhen/zhongyao)
  gid: string      // 分组ID(用于跳转定位)
  title: string    // 显示标题(带图标)
  sub: string      // 匹配内容摘要
}

private doSearch(): void {
  const q = this.skwd.trim().toLowerCase()
  if (!q) { this.sres = []; return }

  const hits: SearchHit[] = []

  // 搜索辨证模块
  for (const d of this.bingan) {
    // 匹配病种名
    if (d.name.toLowerCase().indexOf(q) >= 0) {
      hits.push({ tab: 'bingan', gid: d.id, title: '📋 ' + d.name, sub: d.category })
    }
    // 匹配证型内容
    for (const t of d.types) {
      if (t.major) continue
      const fields = [t.name, t.diagnosis, t.patho, t.treat, t.formula, t.herbs]
      for (const f of fields) {
        if (f && f.toLowerCase().indexOf(q) >= 0) {
          hits.push({
            tab: 'bingan',
            gid: d.id,
            title: '📋 ' + d.name + ' → ' + t.name,
            sub: this.pick(f, q, 30)  // 截取匹配片段
          })
          break  // 每个证型只匹配一次
        }
      }
    }
  }

  // 搜索穴位模块
  for (const g of this.xuewei) {
    if (g.name.toLowerCase().indexOf(q) >= 0) {
      hits.push({ tab: 'xuewei', gid: g.id, title: '💉 ' + g.name, sub: g.points.length + '穴' })
    }
    for (const p of g.points) {
      const fields = [p.name, p.code, p.location, p.indications, p.method]
      for (const f of fields) {
        if (f && f.toLowerCase().indexOf(q) >= 0) {
          hits.push({
            tab: 'xuewei',
            gid: g.id,
            title: '💉 ' + g.name + ' → ' + p.name,
            sub: this.pick(f, q, 30)
          })
          break
        }
      }
    }
  }

  // 搜索四诊/中药模块(结构相同,代码复用)
  for (const g of this.sizhen) {
    for (const it of g.items) {
      const fields = [it.name, it.manifest, it.meaning]
      for (const f of fields) {
        if (f && f.toLowerCase().indexOf(q) >= 0) {
          hits.push({
            tab: 'sizhen',
            gid: g.id,
            title: '🔍 ' + g.name + ' → ' + it.name,
            sub: this.pick(f, q, 30)
          })
          break
        }
      }
    }
  }

  this.sres = hits.slice(0, 50)  // 限制结果数量,防止卡顿
}

// 截取匹配片段(带省略号)
private pick(s: string, q: string, n: number): string {
  const i = s.toLowerCase().indexOf(q)
  if (i < 0) return s.substring(0, n)
  let st = Math.max(0, i - (n - q.length) / 2)
  let en = st + n
  if (en > s.length) { st = Math.max(0, s.length - n); en = s.length }
  return (st > 0 ? '…' : '') + s.substring(Math.floor(st), en) + (en < s.length ? '…' : '')
}

16.5 智能出题系统

// ===== 真实项目中的出题逻辑 =====
// 特点:按范围/题型/题数灵活配置,支持单题/多题模式

private gen(): void {
  let qq: TestQuestion[] = []
  const tf = this.qfield   // 题型:disease/patho/treat/formula/herbs/all
  const td = this.qtopic   // 范围:特定病种名 或 空(全部)

  if (this.tab === 'bingan' || this.isCustom()) {
    // 辨证模块出题
    const src = this.isCustom() ? (this.getCustom()?.data || []) : this.bingan
    const dd = td ? src.filter((d: BinganDisease) => d.name === td) : src

    if (tf === 'disease') {
      // 题型:病种→证型列表
      for (const d of dd) {
        const ns: string[] = []
        for (const tp of d.types) if (!tp.major) ns.push(tp.name)
        if (ns.length > 0) {
          qq.push({
            q: d.name + '有哪些证型?共' + ns.length + '个',
            a: ns.join('、'),
            name: d.name,
            group: d.name,
            field: 'disease',
            hint: d.category
          })
        }
      }
    } else {
      // 题型:证型→病机/治法/方剂/方药
      const fs = tf === 'all' ? ['p', 't', 'f', 'h'] : [tf]
      for (const d of dd) {
        for (const tp of d.types) {
          if (tp.major) continue
          for (const f of fs) {
            const v = f === 'p' ? tp.patho : f === 't' ? tp.treat
                    : f === 'f' ? tp.formula : tp.herbs
            if (!v) continue
            const fl = this.fieldLabel(f)
            qq.push({
              q: tp.name + '|' + d.name + '|' + fl,
              a: v,
              name: tp.name,
              group: d.name,
              field: f,
              hint: tp.diagnosis.replace('主症:', '')
            })
          }
        }
      }
    }
  } else if (this.tab === 'xuewei') {
    // 穴位模块出题
    const xx = td ? this.xuewei.filter((g: XueweiGroup) => g.name === td) : this.xuewei
    const fs = tf === 'all' ? ['location', 'indications', 'method'] : [tf]
    for (const g of xx) {
      for (const p of g.points) {
        for (const f of fs) {
          const v = f === 'location' ? p.location
                  : f === 'indications' ? p.indications : p.method
          if (!v) continue
          const fl = f === 'location' ? '定位'
                   : f === 'indications' ? '主治' : '刺灸法'
          qq.push({
            q: p.name + (p.code ? '(' + p.code + ')' : '') + '的' + fl + '是?',
            a: v,
            name: p.name,
            group: g.name,
            field: f,
            hint: '定位:' + p.location + ';主治:' + p.indications
          })
        }
      }
    }
  }

  // 随机打乱(Fisher-Yates算法)
  if (qq.length > 0) {
    for (let i = qq.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1))
      const t = qq[i]; qq[i] = qq[j]; qq[j] = t
    }
  }

  // 截取指定题数
  const c = Math.min(this.qcnt, qq.length)
  this.qs = qq.slice(0, c)
  this.qa = new Array<string>(c).fill('')
  this.qr = new Array<boolean>(c).fill(false)
  this.qc = new Array<boolean>(c).fill(false)
  this.qscore = 0
  this.qidx = 0
}

16.6 答题判分逻辑

// ===== 真实项目中的判分逻辑 =====
// 特点:模糊匹配、支持部分正确、实时计分

private chk(idx: number, showOnly: boolean): void {
  // showOnly=true 时只显示答案,不判分
  if (!showOnly) {
    const u = this.qa[idx].trim()
    if (!u) return  // 空答案不判
  }

  const ca = this.qs[idx].a     // 正确答案
  const ua = this.qa[idx].trim() // 用户答案

  // 判分逻辑:精确匹配 或 包含匹配 或 关键词匹配
  const ok = !showOnly && (
    ua === ca ||                              // 精确匹配
    ca.indexOf(ua) >= 0 ||                    // 答案包含用户输入
    ua.split(/[、,,\s]+/)                     // 按分隔符拆分
      .filter((t: string) => t.length > 0 && ca.indexOf(t) >= 0)
      .length > 0                             // 至少一个关键词匹配
  )

  const wo = this.qc[idx] && this.qr[idx]    // 之前是否正确
  const wasChecked = this.qc[idx]             // 之前是否已判

  // 更新状态(创建新数组触发UI刷新)
  const nr = [...this.qr]; nr[idx] = ok
  const nc = [...this.qc]; nc[idx] = true
  const np = [...this.qp]; np[idx] = showOnly
  this.qr = nr; this.qc = nc; this.qp = np

  // 实时更新得分
  if (!wo && ok) this.qscore++       // 从错变对,加分
  else if (wo && !ok) this.qscore--  // 从对变错,减分
  if (!wasChecked) this._acnt++      // 已答计数
}

第17章 鸿蒙游戏开发入门

鸿蒙也能做游戏!本章介绍用ArkUI开发休闲小游戏的技巧。

17.1 游戏开发基础:Canvas画布

// Canvas 是鸿蒙中绘制自定义图形的核心组件

@Entry
@Component
struct CanvasDemo {
  @State x: number = 100        // 球的X坐标
  @State y: number = 100        // 球的Y坐标
  @State radius: number = 20    // 球的半径
  private context: CanvasRenderingContext2D | null = null

  aboutToAppear(): void {
    this.context = new CanvasRenderingContext2D(new RenderingContextSettings(true))
  }

  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height(400)
        .backgroundColor('#1a1a2e')
        .onReady(() => {
          this.draw()
        })

      Row({ space: 16 }) {
        Text('←').fontSize(24).width(60).height(60)
          .textAlign(TextAlign.Center).backgroundColor('#333').borderRadius(30)
          .onClick(() => { this.x -= 20; this.draw() })
        Text('→').fontSize(24).width(60).height(60)
          .textAlign(TextAlign.Center).backgroundColor('#333').borderRadius(30)
          .onClick(() => { this.x += 20; this.draw() })
        Text('↑').fontSize(24).width(60).height(60)
          .textAlign(TextAlign.Center).backgroundColor('#333').borderRadius(30)
          .onClick(() => { this.y -= 20; this.draw() })
        Text('↓').fontSize(24).width(60).height(60)
          .textAlign(TextAlign.Center).backgroundColor('#333').borderRadius(30)
          .onClick(() => { this.y += 20; this.draw() })
      }
      .margin({ top: 20 })
    }
    .width('100%').height('100%')
    .backgroundColor('#0f0f23')
  }

  private draw(): void {
    if (!this.context) return
    const ctx = this.context

    // 清空画布
    ctx.clearRect(0, 0, 400, 400)

    // 画背景网格
    ctx.strokeStyle = '#333'
    ctx.lineWidth = 1
    for (let i = 0; i < 400; i += 40) {
      ctx.beginPath()
      ctx.moveTo(i, 0); ctx.lineTo(i, 400)
      ctx.stroke()
      ctx.beginPath()
      ctx.moveTo(0, i); ctx.lineTo(400, i)
      ctx.stroke()
    }

    // 画球
    ctx.beginPath()
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
    ctx.fillStyle = '#e94560'
    ctx.fill()

    // 画光晕
    const gradient = ctx.createRadialGradient(
      this.x, this.y, this.radius * 0.5,
      this.x, this.y, this.radius * 2
    )
    gradient.addColorStop(0, 'rgba(233, 69, 96, 0.4)')
    gradient.addColorStop(1, 'rgba(233, 69, 96, 0)')
    ctx.beginPath()
    ctx.arc(this.x, this.y, this.radius * 2, 0, Math.PI * 2)
    ctx.fillStyle = gradient
    ctx.fill()
  }
}

17.2 实战游戏1:打地鼠

@Entry
@Component
struct WhackAMole {
  @State moles: boolean[] = new Array(9).fill(false)  // 9个地鼠洞
  @State score: number = 0
  @State timeLeft: number = 30
  @State isPlaying: boolean = false
  @State combo: number = 0
  @State bestScore: number = 0
  private timerId: number = -1
  private moleTimerId: number = -1

  startGame(): void {
    this.score = 0
    this.combo = 0
    this.timeLeft = 30
    this.isPlaying = true
    this.moles = new Array(9).fill(false)

    // 倒计时
    this.timerId = setInterval(() => {
      this.timeLeft--
      if (this.timeLeft <= 0) {
        this.endGame()
      }
    }, 1000)

    // 随机冒出地鼠
    this.moleTimerId = setInterval(() => {
      // 先全部隐藏
      this.moles = new Array(9).fill(false)
      // 随机显示1-2个
      const count = Math.random() > 0.7 ? 2 : 1
      for (let i = 0; i < count; i++) {
        const idx = Math.floor(Math.random() * 9)
        this.moles[idx] = true
      }
    }, 800)
  }

  endGame(): void {
    clearInterval(this.timerId)
    clearInterval(this.moleTimerId)
    this.isPlaying = false
    this.moles = new Array(9).fill(false)
    if (this.score > this.bestScore) {
      this.bestScore = this.score
    }
  }

  whack(index: number): void {
    if (!this.isPlaying) return
    if (this.moles[index]) {
      // 打中了!
      this.score += 10 + this.combo * 2  // 连击加分
      this.combo++
      this.moles[index] = false

      // 震动反馈
      // vibrator.vibrate()  // 需要权限
    } else {
      // 打空了,连击重置
      this.combo = 0
      this.score = Math.max(0, this.score - 5)
    }
  }

  aboutToDisappear(): void {
    clearInterval(this.timerId)
    clearInterval(this.moleTimerId)
  }

  build() {
    Column({ space: 16 }) {
      // 标题 + 分数
      Row() {
        Text('🔨 打地鼠').fontSize(24).fontWeight(FontWeight.Bold)
        Text(`⏱ ${this.timeLeft}s`).fontSize(18).fontColor('#FF5722')
          .layoutWeight(1).textAlign(TextAlign.End)
      }
      .width('100%').padding(16)

      // 得分显示
      Row({ space: 24 }) {
        Column({ space: 4 }) {
          Text(`${this.score}`).fontSize(36).fontWeight(FontWeight.Bold).fontColor('#FF5722')
          Text('得分').fontSize(12).fontColor('#999')
        }
        Column({ space: 4 }) {
          Text(`${this.combo}`).fontSize(36).fontWeight(FontWeight.Bold).fontColor('#4CAF50')
          Text('连击').fontSize(12).fontColor('#999')
        }
        Column({ space: 4 }) {
          Text(`${this.bestScore}`).fontSize(36).fontWeight(FontWeight.Bold).fontColor('#2196F3')
          Text('最高').fontSize(12).fontColor('#999')
        }
      }

      // 游戏区域 3x3 网格
      Grid() {
        ForEach(this.moles, (isUp: boolean, index: number) => {
          GridItem() {
            Stack({ alignContent: Alignment.Center }) {
              // 洞
              Text('🕳️')
                .fontSize(48)
              // 地鼠(条件显示)
              if (isUp) {
                Text('🐹')
                  .fontSize(40)
                  .transition({ scale: { x: 0.1, y: 0.1 }, opacity: 0.1 })
              }
            }
            .width(90).height(90)
            .backgroundColor(isUp ? '#FFF3E0' : '#F5F5F5')
            .borderRadius(16)
            .onClick(() => this.whack(index))
          }
        }, (isUp: boolean, index: number) => `${index}`)
      }
      .columnsTemplate('1fr 1fr 1fr')
      .rowsGap(12).columnsGap(12)
      .width(300)

      // 开始/重新开始按钮
      Text(this.isPlaying ? '游戏进行中...' : (this.timeLeft < 30 ? '再来一局' : '开始游戏'))
        .fontSize(18).fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .width(200).height(50)
        .textAlign(TextAlign.Center)
        .backgroundColor(this.isPlaying ? '#9E9E9E' : '#FF5722')
        .borderRadius(25)
        .onClick(() => {
          if (!this.isPlaying) this.startGame()
        })

      if (!this.isPlaying && this.timeLeft < 30) {
        Text(this.score >= 100 ? '🏆 太厉害了!' :
             this.score >= 50 ? '👍 不错!继续加油!' : '💪 多练习就好了!')
          .fontSize(16).fontColor('#666')
      }
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#FAFAFA')
  }
}

17.3 实战游戏2:2048

@Entry
@Component
struct Game2048 {
  @State grid: number[][] = this.initGrid()
  @State score: number = 0
  @State bestScore: number = 0
  @State gameOver: boolean = false
  @State won: boolean = false

  initGrid(): number[][] {
    let g: number[][] = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]]
    this.addRandom(g)
    this.addRandom(g)
    return g
  }

  addRandom(g: number[][]): void {
    let empty: number[][] = []
    for (let r = 0; r < 4; r++)
      for (let c = 0; c < 4; c++)
        if (g[r][c] === 0) empty.push([r, c])
    if (empty.length === 0) return
    let [r, c] = empty[Math.floor(Math.random() * empty.length)]
    g[r][c] = Math.random() < 0.9 ? 2 : 4
  }

  getColor(value: number): string {
    const colors: Record<number, string> = {
      0: '#CDC1B4', 2: '#EEE4DA', 4: '#EDE0C8',
      8: '#F2B179', 16: '#F59563', 32: '#F67C5F',
      64: '#F65E3B', 128: '#EDCF72', 256: '#EDCC61',
      512: '#EDC850', 1024: '#EDC53F', 2048: '#EDC22E'
    }
    return colors[value] || '#3C3A32'
  }

  getFontColor(value: number): string {
    return value <= 4 ? '#776E65' : '#FFFFFF'
  }

  move(direction: string): void {
    if (this.gameOver) return
    let moved = false
    let g = this.grid.map((row: number[]) => [...row])

    if (direction === 'left') {
      for (let r = 0; r < 4; r++) {
        let row = g[r].filter((v: number) => v !== 0)
        for (let i = 0; i < row.length - 1; i++) {
          if (row[i] === row[i + 1]) {
            row[i] *= 2
            this.score += row[i]
            row.splice(i + 1, 1)
            if (row[i] === 2048) this.won = true
          }
        }
        while (row.length < 4) row.push(0)
        if (g[r].join(',') !== row.join(',')) moved = true
        g[r] = row
      }
    } else if (direction === 'right') {
      for (let r = 0; r < 4; r++) {
        let row = g[r].filter((v: number) => v !== 0).reverse()
        for (let i = 0; i < row.length - 1; i++) {
          if (row[i] === row[i + 1]) {
            row[i] *= 2
            this.score += row[i]
            row.splice(i + 1, 1)
            if (row[i] === 2048) this.won = true
          }
        }
        while (row.length < 4) row.push(0)
        row.reverse()
        if (g[r].join(',') !== row.join(',')) moved = true
        g[r] = row
      }
    } else if (direction === 'up') {
      for (let c = 0; c < 4; c++) {
        let col: number[] = []
        for (let r = 0; r < 4; r++) if (g[r][c] !== 0) col.push(g[r][c])
        for (let i = 0; i < col.length - 1; i++) {
          if (col[i] === col[i + 1]) {
            col[i] *= 2
            this.score += col[i]
            col.splice(i + 1, 1)
            if (col[i] === 2048) this.won = true
          }
        }
        while (col.length < 4) col.push(0)
        for (let r = 0; r < 4; r++) {
          if (g[r][c] !== col[r]) moved = true
          g[r][c] = col[r]
        }
      }
    } else if (direction === 'down') {
      for (let c = 0; c < 4; c++) {
        let col: number[] = []
        for (let r = 3; r >= 0; r--) if (g[r][c] !== 0) col.push(g[r][c])
        for (let i = 0; i < col.length - 1; i++) {
          if (col[i] === col[i + 1]) {
            col[i] *= 2
            this.score += col[i]
            col.splice(i + 1, 1)
            if (col[i] === 2048) this.won = true
          }
        }
        while (col.length < 4) col.push(0)
        col.reverse()
        for (let r = 0; r < 4; r++) {
          if (g[r][c] !== col[r]) moved = true
          g[r][c] = col[r]
        }
      }
    }

    if (moved) {
      this.addRandom(g)
      this.grid = g
      if (this.score > this.bestScore) this.bestScore = this.score
      // 检查游戏结束
      if (!this.canMove(g)) this.gameOver = true
    }
  }

  canMove(g: number[][]): boolean {
    for (let r = 0; r < 4; r++)
      for (let c = 0; c < 4; c++) {
        if (g[r][c] === 0) return true
        if (c < 3 && g[r][c] === g[r][c + 1]) return true
        if (r < 3 && g[r][c] === g[r + 1][c]) return true
      }
    return false
  }

  build() {
    Column({ space: 16 }) {
      // 标题
      Row() {
        Text('2048').fontSize(36).fontWeight(FontWeight.Bold).fontColor('#776E65')
        Column({ space: 4 }) {
          Text(`得分`).fontSize(12).fontColor('#999')
          Text(`${this.score}`).fontSize(20).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text(`最高`).fontSize(12).fontColor('#999')
          Text(`${this.bestScore}`).fontSize(20).fontWeight(FontWeight.Bold)
        }
      }
      .width('100%').padding(16)

      // 游戏说明
      Text('← → ↑ ↓ 滑动合并相同数字')
        .fontSize(13).fontColor('#999')

      // 游戏网格
      Column({ space: 8 }) {
        ForEach(this.grid, (row: number[], r: number) => {
          Row({ space: 8 }) {
            ForEach(row, (value: number, c: number) => {
              Text(value === 0 ? '' : `${value}`)
                .fontSize(value >= 1024 ? 18 : value >= 128 ? 22 : 28)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.getFontColor(value))
                .width(70).height(70)
                .textAlign(TextAlign.Center)
                .backgroundColor(this.getColor(value))
                .borderRadius(8)
            }, (value: number, c: number) => `${r}_${c}`)
          }
        }, (row: number[], r: number) => `${r}`)
      }
      .padding(12).backgroundColor('#BBADA0').borderRadius(12)

      // 方向按钮
      Column({ space: 8 }) {
        Text('↑').fontSize(24).width(70).height(50)
          .textAlign(TextAlign.Center).backgroundColor('#8F7A66').borderRadius(8)
          .fontColor(Color.White)
          .onClick(() => this.move('up'))
        Row({ space: 8 }) {
          Text('←').fontSize(24).width(70).height(50)
            .textAlign(TextAlign.Center).backgroundColor('#8F7A66').borderRadius(8)
            .fontColor(Color.White)
            .onClick(() => this.move('left'))
          Text('→').fontSize(24).width(70).height(50)
            .textAlign(TextAlign.Center).backgroundColor('#8F7A66').borderRadius(8)
            .fontColor(Color.White)
            .onClick(() => this.move('right'))
        }
        Text('↓').fontSize(24).width(70).height(50)
          .textAlign(TextAlign.Center).backgroundColor('#8F7A66').borderRadius(8)
          .fontColor(Color.White)
          .onClick(() => this.move('down'))
      }

      // 重新开始
      Text(this.gameOver ? '游戏结束!重新开始' : '新游戏')
        .fontSize(16).fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .width(150).height(44)
        .textAlign(TextAlign.Center)
        .backgroundColor('#8F7A66').borderRadius(8)
        .onClick(() => {
          this.grid = this.initGrid()
          this.score = 0
          this.gameOver = false
          this.won = false
        })

      if (this.won) {
        Text('🎉 恭喜达到2048!').fontSize(18).fontColor('#FF9800').fontWeight(FontWeight.Bold)
      }
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#FAF8EF')
  }
}

17.4 实战游戏3:贪吃蛇

@Entry
@Component
struct SnakeGame {
  @State snake: number[][] = [[5, 5]]     // 蛇身坐标 [[x,y], ...]
  @State food: number[] = [10, 10]        // 食物坐标
  @State direction: string = 'right'      // 当前方向
  @State isPlaying: boolean = false
  @State score: number = 0
  @State speed: number = 200              // 速度(毫秒)
  private timerId: number = -1
  private gridSize: number = 20           // 网格大小
  private cellSize: number = 16           // 每格像素

  startGame(): void {
    this.snake = [[5, 5]]
    this.direction = 'right'
    this.score = 0
    this.speed = 200
    this.isPlaying = true
    this.spawnFood()
    this.startTimer()
  }

  startTimer(): void {
    clearInterval(this.timerId)
    this.timerId = setInterval(() => this.tick(), this.speed)
  }

  spawnFood(): void {
    let pos: number[]
    do {
      pos = [
        Math.floor(Math.random() * this.gridSize),
        Math.floor(Math.random() * this.gridSize)
      ]
    } while (this.snake.some((s: number[]) => s[0] === pos[0] && s[1] === pos[1]))
    this.food = pos
  }

  tick(): void {
    if (!this.isPlaying) return

    const head = [...this.snake[0]]
    if (this.direction === 'up') head[1]--
    else if (this.direction === 'down') head[1]++
    else if (this.direction === 'left') head[0]--
    else if (this.direction === 'right') head[0]++

    // 撞墙检测
    if (head[0] < 0 || head[0] >= this.gridSize ||
        head[1] < 0 || head[1] >= this.gridSize) {
      this.gameOver()
      return
    }

    // 撞自己检测
    if (this.snake.some((s: number[]) => s[0] === head[0] && s[1] === head[1])) {
      this.gameOver()
      return
    }

    const newSnake = [head, ...this.snake]

    // 吃食物
    if (head[0] === this.food[0] && head[1] === this.food[1]) {
      this.score += 10
      this.spawnFood()
      // 加速
      if (this.speed > 80) {
        this.speed -= 5
        this.startTimer()
      }
    } else {
      newSnake.pop()  // 没吃到就移除尾巴
    }

    this.snake = newSnake
  }

  gameOver(): void {
    clearInterval(this.timerId)
    this.isPlaying = false
  }

  changeDirection(dir: string): void {
    // 防止180度转向
    if (this.direction === 'up' && dir === 'down') return
    if (this.direction === 'down' && dir === 'up') return
    if (this.direction === 'left' && dir === 'right') return
    if (this.direction === 'right' && dir === 'left') return
    this.direction = dir
  }

  aboutToDisappear(): void {
    clearInterval(this.timerId)
  }

  build() {
    Column({ space: 12 }) {
      // 标题 + 分数
      Row() {
        Text('🐍 贪吃蛇').fontSize(24).fontWeight(FontWeight.Bold)
        Text(`得分: ${this.score}`).fontSize(18).fontColor('#4CAF50')
          .layoutWeight(1).textAlign(TextAlign.End)
      }
      .width('100%').padding(16)

      // 游戏区域
      Stack() {
        // 背景网格
        Canvas(new CanvasRenderingContext2D(new RenderingContextSettings(true)))
          .width(this.gridSize * this.cellSize)
          .height(this.gridSize * this.cellSize)
          .backgroundColor('#E8F5E9')
          .onReady(() => { this.drawGame() })
      }
      .width(this.gridSize * this.cellSize)
      .height(this.gridSize * this.cellSize)
      .border({ width: 2, color: '#4CAF50' })

      // 方向控制
      Column({ space: 6 }) {
        Text('↑').fontSize(20).width(60).height(45)
          .textAlign(TextAlign.Center).backgroundColor('#4CAF50').borderRadius(8)
          .fontColor(Color.White)
          .onClick(() => this.changeDirection('up'))
        Row({ space: 6 }) {
          Text('←').fontSize(20).width(60).height(45)
            .textAlign(TextAlign.Center).backgroundColor('#4CAF50').borderRadius(8)
            .fontColor(Color.White)
            .onClick(() => this.changeDirection('left'))
          Text('↓').fontSize(20).width(60).height(45)
            .textAlign(TextAlign.Center).backgroundColor('#4CAF50').borderRadius(8)
            .fontColor(Color.White)
            .onClick(() => this.changeDirection('down'))
          Text('→').fontSize(20).width(60).height(45)
            .textAlign(TextAlign.Center).backgroundColor('#4CAF50').borderRadius(8)
            .fontColor(Color.White)
            .onClick(() => this.changeDirection('right'))
        }
      }

      // 开始按钮
      Text(this.isPlaying ? '游戏中...' : '开始游戏')
        .fontSize(16).fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .width(150).height(44)
        .textAlign(TextAlign.Center)
        .backgroundColor(this.isPlaying ? '#9E9E9E' : '#4CAF50')
        .borderRadius(22)
        .onClick(() => { if (!this.isPlaying) this.startGame() })
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#FAFAFA')
  }

  private drawGame(): void {
    // Canvas绘制逻辑(简化版,实际需要在onReady中绘制)
  }
}

17.5 游戏开发技巧总结

┌─────────────────────────────────────────────────────────────┐
│                 🎮 鸿蒙游戏开发要点                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 游戏循环                                                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ setInterval → 更新状态 → 修改@State → UI自动刷新      │   │
│  │ 铁律:页面销毁时必须 clearInterval 防止内存泄漏        │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  2. 状态驱动UI                                               │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ @State grid: number[][] = [...]                       │   │
│  │ 修改 grid → ForEach 自动重新渲染                       │   │
│  │ 不需要手动操作DOM!                                    │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  3. Canvas绘制(复杂图形)                                    │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ Canvas(ctx).onReady(() => { /* 绘制代码 */ })         │   │
│  │ ctx.clearRect() → ctx.beginPath() → ctx.fill()        │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  4. 碰撞检测                                                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 坐标比较:                                              │   │
│  │ if (obj1.x === obj2.x && obj1.y === obj2.y)          │   │
│  │ 范围判断:                                              │   │
│  │ if (x >= 0 && x < gridSize && y >= 0 && y < gridSize)│   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  5. 性能优化                                                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ • 限制帧率:setInterval间隔不要低于16ms(60fps)         │   │
│  │ • 减少重绘:只更新变化的部分                           │   │
│  │ • 对象池:频繁创建/销毁的对象用池化管理                │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

本文档由 Claude 基于真实鸿蒙开发实战经验编写 配套项目: 杏林中医(辨证论治·全科学习系统) 更新: 2026年6月

鸿蒙开发从零到上架 🏠 返回首页
🏠