简介:这是一份面向HarmonyOS应用开发学习者与初学者的完整项目案例,基于ArkTS语言实现了一套适用于网吧场景的会员终端应用。项目覆盖引导页、登录注册、找回密码、网吧列表与详情、会员充值、充值记录、密码修改、个人信息及会员详情查看、退出应用等完整闭环功能,并采用关系型数据库与首选项完成数据持久化,适合用于学习鸿蒙原生开发、页面间通信和数据管理。
压缩包共343个文件,约36.58MB,包含ArkTS源码(.ets)、JavaScript脚本、JSON配置、页面图片以及hap安装包等,结构清晰,可直接导入DevEco Studio进行工程分析与二次开发。已有230人学习下载,适合具备TypeScript基础的开发者快速上手鸿蒙项目。
通过阅读源码可以了解引导页跳转逻辑、表单校验与密码找回实现、网吧列表数据绑定、充值记录查询等典型业务写法,同时可借鉴如何结合关系型数据库和首选项搭建本地数据层,对完整梳理鸿蒙应用开发流程很有帮助。
1. 鸿蒙 ArkTS 项目应用开发:从一屏白页到能上架的实战路径
不少从 Web 转鸿蒙的同事,第一周都会卡在同一个地方:在 DevEco Studio 里新建一个 Empty Ability 工程,拖几个组件上去,模拟器一跑,界面出来了,可一旦要加列表、加搜索、加本地数据库,就发现 ArkTS 这套写法和习惯的 TypeScript 完全不是一回事。状态没更新、列表不刷新、SQL 建表报错,这类问题几乎每个人都会撞上。这篇笔记不聊 SDK 有多强大,就讲 ArkTS 工程从零到能跑、能存数据、能过调试到发布这条路上,哪些环节必须按规矩来,哪些坑是可以提前绕开的。适合刚上手 HarmonyOS NEXT 开发、准备用 ArkTS 写第一个可用元服务或应用的从业者,也适合那些正在对比 ArkTS 和 Flutter 到底选哪个的团队做参考。
2. ArkTS 与鸿蒙 NEXT:选型依据与版本边界
2.1 ArkTS 是 TypeScript 的超集,但在类型约束上更严格
ArkTS 常被一句话概括为“TypeScript 的超集”,实际用起来,它更像是在 TS 基础上做了两件事:一是增加了一套声明式 UI 语法,二是把类型系统收紧了一大截。网上流传的 ArkTS 八股里,出现频率最高的一句话是“ArkTS 是 TS 的子集加语法扩展”,这句话不算错,但会让你低估它对运行时行为的约束。
具体的差异体现在几个地方。第一,ArkTS 语法里取消了any和unknown的隐式泛滥,对象字面量必须和接口或类结构对齐,写习惯了 JS 的人会觉得处处被管着;第二,顶层不允许有自由函数和自由变量,所有东西都要组织在 struct、class 或 namespace 里;第三,struct是 UI 组件的载体,里面的build()方法描述界面,这跟 React 的 JSX、Vue 的 template 都不是一个思路,它更像是一套编译期就能检查的 DSL。
提示:ArkTS 编译期检查严格是好事,坏处是很多网上抄来的 TS 代码片段直接粘进鸿蒙工程会报一堆类型错误,这不是环境坏了,是语法边界不同。
从实践角度说,我一般把 ArkTS 理解成“带了强类型约束的声明式 UI 语言”,写组件时脑子里不要想着“这是个 JS 对象”,而要想着“这是个编译期就锁定结构的 UI 描述”。理解这一点,后面碰到的 90% 类型报错都能立刻看懂。
2.2 ArkTS 和 Flutter 怎么选:关键看你的运行时诉求
“ArkTS 和 Flutter 谁更流行”这个问题在开发者社区里一直有讨论,但真正落地的选型逻辑不在流行度,而在运行时。Flutter 走的是自绘引擎,UI 控件都是 Skia 画出来的,跨端一致性好;鸿蒙 ArkTS 组件走的是系统原生渲染管道,组件最终映射到鸿蒙的 ArkUI 框架节点上,跟系统能力和系统控件的交互是零成本。
从性能角度说,ArkTS 经过方舟编译器编译后,不是跑在常规 JS 引擎里的解释执行,而是编译成更接近原生的字节码,在启动速度和内存占用上比“套壳 WebView”或“解释执行 JS”的方案有明显优势。如果你的目标平台就是鸿蒙全家桶,包括手机、平板、车机、手表这类设备,ArkTS 是首选,因为一次开发就能覆盖全场景。
反过来,如果团队已经有成熟的 Flutter 跨端代码库,而且业务以内容展示为主、对系统深度能力依赖不重,那继续用 Flutter 也不是不行,鸿蒙 NEXT 对 Flutter 的兼容层确实在完善。但你要接受一个现实:Flutter 在鸿蒙上拿系统能力时的路径更长,比如要绕过平台通道取蓝牙、取分布式数据,这中间的调试成本不低。
2.3 SDK 版本矩阵:API 12 与 5.0.0(12) 到底对应什么版本
鸿蒙 NEXT 的版本号一直容易把人绕晕。这里给一个简洁的对应关系:HarmonyOS 5.0.0 就是 HarmonyOS NEXT 的大版本,括号里的 12 对应的是 SDK 的 API Level 12,也就是常说的 API 12+。在 DevEco Studio 里新建工程时,你会看到 SDK 选项写着5.0.0(12),这个组合意味着你的应用目标运行环境是 HarmonyOS NEXT 5.0 及以上。
| DevEco Studio 版本 | SDK 版本标记 | API Level | 典型用途 |
|---|---|---|---|
| 5.0.0 及以后 | 5.0.0(12) | API 12 | 新工程默认,支持 ArkTS 声明式 UI 完整能力 |
| 4.x | 4.1.0(11) | API 11 | 兼容老工程,部分新 API 不可用 |
| 3.x | 3.2.0(9) | API 9 | 早期 FA 模型,不建议新项目使用 |
选 API 12 的实际情况是:新项目直接选默认就行,API 12 已经把 ArkTS 的声明式 UI、状态管理 V2、分布式数据等能力集合得比较完整。如果你的应用要兼容旧款机型,才需要考虑降 API。但鸿蒙 NEXT 本身就切掉了对 Android APK 的兼容,用户群体集中在支持 NEXT 的设备上,API 12 起步几乎没有负担。
3. 工程骨架与声明式 UI:从建项目到写第一个页面
3.1 工程结构拆解:EntryAbility 才是应用入口
新建一个 Empty Ability 工程后,src/main/ets目录下会生成几块固定结构。entryability目录下是EntryAbility.ets,它继承自UIAbility,承担应用生命周期管理,相当于 Android 的 Activity 加 Application 的合体。pages目录下是Index.ets,这是默认的首页组件。resources目录管的是字符串、颜色、图标这些静态资源。
很多新手上来就改Index.ets,页面没出来就以为是代码问题,其实第一步应该看EntryAbility的onWindowStageCreate方法,页面入口是通过windowStage.loadContent('pages/Index')指定的。如果你把页面文件移到别的目录,或改了文件名,这里不跟着改,应用启动就是白屏。
注意:改了页面路径后,
loadContent的参数是$rawfile之外的相对路径,从ets目录开始算,不需要带.ets后缀。
3.2 声明式 UI 语法基础:@Entry、@Component、@State 的分工
ArkTS 页面组件的最小单位是@Component修饰的 struct,一个页面级组件用@Entry标记为入口,build()方法里写 UI 结构。@State是组件内部的状态变量,它跟普通变量的本质区别是:@State变量变化时,依赖它的 UI 会自动重新渲染,普通变量不会。
这三者的分工,我在实践中总结成一句话:@Entry告诉系统“这个组件是页面根”,@Component告诉编译器“这是个 UI 组件”,@State告诉 ArkUI “这个变量变了,UI 该刷新了”。理解了这个,你就明白了为啥在 ArkTS 里改一个普通 class 字段页面纹丝不动,改@State字段整个组件立刻重绘。
3.3 从零写一个任务列表页:代码可直接抄
下面这个例子是一个带输入框和列表的任务记录页面,基本覆盖了声明式 UI 最常见的三个操作:绑定输入、渲染列表、处理点击事件。直接复制到一个新工程的Index.ets里就能跑起来。
@Entry @Component struct TaskPage { @State tasks: string[] = ['学习 ArkTS', '写 RDB 笔记'] @State inputValue: string = '' taskInput: TextInputController = new TextInputController() build() { Column({ space: 12 }) { Text('任务清单') .fontSize(24) .fontWeight(FontWeight.Bold) .width('100%') .padding(16) Row({ space: 8 }) { TextInput({ placeholder: '输入新任务', controller: this.taskInput }) .layoutWeight(1) .onChange((value: string) => { this.inputValue = value }) Button('添加') .onClick(() => { if (this.inputValue.trim().length > 0) { this.tasks.push(this.inputValue.trim()) this.inputValue = '' this.taskInput.setText('') } }) } .width('100%') .padding({ left: 16, right: 16 }) List({ space: 8 }) { ForEach(this.tasks, (task: string, index: number) => { ListItem() { Row() { Text(task) .fontSize(18) .layoutWeight(1) Button('删除') .fontSize(14) .backgroundColor('#E84026') .onClick(() => { this.tasks.splice(index, 1) }) } .padding(12) .backgroundColor('#FFFFFF') .borderRadius(8) } }, (task: string, index: number) => `${index}-${task}`) } .width('100%') .layoutWeight(1) .padding({ left: 16, right: 16 }) } .width('100%') .height('100%') .backgroundColor('#F1F3F5') } }代码里的关键点有三个。第一,TextInput的onChange会把输入内容同步到@State inputValue,而按钮点击时读取的是这个值,不是直接从控件取值,这是 ArkTS 声明式 UI 的推荐做法——数据驱动 UI,而不是反向拉取。第二,ForEach的第三个参数是键值生成函数,这里用index-task复合字符串做 key,保证列表项在增删时的标识稳定,避免渲染复用时出现错乱。第三,List组件自带懒加载能力,数据量几百条以内用List加ForEach完全够用,超过这个量再考虑LazyForEach。
4. 状态管理与数据持久化:把 SQL 用进鸿蒙应用
4.1 状态管理三层模型:@State、@Prop、@Link 与 AppStorage 的边界
ArkTS 的 UI 状态管理是面试八股里最爱考、实战里最容易被绕晕的部分,但它其实可以拆成三个层次来记。组件内部状态用@State,只管当前组件自身;父组件向子组件传值用@Prop,这是单向数据流,子组件改不了父组件的值;父子组件要双向同步用@Link,子组件改了,父组件跟着变。
全局状态则交给AppStorage。它相当于整个应用共享的内存字典,任何组件都能读写,适合存登录态、用户配置这类需要跨页面访问的数据。
| 状态管理方式 | 作用范围 | 数据方向 | 适用场景 |
|---|---|---|---|
@State | 当前组件 | 单向自更新 | 组件内部临时数据 |
@Prop | 父传子 | 单向 | 子组件只读展示 |
@Link | 父子共用 | 双向 | 表单联动、共同编辑 |
AppStorage | 全局 | 全局读写 | 登录态、全局配置 |
我踩过的坑是:把接口返回的大对象塞进@State,然后直接改对象的某个嵌套字段,页面不刷新。原因是@State只能观察到第一层属性的变化,深层嵌套字段改了不会触发 UI 更新。解决方法是整个替换对象,或者用@Observed配合@ObjectLink做深度观察,但能不用就不用,保持数据扁平化才是正路。
4.2 鸿蒙关系型数据库 RDB:从建库建表到增删改查完整流程
ArkTS 应用里要存结构化数据,标准方案是 RDB(Relational Store),它基于 SQLite,但对外暴露的是鸿蒙的relationalStore接口。这套 API 的写法跟浏览器里的 Web SQL 不一样,需要先拿RdbStore实例,再通过它的方法执行 SQL 或封装的查询。
下面的代码是完整的建库、建表、插入和查询流程,可直接用于一个任务管理类的页面。
import { relationalStore } from '@kit.ArkData' let store: relationalStore.RdbStore | null = null // 配置并创建数据库 const STORE_CONFIG: relationalStore.StoreConfig = { name: 'task.db', securityLevel: relationalStore.SecurityLevel.S1 } // 建表 SQL const CREATE_TABLE_SQL = ` CREATE TABLE IF NOT EXISTS task ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, status INTEGER DEFAULT 0, create_time INTEGER ) ` // 初始化 relationalStore.getRdbStore(getContext(), STORE_CONFIG) .then(async (rdbStore) => { store = rdbStore await rdbStore.executeSql(CREATE_TABLE_SQL) console.info('RDB init success') }) .catch((err) => { console.error(`RDB init failed, code=${err.code}, msg=${err.message}`) }) // 插入一条记录 async function insertTask(title: string): Promise<number> { if (!store) { throw new Error('RDB store not ready') } const values: relationalStore.ValuesBucket = { title: title, status: 0, create_time: Date.now() } return await store.insert('task', values) } // 查询未完成任务,按创建时间倒序 async function queryPendingTasks(): Promise<Array<Record<string, number | string>>> { if (!store) { throw new Error('RDB store not ready') } let predicates = new relationalStore.RdbPredicates('task') predicates.equalTo('status', 0) .orderByDesc('create_time') let resultSet = await store.query(predicates) let tasks: Array<Record<string, number | string>> = [] while (resultSet.goToNextRow()) { tasks.push({ id: resultSet.getLong(resultSet.getColumnIndex('id')), title: resultSet.getString(resultSet.getColumnIndex('title')), status: resultSet.getLong(resultSet.getColumnIndex('status')), create_time: resultSet.getLong(resultSet.getColumnIndex('create_time')) }) } resultSet.close() return tasks }这套流程里有四个容易写错的点。第一,getRdbStore的securityLevel至少要用S1,这是鸿蒙对数据库安全等级的最低要求,写成S0会报参数错误;第二,建表 SQL 里AUTOINCREMENT必须跟在INTEGER PRIMARY KEY后面,顺序写反了表能建出来,但自增不生效;第三,resultSet用完后一定要close(),不关的话多次查询后会触发连接池耗尽,报错信息还特别隐蔽,只显示一个莫名其妙的code=14800000;第四,插入时字段名必须和表结构完全一致,用ValuesBucket时多写一个表里不存在的字段,不会报语法错,但数据会静默写入失败,查不到记录。
更新和删除也走同一套模式,更新用store.update(values, predicates),删除用store.delete(predicates),都是基于RdbPredicates条件。唯一的区别是update的values里只需要包含要改的字段,其余字段不传即可。
4.3 SQL 能力的边界:什么时候改用 Preferences
RDB 不是唯一的数据持久化方案,鸿蒙还提供了Preferences,本质是键值对存储,适合存配置项、用户偏好这类简单数据。这两者之间的选择标准就一条:数据有没有结构化查询需求。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 用户偏好、开关状态 | Preferences | 写入简单,无 SQL 学习成本 |
| 列表数据、关联查询、排序分页 | RDB | 字段约束清晰,查询能力强 |
| 大数据量全文搜索 | RDB + 索引 | SQLite 的 FTS 能力可用 |
我一般会建议团队:只要数据需要按条件筛选或排序,哪怕只有几十条,也直接上 RDB。因为 Preferences 取出来的是整个对象,要自己做过滤和排序,数据一多代码就变丑,后面重构成本更高。反过来,纯配置项用 RDB 就是杀鸡用牛刀,没必要。
5. 调试与上架避坑:从 DevEco 运行到发布的六个现场
5.1 预览器白屏:API 12 的部分能力在 Previewer 里不可用
现象:在 DevEco Studio 的 Previewer 里打开页面,界面是空白,控制台没有明显报错,build 日志也正常。原因:Previewer 本质是一个模拟渲染环境,对部分涉及真机硬件或系统服务的 API 支持不全,比如getContext()拿不到完整上下文时,初始化逻辑就卡住了。解决:先看页面有没有在初始化阶段调用系统能力,有的话用if (typeof getContext() !== 'undefined')做一层保护,或者干脆把依赖系统能力的初始化逻辑挪到onPageShow生命周期里,预览器至少能看到 UI 骨架。
5.2 模拟器 API 版本不匹配:装了模拟器还是起不来
现象:模拟器已经启动,但工程运行时报SDK version mismatch,或者直接提示找不到5.0.0(12)的模拟器镜像。原因:DevEco Studio 自带的模拟器镜像版本跟工程配置的 SDK 版本不一致可能性不大,更常见的是模拟器镜像没下载完整,或者本地环境变量指向了旧版 HarmonyOS SDK。解决:在 DevEco Studio 的Settings → SDK Manager里切换到HarmonyOS 5.0.0(12)镜像,重新下载;检查项目build-profile.json5里的compileSdkVersion和targetSdkVersion是否都写成了12。我见过把compileSdkVersion写成11、targetSdkVersion写成12的情况,模拟器能启动,但运行时报一堆系统 API 找不到的错误。
5.3 真机调试签名失败:证书 Profile 和调试证书是两套东西
现象:手机插上电脑,点击运行,提示Signing certificate invalid或No valid profile found。原因:鸿蒙真机调试既需要调试证书(.cer),又需要对应的调试 Profile,而且这两者都要在 AppGallery Connect 里注册设备的 UUID。很多新手只配了调试证书,忘了下载 Profile。解决:在开发工具里选择File → Project Structure → Signing Configs,勾选Automatically generate signature,工具会引导完成证书和 Profile 的创建。首次配置时注意要用已注册为开发设备的华为账号登录,团队账号还要在成员管理里给账号分配开发权限。
5.4 Release 构建混淆后页面崩溃:ArkTS 的类名真的变了
现象:Debug 包一切正常,Release 包打开后页面白屏,日志里报Cannot find module或类找不到。原因:构建 Release 时默认开启代码混淆,ArkTS 的类名和成员名会被改写,如果代码里通过字符串方式动态获取类,或者用了反射,就会在运行时找不到对应的类。我印象里最典型的一个场景是路由跳转时用字符串拼接页面路径,混淆后路径对不上,跳转直接失败,也不报错,就卡在上一个页面。解决:检查路由跳转的字符串是否硬编码;在混淆配置文件里添加 keep 规则,把需要保留类名的白名单写进去。更稳妥的习惯是不用字符串拼路由,用系统推荐的Navigation组件做页面路由。
5.5 RDB 查询返回空结果,但不报错
现象:插入数据成功后,query返回的resultSet.rowCount是 0,反复插入也一样。这是我在本地开发时踩得最隐蔽的一个坑。原因:insert目标表名写错,但表存在的情况下不报错;更常见的是predicates.equalTo的字段名写错了,比如表里是create_time,条件里写成了createTime,查询条件永远不匹配,静默返回空。解决:第一步看插入返回值,insert返回的是 rowId,如果返回 -1 说明写入失败;第二步检查 predicate 字段名,严格对照建表 SQL 里的字段定义,连下划线都要一致。从那以后我每次写查询都先复制建表 SQL 里的字段名,而不是手敲。
6. 从能跑到跑得好:渲染性能和状态更新的验证方法
页面能跑只是第一步,ArkTS 应用最容易在列表加载和频繁状态更新时露出性能短板。这里分享一套我常用的验证流程和优化手法,不用依赖外部工具,DevEco Studio 自带的 Profiler 就够用。
先验证渲染性能。在 DevEco Studio 底部打开Profiler,选择Frame标签,运行应用并滑动列表,观察帧渲染时间。如果单帧超过 16ms,界面上就会掉帧,这时优先检查ForEach的 key 生成函数是否稳定。我处理过的一个实际案例是列表项 key 用了index,数据重新排序后,ArkUI 复用了错误的组件,导致界面闪烁和卡顿,改成业务唯一 ID 后问题立刻消失。
再验证状态更新链路。在需要观察的@State变量 setter 里加console.info,配合HiLog的分级过滤,看每次数据变化触发了几次 UI 更新。常见问题是:同一批数据更新连续执行了多次this.tasks = newTasks,ArkUI 会合并到一次渲染,但如果你在循环里分批改状态,就会造成多次渲染开销。正确做法是循环外组装好完整数组,一次赋值。
最后看内存。Profiler的Memory标签能看 native 堆和 ArkTS 堆的占用曲线。如果曲线在页面返回后不回落,大概率是全局对象或AppStorage里存了大对象没有释放。我习惯在页面onPageHide时把AppStorage里的临时数据置空,再配合weak引用,基本能把泄漏堵住。
一个更省事的技巧:列表项尽量拆成独立@Component,用@Prop接收数据项,这样 ArkUI 可以精确判断“只有这一项变了才重绘这一项”,而不是整个列表重刷。刚开始写的时候嫌麻烦,后来在数据量 500 条的任务列表上对比过一次,独立组件方案的首帧渲染时间和滚动流畅度肉眼可辨地优于整列表刷新。从那以后,我每次新写一个列表页,都强制自己在拆组件之前先问三个问题:数据从哪来、状态给谁用、列表 key 稳不稳定。三个问题答完再动手,后面省下的排查时间足够把组件拆完,希望帮到你。
本文还有配套的精品资源,点击获取