一间自习室如何联动地图、通知与 Canvas?ArkUI 三引擎实践
一、技术前言
在共享经济与终身学习浪潮交汇的当下,共享自习室正从"一张桌子一把椅"的粗放模式向"舱位级精细运营"演进。从春熙旗舰舱的静音沉浸到科华北夜读舱的通宵陪伴,从金融城轻午舱的商务午休到桐梓林阅读舱的深度专注,每一种学习场景都需要匹配不同的座位类型、价格策略和高峰管理机制。传统自习室预约应用面临三大痛点:门店位置仅靠文字描述导致用户找路困难、预约提醒使用系统默认铃声导致辨识度低且无法个性化、入座率数据仅以数字呈现缺乏直观的可视化反馈。

HarmonyOS ArkUI 框架为共享学习空间领域提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Component 封装可复用组件、@State 管理响应式状态、@Builder 拆分复杂 UI 结构,天然适合"舱位-地图-提醒"三层架构。@Observed 装饰器让数据模型字段级变化被 UI 感知,实现"座位更新即视图刷新"的流畅体验。@Entry 标记入口组件,aboutToAppear/aboutToDisappear 生命周期回调为地图初始化、通知授权查询和呼吸动画定时器提供了精确的启停控制点。

本平台深度融合 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供了 searchByText 关键字搜索能力链——通过 query、location、radius、language 四个参数实现基于城市中心的 5 公里范围内自习室 POI 检索,返回结果中的 reliability 相关性分数被映射为高/中/低三档等级标签和线性分数条;同时 onMarkerLongClick 和 onPoiLongClick 双长按监听让用户长按地图标记即可将事件写入日志流。Notification Kit 实现了 EL1 沙箱自定义铃声通知——通过 buildWavBytes 函数生成正弦波 PCM 音频字节,写入 EL1 区域的 filesDir 目录,再通过 fileUri.getUriFromPath 转换为 URI 并以 'uri::' 前缀写入通知请求的 sound 字段,实现"铃声坊→沙箱落盘→通知携带"的完整链路。Canvas 绘制 实现了入座率进度环(drawRing 方法绘制背景环+进度弧+中心百分比)和高峰时段折线图(drawLine 方法绘制网格+渐变填充+折线+数据点+峰值标注),两者均与 breath 呼吸状态联动,每秒触发弧长微缩与柱高波动,模拟实时数据刷新的临场感。

二、整体架构流程图
架构以 Page1211 为根组件,使用 Stack 容器层叠。底层 Column 纵向排列头部区域、分割线、内容区和底部 Tab 栏,顶层是三个独立弹窗(panelAdd/panelEdit/panelDel 各自条件渲染)。内容区通过 currentTab 在 6 个 Builder 方法间切换,地图 Tab 因 MapComponent 需有界高度而独占内容区不进 Scroll 容器,其余 5 个 Tab 共享主滚动容器。四大特性分散在自习室(Canvas 双图)、地图(Map Kit 双长按监听)、搜索(searchByText reliability)和铃音(EL1 沙箱铃声)四个 Tab 上,状态变量统一声明在组件顶层实现跨 Tab 共享。呼吸动画定时器每秒翻转 breath 状态并重绘两张 Canvas 图,同时联动"我的"Tab 的柱状图柱高波动,形成全局呼吸节律。

三、色彩体系设计
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 接口定义了全文件统一的颜色面板契约。每个字段都有明确的语义职责:bg 承载页面底色、card 用于白色卡片容器、chip 提供浅色胶囊底色用于徽章和状态标签。这种接口约束确保了整个应用在任何 UI 组件中引用颜色时都从统一常量取值,避免硬编码十六进制色值散落各处。

3.2 COLORS 常量逐色分析
const COLORS: ColorPalette = {
bg: '#F5F4EF', // 原木米:温暖低饱和背景,模拟原木桌面
card: '#FFFFFF', // 纯白卡片:与米色背景形成微对比
chip: '#EAE8E0', // 浅原木灰:胶囊/徽章底色
title: '#33322C', // 深橄榄黑:主标题文字
sub: '#6E6B5E', // 灰橄榄:副标题文字
text3: '#A3A091', // 浅灰橄榄:三级弱化文本
green: '#4E8D5B', // 静谧绿:主色,按钮/进度/选中态
greenD: '#3A6B45', // 静谧绿深色:渐变终点/已生成态
orange: '#D98243', // 原木橙:辅助暖色,距离/热门/峰值标注
blue: '#4E7FD9', // 信息蓝:Marker 徽标/进行中状态
red: '#D95B52', // 警示红:近满座/删除操作
line: '#E4E1D6', // 原木分割线:低对比不干扰
tabOn: '#4E8D5B', // Tab 选中色与主色绿一致
mask: 'rgba(51,50,44,0.5)' // 半透橄榄黑遮罩
};
色彩体系以"原木米 + 静谧绿"为核心基调。原木米 #F5F4EF 作为页面背景,模拟自习室原木桌面的温暖质感,比纯白背景更护眼,长时间阅读不易疲劳。静谧绿 #4E8D5B 作为主色,用于按钮、进度环、折线、柱状图和 Tab 选中态,这种偏灰调的绿传递"专注、沉静、自然"的学习氛围。原木橙 #D98243 作为辅助暖色出现在距离标注、热门提示和峰值数据标注上,与绿色形成冷暖对比但不喧宾夺主。

值得注意的是 tabOn 使用 green(静谧绿)而非独立色值,这让底部 Tab 选中态与全局主色保持一致,用户在任意页面都能通过绿色 Tab 定位当前位置。弹窗遮罩使用 rgba(51,50,44,0.5)——半透明的深橄榄黑,既保证遮罩下方的 UI 可见,又不会像纯黑遮罩那样过于生硬。

四、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 图标加一个中文标签。六个 Tab 按用户使用频率从左到右排列:自习室是核心高频功能放首位,地图和搜索作为发现入口紧随其后,提醒和铃音是设置类功能居中,我的作为个人中心殿后。ForEach 渲染时使用 tab.label 作为键值,保证 Tab 切换时 DOM 复用高效。

4.2 地图标注与 Canvas 数据
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: '校园' }
];
CITY_CENTER 定义了成都天府广场附近的经纬度坐标,作为地图初始视野中心和 POI 搜索基准点。MARKER_SPOTS 包含成都 6 家研习座门店,每家都有独特的 tag 标签——旗舰、商务、静音、夜车、午休、校园——这些标签反映了不同门店的场景定位,在 setupMapCallback 中批量添加为地图 Marker。
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];
RING_RATE 常量 0.68 表示全城 8 舱区今日入座率 68%(215/314 席),这个值直接驱动 Canvas 进度环的弧长。PEAK_LABELS 和 PEAK_VALUES 是高峰时段在座人数的 12 个数据点——从早 8 点的 18 人逐步攀升到下午 5 点的峰值 95 人,再回落到晚 7 点的 56 人,这条曲线清晰刻画了自习室的"早上升温、午后高峰、晚间回落"三段式客流规律。
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const MONTH_HOURS: number[] = [46, 58, 63, 71, 66, 82];
近 6 个月的入座时长数据从 3 月的 46 小时稳步增长到 8 月的 82 小时,呈现持续上升的会员活跃度趋势。这组数据驱动"我的"Tab 的柱状图渲染。
4.3 预约记录与快捷入口
const BOOKING_ROWS: BookingRow[] = [
{ date: '今天', room: '春熙旗舰舱', zone: '静音区 A08', dur: '6.0h', status: '进行中' },
{ date: '08-28', room: '春熙旗舰舱', zone: '静音区 A12', dur: '3.5h', status: '已完成' },
{ date: '08-27', room: '金融城轻午舱', zone: '独立舱 C03', dur: '2.0h', status: '已完成' },
{ date: '08-26', room: '科华北夜读舱', zone: '夜车区 N21', dur: '4.5h', status: '已完成' },
{ date: '08-25', room: '桐梓林阅读舱', zone: '静音区 B07', dur: '1.5h', status: '已取消' },
{ date: '08-24', room: '建设路撸书舱', zone: '键盘区 K15', dur: '5.0h', status: '已完成' }
];
BOOKING_ROWS 是"我的"Tab 底部的预约记录清单,6 条数据覆盖了三种状态:进行中(今天)、已完成(4 条)和已取消(1 条)。每行包含日期、门店名、舱区编号、时长和状态五个字段,状态字段通过 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 函数将 Map Kit searchByText 返回的 [0,1] 区间相关性分数映射为三档:≥0.8 为高相关(静谧绿)、≥0.5 为中相关(原木橙)、其余为低相关(浅灰橄榄)。这种三档分级让用户无需阅读具体数值就能快速判断搜索结果的质量。在搜索 Tab 中,该函数的返回值同时用于等级标签的文本和颜色,以及线性分数条的填充色。
5.2 事件类型与状态映射函数
function typeColor(type: string): string {
if (type === 'Marker') { return COLORS.blue; }
if (type === 'POI') { return COLORS.orange; }
return COLORS.text3;
}
function statusColor(status: string): string {
if (status === '已完成') { return COLORS.green; }
if (status === '进行中') { return COLORS.blue; }
return COLORS.text3;
}
function occColor(rate: number): string {
if (rate >= 0.85) { return COLORS.red; }
if (rate >= 0.5) { return COLORS.orange; }
return COLORS.green;
}
三个映射函数分别服务于不同业务场景。typeColor 将地图长按事件类型(Marker/POI)映射为蓝/橙两色徽标,让用户在日志流中一眼区分事件来源。statusColor 将预约状态映射为绿/蓝/灰,用于预约记录清单的左侧色条和右侧徽章。occColor 将在座率映射为三档:≥0.85 近满座(警示红)、≥0.5 热门(原木橙)、其余余座充足(静谧绿),用于舱位卡片上的 occupied/seats 徽章配色。
5.3 时间戳与 WAV 音频生成函数
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') 保证时分秒都是两位数,视觉整齐。
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 头写入 ...
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const env = Math.min(1, i / (sampleRate * 0.02)); // 起音包络
const decay = Math.max(0, 1 - t / (durationMs / 1000)); // 自然衰减
const v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay;
view.setInt16(44 + i * 2, Math.round(v * 32767), true);
}
return buf;
}
buildWavBytes 是铃声坊的核心函数,生成标准 WAV 音频字节流。44 字节的 WAV 头包含 RIFF 标识、文件大小、WAVE 格式、PCM 编码、单声道、44100Hz 采样率、16bit 量化等标准字段。音频数据部分使用正弦波公式 sin(2πft) 生成基础波形,叠加了两个包络:env 是 20ms 的起音包络(从 0 渐升到 1,避免爆音),decay 是全程线性衰减包络(模拟铃声自然消失)。这个函数让用户无需外部音频文件,即可通过参数(频率+时长)实时合成个性化通知铃声。
六、数据模型层
6.1 StudyRoomItem 舱区条目
@Observed export class StudyRoomItem {
name: string; // 门店名
zone: string; // 区域(静音区 / 键盘区 / 独立舱…)
price: number; // 时价(元/小时)
seats: number; // 总座位
occupied: number; // 在座数
}
StudyRoomItem 是业务主 Tab 的核心数据模型,使用 @Observed 装饰器确保字段级变化被 UI 感知。当用户通过编辑弹窗修改门店名或区域时,舱位卡片会立即刷新。occupied 除以 seats 即为在座率,通过 occColor 函数映射为徽章颜色。8 条 Mock 数据覆盖了不同门店和区域类型,从春熙旗舰舱的静音区(38/48,热门橙色)到天府三街午休舱的午休区(9/30,充足绿色),呈现丰富的在座率梯度。
6.2 SearchRecord 搜索结果
@Observed export class SearchRecord {
name: string; // 地点名称
address: string; // 格式化地址
distance: number; // 直线距离(米)
reliability: number; // 相关性分数 [0,1]
}
SearchRecord 封装了 Map Kit searchByText 返回的 POI 数据。6 条 Mock 数据的 reliability 从 0.93 递减到 0.28,完整覆盖高/中/低三档,让搜索 Tab 的分数条呈现从满到空的视觉梯度。distance 字段以米为单位,距离越近的原木橙标注越醒目。
6.3 EventLog 长按事件日志
@Observed export class EventLog {
type: string; // Marker / POI
name: string; // 标记 id 或 POI 名称
lat: number; // 纬度
lng: number; // 经度
time: string; // 触发时刻
}
EventLog 记录地图双长按监听的每一次触发。unshift 置顶新事件保证最新日志始终在最上方,pop 限制最多 12 条避免列表无限增长。两条种子数据(Marker 和 POI 各一条)让用户首次进入地图 Tab 就能看到日志流的格式样例。
6.4 RemindItem 与 RingItem
@Observed export class RemindItem {
time: string; // 提醒时刻
title: string; // 提醒标题
repeat: string; // 重复规则
on: boolean; // 开关状态
}
@Observed export class RingItem {
name: string; // 铃声名
file: string; // 沙箱文件名
freq: number; // 生成频率 Hz
duration: number; // 时长 ms
size: string; // 文件大小展示
inSandbox: boolean; // 是否已写入沙箱
}
RemindItem 的 on 字段绑定时间轴卡片中的 Toggle 开关,切换状态后文字颜色立即从静谧绿变为浅灰橄榄。5 条提醒数据覆盖了工作日和周末两种重复规则。RingItem 的 inSandbox 字段是铃声坊的核心状态——初始为 false(未生成),用户点击"生成到沙箱"后变为 true(已落盘),同时 size 字段从占位符 '—' 更新为实际 KB 大小。5 条铃声数据的频率从 440Hz 到 990Hz,覆盖了从低沉到清脆的不同音色。
6.5 NoticeLog 通知历史
@Observed export class NoticeLog {
title: string; // 通知标题
text: string; // 通知正文
time: string; // 发布时刻
}
NoticeLog 记录通知发布历史,每次调用 publishNotice 成功后 unshift 置顶一条新日志,最多保留 8 条。3 条种子数据展示了预约成功、时长提醒和闭馆提醒三种典型通知场景。
七、组件主体结构
7.1 状态变量声明
@Entry
@Component
struct Page1211 {
@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 个 Builder 间切换。第二组是三个弹窗开关和两个索引变量,editIdx/delIdx 记录当前操作的舱位索引。第三组是四个表单缓存变量,用于新增和编辑弹窗的双向数据绑定。第四组是动画状态 breath 和定时器 timer,驱动全局呼吸节律。第五组是四个业务数据数组,均使用 @Observed 类实例初始化。
// --- Map Kit 状态 ---
private mapOptions: mapCommon.MapOptions = {
position: { target: CITY_CENTER, zoom: 13 }
};
private mapCallback?: AsyncCallback<map.MapComponentController>;
private mapController?: map.MapComponentController;
private mapEventManager?: map.MapEventManager;
@State eventLogs: EventLog[] = EVENT_SEED;
@State markerListenOn: boolean = true;
@State poiListenOn: boolean = true;
@State queryInput: string = '自习室';
@State searchRecords: SearchRecord[] = SEARCH_MOCK;
@State searchState: string = '待搜索';
// --- Notification 状态 ---
@State granted: boolean = false;
notifyId: number = 100;
@State currentRing: RingItem = RING_LIST[0];
Map Kit 状态组包含地图配置、回调、控制器和事件管理器四个 private 对象(不需 @State 因为不直接驱动 UI),以及事件日志流、两个监听开关、搜索关键字、搜索结果和搜索状态五个 @State 变量。Notification 状态组包含授权状态 granted、自增通知 ID 和当前默认铃声。currentRing 初始化为铃声库第一项"静谧水滴",用户可在铃音 Tab 切换默认铃声。
private ringCtx: CanvasRenderingContext2D =
new CanvasRenderingContext2D(new RenderingContextSettings(true));
private lineCtx: CanvasRenderingContext2D =
new CanvasRenderingContext2D(new RenderingContextSettings(true));
两个 Canvas 上下文使用 RenderingContextSettings(true) 开启抗锯齿,private 修饰符表明它们不需要响应式更新——Canvas 内容通过 drawRing()/drawLine() 方法手动重绘而非声明式绑定。
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 做三件事:装配地图回调(绑定 controller→eventManager→Marker 群→双长按监听)、查询通知授权状态(异步 Promise 链)、启动呼吸动画定时器(每秒翻转 breath 并重绘两张 Canvas 图)。aboutToDisappear 清除定时器防止内存泄漏,这是 ArkUI 生命周期的标准实践。
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)
}
build 方法使用 Stack 容器层叠:底层 Column 纵向排列头部、分割线、内容区和底部 Tab 栏,顶层是三个条件渲染的弹窗。内容区的关键设计是地图 Tab 独占分支——MapComponent 需要有界高度才能正确渲染,因此当地图 Tab 激活时不进 Scroll 容器,而是直接调用 tabMap() 并通过 layoutWeight(1) 让 MapComponent 占满剩余空间。其余 5 个 Tab 共享 Scroll 滚动容器,关闭滚动条并启用弹性边缘效果。弹窗通过回调函数控制关闭逻辑,三个弹窗互斥显示(同时只有一个可见)。
八、头部区域详解
@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%')
头部区域分为上下两层。上层是应用名与 Tab 联动副标题的水平排列:左侧 Column 容纳 19px 粗体应用名"研习座 · 共享自习室预约"和 11px 副标题,副标题通过六重三元表达式根据 currentTab 切换文案——自习室 Tab 显示"舱位实况 · 今日入座率 68%"、地图 Tab 显示"门店地图 · 长按标记试试"以此类推,让用户在任何 Tab 下都能看到当前功能的一句话说明。右侧是呼吸圆点,10px 的静谧绿圆点通过 opacity 在 1 和 0.4 之间每秒切换,模拟心跳脉冲,提示数据实时刷新。
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)
Row({ space: 4 }) {
Circle({ width: 6, height: 6 })
.fill(this.poiListenOn ? COLORS.green : COLORS.orange)
Text('POI 长按').fontSize(9).fontColor(COLORS.sub)
}.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.chip)
Row({ space: 4 }) {
Circle({ width: 6, height: 6 })
.fill(this.granted ? COLORS.green : COLORS.orange)
Text(this.granted ? '通知已授权' : '通知未授权').fontSize(9).fontColor(COLORS.sub)
}.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.chip)
Row({ space: 4 }) {
Circle({ width: 6, height: 6 }).fill(COLORS.blue)
Text('Canvas 双图').fontSize(9).fontColor(COLORS.sub)
}.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.chip).layoutWeight(1)
}.width('100%')
}.padding({ left: 16, right: 16, top: 12, bottom: 12 }).width('100%')
.linearGradient({ angle: 160, colors: [[COLORS.chip, 0], [COLORS.bg, 1]] })
下层是四个特性状态胶囊,水平排列在 chip 底色的圆角容器中。前三个胶囊的状态圆点颜色根据业务状态动态变化:Marker 长按监听开启为绿、关闭为橙;POI 长按监听同理;通知授权状态根据 granted 变量切换绿/橙,且文案也随之变为"通知已授权"或"通知未授权"。第四个"Canvas 双图"胶囊固定蓝色圆点,表示 Canvas 绘制能力常驻可用。整个头部使用 160 度线性渐变背景从 chip 到 bg,营造从上到下由浅入深的层次感。
九、自习室 Tab 详解
自习室 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)
}
进度环卡使用 Row 水平排列 168px 的 Canvas 画布和右侧信息列。Canvas 的 onReady 回调在画布尺寸确定后首绘进度环,之后每秒由呼吸定时器重绘。右侧信息列展示在座人数(215)、空余席位数(99)和一条运营建议文案——建议用户在晚高峰提前 1 小时锁舱,并提示静音区余位紧张可切换键盘区,体现了精细化运营的产品思维。
drawRing 方法的绘制原理:首先画一个 chip 色的背景环(完整圆),然后从 12 点方向(-Math.PI/2)开始画 green 色的进度弧,弧长为 2π × rate × wave,其中 wave 在呼吸时为 1、非呼吸时为 0.88,形成进度弧长度每秒微缩的呼吸效果。中心绘制 14% 字号粗体的百分比大字和下方 6% 字号的"今日入座率"副标签。
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)
}
折线卡使用 100% 宽度、180px 高度的 Canvas 画布。drawLine 方法的绘制分为五步:第一步画 4 条 line 色横线作为背景网格;第二步用 createLinearGradient 创建从 green 到近透明的渐变填充区域,通过 moveTo 到底部起点、逐点 lineTo 到数据坐标、再 lineTo 回底部、closePath 闭合后填充;第三步画 green 色的折线主线;第四步在每个数据点画白心绿边的圆点(card 色填充 + green 色 1.5px 描边);第五步在峰值点(17 点 95 人)上方用 orange 色标注文字,并在横轴偶数索引位置画时间标签避免拥挤。
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 换行布局实现双列排列,SpaceBetween 对齐让两列卡片间距均匀。每张卡片宽度 49%,从上到下包含四层:第一层是门店名和在座率徽章,门店名超出宽度时省略号截断,徽章颜色由 occColor 根据在座率动态决定;第二层是区域标签(静音区/键盘区/独立舱等),左对齐显示;第三层是价格行,左侧静谧绿粗体时价,右侧"改"和"删"两个操作按钮,分别触发编辑和删除弹窗;第四层是全宽的"预约舱位"按钮,点击后打开新增弹窗并预填当前门店名。ForEach 的键值使用 room.name + room.zone + idx 组合确保唯一性。
十、地图 Tab 详解
@Builder
tabMap() {
Column({ space: 10 }) {
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)
Row({ space: 6 }) {
Toggle({ type: ToggleType.Switch, isOn: this.poiListenOn })
.width(36).height(20).selectedColor(COLORS.green)
.onChange(() => { this.togglePoiListen(); })
Text('POI 长按').fontSize(11)
.fontColor(this.poiListenOn ? COLORS.title : COLORS.text3)
}.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.backgroundColor(COLORS.card).borderRadius(10).layoutWeight(1)
}.width('100%')
MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback })
.layoutWeight(1).width('100%').borderRadius(12)
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)
}.width('100%').layoutWeight(1)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
地图 Tab 从上到下分为三层。第一层是两个监听开关卡片,每个卡片包含一个 Toggle 开关和标签文字,开关颜色为静谧绿,标签颜色根据开关状态在 title 和 text3 之间切换。toggleMarkerListen 和 togglePoiListen 方法通过调用 offMarkerLongClick()(不传参清除全部订阅)或重新注册 onMarkerLongClick 回调来控制监听开关。
第二层是 MapComponent 本体,通过 layoutWeight(1) 占满中间区域并设置 12px 圆角。地图初始化在 setupMapCallback 中完成:回调先获取 mapController 和 mapEventManager,然后循环遍历 MARKER_SPOTS 逐个 await addMarker,每个 Marker 设置 clickable: true、anchorU: 0.5, anchorV: 1(锚点在底部中心)。随后注册两个长按监听——onMarkerLongClick 回调参数为 map.Marker,可读取 getId() 和 getPosition();onPoiLongClick 回调参数为 mapCommon.Poi,仅含 name 和 position。两者均将事件 unshift 到 eventLogs 并限制 12 条。
第三层是长按事件日志流,固定高度 96px 可滚动,每条日志包含类型徽标(typeColor 映射蓝/橙)、事件名称、经纬度坐标(保留 4 位小数)和触发时间。
十一、搜索 Tab 详解
@Builder
tabSearch() {
Column({ space: 12 }) {
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%')
Column({ space: 10 }) {
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)
}.width('100%')
}.width('100%')
}
搜索 Tab 由搜索框、状态文案和结果列表三部分组成。搜索框是 TextInput 和"搜索"按钮的水平排列,queryInput 双向绑定输入内容。点击搜索后调用 runSearch 方法,该方法构造 SearchByTextParams(query + location + radius: 5000 + language: ‘zh’),调用 site.searchByText 异步获取结果,将返回的 sites 数组映射为 SearchRecord 实例列表。搜索状态文案通过一个状态圆点(成功绿/失败红)和文字展示当前状态——“搜索中…”、“返回 N 条结果”、"搜索失败(code),保留当前推荐"等。在无 AGC 配置或无网络时走 catch 分支保留 Mock 数据并提示,体现调用链的完整性。
结果列表每条卡片包含四层信息:第一层是地点名称和 reliabilityScore 映射的等级标签(高/中/低相关 + 对应颜色);第二层是格式化地址(省略号截断);第三层是距离(原木橙)、reliability 线性分数条(Progress 组件,0~100 映射,静谧绿填充)和精确分数值(保留两位小数)。Progress 组件的线性进度条让用户直观感知相关性高低,比纯数字更具可读性。
十二、提醒 Tab 详解
提醒 Tab 由通知授权卡、预约提醒时间轴、发布按钮和通知历史流四部分组成。
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)
}.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 在绿/橙间切换。requestAuth 方法先调用 requestEnableNotification 弹出系统授权框,若曾被拒绝(错误码 1600004)则 fallback 调用 openNotificationSettings 拉起系统通知设置页进行二次授权。按钮文案和颜色也随状态变化——未授权时为绿底白字"去授权",已授权时为灰底绿字"已授权"。
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)
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 宽),中列是 8px 的状态圆点和 2px 宽的竖连线(末行竖线隐藏为 card 色避免拖出空白),右列是提醒卡片。卡片内含标题、状态标签和 Toggle 开关——开关直接修改 item.on 字段,因 RemindItem 是 @Observed 类,UI 会立即刷新时间颜色和状态文案。5 条提醒数据从早鸟场 07:45 到夜车场 22:30,覆盖全天学习场景。
发布按钮调用 publishNotice 方法发送携带沙箱铃声的通知,通知历史流在底部展示已发布通知的标题、正文和时间。
十三、铃音 Tab 详解
@Builder
tabRing() {
Column({ space: 12 }) {
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)
当前铃声卡展示三行信息:第一行是标签和落盘状态(已落盘 EL1 为绿、未落盘为橙),第二行是铃声名称(16px 粗体),第三行是文件名、频率和时长参数。落盘状态通过 inSandbox 字段实时反映——用户点击"生成到沙箱"后,genRing 方法调用 saveRingToSandbox 将 WAV 字节写入 EL1 的 filesDir 目录,然后回填 inSandbox = true 和 size 字段。
Column({ space: 10 }) {
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)
}.width('100%')
}.width('100%')
}
铃声库列表每条卡片包含两行。第一行是铃声信息(名称+参数)和两个徽章——当前默认铃声显示"默认"绿色徽章,已生成的显示"沙箱"绿色徽章、未生成的显示"未生成"灰色徽章。第二行是两个等宽按钮:"生成到沙箱"按钮颜色根据状态在 green(未生成)和 greenD(已生成)间切换,点击调用 genRing;"设为默认"按钮为灰底绿字,点击将 currentRing 指向该项。
publishNotice 方法是铃音 Tab 与提醒 Tab 的桥梁——发布通知前先检查 currentRing.inSandbox,若未落盘则先生成。然后将沙箱路径通过 fileUri.getUriFromPath 转为 URI,加上 'uri::' 前缀写入通知请求的 sound 字段。通知使用 SOCIAL_COMMUNICATION 类型槽,内容包括标题、正文和附加文本(铃声名称+文件名)。
十四、我的 Tab 详解
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]] })
会员渐变大卡是"我的"Tab 的视觉焦点,使用 135 度从 green 到 greenD 的线性渐变背景,模拟静谧绿的深浅过渡。卡片内所有文字使用 card(纯白)色并配合不同 opacity(0.85~0.92)营造层次感。卡片分为三行:第一行是会员类型和应用名,右侧白色底色 VIP 徽章;第二行是剩余时长和有效期;第三行是本月入座时长、累计舱次两个统计指标和"续费时长"按钮——点击后调用 publishNotice 发送续费成功通知,将铃声链路与业务场景打通。
十五、图表卡片
@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)
}
柱状图使用纯 ArkUI 组件实现而非 Canvas,通过 ForEach 渲染 6 个 Column 柱子。每根柱子是 18px 宽的 Column,高度由 MONTH_HOURS[idx] 乘以呼吸系数(breath 为 true 时 1,false 时 0.92)计算,最低 14px 保证可见性。柱子使用 180 度从 green 到 greenD 的垂直渐变,顶部圆角。柱子下方依次显示数值和月份标签,整行通过 alignItems(VerticalAlign.Bottom) 底部对齐。呼吸状态联动让 6 根柱子每秒同步微缩,形成"心跳"效果,与自习室 Tab 的 Canvas 双图呼吸节律一致。
十六、底部 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 栏自绘实现,6 个 Tab 单排排列在白色底色的 Row 中。每个 Tab 是一个 Column,上方 17px 的 emoji 图标,下方 9px 的中文标签。选中态通过 fontColor 切换——选中时为 tabOn(静谧绿),未选中为 text3(浅灰橄榄)。点击后直接修改 currentTab 触发内容区切换。layoutWeight(1) 保证 6 个 Tab 等宽分布。
十七、弹框系统
17.1 遮罩层
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%').backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
遮罩层是一个全屏 Column,背景色为半透橄榄黑 mask。点击空白区域触发 onClose 回调关闭弹窗,提供"点击外部关闭"的交互习惯。
17.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({ text: this.formName, placeholder: '门店名,如:万象城阅读舱' })
.height(38).fontSize(12).fontColor(COLORS.title)
.placeholderColor(COLORS.text3).backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => { this.formName = value; })
// ... 区域、时价、座位数三个 TextInput ...
Row({ space: 10 }) {
Button('取消').height(38).fontSize(12).borderRadius(10)
.fontColor(COLORS.sub).backgroundColor(COLORS.chip).layoutWeight(1)
.onClick(() => { onClose(); })
Button('创建').height(38).fontSize(12).borderRadius(10)
.fontColor(COLORS.card).backgroundColor(COLORS.green).layoutWeight(1)
.onClick(() => { this.confirmAdd(); })
}.width('100%')
}.width('86%').padding(18).borderRadius(14).backgroundColor(COLORS.card)
}.width('100%').height('100%')
}
新增弹窗使用 Stack 层叠遮罩层和表单卡片。表单包含四个 TextInput(门店名、区域、时价、座位数),其中时价和座位数使用 InputType.Number 限制数字输入。confirmAdd 方法对输入值做容错处理:空门店名默认"未命名研习座"、空区域默认"静音区"、非法价格默认 5 元、非法座位数默认 30,在座数从 0 开始。确认后 unshift 置顶到 roomList。
17.3 编辑与删除弹窗
编辑弹窗 panelEdit 结构与新增弹窗一致,但预填当前舱位信息,saveEdit 方法对空输入保留原值、非法数值跳过更新。删除弹窗 panelDel 更简洁,仅展示删除确认文案和取消/删除两个按钮,删除按钮使用 red 警示色,confirmDel 方法通过 splice 移除指定索引的舱位。
十八、功能模块对比表
| Tab | 核心功能 | 主数据模型 | 关键技术 | 交互亮点 | 视觉特色 |
|---|---|---|---|---|---|
| 自习室 | 舱位实况概览 | StudyRoomItem | Canvas drawRing + drawLine | 双列卡片改/删/预约 | 进度环呼吸弧长微缩 |
| 地图 | 门店定位与长按事件 | EventLog | Map Kit 双长按监听 | Marker/POI 监听开关 | 事件日志流置顶刷新 |
| 搜索 | 关键字 POI 检索 | SearchRecord | searchByText reliability | 三档等级标签+分数条 | 线性 Progress 可视化 |
| 提醒 | 预约提醒时间轴 | RemindItem/NoticeLog | Notification Kit 授权链 | Toggle 逐条开关 | 竖线时间轴末行隐藏 |
| 铃音 | 沙箱铃声生成与管理 | RingItem | EL1 沙箱 + WAV 生成 + uri::sound | 生成到沙箱/设为默认 | 渐变按钮状态切换 |
| 我的 | 会员信息与统计 | BookingRow | Canvas-less 纯组件柱状图 | 续费触发通知 | 渐变大卡+呼吸柱状图 |
深化解析:从代码结构到业务闭环
布局方式与数据流
共享自习室页面同时呈现舱位供给、预约操作、地图定位、学习提醒与个人统计。舱位卡和图表用于快速判断资源,地图与搜索帮助找到门店,提醒和铃声保证预约按时履约。代码解读应围绕房间模型、预约状态和地图事件展开,说明双列卡片、进度环、折线图、时间轴等布局为什么适合相应信息密度。
页面根结构通常由头部、内容区和底部 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、图表、弹窗和系统能力协同工作的原理。
十九、总结与展望
本篇深入解析了"研习座·共享自习室预约"平台的完整源码实现。从色彩体系的原木米+静谧绿基调,到 6 个 Tab 各具特色的布局设计,再到 Map Kit、Notification Kit 和 Canvas 三大引擎的技术融合,整个应用展现了 HarmonyOS ArkUI 在共享学习空间领域的系统性实践。
技术亮点回顾。 Map Kit 的 searchByText 首次将 reliability 相关性分数引入 POI 搜索结果的可视化展示,三档分级标签和线性分数条让搜索质量一目了然;onMarkerLongClick 和 onPoiLongClick 双长按监听为地图交互提供了事件级的细粒度捕获能力,配合开关 Toggle 和日志流形成完整的"监听-记录-展示"闭环。Notification Kit 的 EL1 沙箱自定义铃声链路是本应用最复杂的技术特性——从 buildWavBytes 正弦波 PCM 合成,到 contextConstant.AreaMode.EL1 区域文件写入,再到 fileUri.getUriFromPath 转换 URI 并以 'uri::' 前缀写入通知 sound 字段,整条链路贯穿了音频生成、文件系统和通知发布三个子系统。Canvas 绘制的进度环和折线图通过 breath 状态每秒联动重绘,弧长微缩和柱高波动模拟了实时数据刷新的临场感。
架构设计经验。 组件顶层集中声明所有 @State 变量,包括 Tab 状态、弹窗状态、表单缓存、动画状态和四组业务数据数组,实现了跨 Tab 的状态共享。@Observed 装饰器在 6 个数据模型类上确保字段级变化被 UI 即时感知,从 Toggle 开关切换到舱位编辑保存,所有数据变更都无需手动触发刷新。@Builder 方法群将头部、6 个 Tab、底部 Tab 栏和 3 个弹窗拆分为独立函数,build 方法仅做组合编排,代码结构清晰。地图 Tab 独占内容区不进 Scroll 的设计,解决了 MapComponent 需有界高度的关键约束。
未来展望。 当前应用的 Mock 数据为演示用途,后续可对接真实后端 API 实现实时舱位同步。Map Kit 的 searchByText 可进一步结合 site.SearchAroundSiteParams 实现周边搜索,丰富发现入口。Notification Kit 的沙箱铃声链路可扩展为用户录音上传+自动转码,让用户完全自定义通知音色。Canvas 绘制可引入 RenderingContextSettings 的 antialias 全局抗锯齿和更多渐变色阶,提升图表精致度。柱状图可从纯组件方案升级为 Canvas 方案以支持更丰富的交互(如点击柱子查看详情)。此外,可引入 @Watch 装饰器监听 breath 状态变化,实现更精细的动画控制,或使用 animateTo 显式动画替代 setInterval 定时器,获得更平滑的 60fps 动画体验。
随着 HarmonyOS 生态的持续演进,Map Kit 的 POI 数据覆盖度、Notification Kit 的通知渠道丰富度以及 Canvas 的绘制性能都将进一步提升,共享自习室预约平台也将从"功能演示"走向"商业落地",为终身学习者提供更精准、更沉浸、更个性化的舱位预约体验。
附录: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)