一、引言:自驾租车行业的数字化变革

在这里插入图片描述

在中国出行市场中,自驾租车正在经历一场深刻的数字化变革。过去十年里,租车行业从线下门店柜台办理、纸质合同签署、人工核验证件的传统模式,逐步走向全流程线上化、自助化、智能化的新阶段。用户通过手机 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)作为辅助色,整体呈现深色质感风格,既符合出行类应用的专业感,又通过暖色调点缀提升了视觉活力。

四、整体架构总览

在深入代码细节之前,先通过一张架构流程图理解应用的整体结构:

scanning=true

scanning=false

0

1

2

3

4

5

6

addModal

editModal

delModal

Page1006 主组件

Stack 根容器

scanning 状态判断

scanView - Vision Kit 卡证识别

Column 主布局

headerDrive 头部

Divider 分隔线

Scroll 可滚动内容区

currentTab 切换

tabHome 首页

tabPickup 取车

tabFleet 车场

tabOrder 订单

tabSite 网点

tabWallet 钱包

tabGuide 攻略

chartCard 月度图表

tabBar 底部导航

弹窗状态判断

panelAdd 新增弹窗

panelEdit 编辑弹窗

panelDel 删除弹窗

从架构图可以看出,应用采用 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 是翠绿,用于成功状态和积极信息。mainDsecD 分别是主色和辅助色的深色变体,用于渐变背景的起始色。这种"深底+暖色点缀"的配色方案在出行类应用中非常常见,能够在深色环境下保持良好的可读性,同时通过暖色调传递活力。

使用接口+常量的集中式颜色管理方式有三大优势:一是修改主题色只需改一处;二是避免了颜色值散落在代码各处导致的维护困难;三是语义化命名让代码可读性大幅提升,看到 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 接口只包含 iconlabel 两个字段,使用 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 个可见。EntryMetaTabMeta 结构相同但语义不同,分别建模避免了概念混淆。

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_NAMERENT_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;
  }
}

DocItemisNew 字段用于标识新增证件类型(港澳通行证和台湾通行证标记为 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 = '';

新增弹窗中的两个输入框绑定到 addTitleaddNote,用户输入时通过 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 装饰,后续可以通过 pushsplice、索引替换等操作动态修改订单列表,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 数组响应式的典型应用。

十五、卡证识别流程

下面通过流程图展示从用户点击证件到识别完成的完整流程:

用户点击证件类型

设置 scanIdx = i

设置 scanning = true

build 重新渲染

scanning && scanIdx >= 0?

显示 scanView - CardRecognition

显示主界面

用户拍摄证件

Vision Kit 端侧识别

code === 200?

scanning = false, 返回主界面

提取 cardInfo front/back/main

拼接 JSON 字符串

new ScanRecord 并 push 到 scanRecords

scanning = false

取车页显示新记录

这个流程展示了 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)
  }

票券下段展示取还车时间段,右侧有"改期"(紫色)和"删除"(红色)两个操作按钮。点击"改期"设置 editIdxeditModal,点击"删除"设置 delIdxdelModal,分别触发对应的弹窗。整个票券卡片用统一的卡片背景色和圆角包裹,上下两段通过虚线分隔,形成了类似电影票/优惠券的视觉风格。

十八、网点 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 的柱状图,无需引入任何图表库。核心技巧是用 Columnheight 属性模拟柱子高度。高度计算公式为 28 + RENT_VAL[i] / 6 * 76 + (this.breath ? 3 : 0):基础高度 28 像素,加上数据值占最大值(6)的比例乘以 76 像素的动态高度,再叠加呼吸动画的 3 像素微抖动。

柱子颜色根据数据值决定:值为 6(峰值)的柱子用翠绿色 sec,其他用深橙色 mainD。柱子只有顶部圆角(topLefttopRight),模拟真实柱状图的视觉。下方是月份标签。

      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 切换流程

0

1

2

3

4

5

6

用户点击底部 Tab

currentTab = idx

build 重新渲染

currentTab 值判断

tabHome 首页

tabPickup 取车

tabFleet 车场

tabOrder 订单

tabSite 网点

tabWallet 钱包

tabGuide 攻略

chartCard 图表卡片

Scroll 滚动到顶部

tabBar 高亮当前 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 输入框,分别绑定 addTitleaddNote 状态。输入框使用灰色背景配圆角,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 组件使用,类似 TextColumn 等基础组件,但它是一个系统级的复合控件,内部集成了相机调用、取景框渲染、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_MOCardType.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 单向数据流

用户交互

修改 @State 变量

build 重新执行

条件渲染更新

UI 自动刷新

用户看到新界面

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 个语义化颜色字段覆盖了应用所有视觉角色。这种做法的好处在于:修改任何颜色只需改一处,避免了散落各处的硬编码颜色值;语义命名(mainsectitlesub)让代码自文档化;接口约束确保了颜色字段的完整性,遗漏任何字段都会在编译时报错。

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 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 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 版本编写,不同版本界面可能存在细微差异。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐