我把一个从“附近停车场查询”到“预约车位”的工具型App用 ArkTS 完整做了一遍,真机跑通之后最大的感受是:难的不是语法,而是整个思维切换。用惯了 Android 的 XML/Compose 或 iOS 的 UIKit/SwiftUI 再来看鸿蒙开发,先把 ArkTS 那套声明式 UI 和状态管理逻辑接受下来,后面会顺很多。如果你正准备用 ArkTS 做实际业务,这篇就按我真实做停车应用的过程来拆:项目建好后怎么分模块、首页列表怎么做、定位和地图怎么接、网络层怎么封、预约状态机怎么设计,最后再聊模拟器和真机上那些最容易被忽略的坑。
1. 先把停车业务压缩成可落地的MVP:ArkTS不是瓶颈,需求才是
1.1 我最终决定做的功能集合
很多人在拿到“停车应用”这个题目时,第一反应是“先做一个地图,能看到附近停车场”。这其实是最危险的起点。地图只是展示手段,停车业务的核心是“车位状态在时间上的变化”:哪个停车场还有空位、哪个车位被预约了、预约后多久未到场会释放。如果你连业务对象都没理清,一上来就接地图SDK,后面接口一变,页面状态全得返工。
我最后把功能收敛成了这三个主流程:
- 停车场列表:按当前位置展示附近停车场,显示距离、总车位数、剩余车位数、收费标准。
- 停车场详情:展示车场楼层/区域结构、实时空位数量、收费标准明细,支持“预约车位”入口。
- 预约与状态流转:用户选择可预约时段,创建订单,生成预约倒计时;到达后可确认入场;超时未到场自动取消。
没有第一版就做支付、月卡、会员、停车记录等。因为这些功能会把业务复杂度拉高一个量级,但对验证 ArkTS 方案来说不是必需品。
1.2 数据模型和接口边界先于页面定义
第一次用 ArkTS 写业务时,我很自然地照着 TypeScript 的习惯先写类型。停车应用里至少有这几个核心模型:
typescript复制export class ParkingLot {
id: string;
name: string;
address: string;
latitude: number;
longitude: number;
totalSpace: number;
freeSpace: number;
pricePerHour: number;
distance?: number;
}
export class ReservationOrder {
orderId: string;
lotId: string;
spaceCode: string;
status: 'CREATED' | 'RESERVED' | 'ARRIVED' | 'EXPIRED' | 'CANCELED';
reserveStart: number;
reserveEnd: number;
}
我建议把类属性定义得比页面字段多一些,因为接口返回的字段往往有下划线风格,后端可能需要 lot_id,前端需要 lotId。在网络层统一做一次字段映射,页面层直接使用干净的驼峰模型,能省掉很多麻烦。
1.3 “纯鸿蒙”带来的隐性差异
用 ArkTS 时,不要把它当成标准 TypeScript 的子集来写。官方对动态类型有很多限制,比如在普通工程里不建议用 any、不能用某些 JS 运行时特性,代码需要被 ArkTS 编译器约束。这对写业务来说其实是好事,它倒逼你把“数据是什么”定义清楚。
我实际写下来最大的差异来自 UI 框架:ArkUI 不是传统 Controller 模式,而是用 @Component 定义组件、用 build() 描述界面。页面的主体不是 View 的继承体系,而是一个个 struct。一开始很不习惯,因为布局、事件、状态全挤在一个结构体里,如果不刻意控制粒度,一个页面文件很容易写到上千行。
所以我的第一建议是:不要等到写页面时才想架构。先确认需求边界,再开始做 ArkTS 工程,这是整个项目省时间的前提。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 新建工程与模块布局:entry、feature、HAR的角色别混淆
2.1 DevEco Studio建工程时的几个关键选择
我用 DevEco Studio 新建工程时,目标设备选了 Phone,语言选了 ArkTS,工程模板直接用了 Empty Ability。这里要注意,模板默认只生成一个 entry 模块,很多教程也默认所有页面都扔在 entry 里。如果只是写 demo,这没问题;但一旦页面超过五个,公共工具类、网络层、数据模型全部堆在 entry 里,后续改一处就要全量编译,效率会明显下降。
建议新建工程之后,按这样的模块边界来调整:
| 模块 | 类型 | 职责 |
|---|---|---|
| AppScope | 全局配置 | 应用级配置、公共资源 |
| entry | 应用入口模块 | 启动页、首页框架、应用生命周期 |
| common | HAR | 网络请求、通用工具、基础数据模型 |
| feature-parking | HAR/feature | 停车业务相关页面与状态管理 |
| service-parking | HAR | 停车业务仓库层,封装接口调用 |
我不是让你一上来就把模块拆得很碎。停车这种中小型项目,拆一层 common 公共 HAR,再拆一个 feature-parking 业务模块就够了。核心原则是:纯技术能力下沉到 HAR,业务页面按功能域隔离,入口模块只负责装配和启动。
2.2 HAR里能不能放so?封装公共能力时的注意点
关键词里出现“鸿蒙HAR封装so”,这确实是实际工程里会遇到的问题。如果停车应用里要复用一套已经编译好的 C/C++ 能力,比如车牌识别、停车场道闸计费算法,通常做法是把带 N-API 接口的代码打成 HAR 包,然后通过 oh-package.json5 的本地依赖引入。
json复制{
"dependencies": {
"common": "file:../common",
"feature-parking": "file:../feature-parking"
}
}
这里有个容易踩的坑:HAR 里如果包含 .so 文件,不要通过页面里的相对路径去引,ArkTS 的模块解析对这种方式很敏感。正确的做法是让 HAR 导出接口声明文件(index.d.ts),页面代码只 import 导出的函数或类。如果项目编译后出现“找不到 so 文件”或“loader returned false”这类问题,先检查 HAR 打包是否把对应 ABI 目录的 so 还原到了最终产物中,而不是直接去改源码路径。
2.3 模块边界先服务于编译效率
有人会问:既然 feature 模块最终也要被 entry 引用,为什么不直接写在 entry 里?差别最大的是编译和复用。把公共网络层放到 common HAR 后,接口字段调整只影响 common;把停车业务放到 feature 后,后面如果再做“车主端”和“管理端”两个入口应用,可以直接复用 feature-parking,而不是从一个超大 entry 里复制代码。
我自己的经验是,模块拆分的标准只有一个:能不能让正在改代码的人清楚地知道“这个改动会影响哪些模块”。如果改完一个公共类型导致所有页面都编译了一遍,说明拆分粒度太粗;如果为了改一个字段要同时提交五六个模块,说明拆分过细。停车应用这个体量,两级拆分通常最顺手。
3. 首页停车场列表:ArkTS声明式UI最容易被忽视的三件事
3.1 页面直接由状态驱动
首页的核心场景并不复杂:进入页面后请求停车场列表,接口返回后渲染成列表。但在 ArkUI 里怎么写,会直接影响后面的维护成本。
我是这样组织首页的:
typescript复制@Entry
@Component
struct ParkingLotListPage {
@State lotList: ParkingLot[] = [];
@State loading: boolean = true;
@State errorMsg: string = '';
build() {
Column() {
if (this.loading) {
LoadingProgress()
.width(80)
.height(80)
} else if (this.errorMsg.length > 0) {
Text(this.errorMsg)
Button('重试')
.onClick(() => {
this.loadLotList();
})
} else if (this.lotList.length === 0) {
Text('附近暂无可用停车场')
} else {
List({ space: 12 }) {
ForEach(this.lotList, (lot: ParkingLot) => {
ListItem() {
ParkingLotCard({ lot: lot })
}
}, (lot: ParkingLot) => lot.id)
}
.width('100%')
.layoutWeight(1)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
.onAppear(() => {
this.loadLotList();
})
}
}
这段代码里最关键的不是 List 和 ForEach,而是 loading、errorMsg、空列表三个状态的互斥。列表页最常见的问题是只处理了加载成功一种情况,一旦接口超时或返回空数组,页面就白屏,用户不知道是没网还是没数据。用 if/else 把加载态、错误态、空态、正常态分开渲染,看起来代码多一些,但用户体验完全不一样。
3.2 状态装饰器先统一,再谈全局状态
ArkUI 默认提供了 @State、@Prop、@Link、@Provide、@Consume 等状态装饰器。新建项目时我建议先确认当前使用的鸿蒙 SDK 版本对应的状态管理语法,因为 V1 和 V2 两套体系还是有不少差异。同一个项目里频繁混用会让人非常痛苦。
我在首页里只用组件内部 @State 保存列表数据,然后通过子组件的 @Prop 把一个 ParkingLot 对象传进去。停车卡片只需要展示数据,不需要反向修改父组件状态,所以用 @Prop 就够了:
typescript复制@Component
struct ParkingLotCard {
@Prop lot: ParkingLot;
build() {
Column() {
Text(this.lot.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text(`${this.lot.address}`)
.fontSize(14)
.fontColor('#666666')
Row() {
Text(`剩余 ${this.lot.freeSpace}/${this.lot.totalSpace} 个`)
Text(`${this.lot.distance ?? 0} m`)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.onClick(() => {
// 跳转到详情
})
}
}
这里要特别注意 freeSpace 和 distance 这类可能变化的字段。车位剩余数在高峰期几乎每几秒就变一次,如果只是做 MVP,可以增加一个下拉刷新按钮,让用户手动刷新。不要为了“实时”而在首页做全局轮询,那对性能和服务器压力都不友好。
3.3 ForEach的key不要偷懒
ArkTS 里的 ForEach 第三个参数是 key 生成器。我第一次写时直接省略了,结果列表刷新后部分图片和状态闪动得很奇怪。后来给每条停车场数据加上了稳定的 id 作为 key,ArkUI 才能准确判断哪一项需要更新、哪一项需要删除。
这个细节同样适用于预约记录、车位列表等所有带状态的对象。养成“列表项自带稳定唯一标识”的习惯,在鸿蒙上比在 Web 里写 React key 还重要,因为 ArkUI 的列表复用在 key 缺失时很容易出现状态残留。
4. 定位与距离计算:权限、坐标、地图三件事分开排查
4.1 权限配置只是第一步,动态申请才是重点
停车应用需要获取用户位置,因此在 module.json5 里必须声明定位权限:
json复制{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION"
},
{
"name": "ohos.permission.LOCATION"
}
]
}
}
只写配置是不够的。从 API 9 开始,鸿蒙对敏感权限强烈建议在运行时向用户申请,而且申请时机不能放在页面加载时。我的做法是:首页先不弹权限框,等用户点击“查看附近停车场”后再触发定位;如果用户拒绝了,页面显示“未开启定位,无法获取附近停车场”,并提供一个“去设置”按钮。
申请权限的常见写法逻辑类似:
typescript复制import { abilityAccessCtrl, common } from '@kit.AbilityKit';
let atManager = abilityAccessCtrl.createAtManager();
let context = getContext(this) as common.UIAbilityContext;
let permissions: Array<Permissions> = [
'ohos.permission.APPROXIMATELY_LOCATION',
'ohos.permission.LOCATION'
];
atManager.requestPermissionsFromUser(context, permissions).then((result) => {
// 根据 result.authResults 判断哪些权限用户同意了
})
一定要区分“大致位置”和“精确位置”。如果业务只是展示“附近停车场”,优先申请大致位置就够,避免用户对精确位置授权产生顾虑。如果涉及导航到具体车场入口,才需要精确位置。
4.2 获取当前位置:模拟器和真机是两套结果
拿到定位权限后,再调用定位接口。ArkTS 里定位相关的 SDK 在不同版本上有不同写法,我这里用最常见的形式:
typescript复制import geoLocationManager from '@ohos.geoLocationManager';
let locationRequest = {
'priority': geoLocationManager.LocationRequestPriority.FIRST_FIX,
'scenario': geoLocationManager.LocationRequestScenario.CAR_PARKING,
'maxAccuracy': 100
};
geoLocationManager.getCurrentLocation(locationRequest)
.then((location) => {
if (location) {
console.info(`lat: ${location.latitude}, lng: ${location.longitude}`);
}
})
.catch((err) => {
console.error(`locate error: ${JSON.stringify(err)}`);
});
真机上定位通常没问题,但模拟器里很容易翻车。有些模拟器版本返回的经纬度一直是厂商默认位置,无论你怎么改模拟器设置都不变。所以联调阶段不要拿模拟器的定位坐标去验证“附近停车场排序”,最好是把定位接口换成可注入的 Mock 数据源,保证功能测试不依赖真实定位。
4.3 距离计算:不要等地图SDK给你算
停车场列表里最常见的字段是“距离我xx米”。这个值其实不需要先接地图 SDK,自己用球面距离公式算即可:
typescript复制function haversineDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number {
const R = 6371000;
const rad = (deg: number) => (deg * Math.PI) / 180;
const dLat = rad(lat2 - lat1);
const dLng = rad(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(rad(lat1)) * Math.cos(rad(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c);
}
不过实际做排序时,我建议把“计算距离”和“距离字段展示”分开:接口如果能够返回车场到用户的直线距离,直接信任接口;接口没有时才在端上计算。因为很多停车场的入口和用户所在位置之间隔着主干道,单纯经纬度直线距离和真实到达距离差很多。MVP 阶段可以先用直线距离排序,但产品文案上不要写“到达时间”,只写“直线距离”。
4.4 地图方案不是第一优先级
定位、距离、地图这三件事里,地图的集成成本最高。停车详情页展示车场位置,最容易的做法是接地图 SDK 或者 Web 地图组件。但地图 SDK 需要申请服务 Key,测试环境和生产环境还要区分域名白名单,很容易把首版开发节奏拖慢。
我的建议是:先不做地图。详情页用“地址文本 + 一个简单的静态示意图 + 跳转系统地图导航”即可。等到核心预约流程验证通了,再单独抽出时间接地图 SDK,否则你会在“地图加载不出来 vs 预约流程没写完”两个问题之间来回切换,非常难受。
5. 网络请求与Mock联调:预约功能需要的不只是一个get
5.1 统一封装http请求,避免每个页面各写一套
ArkTS 里可以用 @ohos.net.http 或 Kit 化的 @kit.NetworkKit 发起网络请求。我建议所有项目都封装成统一的 ApiClient,不要在页面里直接 new 一个 http 对象。
一个简化的封装思路是:
typescript复制import { http } from '@kit.NetworkKit';
class ApiClient {
request<T>(
url: string,
method: http.RequestMethod,
params?: object
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const httpRequest = http.createHttp();
httpRequest.request(url, {
method: method,
header: { 'Content-Type': 'application/json' },
extraData: params ? JSON.stringify(params) : undefined,
connectTimeout: 10000,
readTimeout: 15000
}).then((response) => {
const result = JSON.parse(response.result as string) as ApiResult<T>;
if (result.code === 0) {
resolve(result.data);
} else {
reject(new Error(result.message));
}
}).catch((err) => {
reject(new Error(`network error: ${err.message}`));
});
});
}
}
export const apiClient = new ApiClient();
随后每个业务接口单独写一个方法,放在 Repository 层。比如停车场列表:
typescript复制export class ParkingRepository {
getNearbyLots(latitude: number, longitude: number): Promise<ParkingLot[]> {
return apiClient.request<ParkingLot[]>(
`/parking/v1/lots?lat=${latitude}&lng=${longitude}`,
http.RequestMethod.GET
);
}
}
这样页面代码不需要关心接口是 GET 还是 POST,不需要每次都做 JSON.parse,出问题时只需要在 Repository 层打断点。
5.2 停车场景的网络错误比想象中更频繁
停车应用的使用场景很特殊:用户在地下车库、商场负一层、隧道出口这些地方,信号经常不稳定。如果网络层只分“成功”和“失败”,预约这样的核心操作会出现很糟糕的体验。
我针对停车场景做了至少三层错误分类:
- 请求超时:提示“网络较慢,请重试”,不能直接让用户以为预约失败。
- 接口业务失败:例如“车位已被占用”“预约时段已满”,需要刷新车位状态。
- 鉴权失效:如果后端使用 token,过期后不能只提示失败,要跳转登录或静默刷新 token。
ArkTS 里 httpRequest.request 的超时时间要分别设置连接超时和读取超时。地下车库这种场景,连接超时往往很慢,与其让用户傻等 30 秒,不如把连接超时控制在 10 秒以内,读取超时可以放宽到 15 秒。
5.3 Mock数据先行,开发不等后端
我在启动这个项目时后端接口还没完全就绪,所以提前做了一个 Mock 层。不是简单地在页面里写死一个列表,而是做一个实现相同接口签名、默认返回构造数据的 Repository:
typescript复制export class MockParkingRepository implements ParkingRepository {
getNearbyLots(latitude: number, longitude: number): Promise<ParkingLot[]> {
const mockData: ParkingLot[] = [
{
id: '1001',
name: '中心广场停车场',
address: '某路100号',
latitude: latitude + 0.01,
longitude: longitude + 0.01,
totalSpace: 200,
freeSpace: 8,
pricePerHour: 5
}
];
return Promise.resolve(mockData);
}
}
页面只依赖 ParkingRepository 接口,A/B 切换时直接替换实现。等到真实接口可用后,不修改页面,只需要把 Repository 换回真实实现。这是我在多端开发里比较常用的一套方式,也推荐给第一次做鸿蒙业务的人。
6. 预约流程的状态设计与倒计时恢复
6.1 订单状态机是预约功能的地基
停车预约不是一个“点击就完成”的接口调用,它需要经过多个状态。我设计了下面这套状态流转:
| 页面状态 | 对应场景 | 用户操作 |
|---|---|---|
| idle | 进入车场详情,尚未预约 | 点击预约按钮 |
| submitting | 预约请求已发出,等待返回 | 按钮置灰,防重复点击 |
| reserved | 预约成功,进入倒计时 | 展示剩余时间,可取消 |
| arrived | 用户到达车场并确认入场 | 结束预约,进入计费流程 |
| expired | 倒计时结束未到场 | 展示订单已释放,可重新预约 |
| canceled | 用户主动取消 | 回到可预约状态 |
如果你只用一两个 @State 布尔值去管理这些状态,后面会越来越乱。我建议在模型里定义枚举或别名:
typescript复制export type ReservationStatus =
| 'idle'
| 'submitting'
| 'reserved'
| 'arrived'
| 'expired'
| 'canceled';
页面拿到状态后,只需要根据 ReservationStatus 渲染对应按钮和文案。任何时候出现一个未知字符串,都当成异常状态处理,不要静默掉。
6.2 倒计时不要只在页面里做减法
预约成功后,服务端会返回预约截止时间 reserveEnd。我在前端做的事是计算当前时间到截止时间剩余多少秒,再用 setInterval 每秒更新 UI。
typescript复制startCountdown(expireAt: number) {
this.clearCountdown();
this.timerId = setInterval(() => {
const left = Math.max(0, Math.floor((expireAt - Date.now()) / 1000));
this.remainSeconds = left;
if (left <= 0) {
this.clearCountdown();
this.refreshOrderStatus();
}
}, 1000);
}
clearCountdown() {
if (this.timerId !== undefined) {
clearInterval(this.timerId);
this.timerId = undefined;
}
}
千万要注意,不要让组件自己把剩余秒数一步步减下去,因为页面一旦退到后台、被系统回收、或者用户切走再回来,基于本地计时器的减法会明显不准。最好的做法是本地只负责“展示”,判断是否超时以 expireAt(后端时间戳)为准,每次回到页面时重新计算一次剩余时间。
6.3 页面返回、重复点击、网络抖动都要兜底
预约按钮如果响应很快,重复点击会出现两个并发请求,后端如果没有做幂等,就会生成两条相同订单。我在预约按钮的点击回调里先判断状态,只有 idle 状态才允许发起请求,发起后立即把状态改成 submitting,按钮同时禁用。
页面在预约请求发出后,用户如果直接返回上一页甚至杀掉 App,这时不能假定订单已经创建成功。我一般不建议在本地直接标记“已预约”,而是以下一次查询订单状态为准。比较稳妥的做法是:进入预约详情页时先查询一次实时订单状态,如果服务端已经有 reserved 订单,UI 自然恢复倒计时;如果没有,就展示可以预约的按钮。
这不算复杂,但很关键。很多用户从停车详情页返回首页后,内心默认订单已经提交了;如果因为网络慢导致实际没有创建成功,用户到了车场才发现没预约上,这就是事故。所以预约结果页/详情页一定要做“状态回查”。
7. 模拟器验证、签名打包和真机安装的一些实战经验
7.1 模拟器能验证UI,验证不了真实定位和部分后台行为
我前期大量页面逻辑是在模拟器和 Previewer 上跑的。模拟器比较适合验证列表渲染、页面跳转、预约状态机这些不依赖外部设备的逻辑。但有两类问题必须上真机才能发现:
- 真实定位权限弹窗、定位首次获取耗时、在电梯间/地下车库等弱网环境的请求表现。
- 应用退到后台再回来,倒计时是否还在正确区间,状态有没有被系统恢复。
所以当你看到模拟器上一切正常时别高兴太早。我会专门列一个“真机测试清单”,把弱网、断网、权限拒绝、后台切回这些都跑一遍。小程序和 App 都一样,弱网下的体验才是用户是否给你差评的关键。
7.2 自动签名与安装失败的排查路径
真机调试前需要在 DevEco Studio 里配置签名。个人开发阶段通常可以使用自动签名,前提是电脑已登录相关开发者账号,而且工程的 bundleName 不能随便和别人冲突。自动签名机制会自动申请调试证书和 Profile,但它不是万能的。
我遇到过的典型问题是:
- 真机已经打开开发者模式,但 DevEco Studio 识别不到设备:先检查 USB 调试授权弹窗是否被错过,再检查设备管理器里有没有出现未知设备。
- 安装时报签名相关错误:常见原因是在多个工程之间复制代码时,把旧的
build-profile.json5里的签名配置也复制过来了。删掉旧签名配置,重新生成一份是最快的。 - 报 API Level 不匹配:如果设备系统版本较低,而工程配置的
compatibleSdkVersion太高,会安装不上。把兼容版本调低到目标设备支持的范围内再试。
7.3 发布前的自测清单更依赖业务场景
功能能跑通之后,我做了一份以场景为导向的自测清单,而不是逐个页面打开看一眼就算完。停车应用至少应该覆盖下面这些场景:
| 场景 | 测试重点 |
|---|---|
| 首次安装 | 定位权限弹窗顺序是否正确,拒绝后是否有引导 |
| 无网进入首页 | 是否有错误提示,点击重试是否可用 |
| 停车场空位为0 | 详情页是否还能进入,预约按钮是否置灰 |
| 预约成功后断网 | 倒计时是否还能正常计算,恢复网络后是否和服务器一致 |
| 倒计时归零 | 状态是否刷新为“已释放”,按钮是否恢复可预约 |
| 弱网点击预约 | 是否防重复提交,是否有明确 loading 反馈 |
这些场景并不需要写很复杂的自动化代码,手工跑一遍就能抓到大部分低级别 bug。关键是一定要按真实用户路径来,而不是按页面清单来。
最后说一个我养成的习惯。ArkTS 项目里我遇到的大部分“莫名其妙”问题,最后都指向两处:一是模块依赖没有正确刷新,二是状态装饰器用错了层级。遇到 UI 不刷新,先别急着改页面,看状态变更有没有走到正确的组件边界;遇到编译不过,先跑一次 clean,再检查 oh-package.json5 的本地依赖。把这两个基础动作养成肌肉记忆,鸿蒙开发的日常效率会高很多。
