ArkUI 深色质感风布局实战:汽车出行·自驾租车·七Tab差异化布局·Vision Kit卡证识别全解析
一、引言:自驾租车行业的数字化变革

在中国出行市场中,自驾租车正在经历一场深刻的数字化变革。过去十年里,租车行业从线下门店柜台办理、纸质合同签署、人工核验证件的传统模式,逐步走向全流程线上化、自助化、智能化的新阶段。用户通过手机 App 完成选车、下单、支付、取车、还车的全链路操作,已经成为一线城市年轻消费群体的主流选择。

在这一行业背景下,HarmonyOS(鸿蒙操作系统)凭借其分布式架构、原生智能能力和统一的 ArkUI 声明式开发框架,为出行类应用提供了独特的技术底座。尤其是 HarmonyOS 提供的 Vision Kit(视觉服务套件),内置了卡证识别(CardRecognition)能力,可以直接调用系统级控件完成驾驶证、身份证、港澳通行证等多种证件的实时拍摄与信息提取,无需接入第三方 OCR 服务,也无需将证件照片上传至云端,既保障了用户隐私安全,又大幅提升了核验效率。

本文将以"悠驾出行 · 自驾租车平台"这一完整应用为案例,深入剖析其基于 ArkUI 框架的工程实现。该应用覆盖了租车业务的核心场景:首页推荐与取车提醒、取车证件核验、车场浏览、订单管理、网点查询、钱包财务管理、自驾攻略时间轴。最值得关注的是,应用中的 7 个 Tab 页面采用了 7 种完全不同的布局风格,展示了 ArkUI 在布局多样性、状态管理、动画系统、模态弹窗、图表绘制等方面的综合能力。

二、ArkUI 与 HarmonyOS 技术背景

ArkUI 是 HarmonyOS 的声明式 UI 开发框架,采用 ArkTS 作为开发语言。ArkTS 在 TypeScript 基础上扩展了注解式语法(如 @Entry、@Component、@State、@Builder、@Observed 等),使开发者能够以声明式的方式描述 UI 结构和状态驱动关系。

ArkUI 的核心设计理念包括以下几个方面:

声明式范式:开发者只需声明"UI 长什么样",框架负责在状态变化时自动重新渲染受影响的部分。这避免了传统命令式编程中手动操作 DOM 的繁琐。
状态驱动:通过 @State、@Prop、@Link、@Observed 等装饰器,建立状态与 UI 的双向绑定。状态变化时,框架精确地只更新依赖该状态的 UI 片段,实现高效渲染。
Builder 函数:@Builder 装饰器允许将 UI 片段封装为可复用的函数,支持参数传递,类似其他框架中的"组件插槽"或"渲染函数"概念,但保持了 ArkUI 的声明式特性。
原生能力集成:通过 @kit.VisionKit 等系统能力套件,ArkUI 应用可以直接调用 HarmonyOS 的 AI 能力、视觉识别能力,实现端侧智能处理。
在本案例中,这些特性得到了充分运用。@Observed 类用于数据模型的响应式追踪,@State 管理 Tab 切换、弹窗开关、扫描状态等局部 UI 状态,@Builder 函数封装了头部、7 个 Tab 页面、图表卡片、底部 Tab Bar 以及三种模态弹窗,形成了清晰的结构分层。
三、应用概览:悠驾出行平台
"悠驾出行"定位为自驾租车平台,核心业务覆盖以下场景:
- 首页:横滑 Banner 展示优惠活动,取车提醒卡片引导用户前往核验
- 取车:基于 Vision Kit 的卡证识别,支持驾驶证(正副页)、身份证、港澳/台湾居民来往通行证
- 车场:横滑车型大卡,展示在租车辆规格与日租金
- 订单:票券式两段卡片布局,虚线分隔上下两段信息
- 网点:清单行展示附近网点,按距离排序
- 钱包:渐变大数字展示余额,优惠券列表
- 攻略:固定高度时间轴,展示自驾行程节点
视觉风格采用深石墨蓝(#0E1116)为主背景,搭配活力橙(#FF7A45)作为主色调、翠绿(#3FD98C)作为辅助色,整体呈现深色质感风格,既符合出行类应用的专业感,又通过暖色调点缀提升了视觉活力。
四、整体架构总览
在深入代码细节之前,先通过一张架构流程图理解应用的整体结构:
从架构图可以看出,应用采用 Stack 作为根容器,根据 scanning 状态决定是显示卡证识别全屏控件还是主业务界面。主界面内部采用 Column 垂直布局,依次为头部、可滚动内容区(内含 7 个 Tab 页面和图表卡片)、底部 Tab Bar。弹窗系统独立于主内容流,通过状态变量控制显隐。
五、颜色系统设计
在任何 UI 应用中,颜色系统是最基础也是最重要的设计基石。本案例定义了一个完整的 ColorPalette 接口和对应的 COLORS 常量,将所有颜色集中管理。
首先看颜色接口的定义:
interface ColorPalette {
bg: string;
card: string;
chip: string;
dark: string;
title: string;
sub: string;
text3: string;
main: string;
mainD: string;
sec: string;
secD: string;
red: string;
green: string;
purple: string;
line: string;
tabOn: string;
mask: string;
}
这个接口定义了 17 个颜色字段,涵盖了应用所需的所有色彩角色。每个字段名具有语义化含义:bg 是页面背景,card 是卡片背景,chip 是标签/按钮背景,dark 是更深的背景层次,title/sub/text3 分别代表三级文字颜色,main/mainD 是主色及其深色变体,sec/secD 是辅助色及其深色变体,line 是分隔线,tabOn 是 Tab 激活色,mask 是弹窗遮罩色。
接下来是具体的颜色值定义:
const COLORS: ColorPalette = {
bg: '#0E1116',
card: '#181D25',
chip: '#212833',
dark: '#12161C',
title: '#EDF1F6',
sub: '#B4BFD0',
text3: '#76839A',
main: '#FF7A45',
mainD: '#C25526',
sec: '#3FD98C',
secD: '#2A9E64',
red: '#FF6B6B',
green: '#3FD98C',
purple: '#B388FF',
line: '#262E3A',
tabOn: '#FF7A45',
mask: 'rgba(4,5,8,0.66)',
};
从颜色值可以分析出设计意图:背景色 #0E1116 是接近纯黑的深石墨蓝,营造沉稳专业的深色主题;卡片背景 #181D25 略亮于页面背景,形成层次感;主色 #FF7A45 是活力橙,用于关键操作按钮和强调数据;辅助色 #3FD98C 是翠绿,用于成功状态和积极信息。mainD 和 secD 分别是主色和辅助色的深色变体,用于渐变背景的起始色。这种"深底+暖色点缀"的配色方案在出行类应用中非常常见,能够在深色环境下保持良好的可读性,同时通过暖色调传递活力。
使用接口+常量的集中式颜色管理方式有三大优势:一是修改主题色只需改一处;二是避免了颜色值散落在代码各处导致的维护困难;三是语义化命名让代码可读性大幅提升,看到 COLORS.main 就知道这是主色调,而不必记住具体的十六进制值。
六、常量定义与数据建模
6.1 Tab 元数据定义
底部 Tab Bar 分两行展示 7 个入口,每行通过独立的常量数组管理:
interface TabMeta {
icon: string;
label: string;
}
const TAB_ROW1: TabMeta[] = [
{ icon: '🏠', label: '首页' },
{ icon: '🔑', label: '取车' },
{ icon: '🚗', label: '车场' },
{ icon: '🧾', label: '订单' },
];
const TAB_ROW2: TabMeta[] = [
{ icon: '📍', label: '网点' },
{ icon: '💰', label: '钱包' },
{ icon: '🗺️', label: '攻略' },
];
TabMeta 接口只包含 icon 和 label 两个字段,使用 emoji 作为图标,简洁直观。将 7 个 Tab 拆成两行(4+3)的设计在手机宽屏下能有效利用横向空间,避免 7 个图标挤在一行导致触控区域过小。第一行 4 个是核心高频功能(首页、取车、车场、订单),第二行 3 个是辅助功能(网点、钱包、攻略),体现了功能优先级的分层设计。
6.2 首页快捷入口定义
interface EntryMeta {
icon: string;
label: string;
}
const HOME_ENTRY: EntryMeta[] = [
{ icon: '🚗', label: '立即租车' },
{ icon: '🔁', label: '异地还车' },
{ icon: '📅', label: '长期包租' },
{ icon: '🏢', label: '企业用车' },
{ icon: '🧑✈️', label: '代驾服务' },
{ icon: '🛟', label: '道路救援' },
{ icon: '⚡', label: '充电查询' },
{ icon: '🎧', label: '更多服务' },
];
首页头部宫格定义了 8 个快捷入口,覆盖了租车业务的主要场景。收起时只显示前 4 个,展开时全部 8 个可见。EntryMeta 与 TabMeta 结构相同但语义不同,分别建模避免了概念混淆。
6.3 Vision Kit 卡证类型映射
const SCAN_TYPES: CardType[] = [
CardType.CARD_DRIVER_LICENSE,
CardType.CARD_DRIVER_LICENSE,
CardType.CARD_ID,
CardType.CARD_MAINLAND_TRAVEL_PERMIT_HK_MO,
CardType.CARD_MAINLAND_TRAVEL_PERMIT_TW,
];
这是一个关键的映射数组。SCAN_TYPES 的索引与后面的 DOC_LIST 一一对应——当用户点击第 i 个证件类型时,通过 SCAN_TYPES[i] 获取对应的 CardType 枚举值,传给 Vision Kit 的 CardRecognition 控件。可以看到,前两项都映射为 CARD_DRIVER_LICENSE(驾驶证正副页共用一种识别类型),第三项是身份证,第四、五项分别是港澳通行证和台湾通行证。这种"UI 列表索引到系统能力枚举"的映射模式,是连接业务层与能力层的典型做法。
6.4 图表数据与虚线索引
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const RENT_VAL: number[] = [2, 3, 2, 4, 6, 5];
const DASH_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23];
MONTH_NAME 和 RENT_VAL 是月度租车柱状图的数据源,6 个月分别对应 2/3/2/4/6/5 单,7 月达到峰值 6 单。DASH_IDX 是一个 0 到 23 的整数数组,用于在订单票券中模拟虚线分隔效果——由于 ArkUI 的 Divider 组件不支持 strokeDashArray 属性,开发者巧妙地用 24 个等宽短横(通过 layoutWeight(1) 均分宽度)拼接来模拟虚线,这是一个典型的"框架限制下的创意解决方案"。
七、辅助函数设计
function rentStatusColor(s: string): string {
if (s === '用车中') return COLORS.green;
if (s === '待取车') return COLORS.main;
return COLORS.text3;
}
rentStatusColor 函数将租车状态字符串映射为颜色值:"用车中"返回翠绿色(积极状态),“待取车"返回活力橙色(需要关注的待办状态),其他(如"已完成”)返回灰色文字色。这个函数在订单列表和时间轴中都被调用,是状态视觉化的核心逻辑。
function carHot(n: string): boolean {
return n.indexOf('SUV') >= 0 || n.indexOf('MVP') >= 0;
}
carHot 函数判断车型名称中是否包含"SUV"或"MVP"关键字,用于车场页面中对热门车型添加呼吸动画效果。简单但有效地实现了"热门车型动态高亮"的视觉效果。
八、数据模型类设计(@Observed)
本案例定义了 7 个 @Observed 数据模型类,分别对应不同的业务实体。@Observed 装饰器使类的实例成为可观察对象,当其属性变化时,引用该对象的 UI 会自动刷新。
8.1 BannerItem - 横滑 Banner 数据
@Observed export class BannerItem {
tag: string;
title: string;
sub: string;
constructor(tag: string, title: string, sub: string) {
this.tag = tag;
this.title = title;
this.sub = sub;
}
}
BannerItem 包含标签(如"首租礼")、主标题(如"新客首日 0 元开走")、副标题(如"认证驾照即享")三个字段。对应的静态数据:
const BANNER_LIST: BannerItem[] = [
new BannerItem('首租礼', '新客首日 0 元开走', '认证驾照即享'),
new BannerItem('周末游', 'SUV 周末特惠 5 折', '周五取 · 周日还'),
new BannerItem('长租省', '月租低至 ¥2,680', '含保养 · 不限里程'),
];
三条 Banner 数据覆盖了首租、周末短租、长租三种典型场景,营销信息层次分明。
8.2 DocItem - 证件核验数据
@Observed export class DocItem {
icon: string;
name: string;
desc: string;
isNew: boolean;
constructor(icon: string, name: string, desc: string, isNew: boolean) {
this.icon = icon;
this.name = name;
this.desc = desc;
this.isNew = isNew;
}
}
DocItem 的 isNew 字段用于标识新增证件类型(港澳通行证和台湾通行证标记为 true),在 UI 上以"NEW"标签突出显示。对应的证件列表:
const DOC_LIST: DocItem[] = [
new DocItem('🪪', '驾驶证(正页)', '准驾车型 · 有效期核验', false),
new DocItem('📄', '驾驶证(副页)', '档案编号 · 状态核验', false),
new DocItem('👤', '居民身份证', '大陆二代证 · 双面识别', false),
new DocItem('🎫', '港澳居民来往内地通行证', '回乡证租车 · 拍卡即录', true),
new DocItem('🚗', '台湾居民来往大陆通行证', '台胞证租车 · 拍卡即录', true),
];
5 个证件项与前面的 SCAN_TYPES 数组一一对应,这种"数据列表+能力映射"的双数组设计,使 UI 展示与系统能力调用保持同步。
8.3 CarItem - 车型数据
@Observed export class CarItem {
name: string;
spec: string;
price: string;
tag: string;
constructor(name: string, spec: string, price: string, tag: string) {
this.name = name;
this.spec = spec;
this.price = price;
this.tag = tag;
}
}
const CAR_LIST: CarItem[] = [
new CarItem('特斯拉 Model 3', '纯电 · 5 座', '¥328/天', '热门'),
new CarItem('丰田 RAV4', 'SUV · 自动挡', '¥258/天', '推荐'),
new CarItem('别克 GL8', 'MVP · 7 座', '¥468/天', '家庭'),
new CarItem('五菱缤果', '纯电 · 市区代步', '¥128/天', '实惠'),
];
车型数据覆盖了电动车、SUV、MPV、经济型四个品类,价格从 128 元到 468 元,满足不同用户画像的需求。
8.4 RentItem - 租车订单数据
@Observed export class RentItem {
car: string;
site: string;
span: string;
status: string;
constructor(car: string, site: string, span: string, status: string) {
this.car = car;
this.site = site;
this.span = span;
this.status = status;
}
}
const RENT_LIST: RentItem[] = [
new RentItem('丰田 RAV4 · 粤B·8K2L6', '深圳湾网点', '08-25 10:00 → 08-27 18:00', '待取车'),
new RentItem('特斯拉 Model 3 · 粤B·D81F3', '前海驿站', '08-12 09:00 → 08-13 12:00', '已完成'),
new RentItem('别克 GL8 · 粤B·5M7Q9', '机场 T3 网点', '09-02 14:00 → 09-06 14:00', '待取车'),
new RentItem('五菱缤果 · 粤B·A02C8', '南山中心网点', '07-20 16:00 → 07-21 10:00', '已完成'),
];
RentItem 是订单管理的核心数据模型,car 字段包含了车型与车牌号,span 记录取还车时间段,status 标记订单状态。注意初始数据中使用了真实格式的粤 B 车牌号,增加了应用的真实感。
8.5 SiteItem - 网点数据
@Observed export class SiteItem {
name: string;
cars: string;
dist: string;
constructor(name: string, cars: string, dist: string) {
this.name = name;
this.cars = cars;
this.dist = dist;
}
}
const SITE_LIST: SiteItem[] = [
new SiteItem('深圳湾网点', '可用 18 台 · 24h 营业', '1.2 km'),
new SiteItem('机场 T3 网点', '可用 32 台 · 24h 营业', '8.6 km'),
new SiteItem('前海驿站', '可用 9 台 · 07:00-23:00', '3.4 km'),
new SiteItem('南山中心网点', '可用 14 台 · 24h 营业', '2.1 km'),
new SiteItem('福田高铁站网点', '可用 21 台 · 06:00-24:00', '5.8 km'),
];
网点数据按距离排序(1.2km 到 8.6km),包含可用车辆数和营业时间,方便用户选择最近最便利的取车点。
8.6 GuideItem - 攻略时间轴数据
@Observed export class GuideItem {
time: string;
title: string;
status: string;
note: string;
constructor(time: string, title: string, status: string, note: string) {
this.time = time;
this.title = title;
this.status = status;
this.note = note;
}
}
const GUIDE_LIST: GuideItem[] = [
new GuideItem('D1 09:00', '深圳湾取车', '已完成', '验车拍照 · 确认油电'),
new GuideItem('D1 11:30', '抵达大鹏半岛', '已完成', '杨梅坑 · 较场尾'),
new GuideItem('D2 10:00', '东西冲穿越', '进行中', '徒步 4h · 备好补给'),
new GuideItem('D2 18:30', '南澳海鲜晚餐', '待开始', '水头沙海鲜街'),
new GuideItem('D3 16:00', '还车验车', '待开始', '提前加满油/充满电'),
];
攻略数据以"D1/D2/D3 + 时间"格式记录三天自驾行程的 5 个关键节点,状态包含"已完成"、“进行中”、"待开始"三种,通过 rentStatusColor 函数映射为不同颜色的圆点。
8.7 ScanRecord - 识别记录数据
@Observed export class ScanRecord {
time: string;
cardName: string;
raw: string;
constructor(time: string, cardName: string, raw: string) {
this.time = time;
this.cardName = cardName;
this.raw = raw;
}
}
ScanRecord 记录每次卡证识别的结果,raw 字段存储识别返回的原始 JSON 字符串。这个类是动态创建的——每次 Vision Kit 识别成功后,会 new ScanRecord(...) 并 push 到 scanRecords 数组中,实现识别历史的实时累积展示。
九、组件主体与状态管理
9.1 @Entry 组件声明
@Entry
@Component
struct Page1006 {
@Entry 标记这是应用的入口组件,@Component 声明这是一个自定义组件。struct 是 ArkTS 中定义组件的关键字,组件名 Page1006 是内部标识。
9.2 状态变量定义
组件内部通过多个 @State 变量管理 UI 状态:
// --- Tab 状态 ---
@State currentTab: number = 0;
currentTab 是最核心的状态变量,控制当前显示哪个 Tab 页面。初始值为 0(首页),范围 0-6 对应 7 个 Tab。每次用户点击底部 Tab 或页面内跳转按钮,都会修改此值触发 UI 切换。
// --- 头部宫格收起/展开 ---
@State gridExpand: boolean = true;
gridExpand 控制首页头部宫格的展开/收起状态。展开时显示 8 个入口(2 行),收起时只显示前 4 个(1 行),配合高度动画实现平滑过渡。
// --- 弹窗状态 ---
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = -1;
@State delIdx: number = -1;
三个布尔变量分别控制新增、编辑、删除弹窗的显隐;两个索引变量记录当前操作的目标订单下标。这种"每个弹窗一个独立状态"的设计虽然状态变量较多,但逻辑清晰,互不干扰。
// --- 弹窗表单 ---
@State addTitle: string = '';
@State addNote: string = '';
新增弹窗中的两个输入框绑定到 addTitle 和 addNote,用户输入时通过 onChange 回调实时更新状态。
// --- 动画状态 ---
@State breath: boolean = false;
timer: number = -1;
breath 是呼吸动画的开关变量,每秒翻转一次(true/false),驱动依赖它的 UI 元素产生闪烁/呼吸效果。timer 是定时器 ID,注意它没有 @State 装饰器——因为它不直接驱动 UI 渲染,只是一个普通的实例属性。
// --- 卡证识别状态 ---
@State scanning: boolean = false;
@State scanIdx: number = -1;
@State scanRecords: ScanRecord[] = [];
scanning 控制是否显示全屏的 Vision Kit 识别控件;scanIdx 记录当前正在核验的证件在 DOC_LIST 中的索引;scanRecords 是动态增长的识别记录列表。
// --- 可变数据 ---
@State rentList: RentItem[] = RENT_LIST;
rentList 初始化为静态常量 RENT_LIST,但因为有 @State 装饰,后续可以通过 push、splice、索引替换等操作动态修改订单列表,UI 会自动刷新。
十、生命周期与呼吸动画
aboutToAppear() {
this.timer = setInterval(() => {
this.breath = !this.breath;
}, 1000);
}
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear 是组件即将显示时的生命周期回调,这里启动了一个每秒执行一次的定时器,翻转 breath 布尔值。aboutToDisappear 是组件即将销毁时的回调,清理定时器避免内存泄漏。
这个呼吸动画机制贯穿整个应用——头部累计租车次数的透明度闪烁、取车大卡的钥匙图标透明度变化、车场热门车型的图标闪烁、钱包余额数字的呼吸效果、月度柱状图的高度微抖动,都依赖这个每秒翻转的 breath 状态。这是一种极简但有效的"心跳式"全局动画驱动模式。
十一、build() 主入口结构
build() {
Stack() {
if (this.scanning && this.scanIdx >= 0) {
this.scanView()
} else {
Column() {
this.headerDrive()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabHome()
} else if (this.currentTab === 1) {
this.tabPickup()
} else if (this.currentTab === 2) {
this.tabFleet()
} else if (this.currentTab === 3) {
this.tabOrder()
} else if (this.currentTab === 4) {
this.tabSite()
} else if (this.currentTab === 5) {
this.tabWallet()
} else {
this.tabGuide()
}
this.chartCard()
}
.padding({ left: 14, right: 14, top: 14, bottom: 18 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
this.tabBar()
}
.width('100%')
.height('100%')
build() 方法是组件的渲染入口。最外层是 Stack 容器,通过条件判断决定显示 scanView()(Vision Kit 全屏识别)还是主业务界面。
主界面内部是 Column 垂直布局:首先是 headerDrive() 头部组件,然后是 1 像素的 Divider 分隔线,接着是 Scroll 可滚动内容区(占据剩余空间,通过 layoutWeight(1) 实现),最后是 tabBar() 底部导航。
Scroll 内部的 Column 包含 7 选 1 的 Tab 页面和共享的 chartCard() 图表卡片。Tab 切换通过 if-else if-else 条件链实现,只有匹配的 Tab 内容才会被渲染,未选中的 Tab 不会占用渲染资源。每个 Tab 页面下方都会显示月度图表,这是通过将 chartCard() 放在条件链之外实现的。
if (this.addModal) {
this.panelAdd(() => {
this.addModal = false;
})
}
if (this.editModal) {
this.panelEdit(() => {
this.editModal = false;
})
}
if (this.delModal) {
this.panelDel(() => {
this.delModal = false;
})
}
弹窗系统通过三个独立的 if 条件控制,每个弹窗接收一个 onClose 回调函数,用于在关闭时将对应的布尔状态设为 false。这种"回调函数传递"的模式让弹窗内部的关闭逻辑非常灵活。
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
.alignContent(Alignment.Center)
}
Stack 根容器设置了全屏尺寸、深色背景和居中对齐。alignContent(Alignment.Center) 确保弹窗在 Stack 中居中显示(删除弹窗使用了居中布局)。
十二、头部 headerDrive() 详解
头部是应用的"门面",集成了品牌标识、行程数据条和快捷功能宫格。
12.1 品牌标题行
@Builder
headerDrive() {
Column({ space: 12 }) {
Row() {
Column({ space: 2 }) {
Text('悠驾出行').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('自驾租车 · you drive').fontSize(10).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Row({ space: 6 }) {
Text('🛡️').fontSize(12)
Text('已认证').fontSize(12).fontColor(COLORS.sec)
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(COLORS.chip)
.borderRadius(12)
}
.width('100%')
头部第一行是品牌标题区。左侧是两行文字:品牌名"悠驾出行"用 20 号粗体白色字,副标题"自驾租车 · you drive"用 10 号灰色字。中间用 Column().layoutWeight(1) 占据弹性空间将右侧元素推到最右。右侧是"已认证"标签,用盾牌 emoji 配翠绿色文字,放在圆角灰色背景上,表明用户已完成身份认证。
12.2 行程数据条
Row({ space: 14 }) {
Column({ space: 2 }) {
Text('18').fontSize(30).fontWeight(FontWeight.Bold).fontColor(COLORS.main)
.opacity(this.breath ? 1 : 0.72)
Text('累计租车(次)').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
Column().width(1).height(38).backgroundColor(COLORS.line)
Column({ space: 2 }) {
Text('42 天').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('累计行程').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
Column().width(1).height(38).backgroundColor(COLORS.line)
Column({ space: 2 }) {
Text('5 枚').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.sec)
Text('驾驶勋章').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
行程数据条展示三个关键指标:累计租车 18 次(30 号橙色大数字,带呼吸透明度效果)、累计行程 42 天(白色 20 号字)、驾驶勋章 5 枚(翠绿色 20 号字)。三个数据之间用 1 像素宽、38 像素高的灰色竖线分隔,形成了数据仪表盘式的视觉效果。18 次的数字最大且是唯一的橙色,配合呼吸动画,是用户最关注的"成长指标"。
Column().layoutWeight(1)
Text('🔑').fontSize(16)
.width(34).height(34).textAlign(TextAlign.Center)
.backgroundColor(COLORS.chip).borderRadius(17)
.onClick(() => {
this.currentTab = 1;
})
}
.width('100%')
数据条最右侧是一个 34x34 的圆形钥匙按钮,点击后跳转到取车 Tab(currentTab = 1),形成了从首页到取车核验的快捷引导。
12.3 快捷宫格
Grid() {
ForEach(HOME_ENTRY, (e: EntryMeta, i: number) => {
if (i < 4 || this.gridExpand) {
GridItem() {
Column({ space: 6 }) {
Text(e.icon).fontSize(20)
Text(e.label).fontSize(10).fontColor(COLORS.sub)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}
.onClick(() => {
if (e.label === '立即租车') {
this.currentTab = 1;
}
})
}
}, (e: EntryMeta) => e.label)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate(this.gridExpand ? '1fr 1fr' : '1fr')
.columnsGap(10)
.rowsGap(10)
.width('100%')
.height(this.gridExpand ? 150 : 75)
.animation({ duration: 220, curve: Curve.EaseInOut })
快捷宫格使用 Grid 组件实现 4 列布局。ForEach 遍历 HOME_ENTRY 数组,但通过条件 i < 4 || this.gridExpand 控制只渲染前 4 项(收起时)或全部 8 项(展开时)。rowsTemplate 在展开时为 '1fr 1fr'(2 行),收起时为 '1fr'(1 行)。高度在 150 和 75 之间切换,配合 animation 的 220 毫秒缓入缓出动画,实现了平滑的展开/收起过渡效果。
点击"立即租车"入口会跳转到取车 Tab,形成了首页到取车的业务引导链路。
12.4 展开/收起按钮
Row() {
Text(this.gridExpand ? '收起 ∧' : '展开 ∨')
.fontSize(10)
.fontColor(COLORS.main)
.padding({ left: 14, right: 14, top: 4, bottom: 2 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.gridExpand = !this.gridExpand;
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 14, bottom: 14 })
.backgroundColor(COLORS.card)
}
最底部是一个居中的展开/收起切换按钮,文字根据 gridExpand 状态在"收起 ∧"和"展开 ∨"之间切换,点击翻转状态。头部整体使用卡片背景色 #181D25,14 像素的内边距,形成了独立的头部卡片视觉区域。
十三、首页 tabHome() - 横滑 Banner 与取车提醒
13.1 横滑 Banner
@Builder
tabHome() {
Column({ space: 12 }) {
Scroll() {
Row({ space: 12 }) {
ForEach(BANNER_LIST, (b: BannerItem) => {
Column({ space: 6 }) {
Text(b.tag).fontSize(9).fontColor(COLORS.sec)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(COLORS.dark).borderRadius(8)
Text(b.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(b.sub).fontSize(10).fontColor(COLORS.sub)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width(220)
.alignItems(HorizontalAlign.Start)
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(14)
}, (b: BannerItem) => b.title)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
首页 Banner 采用横向 Scroll + Row 的经典横滑方案。每个 Banner 卡片固定宽度 220 像素,内部包含标签(翠绿色小字)、标题(15 号粗体白色,单行省略)、副标题(10 号灰色,单行省略)。scrollable(ScrollDirection.Horizontal) 启用横向滑动,scrollBar(BarState.Off) 隐藏滚动条以保持视觉整洁。maxLines(1) 配合 textOverflow 确保长文本不会撑破卡片布局。
13.2 取车提醒卡片
Row({ space: 10 }) {
Text('🚨').fontSize(20)
Column({ space: 2 }) {
Text('取车提醒').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('明天 10:00 · 深圳湾网点 · RAV4 待取').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('去核验').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.dark)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(COLORS.main).borderRadius(14)
.onClick(() => {
this.currentTab = 1;
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(14)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
取车提醒卡片采用水平布局:左侧警示灯 emoji,中间是提醒标题和详细信息(取车时间、网点、车型),右侧是橙色"去核验"按钮,点击跳转到取车 Tab。按钮使用深色文字配橙色背景,对比强烈,引导性强。这种"信息+行动按钮"的卡片模式在出行类应用中非常实用。
十四、取车 tabPickup() - Vision Kit 卡证识别
取车页面是整个应用的技术核心,集成了 HarmonyOS Vision Kit 的卡证识别能力。
14.1 中心大卡 - 核验入口
@Builder
tabPickup() {
Column({ space: 12 }) {
// 中心大卡:Vision Kit 取车核验入口
Column({ space: 10 }) {
Text('🔑').fontSize(40)
.opacity(this.breath ? 1 : 0.75)
Text('自助取车 · 证件秒核').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('基于系统级卡证识别控件完成取车核验。\n港澳居民持回乡证租车同样拍卡即录,无需柜台排队。')
.fontSize(10)
.fontColor(COLORS.sub)
.textAlign(TextAlign.Center)
Text('选择证件开始核验 ↓').fontSize(11).fontColor(COLORS.main)
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(COLORS.chip)
.borderRadius(14)
}
.width('100%')
.padding(22)
.backgroundColor(COLORS.dark)
.borderRadius(16)
.linearGradient({
angle: 160,
colors: [[COLORS.mainD, 0.0], [COLORS.dark, 0.6]]
})
中心大卡是取车页面的视觉焦点。40 号的钥匙 emoji 带呼吸透明度效果,下方是"自助取车 · 证件秒核"的标题和两行说明文字。卡片使用了 linearGradient 线性渐变背景,从 160 度方向的 mainD(深橙色)渐变到 60% 位置的 dark(深灰),形成了从左上到右下的橙色光晕效果。Text('选择证件开始核验 ↓') 是引导性标签,用橙色文字配灰色圆角背景,指向下方的证件列表。
14.2 证件核验列表
Text('取车需核验的证件').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
ForEach(DOC_LIST, (d: DocItem, i: number) => {
Row({ space: 12 }) {
Text(d.icon).fontSize(20)
.width(40).height(40).textAlign(TextAlign.Center)
.backgroundColor(COLORS.chip).borderRadius(20)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(d.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (d.isNew) {
Text('NEW').fontSize(8).fontWeight(FontWeight.Bold).fontColor(COLORS.dark)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.backgroundColor(COLORS.sec).borderRadius(6)
}
}
Text(d.desc).fontSize(10).fontColor(COLORS.sub)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('核验').fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.main)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.chip).borderRadius(12)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
.onClick(() => {
this.scanIdx = i;
this.scanning = true;
})
}, (d: DocItem) => d.name)
证件列表通过 ForEach 遍历 DOC_LIST,每行包含:左侧 40x40 圆形证件图标、中间的证件名称和描述(新增证件显示翠绿色"NEW"标签)、右侧的"核验"按钮。
关键的交互逻辑在 onClick 中:this.scanIdx = i 记录当前选择的证件索引,this.scanning = true 开启扫描状态。这两个状态的变化会触发 build() 中的条件判断,切换到全屏的 scanView() 显示 Vision Kit 识别控件。
14.3 核验记录区
Row() {
Text('🕘 核验记录').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(`${this.scanRecords.length} 条`).fontSize(10).fontColor(COLORS.text3)
}
.width('100%')
if (this.scanRecords.length === 0) {
Text('暂无核验记录,点击上方证件类型开始拍卡核验')
.fontSize(10)
.fontColor(COLORS.text3)
.width('100%')
.padding(16)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.card)
.borderRadius(12)
} else {
ForEach(this.scanRecords, (r: ScanRecord) => {
Column({ space: 6 }) {
Row() {
Text(r.cardName).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.main)
Column().layoutWeight(1)
Text(r.time).fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Text(r.raw).fontSize(9).fontColor(COLORS.sub)
.maxLines(4)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
}, (r: ScanRecord, i: number) => `${r.time}-${i}`)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
核验记录区使用条件渲染:当 scanRecords 为空时显示引导提示文字;有记录时通过 ForEach 渲染每条记录卡片,显示证件名(橙色)、时间(灰色)和原始识别数据(灰色,最多 4 行省略)。记录列表会随识别操作动态增长,是 @State 数组响应式的典型应用。
十五、卡证识别流程
下面通过流程图展示从用户点击证件到识别完成的完整流程:
这个流程展示了 Vision Kit 卡证识别的完整闭环:从用户交互触发状态变更,到系统控件接管全屏进行端侧 AI 识别,再到识别结果回写数据模型并自动刷新 UI。整个流程中无需网络请求,所有识别在设备本地完成,保障了隐私安全和响应速度。
十六、车场 tabFleet() - 横滑车型大卡
@Builder
tabFleet() {
Column({ space: 12 }) {
Row() {
Text('🚗 在场车型').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('深圳湾网点 · 今日价').fontSize(10).fontColor(COLORS.sub)
}
.width('100%')
Scroll() {
Row({ space: 12 }) {
ForEach(CAR_LIST, (c: CarItem) => {
Column({ space: 6 }) {
Text('🚙').fontSize(40)
.opacity(this.breath && carHot(c.name) ? 1 : 0.8)
Row({ space: 6 }) {
Text(c.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(c.tag).fontSize(8).fontColor(COLORS.sec)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.backgroundColor(COLORS.dark).borderRadius(6)
}
Text(c.spec).fontSize(10).fontColor(COLORS.sub)
Text(c.price).fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.main)
.margin({ top: 4 })
}
.width(160)
.alignItems(HorizontalAlign.Start)
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(14)
}, (c: CarItem) => c.name)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
车场页面与首页 Banner 采用了相同的横滑方案,但卡片内容完全不同。每张车型卡片固定宽度 160 像素,包含:40 号汽车 emoji(热门车型通过 carHot 判断后添加呼吸闪烁效果)、车型名称配翠绿色标签(热门/推荐/家庭/实惠)、车辆规格(纯电/SUV/MVP 等)、日租金(15 号橙色粗体)。
carHot(c.name) 函数检查车型名是否包含"SUV"或"MVP",如果是则配合 breath 状态产生透明度闪烁,使热门车型在视觉上更突出。这是一个简单但有效的"动态高亮"实现。
十七、订单 tabOrder() - 票券两段卡
订单页面采用了独特的"票券两段卡"布局,用虚线分隔上下两段,模拟实体票据的视觉风格。
17.1 订单标题行
@Builder
tabOrder() {
Column() {
Row() {
Text('🧾 租车订单').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('+ 新订单').fontSize(12).fontColor(COLORS.main)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.chip).borderRadius(12)
.onClick(() => {
this.addModal = true;
})
}
.width('100%')
.margin({ bottom: 10 })
标题行右侧有"新订单"按钮,点击后设置 addModal = true 弹出新增订单弹窗。
17.2 票券上段
ForEach(this.rentList, (r: RentItem, i: number) => {
Column() {
// 票券上段
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(r.car).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${r.site} · ${r.status}`).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(r.status).fontSize(10).fontColor(rentStatusColor(r.status))
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 10 })
票券上段展示订单核心信息:车型(含车牌号)和网点+状态。右侧重复显示状态文字,但使用 rentStatusColor 函数着色——"待取车"显示橙色,"用车中"显示翠绿,"已完成"显示灰色。同一状态信息在不同位置以不同视觉权重呈现,增强了信息辨识度。
17.3 虚线分隔
// 虚线分隔(Row + 短横模拟,Divider 不支持 strokeDashArray)
Row({ space: 4 }) {
ForEach(DASH_IDX, (k: number) => {
Column().height(1).layoutWeight(1).backgroundColor(COLORS.line)
}, (k: number) => `${k}`)
}
.width('100%')
.padding({ left: 10, right: 10 })
这是前文提到的虚线模拟方案。由于 ArkUI 的 Divider 组件不支持虚线样式(strokeDashArray),开发者用 24 个高度为 1 像素、宽度由 layoutWeight(1) 等分的 Column 拼接成一条由 24 段短横组成的"虚线"。每段之间有 4 像素的间距(Row({ space: 4 })),形成了虚线的断续效果。这是一个在框架限制下发挥创意的典型例子。
17.4 票券下段
// 票券下段
Row() {
Text(r.span).fontSize(10).fontColor(COLORS.sub)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('改期').fontSize(9).fontColor(COLORS.purple)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.chip).borderRadius(9)
.onClick(() => {
this.editIdx = i;
this.editModal = true;
})
Text('删除').fontSize(9).fontColor(COLORS.red)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.chip).borderRadius(9)
.onClick(() => {
this.delIdx = i;
this.delModal = true;
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 12 })
}
.width('100%')
.backgroundColor(COLORS.card)
.borderRadius(12)
.margin({ bottom: 10 })
}, (r: RentItem) => `${r.span}-${r.car}`)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
票券下段展示取还车时间段,右侧有"改期"(紫色)和"删除"(红色)两个操作按钮。点击"改期"设置 editIdx 和 editModal,点击"删除"设置 delIdx 和 delModal,分别触发对应的弹窗。整个票券卡片用统一的卡片背景色和圆角包裹,上下两段通过虚线分隔,形成了类似电影票/优惠券的视觉风格。
十八、网点 tabSite() - 清单行布局
@Builder
tabSite() {
Column({ space: 12 }) {
Row() {
Text('📍 附近网点').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('按距离排序').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
ForEach(SITE_LIST, (s: SiteItem) => {
Row() {
Column({ space: 3 }) {
Text(s.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(s.cars).fontSize(9).fontColor(COLORS.sub)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row({ space: 4 }) {
Text('🧭').fontSize(12)
Text(s.dist).fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.main)
}
}
.width('100%')
.padding({ top: 12, bottom: 12, left: 14, right: 14 })
.backgroundColor(COLORS.card)
.borderRadius(12)
}, (s: SiteItem) => s.name)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
网点页面采用最简洁的清单行布局:每行左侧是网点名称和可用车辆数/营业时间,右侧是导航 emoji 和距离(橙色粗体)。这种布局信息密度高,适合快速浏览和比较多个网点的信息。ForEach 遍历 SITE_LIST,数据本身按距离排序(从 1.2km 到 8.6km),UI 只需按序展示即可。
十九、钱包 tabWallet() - 渐变大数字
19.1 余额大卡
@Builder
tabWallet() {
Column({ space: 12 }) {
// 渐变大数字卡
Column({ space: 8 }) {
Text('钱包余额(元)').fontSize(11).fontColor(COLORS.sub)
Text('368.50').fontSize(46).fontWeight(FontWeight.Bold).fontColor(COLORS.sec)
.opacity(this.breath ? 1 : 0.8)
Text('押金冻结 ¥500.00 · 还车后自动解冻').fontSize(11).fontColor(COLORS.main)
Row({ space: 10 }) {
Text('充值').fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.dark)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.main).borderRadius(14)
Text('提现').fontSize(11).fontColor(COLORS.title)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.chip).borderRadius(14)
Text('押金说明').fontSize(11).fontColor(COLORS.sub)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.chip).borderRadius(14)
}
.margin({ top: 8 })
}
.width('100%')
.padding(20)
.backgroundColor(COLORS.dark)
.borderRadius(16)
.linearGradient({
angle: 150,
colors: [[COLORS.secD, 0.0], [COLORS.dark, 0.55]]
})
钱包余额卡片是视觉焦点:46 号翠绿色粗体的余额数字"368.50"是全应用最大的字号,配合呼吸透明度效果和翠绿到深灰的 150 度渐变背景,形成了强烈的视觉冲击。下方是橙色押金冻结提示和三个操作按钮(充值/提现/押金说明),充值按钮使用橙色背景+深色文字作为主要操作,提现和押金说明使用灰色背景作为次要操作,视觉层次分明。
19.2 优惠券列表
Text('优惠券').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.width('100%')
ForEach(['首日 0 元券 · 8/31 到期', '周末 5 折券 · 满 300 可用', '充电 8 折券 · 不限门槛'], (c: string) => {
Row({ space: 10 }) {
Text('🎟️').fontSize(18)
Text(c).fontSize(11).fontColor(COLORS.title)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('去使用').fontSize(10).fontColor(COLORS.main)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
}, (c: string) => c)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
优惠券列表使用内联字符串数组(而非数据模型类),因为是纯展示型数据,无需响应式追踪。每行包含优惠券 emoji、券描述(单行省略)和橙色"去使用"引导文字。使用内联数组简化了代码,是轻量数据的合理处理方式。
二十、攻略 tabGuide() - 固定高度时间轴
@Builder
tabGuide() {
Column() {
Row() {
Text('🗺️ 大鹏 2 日自驾攻略').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('进行中').fontSize(10).fontColor(COLORS.sec)
}
.width('100%')
.margin({ bottom: 10 })
ForEach(GUIDE_LIST, (g: GuideItem, i: number) => {
Row({ space: 8 }) {
Column() {
Text(g.time.substring(0, 5)).fontSize(9).fontColor(COLORS.text3)
Circle().width(8).height(8).fill(rentStatusColor(g.status)).margin({ top: 3 })
if (i < GUIDE_LIST.length - 1) {
Column().width(2).layoutWeight(1).backgroundColor(COLORS.line).margin({ top: 3 })
}
}
.width(44)
.height(76)
.alignItems(HorizontalAlign.Center)
Column({ space: 5 }) {
Row() {
Text(g.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1)
Text(g.status).fontSize(10).fontColor(rentStatusColor(g.status))
}
.width('100%')
Text(g.note).fontSize(10).fontColor(COLORS.sub)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.height(64)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 10, top: 10, bottom: 10 })
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
}, (g: GuideItem) => `${g.time}-${g.title}`)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
攻略页面实现了固定高度的时间轴布局。左侧是 44 像素宽的时间轴列,固定高度 76 像素,内部从上到下依次是:时间文字(取 g.time 前 5 个字符,如"D1 09")、8x8 的状态圆点(颜色由 rentStatusColor 决定)、以及连接到下一个节点的竖线(最后一条没有竖线)。右侧是行程标题和备注,放在卡片背景中。
时间轴的关键设计在于"固定高度+竖线连接":每个节点高度固定 76 像素,竖线通过 Column().width(2).layoutWeight(1) 占据圆点下方的剩余空间,多个节点的竖线视觉上连成一条完整的轴线。状态圆点的颜色变化(绿色=已完成、橙色=进行中、灰色=待开始)让用户一眼就能了解行程进度。
二十一、图表 chartCard() - 柱状图绘制
@Builder
chartCard() {
Column({ space: 12 }) {
Row() {
Text('📊 月度租车(单)').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('近 6 个月').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Row({ space: 10 }) {
ForEach(MONTH_NAME, (m: string, i: number) => {
Column({ space: 6 }) {
Column()
.width(26)
.height(28 + RENT_VAL[i] / 6 * 76 + (this.breath ? 3 : 0))
.backgroundColor(RENT_VAL[i] === 6 ? COLORS.sec : COLORS.mainD)
.borderRadius({ topLeft: 6, topRight: 6 })
.opacity(this.breath ? 1 : 0.82)
Text(m).fontSize(9).fontColor(COLORS.text3)
}
.layoutWeight(1)
}, (m: string) => m)
}
.width('100%')
图表卡片实现了一个纯 ArkUI 的柱状图,无需引入任何图表库。核心技巧是用 Column 的 height 属性模拟柱子高度。高度计算公式为 28 + RENT_VAL[i] / 6 * 76 + (this.breath ? 3 : 0):基础高度 28 像素,加上数据值占最大值(6)的比例乘以 76 像素的动态高度,再叠加呼吸动画的 3 像素微抖动。
柱子颜色根据数据值决定:值为 6(峰值)的柱子用翠绿色 sec,其他用深橙色 mainD。柱子只有顶部圆角(topLeft 和 topRight),模拟真实柱状图的视觉。下方是月份标签。
Row() {
Row({ space: 6 }) {
Column().width(10).height(10).backgroundColor(COLORS.sec).borderRadius(3)
Text('租车高峰').fontSize(10).fontColor(COLORS.sub)
}
Row({ space: 6 }).margin({ left: 16 }) {
Column().width(10).height(10).backgroundColor(COLORS.mainD).borderRadius(3)
Text('常规月').fontSize(10).fontColor(COLORS.sub)
}
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
.margin({ top: 12 })
}
图表底部是图例,居中显示翠绿色方块+"租车高峰"和深橙色方块+"常规月"两组图例说明。这种自绘柱状图虽然简单,但通过呼吸动画、颜色区分、图例说明,达到了不输专业图表库的视觉效果。
二十二、底部 tabBar() - 七 Tab 两行布局
22.1 单个 Tab 项
@Builder
tabItem(t: TabMeta, idx: number) {
Column({ space: 3 }) {
Text(t.icon).fontSize(19).opacity(idx === this.currentTab ? 1 : 0.5)
Text(t.label).fontSize(9)
.fontColor(idx === this.currentTab ? COLORS.tabOn : COLORS.text3)
.fontWeight(idx === this.currentTab ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.onClick(() => {
this.currentTab = idx;
})
}
tabItem 是单个 Tab 项的 Builder 函数,接收 TabMeta 和索引 idx 两个参数。当前选中的 Tab(idx === this.currentTab)图标完全不透明、文字橙色粗体;未选中的 Tab 图标半透明、文字灰色常规。点击后设置 currentTab = idx 触发页面切换。layoutWeight(1) 确保每个 Tab 项等分宽度。
22.2 Tab Bar 容器
@Builder
tabBar() {
Column({ space: 2 }) {
Row() {
ForEach(TAB_ROW1, (t: TabMeta, i: number) => {
this.tabItem(t, i)
}, (t: TabMeta) => t.label)
}
.width('100%')
Row() {
ForEach(TAB_ROW2, (t: TabMeta, i: number) => {
this.tabItem(t, i + 4)
}, (t: TabMeta) => t.label)
}
.width('100%')
}
.width('100%')
.padding({ top: 4, bottom: 6 })
.backgroundColor(COLORS.card)
}
Tab Bar 用 Column 包裹两个 Row,第一行遍历 TAB_ROW1(索引 0-3),第二行遍历 TAB_ROW2(索引 4-6,通过 i + 4 偏移)。整体使用卡片背景色,4-6 像素的上下内边距。7 个 Tab 分两行(4+3)的布局避免了单行 7 个 Tab 的拥挤感,也为主功能(第一行)和辅助功能(第二行)提供了视觉分层。
二十三、Tab 切换流程
Tab 切换流程清晰展示了一个点击操作如何驱动整个页面的更新:用户点击 → 状态变更 → 条件渲染重新求值 → 对应 Tab 内容渲染 → 图表卡片共享显示 → Tab Bar 高亮更新。这种"单一状态驱动多区域联动"的设计模式是 ArkUI 声明式范式的精髓。
二十四、scanView() - Vision Kit 控件
@Builder
scanView() {
// Vision Kit 卡证识别控件:识别期间全屏独占,不允许任何元素遮挡
CardRecognition({
supportType: SCAN_TYPES[this.scanIdx],
cardRecognitionConfig: {
defaultShootingMode: ShootingMode.MANUAL,
isPhotoSelectionSupported: true
},
onResult: ((params: CardRecognitionResult) => {
if (params.code !== 200) {
this.scanning = false;
return;
}
const parts: string[] = [];
if (params.cardInfo?.front !== undefined) {
parts.push(JSON.stringify(params.cardInfo.front));
}
if (params.cardInfo?.back !== undefined) {
parts.push(JSON.stringify(params.cardInfo.back));
}
if (params.cardInfo?.main !== undefined) {
parts.push(JSON.stringify(params.cardInfo.main));
}
this.scanRecords.push(new ScanRecord('刚刚', DOC_LIST[this.scanIdx].name, parts.join('\n')));
this.scanning = false;
})
})
.width('100%')
.height('100%')
}
scanView 是 Vision Kit 卡证识别的核心实现。CardRecognition 组件接收三个关键参数:
supportType:从SCAN_TYPES数组按scanIdx取出的CardType枚举值,决定识别哪种证件cardRecognitionConfig:配置对象,defaultShootingMode: ShootingMode.MANUAL设置为手动拍摄模式(用户主动点击拍摄),isPhotoSelectionSupported: true允许从相册选择照片识别onResult:识别完成回调,参数类型为CardRecognitionResult
回调逻辑中,首先检查 params.code 是否为 200(成功),非 200 直接关闭扫描。成功时从 params.cardInfo 中提取 front(正面)、back(背面)、main(主面)三部分数据(都通过可选链 ?. 安全访问),分别 JSON.stringify 后用换行符拼接为原始数据字符串。然后 new ScanRecord 创建记录并 push 到 scanRecords 数组,最后设置 scanning = false 退出扫描界面。
这个控件全屏独占显示,在识别期间不允许任何元素遮挡,确保了识别过程的纯粹性和用户体验的专注性。所有识别在设备端侧完成,不上传任何图片数据到云端,这是 HarmonyOS Vision Kit 的核心安全优势。
二十五、模态弹窗系统
25.1 通用遮罩层
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => onClose())
}
modalOverlay 是所有弹窗共用的遮罩层 Builder 函数,接收 onClose 回调。全屏半透明黑色背景(rgba(4,5,8,0.66)),点击遮罩区域触发关闭回调。这种"点击背景关闭"的交互模式在移动端弹窗中是标配。
25.2 新增订单弹窗
@Builder
panelAdd(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('+ 新增租车单').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column({ space: 6 }) {
Text('车型').fontSize(11).fontColor(COLORS.sub)
TextInput({ placeholder: '如:丰田 RAV4' })
.fontSize(13)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.chip)
.borderRadius(10)
.onChange((v: string) => {
this.addTitle = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
Text('网点与时间').fontSize(11).fontColor(COLORS.sub)
TextInput({ placeholder: '如:深圳湾 · 下周五 10:00 取' })
.fontSize(13)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.chip)
.borderRadius(10)
.onChange((v: string) => {
this.addNote = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
新增弹窗使用 Stack 叠加遮罩层和内容面板,内容从底部弹出(alignContent(Alignment.Bottom))。包含两个 TextInput 输入框,分别绑定 addTitle 和 addNote 状态。输入框使用灰色背景配圆角,placeholder 使用浅灰色引导文字。
Row({ space: 10 }) {
Button('取消')
.fontSize(13)
.fontColor(COLORS.sub)
.backgroundColor(COLORS.chip)
.borderRadius(14)
.layoutWeight(1)
.onClick(() => onClose())
Button('保存')
.fontSize(13)
.fontColor(COLORS.dark)
.backgroundColor(COLORS.main)
.borderRadius(14)
.layoutWeight(1)
.onClick(() => {
const car: string = this.addTitle === '' ? '待选车型' : this.addTitle;
const site: string = this.addNote === '' ? '待定' : this.addNote;
this.rentList.push(new RentItem(car, site, '待定', '待取车'));
this.addTitle = '';
this.addNote = '';
this.addModal = false;
})
}
.width('100%')
.margin({ top: 4 })
}
.width('100%')
.padding(18)
.backgroundColor(COLORS.card)
.borderRadius({ topLeft: 18, topRight: 18 })
}
.width('100%')
.height('100%')
.alignContent(Alignment.Bottom)
}
底部是"取消"和"保存"两个等宽按钮。保存逻辑:如果输入为空则用默认值(“待选车型”/“待定”),new RentItem 创建新订单并 push 到 rentList,清空输入框状态,关闭弹窗。由于 rentList 是 @State 数组,push 操作会触发 UI 自动刷新,新订单立即出现在订单列表中。
25.3 编辑订单弹窗
@Builder
panelEdit(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('修改订单状态').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(this.editIdx >= 0 ? this.rentList[this.editIdx].car : '')
.fontSize(12)
.fontColor(COLORS.sub)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
ForEach(['待取车', '用车中', '已完成'], (s: string) => {
Text(s).fontSize(12).fontColor(rentStatusColor(s))
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(COLORS.chip)
.borderRadius(14)
.onClick(() => {
if (this.editIdx >= 0) {
const r = this.rentList[this.editIdx];
this.rentList[this.editIdx] = new RentItem(r.car, r.site, r.span, s);
}
this.editModal = false;
})
}, (s: string) => s)
}
Button('关闭')
.fontSize(13)
.fontColor(COLORS.sub)
.backgroundColor(COLORS.chip)
.borderRadius(14)
.width('100%')
.onClick(() => onClose())
}
.width('100%')
.padding(18)
.backgroundColor(COLORS.card)
.borderRadius({ topLeft: 18, topRight: 18 })
}
.width('100%')
.height('100%')
.alignContent(Alignment.Bottom)
}
编辑弹窗展示当前订单的车型信息,下方是三个状态选项(“待取车”/“用车中”/“已完成”),每个选项用 rentStatusColor 着色。点击某个状态后,通过 new RentItem 创建一个替换对象(保持其他字段不变,只改 status),赋值到 rentList 的对应索引。这种"整体替换"而非"属性修改"的方式,是因为 @Observed 类的属性变化需要通过引用变更才能可靠触发数组项的 UI 更新。
25.4 删除订单弹窗
@Builder
panelDel(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 14 }) {
Text('⚠️ 删除订单').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.red)
Text(this.delIdx >= 0 ? `确定删除「${this.rentList[this.delIdx].car}」吗?` : '')
.fontSize(12)
.fontColor(COLORS.sub)
Row({ space: 10 }) {
Button('再想想')
.fontSize(13)
.fontColor(COLORS.sub)
.backgroundColor(COLORS.chip)
.borderRadius(14)
.layoutWeight(1)
.onClick(() => onClose())
Button('确认删除')
.fontSize(13)
.fontColor(COLORS.title)
.backgroundColor(COLORS.red)
.borderRadius(14)
.layoutWeight(1)
.onClick(() => {
if (this.delIdx >= 0) {
this.rentList.splice(this.delIdx, 1);
}
this.delModal = false;
})
}
.width('100%')
}
.width('72%')
.padding(18)
.backgroundColor(COLORS.card)
.borderRadius(16)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
删除弹窗与新增/编辑弹窗不同,使用居中布局(alignContent(Alignment.Center))和 72% 宽度,形成了对话框(Dialog)而非底部面板的视觉风格。标题和"确认删除"按钮使用红色 COLORS.red,警示性强。确认删除时调用 this.rentList.splice(this.delIdx, 1) 直接从数组中移除目标订单,splice 操作触发 @State 数组的响应式更新,UI 自动移除对应卡片。
二十六、七大 Tab 布局对比
下面通过表格对比 7 个 Tab 页面的布局特点:
| Tab | 页面名称 | 布局风格 | 核心组件 | 视觉特点 | 交互亮点 |
|---|---|---|---|---|---|
| 0 | 首页 | 横滑 Banner + 提醒卡 | Scroll(horizontal) + Row + Column | 双卡上下排列,Banner 可横滑 | 点击"去核验"跳转换车 Tab |
| 1 | 取车 | 中心大卡 + 证件列表 | Column + linearGradient + ForEach | 渐变大卡为焦点,列表项带 NEW 标签 | 点击证件唤起 Vision Kit 全屏识别 |
| 2 | 车场 | 横滑车型大卡 | Scroll(horizontal) + Row + ForEach | 等宽卡片横滑,热门车型呼吸闪烁 | carHot 函数动态高亮 SUV/MVP |
| 3 | 订单 | 票券两段卡 + 虚线分隔 | Column + Row + ForEach(模拟虚线) | 票券视觉,虚线分隔上下段 | 改期/删除操作触发弹窗 |
| 4 | 网点 | 清单行 | Row + ForEach | 信息密度高,右侧距离醒目 | 纯展示型,按距离排序 |
| 5 | 钱包 | 渐变大数字 + 优惠券列表 | Column + linearGradient + ForEach | 46px 大数字 + 翠绿渐变背景 | 充值/提现/押金三按钮分组 |
| 6 | 攻略 | 固定高度时间轴 | Row + Column + Circle + 竖线 | 时间轴左列 + 内容卡片右列 | 状态圆点颜色区分进度 |
从对比表可以看出,7 个 Tab 没有任何一个使用相同的布局方案,每个页面都根据其业务场景选择了最合适的布局形式。横滑 Scroll 用于需要展示多个等宽卡片的场景(首页 Banner、车场车型),For Each + Row 用于列表型数据(证件、订单、网点、优惠券),linearGradient 用于需要视觉冲击力的大卡(取车入口、钱包余额),时间轴则用于有序的行程节点展示。
二十七、Vision Kit 导入与能力体系深度解析
27.1 导入语句分析
import { CardRecognition, CardRecognitionResult, CardType, ShootingMode } from '@kit.VisionKit';
这行导入语句是整个应用技术含量最高的部分。从 @kit.VisionKit 模块中导入了四个核心符号:
CardRecognition:这是卡证识别控件组件,可以直接在build()中作为 UI 组件使用,类似Text、Column等基础组件,但它是一个系统级的复合控件,内部集成了相机调用、取景框渲染、AI 推理、结果回调等完整流程。CardRecognitionResult:识别结果的类型定义,包含code(状态码)、cardInfo(卡证信息对象,内含 front/back/main 三个可选属性)等字段。CardType:卡证类型枚举,定义了系统支持的各种证件类型,本案例用到了CARD_DRIVER_LICENSE(驾驶证)、CARD_ID(身份证)、CARD_MAINLAND_TRAVEL_PERMIT_HK_MO(港澳居民来往内地通行证)、CARD_MAINLAND_TRAVEL_PERMIT_TW(台湾居民来往大陆通行证)。ShootingMode:拍摄模式枚举,本案例使用MANUAL(手动拍摄),用户需要主动点击拍摄按钮触发识别。
27.2 端侧 AI 的隐私优势
传统租车应用的证件识别通常依赖云端 OCR 服务:用户拍摄证件照片 → 上传到服务器 → 服务端调用 OCR API → 返回识别结果。这个流程存在三个问题:一是证件照片(包含身份证号、驾驶证号等敏感信息)在网络传输中存在泄露风险;二是依赖网络质量,弱网环境下体验差;三是服务端 OCR 调用产生额外成本。
Vision Kit 的 CardRecognition 控件将整个识别流程移到了设备端侧:HarmonyOS 系统内置了针对各类证件优化的 AI 推理模型,控件启动后直接调用设备摄像头进行实时取景,用户对准证件后系统在本地完成推理识别,结果通过回调直接返回给应用。整个过程不产生任何网络请求,证件图像不离开设备,既保障了隐私安全,又实现了毫秒级响应。
27.3 通行证识别的行业价值
值得注意的是 CardType.CARD_MAINLAND_TRAVEL_PERMIT_HK_MO 和 CardType.CARD_MAINLAND_TRAVEL_PERMIT_TW 两种通行证类型。在粤港澳大湾区融合发展的背景下,越来越多的港澳台居民在内地租车出行。传统模式下,港澳台居民租车需要到柜台人工核验证件,效率低下。通过 Vision Kit 的通行证识别能力,港澳台居民可以像大陆居民一样自助完成证件核验,这大大提升了跨境出行的便利性。应用中这两种证件类型标记了"NEW"标签,也暗示了这是近期新增的能力。
二十八、虚线分隔的创意实现深度剖析
28.1 问题背景
在订单页面的票券卡片中,需要用虚线分隔上下两段信息,模拟实体票据的撕开线效果。然而 ArkUI 的 Divider 组件只支持实线,没有 strokeDashArray 这样的虚线样式属性。这是一个在声明式 UI 框架中常见的"能力缺口"问题。
28.2 解决方案
const DASH_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23];
开发者定义了一个包含 24 个整数(0-23)的数组,然后在渲染时:
Row({ space: 4 }) {
ForEach(DASH_IDX, (k: number) => {
Column().height(1).layoutWeight(1).backgroundColor(COLORS.line)
}, (k: number) => `${k}`)
}
.width('100%')
.padding({ left: 10, right: 10 })
ForEach 遍历这个 24 元素数组,渲染 24 个高度为 1 像素、宽度通过 layoutWeight(1) 等分的 Column。每个 Column 之间有 4 像素的间距(Row({ space: 4 })),形成了"24 段短横 + 23 个间隙"的虚线效果。
28.3 方案评价
这个方案虽然看似"笨拙",但实际上体现了声明式 UI 的一个重要思维模式:当框架不提供某个特性时,可以用基础组件的组合来模拟。24 个 Column 的渲染开销在现代设备上完全可以忽略不计,而视觉效果与真正的虚线几乎没有区别。DASH_IDX 数组中的具体数值(0-23)并不重要,重要的是数组长度决定了虚线的段数——如果需要更细的虚线,增加数组长度即可;如果需要更粗的虚线,减少数组长度即可。这种"用数据驱动视觉"的方式,正是声明式范式的精髓。
二十九、动画系统深度解析
29.1 呼吸动画的数学原理
.opacity(this.breath ? 1 : 0.72)
呼吸动画的核心是透明度在两个值之间的周期性切换。以头部累计租车次数为例,透明度在 1.0(完全可见)和 0.72(部分透明)之间每秒切换一次。由于人眼的视觉暂留效应,这种快速的透明度切换看起来不是"闪烁",而是"呼吸"般的柔和明暗变化。0.72 这个数值经过精心调校——如果差异太小(如 0.95),变化几乎不可见;如果差异太大(如 0.3),则变成了刺眼的闪烁。
29.2 Grid 展开动画
.height(this.gridExpand ? 150 : 75)
.animation({ duration: 220, curve: Curve.EaseInOut })
Grid 宫格的展开/收起使用了 ArkUI 的 animation 属性,指定 220 毫秒的持续时间和 EaseInOut 缓动曲线。EaseInOut 曲线在动画开始和结束时速度较慢,中间阶段加速,模拟了自然物体的运动节奏,比线性动画更有"物理感"。当 gridExpand 状态切换时,Grid 高度在 150 和 75 之间平滑过渡,rowsTemplate 也同步从 '1fr 1fr' 变为 '1fr',内容和容器同步动画。
29.3 柱状图的微抖动
.height(28 + RENT_VAL[i] / 6 * 76 + (this.breath ? 3 : 0))
柱状图的呼吸效果是在基础高度上叠加 3 像素的抖动。当 breath 为 true 时,每根柱子增高 3 像素;为 false 时回到基础高度。这种微小的高度变化让柱状图看起来像在"跳动",赋予了静态数据以生命力。3 像素的抖动幅度足够明显但不会干扰数据阅读,是数据可视化和动效设计的良好平衡。
29.4 动画驱动模式对比
| 动画类型 | 触发方式 | 实现机制 | 适用场景 |
|---|---|---|---|
| 透明度呼吸 | setInterval 定时器 | breath 布尔值每秒翻转 | 强调重要数据 |
| Grid 展开/收起 | 用户点击 | animation 属性 + 状态切换 | 内容折叠交互 |
| 柱状图抖动 | setInterval 定时器 | breath 叠加到高度计算 | 数据可视化活力 |
| 热门车型闪烁 | setInterval 定时器 | breath + carHot 条件判断 | 热门商品突出 |
从对比可以看出,本案例的动画策略是"一个定时器驱动多处效果"的集中式模式,而非"每个动画独立定时器"的分散式模式。这种选择在动画元素较多但周期一致的场景下是最优的——减少了定时器数量,降低了资源消耗,且所有呼吸效果同步跳动,形成了统一的视觉节奏。
三十、数据流与状态管理架构解析
30.1 单向数据流
ArkUI 采用单向数据流:用户交互修改状态 → 状态变化触发 build 重新执行 → 条件渲染更新 UI → 用户看到新界面。这种模式确保了 UI 始终是状态的正确投影,不会出现"状态和视图不一致"的 bug。
30.2 状态变量的职责分类
本案例的 @State 变量按职责可分为四类:
导航状态:currentTab 控制 Tab 切换,是应用级导航的核心。
交互状态:gridExpand(宫格展开)、addModal/editModal/delModal(弹窗开关)、editIdx/delIdx(操作目标索引),这些都由用户交互直接触发。
动画状态:breath 由定时器自动驱动,不受用户直接控制。
业务数据:rentList(订单列表)、scanRecords(识别记录)是可变业务数据,初始来自静态常量,运行中可通过操作增删改。
30.3 ScanRecord 的动态增长模式
this.scanRecords.push(new ScanRecord('刚刚', DOC_LIST[this.scanIdx].name, parts.join('\n')));
scanRecords 是一个动态增长的 @State 数组,每次识别成功后 push 新记录。由于 @State 装饰器会追踪数组引用的变化,push 操作触发 ForEach 重新渲染,新记录卡片自动出现在列表末尾。这种"操作即渲染"的模式无需手动调用刷新方法,是声明式框架的核心便利。
30.4 rentList 的增删改模式
// 新增
this.rentList.push(new RentItem(car, site, '待定', '待取车'));
// 编辑(整体替换)
const r = this.rentList[this.editIdx];
this.rentList[this.editIdx] = new RentItem(r.car, r.site, r.span, s);
// 删除
this.rentList.splice(this.delIdx, 1);
订单列表的三种操作分别对应数组的 push(追加)、索引赋值(替换)、splice(删除)。编辑操作采用"整体替换"而非"属性修改"的方式——这是因为 @Observed 类的属性级变化追踪在某些场景下不如引用级替换可靠。通过 new RentItem 创建新对象并赋值到原索引,确保了数组项的引用发生变化,触发可靠的 UI 更新。
三十一、技术亮点综合分析
31.1 颜色系统的工程化
本案例的颜色系统采用了"接口定义 + 常量实现"的模式,17 个语义化颜色字段覆盖了应用所有视觉角色。这种做法的好处在于:修改任何颜色只需改一处,避免了散落各处的硬编码颜色值;语义命名(main、sec、title、sub)让代码自文档化;接口约束确保了颜色字段的完整性,遗漏任何字段都会在编译时报错。
31.2 @Observed 与 @State 的分层响应式
数据模型类使用 @Observed 装饰器,使对象属性变化可被追踪;组件内使用 @State 管理局部 UI 状态。两者配合形成了分层响应式体系:@Observed 类的实例作为数据载体,可以被多个组件共享引用;@State 变量控制组件内部的 UI 状态(Tab 切换、弹窗开关、动画状态等)。当 @State 数组中的 @Observed 对象发生变化时(如 push、splice、索引替换),UI 会精确地只更新受影响的部分。
31.3 @Builder 的函数化 UI 复用
@Builder 装饰器是 ArkUI 中封装可复用 UI 片段的核心机制。本案例定义了 14 个 @Builder 函数,包括 1 个头部、7 个 Tab 页面、1 个图表卡片、1 个 Tab 项、1 个 Tab Bar、1 个扫描视图、1 个遮罩层、3 个弹窗面板。@Builder 函数支持参数传递(如 tabItem(t: TabMeta, idx: number) 和 panelAdd(onClose: () => void)),使 UI 片段可以接受外部数据和控制回调,实现了高度的灵活性和复用性。
31.4 Vision Kit 端侧智能
CardRecognition 是 HarmonyOS Vision Kit 提供的系统级卡证识别控件,本案例用它实现了驾驶证、身份证、港澳通行证、台湾通行证四种证件的端侧识别。关键优势在于:识别过程完全在设备本地完成,不上传图片到云端,保障用户隐私;系统级控件提供了统一的拍摄界面和交互体验,无需开发者自行实现相机 UI;识别结果通过 CardRecognitionResult 回调直接返回结构化数据(front/back/main),开发者只需 JSON 序列化即可存储和展示。
31.5 Canvas/图表的纯 ArkUI 实现
月度租车柱状图完全使用 ArkUI 的 Column 组件实现,无需引入任何图表库或 Canvas 绘制。核心技巧是将 Column 的高度与数据值关联,通过数学计算 28 + RENT_VAL[i] / 6 * 76 将数据映射为像素高度。配合呼吸动画叠加 3 像素抖动、颜色区分峰值月和常规月、图例说明,达到了接近专业图表库的视觉效果。这种"用布局组件模拟图表"的做法在轻量级数据可视化中非常实用。
31.6 模态弹窗的统一架构
三种弹窗(新增、编辑、删除)共享相同的架构模式:Stack 根容器 + modalOverlay 遮罩层 + 内容面板 + onClose 回调。遮罩层通过 @Builder 函数复用,内容面板根据业务需求定制。新增和编辑使用底部弹出(Alignment.Bottom),删除使用居中弹出(Alignment.Center),通过 alignContent 轻松切换弹窗位置。弹窗的显隐完全由 @State 布尔变量控制,声明式渲染自动处理弹窗的创建和销毁。
31.7 全局呼吸动画驱动
breath 状态变量每秒翻转一次,驱动了头部数字、取车钥匙、热门车型图标、钱包余额、柱状图柱子五个位置的呼吸/闪烁效果。这种"一个定时器驱动多个动画"的设计极简但有效,避免了为每个动画元素创建独立定时器的资源浪费。aboutToDisappear 中清理定时器,确保了组件销毁时不发生内存泄漏。
三十二、综合总结
"悠驾出行 · 自驾租车平台"这一案例完整展示了 ArkUI 框架在出行行业应用开发中的综合能力。从颜色系统的工程化设计,到 7 个 @Observed 数据模型类的建模,到 14 个 @Builder 函数的 UI 封装,到 Vision Kit 端侧卡证识别的集成,到纯 ArkUI 柱状图的绘制,到三种模态弹窗的统一架构,到全局呼吸动画的驱动机制——每一个环节都体现了 ArkUI 声明式范式的简洁与强大。
最值得借鉴的设计思路是"7 个 Tab 7 种布局"的差异化策略。很多应用在不同 Tab 页面中复用同一种列表布局,导致页面间视觉单调。而本案例根据每个 Tab 的业务场景精心选择了不同的布局方案:横滑 Banner、渐变大卡、横滑车型卡、票券虚线卡、清单行、渐变大数字、固定高度时间轴,形成了丰富的视觉层次。这种"业务场景驱动布局选择"的设计思维,值得每一个移动应用开发者学习。
Vision Kit 的集成是另一个亮点。通过 SCAN_TYPES 数组的索引映射,将 UI 层的证件列表与系统能力层的 CardType 枚举优雅连接;CardRecognition 控件的全屏独占显示确保了识别体验的专注性;识别结果回写 ScanRecord 并自动刷新 UI,形成了完整的"识别-存储-展示"闭环。端侧识别的隐私安全特性,对于处理驾驶证、身份证等敏感证件的租车场景,具有不可替代的价值。
在工程实践层面,本案例展示了多个值得参考的模式:用 24 个等宽 Column 模拟 Divider 不支持的虚线效果,用 Column 高度模拟柱状图柱子,用一个全局 breath 定时器驱动多处动画,用 onClose 回调函数实现弹窗的灵活关闭。这些"在框架限制下发挥创意"的解决方案,体现了开发者的工程智慧。
总而言之,这一案例不仅是一个功能完整的租车应用原型,更是一份 ArkUI 综合实践的教科书。无论是颜色系统、状态管理、Builder 封装、系统能力集成、图表绘制、动画驱动还是弹窗架构,都能为 HarmonyOS 应用开发者提供直接的参考价值。随着 HarmonyOS 生态的持续发展和 Vision Kit 等系统能力的不断丰富,基于 ArkUI 的出行类应用将拥有更广阔的创新空间。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

1.2 选择项目模板
在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:
| 类型 | 说明 |
|---|---|
| 应用(Application) | 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期 |
| 元服务(Atomic Service) | 开发轻量级的原子化服务,无需安装即可使用 |
选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

1.3 配置项目信息
点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 项目名称(Project name) | rollboat |
应用的项目名称,建议使用英文命名 |
| 包名(Bundle name) | com.rollboat.myapplication |
应用唯一标识,采用反向域名格式 |
| 保存路径(Save location) | D:\CodeFactory\rollboat |
项目本地存储路径,避免使用中文和空格 |
| 兼容 SDK(Compatible SDK) | 6.1.1(24) |
目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异 |
| 模块名称(Module name) | entry |
主模块名称,默认 entry 为应用入口模块 |
| 设备类型(Device types) | ☑ Phone | 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV |
右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

1.4 完成创建
确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 “退出代码为 0” 表示项目初始化成功。

1.5 项目结构概览
创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:
rollboat/
├── .hvigor/ # Hvigor 构建工具缓存
├── .idea/ # IDE 配置文件
├── AppScope/ # 应用级全局配置
│ └── app.json5
├── entry/ # 主模块(入口模块)
│ ├── src/main/ets/
│ │ ├── entryability/ # Ability 生命周期管理
│ │ │ └── EntryAbility.ets
│ │ └── pages/ # UI 页面
│ │ └── Index.ets # 首页(默认 Hello World)
│ ├── src/main/resources/ # 资源文件
│ ├── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── oh_modules/ # OHPM 依赖包
├── build-profile.json5 # 工程构建配置
├── hvigorfile.ts # Hvigor 构建脚本
└── oh-package.json5 # 包管理配置
核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
})
}
.height('100%')
.width('100%')
}
}
| 关键语法 | 作用 |
|---|---|
@Entry |
标记为页面入口,可用于路由跳转 |
@Component |
声明为自定义组件 |
@State |
状态变量,数据变更时自动触发 UI 刷新 |
RelativeContainer |
相对布局容器,替代传统线性布局 |
.onClick() |
点击事件,此处点击后文本变为 “Welcome” |
打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

二、查看 SDK 版本
2.1 查看 HarmonyOS SDK
DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:
文件 → 设置 → HarmonyOS SDK(或快捷键
Ctrl + Alt + S搜索 “HarmonyOS SDK”)
在设置面板中,可以看到当前已安装的 SDK 版本信息:
| 名称 | 阶段 | 状态 |
|---|---|---|
| HarmonyOS 6.1.1 | Release | ✅ 已安装 |
界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

2.2 查看 ArkUI-X SDK(跨平台扩展)
如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:
文件 → 设置 → 语言和框架 → ArkUI-X
在这里可以查看已安装和可选的 ArkUI-X SDK 版本:
| 版本 | SDK 版本号 | 阶段 | 状态 |
|---|---|---|---|
| API Version 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
更多推荐



所有评论(0)