
简介一套基于 HarmonyOS ArkTS 开发的网吧会员终端应用源码工程面向鸿蒙生态的初中级开发者适合通过完整项目学习 ArkTS 页面开发、SQL 关系型数据库与 Preferences 首选项存储。应用功能覆盖引导页、登录、注册、找回密码、网吧列表与详情、会员充值、充值记录、修改密码、个人信息及会员详情、退出应用等业务链路完整。资源包共 343 个文件压缩包大小 36.58MB主要包含 ets 类型页面源码、js/ts 逻辑脚本、json 与 json5 配置文件、png/jpg 图片资源、protobin 资源索引及可直接运行的 hap 安装包目录结构清晰便于按模块对照阅读。目前已有 228 人学习下载。借助该工程开发者可快速理解鸿蒙应用从页面导航、数据绑定到本地持久化存储的整体实现也能通过实际业务代码梳理功能分层与排错思路是课程设计、毕业设计或鸿蒙应用入门进阶的一份实用参考。1. ArkTS到底给鸿蒙开发带来了什么先分清它与TS/JS的关系刚接触HarmonyOS应用开发的工程师第一眼看到ArkTS通常两种反应一种觉得“这不就是TypeScript换个皮”另一种对着装饰器和UI语法一脸懵。实际动手两周后你会发现它既不是简单换皮也不是全新的语言而是站在TypeScript类型系统之上、为鸿蒙ArkUI框架定制的一套声明式开发范式。ArkTS把“页面长什么样”和“数据怎么变”这两件事用装饰器和状态管理明确地拆开让UI随数据驱动而不是靠手动操作DOM节点刷新界面。这篇文章面向的是准备把业务落到鸿蒙原生应用上的团队和个人开发者。你会从工程搭建、状态管理、路由跳转、数据持久化一路走到常见翻车现场最后拿到一套能直接照做的调优和验证清单。文中所有代码都基于DevEco Studio当前稳定版本能用最小工程跑通的绝不多写一行。2. 用DevEco Studio搭起ArkTS工程目录结构与最小页面2.1 从创建工程到第一个页面必须改的三个配置创建HarmonyOS工程时DevEco Studio会生成一个标准的两模块结构entry是应用主模块hvigor是构建脚本配置。很多新手一上来就写代码忽略了三个直接影响编译和运行的配置项等到真机装不上、签名报错才回头找原因。第一个是module.json5里的deviceTypes字段。默认工程只勾选了phone和tablet如果要跑在鸿蒙平板上或者后续要适配折叠屏必须手动把对应设备类型加进去。第二个是build-profile.json5里的signingConfigs。用模拟器调试时可以不配签名但真机安装必须要有一个有效的调试证书而这个证书和你的华为账号、设备UDID是绑定的。第三个是entry/src/main/resources/base/profile/main_pages.json它登记了页面路由表中所有页面路径。你每新建一个页面就要在这里注册一次否则运行时路由跳转直接找不到页面。下面用一个最小示例说明这三个配置之间的关系。先看module.json5里deviceTypes的改动{ module: { name: entry, type: entry, deviceTypes: [phone, tablet], // 按需追加缺了平板装不上 deliveryWithInstall: true, installationFree: false } }deviceTypes数组决定这个模块能安装到哪些形态的设备上。deliveryWithInstall和installationFree这对参数控制应用是否随安装分发、是否支持免安装普通项目保持默认即可。真机调试时DevEco Studio的“File Project Structure Signing Configs”会自动生成调试证书不要手动去改build-profile.json5里的证书路径改错一个字符就是编译期一串红色报错。需要说明的是main_pages.json里的路由表修改频率比你想象得高。每加一个页面就要同步登记这是ArkTS工程里最容易漏的配置项漏掉的直接报route not found和页面代码本身没关系。2.2 entry模块里的ArkTS文件pages与resources怎么配合一个页面的最小构成是三件套.ets文件写UI逻辑、resources目录放图片和多语言文案、module.json5和路由表把页面串起来。页面文件本身并不复杂真正让新手迷惑的是资源引用路径和状态声明的关系。看一下entry/src/main/resources/base/element/string.json里的字符串定义{ string: [ { name: module_desc, value: entry模块 }, { name: main_title, value: 欢迎使用ArkTS } ] }在页面里通过$r(app.string.main_title)引用。这里有个常见误区资源名一旦在string.json里定义就不能直接删除否则编译时所有引用它的地方一起报错。开发中我习惯把文案全部集中到element目录管理代码里不写死字符串这样后续做多语言时只需要复制一份zh_CN和en_US目录。再看一个最基础的页面它同时展示了声明式UI和资源引用的标准写法Entry Component struct IndexPage { State message: string $r(app.string.main_title).toString(); build() { Column({ space: 12 }) { Text(this.message) .fontSize(24) .fontWeight(FontWeight.Bold) Button(点击换文案) .onClick(() { this.message 你按下了ArkTS按钮; }) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) } }Entry标记这是页面入口Component声明这是一个UI组件State表示这个变量是响应式的——当message被重新赋值时页面会自动局部刷新Text组件。Column是垂直布局容器类似传统前端里的flex-direction: column。$r是资源引用函数会在编译阶段按当前语言环境解析成具体字符串。这里需要理解一点ArkTS的UI不是“画”出来的而是“声明”出来的。你描述的是UI在任意数据状态下的形态系统负责比较前后状态差异并最小化刷新。这也是ArkTS与命令式开发最核心的思维转变——别想着document.getElementById然后改innerText而是改变数据源让UI自己跟着变。2.3 自定义组件与Builder避免页面膨胀的第一道闸页面一多组件化就成为一个必须解决的问题。ArkTS里自定义组件就是一个被Component修饰的struct把重复出现的UI片段抽出去比如商品卡片、列表项、弹窗内容。下面这段代码演示了父子组件传值和Builder函数的使用Component export struct ProductCard { Prop title: string; // 父组件传入的标题单向同步 Prop price: string; Builder customFooter() { Row({ space: 8 }) { Text(查看详情).fontSize(14).fontColor(#007DFF) Text(立即购买).fontSize(14).fontColor(#E84026) } } build() { Column({ space: 8 }) { Text(this.title).fontSize(18).fontWeight(FontWeight.Medium) Text(this.price).fontSize(16).fontColor(#E84026) this.customFooter() } .padding(16) .backgroundColor(Color.White) .borderRadius(12) } }Prop在父组件初始化时把值复制一份给子组件之后子组件内部改这个值不会反向影响父组件。Builder函数可以让你在build()里复用一段UI结构它和普通函数的区别是它在编译阶段被展开成UI描述节点不是在运行时返回一个对象。组件化的收益在页面超过200行时特别明显。一个页面塞满各种if/else和嵌套布局调试时连缩进都看不明白拆成组件后每个文件只干一件事定位问题快很多。我一般在页面首次超过300行时就开始拆不等它膨胀成“屎山”再后悔。3. 把状态管理用明白State、Prop、Link与Provide/Consume3.1 装饰器是怎么把数据变成UI的声明式绑定的执行顺序ArkTS的状态管理基于一组装饰器它们的核心作用是把变量变成“可观察数据”。当数据变化时框架自动找到依赖它的UI组件并刷新这套机制类似响应式前端框架但细节和参数行为有差异。最常用的四个装饰器要搞清楚区别State是组件内部状态只能在组件内修改Prop是父组件传入的初始化值子组件本地修改不会同步回父组件Link是双向同步子组件改了父组件也跟着变Provide/Consume兄弟组件或跨层级共享状态不用一层一层传props。先看一段代码来理解执行顺序Component struct Counter { State count: number 0; build() { Column({ space: 16 }) { Text(当前计数: ${this.count}).fontSize(20) Button(加一) .onClick(() { this.count; }) Button(加十) .onClick(() { this.count 10; }) } } }按钮点击后this.count变化框架会在下一个UI渲染周期里把依赖count的Text组件标记为“脏”然后重新渲染该组件。这里有一个性能视角的关键点State只对简单类型和对象的“整体替换”做响应式处理。如果你修改的是对象内部的某个属性框架是观察不到的。interface UserInfo { name: string; age: number; } Component struct UserBox { State user: UserInfo { name: 张三, age: 20 }; build() { Column({ space: 12 }) { Text(${this.user.name} 今年 ${this.user.age} 岁) Button(改姓名) .onClick(() { // 这样改UI不会刷新 this.user.name 李四; }) Button(改姓名正确写法) .onClick(() { // 重新赋值整个对象UI才感应到变化 this.user { ...this.user, name: 李四 }; }) } } }第一种写法this.user.name 李四改的是user对象内部属性State监听的是引用本身。想刷新就得创建一个新对象重新赋值。这个坑几乎每个从Vue或React过来的人都会踩一轮因为Vue3的响应式已经做到了深层代理ArkTS这里没那么“智能”。3.2 父子组件传值Prop与Link的边界和改法实际项目中页面会被拆成多个子组件怎么传数据就成了日常操作。Prop和Link的适用场景完全不同选错了要么数据不刷新要么出现循环更新警告。Prop适合父传子、单向展示的场景比如列表项组件只需要显示数据而不修改Link适合子组件需要反向修改数据的场景比如表单输入框、开关控件。看代码理解两者差异// 父组件里声明一个开关状态 State switchOn: boolean false; // 父组件build中使用子组件 ToggleSwitch({ isOn: this.switchOn }) // 子组件定义 Component struct ToggleSwitch { Prop isOn: boolean; build() { Toggle({ type: ToggleType.Switch, isOn: this.isOn }) .onChange((val: boolean) { // Prop只是快照这里改了也不会通知父组件 }) } }如果你希望开关切换后父组件的switchOn同步变化必须把Prop改成LinkComponent struct ToggleSwitch { Link isOn: boolean; build() { Toggle({ type: ToggleType.Switch, isOn: this.isOn }) .onChange((val: boolean) { this.isOn val; // 直接修改Link父组件响应更新 }) } }使用Link时父组件传入必须携带$前缀例如ToggleSwitch({ isOn: $switchOn })。这个$符号表示引用而非值拷贝很多人卡在这一步报“isOn类型不匹配”或者“状态无法同步”时优先检查是不是少了$。3.3 跨层级共享Provide/Consume与Observed/ObjectLink怎么用当组件层级超过三层用Prop一层层传就变得很痛苦。ArkTS提供了Provide和Consume来实现类似“上下文”的能力——祖先组件提供数据任何后代组件通过Consume声明即可直接拿到。Component struct GrandParent { Provide username: string HarmonyOS用户; build() { Column() { ParentComponent() } } } Component struct ParentComponent { build() { Column() { ChildComponent() // 不用自己传username } } } Component struct ChildComponent { Consume username: string; build() { Text(问候你${this.username}) } }Consume只能在初始化阶段绑定到祖先已提供的Provide变量。运行时如果找不到对应的Provider会直接报错。同一个变量名如果有多个Provide就近绑定这点和JS作用域类似。Observed/ObjectLink解决另一个问题State整体替换对象的局限。用Observed标记的类其内部属性变化也能被观察到再配合ObjectLink传递给子组件就能做到子组件修改深层属性时UI自动刷新。Observed class ShoppingCart { items: string[] []; total: number 0; addItem(name: string) { this.items.push(name); this.total 1; } }这种写法让状态管理更加贴近真实业务模型。注意Observed类不能被继承它的方法体里不能使用async/await调试时遇到这些限制直接改设计别硬绕。4. 做真实项目应用路由跳转与数据持久化落地的标准写法4.1 router与Navigation两种跳转方式的取舍HarmonyOS页面跳转有两条路线router模块是传统的命令式路由适合页面少、跳转逻辑固定的场景Navigation是声明式导航适合底部Tab、多级嵌套、需要动态控制返回行为的场景。两者API风格差别巨大混用容易出问题一个页面栈里最好不要同时用两套导航。router的使用非常直接import { router } from kit.ArkUI; // 跳转到详情页并携带参数 router.pushUrl({ url: pages/SecondPage, params: { id: 123, name: HarmonyOS } });目标页面通过router.getParams()取出参数。注意router的页面栈是全局的router.back()返回上一页router.clear()清空整个栈。如果你的应用只有一个主流程router足够用。Navigation则是用一个NavPathStack实例来管理跳转好处是可以把路由参数类型化跳转前做校验Entry Component struct MainPage { pathStack: NavPathStack new NavPathStack(); build() { Navigation(this.pathStack) { Button(跳转到详情) .onClick(() { this.pathStack.pushPathByName(DetailPage, { id: 42 }, false); }) } .hideTitleBar(true) .mode(NavigationMode.Auto) } }pushPathByName第三参false代表不往系统返回栈里加压直接替换当前页面true则会压栈系统返回键能回到上一页。这个参数决定用户返回行为特别重要。做Tab页时切换Tab不要让tab页面往路由栈里压否则每次切换都产生历史记录用户按返回键会陷入混乱。4.2 Preferences落地一个设置页读、写、监听数据持久化是应用开发的标配需求。轻量级数据用户设置、登录token、上次阅读位置用Preferences最合适它本质上是键值对数据库底层存储在XML文件里。重量级数据大量业务列表需要上SQLite或分布式KV。先看Preferences的完整读写流程import { preferences } from kit.ArkData; // 获取Preferences实例name定义存储文件名 let store: preferences.Preferences | null null; async function getPref(context: Context): Promisepreferences.Preferences { if (!store) { store await preferences.getPreferences(context, my_settings); } return store; } // 读操作 export async function readSetting(context: Context, key: string): Promisenumber | string | boolean { const pref await getPref(context); const value await pref.get(key, default); // 第二个参数是默认值 return value; } // 写操作 export async function writeSetting(context: Context, key: string, value: number | string | boolean) { const pref await getPref(context); await pref.put(key, value); await pref.flush(); // flush之后才真正落盘 }flush()是同步落盘的调用不调flush()程序退出后数据可能丢失这是Preferences最典型的翻车点。flush()高频调用会影响性能推荐批量修改后统一flush一次。Preferences支持监听数据变化function observeSetting(pref: preferences.Preferences, key: string, callback: (newValue: preferences.ValueType) void) { pref.on(change, (curPref) { const val curPref.get(key, ); callback(val); }); }这个监听在需要跨页面同步设置变化时很有用比如全局深色模式开关。但注意注册了监听一定要在页面销毁时用pref.off(change, callback)解绑否则页面退栈后回调还在执行就是肉眼可见的内存泄漏。4.3 与系统能力打交道权限声明与回调HarmonyOS对敏感权限的管控比Android更严格。相机、位置、麦克风都要在module.json5里声明requestPermissions字段运行时再向用户弹窗请求授权。先看配置声明怎么写{ module: { requestPermissions: [ { name: ohos.permission.CAMERA, reason: 用于扫描二维码, usedScene: { abilities: [EntryAbility], when: inuse } }, { name: ohos.permission.ACCESS_FINE_LOCATION, reason: 用于获取周边推荐服务, usedScene: { abilities: [EntryAbility], when: always } } ] } }usedScene.when字段填inuse表示仅前台使用always表示后台也要用。系统在应用商店审核时特别关注always权限没有明确功能场景基本不给过。建议能用inuse就绝不用always省得审核麻烦。运行时请求权限的常用写法import { abilityAccessCtrl, Permissions } from kit.AbilityKit; import { BusinessError } from kit.BasicServicesKit; async function requestPermission(context: Context, permission: Permissions) { const atManager abilityAccessCtrl.createAtManager(); try { // 先查授权状态alreadyGranted直接返回 const res await atManager.checkAccessToken(context.applicationInfo.accessTokenId, permission); if (res abilityAccessCtrl.GrantStatus.GRANTED) { return true; } // 未授权则弹窗申请result为0表示用户同意 const result await atManager.requestPermissionsFromUser(context, [permission]); return result.authResults[0] 0; } catch (err) { const bizErr err as BusinessError; console.error(权限请求失败: code${bizErr.code}, message${bizErr.message}); return false; } }这里要特别注意checkAccessToken在应用首次启动时如果用户还没做出过选择返回的是DENIED。此时必须走requestPermissionsFromUser弹窗。有些开发者图省事直接弹窗结果频繁打扰用户被系统自动拉黑——权限弹窗的申请次数是有限制的连续多次被拒后系统不再弹窗用户只能去设置里手动打开。这个坑毁了不少刚上架的应用。5. ArkTS开发避坑指南现象、原因与解决5.1 构建失败却看不到语法错误SDK与编译缓存现象代码在同事电脑上编译正常自己电脑上构建报错arkts-analyzer崩溃或者错误信息指向的是node_modules里的一堆压缩代码完全看不懂。升级DevEco Studio后突然冒出一堆红色波浪线重启也没用。原因ArkTS编译器版本和SDK版本不匹配另外hvigor的增量构建缓存在某些场景下会坏掉比如非正常关机、手动删除了oh_modules目录部分文件。还有一种情况是安装了多个版本的APISDK路径自动切换导致缓存失效。解决不要急着改代码。先在File Project Structure SDK Management里确认当前API版本和编译工具版本。然后清掉hvigor缓存删除项目根目录下oh_modules和.hvigor文件夹重新构建。如果还不行就到~/.hvigor下删掉旧版本的临时文件再回DevEco Studio菜单Build Clean Project一次。这套组合拳基本能解决九成玄学编译失败。5.2 状态不更新State没被赋值还是对象引用没变现象点击按钮后页面纹丝不动控制台也没有任何报错。把数据打印到日志里看值其实已经改了就是UI不刷新。原因前面说过State只观察引用变化不观察对象内部属性变化。另一个常见原因是你在子组件里改的是Prop的本地拷贝父组件原数据没变。还有一种情况你在非UI线程比如网络回调里改State变量ArkTS的状态更新必须回到主线程执行。解决对象类型通过解构创建新对象再整体赋值。网络请求回调里用runOnMain包装状态更新代码。检查子组件传参到底是Prop还是Link需要联动的一定用Link不需要联动的用Prop反而更安全。如果确认都做到了还不刷新试一下在build里把变量多打印一次强逼框架感知依赖。5.3 真机与模拟器表现不一致签名与权限现象模拟器上一切正常真机上应用启动后白屏或者调用相机直接闪退日志显示Permissions denied。有些人用的是自己下载的release签名包装到别的手机上就提示解析失败。原因模拟器默认所有权限自动授予真机需要逐项申请。另外真机安装的包签名必须和申请权限时的证书一致否则系统不认这个应用的权限声明。HarmonyOS的权限绑定签名文件换一台电脑构建出的证书不一样老包就会被系统标记为脏数据。解决保持“模拟器跑逻辑、真机验权限”的习惯。所有涉及系统权限的功能直接拿真机调试不要在模拟器上确定“功能没问题”。上线前用统一的企业证书或应用市场证书签名不要每台电脑用各自的debug证书打release包。真机白屏的另个检查点是main_pages.json里的页面路径是否区分大小写模拟器容错高真机路由表校验严格。5.4 页面返回数据丢失路由参数与状态栈现象A页面填了一堆表单跳B页面点击返回回到A页面发现所有填好的内容全变成初始值连滚动位置都复位了。原因用router.pushUrl跳转后A页面可能被系统在内存紧张时销毁返回时重新走aboutToAppear。还有一种情况是你用了router.replaceUrl它会把当前页面替换原来的表单页直接出栈再回来只能重新加载。解决表单这类重要数据不要全指望页面栈保活。在aboutToDisappear里把关键字段写入Preferences或AppStorage返回时恢复。追求更灵活的返回策略用Navigation的pushPathByName可以指定是否压栈系统销毁页面时你还能拿到回退回调做数据暂存。5.5 内存泄漏全局变量与未解绑的监听现象应用长时间运行后越来越卡真机调试的Memory Profiler里看到内存只涨不降反复进出同一个页面后数值持续上升。原因大概率是全局变量持有页面实例或者事件监听没解绑。常见三个雷把Component实例存到全局Map里注册了AppStorage的onChange回调但页面销毁时不移除定时器setInterval没在aboutToDisappear里清掉。解决养成页面销毁时清理的习惯。aboutToDisappear里执行四件事清定时器、解绑事件监听、置空全局引用、取消网络请求。如果你用了Consume不需要手动清理框架会处理绑定关系。写一个通用的Cleanable接口让每个页面组件统一实现cleanup()方法在页面消失时集中调用比散在各处硬记要可靠得多。6. 把性能逼出来从build视图到真机抓包的验证习惯应用开发收尾阶段最怕的不是功能bug而是“感觉有点卡但不知道卡在哪”。ArkTS页面卡顿通常源于三件事主线程做了耗时计算、列表组件没有懒加载、状态更新粒度太大。第一件事看Profiler。DevEco Studio的Profiler工具能录制一段时间内的帧渲染耗时和主线程任务分布。启动Profile后操作页面结束后看Main Thread里的长时间任务红色块超过16ms就是卡顿元凶。常见解决是耗时计算放TaskPool完成任务后再把结果抛回主线程更新状态。注意TaskPool里不能用闭包捕获大对象传参数时挑基本类型。第二件事针对列表。长列表必须用LazyForEach替代ForEach它按需创建组件滑出视口的节点会被回收。LazyForEach需要一个数据源类实现IDataSource接口懒加载的容器用List最合适。class ListDataSource implements IDataSource { data: string[] []; totalCount(): number { return this.data.length; } getData(index: number): string { return this.data[index]; } registerDataChangeListener(listener: DataChangeListener): void { // 注册数据监听数据变化时通知列表刷新 } unregisterDataChangeListener(listener: DataChangeListener): void { // 页面销毁时反注册 } }不加缓存的列表在数据超过50条后会明显掉帧这是ArkTS应用被吐槽“卡”的第一大原因。第三件事是状态更新粒度。一个页面里如果整个build()都依赖一个频繁变化的变量每次变化都会引发全页面重新描述。做法是把这部分拆成独立子组件让高频变化的数据只影响一个小组件减少框架比较节点树的开销。验证性能有个笨但有效的习惯打开hdc shell连接真机启动应用后用hdc shell top看进程CPU占用。如果CPU一直在10%以上而且没有做任何密集操作说明有隐式刷新在空转。配合日志里在aboutToAppear和aboutToDisappear打点确认页面退出时没有遗留的循环任务。把一个页面从“能跑”调到“稳住60帧”需要反复尝试。我的个人教训是性能优化要一路做一路验证别等所有页面写完再统一调到时候问题堆积在一起你连是哪个组件引起的都定位不出来。先让一个页面跑顺跑稳把这套方法沉淀成团队规范再铺到其他页面快很多。希望这些从工程里摸出来的经验能帮你少走几段弯路也欢迎你在自己的项目里继续往深里挖——踩过坑的地方才是真正能积累出护城河的地方。本文还有配套的精品资源点击获取