简介:这是一套基于鸿蒙ArkTS语言开发的网吧会员终端应用完整工程,适合正在学习鸿蒙应用开发、需要参考完整项目实践的技术人员,也可作为高校移动应用开发课程的实训案例。工程覆盖引导页、登录注册、找回密码、网吧列表与详情、会员充值、充值记录查询、密码修改、个人信息查看、会员详情查看及退出应用等核心功能,业务数据统一存放到关系型数据库,用户设置则通过首选项持久化保存。整个压缩包共343个文件,大小36.58MB,以ArkTS与TypeScript源码、JSON配置文件、PNG/JPG界面图片、hap安装包和protobin/map编译中间产物为主,便于从源码到构建环节进行完整对比。已有230人浏览/学习该资源。通过该工程,既能学习多模块鸿蒙应用的页面组织与逻辑实现,也能掌握关系型数据库和首选项的搭配用法;同时可复用现有结构、资源与配置,直接作为鸿蒙应用原型或课程设计的起点。
1. 先立住:ArkTS不是“鸿蒙版TypeScript”,状态管理才是真正门槛
把一个会写TypeScript的前端工程师直接放进HarmonyOS项目里,第一天他多半能跑起一个页面;从第二天开始,他就会被状态不刷新、参数传不过去、数组改了UI没反应这些事反复绊倒。这中间的落差不在语法,而在ArkTS带来的整套声明式开发模型。ArkTS是HarmonyOS应用开发的主推语言,它用组件树加状态驱动的方式描述界面,把“UI等于状态的函数”这件事在系统层面落了下来。搞清楚它怎么编译、状态怎么传播、哪些JS/TS能力被约束掉了,项目才不会写到一半翻车。这篇文章写给正要从零搭一个ArkTS项目的开发者,也写给想评估团队要不要转过来的技术负责人。
2. 为什么项目选ArkTS而不是“会TS就行”:声明式模型与运行时约束
这两年问ArkTS和Flutter谁更流行的团队很多。严格说这不是同层竞争:Flutter是跨端方案,ArkTS是HarmonyOS原生开发的主语言。如果产品要做的是纯鸿蒙生态,或者强依赖系统能力,走ArkTS是顺理成章的事。但“顺理成章”不等于“没有门槛”。ArkTS表面上长得很像TypeScript,工程里也确实大量复用TS语法,但它的编译链路、状态管理模型和运行时环境都和前端世界不同。用写网页的习惯去写ArkTS,第一周就会感受到别扭。
2.1 ArkTS与TypeScript的关系:能继承什么、要改掉什么
ArkTS在语言层面是TypeScript的超集,完全支持TS的类、接口、泛型、枚举这些静态类型能力,也保留了async/await等异步写法。对一个有TS基础的开发者来说,类型定义和模块组织的经验可以直接迁移。但进入项目后你会发现,ArkTS对“动态”的部分约束得很死:JS里的隐式any、运行时随意给对象加属性、用字符串动态访问成员,这些写法在ArkTS里不是被编译器警告,而是直接不给过。常见做法是保留TS的类型思维,但丢掉那些依赖运行时的动态技巧,尽量在编译期就把结构定下来。
另一个要改掉的习惯是“面对DOM思考”。ArkTS里没有document、window、navigator这一套浏览器对象,UI是由ArkUI框架的组件树描述的。你在前端写的document.getElementById在ArkTS里不存在,取而代之的是组件的状态、事件回调和@Builder构建的UI描述。说白了,ArkTS把“命令式地操作界面”这条路基本堵死,逼着开发者走“声明式描述状态,状态驱动界面刷新”的路线。这一点是新手最容易忽略的隐性成本。
2.2 状态驱动UI:页面为什么会被“自动”刷新
ArkTS最核心的模型是“状态驱动UI”。你可以把页面理解成一个函数:UI = f(state)。当函数里的状态变量发生变化时,框架自动重新计算依赖了该状态的组件,只更新受影响的节点。代码里体现为@State、@Prop、@Link这类装饰器修饰成员变量,build方法里使用这些变量的UI组件会被框架标记为“依赖方”。
这个机制带来了两个好处:一是开发者不用再手动维护“数据变了去改哪个DOM节点”的逻辑,二是状态变化的追踪是精确到变量级别的,比全页重绘高效得多。面试里的ArkTS八股翻来覆去就是状态管理、生命周期、数据流这几个考点,但项目里的坑恰恰也从这里长出来。后面第4章我会用完整代码演示状态在不同组件之间怎么流动,以及哪些写法会让框架“看不见”你的修改。
2.3 运行时约束与依赖选型:为什么有的npm包不能直接进来
ArkTS的代码最终经过方舟编译器处理,运行在HarmonyOS的应用运行时里。这个环境没有浏览器API,也不完全等同于Node.js。因此,一个npm包能不能直接用,取决于它是否依赖了DOM、window、process、Buffer这些非ES标准能力。很多前端常用的工具库在ArkTS项目里一编译就报一堆“Cannot find name 'document'”,不是你的配置问题,是运行环境根本不提供这些对象。
选型时的常见做法是:优先选纯逻辑、无平台依赖的库,比如日期处理、数据结构类的;凡是涉及DOM操作、浏览器特性、Node内置模块的,要么找替代实现,要么自己用ArkTS重写薄薄一层。还有一个容易被忽略的点:有些纯逻辑库会在内部用到eval或Function构造器这类动态执行能力,ArkTS编译器出于安全考虑会限制这些用法,导入后也会在构建期报错。所以进依赖之前,先看一眼它的源码有没有碰这些红线,比装完再拆要省心得多。
3. 搭起一个最小HarmonyOS应用:用ArkTS写通首页与页面跳转
理论部分说完,开始落地。这一章从一个标准的工程骨架开始,写通两个页面并在它们之间传参。你会看到ArkTS页面结构长什么样、装饰器怎么用、路由跳转怎么带参数,以及我在参数传递上踩过的第一个坑。
3.1 工程结构与ArkTS文件怎么组织
一个用DevEco Studio新建出来的ArkTS工程,核心代码集中在entry模块下。main/ets目录里通常有两个关键目录:entryability放应用入口Ability,pages放页面组件。页面基本都是“一个文件一个组件”,文件后缀是.ets。这个目录结构虽然由IDE生成,但理解它有助于定位问题:启动流程是Ability创建窗口后,通过loadContent加载指定页面;之后页面之间的跳转,由路由或Navigation框架管理。
entry/src/main/ets/ ├── entryability/ │ └── EntryAbility.ets ├── pages/ │ ├── Index.ets │ └── Second.ets └── resources/ ├── base/ └── rawfile/Startup中的关键动作在EntryAbility.ets的onWindowStageCreate回调里:把pages/Index加载进来。这里不做多余逻辑,只保留加载入口,后续的业务页面都从Index开始扩展。Index.ets里最外层有一个@Entry装饰的组件,它标记了“这是页面入口”,路由跳转时的URL就是基于这个pages路径写的。搞清楚这层关系,跳转报错时就知道去检查页面路径和文件名是否一致。
3.2 第一个ArkTS页面:@Entry、@Component与build()
下面是一个最精简的页面。它不碰网络、不碰数据库,只演示ArkTS页面三要素:@Entry标记入口,@Component声明这是一个组件,build方法描述UI结构。状态变量用@State修饰,它的值一旦变化,依赖它的Text组件会自动刷新。
// entry/src/main/ets/pages/Index.ets @Entry @Component struct Index { @State message: string = 'Hello ArkTS' @State count: number = 0 build() { Column({ space: 12 }) { Text(this.message) .fontSize(28) .fontWeight(FontWeight.Bold) Text(`当前计数: ${this.count}`) .fontSize(18) Button('点我加一') .type(ButtonType.Capsule) .onClick(() => { this.count++ }) } .width('100%') .padding(24) } }这里有三点需要注意。Column的space参数控制子组件垂直间距,单位是vp(虚拟像素),不同屏幕密度下会自动换算;Button的type参数决定外观形态,Capsule是胶囊圆角,也可以不设或改成Normal;onClick回调里直接改this.count,ArkTS会记录这次赋值并触发依赖它的Text刷新。这套“改属性、等刷新”的模式贯穿整个项目,跟操作DOM是两种思维方式。
3.3 页面跳转与参数传递:router还是Navigation
页面多了就要跳转。ArkTS有两个常用方案:router和Navigation。router是轻量级路由,API直观,适合页面不太多、层级不太深的项目;Navigation是组件化导航方案,支持类似Flutter的页面栈管理,适合后续要搞复杂嵌套结构。我一般在小项目里先用router起步,等页面结构复杂了再迁移Navigation。下面是最常见的router跳转写法:
// 从 Index.ets 跳转到 Second.ets 并携带参数 import { router } from '@kit.ArkUI' Button('去第二个页面') .onClick(() => { router.pushUrl({ url: 'pages/Second', params: { taskId: 1024, from: 'Index' } }) })// Second.ets 中接收参数 import { router } from '@kit.ArkUI' @Entry @Component struct Second { @State taskId: number = 0 onPageShow(): void { const params = router.getParams() as Record<string, number | string> if (params && params.taskId) { this.taskId = Number(params.taskId) } } }接收端有两个细节容易翻车。一是取参数的时机:router.getParams()在页面onPageShow时往往已经可取,但如果你在aboutToAppear里读,个别版本下可能拿到undefined,稳妥做法是在onPageShow里读并缓存到成员变量。二是params适合传可序列化的简单数据,传对象时建议只传ID这类标识,真正的数据到目标页再取,避免“页面还没建好,对象已经被改掉”的时序问题。这套“跳转传ID、落地取数据”的习惯,能让参数链路清晰很多。
4. 业务状态怎么管:@Prop/@Link/@Observed与跨组件数据流
第3章跑通了单页面的基础状态。真实项目很快会遇到另一个问题:页面里的自定义组件越来越多,状态怎么在父子组件之间传递?用全局变量行不行?改了对象数组为什么有时刷新有时不刷新?这一章把这些问题拆开讲,代码可以直接抄进工程里对照验证。
4.1 单向数据流:父子组件之间为什么要一进一出
ArkTS的自定义组件允许接收外部传入的数据,用@Prop和@Link两个装饰器区分。@Prop是单向的,父组件传什么进来,子组件展示什么;子组件内部修改这个值,只会影响自己,不会反向改到父组件。@Link则建立双向绑定,子组件里改了,父组件对应的变量也跟着变。用哪个不是拍脑袋的事:我一般的原则是“能单向就不双向”,只有像输入框、开关这类强交互的受控组件才用@Link。原因是双向绑定把数据流变复杂,一旦页面逻辑多起来,调试时很难说清“现在这个值到底是谁改的”。
下面的例子是一个待办列表的子项。父组件把任务标题和完成状态传进来,子项负责展示和切换勾选状态。
// 子组件:待办列表的每一项 @Component struct TaskItem { @Prop title: string @Link isDone: boolean build() { Row({ space: 8 }) { Checkbox() .select(this.isDone) .onChange((checked: boolean) => { this.isDone = checked }) Text(this.title) .fontSize(16) } } }// 父组件:待办列表 @Entry @Component struct TaskPage { @State taskTitle: string = '完成方案初稿' @State finished: boolean = false build() { Column({ space: 16 }) { TaskItem({ title: this.taskTitle, isDone: $finished }) } } }注意传参写法:isDone传入的是$finished,不是this.finished。$符号表示“把变量的引用交给子组件”,这是@Link在父侧的固定语法;而@Prop就不需要$,直接传值即可。这个区别接住了很多新手第一次踩的坑——漏掉$,子组件改了没反应,编译器也不报错,因为参数类型检查是宽松的。
4.2 数组和对象的状态刷新:为什么splice之后UI没动
这是ArkTS项目里翻车率最高的问题之一:页面里有个@State修饰的数组,你用splice删了一个元素,页面没动;你用task.done = true尝试改某个对象的属性,页面也没动。现象一样,原因不同。
第一种情况,直接给数组的索引赋值不行。比如this.tasks[0] = newTask,这种操作对ArkUI的观测机制来说是不可见的。正确做法是用数组整体的赋值操作替换引用,常见做法是先用filter或map生成新数组,再整体赋值;或者用splice后立刻this.tasks = this.tasks.slice(),让框架感知数组引用变化。第二种情况,对象属性的修改要对“对象本身”做观测标记:用@Observed装饰类,用@ObjectLink在子组件里接收该类实例,这样改属性才会触发UI刷新。
// 用 @Observed 标记数据模型 @Observed class Task { title: string done: boolean = false constructor(title: string) { this.title = title } } @Component struct TaskRow { @ObjectLink task: Task build() { Row({ space: 8 }) { Checkbox() .select(this.task.done) .onChange((checked: boolean) => { this.task.done = checked }) Text(this.task.title) } } }这段代码的关键在@ObjectLink的配合:父组件持有@State tasks数组,数组里的每个元素用@Observed类实例来new;渲染子项时,通过@ObjectLink把单个Task实例传进子组件。此时check任务会直接改变this.task.done,ArkUI能观测到这个属性级变化并刷新对应组件。这个模式适合“列表项内部有可交互状态”的场景,比每次改动都整体重建数组高效,也更好读。
4.3 全局状态:AppStorage、LocalStorage与自写单例的取舍
跨页面共享的用户信息、主题设置这类状态,不适合放在@State里一级级往下传。ArkUI提供了AppStorage作为应用级状态中心,配合@StorageProp和@StorageLink装饰器使用,可以在任意组件里读写全局变量,并自动刷新依赖它的UI。LocalStorage则用于单个页面内部共享状态,我理解它是“作用域更小的AppStorage”。如果项目里临时要存的东西不多,也可以不用这些装饰器,直接写一个TS单例类来管理全局数据,页面里手动订阅或拉取。
我的习惯是分场景处理:纯数据模型、需要跨组件响应式刷新的,优先用AppStorage,因为响应式是现成的;不涉及UI联动、只是存一份查询结果的,用普通单例类更直观,后续好测试、好替换。一个容易误用的点是在AppStorage里塞复杂对象,比如整个页面状态树。这种做法会让状态来源变得不可追踪,调试时很难判断是哪个页面改的。更好的做法是只放跨页面需要的“标识性数据”,页面内部结构仍然由该页面的@State管理。
5. 避坑:ArkTS项目开发中高频踩坑记录
这一章是血泪经验。下面五条都是我在实际写ArkTS项目时遇到的问题,每一条都按“现象 → 原因 → 解决”的顺序写,方便你对照排查。
5.1 数组改了UI不刷新:观测机制的边界在哪
现象:用this.tasks[0] = newTask替换数组项,或者直接给数组元素加一个新属性,页面没有任何反应。原因:ArkUI能够观测的是数组本身的方法调用和引用替换,对“索引赋值”和“动态加属性”这类操作不做深层监听。解决:改数据时优先用push、splice、或整体赋值替换引用;如果确实要改某个元素的属性,把这个元素使用@Observed类配合@ObjectLink,或者干脆生成新数组再整体赋值。这属于每次排查必然要过一遍的检查项,建议项目里统一定个规范,禁止直接索引赋值。
5.2 router.getParams() 在页面刚创建时拿不到
现象:在aboutToAppear里调用router.getParams(),打印结果是undefined,但onPageShow里又能取到。原因:页面生命周期有先后,aboutToAppear阶段路由参数可能尚未注入完成,不同版本SDK的行为也不完全一致。解决:把读取参数的逻辑统一放在onPageShow里,只读一次并缓存到成员变量;不要在每个生命周期回调里都去读一遍,避免拿到不一致的结果。这个问题的排查难点在于它“时好时坏”,跟页面加载速度有关,建议一次定死读取位置。
5.3 npm包构建报错:运行时环境与Web不同
现象:引入一个前端常用的工具库,编译时报了一堆“Cannot find name 'document'”或“Property 'Buffer' does not exist”之类的错。原因:ArkTS的运行环境没有浏览器DOM和Node.js API,库的源码里一旦出现这些引用,就会在构建期暴露出来。解决:选依赖前先确认它是否依赖平台API;如果只是用到了某一个函数,直接把这个函数实现复制过来更可控,而不是整个包拖进来。这条建议在团队里尤其有用——省得每次构建失败都有人去翻依赖源码。
5.4 页面里所有逻辑都堆在build()里,拆不动
现象:页面刚开始功能少,build()写了四五百行,后面加需求时改一处要滚动半天,组件复用基本靠复制粘贴。原因:缺少组件拆分的习惯,也低估了界面复杂度的增长速度。解决:用@Builder把页面里的独立区块抽出来,比如列表项、卡片、弹窗内容;再进一步把超过30行的独立区块改成自定义组件,通过参数传入数据。ArkTS的组件化设计就是用来干这个的,不要因为一开始页面小就放弃拆分,越到后期越难拆。
5.5 热重载之后状态错乱,页面白屏
现象:开发时改完代码触发热重载,界面偶发白屏或状态值回到初始值,重启应用又正常。原因:热重载对状态变量的重新初始化处理并不总是完整保留,依赖了外部状态或全局单例的页面更容易出现。解决:碰到白屏先用“停掉应用重新启动”排除;根治上,尽量把页面状态初始化逻辑收敛到构造函数或aboutToAppear里,避免依赖“上次热重载留下的上下文”。这条不算框架Bug,更像开发工具的边界,理解它的存在能减少无谓的查错时间。
6. 让项目持续可控:状态更新验证、性能观测与后续方向
项目跑通以后,真正的工程问题才开始:状态到底有没有按预期刷新?刷新次数是否过多?页面卡顿是渲染问题还是状态问题?我的做法是给每个关键状态变量建一条“可观测链路”。比如一个@State count,在每次变化的位置打一条带业务上下文的日志,再配合的DevEco Studio里自带的ArkUI Inspector和Profiler工具,就能看清组件树结构、帧率和状态变更点。日志打点只用于开发期验证,发布前统一摘掉,不额外占包体积。
// 开发期验证状态更新的打点示例 @State count: number = 0 private updateCount(delta: number): void { this.count += delta console.info(`[state] count -> ${this.count}`) }如果发现一个简单操作引起大量组件刷新,优先检查是不是把大对象整体放进了@State,或者@Prop传入了深层嵌套数据。此时最值得先做的优化不是加缓存,而是把状态层级降下来:页面级状态只保留“当前正在编辑的项ID”,具体的表单值让子组件自己管理。这样父组件刷新范围会小很多。
先把这个状态关系的图在脑子里画清楚,再动手改代码,往往比反复看火焰图更有效。我接手ArkTS项目的习惯是先花半天梳理状态流向:哪里用@State、哪里用@Link、哪里用AppStorage,全部记在一张纸上。多个项目下来,这套思路帮我把排查时间压缩了一大半。后面的通信、并发、长列表这些更复杂的内容,也都是建立在状态模型清晰的前提之上。希望帮到你。
本文还有配套的精品资源,点击获取