☰
鸿蒙Stage模型+ArkTS实战:健身预约系统全链路开发解析
2026/10/16 10:42:38 网站建设 项目流程

简介:这套鸿蒙全民健身预约系统应用开发源码,面向鸿蒙移动应用开发者与高校实训学员,提供一套包含后台管理端与移动端的完整健身预约项目。应用覆盖用户管理、场馆动态、运动常识、预订管理、预约通知等核心模块,支持查看可预约的场馆、教练与陪练,并实现多端流转与协同交互,界面简洁,兼顾隐私与数据安全。压缩包共三百五十五个文件,大小约十兆,其中包含九十九张图片素材、六十三个代码文件、三十八个配置文件等,结构清晰,便于按模块研读或二次开发。已有五百二十七人学习下载。作为首次发布的移动端实训作品,该源码还原了真实预约业务链路,适合作为课程设计、毕业设计或鸿蒙应用开发入门进阶的参考项目,可帮助读者快速理解鸿蒙工程组织方式与预约类应用的设计思路。

1. 全民健身预约系统到底是什么:一个能用鸿蒙原生能力直接跑起来的预约闭环

一个运动场馆的预约场景,远比想象中琐碎。打开健身App,用户要的不是看广告,而是快速知道今天哪个时段有空场、约上之后到场不用报手机号、临时有事能取消。这套鸿蒙全民健身预约系统App,做的就是这件事:从场馆列表、课程时段选择、预约提交到订单状态管理,全部在鸿蒙设备上原生跑通,不依赖浏览器壳,也不借助第三方小程序容器。

适合两种人拿它当起点。一是刚接触HarmonyOS应用开发、想找个完整项目练手的开发者——它把Stage模型、ArkTS状态管理、路由跳转和本地持久化串成一条完整链路,比自己零散拼Demo更接近真实App的结构。二是确实要做健身场馆、体育场地预约类产品的人,这套代码相当于一个可运行的前端骨架,业务字段、页面流程都能直接改造成自己的预约系统。

全文围绕一个核心展开:一次预约请求,在鸿蒙App里从首页点击到订单生成,数据是怎么流转的。下文会按工程目录、界面层、状态层、数据层逐层拆,再给你真机运行的具体步骤和踩坑记录。

2. 技术架构选型:为什么用Stage模型加ArkTS做预约闭环

先看整个项目的技术骨架。HarmonyOS从API 9开始全面推行Stage模型,这套预约系统就是按Stage模型的标准姿势组织的。Stage模型和老的FA模型最大的区别在于:代码必须模块化,每个Ability都有独立的生命周期,UIAbility负责界面,DataAbility或者模块化的数据层负责业务数据,组件不能越权直接访问系统资源。

2.1 工程形态:Ability、Page与组件三层分工

打开工程先看AppScope目录下的app.json5,里面声明了应用包名和图标,这个文件相当于整个App的门面配置。真正的业务代码在entry模块下,entry/src/main/ets里分成几个核心目录:entryability装着唯一的UIAbility入口,pages目录放页面级组件,common放公共工具和常量。

三个层次的职责边界很清晰。UIAbility只做两件事:onCreate里初始化全局数据,onWindowStageCreate里加载首页。页面组件负责拼装UI和接收用户事件,公共模块负责被多个页面复用的方法,比如日期格式化、预约状态映射这类纯函数。

2.2 状态管理选型:@State、@Prop与@Link的配合方式

预约流程里最麻烦的不是页面多,而是状态分散。选课时段要同步到确认页,确认页改了备注要回传首页。如果全部用全局变量,页面一销毁数据就没了;全部用持久化,每改一个字段都写磁盘又太重。这套工程的做法是分了两层:页面内部用@State管局部状态,页面间用router带参数传递,订单落库用Preferences持久化。最顶层不需要AppStorage级别的全局状态,因为预约流程的共享数据集中在一次操作会话里,路由参数足够承担。

@Entry @Component struct BookingPage { @State selectTime: string = '' @State courtType: number = 1 // 1-羽毛球 2-篮球 3-健身房 @State remainingCount: number = 0 build() { Column() { Text(this.selectTime ? `已选: ${this.selectTime}` : '请选择时段') ForEach(this.timeSlots, (slot: string) => { Button(slot).onClick(() => { this.selectTime = slot this.remainingCount = 20 - Math.floor(Math.random() * 5) }) }) } } }

这段代码演示了页面内部状态的管理方式。@State装饰的变量一旦变化,build方法自动重新执行,UI跟着刷新,不需要手动操作DOM。timeSlots是页面里定义好的静态时段数组,点击时段按钮后既更新选中文案,也模拟计算出剩余名额。

2.3 数据持久化:Preferences而不是关系型数据库的原因

预约记录、用户收藏的场馆信息这类数据,特点是单条数据小、读写频率不高、结构固定。鸿蒙自带的关系型数据库(RDB)功能强但初始化代码长,分布式数据库在这个单设备场景里没必要。项目选用@ohos.data.preferences里的Preferences,它本质是个轻量级KV存储,适合存JSON字符串。

import preferences from '@ohos.data.preferences' import { BusinessError } from '@ohos.base' const PREF_NAME = 'fitness_pref' export async function saveBookingRecord(context: Context, key: string, value: object) { const pref = await preferences.getPreferences(context, PREF_NAME) await pref.put(key, JSON.stringify(value)) await pref.flush() }

getPreferences拿到的是单例存储实例,put写入后必须调用flush才真正落盘。这里把存储逻辑封装成独立函数,页面层只管传context进来,不需要关心Preferences的实例管理。强调一下flush这一步,开发中容易漏,漏了之后数据在内存里存在,重启App就不见了。

3. 核心模块拆解:一次预约从点击到订单生成的全链路

预约是整套系统的用例主线,从首页场馆卡片开始,到我的预约页里看到订单为止,中间跨了四个页面和两层数据写操作。从代码组织的角度,可以拆成入口分发、路由传参、数据回写三个环节去看。

3.1 首页场馆列表:数据渲染与点击分发

首页没有采用复杂的懒加载框架,直接用List组件加ForEach渲染场馆列表。每个场馆卡片是独立子组件,父组件通过@Prop把场馆数据传下去,点击事件在最上层统一处理。这样做的原因是预约场馆的数据量级在几十条以内,不需要LazyForEach按需创建,简单方案反而更好维护。

import router from '@ohos.router' @Entry @Component struct HomePage { private venues: Array<object> = [ { id: 1, name: '羽毛球馆A', type: '羽毛球', price: 35 }, { id: 2, name: '篮球馆B', type: '篮球', price: 50 } ] build() { List() { ForEach(this.venues, (item: any) => { ListItem() { Column() { Text(item.name).fontSize(18).fontWeight(FontWeight.Medium) Text(`${item.type} | ¥${item.price}/小时`) } .onClick(() => { router.pushUrl({ url: 'pages/BookingPage', params: { venueId: item.id, venueName: item.name } }) }) } }, (item: any) => item.id.toString()) } } }

router.pushUrl里的params对象是把场馆ID和名称带给预约页面的关键。注意ForEach的第三个参数是键生成器,这里用id做键保证列表项复用正确,避免因为场馆名称重复导致界面状态错乱。这是一个新手经常翻车的地方,直接不给键生成器,开发工具会警告甚至出现渲染异常。

3.2 预约页:时段选择、名额余量显示与校验

预约页是整条业务链路里逻辑最重的地方。几个核心逻辑串在一起:日期默认今天且不能选过去,时段以半小时为粒度展示,选中时段后展示对应剩余名额,名额不足时禁止提交。这些规则不复杂,但要拆成独立方法去维护,不然build方法会膨胀到没法看。

@Entry @Component struct BookingPage { @State currentDate: string = '' @State timeOption: string = '' @State quota: number = 0 @State quotaEnough: boolean = true private allSlots: string[] = ['08:00-08:30', '08:30-09:00', '09:00-09:30'] aboutToAppear() { const now = new Date() this.currentDate = `${now.getMonth() + 1}月${now.getDate()}日` } checkQuota(slot: string): number { // 模拟从本地数据源读取该时段余量,真实项目替换为远端接口 return this.allSlots.indexOf(slot) >= 0 ? 6 : 0 } build() { Column({ space: 12 }) { Text(`预约日期: ${this.currentDate}`) ForEach(this.allSlots, (slot: string) => { Button(slot) .enabled(this.checkQuota(slot) > 0) .onClick(() => { this.timeOption = slot this.quota = this.checkQuota(slot) this.quotaEnough = this.quota > 0 }) }) Button('提交预约') .enabled(this.timeOption !== '' && this.quotaEnough) } } }

这个页面里enabled属性的使用值得注意。Button的enabled既用来控制时段按钮(余量为0的时段直接置灰),也控制提交按钮(没选时段不能提交)。预约系统的交互底线就在这里:不允许用户走到提交订单那一步才发现时段满了,必须在选择阶段就拦截掉。

3.3 订单确认页:参数接收、补充信息和本地落库

预约确认页负责把前面选的时段信息变成一条真正的订单记录。路由参数在这个页面通过router.getParams()取出来,补充用户手机号之后,组装成订单对象,写入Preferences。订单写本地而不是远端接口,是因为这套源码默认无服务端运行,所有数据落设备本地。

@Entry @Component struct ConfirmPage { @State phone: string = '' @State orderNo: string = '' private venueId: number = 0 aboutToAppear() { const params = router.getParams() as Record<string, Object> if (params) { this.venueId = params.venueId as number } this.orderNo = `FT${Date.now()}${Math.floor(Math.random() * 1000)}` } submitOrder() { const order = { orderNo: this.orderNo, venueId: this.venueId, phone: this.phone, status: '已确认' } saveBookingRecord(getContext(this), `order_${this.orderNo}`, order) router.pushUrl({ url: 'pages/MyBookingPage' }) } build() { Column({ space: 16 }) { Text(`订单号: ${this.orderNo}`) TextInput({ placeholder: '请输入手机号' }) .onChange((value: string) => { this.phone = value }) Button('确认提交') .enabled(this.phone.length === 11) .onClick(() => this.submitOrder()) } } }

订单号用时间戳加随机数生成的做法,先天缺点是不能保证绝对唯一,但在这个量级的个人项目里够用。如果后面要接服务端,换成服务端生成的订单号会更稳妥。TextInput的onChange回调不能漏,预约场景里手机号是提交前的必填项,按钮的enabled直接绑定手机号长度,算是最简单可用的前校验方案。

3.4 我的预约页:订单列表读取与状态展示

最后一个页面是订单落库后的闭环展示。从Preferences里把所有order_开头的key读出来,解析JSON后展示成列表。这个页面的代码思路是:读取的时候不确认有多少条订单,统一用getAllSync取全量再按key过滤。

@Entry @Component struct MyBookingPage { @State orders: object[] = [] aboutToAppear() { this.loadOrders() } loadOrders() { const pref = preferences.getPreferencesSync(getContext(this), 'fitness_pref') const allValues = pref.getAllSync() if (!allValues) return const list: object[] = [] for (const key of Object.keys(allValues)) { if (key.startsWith('order_')) { list.push(JSON.parse(allValues[key] as string)) } } this.orders = list } build() { List() { ForEach(this.orders, (order: any, index) => { ListItem() { Row() { Text(order.venueName).fontSize(16) Text(order.status).fontSize(14).fontColor(order.status === '已确认' ? '#0A59F7' : '#999999') } } }, (order: any, index) => index.toString()) } } }

需要注意loadOrders里用的是getAllSync,同步接口在page的aboutToAppear生命周期里调用不会阻塞太久,因为订单数据量很小。数据量涨到几百条之后,建议换成异步getAll加Loading状态,这是从Demo走向产品化必然要改的一步。键过滤用startsWith来确定订单数据,坏处是其他业务字段只要前缀带order_也会被拉进来,后面做二次开发时最好把订单单独存一个key,value里再放数组,避免遍历全表。

4. 从源码到真机运行:环境搭建、跑通流程与避坑记录

拿到源码第一件事不是读代码,而是先让它在真机或模拟器上跑起来。鸿蒙项目跟Android项目不一样,不能用Android Studio直接打开,必须用DevEco Studio。版本对应关系是固定的,DevEco Studio的版本决定了能编译哪个API版本的工程。

4.1 环境准备与工程导入的具体操作

先装DevEco Studio,装的时候勾选HarmonyOS SDK组件。打开SDK Manager,把API版本切到和工程兼容的版本。当前这套工程在API 9的Stage模型下开发,用较新版本的DevEco Studio打开后,首次构建会提示SDK版本不匹配,这时候进File > Project Structure里确认SDK版本,IDE会给出自动修正选项。

导入工程用File > Open直接选到entry所在的根目录,不要只选entry子目录,否则工程配置会丢失。等IDE完成Sync之后,检查两个位置:entry/build-profile.json里的compileSdkVersion是否本地已安装,signingConfigs是否已配置。实操中最常见的失败点就是签名配置缺失——鸿蒙真机调试必须要有签名,否则安装阶段直接报错。

4.2 真机调试前的关键设置

真机调试开开发者模式,这个不多说。鸿蒙特有的一步是:在DevEco Studio的File > Project Structure > Signing Configs里勾选Automatically generate signature,IDE拿着你的华为账号做自动签名。这里需要本机登录华为账号,如果账号没实名认证,自动签名会失败,这是环境准备里最容易卡住的地方。

hdc list targets

命令行先确认设备连接状态。hdc是鸿蒙的命令行工具,作用和adb在Android生态里一样。看到设备列表后,直接点DevEco Studio的Run按钮,IDE会完成编译、签名、安装、启动四个步骤。如果Run按钮点完一直卡在Launching,多半是签名没生成成功,去检查签名配置页的报错提示。

4.3 避坑记录:跑鸿蒙预约项目时最容易翻车的五个地方

先说最常见的签名同步问题。现象:代码完全没问题,编译也通过,但装到手机上提示签名错误。原因:工程里的自动签名文件是生成时的,和当前设备不匹配。解决:打开Signing Configs重新勾选自动签名,让IDE重新生成,不要手动拷贝别人的签名文件,那样会浪费一小时起步。

再说Preferences写入后数据丢失。现象:提交订单成功,杀掉App重进,我的预约页数据消失了。原因:调了put但没调flush。解决:每次put后强制flush,或者在封装的储蓄函数里把flush写在put后面,这个习惯值得一直保持。

第三个是路由跳转参数类型翻车。现象:从首页跳到预约页,venueId取出来显示成了undefined。原因:router.getParams()返回的对象里的值,被IDE识别成了unknown类型,直接当成number用就崩了。解决:用as Record<string, Object>做类型断言后再取值,配合console.log打印确认类型,不要想当然。

第四个是ForEach缺key生成器。现象:场馆列表滑动后按钮点击没有反应,或者界面出现闪烁。原因:列表项没有稳定的键,组件复用时状态错乱。解决:给ForEach第三个参数传稳定键,场馆数据用id,没有id的数据用下标,但下标方案在列表排序或删除后会产生更隐蔽的问题。

第五个是时区显示偏差。现象:页面显示的日期和手机系统日期对不上,预约时间整体偏移。原因:new Date()拿的是设备本地时间,但模拟器时区常常是格林尼治。解决:真机调试为主,模拟器只验证布局和基础交互。这个坑在预约类项目里尤其要重视,用户迟到了会投诉的。

5. 二次开发:把单机预约Demo改造成生产级预约闭环

这套源码的定位是能跑的骨架,要真正上线还有很多硬骨头要啃。从工程现状往前推,最优先改的不是UI,而是数据链路——它现在是纯本地存储,离真实业务还差一个服务端。

5.1 第一步改造:所有本地数据操作替换为网络请求

Preferences在当前架构里承担了订单存储的职责,二次开发时先抽象一个数据服务层。这里给一个基本的接口思路:

export class BookingApi { static getVenueList(): Promise<Venue[]> { return request('GET', '/api/venues') } static createOrder(order: OrderPayload): Promise<Order> { return request('POST', '/api/orders', order) } }

把页面里的saveBookingRecord调用全部替换成BookingApi.createOrder,页面代码的改动量可以控制在很小的范围内,因为页面层只依赖saveBookingRecord这个函数名,不关心它内部是写本地还是发网络请求。

5.2 第二步改造:预约名额实时校验

单机版用随机数模拟余量,这是Demo最大的软肋。改成真实业务后,提交预约瞬间必须向后端确认名额并锁定。前端要做的是在提交按钮点击后进入loading态,拿到后端成功响应才算下单成功,失败则回滚按钮状态并提示用户重选时段。

5.3 第三步改造:补充消息通知和取消流程

预约类App的完整闭环至少还缺两块:预约成功后的提醒通知,以及爽约或计划变化的取消通道。鸿蒙侧建议用系统通知能力做预约成功提醒,用长按订单弹出取消确认框的方式处理取消,取消后刷新列表状态。这套逻辑接在现有订单页的状态字段上,扩展成本不高。

5.4 第四步改造:状态机代替散落的字符串判断

当前代码里用status字段存中文描述,这在Demo里没问题,一旦接入后端就会遇到显示文案和业务状态耦合的问题。建议改成数字枚举,前端维护一张映射表,后端只传状态码。从那以后我每次做预约类项目,都会强制先定一张状态机表再动手写页面,把已确认、已完成、已取消、已过期几种状态和用户可执行操作的关系理清楚,页面侧漏掉任何一个分支都会在真实运营时冒出来,预约功能最怕的就是状态不一致。

这套工程的价值在于把鸿蒙应用开发从入门到上手的完整流程压缩了一遍,下载后花一晚上把代码从头跟到尾,再按上面的思路改一个功能点,比单独看文档领会深得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询