简介:鸿蒙全民健身预约系统App应用开发源码,面向移动应用开发者与高校实训教学,适合作为鸿蒙项目二次开发的基础工程。系统覆盖健身预约典型业务链路:用户管理、场馆动态、运动常识、预订管理与预约通知,支持查看可预约场馆、教练及陪练,并及时接收预约提醒;界面简洁友好,注重隐私保护与数据安全,实现多端流转协同交互。压缩包共355个文件,大小约10MB,以99个JPG、49个PNG图片素材,以及63个JS、38个JSON、25个ETS等源码与配置文件为主,另有若干TXT说明与工程配置文件,整体目录结构完整,便于按模块阅读与扩展。该项目移动端为首次发布,已有527人学习下载,可用作鸿蒙课程设计、毕业设计或实训项目参考,也是理解健身预约类业务流程与鸿蒙应用工程组织的直观样例。
1. 鸿蒙健身预约系统App:这套源码为什么值得你花一个晚上拆一遍
鸿蒙应用开发的项目示例不少,但大多数停在按钮、列表、轮播图上,真正能把“预约业务”闭环跑完的很少。这套全民健身预约系统源码的价值就在于它把场馆列表、预约表单、个人中心、数据持久化、网络请求全部串在了一起,是个能直接编译进模拟器的完整移动应用,不是教学用的半成品。对刚开始接触 ArkTS 和 ArkUI 的开发者来说,照着这个工程改一版自己的预约工具,比从零搭框架省太多时间;对已经写过几个 Demo 的熟手,源码里关于状态管理和接口对接的处理方式也值得对照检查。它能解决的核心问题是:让你在拿到手的第一晚,就看到一个鸿蒙 App 从数据到页面再到落盘的完整路径。
2. 环境准备与工程跑通:DevEco Studio 配置和项目落地
2.1 环境清单:SDK 版本、模拟器与真机怎么选
先讲环境,这一步卡住后面全是白费。开发鸿蒙应用,IDE 目前主要靠 DevEco Studio,下载安装后第一次启动会提示你配置 SDK 路径。这里有个新手常踩的选择困难:SDK 版本到底装哪个?我的建议是直接看工程里build-profile.json5声明的compatibleSdkVersion,不要凭感觉选最新。这套源码在工程里已经锁定了编译目标,你本地装的 SDK 版本如果比工程要求的低,编译会直接报compatibleSdkVersion不满足;如果装得过高,又容易出现 API 废弃警告甚至行为差异。优先保证和工程声明一致,然后再考虑要不要升级。
模拟器方面,DevEco Studio 自带的模拟器足够跑通界面和预约流程,但要注意模拟器默认的联网策略和真机不完全一样。如果你想测网络层,最好准备一台真机,把开发者模式打开,用数据线连接后让 IDE 自动安装 HAP 包。真机调试第一次会比较折腾,签名、授权、版本匹配都可能出问题,这部分我在第 5 章单独展开。如果只是先看页面效果和交互逻辑,那模拟器就够了,别一上来就上真机,给自己增加排错负担。
还有一点容易被忽略:鸿蒙应用工程分entry模块和feature模块,本套源码的业务代码都在entry/src/main下。你在 IDE 里打开工程后,要等右下角的 Gradle 同步任务跑完(实际上是 hvigor 同步),别在索引还没完成时就急着点运行,那样经常报一些莫名其妙的找不到模块错误,其实就是同步没结束。等状态栏提示同步完成再动手,能少翻很多次车。
2.2 导入源码与工程结构解读
拿到源码包后解压,用 DevEco Studio 的Open选择工程根目录,注意一定要选到包含build-profile.json5的那一层,选错层级会导致 IDE 不识别工程类型。打开后先看一眼目录结构,鸿蒙工程的关键路径如下:
entry/ ├── src/main/ │ ├── module.json5 // 模块配置,声明权限和入口 │ ├── ets/ │ │ ├── entryability/ │ │ ├── pages/ // 页面目录 │ │ ├── components/ // 自定义组件 │ │ └── model/ // 数据模型 │ └── resources/ // 字符串、颜色、图标资源 ├── build-profile.json5 // 模块编译配置 └── hvigorfile.ts // 构建脚本module.json5是每个鸿蒙模块的身份证,里面对应注册了 Ability、申请了权限。如果你后面新增页面,要检查是否需要在这里注册路由;如果你要用到网络请求和用户存储,也需要确认权限是否在requestPermissions里声明过。常见做法是,网络权限和读写权限在项目里会被集中放在模块配置文件的数组中,不要随意删除。
// entry/src/main/module.json5 { "module": { "name": "entry", "type": "entry", "deviceTypes": ["phone"], "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }这里INTERNET权限决定了 App 能不能发起网络请求,如果你把这条注释掉,后面接口调试时请求会直接失败,而且报错信息不会明确提示是权限问题,很容易让人误以为是后端出故障了。我的习惯是拿到别人的工程后先扫一遍module.json5,把权限字段梳理成一个清单,再对照业务功能看哪些权限是多余的。多余权限能删就删,能少申请一个是一份安全风险。
2.3 首次编译:IDE 和命令行两种方式跑通
在 IDE 里运行很简单:选择entry模块,点击运行按钮,选择模拟器设备,等待编译完成。但如果你习惯了命令行操作,或者想在 CI 环境里验证工程能否通过编译,可以走 hvigor 命令行:在工程根目录执行构建命令,产物会输出到entry/build/default/outputs目录下。
# 在工程根目录执行,clean 后重新构建 HAP 包 hvigorw clean assembleHap --mode module -p product=defaultclean是清理上次构建产物,assembleHap是打包命令,-p product=default指定构建产物类型。对于 HAP 包而言,outputs 目录下会生成一个可安装文件,你可以手动把它推送到模拟器或真机安装。这个命令在你用 IDE 遇到构建缓存异常时特别有用,很多情况下 IDE 界面一直报编译错误,但用命令行 clean 一遍再重新构建就恢复了,属于典型的“玄学但有效”的排错手段。
首次编译时间会比较长,因为要下载依赖的 SDK 组件和编译工具链。如果进度条卡在某个百分比超过五分钟,不要反复取消重试,先检查网络到 SDK 仓库的连通性,再查看 IDE 日志里具体的失败任务。编译失败时日志末尾通常有FAILURE: Build failed字样,往上翻能看到具体的库或模块名,把报错模块名复制到搜索引擎里更容易找到答案,比自己瞎猜快得多。
3. ArkUI 界面层:从场地列表到预约表单的完整实现
3.1 列表页拆解:ForEach 渲染与组件化卡片
这套源码的首页是场地列表,逻辑很直接:定义一个数组保存场馆数据,用ForEach遍历渲染,每一行用自定义的卡片组件展示名称、位置、价格和可预约状态。为什么推荐用组件化拆分而不是把所有 UI 写在一个页面里?因为预约卡片在首页、收藏页、搜索结果页都会出现,抽成独立组件后,改一处样式三处生效,而且卡片内部的状态变化不会牵动整个页面重渲染。
// components/CourtCard.ets @Component export struct CourtCard { @Prop name: string = ''; @Prop location: string = ''; @Prop price: number = 0; @Prop bookable: boolean = true; build() { Column({ space: 8 }) { Row() { Text(this.name) .fontSize(18) .fontWeight(FontWeight.Bold) Blank() Text(this.bookable ? '可预约' : '已约满') .fontSize(14) .fontColor(this.bookable ? '#1890FF' : '#999999') } .width('100%') Text(this.location) .fontSize(14) .fontColor('#666666') .width('100%') Row() { Text(`¥${this.price}/小时`) .fontSize(16) .fontColor('#FF6B35') Blank() Button(this.bookable ? '去预约' : '查看排期') .enabled(this.bookable) } .width('100%') } .padding(16) .backgroundColor(Color.White) .borderRadius(12) } }代码里的@Prop是父组件向子组件传值的单向数据绑定声明,父页面数据变化时,子组件收到的新值会触发界面刷新。Blank()的作用是占据剩余空间,把左右两端的文字顶开,实现两端对齐的效果,比手动计算间距要省心。价格这里用的是模板字符串渲染,注意 ArkTS 里字符串模板语法和 TypeScript 保持一致,直接写¥${this.price}就能拼出价格文本。enabled属性控制按钮是否可点击,当场馆约满时按钮置灰,用户点击不会触发响应,这是预约场景里一个很小的交互细节,但特别影响体验。
3.2 预约表单:日期选择、时间段与提交逻辑
从列表点击“去预约”后,跳转到预约表单页。这个页面的核心字段是日期、时间段和联系人信息。日期部分源码用的是DatePickerDialog弹窗,时间段则是一组单选按钮。选择完成后点提交,先把数据写入本地存储,再调用网络接口同步。这里最值得学习的是表单校验的写法:没有用特别复杂的校验框架,而是在点击提交时逐项检查,哪一项为空就把错误提示放到对应字段下面。
// pages/BookingPage.ets @State selectedDate: string = ''; @State selectedTime: string = ''; @State contactName: string = ''; @State errorText: string = ''; private submitBooking(): void { if (!this.selectedDate) { this.errorText = '请选择预约日期'; return; } if (!this.selectedTime) { this.errorText = '请选择时间段'; return; } if (!this.contactName.trim()) { this.errorText = '请填写联系人姓名'; return; } // 校验通过后组装预约对象并保存 const booking = { date: this.selectedDate, time: this.selectedTime, name: this.contactName.trim(), createdAt: new Date().getTime() }; this.saveBooking(booking); }这个提交函数的执行顺序可以作为模板记到脑子里:先校验,再组装数据,最后落盘或发请求。很多初学者会把网络请求直接写在 setState 回调里,导致界面还没更新完就发起请求,数据状态混乱。源码这里把每个校验分支都独立 return,好处是用户可以清楚地知道到底哪个字段没填,而不是收到一个含糊的“请检查表单信息”。trim()处理了用户误输入的空格,这个细节在真实场景里几乎一定会遇到,不处理的话“张三 ”和“张三”会被当成两个人,后续查询时对不上。
3.3 状态管理:@State / @Prop / @Observed 的分工
鸿蒙 ArkUI 的状态管理机制初学者最容易晕,这里我把这套源码里用到的几种装饰器做一个对比。@State管组件自己的内部状态,@Prop管父传子的单向属性,@Observed和@ObjectLink配合起来管跨组件的对象属性同步。选错装饰器最典型的后果是:数据变了,界面不刷新。这不是玄学,是数据流方向没配对。
| 装饰器 | 使用位置 | 数据流向 | 适用场景 |
|---|---|---|---|
| @State | 组件内部 | 自身读写 | 表单输入、弹窗显隐 |
| @Prop | 子组件 | 父传子 | 列表卡片展示字段 |
| @Observed | 数据类 | 配合 @ObjectLink | 多个组件共享同一个对象 |
| @ObjectLink | 子组件 | 双向同步 | 场馆详情的收藏状态 |
举个例子,场馆的收藏状态如果只存在列表页的 @State 里,详情页改了收藏,返回列表页时数据不会刷新,因为两个页面各持有一份独立数据。正确做法是把收藏字段放进一个 @Observed 装饰的数据类实例里,列表页和详情页都通过 @ObjectLink 引用这个实例,任何一边修改都能通知另一边。源码里对收藏功能的处理就是走的这条路,你可以在model目录下找到对应的数据模型定义,建议拿到工程后先看一眼这个类,理解它怎么被多个页面引用。
// model/CourtModel.ets @Observed export class CourtModel { id: string = ''; name: string = ''; collected: boolean = false; toggleCollect(): void { this.collected = !this.collected; } }toggleCollect方法是对象内部修改状态的标准写法,比在页面里直接给collected赋值更规范。因为 @Observed 类内部的方法天然具备通知能力,外部直接修改对象字段反而可能漏掉刷新。从这套源码里学到的习惯是:把所有和业务状态相关的字段封装进模型类里,页面只负责调用方法,不要让业务逻辑散落在 build 方法里。代码可读性和排错效率都会好很多。
4. 数据层与网络层:本地持久化、登录态与接口对接
4.1 本地持久化:Preferences 存储用户信息与预约草稿
预约系统离不开用户身份和草稿保存。源码用的是 Preferences 轻量级持久化方案,适合存键值对形式的配置,比如登录 token、上次选择的场地 id、未提交的表单草稿。它的特点是读写速度快,适合存小体积数据,但如果你的数据结构复杂到需要嵌套查询,那就不再适合,应该考虑数据库方案。
// utils/StorageUtil.ets import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; export class StorageUtil { static async put(context: common.Context, key: string, value: string): Promise<void> { const pref = preferences.getPreferencesSync(context, { name: 'fitness_store' }); pref.putSync(key, value); await pref.flush(); } static get(context: common.Context, key: string): string { const pref = preferences.getPreferencesSync(context, { name: 'fitness_store' }); return pref.getSync(key, '') as string; } }注意这里getPreferencesSync的第一个参数必须是Context,页面里可以通过getContext(this)获取。flush()是异步落盘方法,调用后数据才真正写入磁盘,只putSync不flush的话,进程被杀后数据会丢失,这是最容易被忽略的一步。Preferences 的存取都在同一个fitness_store文件里,如果同时读写特别频繁,建议把写入操作集中到一个队列里,避免并发冲突,不过这套源码的业务量级还不需要考虑这么深。
调用方式也很简单,在页面里拿到 context 后直接操作。比如登录成功后存 token,下次启动时先读 token 再决定跳转首页还是登录页。
// pages/LoginPage.ets const context = getContext(this); await StorageUtil.put(context, 'session_token', response.token);这种存储方式的优势是接口简单、没有额外依赖,适合中小型工具类应用。劣势在于没有 Schema 约束,存进去的数据结构如果后续变更,需要自己写迁移逻辑。这套源码里的做法是约定 key 的前缀来区分业务域,比如user_开头的 key 存用户信息,booking_开头的 key 存预约记录,这样在调试时通过数据库工具查看数据也一目了然。
4.2 网络封装:基于 @ohos.net.http 的请求模块
网络层是所有 App 的命脉,这套源码封装了一个轻量请求模块,统一处理 GET 和 POST 请求,把 header 拼接、超时设置、错误码判断都收口在一个文件里,页面只关心传参和拿结果。
// utils/HttpUtil.ets import { http } from '@kit.NetworkKit'; export class HttpUtil { static request(method: http.RequestMethod, url: string, body?: object): Promise<any> { return new Promise((resolve, reject) => { const req = http.createHttp(); req.request(url, { method: method, header: { 'Content-Type': 'application/json' }, extraData: body ? JSON.stringify(body) : undefined, expectDataType: http.HttpDataType.STRING, connectTimeout: 10000, readTimeout: 10000 }).then((resp: http.HttpResponse) => { if (resp.responseCode === 200) { resolve(JSON.parse(resp.result as string)); } else { reject(new Error(`请求失败,状态码:${resp.responseCode}`)); } }).catch((err: Error) => { reject(err); }); }); } }参数说明:connectTimeout是建立连接的超时时间,readTimeout是等待响应的超时时间,单位都是毫秒。这两个值要根据应用场景调整,预约系统一般设置 10 秒比较合理,时间太短弱网环境容易失败,太长用户体验差。expectDataType声明期望的返回类型为字符串,JSON 解析放在 then 里处理。extraData是 POST 请求的请求体,GET 请求不需要传。封装该方法最大的价值在于把 JSON.stringify 和 JSON.parse 统一收口,避免每个页面各自处理序列化逻辑,代码里出现不一致的频率会显著降低。
调用封装的网络模块时,只要能确保后端接口的返回结构符合约定的格式,页面层就可以保持简洁。比如获取场馆列表,在aboutToAppear生命周期里发起请求,成功后将结果赋值给 @State 数组,界面自动刷新。
aboutToAppear(): void { HttpUtil.request(http.RequestMethod.GET, 'https://your-api-host/api/courts') .then((data: Court[]) => { this.courts = data; }) .catch((err: Error) => { console.error(`加载场馆失败:${err.message}`); }); }这里有个阿珂苏开发者容易踩的认知误区:https://your-api-host是占位地址,下载源码后如果不换成你在用的后端地址,请求必然失败。也不要天真地以为随便填一个域名就能通,HTTP 请求必须指向一个真实可访问的服务器接口。如果只想快速跑通界面,可以在第 6 章里介绍的 Mock 开关来控制数据来源,暂时绕开网络层。
4.3 模型层与 JSON 解析:字段映射的常见坑
后端接口返回的是 JSON 字符串,解析到 TS 对象时有一个隐藏风险:TypeScript 的类型声明只存在于编译期,运行时并不会自动帮你校验字段。也就是说,你定义了interface Court,但后端返回的 JSON 里缺少price字段,程序不会报编译错误,而是到了界面渲染时价格显示成 undefined。
// model/Court.ts export interface Court { id: string; name: string; location: string; price: number; bookable: boolean; }解析后访问court.price / 100就会得到NaN。源码里的规避方式是做一个字段适配层,在拿到原始响应后先清洗字段,为可能缺失的字段设置兜底值。
function parseCourts(rawData: any[]): Court[] { return rawData.map((item: any) => ({ id: String(item.id ?? ''), name: String(item.name ?? '未知场地'), location: String(item.location ?? ''), price: Number(item.price ?? 0), bookable: Boolean(item.bookable ?? false) })); }??是空值合并操作符,当左侧值为 null 或 undefined 时,取右侧的默认值。这组代码的用意是防止后端字段缺失或类型不匹配导致界面崩溃,它把不可信的外部数据转换为内部可用的结构化对象,做好从接口到界面最后一公里的防护。很多线上故障其实就是缺了这一步适配,后端改了个字段名,前端就崩了,而适配层恰恰是前端能主动把控风险的位置。
5. 真机联调避坑:签名、同步与数据刷新的五个排查点
5.1 现象一:真机安装 HAP 包时报签名不一致
把 HAP 包手动拖到真机安装时,系统弹窗提示“无法安装,应用签名不一致”。原因是我本地的签名文件和手机上已安装的版本签名不同,通常是因为之前用自动签名装过一版,后来工程里切换了签名证书,或者换了一台电脑重新签名。解决方法是先卸载手机上已安装的旧版本 App,再重新安装新包。记住签名信息的匹配是安装失败的元凶,和代码逻辑无关,别浪费时间查代码。
5.2 现象二:页面数据更新了但界面不刷新
列表页里调用接口拿到了新数据,数组也打印出来确认有新内容,但 UI 上就是不变。排查后发现是直接把数组this.courts.push(item),而 @State 监听的数组地址没有变化,ArkUI 没感知到元素级变更。解决办法是重新赋一个新数组,比如用扩展运算符拷贝后赋值。另一个常见原因是 @State 修饰的是普通对象,修改对象里的嵌套属性不会触发刷新,要改用 @Observed 类或者重新整体赋值对象引用。
5.3 现象三:请求头设置了 Content-Type 但接口总返回 415
HTTP 请求发出去了,后端返回 415 Unsupported Media Type,排查后才意识到 contentType 设置成了application/x-www-form-urlencoded,而后端期望的是 JSON。不同接口对请求体格式的要求不一样,这套混淆点在于同一个封装函数里用自己的 header 覆盖了默认值,导致后面新增的 POST 接口复制粘贴时没注意。解决方法是每个接口在文档里明确标记 Content-Type,或者在封装的 request 方法里根据 method 自动设置,不让调用方手动传。
5.4 现象四:列表滑动时卡片闪烁、位置错乱
ForEach 遍历时用了数组索引作为 key,服务端返回的列表顺序发生变化后,ArkUI 复用组件时匹配不上旧节点,出现闪烁和内容串位。解决方法是给每条数据一个稳定唯一的 id,用item.id作为渲染 key 而不是 index。这个 id 是后端返回的主键字段,只要它不变,界面就能准确跟踪每个卡片的状态,不至于复用错位。
5.5 现象五:模拟器能跑但真机冷启动白屏
真机冷启动时页面一片白,模拟器却正常。排查发现aboutToAppear里同步执行了阻塞式数据初始化,占用了主线程,导致 UI 长时间无法完成首帧绘制。真正原因是真机的数据读取速度比模拟器慢,同步等待时间更长。解决方法是把初始化逻辑改成异步执行,先渲染页面骨架再填充数据,同时加一个加载态组件提示用户等待。从那以后,我每次写页面生命周期里的初始化逻辑都会先问自己一句:这段代码会不会阻塞首帧。
6. 把示例改造成你的工具:Mock 开关、缓存降级与日志验证
拿到源码并跑通之后,下一步就是把静态演示数据替换成你自己的后端接口。源码里已经留了一个 Mock 数据开关,你可以在utils/Config.ets里找到类似USE_MOCK的布尔变量。写代码时我习惯把这个开关放在一个独立配置文件中,方便随时切换,不要让它在业务代码里散落。
// utils/Config.ets export const USE_MOCK = true; // 改 false 走真实接口 export function fetchCourts(): Promise<Court[]> { if (USE_MOCK) { return Promise.resolve(mockCourts()); } return HttpUtil.request(http.RequestMethod.GET, 'https://your-api-host/api/courts'); }这个开关的使用顺序值得讲究:先开着 Mock 跑通所有页面跳转和预约流程,确认 UI 层没问题后再切到真实接口,集中精力排查字段映射问题。直接上真实接口的话,一旦页面白屏,你很难判断是 UI 逻辑问题还是接口数据问题,等于同时引入两个变量,排错成本翻倍。改成真实接口后,要自测一遍完整的预约链路,包括列表加载、日期选择、表单提交、本地存储、登录态恢复,每个环节打印一条日志。
console.info(`[预约链路] 提交成功: ${JSON.stringify(booking)}`);日志是验证数据链路最直接的手段,比断点调试更省时间。我见过太多人抱怨接口数据对不上,结果一打日志就发现是字段名拼写错误,前后端各检查一遍就知道问题在哪。这套源码里已经埋了几个关键日志点,你可以在 IDE 的 Log 面板里过滤[预约链路]这个标签,一条条看数据流转是否完整。
接着可以加上缓存降级逻辑:请求失败时读取本地最近一次成功的数据,保证用户在没有网络的情况下还能查看场馆列表。这个功能对预约类工具特别重要,毕竟用户可能在信号差的健身房门口掏出手机查场地。基本思路是在preferences中存一份序列化后的列表数据,作为接口异常时的兜底方案,让应用不至于一断网就白屏。
真要把它做成能上线的小工具,还需要补齐这几件事:检查后端接口是否返回了正确的场次时间,确认多场馆场景下预约冲突怎么处理,以及把本人信息和联系人信息区分清楚。预约系统的核心是“数据不发散”,更新了已选时间段,提交时必须用同一个数据源,切来切去容易出乱子。
从那以后,我每次接手新工程都会强制走一遍这套验证流程:先看 Mock 开关在哪里,再打日志确认数据链路,最后才改界面逻辑。这套源码里最值得学的不是某个炫技写法,而是它把常见业务场景的每一步都处理得踏实,照着这个思路改出来的预约工具,才真正能解决你自己的实际问题。希望帮到你。
本文还有配套的精品资源,点击获取