适用版本:HarmonyOS Next 5.0 (API 12+) | 语言:ArkTS | 工具:DevEco Studio 5.0+ 特点:每个关键字标注中文,每段代码解释框架结构;从 Hello World 起步,逐步构建完整App 作者实战环境:Mac ARM + DevEco Studio 26.0.0 + 真机调试
HarmonyOS(鸿蒙)是华为自研的全场景分布式操作系统,与 Android/iOS 完全不同——它使用自研内核,不是 Android 套壳。
关键版本节点:
| 版本 | 状态 | 说明 |
|---|---|---|
| HarmonyOS 4.x | 过去式 | 兼容 Android APK |
| HarmonyOS Next (5.0) | 当前主力 | 纯血鸿蒙,不兼容 APK,仅支持 HAP |
┌──────────────────────────────────────────────────┐
│ 你的应用包 (HAP 包) │
│ ┌────────────────────────────────────────────┐ │
│ │ ArkUI (声明式UI框架) │ │
│ │ ├── 组件体系 (component) Text/Image/... │ │
│ │ ├── 布局体系 (layout) Column/Row/... │ │
│ │ └── 状态管理 (state) @State/@Prop │ │
│ ├────────────────────────────────────────────┤ │
│ │ ArkTS (TypeScript 超集) │ │
│ │ └── 装饰器 (decorator) @Component/@Entry │ │
│ ├────────────────────────────────────────────┤ │
│ │ Ability 框架 │ │
│ │ ├── UIAbility 页面容器 │ │
│ │ └── ServiceAbility 后台服务 │ │
│ ├────────────────────────────────────────────┤ │
│ │ ArkCompiler 编译器 + 方舟运行时 │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
| 特性 (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步:环境搭建 + 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 │
└─────────────────────────────────────────────────────────────┘
| 项目 | 最低要求 |
|---|---|
| CPU | ARM Mac (M1+) 或 x86 16GB+ |
| 内存 | 16GB 以上(模拟器很吃内存) |
| 磁盘 | SDK 占 20GB+,预留 50GB |
/Applications 或外置盘你的 SDK 实际位置:
~/Library/Huawei/Sdk/ ← HarmonyOS SDK 14G
~/Library/OpenHarmony/Sdk/ ← OpenHarmony SDK 3.9G
~/Library/ArkUI-X/Sdk/ ← 跨平台 SDK 1.4G
⚠️ SDK 路径有硬编码依赖,不要手动移动。要换位置只能重装 DevEco Studio 时指定。
打开 DevEco Studio → Preferences → SDK:
HarmonyOS SDK Location: ~/Library/Huawei/Sdk
OpenHarmony SDK Location: ~/Library/OpenHarmony/Sdk
需要下载: - API Version 12+ — HarmonyOS Next - System Image — 模拟器镜像 - Toolchains — 编译工具链
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)
🎯 本章目标:创建第一个鸿蒙应用,在模拟器/手机上看到”你好,鸿蒙”,理解代码的每一行在做什么。
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 │
│ │
│ │
└──────────────────────┘
恭喜!你第一个鸿蒙应用跑起来了! 🎉
现在把代码改一下,看看效果:
@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() | 交叉轴对齐 | 控制子元素在交叉轴方向的位置
|
@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的核心
把前面的按钮例子扩展成一个完整的计数器:
@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() |
配置组件属性 |
🚀 进度:你已经能独立写出”计数器”级别的互动应用了!接下来深入理解每个部分的原理。
学完本章,试试能不能独立完成这个小游戏:
@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显示不同颜色 |
在Hello World中我们已经见过
@Entry @Component struct build(),本章深入理解它们的原理和规则。
Stage 模型(HarmonyOS Next 专属架构)
┌────────────────────────────────────────┐
│ App 应用 (application) │
│ ┌──────────────────────────────────┐ │
│ │ HAP 包 (HarmonyOS Application │ │
│ │ Package) 主模块 entry │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ UIAbility 界面能力 │ │ │
│ │ │ ├── WindowStage 窗口阶段 │ │ │
│ │ │ │ └── Page 页面 │ │ │
│ │ │ └── 生命周期回调 │ │ │
│ │ └────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ HSP 包 (HarmonyOS Shared │ │
│ │ Package) 共享模块 │ │
│ │ └── 被多个HAP共享的代码和资源 │ │
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘
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
}
})
}
}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() { /* ... */ }
}回顾Hello World,每个页面都遵循这个骨架:
┌─────────────────────────────────────────────┐
│ 写一个页面的标准框架(7要素): │
│ │
│ 1. @Entry ← 声明这是页面 │
│ 2. @Component ← 声明这是组件 │
│ 3. struct 页面名 ← 结构体定义 │
│ 4. @State 变量们 ← 状态数据(在build外声明)│
│ 5. build() { ← 构建方法 │
│ 6. 布局组件() { ← 根节点有且只有一个 │
│ 7. 子组件们 ← 嵌套摆放 │
│ } │
│ } │
│ } │
└─────────────────────────────────────────────┘
代码结构 渲染后的界面
══════════════════════════════════════════════════════
@Component
struct MyPage { ┌──────────────────┐
│ 手机屏幕 │
build() { │ │
Column() { ← 根 │ ┌────────────┐ │
Text('标题') ← 子1 │ │ 标题 │ │
Row() { ← 子2 │ ├────────────┤ │
Text('左') ← 孙1 │ │ 左 │ 右 │ │
Text('右') ← 孙2 │ ├────────────┤ │
} │ │ 底部文字 │ │
Text('底部') ← 子3 │ └────────────┘ │
} │ │
} └──────────────────┘
}
组件树(嵌套关系): 布局规则:
Column (垂直排列) Column → 子元素竖着排
├── Text "标题" Row → 子元素横着排
├── Row (水平排列) build → 根节点必须唯一
│ ├── Text "左"
│ └── Text "右"
└── Text "底部"
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('右')
}
}
}利用生命周期和页面结构知识,做一个倒计时组件:
@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)
}
}阅读方式:每个代码块中
//后的中文标注了前面英文关键字的意思。 有 TypeScript/JavaScript 基础可快速过;零基础建议逐段敲一遍。
// 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 = "中医题库"// 数组 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 // 也合法// 函数 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},你好`
}// 类 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() // 输出: 旺财: 汪汪!// 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) // 失败则打印错误
}
}// 数组解构:把数组元素取出来赋给变量
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}从Hello World的
Text()开始,本章逐个介绍最常用的UI组件。每个组件都以”能跑的小例子”展示。
// 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)
}// 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)// ⚠️ 铁律:不要用原生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' })
}
})// 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)
}@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分支
})
}
}
}@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生成器(必须提供!)
// ↑ 返回唯一标识,用于高效更新
)
}
}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('被点击了')
})把前面学的组件组合起来,做一个完整的登录页面:
@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 | 按钮点击、记住密码切换 | |
三元表达式 | 按钮颜色/文字动态变化 |
组件学会了,下一步是把它们摆放整齐。本章从简单的居中开始,到复杂的弹性布局。
// ===== 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下// ===== 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 垂直对齐(横向布局中)// ===== 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
)
}// 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)
}// ⚠️ 铁律:不要用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隐藏
}// 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())把前面学的组件和布局组合起来:
@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')
}
}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')
}
}状态管理是ArkUI的灵魂。理解了它,就理解了整个框架的运作方式。 我们已经用过
@State(计数器例子),现在系统学习全部状态管理工具。
| 装饰器 | 读法 | 作用 | 传递方向 | 典型场景 |
|---|---|---|---|---|
@State |
组件状态 | 组件自有数据,变化自动刷新UI | 不能传 | 输入框、开关、计数 |
@Prop |
父传子 | 父组件传值给子组件,子组件不能改 | 父→子 单向 | 展示标题、配置项 |
@Link |
双向绑定 | 父子共享数据,一方改双方更新 | 父↔︎子 双向 | 开关、单选框 |
@Provide |
全局提供 | 祖先向所有后代提供数据 | 跨层级 | 主题色、用户信息 |
@Consume |
全局消费 | 后代读取祖先提供的数据 | 跨层级 | 读取主题色 |
@StorageLink |
持久化状态 | 数据存AppStorage,全局共享 | 全局 | 登录态、设置 |
@ObjectLink |
对象绑定 | 绑定被@Observed装饰的类对象 | 父→子 | 复杂数据模型 |
回顾第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)
}
}
}// 场景:主题色从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自动刷新
})
}
}
}需要状态管理时,按这个顺序选:
自己是唯一的用户? → @State
↓否
只传给直接子组件? → 子只读? → @Prop
↓否 ↓子也要改? → @Link($)
需要跨很多层? → @Provide/@Consume
↓否
需要全局共享 + 持久化? → @StorageLink
↓是
复杂对象? → @Observed + @ObjectLink
┌──────────────────────────────────────────────────────────────────┐
│ 🌀 状态数据流全景 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ @StorageLink ────── AppStorage(全局内存) ────── @StorageLink │
│ (组件A) (组件B) │
│ │ │ │
│ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ @State │──$──→ │ @Link │──改─→ │ @State │ │
│ │ (数据源)│ │ (双向) │ │ (同步) │ │
│ └───┬────┘ └────────┘ └────────┘ │
│ │ │
│ ├──值复制──→ @Prop (子组件只读展示) │
│ │ │
│ └──@Provide──→ 跨层级 ──→ @Consume (任意后代读取) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 数据流向规则 │ │
│ │ │ │
│ │ "数据向下流动,事件向上冒泡" │ │
│ │ │ │
│ │ 父组件 ──@Prop/@Link(数据)──→ 子组件 │ │
│ │ 子组件 ──回调函数(事件)──→ 父组件 │ │
│ │ │ │
│ │ ✅ 好: 父传数据,子回调 │ │
│ │ ❌ 坏: 子直接改父的数据(不走@Link的情况) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
装饰器作用范围图:
┌─────────────────┐
@Provide ←──────────────│ 祖先组件 │
│ └────────┬────────┘
│ @Prop/@Link ←─────── │
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ @Consume│ │ 子组件A │ │ 子组件B │
│ (任意层) │ │ @Prop │ │ @Link │
└─────────┘ └──────────┘ └──────────┘
// ===== 数据模型 =====
@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片段(本例可扩展) |
单页面不够用。本章学习多页面跳转和底部Tab导航。
┌────────────────────────────────────────────────────────────┐
│ 🗺 页面路由栈 │
├────────────────────────────────────────────────────────────┤
│ │
│ 操作: 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() 接收 │
└────────────────────────────────────────────────────────────┘
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// 普通返回:返回上一页
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' }) // 重新压入首页// 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
})
}
}页面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')
}
}App需要记住数据。从最简单的键值对到完整的SQLite数据库。
┌────────────────────────────────────────────────────────────────┐
│ 💾 数据存储三件套 │
├────────────────────────────────────────────────────────────────┤
│ │
│ Preferences (键值对) SQLite (关系数据库) │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ key → value │ │ 表格 ├── 行 ── 列 │ │
│ │ "name": "张三" │ │ │ │ │
│ │ "age": "25" │ │ ┌───┬────┬──────┐ │ │
│ │ "token":"abc" │ │ │id │题目 │答案 │ │ │
│ └──────────────────┘ │ ├───┼────┼──────┤ │ │
│ │ │ 1 │脾虚 │食少. │ │ │
│ 适用: 设置项、登录态 │ │ 2 │湿热 │身重. │ │ │
│ 大小: 小(<100条) │ └───┴────┴──────┘ │ │
│ │ │ │
│ │ 适用: 题库、用户数据 │ │
│ │ 大小: 大(千条以上) │ │
│ └──────────────────────┘ │
│ │
│ 沙箱文件 (fileIo) │
│ ┌──────────────────────────────────────────┐ │
│ │ filesDir/ 应用专属目录, 其他App不能访问 │ │
│ │ ├── notes.txt │ │
│ │ ├── cache/ 缓存文件 │ │
│ │ └── data/ 自定义数据 │ │
│ │ │ │
│ │ 适用: 大文件(图片/音频/数据库文件) │ │
│ └──────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
// 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()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 删除:条件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)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%')
}
}App联了网才真正”活”起来。本章学习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 一定被执行 │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
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()
}
}// 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')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')
}
}1. 设置 → 关于手机 → 连续点击"软件版本"7次
2. 返回 → 系统和更新 → 开发人员选项
3. 开启 USB 调试
4. 开启 "仅充电"模式下允许 ADB 调试
// 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 证书路径
}
}
]
1. USB 连接手机 → 手机上点"允许USB调试"
2. DevEco Studio 顶部选择设备(应该能看到手机型号)
3. 点击 ▶️ 运行 → 自动编译 → 安装到手机 → 自动启动
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 错误级别——出了异常┌─────────────────────────────────────────────────────────────┐
│ 🔧 真机调试问题排查清单 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 问题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信息 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
以真实项目 TCMExamApp 为例,逐文件拆解项目结构、代码写法、数据流。
┌──────────────────────────────────────────────────────────────────────┐
│ 🏗 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层 → 数据层 → 存储层,每层只依赖下一层 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
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 ← 构建脚本
// 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
}
}// 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
}
}// 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)
}
}// 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)
}
}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')
}
}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')
}
}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')
}
}菜单: Build → Build Hap(s)/App(s) → Build App(s)
输出: build/outputs/default/xxx-release.app
1. https://developer.huawei.com → AppGallery Connect
2. 我的应用 → 创建应用 → HarmonyOS
3. 填写:应用名称、包名、分类、隐私政策URL
4. 版本管理 → 上传APP包(选上一步生成的.app文件)
5. 填写版本信息(更新日志、截图)
6. 提交审核 → 等待1~3工作日
| 原因 | 解决 |
|---|---|
| 隐私政策缺失/404 | 确保URL可访问 |
| 权限未声明原因 | module.json5中permission加reason字段 |
| 有崩溃 | 充分测试、修bug |
| 功能不完整 | 不提交半成品 |
| 截图与实际不符 | 用真机截图 |
写代码不怕出错,怕的是不知道怎么查错。本章总结鸿蒙开发中最常遇到的坑和解决方法。
// 铁律:所有异步操作(网络/数据库/文件)必须用 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()
}
}// 完整的数据库操作模式
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() // 关闭结果集(重要!)
}
}@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)
}
}// ❌ 危险:直接解析未知数据
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 })@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() {
// ...
}
}
}// 错误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
}
}
}编译不过=装不到手机,以下每条都能让你编译失败:
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()里面只能有一个根布局组件
| 错误信息(英文) | 中文意思 | 原因 | 解决 |
|---|---|---|---|
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 断言 |
| 英文 | 中文 | 说明 |
|---|---|---|
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 |
等待 | 等待异步完成 |
| 英文 | 中文 | 说明 |
|---|---|---|
@Entry |
入口页 | 标记页面入口 |
@Component |
组件 | 标记UI组件 |
@State |
组件状态 | 组件本地状态 |
@Prop |
父传子 | 父→子单向传递 |
@Link |
双向绑定 | 父子双向同步 |
@Provide |
全局提供 | 跨层级提供 |
@Consume |
全局消费 | 跨层级读取 |
@StorageLink |
持久化状态 | 全局+持久化 |
@Observed |
可观察类 | 类的属性变化可观察 |
@ObjectLink |
对象绑定 | 绑定@Observed对象 |
@Builder |
构建器 | 复用构建逻辑 |
@CustomDialog |
自定义弹窗 | 弹窗组件 |
| 英文 | 中文 | 说明 |
|---|---|---|
router |
路由器 | 页面跳转 |
pushUrl |
跳转 | 打开新页面 |
back |
返回 | 返回上一页 |
getParams |
获取参数 | 获取页面参数 |
preferences |
偏好存储 | 键值对存储 |
relationalStore |
关系数据库 | SQLite |
http.createHttp() |
HTTP客户端 | 发网络请求 |
JSON.parse |
JSON解析 | 字符串→对象 |
JSON.stringify |
JSON序列化 | 对象→字符串 |
hilog |
日志 | 鸿蒙日志 |
getContext |
获取上下文 | 获取App上下文 |
AppStorage |
应用存储 | 全局内存存储 |
// ❌ 错误:大量数据一次性渲染
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}`)
}// ❌ 加载大图不做处理
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// ❌ 频繁更新状态导致多次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
}// ❌ 用定时器模拟动画(不流畅)
setInterval(() => {
this.offset += 1 // 不跟随系统刷新率
}, 16)
// ✅ 用系统动画API(60fps)
animateTo({
duration: 300,
curve: Curve.EaseInOut,
onFinish: () => { console.log('动画完成') }
}, () => {
this.offset = 200 // 系统自动插值,流畅60fps
})@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() { /* ... */ }
}推荐的项目结构:
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
本章基于真实上架项目「杏林中医」拆解,展示一个完整鸿蒙App的架构设计和核心代码。
┌──────────────────────────────────────────────────────────────┐
│ 🌿 杏林中医 App 架构 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ UI 层 (pages/) │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Index │ │WebView │ │Settings│ │ ... │ │ │
│ │ │ (主页面)│ │ (网页) │ │ (设置) │ │ │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 数据层 (store/ + model/) │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ QuizStore │ │ DataModel │ │ │
│ │ │ (数据持久化) │ │ (数据结构) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 数据源 (model/Constants*.ets) │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ 辨证 │ │ 穴位 │ │ 四诊 │ │ 中药 │ │ │
│ │ │ 48病种 │ │ 十四经 │ │ 望闻问 │ │ 方剂 │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ 核心功能: │
│ ✅ 多模块学习(辨证/穴位/四诊/中药/自定义) │
│ ✅ 智能出题(按范围/题型/题数配置) │
│ ✅ 全局搜索(跨模块模糊匹配) │
│ ✅ 数据备份导出 │
│ ✅ 管理模式(增删改查) │
│ │
└──────────────────────────────────────────────────────────────┘
// ===== 真实项目中的数据模型设计 =====
// 关键设计:接口复用 + 可选字段 + 扩展性
/** 病种下证型条目 */
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[]→字段,清晰的一对多关系 |
// ===== 真实项目中的数据存储方案 =====
// 策略:内置数据(常量) + 用户数据(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去重,防止用户操作导致数据重复 |
// ===== 真实项目中的全局搜索 =====
// 特点:跨模块搜索、高亮匹配、结果限制
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 ? '…' : '')
}// ===== 真实项目中的出题逻辑 =====
// 特点:按范围/题型/题数灵活配置,支持单题/多题模式
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
}// ===== 真实项目中的判分逻辑 =====
// 特点:模糊匹配、支持部分正确、实时计分
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++ // 已答计数
}鸿蒙也能做游戏!本章介绍用ArkUI开发休闲小游戏的技巧。
// 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()
}
}@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')
}
}@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')
}
}@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中绘制)
}
}┌─────────────────────────────────────────────────────────────┐
│ 🎮 鸿蒙游戏开发要点 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 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月