简介:这是一套面向鸿蒙应用开发学习者的健身预约系统完整项目源码,覆盖用户端移动应用及后台管理设计,适合日常教学、课程实训与毕业设计参考。项目中包含场馆动态、运动常识、预订管理、预约通知等业务模块,支持多端流转与协同交互,并注重用户隐私和数据安全。资源包为zip格式,共355个文件,大小约9.99MB,以ets源码、JavaScript脚本、JSON配置和PNG/JPG图片资源为主,另有protobin等构建辅助文件,目录结构清晰,便于快速定位代码和资源。目前已有527人下载学习,适合需要实战演练或二次开发的鸿蒙开发者。通过运行与阅读代码,可以理解鸿蒙App从界面搭建、交互逻辑到业务配置的完整流程,也能掌握预约类应用的设计思路,并可在此基础上扩展教练陪练、场馆动态等更多场景。
1. 别再只刷鸿蒙文档:这套预约系统为什么值得跑一遍
鸿蒙开发真正稀缺的,从来不是 API 文档,而是一套能把场馆浏览、时段预约、订单流转串成完整业务闭环的参考工程。这份《鸿蒙全民健身预约系统App应用开发源码》就是干这个的:基于 HarmonyOS 原生技术栈(ArkTS + ArkUI + Stage 模型)实现的移动应用,覆盖首页信息流、场馆筛选、按日期时段下单、订单管理整套链路,接口层做了本地 Mock 与真实请求的可切换设计。它适合两类人:一类是刚接触 harmonyos 鸿蒙应用开发、想在真机上跑通一个完整移动 app 的新手;另一类是要快速交付预约类鸿蒙 app 应用、不想从零搭框架的开发者。下面按我拆工程的习惯,从架构到代码逐层过一遍。
2. 技术架构与工程结构:ArkTS、Stage 模型与数据层怎么组织
2.1 技术选型:为什么主流实现都落在 ArkTS 上
早期鸿蒙应用还能用兼容 JS 或 Java 的方式写,但官方主推的方向很明确:ArkTS + ArkUI 的声明式开发范式。ArkTS 是 TypeScript 的超集,保留强类型,又加了 UI 状态管理能力。选它做移动应用的核心原因有两个:一是强类型在多人协作时改动有底,字段写错编译期就暴露;二是声明式 UI 让页面代码从「找节点、改属性」变成「改状态、等重绘」,写起来更接近业务逻辑本身。
Stage 模型是这套工程的应用模型底座。一个应用由一个或多个 UIAbility 组成,每个 Ability 有独立的窗口,页面通过 router 或 Navigation 做跳转。相比早期 FA 模型,Stage 的模块边界更清晰,冷启动更可控,后台任务有独立管控机制。这套工程按 Stage 组织,入口是一个 EntryAbility,页面之间用 router 跳转。最典型的页面骨架长这样:
@Entry @Component struct IndexPage { @State currentTab: number = 0 build() { Column() { // 顶部标题栏 Text('全民健身预约') .fontSize(20) .fontWeight(FontWeight.Bold) .height(56) // 按当前 Tab 渲染不同页面 if (this.currentTab === 0) { HomePage() } else { OrderPage() } } } }这里的 @Entry 标记入口组件,@Component 声明这是一个组件,@State 表示变量变化时会触发 build 重新执行,这是声明式 UI 的核心机制:数据变了 UI 自动跟着变,不需要手动 setData。实际工程里底部 Tab 容器会写成 Tabs + TabContent 的组合,上面这段是为了把状态驱动逻辑讲清楚。
2.2 工程目录:entry 模块、业务页面与公共层的分工
我拆工程第一步永远是看目录结构,它直接反映作者的分层思路。这套代码把页面、模型、服务、资源拆得很干净,典型结构如下:
FitnessBooking ├── AppScope │ └── app.json5 # 应用级配置:包名、图标、版本 ├── entry │ └── src │ ├── main │ │ ├── module.json5 # 模块配置:Ability、权限、页面入口 │ │ ├── ets │ │ │ ├── entryability │ │ │ │ └── EntryAbility.ets │ │ │ ├── pages │ │ │ │ ├── IndexPage.ets # 底部 Tab 容器 │ │ │ │ ├── home │ │ │ │ │ ├── HomePage.ets # 首页 + 轮播 │ │ │ │ │ └── VenueListPage.ets # 场馆列表 │ │ │ │ ├── venue │ │ │ │ │ └── VenueDetailPage.ets │ │ │ │ ├── order │ │ │ │ │ ├── BookingPage.ets # 预约下单 │ │ │ │ │ └── OrderListPage.ets # 我的订单 │ │ │ │ └── mine │ │ │ │ └── ProfilePage.ets # 个人中心 │ │ │ ├── model │ │ │ │ ├── Venue.ets # 场馆模型 │ │ │ │ └── Order.ets # 订单模型 │ │ │ ├── service │ │ │ │ ├── ApiClient.ets # 统一数据入口 │ │ │ │ └── MockData.ets # 本地 mock 数据 │ │ │ └── common │ │ │ └── Constants.ets │ │ └── resources │ │ ├── base │ │ │ ├── element │ │ │ ├── media │ │ │ └── profile │ │ └── rawfile │ │ └── mock │ │ ├── venues.json │ │ └── orders.json │ └── ohosTest └── build-profile.json5这套分层最有价值的一点是:pages 按业务分目录(home / venue / order / mine),model 放实体类,service 层放统一数据出口,resources 的 rawfile 目录专门放 mock 数据。它和传统后端 controller / service / dao 三层是同一个思路,只是把 controller 换成了页面。后端没就绪时页面开发不阻塞,后端一就绪只需要动 service 层,页面代码一行不用改。
2.3 数据层设计:本地 Mock 与真实请求一键切换
数据层是这类工程最容易被忽略、但联调时最救命的部分。这套代码把数据访问统一收敛到 ApiClient,页面只认这个出口,不关心数据来自本地还是远端。
// service/ApiClient.ets import http from '@ohos.net.http' export class ApiClient { static useMock: boolean = true // 联调时改为 false static async get<T>(url: string): Promise<T> { if (ApiClient.useMock) { // 从本地 rawfile 读 json,再反序列化成对应类型 return (await MockData.load(url)) as T } // 真实请求:走 http 模块,带公共超时时间 const req = http.createHttp() const resp = await req.request(url, { method: http.RequestMethod.GET, connectTimeout: 10000 }) return JSON.parse(resp.result as string) as T } }useMock 是这里的核心开关:true 时所有页面读 rawfile 下的 JSON,false 时走 @ohos.net.http 发真实请求。connectTimeout 设置的是连接超时时间,单位毫秒,10000 表示 10 秒没连上就判定失败。实际工程里我会在 request 之后先判断 responseCode 再解析,避免接口返回 4xx/5xx 时把错误页当成成功数据塞给页面,那是最容易掩盖问题的写法。页面层只需要ApiClient.get<Array<Venue>>(...),后面加 .then 拿结果,数据来源对页面完全透明。
3. 核心模块拆解:从首页推荐到预约下单的完整链路
3.1 首页信息流:Swiper 轮播与推荐场馆卡片
首页是用户在移动应用里看到的第一个画面,承担两个职责:展示运营内容和分流到核心功能。代码里轮播图直接用 Swiper 组件实现,没有手写定时器,这是鸿蒙官方推荐的写法:
import ApiClient from '../service/ApiClient' import { Venue } from '../model/Venue' @Entry @Component struct HomePage { @State bannerList: Array<string> = [] @State hotVenues: Array<Venue> = [] aboutToAppear(): void { // 页面出现时统一走数据入口,避免页面直接耦合 mock 或 http ApiClient.get<Array<string>>('/mock/banners.json').then((data: Array<string>) => { this.bannerList = data }) ApiClient.get<Array<Venue>>('/mock/venues.json').then((data: Array<Venue>) => { this.hotVenues = data }) } build() { Column() { Swiper() { ForEach(this.bannerList, (url: string) => { Image(url) .width('100%') .height(160) .borderRadius(12) }) } .autoplay(true) .loop(true) .interval(4000) .indicator(true) List() { ForEach(this.hotVenues, (venue: Venue) => { ListItem() { VenueCard({ venue: venue }) } }) } } } }Swiper 的 autoplay(true) 开启自动播放,loop(true) 表示无限循环,interval(4000) 是 4 秒切换一张,indicator(true) 显示底部圆点指示器。aboutToAppear 是页面生命周期回调,在页面即将显示时触发,在这里异步拿数据不会阻塞首帧渲染。这个写法还有个细节:bannerList 用 string 数组,如果后端返回的是对象,建议直接把 model 定义成 BannerItem 结构,字段取 imageUrl 即可,前端尽量别为了省事把对象拆成字符串数组。
3.2 场馆列表与分类筛选:条件驱动 List 刷新
场馆列表页是预约流的入口,核心交互是「顶部运动类型筛选 + 下方 List 联动刷新」。这里的实现思路是筛选条件先改选中态,再重新计算结果集,完全由状态驱动 UI:
@State venueList: Array<Venue> = [] @State filteredList: Array<Venue> = [] @State selectedType: string = '全部' private venueTypes: string[] = ['全部', '羽毛球', '篮球', '游泳', '健身房'] build() { Column() { Scroll() { Row() { ForEach(this.venueTypes, (type: string) => { Text(type) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .backgroundColor(this.selectedType === type ? '#FF7A00' : '#F0F0F0') .borderRadius(16) .onClick(() => { this.selectedType = type this.filterVenues(type) }) }) } } List() { ForEach(this.filteredList, (venue: Venue) => { ListItem() { VenueCard({ venue: venue }) } }) } } } private filterVenues(type: string): void { if (type === '全部') { this.filteredList = this.venueList } else { this.filteredList = this.venueList.filter((item: Venue) => item.type === type) } }点击分类时先给 selectedType 重新赋值——这个动作会触发选中态刷新,让背景色切到橙色高亮;然后调用 filterVenues 计算出新的 filteredList,List 拿到新数组自动重绘。这里的关键是 filter 返回的是全新数组,UI 一定感知得到变化;如果你图省事直接修改 venueList 某项的字段,反而会掉进 @State 浅观察的坑,这个后面避坑章细说。场馆模型字段也是后端联调时的对齐依据,典型结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 场馆唯一标识 |
| name | string | 场馆名称 |
| type | string | 运动类型:羽毛球 / 篮球 / 游泳 / 健身房 |
| address | string | 位置描述 |
| distance | number | 距当前位置,单位 km,仅展示用 |
| score | number | 评分 0~5 |
| images | string[] | 轮播 / 详情图 |
| timeSlots | string[] | 可约时段,格式 HH:mm-HH:mm |
| price | number | 单时段价格,单位元 |
3.3 预约下单:日期、时段选择与订单提交
预约页是整个系统业务逻辑最集中的地方:选日期、选时段、判断满约、提交订单。实现时把选中态管理做成了「记录下标」而不是「记录字符串」,这样高亮、禁用和提交三个动作可以共用一份状态:
import router from '@ohos.router' @Entry @Component struct BookingPage { private venueId: string = '' @State selectedDate: string = '' @State selectedSlot: number = -1 @State slotStatus: number[] = [] // 0 可约 1 已约满 private timeSlots: string[] = ['09:00-10:00', '10:00-11:00', '14:00-15:00', '19:00-20:00'] aboutToAppear(): void { // 接收上一页传过来的场馆 id const params = router.getParams() as Record<string, Object> this.venueId = params.venueId as string } build() { Column() { Text(this.selectedDate).fontSize(16) ForEach(this.timeSlots, (slot: string, index: number) => { Text(slot) .backgroundColor(this.selectedSlot === index ? '#FF7A00' : '#F0F0F0') .enabled(this.slotStatus[index] !== 1) .onClick(() => { if (this.slotStatus[index] === 1) { return // 已约满的时段直接拦截 } this.selectedSlot = index }) }) Button('提交预约') .enabled(this.selectedSlot >= 0) .onClick(() => this.submitOrder()) } } private submitOrder(): void { // 组装订单参数 const order = { venueId: this.venueId, date: this.selectedDate, slotIndex: this.selectedSlot, slot: this.timeSlots[this.selectedSlot] } // 交给数据层提交,成功后再回跳订单列表 router.pushUrl({ url: 'pages/order/OrderListPage' }) } }selectedSlot 的初始值是 -1,表示「还没选中任何时段」,所以提交按钮的 enabled 条件就是 selectedSlot >= 0,这个设计让未选时按钮天然置灰,省了额外判断。slotStatus 数组标记每个时段是否约满,enabled(false) 会让组件直接禁用点击事件。日期选择在代码里简化成一行 Text,实际工程我会用 DatePickerDialog 弹窗:点击日期区域调起系统日期选择器,选中后回填 selectedDate 再回到页面。submitOrder 把场馆、日期、时段组装成 order 对象交给数据层,正式项目里这里还要插入「是否重复提交」的幂等校验,防止用户连点生成重复订单。
3.4 订单管理:待处理与历史订单的状态映射
订单列表页比场馆列表简单,但它把「状态机」这个概念落到了 UI 上。预约流程里订单至少经历「待支付 → 预约成功 → 已完成 / 已取消」几个状态,代码里用数字映射文案和颜色,比散落的字符串判断清晰得多:
@Component export struct OrderItem { @Prop order: Order private statusTextMap: string[] = ['待支付', '预约成功', '已完成', '已取消'] build() { Row() { Column() { Text(this.order.venueName).fontSize(16) Text(`${this.order.date} ${this.order.timeSlot}`) .fontSize(13) .fontColor('#999999') } Blank() Text(this.statusTextMap[this.order.status]) .fontColor(this.order.status === 0 ? '#FF7A00' : '#999999') } } }@Prop 表示这个属性由父组件传入,并且父组件更新时会同步给子组件。订单列表页一般做成「进行中 / 历史」两个 Tab,本质是同一个 List 组件,切换时把筛选条件从「status 为 0 或 1」换成「status 为 2 或 3」,两个 Tab 共用一套渲染逻辑。如果要加「取消预约」按钮,就在 Row 里加一个 Button,点击后调 service 层更新状态并通知父组件刷新列表。这里有个值得培养的习惯:状态字段尽量用数字常量而不是字符串,后续做支付回调、超时关闭这类逻辑时比较和扩展都方便。
4. 本地运行:DevEco Studio 配置、模拟器与真机调试全流程
4.1 环境准备:IDE 与 SDK 版本匹配
拿到源码第一件事不是改代码,而是把开发环境对齐。鸿蒙移动应用开发使用 DevEco Studio 作为 IDE,安装时会让你选择下载哪个版本的 HarmonyOS SDK。这里最容易翻车的是版本匹配:工程里的 compileSdkVersion 和本机安装的 SDK 版本对不上,同步时直接报错。
// build-profile.json5 部分字段 { "app": { "signingConfigs": [], "products": [ { "name": "default", "compileSdkVersion": 9, "compatibleSdkVersion": 9 } ] } }compileSdkVersion 决定用哪套 SDK 编译,compatibleSdkVersion 决定最低能跑在哪个系统版本的真机上。工程打开后如果提示找不到 SDK,最快解法是打开 Settings > SDK 把对应版本的组件补装上,不要一上来就改工程的 compileSdkVersion 往下压——那很容易引入 API 不兼容的报错。装完 SDK 后执行一次 File > Sync and Refresh Project,等构建跑完再继续。这部分对新手来说是「跟着做就对了」的事,但版本对齐的坑我踩过不止一次,值得先讲清楚。
4.2 导入工程、改包名与自动签名
环境就绪后按下面顺序走,基本不会卡壳:
- File > Open 选择工程根目录,等索引和同步完成。
- 打开 module.json5,把 bundleName 改成自己的包名,比如 com.yourname.fitness。注意包名不能以数字开头,全局唯一。
- 打开 File > Project Structure > Signing Configs,勾选 Automatically generate signature。
- 登录开发者账号,IDE 会自动生成调试证书和 Profile。
自动签名是新手最容易漏的一环。模拟器对签名不敏感,不签名也能跑;但真机安装时系统会做严格的签名校验,没签名的 hap 包直接报安装失败。有些工程为了避免重复签名,会把签好名的包放在 build 目录下,你改动代码后一定要 Rebuild 一次再安装,不要拿着旧包调试半天才发现改的代码没进去。这套动作我建议每次新建工程都强制走一遍,形成肌肉记忆。
4.3 模拟器与真机:两种验证方式的选择
跑起来之前先想想用哪种设备验证。模拟器的优势是启动快、随手可用,适合看 UI 布局和页面跳转;真机才能验证签名、性能和系统级能力。两者的选择参考下表:
| 验证方式 | 适用场景 | 注意点 |
|---|---|---|
| Local Emulator | UI 快速验证、没有真机环境 | 首次启动慢,内存建议 2GB 以上 |
| 真机调试 | 签名验证、相机、推送、性能 | 需开启开发者模式并授权 USB 调试 |
模拟器在 Device Manager > Local Emulator 里创建,选择设备镜像后等待启动。真机连接需要先在系统设置里开启开发者模式,插线后设备弹窗选择允许调试。设备连上后可以用 hdc 工具确认状态:
# 查看 DevEco Studio 是否识别到设备 hdc list targets # 安装并启动调试包 hdc install entry/build/default/outputs/default/entry-default-signed.haphdc 是鸿蒙的命令行调试工具,hdc list targets 能看到设备序列号,hdc install 后面接的是签名后的 hap 包完整路径。这个文件在每次 Build 后自动生成到 build 目录,路径里的 entry-default-signed.hap 就是带签名的调试包。如果安装报错,先确认 hdc list targets 能不能看到设备,再确认 hap 文件存在,这两个原因占了八成,剩下两成才是签名问题。
4.4 首跑验收清单:怎么确认项目真的跑通了
很多初学者看到应用启动就以为跑通了,实际业务链路未必完整。我建议按下面清单逐项验证一遍:
- 首页 Swiper 能自动轮播,圆点指示器跟随变化。
- 场馆分类切换后,列表内容联动刷新。
- 预约页已约满的时段置灰,点击无响应。
- 提交订单后,订单页新增一条记录,状态为待支付。
- 切后台再回来,页面状态没有丢。
如果这套工程带了本地存储(Preferences),杀进程重开后订单记录应该还在;如果还没接持久化,重开后回到 Mock 初始数据也算符合预期。验收的目的不是挑毛病,而是确认你掌握了这套工程里数据是怎么流转的,后续改动才有据可依。
5. 避坑指南:编译、签名与状态刷新里的真实踩坑记录
5.1 hvigor 编译中断:SDK 组件缺失与缓存问题
现象:同步工程时一直卡在 Resolving dependencies,或者构建直接抛hvigor ERROR: Cannot find module,根本进不到编译阶段。
原因:DevEco Studio 的 IDE 版本低于工程要求的版本,或者本机缺了对应 API 版本的 SDK 组件。hvigor 构建工具和 IDE 是绑定的,工程里 hvigor-config.json5 声明的版本和 IDE 内置版本不一致时,也会出现各种莫名其妙的解析失败。
解决:先打开 Settings > SDK,确认组件列表里有对应版本的 SDK;再处理 hvigor 缓存——删除工程根目录下的 oh_modules 和 .hvigor 目录,然后重新 Sync。我踩过最狠的一次是 IDE 大版本升级后旧工程不认新 hvigor,最后是把工程自动生成的 hvigor 目录整个删掉重新同步解决的。这类问题优先动环境,不要先动工程配置。
5.2 真机安装失败:签名校验与设备绑定
现象:hdc install 时终端抛error: install failed, signature verification failed,模拟器上跑得好好的,一上真机就装不上。
原因:调试证书和设备不匹配。更常见的是根本没勾自动签名——模拟器不做签名校验,真机严格校验,所以模拟器的成功会掩盖签名缺失。
解决:File > Project Structure > Signing Configs,勾选 Automatically generate signature,登录账号重新生成证书,然后 Rebuild 一次再安装。这里有个隐藏细节:调试 Profile 和设备是绑定的,如果账号里曾经删过这台设备,需要重新弹窗授权,否则生成的 Profile 不包含当前设备,签名结果依然失败。
5.3 列表不刷新:@State 浅观察带来的玄学问题
现象:代码里明明改了列表某个 item 的字段,页面纹丝不动,重建工程也没用。
原因:@State 只做一层观察,直接修改对象嵌套属性不会触发 build 重绘。这是声明式 UI 新手最常见的翻车点,属于「代码没报错但表现异常」的玄学问题。
解决:改完后整体重新赋值,比如this.venueList = [...this.venueList],让 @State 拿到一个新数组引用,一定触发刷新。或者给自定义类加 @Observed,子组件用 @ObjectLink 接收,让深层属性可观察。两种方式都行,我更推荐前者——整体赋值一行搞定,少绕一层概念,排查起来也更直观。
5.4 路由传参丢失:pushUrl 后拿到 undefined
现象:从场馆详情页跳到预约页,回调里打印 venueId 是 undefined,页面直接错乱。
原因:路径写错,或者目标页取参数的方式不对。API 9 之后部分场景要求 URL 按工程内路径写法,写成pages/venue/VenueDetailPage没问题,但如果 bundleName 改过,旧的含 @bundle 前缀的写法也要同步改。
解决:在目标页的 aboutToAppear 里先打印router.getParams(),看看整体参数结构,再决定怎么取值。这能帮你区分是路径问题、参数名不一致问题,还是取参时机问题。
5.5 rawfile 解析失败:文件路径与编码问题
现象:JSON.parse抛异常,页面白屏,控制台只有一行 cryptic 的 parse error。
原因:mock 文件没有放在 src/main/resources/rawfile 目录下,或者读取方式不对;中文内容带 BOM 时也可能解析失败。
解决:确认文件路径在 rawfile 下,不是 assets。读取用getContext().resourceManager.getRawFileContent('mock/venues.json'),拿到 ArrayBuffer 转成 UTF-8 字符串后再 JSON.parse。开发阶段不要吞异常,解析失败时至少 console.error 打印原始内容,很多路径问题一眼就能看出来。
6. 二次开发:把预约骨架改造成带支付与多门店的预约应用
6.1 第一步:替换 ApiClient,把 Mock 换成后端接口
用这套工程做二次开发,最优先替换的就是数据层。把 ApiClient 里的 mock 分支直接改成真实请求,注意加上状态码判断和鉴权头:
import http from '@ohos.net.http' static async get<T>(url: string): Promise<T> { const httpClient = http.createHttp() const resp = await httpClient.request(`https://api.example.com${url}`, { method: http.RequestMethod.GET, connectTimeout: 10000, header: { 'Authorization': `Bearer ${AuthStore.getToken()}` } }) if (resp.responseCode === 200) { return JSON.parse(resp.result as string) as T } throw new Error(`request failed: ${resp.responseCode}`) }改动量集中在 ApiClient 一个文件,页面层签名不动。真正的改造工作在登录态管理、错误提示统一、分页参数透传这几件事上,我一般会先把 ApiClient 的契约定成 Promise ,页面不用感知数据来源,后续切换后端零成本。
6.2 第二步:订单状态机扩展与支付接入
要加支付,订单状态机需要从「预约成功 / 已完成」扩展为「已支付 / 已核销」:在 submitOrder 成功回调里拉起支付面板,支付完成后轮询订单详情接口确认结果,再刷新订单列表。把支付逻辑单独收进 service/pay/PayService.ets,不要让页面直接拼支付参数。多门店、优惠券、教练排期这类功能,本质上都是在现有模型上加字段,页面结构不用大改。
6.3 验证方法与实践习惯
改完跑一次完整链路验证:登录 → 首页进详情 → 选一个可约时段 → 提交订单 → 订单页出现「待支付」→ 拉起支付 → 状态变「已支付」→ 场馆端核销 → 状态变「已核销」。这个链路走通,说明数据流、状态机、路由都是通的。从那以后我每次拿到新工程,第一件事都不是点 Run,而是按顺序看 module.json5、pages 路由表、service 层入口,把这三样理清再动手。这个习惯帮我避掉了大半的坑。这次拆这套「鸿蒙全民健身预约系统」,最有价值的不是某个组件写法,而是它把「业务闭环」落到了代码上——顺着链路读一遍,预约类应用的骨架就长在脑子里了。希望帮到你。
本文还有配套的精品资源,点击获取