原木米与静谧绿如何服务预约体验?共享自习室 ArkUI 全栈实现思路
一、技术前言

在共享经济与知识付费的双重浪潮下,共享学习空间(自习室)行业正经历从"纯线下预约"到"数字化全流程管控"的深刻转型。从春熙旗舰舱的静音区域到金融城轻午舱的独立舱位,从科华北夜读舱的夜车场到建设路撸书舱的键盘区,每一个舱区都需要匹配不同的座位管控策略、入座率监控维度和到点提醒机制。传统自习室管理应用面临三大核心挑战:门店地图交互单薄导致用户无法精准定位舱位、预约提醒铃声千篇一律导致用户体验割裂、入座率与高峰时段数据可视化缺失导致运营决策盲目。

HarmonyOS ArkUI 框架为这些挑战提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Component 封装可复用组件、@State 管理响应式状态、@Builder 拆分复杂 UI 结构,天然适合"舱位-地图-提醒-铃音"六 Tab 联动架构。@Observed 装饰器让数据模型字段级变化被 UI 感知,实现"在座数更新即视图刷新"的流畅体验。ForEach 的键值回调确保列表渲染高效且状态准确。

本系统深度融合 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供地图组件的 searchByText 关键字搜索能力——通过 SearchByTextParams 配置查询关键字、中心坐标、搜索半径和语言,返回的 site.Site[] 数组携带 reliability 相关性分数实现搜索结果分级展示;同时 MapEventManager 的 onMarkerLongClick 和 onPoiLongClick 双长按监听实现 Marker 标记与 POI 兴趣点的长按事件捕获,回调分别返回 map.Marker 和 mapCommon.Poi 对象,覆盖门店标记管理与周边兴趣点探索两种交互场景。Notification Kit 引入了 EL1 沙箱自定义铃声能力链——通过 contextConstant.AreaMode.EL1 将应用文件上下文切换至设备级加密区域,以 fileIo 将生成的 WAV 音频写入沙箱 filesDir,再通过 fileUri.getUriFromPath 将沙箱路径转换为 URI,最终以 'uri::' + uri 前缀写入 NotificationRequest.sound 字段,实现通知铃声从代码生成到沙箱落盘到通知播放的完整闭环。Canvas 绘制 实现入座率进度环和高峰时段折线图两种数据可视化——drawRing 方法通过 arc 绘制背景环和进度弧,配合 breath 状态实现弧长呼吸微缩动画;drawLine 方法通过 createLinearGradient 绘制渐变填充区域、折线主线、数据点圆环和峰值标注,构成完整的高峰时段在座曲线图谱。

二、整体架构流程图
架构以 Page1206 为根组件,使用 Stack 容器层叠:底层 Column 纵向排列头部区域、分割线、内容区和底部 Tab 栏,顶层是三个独立弹窗。内容区的核心设计在于地图 Tab(currentTab === 1)独占内容区且不进入 Scroll 容器——这是因为 MapComponent 需要有界高度才能正确布局,其余 5 个 Tab 统一进入主滚动容器并启用 EdgeEffect.Spring 弹性回弹。状态变量统一声明在组件顶层实现跨 Tab 共享,包括 Tab 切换状态、弹窗状态、呼吸动画状态、业务数据数组和 Map Kit/Notification Kit 状态。

三、色彩体系设计
3.1 ColorPalette 接口定义
interface ColorPalette {
bg: string; // 页面背景(原木米)
card: string; // 卡片底色(纯白)
chip: string; // 浅色胶囊 / 徽章底
sub: string; // 副标题(灰橄榄)
text3: string; // 三级弱文本(浅灰橄榄)
green: string; // 静谧绿(主色)
greenD: string; // 静谧绿深色
orange: string; // 原木橙(辅助暖色)
blue: string; // 信息蓝(Marker 徽标)
red: string; // 警示红(近满座 / 删除)
line: string; // 分割线 / Canvas 网格
tabOn: string; // Tab 选中色
mask: string; // 弹窗遮罩
}
ColorPalette 接口定义了 14 个语义化颜色字段,覆盖页面背景、卡片底色、文本层级、主辅色和交互状态色。与传统的十六进制颜色硬编码不同,这种接口约束确保了全文件颜色引用的类型安全和语义统一。

3.2 COLORS 常量逐色分析
const COLORS: ColorPalette = {
bg: '#F5F4EF', // 原木米:页面主背景色
card: '#FFFFFF', // 纯白卡片底色,与原木米形成微妙层次
chip: '#EAE8E0', // 浅灰橄榄胶囊底,用于徽章和状态标签
title: '#33322C', // 深灰橄榄主标题,高对比可读性
sub: '#6E6B5E', // 灰橄榄副标题,柔和层次过渡
text3: '#A3A091', // 浅灰橄榄弱文本,辅助信息不抢视觉
green: '#4E8D5B', // 静谧绿主色,进度环/按钮/Tab选中
greenD: '#3A6B45', // 静谧绿深色,渐变终点/已落盘按钮
orange: '#D98243', // 原木橙辅助暖色,距离/峰值/未授权
blue: '#4E7FD9', // 信息蓝,Marker标识/进行中状态
red: '#D95B52', // 警示红,近满座/删除/搜索失败
line: '#E4E1D6', // 浅米分割线,低对比不干扰
tabOn: '#4E8D5B', // Tab选中色与主色同色
mask: 'rgba(51,50,44,0.5)' // 半透深色遮罩
};
色彩体系以"原木米 + 静谧绿"为核心视觉语言。原木米(#F5F4EF)作为页面背景营造出温暖、安静的学习氛围,与纯白卡片(#FFFFFF)之间只有微妙的明度差异,避免了强对比带来的视觉疲劳。静谧绿(#4E8D5B)作为主色调贯穿整个应用——进度环的进度弧、按钮的背景色、Tab 的选中态、时间轴的连线、柱状图的渐变起点全部使用这一颜色,形成了高度统一的品牌识别。原木橙(#D98243)作为辅助暖色用于距离信息、峰值标注和未授权状态提示,与静谧绿形成自然的冷暖互补。值得注意的是 Tab 选中色与主色一致(tabOn = green),这是浅色系应用的常见策略——在明亮的背景上,主色绿比任何强调色都更能让用户快速定位当前 Tab。

四、Tab 元数据与辅助数据
4.1 底部导航 Tab 定义
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '🏠', label: '自习室' },
{ icon: '🗺️', label: '地图' },
{ icon: '🔍', label: '搜索' },
{ icon: '⏰', label: '提醒' },
{ icon: '🔔', label: '铃音' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口采用图标 + 标签的双字段结构,使用 emoji 图标避免了图标资源的引入。6 个 Tab 的排列顺序遵循"概览→探索→检索→管理→个性化"的用户使用路径:自习室 Tab 提供全局入座概览,地图 Tab 支持门店探索,搜索 Tab 精准检索门店,提醒 Tab 管理预约提醒,铃音 Tab 个性化通知铃声,我的 Tab 查看个人数据。
4.2 地图标注点数据
const CITY_CENTER: mapCommon.LatLng = { latitude: 30.5728, longitude: 104.0668 };
const MARKER_SPOTS: SpotItem[] = [
{ name: '春熙旗舰舱', lat: 30.6598, lng: 104.0817, tag: '旗舰' },
{ name: '金融城轻午舱', lat: 30.5731, lng: 104.0633, tag: '商务' },
{ name: '桐梓林阅读舱', lat: 30.6110, lng: 104.0730, tag: '静音' },
{ name: '科华北夜读舱', lat: 30.6240, lng: 104.0980, tag: '夜车' },
{ name: '天府三街午休舱', lat: 30.5410, lng: 104.0620, tag: '午休' },
{ name: '建设路撸书舱', lat: 30.6760, lng: 104.1110, tag: '校园' }
];
城市中心点定位于成都天府广场附近,作为地图初始视野中心和 POI 搜索基准。6 家门店的命名融合了地理位置(春熙路、金融城、桐梓林等)和功能特征(旗舰舱、轻午舱、阅读舱等),tag 字段标记每家门店的核心场景标签,为后续的门店筛选和推荐提供数据基础。
4.3 Canvas 图表数据
const RING_RATE: number = 0.68;
const PEAK_LABELS: string[] = ['08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19'];
const PEAK_VALUES: number[] = [18, 35, 58, 76, 64, 42, 48, 70, 88, 95, 82, 56];
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const MONTH_HOURS: number[] = [46, 58, 63, 71, 66, 82];
RING_RATE 定义全城今日入座率为 68%(215/314 席),驱动进度环绘制。PEAK_VALUES 记录 08:00 至 19:00 共 12 个整点的在座人数,呈现明显的双峰特征——上午 11 点(76 人)和下午 17 点(95 人)为两个高峰,中午 13 点(42 人)为午休低谷。MONTH_HOURS 记录近 6 个月入座时长,呈现稳步增长趋势,从 3 月的 46 小时增长到 8 月的 82 小时,反映用户使用粘性持续提升。
4.4 预约记录常量数据
interface BookingRow {
date: string;
room: string;
zone: string;
dur: string;
status: string;
}
const BOOKING_ROWS: BookingRow[] = [
{ date: '今天', room: '春熙旗舰舱', zone: '静音区 A08', dur: '6.0h', status: '进行中' },
{ date: '08-28', room: '春熙旗舰舱', zone: '静音区 A12', dur: '3.5h', status: '已完成' },
// ... 其余 4 行
];
BookingRow 定义了预约记录清单行结构,包含日期、门店、舱位区域、时长和状态五项信息。状态字段覆盖"进行中"“已完成”"已取消"三种业务状态,为 statusColor 函数提供映射依据。
五、工具函数
5.1 搜索相关性分级函数
function reliabilityScore(score: number): ScoreLevel {
if (score >= 0.8) {
return { label: '高相关', color: COLORS.green };
}
if (score >= 0.5) {
return { label: '中相关', color: COLORS.orange };
}
return { label: '低相关', color: COLORS.text3 };
}
reliabilityScore 将 searchByText 返回的 reliability 分数(0~1)映射为三档等级标签和对应颜色。≥0.8 为高相关(静谧绿),≥0.5 为中相关(原木橙),其余为低相关(浅灰橄榄)。这种三档分级策略让用户无需理解分数含义即可快速判断搜索结果质量。
5.2 长按事件类型颜色函数
function typeColor(type: string): string {
if (type === 'Marker') {
return COLORS.blue;
}
if (type === 'POI') {
return COLORS.orange;
}
return COLORS.text3;
}
typeColor 将长按事件类型映射为徽标底色——Marker 长按返回信息蓝,POI 长按返回原木橙,其他返回浅灰橄榄。蓝橙对比让用户在日志流中一眼区分两种事件的来源。
5.3 预约状态颜色函数
function statusColor(status: string): string {
if (status === '已完成') {
return COLORS.green;
}
if (status === '进行中') {
return COLORS.blue;
}
return COLORS.text3;
}
statusColor 将预约状态映射为状态色——已完成(绿)、进行中(蓝)、已取消(灰),用于预约记录清单行的状态色条和状态徽章。
5.4 在座率提示色函数
function occColor(rate: number): string {
if (rate >= 0.85) {
return COLORS.red;
}
if (rate >= 0.5) {
return COLORS.orange;
}
return COLORS.green;
}
occColor 将在座率映射为提示色——≥85% 近满座(警示红),≥50% 热门(原木橙),<50% 余座充足(静谧绿)。这个函数直接驱动舱位卡片上 occupied/seats 徽章的颜色,让用户在双列卡片列表中快速识别舱位的紧张程度。
5.5 时间戳生成函数
function nowTime(): string {
const d = new Date();
const p = (n: number) => n.toString().padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
nowTime 生成当前时刻的 HH:mm:ss 格式时间戳,用于长按事件日志和通知历史记录的时间标记。padStart(2, '0') 确保时、分、秒均为两位数。
5.6 WAV 音频字节生成函数
function buildWavBytes(freq: number, durationMs: number): ArrayBuffer {
const sampleRate = 44100;
const numSamples = Math.floor(sampleRate * durationMs / 1000);
const dataSize = numSamples * 2;
const buf = new ArrayBuffer(44 + dataSize);
const view = new DataView(buf);
// ... 44 字节 WAV 头写入 + PCM 数据生成
return buf;
}
buildWavBytes 是铃声生成的核心函数,通过 ArrayBuffer 和 DataView 手动构造标准 WAV 文件。44 字节头部包含 RIFF/WAVE 格式标识、PCM 编码标记、单声道、44100Hz 采样率、16bit 量化等标准参数。数据段通过正弦波生成音频样本,配合起音包络(前 20ms 渐入)和自然衰减包络模拟真实铃声的声学特性。这个函数使得整个铃声生成链路完全在端侧完成,不依赖任何音频文件资源。
六、数据模型层
6.1 StudyRoomItem — 自习室舱位模型
@Observed export class StudyRoomItem {
name: string; // 门店名
zone: string; // 区域
price: number; // 时价(元/小时)
seats: number; // 总座位
occupied: number; // 在座数
constructor(name: string, zone: string, price: number,
seats: number, occupied: number) { ... }
}
StudyRoomItem 是业务主 Tab 的核心数据模型,使用 @Observed 装饰器确保字段级变化被 UI 感知。5 个字段覆盖了舱位展示所需的全部信息:门店名用于标题、区域用于标签分类、时价用于价格展示、总座位和在座数通过 occupied/seats 比值驱动 occColor 函数生成在座率提示色。ROOM_LIST 常量初始化了 8 个舱区数据,覆盖春熙旗舰舱、金融城轻午舱等 6 家门店的不同区域。
6.2 SearchRecord — POI 搜索结果模型
@Observed export class SearchRecord {
name: string; // 地点名称
address: string; // 格式化地址
distance: number; // 直线距离(米)
reliability: number; // 相关性分数 [0,1]
constructor(name: string, address: string,
distance: number, reliability: number) { ... }
}
SearchRecord 封装 POI 搜索结果,reliability 字段是 HarmonyOS 6.1.1 Map Kit 的关键字段,表示搜索结果与查询关键字的相关性。SEARCH_MOCK 常量初始化了 6 条 Mock 数据,reliability 分数从 0.93 到 0.28 覆盖高/中/低三档,确保搜索结果列表的三档分级展示效果在无网络环境下也可演示。
6.3 EventLog — 长按事件日志模型
@Observed export class EventLog {
type: string; // Marker / POI
name: string; // 标记 id 或 POI 名称
lat: number; // 纬度
lng: number; // 经度
time: string; // 触发时刻
constructor(type: string, name: string,
lat: number, lng: number, time: string) { ... }
}
EventLog 记录地图长按事件的完整信息,type 字段区分 Marker 长按和 POI 长按两种事件来源。事件日志采用 unshift 置顶策略——新事件插入数组头部,超过 12 条时 pop 尾部,形成固定容量的"最新在上"日志流。EVENT_SEED 预置了 2 条演示数据让初始界面不空白。
6.4 RemindItem — 预约提醒模型
@Observed export class RemindItem {
time: string; // 提醒时刻
title: string; // 提醒标题
repeat: string; // 重复规则
on: boolean; // 开关状态
constructor(time: string, title: string,
repeat: string, on: boolean) { ... }
}
RemindItem 定义预约提醒的结构,repeat 字段支持"工作日"“每天”"周末"三种重复规则。on 布尔字段绑定 Toggle 组件实现提醒开关的实时切换,@Observed 确保 on 状态变化时时间轴上的圆点和文本颜色同步更新。REMIND_LIST 初始化了 5 条提醒数据,覆盖早鸟场、午间舱、晚高峰、闭馆前和夜车场五个场景。
6.5 RingItem — 铃声库模型
@Observed export class RingItem {
name: string; // 铃声名
file: string; // 沙箱文件名
freq: number; // 生成频率 Hz
duration: number; // 时长 ms
size: string; // 文件大小展示
inSandbox: boolean; // 是否已写入沙箱
constructor(name: string, file: string, freq: number,
duration: number, size: string, inSandbox: boolean) { ... }
}
RingItem 定义铃声库条目,freq 和 duration 驱动 buildWavBytes 生成音频数据,inSandbox 标记铃声是否已写入 EL1 沙箱,size 在生成后回填。RING_LIST 初始化了 5 首铃声——从 440Hz 低频"闭馆风铃"到 990Hz 高频"开舱叮咚",频率覆盖约两个八度。
6.6 NoticeLog — 通知历史模型
@Observed export class NoticeLog {
title: string; // 通知标题
text: string; // 通知正文
time: string; // 发布时刻
constructor(title: string, text: string, time: string) { ... }
}
NoticeLog 记录通知发布历史,与 EventLog 类似采用 unshift 置顶策略,容量限制 8 条。NOTICE_SEED 预置了预约成功、时长提醒和闭馆提醒三条种子数据。
七、组件主体结构
7.1 状态变量声明
@Entry
@Component
struct Page1206 {
// --- Tab 状态 ---
@State currentTab: number = 0;
// --- 弹窗状态 ---
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = -1;
@State delIdx: number = -1;
// --- 弹窗表单缓存 ---
@State formName: string = '';
@State formZone: string = '';
@State formPrice: string = '';
@State formSeats: string = '';
// --- 动画状态 ---
@State breath: boolean = false;
timer: number = -1;
// --- 业务数据数组 ---
@State roomList: StudyRoomItem[] = ROOM_LIST;
@State remindList: RemindItem[] = REMIND_LIST;
@State ringList: RingItem[] = RING_LIST;
@State noticeLogs: NoticeLog[] = NOTICE_SEED;
// ...
}
组件的状态变量分为六大类。Tab 状态仅有 currentTab,驱动 6 个 Tab 之间的切换。弹窗状态包括三个布尔开关(addModal/editModal/delModal)和两个索引(editIdx/delIdx),控制弹窗的显示与目标行定位。表单缓存包括 formName/formZone/formPrice/formSeats 四项,作为新增和编辑弹窗的共享输入缓冲。动画状态 breath 是一个每秒翻转的布尔值,驱动 Canvas 重绘和柱状图高度微缩。业务数据数组直接引用常量初始化,因为 @Observed 类的实例变化会被自动追踪。Map Kit 和 Notification 状态包含地图控制器、事件管理器、搜索关键字、搜索结果、通知授权状态和当前铃声等。
7.2 生命周期
aboutToAppear() {
this.setupMapCallback();
notificationManager.isNotificationEnabled().then((enabled: boolean) => {
this.granted = enabled;
}).catch((err: BusinessError) => {
console.error(`isNotificationEnabled failed: ${err.message}`);
});
this.timer = setInterval(() => {
this.breath = !this.breath;
this.drawRing();
this.drawLine();
}, 1000);
}
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear 完成三项初始化:装配地图回调函数、查询通知授权状态、启动呼吸动画定时器。呼吸动画通过 setInterval 每秒翻转 breath 布尔值并触发 drawRing 和 drawLine 重绘 Canvas,实现进度环弧长微缩和折线数据点微动的动态效果。aboutToDisappear 清除定时器避免内存泄漏。
7.3 build() 根构建
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
if (this.currentTab === 1) {
this.tabMap()
} else {
Scroll() {
Column({ space: 12 }) {
if (this.currentTab === 0) {
this.tabStudy()
} else if (this.currentTab === 2) {
this.tabSearch()
} else if (this.currentTab === 3) {
this.tabRemind()
} else if (this.currentTab === 4) {
this.tabRing()
} else {
this.tabMine()
}
}.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 16 })
}.layoutWeight(1).width('100%')
.scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
}
this.tabBar()
}.width('100%').height('100%')
// 弹窗系统
if (this.addModal) { this.panelAdd(() => { this.addModal = false; }) }
if (this.editModal) { this.panelEdit(() => { this.editModal = false; }) }
if (this.delModal) { this.panelDel(() => { this.delModal = false; }) }
}.width('100%').height('100%').backgroundColor(COLORS.bg)
}
根构建采用 Stack 层叠布局,底层 Column 纵向排列头部、分割线、内容区和 Tab 栏。关键设计在于地图 Tab 独立于 Scroll 容器——因为 MapComponent 需要有界高度(layoutWeight(1))才能正确渲染,若放入 Scroll 内会因高度不确定而无法显示。其余 5 个 Tab 统一进入 Scroll 容器,启用 EdgeEffect.Spring 弹性回弹和 BarState.Off 隐藏滚动条。三个弹窗通过条件渲染叠加在 Stack 顶层,各弹窗通过回调函数控制自身的关闭。
八、头部区域详解
@Builder
headerMain() {
Column({ space: 10 }) {
Row() {
Column({ space: 4 }) {
Text('静学空间 · 共享自习室预约').fontSize(19).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text(this.currentTab === 0 ? '舱位实况 · 今日入座率 68%'
: this.currentTab === 1 ? '门店地图 · 长按标记试试'
: this.currentTab === 2 ? '门店搜索 · reliability 评分'
: this.currentTab === 3 ? '预约提醒 · 沙箱铃声通知'
: this.currentTab === 4 ? '铃声坊 · EL1 沙箱音频'
: '我的自习 · 会员时长').fontSize(11).fontColor(COLORS.sub)
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
// 呼吸圆点
Circle({ width: 10, height: 10 }).fill(COLORS.green)
.opacity(this.breath ? 1 : 0.4)
}.width('100%')
// 三特性状态胶囊
Row({ space: 8 }) {
Row({ space: 4 }) {
Circle({ width: 6, height: 6 })
.fill(this.markerListenOn ? COLORS.green : COLORS.orange)
Text('Marker 长按').fontSize(9).fontColor(COLORS.sub)
}.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.chip)
// ... POI 长按胶囊、通知授权胶囊、Canvas 双图胶囊
}.width('100%')
}.padding({ left: 16, right: 16, top: 12, bottom: 12 }).width('100%')
.linearGradient({ angle: 160, colors: [[COLORS.chip, 0], [COLORS.bg, 1]] })
}
头部区域包含三层结构。第一层是应用名和 Tab 联动副标题——应用名固定为"静学空间 · 共享自习室预约",副标题根据 currentTab 切换为 6 种不同的功能描述文案,让用户在 Tab 切换时立即感知当前功能模块的定位。第二层是呼吸圆点,一个 10x10 的静谧绿圆点通过 opacity 在 1 和 0.4 之间每秒翻转,提示数据正在实时刷新。第三层是三特性状态胶囊行——四个胶囊分别显示 Marker 长按监听状态、POI 长按监听状态、通知授权状态和 Canvas 双图标识,每个胶囊内含一个 6x6 状态圆点和文案。状态圆点颜色根据开关状态在静谧绿和原木橙之间切换,让用户一眼掌握三大特性的运行状态。头部整体使用 160° 线性渐变从 chip 到 bg,营造从浅灰到原木米的自然过渡。
九、Tab0 自习室详解
自习室 Tab 是应用的主功能页面,包含入座率进度环卡、高峰折线卡和舱位双列卡片三个区域。
9.1 入座率进度环卡
@Builder
ringCard() {
Column({ space: 8 }) {
Row() {
Text('今日入座率').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title).layoutWeight(1)
Text('全城 8 舱区 · 实时').fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 14 }) {
Canvas(this.ringCtx).width(168).height(168)
.onReady(() => { this.drawRing(); })
Column({ space: 8 }) {
Text('在座 215 人').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('空余 99 席').fontSize(12).fontColor(COLORS.sub)
Text('晚高峰 17~19 点最紧张,建议提前 1 小时锁舱...')
.fontSize(10).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
}.width('100%').alignItems(VerticalAlign.Center)
}.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
进度环卡采用左 Canvas 右文字的双列布局。Canvas 组件宽高均为 168px,在 onReady 回调中调用 drawRing 首绘。右侧文字列展示在座 215 人、空余 99 席和运营建议文案,与进度环的 68% 形成数据呼应。
drawRing 方法通过 Canvas 2D API 绘制三层内容。首先是背景环——以画布中心为圆心、宽度的 34% 为半径绘制完整圆环,使用 chip 色和圆角线帽。然后是进度弧——从 12 点方向(-Math.PI/2)起画,弧长为 Math.PI * 2 * rate * wave,其中 wave 随 breath 状态在 1 和 0.88 之间切换实现呼吸微缩。最后是中心文字——大字显示百分比、副标签显示"今日入座率"。
9.2 高峰时段折线卡
@Builder
peakCard() {
Column({ space: 8 }) {
Row() {
Text('高峰时段在座曲线').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title).layoutWeight(1)
Text('08:00 ~ 19:00').fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Canvas(this.lineCtx).width('100%').height(180)
.onReady(() => { this.drawLine(); })
}.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
drawLine 方法是整个应用中最复杂的 Canvas 绘制,包含五个层次。第一层是背景网格——绘制 4 条横线作为纵轴刻度参考。第二层是渐变填充区域——使用 createLinearGradient 从静谧绿到近透明绿绘制折线下方的填充。第三层是折线主线——以静谧绿、2px 宽度连接 12 个数据点。第四层是数据点——白心绿边圆点标注每个整点的在座人数。第五层是峰值标注——在 17 点(95 人)位置以原木橙标注"峰值 95 人",并在横轴偶数索引位置标注时间标签。
9.3 舱位双列卡片
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.roomList, (room: StudyRoomItem, idx: number) => {
Column({ space: 8 }) {
Row() {
Text(room.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${room.occupied}/${room.seats}`).fontSize(9)
.fontColor(occColor(room.occupied / room.seats))
.padding({ left: 7, right: 7, top: 3, bottom: 3 })
.borderRadius(8).backgroundColor(COLORS.chip)
}.width('100%')
Text(room.zone).fontSize(10).fontColor(COLORS.sub)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(6).backgroundColor(COLORS.chip)
.alignSelf(ItemAlign.Start)
Row({ space: 8 }) {
Text(`¥${room.price}/时`).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.green).layoutWeight(1)
Text('改').fontSize(9).fontColor(COLORS.blue)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(COLORS.chip)
.onClick(() => { this.openEdit(idx); })
Text('删').fontSize(9).fontColor(COLORS.red)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(COLORS.chip)
.onClick(() => { this.openDel(idx); })
}.width('100%')
Button('预约舱位').height(30).fontSize(11).borderRadius(8)
.fontColor(COLORS.card).backgroundColor(COLORS.green).width('100%')
.onClick(() => { this.openAdd(room.name); })
}.width('49%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
.margin({ bottom: 10 })
}, (room: StudyRoomItem, idx: number) => room.name + room.zone + idx.toString())
}.width('100%')
舱位卡片采用 Flex 换行布局实现双列排列,每个卡片宽度 49% 并通过 SpaceBetween 对齐实现中间间距。每张卡片包含四层信息:顶部行展示门店名和在座数徽章(颜色由 occColor 驱动),中部展示区域标签,下部展示时价、改和删操作按钮,底部是全宽"预约舱位"按钮。ForEach 的键值回调使用 room.name + room.zone + idx 确保列表项唯一标识,避免增删操作时的渲染错乱。
十、Tab1 地图详解
地图 Tab 是应用中最复杂的交互页面,包含双长按监听开关、MapComponent 本体和长按事件日志流三个区域。
10.1 双长按监听开关
Row({ space: 10 }) {
Row({ space: 6 }) {
Toggle({ type: ToggleType.Switch, isOn: this.markerListenOn })
.width(36).height(20).selectedColor(COLORS.green)
.onChange(() => { this.toggleMarkerListen(); })
Text('Marker 长按').fontSize(11)
.fontColor(this.markerListenOn ? COLORS.title : COLORS.text3)
}.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.backgroundColor(COLORS.card).borderRadius(10).layoutWeight(1)
// ... POI 长按 Toggle 同构
}.width('100%')
两个 Toggle 开关分别控制 Marker 和 POI 的长按监听。toggleMarkerListen 方法在开/关之间切换——关闭时调用 offMarkerLongClick()(不传参表示清除该类型全部订阅),开启时重新注册 onMarkerLongClick 回调。Toggle 的 selectedColor 使用静谧绿与主题一致,文本颜色根据开关状态在 title 和 text3 之间切换,关闭时文本变灰提供视觉反馈。
10.2 MapComponent 初始化与 Marker 装配
setupMapCallback() {
this.mapCallback = async (err: BusinessError, mapController: map.MapComponentController) => {
if (err) {
console.error(`Map init failed, code: ${err.code}, message: ${err.message}`);
return;
}
this.mapController = mapController;
this.mapEventManager = mapController.getEventManager();
// 批量添加门店 Marker
for (const spot of MARKER_SPOTS) {
const markerOptions: mapCommon.MarkerOptions = {
position: { latitude: spot.lat, longitude: spot.lng },
clickable: true, visible: true, rotation: 0,
zIndex: 0, alpha: 1, anchorU: 0.5, anchorV: 1,
draggable: false, flat: false
};
try {
await this.mapController.addMarker(markerOptions);
} catch (e) {
console.error(`addMarker failed: ${(e as BusinessError).message}`);
}
}
// Marker 长按监听
this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
this.eventLogs.unshift(new EventLog('Marker',
`#${marker.getId()} 门店标记`, marker.getPosition().latitude,
marker.getPosition().longitude, nowTime()));
if (this.eventLogs.length > 12) { this.eventLogs.pop(); }
});
// POI 长按监听
this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
this.eventLogs.unshift(new EventLog('POI', poi.name,
poi.position.latitude, poi.position.longitude, nowTime()));
if (this.eventLogs.length > 12) { this.eventLogs.pop(); }
});
};
}
setupMapCallback 装配地图初始化的完整回调链。回调被触发时首先保存 mapController,通过 getEventManager 获取事件管理器。然后遍历 MARKER_SPOTS 逐个 addMarker——每个 Marker 的 anchorU: 0.5, anchorV: 1 确保锚点在图标底部中心,使标注点精确定位在经纬度坐标上。Marker 添加完成后注册两个长按监听——onMarkerLongClick 回调参数为 map.Marker 对象,可获取 getId() 和 getPosition();onPoiLongClick 回调参数为 mapCommon.Poi 对象,仅包含 id、name 和 position 三项。两种长按事件均通过 unshift 置顶到日志流并限制 12 条容量。
10.3 长按事件日志流
Column({ space: 6 }) {
Row() {
Text('长按事件日志').fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title).layoutWeight(1)
Text(`${this.eventLogs.length} 条`).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
List({ space: 6 }) {
ForEach(this.eventLogs, (log: EventLog) => {
ListItem() {
Row({ space: 8 }) {
Text(log.type).fontSize(9).fontColor(COLORS.card)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4).backgroundColor(typeColor(log.type))
Text(log.name).fontSize(11).fontColor(COLORS.title)
.layoutWeight(1).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${log.lat.toFixed(4)}, ${log.lng.toFixed(4)}`)
.fontSize(9).fontColor(COLORS.text3)
Text(log.time).fontSize(9).fontColor(COLORS.text3)
}.width('100%')
}
}, (log: EventLog) => log.type + log.name + log.time)
}.width('100%').height(96).scrollBar(BarState.Off)
}.width('100%').padding(10).backgroundColor(COLORS.card).borderRadius(12)
日志流使用 List 容器固定高度 96px 并隐藏滚动条。每行展示四项信息:类型徽标(Marker 蓝或 POI 橙底色白字)、事件名称(单行省略)、经纬度坐标(四位小数)和触发时间。typeColor 函数驱动的类型徽标颜色让用户在快速滚动时也能区分事件来源。
10.4 关键字搜索
async runSearch() {
this.searchState = '搜索中…';
const params: site.SearchByTextParams = {
query: this.queryInput,
location: CITY_CENTER,
radius: 5000,
language: 'zh'
};
try {
const result: site.SearchByTextResult = await site.searchByText(params);
const sites: site.Site[] = result.sites ?? [];
if (sites.length === 0) {
this.searchState = '无结果,已保留当前推荐';
return;
}
this.searchRecords = sites.map((s: site.Site) => new SearchRecord(
s.name ?? '未命名地点', s.formatAddress ?? '暂无地址',
s.distance ?? 0, s.reliability ?? 0));
this.searchState = `返回 ${sites.length} 条结果`;
} catch (e) {
const err = e as BusinessError;
this.searchState = `搜索失败(${err.code}),保留当前推荐`;
}
}
runSearch 调用 Map Kit 的 searchByText 接口,参数包含查询关键字、中心坐标(天府广场)、5 公里搜索半径和中文语言。返回的 sites 数组通过 map 转换为 SearchRecord 实例,其中 reliability 字段使用 ?? 0 进行空值兜底。搜索状态文案在"搜索中"“返回 N 条结果”"无结果"和"搜索失败"之间切换,失败时保留 Mock 数据并提示错误码,确保用户界面始终有内容可看。
十一、Tab2 搜索详解
搜索 Tab 将 searchByText 的搜索能力可视化呈现,包含搜索框、状态文案和结果列表三个区域。
11.1 搜索框与状态文案
Row({ space: 8 }) {
TextInput({ text: this.queryInput, placeholder: '输入关键字,如:自习室' })
.layoutWeight(1).height(40).fontSize(12)
.fontColor(COLORS.title).placeholderColor(COLORS.text3)
.backgroundColor(COLORS.card).borderRadius(10)
.onChange((value: string) => { this.queryInput = value; })
Button('搜索').height(40).fontSize(12).borderRadius(10)
.fontColor(COLORS.card).backgroundColor(COLORS.green)
.onClick(() => { this.runSearch(); })
}.width('100%')
Row({ space: 6 }) {
Circle({ width: 6, height: 6 })
.fill(this.searchState.startsWith('搜索失败') ? COLORS.red : COLORS.green)
Text(this.searchState).fontSize(11).fontColor(COLORS.sub)
}.width('100%')
搜索框采用 TextInput + Button 的横排布局,输入框默认值"自习室"与 Map Kit 的 query 参数直接绑定。状态文案行的圆点颜色根据搜索结果动态切换——搜索失败时为警示红,其余状态为静谧绿。
11.2 reliability 分数条结果列表
ForEach(this.searchRecords, (rec: SearchRecord) => {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(reliabilityScore(rec.reliability).label).fontSize(10)
.fontColor(reliabilityScore(rec.reliability).color)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8).backgroundColor(COLORS.chip)
}.width('100%')
Text(rec.address).fontSize(11).fontColor(COLORS.sub).width('100%')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(`${rec.distance} m`).fontSize(10).fontColor(COLORS.orange)
Progress({ value: rec.reliability * 100, total: 100, type: ProgressType.Linear })
.layoutWeight(1).height(5).color(COLORS.green)
Text(`reliability ${rec.reliability.toFixed(2)}`).fontSize(10)
.fontColor(COLORS.text3)
}.width('100%')
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}, (rec: SearchRecord) => rec.name)
每条搜索结果包含三层信息。第一层是地点名称和等级标签——reliabilityScore 函数返回的标签和颜色直接驱动标签的文案和文字色。第二层是格式化地址,单行省略。第三层是距离、分数条和 reliability 数值——距离以原木橙显示,Progress 线性进度条将 reliability(0~1)映射为 0~100 的进度值,以静谧绿填充,右侧以浅灰橄榄显示精确数值。这种"标签 + 进度条 + 数值"的三重展示让不同阅读习惯的用户都能快速理解搜索结果质量。
十二、Tab3 提醒详解
提醒 Tab 整合了通知授权管理和预约提醒两个核心功能,包含通知授权卡、提醒时间轴、发布按钮和通知历史流四个区域。
12.1 通知授权状态卡
Column({ space: 10 }) {
Row({ space: 10 }) {
Circle({ width: 10, height: 10 })
.fill(this.granted ? COLORS.green : COLORS.orange)
Column({ space: 2 }) {
Text('通知授权状态').fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text(this.granted ? '已授权:预约提醒可携带沙箱自定义铃声送达'
: '未授权:点击右侧按钮申请,拒绝过会跳转系统通知设置页')
.fontSize(10).fontColor(COLORS.sub)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Button(this.granted ? '已授权' : '去授权')
.height(30).fontSize(11).borderRadius(8)
.fontColor(this.granted ? COLORS.green : COLORS.card)
.backgroundColor(this.granted ? COLORS.chip : COLORS.green)
.onClick(() => { this.requestAuth(); })
}.width('100%')
}.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
授权卡采用左圆点、中文字、右按钮的三列布局。状态圆点颜色随 granted 在静谧绿和原木橙之间切换。文案动态切换为已授权或未授权的详细说明。按钮文案和配色也随授权状态变化——未授权时为"去授权"(绿底白字),已授权时为"已授权"(灰底绿字)。
12.2 通知授权请求
requestAuth() {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return;
}
notificationManager.requestEnableNotification(hostCtx).then(() => {
this.granted = true;
}).catch((err: BusinessError) => {
console.error(`requestEnableNotification failed: ${err.code}`);
notificationManager.openNotificationSettings(hostCtx).then(() => {
}).catch((e: BusinessError) => {
console.error(`openNotificationSettings failed: ${e.message}`);
this.granted = false;
});
});
}
requestAuth 实现通知授权的两步策略。首先调用 requestEnableNotification 弹出系统授权弹窗——如果用户首次授权,granted 置为 true。如果用户曾经拒绝(错误码 1600004),则进入 catch 分支调用 openNotificationSettings 拉起系统通知设置页,引导用户进行二次授权。这种"首次弹窗 + 拒绝后跳设置页"的策略是 HarmonyOS 通知授权的最佳实践。
12.3 预约提醒时间轴
ForEach(this.remindList, (item: RemindItem, idx: number) => {
Row() {
// 时间列
Column({ space: 4 }) {
Text(item.time).fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(item.on ? COLORS.green : COLORS.text3)
Text(item.repeat).fontSize(9).fontColor(COLORS.text3)
}.width(52).alignItems(HorizontalAlign.Start)
// 竖线列(末行隐藏)
Column() {
Circle({ width: 8, height: 8 })
.fill(item.on ? COLORS.green : COLORS.text3)
Column().width(2).layoutWeight(1)
.backgroundColor(idx === this.remindList.length - 1 ? COLORS.card : COLORS.line)
}.width(16).alignItems(HorizontalAlign.Center).height('100%')
// 提醒卡片
Column({ space: 6 }) {
Row() {
Text(item.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.on ? '已开启' : '已暂停').fontSize(9)
.fontColor(item.on ? COLORS.green : COLORS.text3)
}.width('100%')
Row() {
Text('到点通过系统通知送达').fontSize(9).fontColor(COLORS.text3)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: item.on })
.width(34).height(19).selectedColor(COLORS.green)
.onChange((isOn: boolean) => { item.on = isOn; })
}.width('100%')
}.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center)
.padding(10).backgroundColor(COLORS.card).borderRadius(10)
}.width('100%').height(72).margin({ bottom: 6 })
}, (item: RemindItem) => item.time + item.title)
时间轴采用三列布局——左侧时间列(52px 宽)、中间竖线列(16px 宽)和右侧提醒卡片。竖线列由一个 8x8 圆点和一根 layoutWeight(1) 填充的竖线组成,末行竖线背景色设为 card(与卡片同色)实现"隐形"效果。提醒卡片内含标题行和操作行——标题行展示提醒标题和开关状态文案,操作行展示送达方式说明和 Toggle 开关。Toggle 的 onChange 直接将 isOn 赋值给 item.on,由于 RemindItem 是 @Observed 类,状态变化会自动触发时间轴圆点和文本颜色的同步更新。
12.4 发布通知与通知历史
Button('发布预约提醒(携带沙箱铃声)')
.width('100%').height(42).fontSize(13).borderRadius(10)
.fontColor(COLORS.card).backgroundColor(COLORS.green)
.onClick(() => {
this.publishNotice('预约提醒',
'您预约的春熙旗舰舱·静音区 A08 将于 15 分钟后开场,请及时到舱刷卡入座。');
})
发布按钮触发 publishNotice 方法,该方法构造 NotificationRequest 并通过 notificationManager.publish 发送系统通知。通知的 sound 字段携带 EL1 沙箱自定义铃声的 'uri::' 前缀 URI。通知发布成功后将日志 unshift 到通知历史流(限制 8 条),失败时记录错误码到日志标题。
十三、Tab4 铃音详解
铃音 Tab 实现了从代码生成铃声到 EL1 沙箱落盘到设为通知铃声的完整链路,包含当前铃声卡和铃声库列表两个区域。
13.1 当前默认铃声卡
Column({ space: 10 }) {
Row() {
Text('当前默认铃声').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text(this.currentRing.inSandbox ? '已落盘 EL1' : '未落盘').fontSize(10)
.fontColor(this.currentRing.inSandbox ? COLORS.green : COLORS.orange)
}.width('100%')
Text(this.currentRing.name).fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title).width('100%')
Text(`${this.currentRing.file} · ${this.currentRing.freq}Hz · ${this.currentRing.duration}ms`)
.fontSize(10).fontColor(COLORS.text3).width('100%')
}.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
当前铃声卡展示三项信息:落盘状态标签(颜色随 inSandbox 在绿橙之间切换)、铃声名称(大字粗体)和文件参数(文件名、频率、时长)。
13.2 EL1 沙箱铃声落盘
saveRingToSandbox(fileName: string, freq: number, durationMs: number): string {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) { return ''; }
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1; // 通知铃声必须位于 EL1 区域
const path = appCtx.filesDir + '/' + fileName;
try {
const data = buildWavBytes(freq, durationMs);
const file = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, data);
fs.closeSync(file);
} catch (e) {
console.error(`saveRingToSandbox failed: ${(e as BusinessError).message}`);
return '';
}
return path;
}
saveRingToSandbox 是铃声落盘的核心方法。首先通过 getHostContext 获取 UIAbilityContext,再通过 getApplicationContext 获取应用上下文。关键一步是将 appCtx.area 设为 contextConstant.AreaMode.EL1——HarmonyOS 的文件加密区域分为 EL0(应用级)到 EL4(设备级),通知铃声必须位于 EL1 设备级加密区域才能在锁屏状态下被系统通知服务读取。然后调用 buildWavBytes 生成 WAV 字节数据,通过 fileIo 的 openSync(CREATE | WRITE_ONLY | TRUNC 模式)、writeSync 和 closeSync 三步写入文件。返回沙箱绝对路径供后续 URI 转换使用。
13.3 铃声生成与设为默认
genRing(item: RingItem) {
const path = this.saveRingToSandbox(item.file, item.freq, item.duration);
if (path !== '') {
item.inSandbox = true;
const bytes = 44 + Math.floor(44100 * item.duration / 1000) * 2;
item.size = (bytes / 1024).toFixed(1) + ' KB';
}
}
setDefaultRing(item: RingItem) {
this.currentRing = item;
}
genRing 调用 saveRingToSandbox 落盘后回填 inSandbox 状态和文件大小(44 字节头 + 采样数据计算)。setDefaultRing 将选中的铃声设为 currentRing,后续通知发布时将使用该铃声的沙箱文件作为 sound 字段。
13.4 铃声库列表
ForEach(this.ringList, (item: RingItem) => {
Column({ space: 8 }) {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(item.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${item.freq}Hz · ${item.duration}ms · ${item.size}`)
.fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
if (item === this.currentRing) {
Text('默认').fontSize(9).fontColor(COLORS.green)
.padding({ left: 7, right: 7, top: 3, bottom: 3 })
.borderRadius(8).backgroundColor(COLORS.chip)
}
Text(item.inSandbox ? '沙箱' : '未生成').fontSize(9)
.fontColor(item.inSandbox ? COLORS.green : COLORS.text3)
.padding({ left: 7, right: 7, top: 3, bottom: 3 })
.borderRadius(8).backgroundColor(COLORS.chip)
}.width('100%')
Row({ space: 8 }) {
Button('生成到沙箱').height(30).fontSize(11).borderRadius(8)
.fontColor(COLORS.card)
.backgroundColor(item.inSandbox ? COLORS.greenD : COLORS.green)
.layoutWeight(1)
.onClick(() => { this.genRing(item); })
Button('设为默认').height(30).fontSize(11).borderRadius(8)
.fontColor(COLORS.green).backgroundColor(COLORS.chip)
.layoutWeight(1)
.onClick(() => { this.setDefaultRing(item); })
}.width('100%')
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}, (item: RingItem) => item.file)
铃声库列表展示 5 首铃声。每张卡片顶部行展示铃声名称、参数信息、默认标签(仅当前默认铃声显示)和落盘状态标签。底部行提供两个操作按钮——"生成到沙箱"按钮背景色在已落盘时为深绿(greenD)、未落盘时为静谧绿(green),"设为默认"按钮始终为灰底绿字。
十四、Tab5 我的详解
我的 Tab 展示用户个人信息和学习数据,包含会员时长渐变大卡、月度柱状图和预约记录清单三个区域。
14.1 会员时长渐变大卡
Column({ space: 10 }) {
Row() {
Column({ space: 4 }) {
Text('静读会员 · 年卡').fontSize(11).fontColor(COLORS.card).opacity(0.9)
Text('静学空间').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.card)
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('VIP').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.greenD)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(8).backgroundColor(COLORS.card)
}.width('100%')
Text('剩余 168 小时 · 有效期至 2026-12-31').fontSize(11)
.fontColor(COLORS.card).opacity(0.92).width('100%')
Row({ space: 16 }) {
Column({ space: 2 }) {
Text('本月入座').fontSize(9).fontColor(COLORS.card).opacity(0.85)
Text('82h').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.card)
}.alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text('累计舱次').fontSize(9).fontColor(COLORS.card).opacity(0.85)
Text('57 次').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.card)
}.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Button('续费时长').height(30).fontSize(11).borderRadius(15)
.fontColor(COLORS.greenD).backgroundColor(COLORS.card)
.onClick(() => {
this.publishNotice('续费成功', '年卡已续 60 小时...');
})
}.width('100%').alignItems(VerticalAlign.Center)
}.width('100%').padding(16).borderRadius(14)
.linearGradient({ angle: 135, colors: [[COLORS.green, 0], [COLORS.greenD, 1]] })
会员大卡是全应用唯一的深色背景区域——使用 135° 线性渐变从静谧绿到静谧绿深色,与浅色页面形成强烈的视觉焦点。卡片内容分三层:顶部行展示会员类型和应用名,右侧 VIP 标签以白底深绿字反向显示。中部展示剩余时长和有效期。底部行展示本月入座时长(82h)和累计舱次(57 次)两个统计指标,右侧"续费时长"按钮以白底深绿字呈现,点击后发布续费成功通知。
14.2 近 6 个月入座时长柱状图
@Builder
chartCard() {
Column({ space: 10 }) {
Row() {
Text('近 6 个月入座时长').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title).layoutWeight(1)
Text('单位:小时').fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 12 }) {
ForEach(MONTH_NAME, (m: string, idx: number) => {
Column({ space: 4 }) {
Column()
.width(18)
.height(Math.max(14, MONTH_HOURS[idx] * (this.breath ? 1 : 0.92)))
.borderRadius({ topLeft: 4, topRight: 4 })
.linearGradient({ angle: 180, colors: [[COLORS.green, 0], [COLORS.greenD, 1]] })
Text(`${MONTH_HOURS[idx]}`).fontSize(8).fontColor(COLORS.sub)
Text(m).fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1).justifyContent(FlexAlign.End)
}, (m: string) => m)
}.width('100%').height(120).alignItems(VerticalAlign.Bottom)
}.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
柱状图采用 Column 组件而非 Canvas 实现——每根柱子是一个 Column 组件,高度由 MONTH_HOURS[idx] 驱动,配合 breath 状态实现 8% 的高度微缩呼吸效果。柱子使用 180° 垂直渐变从静谧绿到静谧绿深色,顶部圆角。每根柱子下方显示数值和月份标签。整个柱状图区域高度 120px,alignItems(VerticalAlign.Bottom) 确保所有柱子底部对齐。这种用组件而非 Canvas 实现柱状图的方式更轻量,且天然支持 breath 联动。
14.3 预约记录清单行
ForEach(BOOKING_ROWS, (row: BookingRow) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text(row.date).fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.sub)
Text(row.dur).fontSize(9).fontColor(COLORS.text3)
}.width(46).alignItems(HorizontalAlign.Start)
Column().width(3).height(30).borderRadius(2)
.backgroundColor(statusColor(row.status))
Column({ space: 2 }) {
Text(row.room).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(row.zone).fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(row.status).fontSize(10).fontColor(statusColor(row.status))
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8).backgroundColor(COLORS.chip)
}.width('100%').padding(10).backgroundColor(COLORS.card).borderRadius(10)
}, (row: BookingRow) => row.date + row.zone)
每行预约记录采用四列布局——左侧日期+时长列(46px 宽)、3px 宽的状态色条、中间门店+舱位列(layoutWeight(1) 填充)和右侧状态徽章。状态色条使用 statusColor 函数返回的颜色,与右侧状态徽章文字色一致,形成"色条+标签"的双重状态视觉锚点。
十五、底部 Tab 栏
@Builder
tabBar() {
Row() {
ForEach(TAB_LIST, (tab: TabMeta, index: number) => {
Column({ space: 3 }) {
Text(tab.icon).fontSize(17)
Text(tab.label).fontSize(9)
.fontColor(this.currentTab === index ? COLORS.tabOn : COLORS.text3)
}.justifyContent(FlexAlign.Center).layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.onClick(() => {
this.currentTab = index;
})
}, (tab: TabMeta) => tab.label)
}.width('100%').backgroundColor(COLORS.card)
}
底部 Tab 栏采用自绘的 Row + ForEach 布局,6 个 Tab 等宽分布(layoutWeight(1))。每个 Tab 是一个纵向排列的 emoji 图标(17px)和文字标签(9px),选中态文字色为 tabOn(静谧绿),非选中态为 text3(浅灰橄榄)。点击直接将 currentTab 设为当前索引,由于 currentTab 是 @State 变量,切换会立即触发内容区和头部副标题的同步更新。
十六、弹窗系统
16.1 遮罩层
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%').backgroundColor(COLORS.mask)
.onClick(() => {
onClose();
})
}
modalOverlay 是三个弹窗共享的遮罩层,全屏半透明深色背景(rgba(51,50,44,0.5)),点击空白区域触发 onClose 回调关闭弹窗。
16.2 新增静学空间弹窗
@Builder
panelAdd(onClose: () => void) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('新增静学空间').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title).width('100%')
Text('创建后置顶到舱位列表,在座数从 0 开始计').fontSize(10).fontColor(COLORS.text3).width('100%')
// 四项 TextInput:门店名 / 区域 / 时价 / 总座位
TextInput({ text: this.formName, placeholder: '门店名,如:万象城阅读舱' }) ...
TextInput({ text: this.formZone, placeholder: '区域,如:静音区 / 键盘区 / 独立舱' }) ...
TextInput({ text: this.formPrice, placeholder: '时价(元/小时),如:6' }).type(InputType.Number) ...
TextInput({ text: this.formSeats, placeholder: '总座位数,如:40' }).type(InputType.Number) ...
Row({ space: 10 }) {
Button('取消').onClick(() => { onClose(); }) ...
Button('创建').onClick(() => { this.confirmAdd(); }) ...
}.width('100%')
}.width('86%').padding(18).borderRadius(14).backgroundColor(COLORS.card)
}.width('100%').height('100%')
}
新增弹窗包含标题、说明文案和四项 TextInput 表单——门店名(文本)、区域(文本)、时价(数字)、总座位(数字)。时价和座位使用 InputType.Number 确保仅接受数字输入。底部"取消"和"创建"按钮等宽分布。
confirmAdd 方法对表单输入进行容错处理——空门店名默认为"未命名静学空间",空区域默认为"静音区",非法时价默认为 5 元,非法座位默认为 30 个,在座数始终从 0 开始。通过 unshift 将新舱位置顶到 roomList。
16.3 编辑静学空间弹窗
编辑弹窗与新增弹窗结构一致,但预填当前舱位信息。openEdit 方法将目标行的数据复制到表单缓存变量。saveEdit 方法采用"空输入保留原值"策略——只有非空字符串才会覆盖原值,数值字段还需通过 Number.isNaN 和正数校验。这种策略允许用户仅修改部分字段而保留其余不变。
16.4 删除确认弹窗
@Builder
panelDel(onClose: () => void) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('删除静学空间').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title).width('100%')
Text(`确认删除「${...}」?删除后该舱区将从实时概览中移除,不可恢复。`)
.fontSize(11).fontColor(COLORS.sub).width('100%')
Row({ space: 10 }) {
Button('取消').onClick(() => { onClose(); }) ...
Button('删除').fontColor(COLORS.card).backgroundColor(COLORS.red)
.onClick(() => { this.confirmDel(); }) ...
}.width('100%')
}.width('86%').padding(18).borderRadius(14).backgroundColor(COLORS.card)
}.width('100%').height('100%')
}
删除弹窗是最简的确认弹窗——展示目标舱位名称和不可恢复警告,"删除"按钮使用警示红背景以提示操作的破坏性。confirmDel 通过 splice 从 roomList 中移除目标行。
十七、功能模块对比表
| 功能模块 | 所在 Tab | 核心技术 | 数据模型 | 交互特色 | 颜色特征 |
|---|---|---|---|---|---|
| 自习室概览 | Tab0 | Canvas 进度环 + 折线图 | StudyRoomItem | breath 联动重绘、双列卡片 | 静谧绿进度弧、原木橙峰值标注 |
| 门店地图 | Tab1 | Map Kit MapComponent + 双长按监听 | EventLog | Marker/POI 长按开关、日志流置顶 | 信息蓝 Marker、原木橙 POI |
| 门店搜索 | Tab2 | Map Kit searchByText + reliability | SearchRecord | 关键字搜索、三档分级、分数条 | 三档色(绿/橙/灰)分级 |
| 预约提醒 | Tab3 | Notification Kit + Toggle 状态管理 | RemindItem, NoticeLog | 授权状态卡、时间轴、通知历史 | 绿/橙授权状态、绿时间轴线 |
| 铃声坊 | Tab4 | Notification Kit EL1 沙箱 + WAV 生成 | RingItem | 代码生成铃声、沙箱落盘、设默认 | 绿/深绿落盘状态 |
| 我的中心 | Tab5 | 渐变大卡 + 组件柱状图 | BookingRow | 会员时长、月度统计、预约记录 | 绿渐变大卡、状态色条 |
深化解析:从代码结构到业务闭环
布局方式与数据流
共享自习室页面同时呈现舱位供给、预约操作、地图定位、学习提醒与个人统计。舱位卡和图表用于快速判断资源,地图与搜索帮助找到门店,提醒和铃声保证预约按时履约。代码解读应围绕房间模型、预约状态和地图事件展开,说明双列卡片、进度环、折线图、时间轴等布局为什么适合相应信息密度。
页面根结构通常由头部、内容区和底部 Tab 栏组成。头部负责展示当前业务状态,内容区根据索引选择不同的 @Builder,底部导航负责修改索引。这样的结构把“当前显示什么”收敛为一个明确状态:用户点击 Tab 后先更新索引,ArkUI 再重新计算相关分支。各个 Builder 虽然共享主题色和页面级数据,却可以采用完全不同的布局方式;高密度列表适合纵向 Scroll,概览数据适合横向统计卡或双列 Flex,实时预览类组件需要独占有界高度,历史事件则适合时间轴或固定行高 List。
数据模型层承担界面与业务之间的契约。使用 @Observed 的实体保存可编辑字段,页面级 @State 数组负责驱动 ForEach。新增时创建新实体并插入数组,编辑时修改目标实体,删除时移除对应项。为了让列表差分稳定,key 应来自不会改变的唯一标识,不宜使用标题等可编辑字段。统计数字、完成比例和分类数量属于派生信息,可以从数组即时计算,避免同时维护两份状态后出现卡片已经更新、图表仍显示旧值的情况。
弹窗表单使用独立缓存是必要的。打开新增弹窗时清空缓存,打开编辑弹窗时复制目标字段,用户确认后才写回正式模型。这样点击取消不会污染列表数据。若直接把 TextInput 双向绑定到列表实体,用户尚未保存时卡片就可能跟着变化,破坏“确认提交”的交互语义。删除弹窗还需要保存目标索引或唯一标识,并在确认时再次校验目标存在,避免列表变化后误删其他项。
核心代码与状态驱动机制
@State 的价值不是简单替代普通变量,而是建立状态与界面之间的依赖关系。当前 Tab、筛选条件、动画开关、弹窗显隐、下载进度或能力状态发生变化时,只有读取这些变量的组件需要刷新。代码段中连续的修饰器调用分别控制尺寸、间距、背景、字体和事件,它们共同构成声明式描述;阅读时应从容器方向、子项分布、状态绑定和交互回调四个层面理解,而不是逐个孤立翻译属性名称。
ForEach 负责把数组映射为重复 UI。回调中的 item 提供业务字段,index 适合显示顺序,但不适合作为长期身份。列表发生新增或删除时,稳定 key 可以让框架复用未变化节点,减少重建。若直接修改对象属性后界面没有按预期刷新,可在保持实体身份的前提下替换数组引用;但不应为了刷新把所有元素都重新构造,否则会增加无意义渲染并丢失局部状态。
条件渲染体现了页面状态机。空闲时展示引导,准备中展示进度,成功时展示结果,失败时展示原因和重试入口。相比一个布尔值,四态文案更能覆盖异步能力。系统接口调用前先检查权限、设备支持和会话状态,调用后再读取结果校验。异常处理除了记录错误码,还要把可理解的反馈写入响应式状态,让用户知道失败发生在哪一步。
动画效果与颜色使用策略
呼吸动画通常由定时器周期翻转 breath,再把该状态映射为透明度、柱高或圆点半径的小幅变化。它适合表达“正在运行”或让统计图保持生命感,但幅度应克制,不能改变核心数据含义。柱状图的基础高度仍由真实数值计算,动画只能在很小范围内偏移;进度环的角度仍由完成比例决定,不能为了视觉效果显示超过真实进度的结果。页面离开时必须清理定时器,避免后台继续刷新。
颜色常量应按语义使用。主色承担选中态和主要操作,辅助色突出数据或次级动作,绿色表达完成与可用,橙色表达进行中或需要注意,红色只用于失败、逾期和删除等高风险场景。弱文本与分割线降低视觉权重,遮罩色用于聚焦弹窗。颜色不能成为唯一的状态信息,还要配合文字、图标或进度值,保证色觉差异用户也能理解。
渐变更适合头部大卡、核心指标或柱状图,不宜在每个小元素上重复使用。深色主题要检查正文与卡片背景的对比度,浅色主题则要避免辅助文字过淡。选中和未选中 Tab 除颜色差异外,还可以通过字重、图标透明度或底部指示器区分。这样既保持主题统一,又能建立清晰的信息层级。
各 Tab 之间的交互联动
各 Tab 不应只共享一个导航索引,还应围绕业务对象建立必要联动。列表页新增或编辑数据后,头部计数、图表和个人统计要同步更新;网页或地图产生的结果应写入记录模型,供下载、日志或我的页面继续展示;通知、字幕、相机等系统能力的状态应在头部胶囊或对应 Tab 中保持一致。跨 Tab 跳转时先更新必要参数,再修改当前索引,可以避免目标页面读取到旧条件。
切换离开重型组件时需要处理资源边界。相机输入、地图监听、字幕控制器、Web 下载代理和定时器都不能只创建不释放。可以在统一的 switchTab 方法中判断来源与目标,离开能力页时解除监听或停止会话;页面销毁时再执行兜底释放。释放方法应允许重复调用,并对每个资源独立判空,确保一次异常不会阻止后续清理。
交互反馈要覆盖成功与失败。按钮点击后先进入处理中状态并防止重复提交;成功后更新模型、关闭弹窗并显示结果;失败后保留用户输入,展示错误原因和重试入口。权限拒绝、能力不支持、网络失败、文件不存在和输入非法都属于正常业务分支。通过状态卡或行内提示展示这些分支,比只在控制台打印更符合完整产品体验。
边界场景与验证思路
空列表时应显示占位说明和新增入口,不能只留下空白。长标题需要限制行数并使用省略号,数字字段需要限定上下界,文本提交前要去除首尾空格。筛选后无结果应保留清除条件的入口。删除最后一项后,当前选择索引要回退到有效范围。异步搜索连续触发时,应防止较早请求晚返回后覆盖新结果。
验证数据链路时,可以依次检查新增、编辑、删除和筛选:新增后列表条数、统计数字和图表是否同时变化;编辑取消后正式数据是否保持不变;删除后 ForEach key 是否稳定;切换 Tab 再返回时必要数据是否仍在。验证系统能力时分别模拟支持、拒绝和异常,确认界面都有明确状态。验证动画时检查页面离开后是否停止,低性能设备上是否仍保持流畅。
视觉验收需要检查不同屏幕宽度、系统字体放大、深浅背景对比和长文本换行。表格中的布局方式、模型、字段数、核心操作、动画、状态颜色、数据量和特殊组件应与正文一致。Mermaid 图则需要对应真实的数据流和能力链路,节点文字加引号以避免中文或特殊字符导致解析失败。
组件化设计的进一步理解
参数化 Builder 适合抽取重复的统计格、状态行、标签和按钮组。参数只传入渲染所需数据和事件,不让子构建器直接依赖过多页面变量,可以降低耦合。业务复杂后,可把模型与系统能力封装为独立控制器,页面只负责组合 UI 和响应状态。这样既保留声明式代码的直观性,也能让权限、错误码翻译和资源释放得到集中管理。
当前单页面集中展示完整源码,便于博文逐段讲解。若演进为正式项目,可以按领域拆分组件:导航和页面框架位于容器层,列表、图表和弹窗位于展示层,数据读写和 Kit 接入位于服务层。组件之间通过参数、回调、@Link 或 @ObjectLink 传递状态,不使用全局变量代替清晰的数据流。
性能优化首先来自减少不必要刷新。派生数据不要重复存储,动画状态不要进入列表 key,长列表使用稳定标识,Canvas 只在数据或尺寸变化时重绘。其次是控制资源生命周期,页面不可见时停止高成本任务。最后才是微调阴影、渐变和绘制细节。这样的优先级能保证页面在功能增加后仍然可维护。
通过以上补充,可以看到 ArkUI 的声明式模式并非只让布局语法更简洁,它更重要的价值是把数据变化、界面刷新和交互反馈连接为可追踪链路。理解每个代码段读取什么状态、写入什么状态、影响哪些组件,才能真正掌握文章中多个 Tab、图表、弹窗和系统能力协同工作的原理。
十八、总结与展望
本文深度解析了"静学空间·共享自习室预约"应用的完整源码架构。从原木米与静谧绿的色彩体系设计,到六大 Tab 的差异化布局策略,再到 Map Kit、Notification Kit 和 Canvas 三大特性的深度融合,整个应用展现了 HarmonyOS ArkUI 在共享学习空间行业的全栈实践能力。
色彩设计层面,应用采用"原木米 + 静谧绿 + 原木橙"的三色体系,原木米营造温暖安静的学习氛围,静谧绿作为主色贯穿进度环、按钮、Tab 选中态和渐变大卡,原木橙作为辅助暖色用于距离、峰值和警示状态。14 个语义化颜色字段通过 ColorPalette 接口约束,确保全文件颜色引用的类型安全和语义统一。
数据架构层面,6 个 @Observed 数据模型覆盖了舱位管理、搜索结果、事件日志、预约提醒、铃声库和通知历史全业务域。@Observed 装饰器确保字段级变化被 UI 感知,ForEach 的键值回调保证列表渲染高效准确。工具函数将状态映射逻辑(在座率→颜色、reliability→等级、状态→颜色)集中管理,避免了 UI 中的条件判断冗余。
技术特性层面,三大 HarmonyOS 6.1.1 特性形成完整的"探索→提醒→个性化"链路。Map Kit 的 searchByText 实现 5 公里半径内的门店搜索,reliability 字段驱动三档分级展示,onMarkerLongClick 和 onPoiLongClick 双长按监听覆盖门店标记和周边兴趣点两种交互。Notification Kit 的 EL1 沙箱铃声链路从 buildWavBytes 生成 WAV 字节,到 contextConstant.AreaMode.EL1 切换加密区域,到 fileIo 写入沙箱,到 fileUri.getUriFromPath 转换 URI,到 'uri::' 前缀写入 sound 字段,形成端侧铃声生成的完整闭环。Canvas 绘制通过 drawRing 和 drawLine 两个方法实现进度环和折线图两种数据可视化,配合 1 秒呼吸定时器实现弧长微缩和数据点微动的动态效果。
未来展望方面,应用可在以下方向持续深化。第一,引入实时座位感知能力——通过 IoT 传感器或门禁系统对接,将 StudyRoomItem.occupied 从 Mock 数据升级为实时数据流,让进度环和折线图展示真实入座状态。第二,扩展 Map Kit 的 routePlanner 路径规划能力——用户选中门店后自动规划从当前位置到门店的最优路线,结合 onPoiLongClick 的 POI 信息实现"搜索-导航-预约"的一站式体验。第三,深化 Notification Kit 的通知渠道管理——利用 notificationSlotType 的 SOCIAL_COMMUNICATION 类型实现高优先级预约提醒,配合 SlotType 的精细化配置区分"开场前提醒"“时长不足”"闭馆提醒"等不同通知场景。第四,引入 AI 学习时长分析——基于 MONTH_HOURS 的历史数据趋势,通过端侧 AI 模型预测用户下周最佳学习时段,主动推荐预约提醒。第五,探索 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)