HarmonyOS 6.1 实战 沙箱文件目录获取与文件保存逻辑,适配应用私有目录下载文件持久化存储方案
一、技术前言
在智慧园区与现代化物业管理领域,安全巡检正经历从"纸质表单+人工巡签"到"数字取证+实时告警"的深刻变革。从消防通道占用复查到配电间红外测温,从喷淋管网压力巡检到应急照明断电测试,每一项巡检任务都需要匹配不同的取证场景、检查维度和告警机制。传统物业巡检应用面临三大挑战:巡检取证画面构图不稳定导致隐患遗漏、对焦距离无法精确控制导致铭牌模糊、告警铃声无法自定义导致通知辨识度低。
HarmonyOS ArkUI 框架为这些挑战提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Component 封装可复用组件、@State 管理响应式状态、@Builder 拆分复杂 UI 结构,天然适合"任务-取证-告警"三层架构。@Observed 装饰器让数据模型字段级变化被 UI 感知,实现"进度更新即视图刷新"的流畅体验。XComponent 作为原生 Surface 宿主,为相机预览提供了直接的图形缓冲区渲染通道。
本平台深度融合 HarmonyOS 6.1.1 的三大前沿特性。Camera Kit 提供 VideoSession 的 AUTO_FRAMING(影随人动)能力链——通过 isControlCenterSupported、getSupportedEffectTypes、enableControlCenter 三步实现巡检跟拍时巡查人员始终居中构图;同时 PhotoSession 的手动对焦三接口 isFocusDistanceSupported、setFocusDistance、getFocusDistance 实现从设备铭牌近拍到机房全景远距的精确对焦控制,设置值与读回值差值小于 0.01 即判定已生效。Notification Kit 通过 buildWavBytes 正弦波合成 WAV 字节流(44 字节头 + 16bit 单声道 PCM),写入 EL1 沙箱 filesDir 后以 sound: 'uri::' + fileUri.getUriFromPath(沙箱路径) 拼接发布,实现通知自定义铃声的零依赖生成。Tabs 嵌套滚动通过内层 Tabs.nestedScroll(TabsNestedScrollMode) 实现楼宇频道与检查项子页签的边缘接力,SELF_FIRST 模式下内层滑到边缘后联动外层切换,SELF_ONLY 模式则仅在内层滚动。
从工程架构视角审视,本平台采用了"单组件多 Builder"的集中式组织模式——根组件 Page1256 内聚了全部 7 个 Tab 的构建函数、3 个弹窗面板、以及 Camera Kit 和 Notification Kit 的全部方法,总代码量约 1880 行。这种模式的优势在于状态变量天然共享、方法调用无需跨组件传参、生命周期统一管理;代价是单文件体积较大,需要通过清晰的分区注释和命名规范维持可读性。代码中使用 // ============ ② 颜色系统 ============ 这样的六段式分区标记,将全文件划分为颜色系统、常量定义、辅助函数、数据模型、组件主体、Builder 函数群六大区块,配合 @Builder 装饰器的函数级拆分,实现了"文件集中、逻辑分散"的平衡架构。
从状态管理维度分析,平台综合运用了 ArkUI 的四层响应式机制:最顶层是 @State 装饰的组件级状态变量,共 30+ 个,覆盖 Tab 切换、弹窗显隐、动画驱动、相机会话、通知授权、嵌套模式六大领域;中间层是 @Observed 装饰的 6 个数据实体类(TaskItem、FocusRecord、InnerCard、SwipeLog、RingItem、NoticeLog),其实例属性变更可被 @State 数组感知并触发局部刷新;底层是常量数据层,10+ 个 const 数组为 UI 提供静态数据源;最内层是 private 修饰的控制器对象(XComponentController、CameraInput、PreviewOutput 等),它们不参与渲染但持有原生资源句柄,需要在生命周期中手动管理创建与释放。
二、整体架构流程图
架构以根组件为入口,使用 Stack 容器层叠:底层 Column 纵向排列头部渐变横幅、分割线、Scroll 内容区和底部 Tab 栏,顶层是三个独立弹窗(新建/编辑/删除各自条件渲染)。内容区通过 currentTab 在 7 个 Builder 方法间切换,四大特性分散在相机(影随人动)、对焦(手动对焦)、频道(嵌套滚动)和告警(沙箱铃声)四个 Tab 上,状态变量统一声明在组件顶层实现跨 Tab 共享。头部横幅以 160 度 linearGradient 从警示橙深色渐变到页面背景色,三特性状态胶囊(相机会话/通知授权/嵌套模式)实时反映系统级能力状态。
从数据流的角度看,整个平台形成了"状态驱动视图、事件回写状态"的单向闭环。用户的每一次交互(点击 Tab、滑动嵌套页签、拖动 Slider、提交表单)都通过事件回调修改 @State 变量,ArkUI 框架检测到状态变化后自动执行最小化 diff 并重渲染受影响的组件区域。对于 @Observed 装饰的实体类,框架甚至能感知到对象内部属性的变更——比如编辑任务进度时,只修改 taskList[idx].progress 这一个字段,对应的 taskCard 卡片就会局部刷新进度条和百分比文字,而无需重建整个列表。这种细粒度的响应式机制是 ArkUI 声明式范式的核心优势,也是本平台能够在单组件内维护 30+ 状态变量而不致性能劣化的关键保障。
三、色彩体系设计
3.1 ColorPalette 接口定义
interface ColorPalette {
bg: string; // 页面背景(安全蓝灰黑)
card: string; // 卡片底色(深蓝灰)
title: string; // 主标题(冷白)
sub: string; // 副标题(蓝灰)
text3: string; // 三级弱文本(暗蓝灰)
orange: string; // 警示橙(主色)
orangeD: string; // 警示橙深色(渐变起点)
blue: string; // 信息蓝(电气/内层日志)
green: string; // 合格绿(消防/已生效)
red: string; // 警示红(失败/删除)
line: string; // 分割线
tabOn: string; // Tab 选中色
mask: string; // 弹窗遮罩
onMain: string; // 橙底文字色(深暖黑)
}
这段接口定义体现了 ArkTS 的类型安全优势。与普通 JavaScript 动态添加属性不同,ColorPalette 接口在编译期即约束所有颜色字段必须是 string 类型,任何拼写错误或类型不匹配都会在编译阶段暴露。接口包含 14 个字段,覆盖了从页面背景到弹窗遮罩的全部语义色阶,每个字段的注释清晰标注了其用途和语义角色,使后续维护者无需追踪代码即可理解色彩用途。
值得注意的是接口中定义了 14 个字段,但实际 COLORS 常量中额外增加了一个 dark 字段——这是开发过程中逐步演化的结果。dark 作为次级容器底色,用于统计格、标签底、输入框背景等需要与卡片底色 card 形成微弱对比的场景。这种"接口定标准、常量做扩展"的模式在实际项目中很常见——接口保证核心字段的存在,常量根据实际需求灵活补充,两者通过 TypeScript 的结构兼容性(structural typing)和平共处。
3.2 COLORS 常量逐色分析
const COLORS: ColorPalette = {
bg: '#14171C', // 安全蓝灰黑,沉浸式深色基底
card: '#1E232B', // 卡片底色,比背景亮一档蓝灰
dark: '#283039', // 次级容器底色(统计格/标签底)
title: '#EDF1F5', // 冷白标题,暗光高对比
sub: '#ADBAC7', // 蓝灰副标题,层次柔和
text3: '#74818E', // 暗蓝灰弱文本,辅助信息不抢视觉
orange: '#FF8A2B', // 警示橙主色,渐变横幅与按钮主色
orangeD: '#E06A10', // 深橙渐变起点,头部横幅过渡
blue: '#4E9BE3', // 信息蓝,电气类与内层日志标识
green: '#34C98A', // 合格绿,消防类与已生效状态
red: '#E85555', // 警示红,失败与删除操作
line: '#2A313A', // 蓝灰分割线,低对比不干扰
tabOn: '#FF8A2B', // Tab 选中色为警示橙
mask: 'rgba(0,0,0,0.6)', // 半透黑遮罩
onMain: '#231507' // 深暖黑,橙底文字专用色
};
色彩体系以"安全蓝灰 + 警示橙"为核心对比。蓝灰色系构建深色沉浸基底,模拟安保监控中心的暗光环境;警示橙作为主色出现在渐变横幅、主操作按钮和 Tab 选中态上,确保巡检关键信息在深色背景上获得最高视觉优先级。
背景与容器三色阶构建了清晰的纵深层次。bg 为 #14171C 安全蓝灰黑,是最深的页面底色,营造沉浸式暗色氛围;card 为 #1E232B 深蓝灰,比背景亮约 8%,作为卡片容器的底色,与背景形成柔和但可辨识的边界;dark 为 #283039,比卡片再亮约 6%,用于输入框背景、统计格底色、徽章底色等次级容器。三档底色层层递进,每档亮度差控制在 6%~8% 之间,既保证了视觉层次的丰富性,又避免了对比度过强导致的割裂感。
文字三色阶建立了清晰的信息层级。title 为 #EDF1F5 冷白色,与深色背景对比度超过 14:1,远超 WCAG AAA 标准(7:1),用于主标题和重要数据;sub 为 #ADBAC7 蓝灰色,对比度约 7:1,符合 WCAG AA 标准,用于副标题和正文;text3 为 #74818E 暗蓝灰色,对比度约 4.5:1,刚好达到 WCAG AA 正常文本标准,用于辅助说明和时间戳。三档文字色的对比度经过精确计算,确保在深色主题下各级信息的可读性都有保障。
功能色四色各司其职。orange 警示橙 #FF8A2B 是平台主色,用于渐变横幅、主操作按钮、Tab 选中态、进度数值等核心交互元素,在深色背景上具有最高的视觉捕获率;orangeD 深橙 #E06A10 作为渐变起点色,与 orange 形成 140~160 度的渐变效果,模拟安全警示灯由近及远的衰减;blue 信息蓝 #4E9BE3 用于电气类检查项、内层日志标识、进行中状态等,与主橙色形成冷暖对比;green 合格绿 #34C98A 用于已完成状态、已生效校验、授权成功等正面语义,传递安全可靠的心理暗示;red 警示红 #E85555 仅用于失败状态、删除操作、已逾期任务等负面语义,通过低频使用强化警示效果。
辅助色三字段完善细节体验。line 分割线 #2A313A 亮度介于 card 和 dark 之间,低对比度不干扰内容阅读;tabOn 与 orange 同值,保证 Tab 选中态与主色一致,降低维护成本;mask 遮罩 rgba(0,0,0,0.6) 使用 RGBA 格式实现 60% 透明度的纯黑遮罩,与深色系整体协调;onMain 深暖黑 #231507 是一个特别设计的颜色——专为橙底文字设计,微暖调暗色与橙色形成更柔和的对比,而非使用纯黑(#000000),避免了纯黑在鲜艳橙底上产生的"硬边"视觉不适。
3.3 状态颜色映射函数
色彩体系通过多个辅助函数实现语义化映射。taskStatusColor 将任务状态映射为四色——已完成绿、进行中橙、待复查蓝、已逾期红,使进度条与状态徽章同色联动。framingStateColor 将影随人动状态映射为三色——已启用绿、能力缺失蓝、失败红。focusOkColor 将对焦校验结果映射为——已生效绿、偏差橙、失败红。levelColor 将告警等级映射为——一般蓝、严重橙、紧急红。这套映射体系确保同一语义状态在任意 Tab 中颜色一致,降低用户认知成本。
状态颜色映射函数的设计遵循"语义优先、视觉一致"的原则。每个函数都是纯函数(pure function)——输入相同的状态字符串,输出固定的颜色值,没有副作用、不依赖外部状态。这种设计有三大优势:其一,可测试性强,单元测试只需验证输入输出对即可;其二,可维护性好,修改颜色映射只需改动函数内部逻辑,不影响调用方;其三,一致性有保障,同一语义状态在不同 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: '告警' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口定义了底部导航项的最小数据结构,仅包含 icon(emoji 图标字符串)和 label(中文标签文字)两个字段。这种极简设计体现了"数据与渲染分离"的架构思想——Tab 的元数据只描述"是什么",而"怎么显示"由 tabBar() 构建函数负责。如果未来需要新增 Tab,只需在数组中追加一项,无需修改任何渲染逻辑。
底部单排 7 个 Tab,每个对应一个完全不同的布局范式。任务 Tab 聚焦巡检进度管理,采用"统计卡+清单+图表"的三段式布局;相机 Tab 承载影随人动取证,以 XComponent 预览为核心辅以能力链状态展示;对焦 Tab 演示手动对焦三接口,采用"能力查询+预设档位+滑杆控制+记录时间线"的交互流;频道 Tab 展示嵌套滚动接力,以双层 Tabs 为核心加模式切换;日志 Tab 记录翻页事件时间轴,采用固定行高的时间线列表;告警 Tab 整合通知铃声全链路,压缩了表单、授权、铃声、预览、历史五大区块;我的 Tab 呈现巡检员绩效看板,以渐变大卡加绩效清单为核心。
7 个 Tab 的排布也经过了精心设计:任务(首页)、相机(核心能力A)、对焦(核心能力A延伸)、频道(核心能力B)、日志(能力B验证)、告警(核心能力C)、我的(个人中心)。两大 Camera Kit 能力(影随人动和手动对焦)被有意拆分为两个独立 Tab 并相邻排布,既保证各自有充足的展示空间,又便于用户对比理解 VideoSession 和 PhotoSession 的差异。嵌套滚动的"操作-验证"组合(频道 Tab + 日志 Tab)也采用同样的相邻策略。
4.2 Camera Kit 常量
4.2.1 效果类型枚举
interface EffectInfo {
type: number;
name: string;
desc: string;
}
const EFFECT_INFOS: EffectInfo[] = [
{ type: 0, name: 'BEAUTY', desc: '美颜 · since 20' },
{ type: 1, name: 'PORTRAIT', desc: '人像 · since 20' },
{ type: 2, name: 'AUTO_FRAMING', desc: '影随人动 · 6.1.1 新增' }
];
EffectInfo 接口定义了控制中心效果类型的三元组结构:type 为枚举数值、name 为英文枚举名、desc 为中文说明与版本标注。EFFECT_INFOS 数组枚举了 ControlCenterEffectType 的三个值:BEAUTY(美颜,type=0)是最早的控制中心能力,主要用于自拍场景的美肤、瘦脸、大眼等效果;PORTRAIT(人像,type=1)提供人像模式的背景虚化和灯光效果;AUTO_FRAMING(影随人动,type=2)标注为 6.1.1 新增,是本平台相机跟拍能力的核心。
在相机 Tab 的"ControlCenterEffectType 枚举"卡片中,这三个效果类型通过 ForEach 逐行渲染,每行左侧显示 type 数值(等宽字体)、中间显示英文枚举名、右侧显示中文说明。当 framingSupported 为 true 时,AUTO_FRAMING 行的右侧会额外显示一个绿色的"已声明"徽章,直观标识出当前设备支持的核心能力。这种枚举展示方式兼具教学和实用价值——开发者可以快速了解控制中心支持的效果类型及其版本信息,用户也能直观看到自己的设备是否具备影随人动能力。
4.2.2 对焦预设档位
interface FocusPreset {
label: string;
distance: number;
scene: string;
}
const FOCUS_PRESETS: FocusPreset[] = [
{ label: '铭牌近拍', distance: 0.1, scene: '0.1 · 铭牌/序列号' },
{ label: '设备中距', distance: 0.5, scene: '0.5 · 配电柜整柜' },
{ label: '环境远距', distance: 0.9, scene: '0.9 · 机房全景' }
];
FocusPreset 接口定义了对焦预设的三字段结构:label 为中文档位名、distance 为对焦距离值(0.0~1.0)、scene 为应用场景描述。三档预设覆盖了巡检取证最常见的对焦距离区间:
- 铭牌近拍(0.1):用于拍摄设备铭牌、出厂序列号、压力表读数等需要高分辨率细节的近距离场景。对焦距离 0.1 是最接近镜头的档位,能够清晰捕捉毫米级的文字和刻度。
- 设备中距(0.5):用于拍摄配电柜整柜、管道阀门组、消防栓箱体等中等距离的设备整体照。对焦距离 0.5 是巡检中最常用的档位,既能看清设备全貌,又能保留足够的细节。
- 环境远距(0.9):用于拍摄机房全景、疏散通道全貌、地下车库整排车位等大场景远景。对焦距离 0.9 接近最远对焦距离,适合展现整体环境和空间布局。
在对焦 Tab 的三档预设行中,这三个预设以三列等宽卡片呈现,选中项使用警示橙底色高亮,未选中项使用卡片底色。点击预设卡片会直接将 focusDistance 设置为对应值,Slider 滑杆也会同步更新,实现了"预设选档 + 滑杆微调"的双轨控制模式。
4.3 嵌套滚动与业务常量
4.3.1 外层楼宇频道
interface ChannelItem {
name: string;
icon: string;
}
const OUTER_CHANNELS: ChannelItem[] = [
{ name: '1 号楼', icon: '🏢' },
{ name: '2 号楼', icon: '🏬' },
{ name: '地下车库', icon: '🅿️' },
{ name: '配电房', icon: '⚡' },
{ name: '消防泵房', icon: '🚒' }
];
ChannelItem 接口定义了外层楼宇频道的两字段结构:name 为频道名称、icon 为对应 emoji 图标。OUTER_CHANNELS 定义了 5 个物业巡检责任分区,覆盖了典型智慧园区的主要巡检区域:
- 1 号楼/2 号楼:高层办公楼宇,主要巡检消防通道、电梯机房、应急照明等
- 地下车库:大空间开放区域,主要巡检喷淋管网、排烟系统、周界安防等
- 配电房:核心设备机房,主要巡检配电柜、变压器、直流屏电池等
- 消防泵房:专用设备机房,主要巡检消防水泵、湿式报警阀、气压罐等
这 5 个频道在外层 Tabs 中以 BarMode.Scrollable 横滑页签形式排列,每个频道承载一个内层 innerTabs 构建函数,形成"楼宇 × 检查项"的二维矩阵结构。
4.3.2 内层检查项与告警等级
const INNER_TABS: string[] = ['消防', '电气', '管道', '通道', '监控'];
const ALARM_LEVELS: string[] = ['一般', '严重', '紧急'];
INNER_TABS 定义了 5 个检查项子页签:消防、电气、管道、通道、监控。这五类检查涵盖了物业安全巡检的核心维度——消防类关注火灾防控设施、电气类关注供配电系统安全、管道类关注给排水和消防管网、通道类关注疏散通道和安全出口、监控类关注安防监控系统。每个检查项子页签内有 8 条检查卡片,内容由 innerMockData 函数动态生成。
ALARM_LEVELS 定义了三档告警等级:一般、严重、紧急。三级颜色分别映射为蓝、橙、红,形成"信息-警告-危险"的语义递进。在告警 Tab 的上报表单中,这三个等级以 chips 形式呈现,用户点击选择后 alarmLevel 状态变量更新,发布通知时自动拼接为"园区巡卫 · 严重告警"格式的标题。
4.3.3 月度隐患数据
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const MONTH_HAZARD: number[] = [12, 9, 15, 7, 11, 6];
const MONTH_MAX: number = 16;
MONTH_NAME 和 MONTH_HAZARD 为近 6 个月隐患发现数(单位:处),驱动任务 Tab 的月度隐患柱状图。数据呈现出明显的波动特征:3 月 12 处、4 月 9 处(下降)、5 月 15 处(峰值)、6 月 7 处(低谷)、7 月 11 处(回升)、8 月 6 处(持续下降)。整体趋势向下,说明隐患治理成效显著。
MONTH_MAX 设为 16,略高于实际最大值 15,为柱状图预留顶部空间,避免最高柱贴顶显得局促。柱高计算公式为 MONTH_HAZARD[i] / MONTH_MAX * 96,即以 96px 为最高柱基准,按比例换算各月柱高,再乘以呼吸波动因子(1.06/0.94)产生微动效果。
4.3.4 绩效清单数据
interface PerfRow {
label: string;
value: string;
note: string;
}
const PERF_ROWS: PerfRow[] = [
{ label: '本月完成点位', value: '312', note: '应检 320 · 完成率 97.5%' },
{ label: '隐患整改闭环', value: '54/56', note: '闭环率 96.4% · 超期 2 项' },
{ label: '平均响应时长', value: '8 分钟', note: '严重告警到场时限 15 分钟' },
{ label: '本月巡检里程', value: '46.8 km', note: '含地库 B1/B2 两层环线' },
{ label: '拍照取证张数', value: '218 张', note: '含隐患整改前后对比图' },
{ label: '连续安全达标', value: '12 天', note: '当班期间安全零事故' }
];
PerfRow 接口定义了绩效清单行的三字段结构:label 为指标名、value 为指标值、note 为补充说明。PERF_ROWS 包含 6 条巡检员月度绩效指标,分为三个层次:
- 核心指标(前3条,橙色高亮):本月完成点位 312 个(完成率 97.5%)、隐患整改闭环 54/56(闭环率 96.4%)、平均响应时长 8 分钟(达标 15 分钟内)。这三项是衡量巡检员工作质量的核心 KPI。
- 工作量指标(第4-5条):本月巡检里程 46.8 公里、拍照取证 218 张。这两项反映巡检员的工作强度和取证完整度。
- 安全指标(第6条):连续安全达标 12 天。这是安全管理的最终目标——当班期间零事故。
在"我的"Tab 的绩效清单中,每行左侧有一个序号徽标,中间是指标名和补充说明,右侧是等宽字体的指标值。前三行因核心指标属性使用警示橙色高亮,其余使用蓝灰色,形成视觉上的优先级区分。
五、工具函数层
5.1 时间与模式工具
5.1.1 nowTime 时间戳函数
function nowTime(): string {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
}
nowTime() 函数返回当前时刻的 HH:mm:ss 格式字符串,是平台中使用最频繁的工具函数之一。它通过 String() 将数字转为字符串,再调用 padStart(2, '0') 实现两位补零——例如 9 点 5 分 3 秒会被格式化为 “09:05:03” 而非 “9:5:3”。
该函数被三个时间线共用:对焦 Tab 的 FocusRecord 记录每次手动对焦操作的时间戳、频道/日志 Tab 的 SwipeLog 记录每次翻页事件的时间戳、告警 Tab 的 NoticeLog 记录每次通知发布的时间戳。统一的时间格式确保了跨模块时间展示的一致性,也便于用户对比不同事件的先后顺序。
值得注意的是,nowTime() 使用的是客户端本地时间而非服务器时间。在实际生产环境中,如果需要精确的事件时间戳(如用于合规审计),应该从服务器获取统一时间以避免客户端时钟偏差。但在演示场景下,本地时间足以满足功能展示的需求。
5.1.2 嵌套模式翻译函数
function modeLabel(mode: TabsNestedScrollMode): string {
return mode === TabsNestedScrollMode.SELF_FIRST
? 'SELF_FIRST·先内后外' : 'SELF_ONLY·仅内层';
}
function modeShort(mode: TabsNestedScrollMode): string {
return mode === TabsNestedScrollMode.SELF_FIRST ? '先内后外' : '仅内层';
}
modeLabel 和 modeShort 两个函数将 TabsNestedScrollMode 枚举值翻译为中文说明,分别用于完整展示和精简展示两种场景。
TabsNestedScrollMode 是 HarmonyOS 6.1.1 新增的枚举类型,包含两个值:
- SELF_FIRST(先内后外):内层 Tabs 优先消费滚动事件,当内层滑到边缘后,剩余的滚动手势会传递给外层容器,实现"内层先滚、滚到边接力外层"的嵌套滚动效果。
- SELF_ONLY(仅内层):内层 Tabs 只消费自身范围内的滚动事件,滑到边缘后不再向外层传递,内外层滚动完全独立。
modeLabel 返回"枚举名·中文名"的完整格式,用于需要明确技术术语的场景(如日志记录、状态说明);modeShort 返回纯中文短格式,用于空间有限的场景(如模式切换 chips、状态胶囊)。
5.2 状态颜色映射函数
5.2.1 framingStateColor 影随人动状态色
function framingStateColor(s: string): string {
if (s === '影随人动已启用') {
return COLORS.green;
}
if (s === '控制中心不支持' || s === 'AUTO_FRAMING 未声明') {
return COLORS.blue;
}
if (s.indexOf('失败') >= 0 || s.indexOf('被拒') >= 0 || s.indexOf('未就绪') >= 0) {
return COLORS.red;
}
return COLORS.text3;
}
framingStateColor 函数将影随人动能力链的状态字符串映射为对应的颜色,遵循"成功-信息-失败"的三色语义体系:
- 绿色(成功):状态为"影随人动已启用"时返回
COLORS.green,表示能力链三步全部通过,影随人动功能正常工作。 - 蓝色(信息):状态为"控制中心不支持"或"AUTO_FRAMING 未声明"时返回
COLORS.blue,表示设备不具备该能力,属于正常的硬件差异而非错误。 - 红色(失败):状态字符串中包含"失败"“被拒”"未就绪"等关键词时返回
COLORS.red,表示能力链执行过程中出现异常。 - 灰色(默认):其他状态(如"未查询""Surface 未就绪"等初始态)返回
COLORS.text3,以弱文本色呈现,不吸引用户注意力。
该函数使用字符串匹配而非枚举判断,是因为 framingState 状态变量直接存储面向用户的展示文案而非枚举值。这种设计的好处是状态文本与颜色映射集中在一处,修改文案时同步调整颜色判断即可;代价是字符串匹配的鲁棒性略低于枚举——如果新增的状态文案未包含关键词,就会回退到默认灰色。
5.2.2 focusOkColor 对焦校验色
function focusOkColor(ok: string): string {
if (ok === '已生效') {
return COLORS.green;
}
if (ok.indexOf('失败') >= 0) {
return COLORS.red;
}
return COLORS.orange;
}
focusOkColor 函数将对焦校验结果映射为颜色,采用三级判断:完全匹配"已生效"返回绿色、包含"失败"返回红色、其余情况(如"读回偏差")返回橙色。这种三档分类体现了对焦校验的严谨性——设置值与读回值差值小于 0.01 为"已生效"(绿色,完全达标)、差值大于等于 0.01 为"读回偏差"(橙色,部分达标但仍可用)、调用异常为"失败"(红色,功能不可用)。
5.2.3 taskStatusColor 任务状态色
function taskStatusColor(s: string): string {
if (s === '已完成') {
return COLORS.green;
}
if (s === '进行中') {
return COLORS.orange;
}
if (s === '待复查') {
return COLORS.blue;
}
if (s === '已逾期') {
return COLORS.red;
}
return COLORS.text3;
}
taskStatusColor 函数将任务状态映射为四种颜色,四种状态分别对应巡检任务生命周期的不同阶段:
- 已完成(绿色):任务已 100% 完成,通过验收。绿色传递"安全、完成、合格"的语义。
- 进行中(橙色):任务正在执行,进度介于 0%~100% 之间。橙色传递"活跃、进行中、需关注"的语义。
- 待复查(蓝色):任务已执行完毕但需上级复查确认。蓝色传递"等待、流程中"的语义。
- 已逾期(红色):任务超过截止时间仍未完成。红色传递"紧急、风险、需立即处理"的语义。
这套颜色映射被同时用于任务卡片的状态徽章文字色和进度条填充色,实现了"徽章即进度条颜色的缩影"的视觉一致性。
5.2.4 levelColor 告警等级色
function levelColor(level: string): string {
if (level === '严重') {
return COLORS.orange;
}
if (level === '紧急') {
return COLORS.red;
}
return COLORS.blue;
}
levelColor 函数将告警等级映射为颜色:严重对应橙色、紧急对应红色、一般(默认)对应蓝色。三级颜色与告警严重程度正相关——蓝色最温和(一般告警,按常规流程处理)、橙色中等(严重告警,需尽快到场)、红色最紧急(紧急告警,需立即响应)。
在告警 Tab 的等级选择 chips 中,选中的等级不仅文字变色,整个 chip 的背景色也会变为对应的等级色,形成"色即等级"的强视觉关联。发布通知时,等级也会体现在标题中(如"园区巡卫 · 严重告警"),使用户在通知栏就能快速判断告警的紧急程度。
5.3 sessionLabel 会话模式标签
function sessionLabel(mode: string): string {
if (mode === 'video') {
return 'VideoSession · 影随人动宿主';
}
if (mode === 'photo') {
return 'PhotoSession · 手动对焦宿主';
}
return 'idle · 未启动会话';
}
sessionLabel 函数将会话模式字符串翻译为中文说明,用于相机预览右下角的会话模式标签和对焦 Tab 的会话状态显示。三种模式分别对应:
- video 模式:当前为 VideoSession,是影随人动(AUTO_FRAMING)能力的宿主。VideoSession 支持视频录制和控制中心效果,适合需要实时跟拍的巡检取证场景。
- photo 模式:当前为 PhotoSession,是手动对焦(ManualFocus)能力的宿主。PhotoSession 支持拍照和手动对焦控制,适合需要精确对焦的设备检查场景。
- idle 模式:未启动任何会话,相机资源已释放。这是默认状态和切换过程中的过渡态。
该函数的设计反映了 Camera Kit 的核心架构原则——cameraInput 同一时间只能绑定一个 session,VideoSession 和 PhotoSession 互斥存在。平台通过 switchTab 方法在离开相机/对焦 Tab 时自动释放会话,避免资源泄漏和后续会话创建失败。
5.4 distanceLabel 对焦距离文案函数
function distanceLabel(v: number): string {
if (v < 0.3) {
return '近拍 · 设备铭牌与出厂序列号';
}
if (v < 0.7) {
return '中距 · 配电柜整柜与管线走向';
}
return '远距 · 机房全景与疏散通道';
}
distanceLabel 函数将对焦距离值(0.0~1.0 的连续值)翻译为巡检景别文案,为 Slider 拖动时提供实时的场景化提示。两档阈值(0.3 和 0.7)将对焦范围划分为三个区间,与三档预设档位(0.1/0.5/0.9)形成对应关系:
- 近拍区间(0.0~0.3):对应"铭牌近拍"预设(0.1),适用于拍摄设备铭牌、出厂序列号、压力表读数等近距离细节。
- 中距区间(0.3~0.7):对应"设备中距"预设(0.5),适用于拍摄配电柜整柜、管道阀门组、消防栓箱体等中等距离设备。
- 远距区间(0.7~1.0):对应"环境远距"预设(0.9),适用于拍摄机房全景、疏散通道全貌、地下车库整排车位等大场景远景。
在对焦 Tab 的焦距滑杆卡中,Slider 下方实时显示当前对焦距离对应的景别文案,用户拖动滑杆时文字会即时切换,帮助用户理解抽象的 0.0~1.0 数值与实际拍摄场景的对应关系。
5.5 WAV 音频合成与大小估算
5.5.1 wavSizeText 文件大小估算
function wavSizeText(durationMs: number): string {
const bytes = 44 + Math.floor(44100 * durationMs / 1000) * 2;
return `${(bytes / 1024).toFixed(1)} KB`;
}
wavSizeText 函数根据音频时长估算 WAV 文件大小并格式化为 KB 文案。计算公式为:44 字节(WAV 文件头)+ 采样数 × 2 字节(16bit 单声道 PCM 采样)。采样数 = 44100 Hz × 时长秒数。
例如,900ms 的音频:采样数 = 44100 × 0.9 = 39690,数据量 = 39690 × 2 = 79380 字节,总大小 = 44 + 79380 = 79424 字节 ≈ 77.6 KB。
该函数用于铃声列表中显示每个铃声的文件大小,帮助用户了解存储空间占用情况。在铃声导入沙箱成功后,ring.size 会被更新为该函数的计算结果。
5.5.2 buildWavBytes 正弦波 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);
const writeStr = (offset: number, s: string) => { for (let i = 0; i < s.length; i++) { view.setUint8(offset + i, s.charCodeAt(i)); } };
writeStr(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM 编码
view.setUint16(22, 1, true); // 单声道
view.setUint32(24, sampleRate, true); // 采样率
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true); // 16bit
writeStr(36, 'data');
view.setUint32(40, dataSize, true);
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)); // 自然衰减
view.setInt16(44 + i * 2, Math.round(Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay * 32767), true);
}
return buf;
}
这是平台最底层的音频生成函数,用于在客户端动态构建 WAV 格式的正弦波音频字节,是 Notification Kit 自定义铃声链路的核心。函数接收频率(Hz)和时长(毫秒)两个参数,返回完整的 ArrayBuffer,可直接写入文件或通过网络传输。
WAV 文件头结构(共 44 字节):
- 字节 0-3:
RIFF标识(资源交换文件格式) - 字节 4-7:文件大小 - 8(36 + 数据大小),小端序 32 位整数
- 字节 8-11:
WAVE格式标识 - 字节 12-15:
fmt子块标识(注意末尾空格) - 字节 16-19:fmt 子块大小(16 字节,PCM 格式)
- 字节 20-21:音频格式编码(1 = 线性 PCM)
- 字节 22-23:声道数(1 = 单声道)
- 字节 24-27:采样率(44100 Hz)
- 字节 28-31:字节率 = 采样率 × 声道数 × 位深度/8
- 字节 32-33:块对齐 = 声道数 × 位深度/8
- 字节 34-35:位深度(16 bit)
- 字节 36-39:
data子块标识 - 字节 40-43:数据区大小(字节数)
PCM 数据生成逻辑:
t为当前采样对应的时间(秒),从 0 递增到durationMs / 1000env为起音包络:前 20ms 线性从 0 上升到 1,避免音频开始时的"咔哒"声(这是由于信号从 0 突然跳到正弦波峰值产生的高频瞬态噪声)decay为自然衰减包络:从 1 线性下降到 0,使声音有自然的渐弱效果,模拟真实铃声的衰减特性- 最终采样值 = sin(2π·freq·t) × 0.5 × env × decay × 32767,其中 0.5 是振幅系数(防止削波失真),32767 是 16 位有符号整数的最大值
writeStr 是一个内部辅助函数,用于将字符串逐字符写入 DataView,本质是把 ASCII 字符串转换为字节序列。所有多字节整数的写入使用 true 参数表示小端序(little-endian),这是 WAV 规范的要求——WAV 文件基于 RIFF 格式,而 RIFF 格式使用小端序存储多字节整数。
此函数生成的音频可直接写入 EL1 沙箱文件,再通过 fileUri.getUriFromPath 转换为 uri:: 前缀的通知铃声,实现了从参数到可播放铃声的完整链路,零音频文件依赖、零网络请求、完全在客户端生成。
5.6 checkPoint 检查项要点生成器
function checkPoint(tabName: string, i: number): string {
const pool: string[] = tabName === '消防'
? ['灭火器压力表指针', '消火栓水带卡扣', '疏散指示标识灯', '防火门闭门器', '卷帘门导轨积尘', '消防电话分机', '排烟阀执行机构', '水泵接合器锈蚀']
: tabName === '电气'
? ['配电柜母排温度', '断路器接线端子', '电缆桥架盖板', '接地扁铁连接', '应急照明蓄电池', '双电源切换柜', '线槽穿墙封堵', '电表箱铅封完好']
: tabName === '管道'
? ['喷淋管网压力表', '湿式报警阀阀瓣', '排水沟防鼠网', '集水坑液位浮球', '阀门铅封完好性', '法兰垫片渗漏点', '给水立管支架', '污水提升泵试运行']
: tabName === '通道'
? ['安全出口堆物核查', '疏散通道净宽度', '台阶防滑条完好', '卷帘下净空高度', '消防车道占位车辆', '楼梯间杂物清理', '门禁断电常开测试', '指示牌反光膜状态']
: ['监控镜头遮挡检查', '硬盘录像机容量', '周界红外对射探测', '电子巡更读卡点位', '录像保存天数达标', '视频画面雪花排查', '机房温湿度记录', 'UPS 备用电池电压'];
const idx = Math.min(pool.length - 1, Math.max(0, i - 1));
return pool[idx];
}
checkPoint 函数接收检查类别名和序号,返回对应行业的巡检要点文案。五类检查各定义 8 个语义化要点,覆盖了物业安全巡检的核心检查项:
- 消防类:灭火器、消火栓、疏散指示、防火门、卷帘门、消防电话、排烟阀、水泵接合器——涵盖建筑消防设施的八大类核心设备
- 电气类:配电柜、断路器、电缆桥架、接地、应急照明、双电源切换、线槽封堵、电表箱——覆盖供配电系统的主要检查点
- 管道类:喷淋管网、湿式报警阀、排水沟、集水坑、阀门、法兰垫片、给水立管、污水提升泵——覆盖给排水和消防管网系统
- 通道类:安全出口、疏散通道、台阶防滑、卷帘净空、消防车道、楼梯间杂物、门禁断电、指示牌反光——覆盖疏散通道和安全出口的检查维度
- 监控类:监控镜头、硬盘录像机、红外对射、电子巡更、录像天数、视频雪花、温湿度、UPS 电池——覆盖安防监控系统的检查要点
序号通过 Math.min/Math.max 钳制在有效范围内(0~7),防止数组越界。该函数被 innerMockData 调用,为每个楼宇频道和检查项组合生成 8 条语义化的检查卡片内容,保证内容超过一屏——这是 nestedScroll 边缘接力演示的前提条件,若内容不超一屏则无法触发内层滑到边缘的场景。
六、数据模型层
6.1 TaskItem 巡检任务实体
@Observed export class TaskItem {
building: string; // 楼宇/分区名
item: string; // 巡检项目名
progress: number; // 进度(0~100)
status: string; // 状态(已完成/进行中/待复查/已逾期)
constructor(building: string, item: string, progress: number, status: string) {
this.building = building; this.item = item; this.progress = progress; this.status = status;
}
}
TaskItem 是任务 Tab 的核心实体,用 @Observed 装饰器修饰,表示该类的实例具备可观察性。当任何实例的属性发生变化时(如编辑后修改 progress),所有引用该实例的 @State 数组会收到通知并触发 UI 刷新。
四个属性的设计体现了巡检任务的核心维度:
building:楼宇/分区名,标识任务所属的物理区域,如"1 号楼"“地下车库”"配电房"等item:巡检项目名,描述具体的检查内容,如"消防通道占用复查""配电间红外测温巡检"等progress:进度值(0~100),用百分比量化任务完成程度,驱动进度条和完成率统计status:状态文字,描述任务的生命周期阶段,有"已完成"“进行中”“待复查”"已逾期"四种取值
TASK_LIST 常量预置了 7 条巡检任务,覆盖各种状态和场景:
const TASK_LIST: TaskItem[] = [
new TaskItem('1 号楼', '消防通道占用复查', 100, '已完成'),
new TaskItem('2 号楼', '配电间红外测温巡检', 72, '进行中'),
new TaskItem('地下车库', '喷淋管网压力巡检', 45, '进行中'),
new TaskItem('消防泵房', '水泵启动试运行测试', 88, '待复查'),
new TaskItem('配电房', '直流屏电池巡检', 30, '进行中'),
new TaskItem('3 号楼', '电梯机房月度点检', 100, '已完成'),
new TaskItem('5 号楼', '应急照明断电测试', 0, '已逾期')
];
7 条任务分布在 6 个不同区域,进度从 0% 到 100% 全覆盖,状态包含四种类型。这种多样化的预置数据使得任务清单的展示效果丰富——进度条颜色各异(绿/橙/蓝/红)、状态徽章各不相同,用户能直观感受到任务管理的完整性。
@Observed 装饰器的作用机制值得深入理解。在 ArkUI 中,@State 装饰的数组变量默认只能检测数组引用的变化(如重新赋值、push、pop 等),而无法检测数组元素内部属性的变化。@Observed 装饰器让类的实例具备了"可观察"的能力——当实例的某个属性被修改时,框架会通知所有引用该实例的 @State、@Link、@Prop 等装饰器,触发相应的 UI 刷新。这就是为什么编辑任务进度时,只需修改 this.taskList[this.editIdx].progress = this.editProgress 这一行代码,对应的任务卡片就会自动更新进度条和百分比显示,而无需手动触发数组的"引用变化"。
6.2 FocusRecord 对焦记录实体
@Observed export class FocusRecord {
time: string; // 操作时间戳
distance: number; // 设置的对焦距离(0.0~1.0)
readback: number; // 读回值(-1 表示调用失败)
ok: string; // 校验结论(已生效/读回偏差/失败)
constructor(distance: number, readback: number, ok: string) {
this.time = nowTime(); this.distance = distance; this.readback = readback; this.ok = ok;
}
}
FocusRecord 记录每次手动对焦操作的三元组信息:设置的对焦距离、读回值、校验结论。构造函数自动调用 nowTime() 填充时间戳,确保每次操作都有精确的时间记录。
distance:通过setFocusDistance()设置的对焦距离,范围 0.0(最近)到 1.0(最远)readback:通过getFocusDistance()读回的实际对焦距离。如果调用失败,此字段设为 -1 作为特殊标记值ok:校验结论,有三种可能:- “已生效”:
Math.abs(readBack - distance) < 0.01,设置值与读回值差值小于 0.01,对焦设置成功生效 - “读回偏差”:差值大于等于 0.01,设置了但实际值有偏差,可能是硬件精度限制或设置未完全生效
- “失败(code)”:调用过程中抛出异常,记录错误码
- “已生效”:
aboutToAppear 中预置了 3 条种子记录,分别演示三种校验结果:
this.focusRecords.unshift(new FocusRecord(0.9, 0.9, '已生效'));
this.focusRecords.unshift(new FocusRecord(0.5, 0.51, '已生效'));
this.focusRecords.unshift(new FocusRecord(0.1, 0.12, '读回偏差'));
三条记录的设计非常巧妙:
- 第一条(0.9→0.9):完全一致,差值为 0,标准的"已生效"
- 第二条(0.5→0.51):差值 0.01,刚好等于阈值,判为"已生效"——这是阈值边界的临界案例
- 第三条(0.1→0.12):差值 0.02,超过阈值 0.01,判为"读回偏差"——展示偏差场景
对焦记录使用 unshift 置顶新增(最新记录在最上方),列表封顶 20 条,超出时 pop 移除尾部,保持滑动窗口大小恒定。
6.3 InnerCard 检查项卡片与 Mock 生成器
@Observed export class InnerCard {
id: string; // ForEach 键
tag: string; // 检查项类别名
title: string; // 检查点标题
desc: string; // 检查内容描述
constructor(id: string, tag: string, title: string, desc: string) {
this.id = id; this.tag = tag; this.title = title; this.desc = desc;
}
}
InnerCard 是嵌套滚动内层列表的内容实体,包含四个字段:
id:唯一标识符,用作ForEach的键生成函数参数,保证列表渲染的稳定性tag:检查项类别名(消防/电气/管道/通道/监控),用于分类标识title:检查点标题,描述具体的检查项desc:检查内容描述,详细说明检查要求和操作方法
id 的生成规则是 ${channel.name}-${tabName}-${i},例如"1 号楼-消防-1",确保在不同楼宇频道和不同检查项下的卡片都有唯一标识。这种命名方式也便于调试——看到 id 就能知道卡片属于哪个频道和检查项。
function innerMockData(channel: ChannelItem, tabName: string): InnerCard[] {
const list: InnerCard[] = [];
for (let i = 1; i <= 8; i++) {
const point = checkPoint(tabName, i);
list.push(new InnerCard(`${channel.name}-${tabName}-${i}`, tabName,
`【${tabName}】${point}核查`,
`${channel.icon} 「${channel.name}」${tabName}类第 ${i} 项:核查${point},拍照取证并回填巡检结论`));
}
return list;
}
innerMockData 函数接收楼宇频道和检查类别名,调用 checkPoint 生成 8 条检查卡片。每张卡片的标题格式为"【类别】检查点核查",描述格式为"图标 楼宇名 类别第 N 项:核查检查点,拍照取证并回填巡检结论"。这种模板化的生成方式保证了 5 个楼宇 × 5 个检查项 = 25 个列表的内容一致性,同时通过楼宇名和检查类别的组合产生差异化。
生成 8 条卡片是经过精心计算的——每条卡片约 100px 高,加上 10px 间距,8 条卡片总高度约 870px,超过了常见手机屏幕的内容区高度(约 600~700px),确保内容超一屏。这是 nestedScroll 边缘接力演示的前提条件:只有内容超出容器高度,内层才能滚动,也才能产生"滑到边缘"的事件。如果内容只有寥寥几条、一屏就能放下,那么内层根本不需要滚动,嵌套滚动的接力效果也就无从演示。
6.4 SwipeLog 翻页日志实体
@Observed export class SwipeLog {
layer: string; // '外层楼宇' / '内层检查项'
tabName: string; // 切换到的页签名
fromIdx: number; // 起始索引
toIdx: number; // 目标索引
mode: string; // 事发时的嵌套模式(modeLabel 结果)
time: string; // 时间戳
constructor(layer: string, tabName: string, fromIdx: number, toIdx: number, mode: string) {
this.layer = layer; this.tabName = tabName; this.fromIdx = fromIdx;
this.toIdx = toIdx; this.mode = mode; this.time = nowTime();
}
}
SwipeLog 记录两层翻页事件,是嵌套滚动行为的可视化验证工具。六个字段完整描述了一次翻页事件的全部信息:
layer:事件发生在哪一层——“外层楼宇"或"内层检查项”tabName:切换到的目标页签名称fromIdx:切换前的索引(从哪个页签滑过来)toIdx:切换后的索引(滑到了哪个页签)mode:事件发生时的嵌套滚动模式(SELF_FIRST·先内后外或SELF_ONLY·仅内层)time:事件发生的时间戳
构造函数自动调用 nowTime() 填充时间戳。列表封顶 40 条,超出时 pop 移除尾部。
这个数据模型的设计体现了"日志即证据"的思想。在演示嵌套滚动特性时,用户可能会疑惑:“我怎么知道外层切换是因为内层滑到边缘触发的接力,还是我直接滑了外层?” SwipeLog 就提供了答案——通过查看日志的 layer 字段和 mode 字段,用户可以清晰地看到:在 SELF_FIRST 模式下,当在内层持续滑动时,先是产生若干"内层检查项"翻页日志,当内层滑到边缘后,继续滑动会产生"外层楼宇"翻页日志,这就是嵌套滚动"边缘接力"的直接证据。
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) {
this.name = name; this.file = file; this.freq = freq;
this.duration = duration; this.size = size; this.inSandbox = inSandbox;
}
}
RingItem 描述告警铃声的完整属性,六个字段覆盖了铃声从定义到使用的全生命周期:
name:铃声的中文名称,如"疏散警报""消防长鸣"等,用于 UI 展示file:铃声在沙箱中的文件名,如"ring_evacuate.wav",用于构建沙箱路径freq:生成正弦波的频率(Hz),决定音高。频率越高音调越高,880Hz 是 A5 音、660Hz 约为 E5 音、1046Hz 约为 C6 音、1320Hz 约为 E6 音duration:音频时长(毫秒),决定铃声长度。450ms~1200ms 是通知铃声的典型时长范围size:文件大小展示文案,初始为"—“,导入沙箱后更新为实际大小(如"77.6 KB”)inSandbox:是否已写入沙箱。只有已写入沙箱的铃声才能作为通知自定义铃声使用
RING_LIST 预置了 4 条铃声,覆盖不同场景和频率特征:
const RING_LIST: RingItem[] = [
new RingItem('疏散警报', 'ring_evacuate.wav', 880, 900, '—', false),
new RingItem('消防长鸣', 'ring_firelong.wav', 660, 1200, '—', false),
new RingItem('门禁提示', 'ring_access.wav', 1046, 600, '—', false),
new RingItem('周界蜂鸣', 'ring_perimeter.wav', 1320, 450, '—', false)
];
四条铃声的设计遵循"场景-频率-时长"的匹配原则:
- 疏散警报(880Hz/900ms):中高频 + 中等时长,声音尖锐醒目,适合紧急疏散场景
- 消防长鸣(660Hz/1200ms):低频 + 长时长,声音低沉持久,模拟消防警报的长鸣特征
- 门禁提示(1046Hz/600ms):高频 + 短时长,声音清脆短促,适合门禁刷卡等提示场景
- 周界蜂鸣(1320Hz/450ms):甚高频 + 极短时长,声音急促尖锐,适合周界安防的蜂鸣告警
所有铃声初始均未导入沙箱(inSandbox: false),需要用户点击"生成"按钮后才会通过 buildWavBytes 生成音频并写入 EL1 沙箱。这种设计既演示了"从零生成"的完整链路,又避免了应用启动时不必要的文件操作。
6.6 NoticeLog 通知历史实体
@Observed export class NoticeLog {
title: string; // 通知标题
text: string; // 通知正文(失败时为失败原因)
time: string; // 发布时间戳
constructor(title: string, text: string) {
this.title = title; this.text = text; this.time = nowTime();
}
}
NoticeLog 记录通知发布历史,三条字段简洁明了:标题、正文、时间戳。构造函数自动填充时间戳。
与其他列表类似,通知历史也使用 unshift 置顶新增,列表封顶 8 条。但有一个特殊的 UI 规则:标题含"失败"字样时在列表中红色高亮。这是通过 noticeRow 构建函数中的条件判断实现的:
.fontColor(log.title.indexOf('失败') >= 0 ? COLORS.red : COLORS.title)
NOTICE_SEED 预置了 2 条种子数据,展示正常发布的通知样式:
const NOTICE_SEED: NoticeLog[] = [
new NoticeLog('园区巡卫 · 严重告警', '地下车库 B2 喷淋管网压力低于阈值,已派单整改'),
new NoticeLog('园区巡卫 · 一般告警', '3 号楼 12 层疏散指示灯故障 1 处,已更换灯箱')
];
两条种子数据分别展示了"严重"和"一般"两个等级的告警通知,使用户在首次进入告警 Tab 时就能看到发布历史的展示效果,而非空列表。
6.7 数据模型层设计总结
六个 @Observed 实体类构成了平台的数据模型层,每个实体类对应一个特定的业务领域,通过 @State 数组与 UI 层联动。从设计模式的角度看,这是一种典型的"观察者模式"(Observer Pattern)的应用——数据模型是被观察者,UI 组件是观察者,当数据模型的属性发生变化时,所有依赖该属性的 UI 组件会自动更新。
与传统的观察者模式相比,ArkUI 的 @Observed 机制有两个显著优势:其一,声明式——开发者只需用装饰器标记类,无需手动注册和注销观察者,框架自动处理依赖收集和通知分发;其二,细粒度——框架能够精确追踪到具体哪个属性被修改,只更新依赖该属性的 UI 片段,而非整个组件重绘。这种机制使得即使单组件内有 30+ 状态变量和 6 个实体类,UI 刷新依然高效流畅。
七、组件主体结构
7.1 状态变量分层
根组件的状态变量按照职责划分为六个层次,每层变量各司其职,共同构成完整的状态管理体系。这种分层设计使 30+ 个状态变量保持了良好的可维护性——新增状态时只需归入对应层级,查找状态时按层检索即可快速定位。
7.1.1 Tab 状态层
@State currentTab: number = 0;
Tab 状态层只有一个变量 currentTab,控制底部导航的选中态和内容区 7 个 Builder 方法的切换。初始值为 0,对应"任务"Tab 作为首页。这个变量是整个页面导航的核心状态,所有 Tab 切换操作最终都归结为修改 currentTab 的值。
7.1.2 弹窗状态层
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = -1;
@State delIdx: number = -1;
弹窗状态层包含三个布尔标志和两个索引变量。三个布尔标志分别对应新建、编辑、删除三种弹窗的显隐状态,正常情况下同一时间只有一个为 true(由 modalOverlay 的条件渲染保证三选一)。editIdx 和 delIdx 分别记录当前编辑和删除的任务索引,初始值 -1 表示无选中项。
这种"标志位+索引"的弹窗管理模式是声明式 UI 的典型做法。与命令式 UI 中直接调用 dialog.show() 不同,声明式方式通过修改状态变量间接控制弹窗显隐——当 addModal 变为 true 时,框架检测到状态变化,自动在组件树中插入弹窗组件;当 addModal 变为 false 时,框架自动移除弹窗组件。
7.1.3 动画状态层
@State breath: boolean = false;
timer: number = -1;
动画状态层包含一个 @State 变量 breath 和一个普通成员变量 timer。breath 是布尔型的呼吸开关,每秒翻转一次,驱动柱状图的奇偶柱交替放大缩小、头部横幅的呼吸圆点透明度变化等微动效。timer 是 setInterval 的句柄,用于在页面销毁时清理定时器,防止内存泄漏。
值得注意的是 timer 没有用 @State 装饰——因为它只是一个资源句柄,不参与 UI 渲染,框架不需要追踪它的变化。这体现了状态管理的基本原则:只有影响 UI 渲染的数据才需要用 @State 装饰,纯内部使用的资源句柄应使用普通成员变量。
7.1.4 任务数据与表单层
@State taskList: TaskItem[] = TASK_LIST;
@State formBuilding: string = '';
@State formItem: string = '';
@State formProgress: number = 0;
@State editProgress: number = 0;
任务数据与表单层包含任务列表数据和两组表单字段。taskList 是核心数据数组,驱动任务 Tab 的进度条清单和完成率统计。formBuilding、formItem、formProgress 是新建弹窗的表单字段,editProgress 是编辑弹窗的进度字段。
表单字段与数据列表分离的设计有两个好处:其一,表单输入过程中不会影响列表数据,用户可以随时取消而不产生副作用;其二,新建和编辑各自维护独立的表单状态,互不干扰。
7.1.5 Camera Kit 成员层
private previewController: XComponentController = new XComponentController();
private cameraInput?: camera.CameraInput;
private previewOutput?: camera.PreviewOutput;
private videoSession?: camera.VideoSession;
private photoSession?: camera.PhotoSession;
@State surfaceReady: boolean = false;
@State sessionMode: string = 'idle';
@State framingState: string = '未查询';
@State framingSupported: boolean = false;
@State focusSupported: boolean = false;
@State focusDistance: number = 1.0;
@State focusRecords: FocusRecord[] = [];
@State permState: string = '未申请';
Camera Kit 成员层是最复杂的一层,包含 5 个 private 控制器对象和 8 个 @State 响应式状态。
控制器对象(private,不参与渲染):
previewController:XComponent 控制器,用于获取 Surface ID 和控制 XComponent 的生命周期cameraInput:相机输入对象,代表物理相机设备previewOutput:预览输出对象,将相机画面输出到 XComponent SurfacevideoSession:视频会话对象,影随人动能力的宿主photoSession:拍照会话对象,手动对焦能力的宿主
这五个对象都使用 private 修饰且不参与 UI 渲染,它们持有原生相机资源的句柄,需要在生命周期中手动管理创建与释放。使用可选链(?)声明是因为它们在页面初始时不存在,需要在用户启动相机时动态创建。
响应式状态(@State,驱动 UI 渲染):
surfaceReady:XComponent Surface 是否已就绪,是启动相机的前置条件sessionMode:当前会话模式(idle/video/photo),用于显示会话状态和控制按钮可用性framingState:影随人动能力链的状态文案,是能力链执行结果的用户可见表达framingSupported:本机是否声明 AUTO_FRAMING 能力,用于枚举表中的"已声明"徽章focusSupported:是否支持设置对焦距离,手动对焦功能的可用性标志focusDistance:当前对焦距离值(0.0~1.0),驱动 Slider 和预设档位高亮focusRecords:对焦记录数组,驱动对焦记录时间线permState:CAMERA 权限申请状态文案
7.1.6 Notification Kit 成员层
@State granted: boolean = false;
@State notifyId: number = 200;
@State ringList: RingItem[] = RING_LIST;
@State currentRingIdx: number = 0;
@State noticeLogs: NoticeLog[] = NOTICE_SEED;
@State alarmTitle: string = '';
@State alarmLevel: string = '一般';
@State alarmDesc: string = '';
Notification Kit 成员层包含 8 个状态变量,覆盖通知授权、铃声管理、发布历史和上报表单四大功能块。
granted:通知授权状态,是发布通知的前置条件notifyId:通知 ID 自增计数器,从 200 开始递增,避免与系统通知 ID 冲突ringList:铃声库数组,驱动告警 Tab 的铃声列表显示currentRingIdx:当前默认铃声索引,决定发布通知时使用哪个铃声noticeLogs:发布历史数组,驱动通知历史时间线alarmTitle:上报表单的标题字段alarmLevel:上报表单的告警等级字段,默认为"一般"alarmDesc:上报表单的描述字段
7.1.7 嵌套滚动成员层
@State nestedMode: TabsNestedScrollMode = TabsNestedScrollMode.SELF_FIRST;
@State outerIndex: number = 0;
@State innerIndex: number = 0;
@State swipeLogs: SwipeLog[] = [];
嵌套滚动成员层包含 4 个状态变量,管理双层 Tabs 的嵌套滚动行为。
nestedMode:嵌套滚动模式,默认为SELF_FIRST(先内后外),用户可通过频道 Tab 的模式切换 chips 更改outerIndex:外层楼宇频道当前索引,默认为 0(1 号楼)innerIndex:内层检查项当前索引,默认为 0(消防)swipeLogs:翻页日志数组,驱动日志 Tab 的时间线显示
7.2 生命周期方法
7.2.1 aboutToAppear 初始化
aboutToAppear() {
notificationManager.isNotificationEnabled().then((enabled: boolean) => {
this.granted = enabled;
}).catch(() => {});
this.focusRecords.unshift(new FocusRecord(0.9, 0.9, '已生效'));
this.focusRecords.unshift(new FocusRecord(0.5, 0.51, '已生效'));
this.focusRecords.unshift(new FocusRecord(0.1, 0.12, '读回偏差'));
this.timer = setInterval(() => {
this.breath = !this.breath;
}, 1000);
}
aboutToAppear 是组件即将显示时的生命周期回调,执行三项初始化工作:
第一项:查询通知授权状态。 调用 notificationManager.isNotificationEnabled() 异步查询通知是否已授权,结果存入 granted 状态变量。使用 .catch(() => {}) 静默吞掉异常——授权查询失败时默认视为未授权,不影响其他功能。这是一个典型的"尽力而为"初始化:能查到最好,查不到也不阻断页面加载。
第二项:预置对焦记录种子。 向 focusRecords 数组中 unshift 三条种子记录,分别演示已生效(差值0)、已生效(差值0.01临界)、读回偏差(差值0.02超标)三种校验结果。使用 unshift 而非 push 是因为记录列表是"最新在前"的倒序排列。
第三项:启动呼吸动画定时器。 调用 setInterval 每秒翻转一次 breath 布尔值,驱动柱状图呼吸微动效和头部圆点呼吸效果。定时器句柄存入 timer 变量,便于后续清理。
这三项初始化的顺序也经过了考量:异步的授权查询放在最前面(先发起请求,后续操作不等待结果),同步的种子数据填充放在中间,定时器启动放在最后(确保其他初始化完成后再开始动画)。
7.2.2 aboutToDisappear 清理
aboutToDisappear() {
clearInterval(this.timer);
this.releaseSession();
}
aboutToDisappear 是组件即将销毁时的生命周期回调,执行两项清理工作:
第一项:停止呼吸动画定时器。 调用 clearInterval(this.timer) 清除定时器,防止页面销毁后定时器继续执行导致内存泄漏。这是 ArkUI 开发中的最佳实践——所有使用 setInterval 或 setTimeout 创建的定时器都必须在 aboutToDisappear 中清理。
第二项:释放相机资源。 调用 releaseSession() 方法释放相机资源,防止应用退到后台后相机仍被占用。这一点非常重要——Camera Kit 是系统级资源,如果应用退到后台后不释放相机,不仅会导致本应用下次启动相机失败,还可能影响其他应用的相机使用。
7.3 Tab 切换与会话互斥
switchTab(idx: number) {
if ((this.currentTab === 1 || this.currentTab === 2) && idx !== 1 && idx !== 2) { this.releaseSession(); }
this.currentTab = idx;
}
switchTab 方法处理 Tab 切换逻辑,核心是实现相机 Tab(Tab1 相机、Tab2 对焦)与其他 Tab 之间的会话互斥。
判断逻辑:如果当前 Tab 是相机(1)或对焦(2),且目标 Tab 不是相机也不是对焦,则释放相机会话。这意味着:
- 从相机 Tab 切到任务/频道/日志/告警/我的 Tab → 释放会话
- 从对焦 Tab 切到任务/频道/日志/告警/我的 Tab → 释放会话
- 从相机 Tab 切到对焦 Tab → 不释放(后续由对焦模式切换逻辑处理)
- 从对焦 Tab 切到相机 Tab → 不释放(后续由视频模式切换逻辑处理)
- 其他 Tab 之间切换 → 不释放(本来就没占用相机)
这种设计的原因是 Camera Kit 的架构限制:cameraInput 同一时间只能绑定一个 session。如果从相机 Tab 直接切到其他 Tab 而不释放会话,相机会持续占用资源,不仅消耗电量,还可能导致后续重新进入相机 Tab 时会话创建失败。
switchTab 方法体现了"资源惰性管理"的设计思想——只在真正需要时才创建资源,不再需要时立即释放。相机作为高功耗的系统级资源,尤其需要精细化的生命周期管理。
7.4 根构建方法
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.headerBanner()
Divider().strokeWidth(1).color(COLORS.line)
Column() {
if (this.currentTab === 0) {
this.tabTask()
} else if (this.currentTab === 1) {
this.tabCamera()
} else if (this.currentTab === 2) {
this.tabFocus()
} else if (this.currentTab === 3) {
this.tabChannel()
} else if (this.currentTab === 4) {
this.tabLogs()
} else if (this.currentTab === 5) {
this.tabAlarm()
} else {
this.tabMine()
}
}.layoutWeight(1).width('100%')
this.tabBar()
}.width('100%').height('100%')
if (this.addModal || this.editModal || this.delModal) {
this.modalOverlay(() => { this.closeAllModals(); })
}
}.width('100%').height('100%').backgroundColor(COLORS.bg)
}
根构建方法是整个页面的 UI 入口,采用 Stack 容器实现层叠布局。结构分为两层:
底层:页面主体(Column 纵向布局)
- 头部渐变横幅(
headerBanner) - 1px 分割线
- 内容区(
layoutWeight(1)占满剩余高度)- 通过
if-else链根据currentTab选择对应的 Tab Builder 方法
- 通过
- 底部 Tab 导航栏(
tabBar)
顶层:弹窗遮罩(条件渲染)
- 当三个弹窗标志中任意一个为 true 时,渲染
modalOverlay全屏遮罩 - 遮罩覆盖在整个页面之上,实现模态弹窗效果
使用 Stack 而非在 Column 内嵌套弹窗的原因有两个:其一,Stack 的层叠特性天然适合弹窗这种"浮在内容之上"的场景;其二,弹窗需要全屏遮罩,包括覆盖头部横幅和底部 Tab 栏,如果放在 Column 内部就只能覆盖内容区,无法实现全屏遮罩效果。
内容区的 Tab 切换使用 if-else 链而非 Tabs 组件,这是一个经过权衡的设计决策。使用系统 Tabs 组件的优势是有内置的滑动切换动画和 Tab 栏联动,但劣势是 Tabs 的 onChange 事件与嵌套 Tabs 的 onChange 可能产生事件冒泡和管理混乱。而使用 if-else + 自绘底部 Tab 栏的方式虽然牺牲了滑动切换动画,但获得了完全的控制权——Tab 切换时机、切换时的副作用(如释放相机会话)、选中态样式都可以精确控制。对于一个以功能演示为核心目标的技术展示平台来说,可控性比滑动动画更重要。
八、Camera Kit 方法群详解
8.1 权限申请
async requestCameraPermission(): Promise<boolean> {
try {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined || ctx === null) { this.permState = '上下文未就绪'; return false; }
const atManager = abilityAccessCtrl.createAtManager();
const result = await atManager.requestPermissionsFromUser(ctx, ['ohos.permission.CAMERA']);
const granted = result.authResults.length > 0 && result.authResults[0] === 0;
this.permState = granted ? '已授权' : '权限被拒';
return granted;
} catch (e) {
const err = e as BusinessError;
this.permState = `申请失败(${err.code})`;
console.error(`permission failed: ${err.message}`);
return false;
}
}
requestCameraPermission 方法负责动态申请 CAMERA 权限,是启动相机的前置步骤。这是一个异步方法,返回 Promise<boolean>,调用方通过 await 等待授权结果。
方法的执行流程如下:
第一步:获取宿主上下文。 调用 this.getUIContext().getHostContext() 获取 UIAbility 上下文。这里有两个关键点:
- 使用
getUIContext().getHostContext()而非已废弃的getContext(this),这是 HarmonyOS 新版本的推荐写法 - 对返回值进行判空兜底,如果上下文未就绪(如组件尚未完全挂载),设置状态为"上下文未就绪"并返回 false
第二步:创建权限管理器并发起申请。 调用 abilityAccessCtrl.createAtManager() 创建权限管理器,然后调用 requestPermissionsFromUser(ctx, ['ohos.permission.CAMERA']) 动态申请相机权限。ohos.permission.CAMERA 是 user_grant 级权限,需要用户手动授权。
第三步:解析授权结果。 result.authResults 是一个数组,每个元素对应一个权限的授权结果(0 表示已授权,其他值表示未授权)。方法检查第一个元素是否为 0,设置 permState 状态文案,并返回授权结果。
异常处理: 如果整个过程抛出异常(如权限申请框架错误),捕获 BusinessError,记录错误码到 permState,打印错误日志,返回 false。
这种"多状态文案"的权限管理方式比简单的"已授权/未授权"二值状态更具信息量——用户可以看到是"权限被拒"“申请失败"还是"上下文未就绪”,从而采取不同的应对措施。
8.2 影随人动模式
8.2.1 startVideoMode 启动视频会话
async startVideoMode() {
if (!this.surfaceReady) { this.framingState = 'Surface 未就绪'; return; }
if (this.sessionMode === 'video') { return; }
if (this.sessionMode === 'photo') { await this.releaseSession(); }
const granted = await this.requestCameraPermission();
if (!granted) { this.framingState = '权限被拒'; return; }
try {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined || ctx === null) { this.framingState = '上下文未就绪'; return; }
const manager = camera.getCameraManager(ctx);
let device: camera.CameraDevice | undefined = undefined;
for (const d of manager.getSupportedCameras()) {
if (d.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK) {
device = d; break;
}
}
if (device === undefined) { this.framingState = '未发现后摄'; return; }
const capability = manager.getSupportedOutputCapability(device, camera.SceneMode.NORMAL_VIDEO);
const profile = capability.previewProfiles.length > 0
? capability.previewProfiles[0] : undefined;
if (profile === undefined) { this.framingState = '无预览Profile'; return; }
this.cameraInput = manager.createCameraInput(device);
await this.cameraInput.open();
this.previewOutput = manager.createPreviewOutput(profile,
this.previewController.getXComponentSurfaceId());
this.videoSession = manager.createSession<camera.VideoSession>(camera.SceneMode.NORMAL_VIDEO);
this.videoSession.on('error', (err: BusinessError) => {
console.error(`session error: ${err.code}`);
});
this.videoSession.beginConfig();
this.videoSession.addInput(this.cameraInput);
this.videoSession.addOutput(this.previewOutput);
await this.videoSession.commitConfig();
this.queryFraming(this.videoSession);
await this.videoSession.start();
this.sessionMode = 'video';
} catch (e) {
const err = e as BusinessError;
this.framingState = `会话失败(${err.code})`;
await this.releaseSession();
}
}
startVideoMode 是影随人动能力链的入口方法,负责创建 VideoSession 并启动影随人动功能。这是一个相当复杂的异步方法,包含多重前置检查、资源创建和能力查询。
前置检查(防御式编程):
surfaceReady检查:XComponent Surface 必须就绪,否则无法绑定预览输出- 重复调用检查:如果已经是 video 模式,直接返回避免重复初始化
- 模式互斥检查:如果当前是 photo 模式,先释放 PhotoSession 再创建 VideoSession
- 权限检查:申请相机权限,未授权则不继续
相机设备选择:
遍历 manager.getSupportedCameras() 返回的所有相机设备,选择后置摄像头(CAMERA_POSITION_BACK)作为巡检取证主镜头。选择后摄而非前摄是因为巡检场景需要拍摄前方的设备和环境,而非自拍。
能力查询:
调用 getSupportedOutputCapability(device, NORMAL_VIDEO) 获取视频场景的输出能力,从中取第一个预览 Profile。如果设备不支持视频预览或没有预览 Profile,则无法继续。
会话配置流程(标准五步):
- 创建
CameraInput并open()打开相机设备 - 创建
PreviewOutput并绑定 XComponent 的 Surface ID - 创建
VideoSession,注册 error 事件监听 beginConfig()→addInput()→addOutput()→commitConfig()完成会话配置- 调用
queryFraming执行影随人动能力链查询 start()启动会话,设置sessionMode = 'video'
异常处理:
如果任何步骤抛出异常,捕获 BusinessError,设置失败状态文案,调用 releaseSession() 清理已创建的资源,避免资源泄漏。
整个方法体现了 Camera Kit 的标准使用流程:先获取设备、再创建输入输出、然后配置会话、最后启动会话。每一步都有对应的失败处理和状态反馈,使用户能够清楚地知道当前进展和失败原因。
8.2.2 queryFraming 影随人动能力链
queryFraming(session: camera.VideoSession) {
if (!session.isControlCenterSupported()) {
this.framingState = '控制中心不支持';
this.framingSupported = false;
return;
}
const effects = session.getSupportedEffectTypes();
this.framingSupported = effects.includes(camera.ControlCenterEffectType.AUTO_FRAMING);
if (!this.framingSupported) { this.framingState = 'AUTO_FRAMING 未声明'; return; }
try {
session.enableControlCenter(true);
this.framingState = '影随人动已启用';
} catch (e) {
this.framingState = `接管失败(${(e as BusinessError).code})`;
}
}
queryFraming 方法实现了影随人动三步能力链,这是 HarmonyOS 6.1.1 的核心新特性之一。三步层层递进,每一步失败都会终止后续步骤并设置相应的状态文案。
第一步:查询控制中心是否可用。
调用 session.isControlCenterSupported() 查询当前设备和会话是否支持控制中心。控制中心(ControlCenter)是 Camera Kit 提供的一个统一能力入口,美颜、人像、影随人动等效果都通过控制中心来管理。如果控制中心不支持,后续的效果类型查询和启用都无从谈起,直接设置状态为"控制中心不支持"并返回。
第二步:查询支持的效果类型。
调用 session.getSupportedEffectTypes() 获取当前设备支持的所有控制中心效果类型列表。然后检查列表中是否包含 AUTO_FRAMING(影随人动)。如果设备未声明该能力,设置状态为"AUTO_FRAMING 未声明"并返回。
这一步的设计非常重要——它区分了"控制中心不支持"和"控制中心支持但不支持影随人动"两种情况。前者是硬件层面的限制(设备较老,没有控制中心),后者是功能层面的差异(设备有控制中心,但影随人动是 6.1.1 新增能力,旧版本可能没有)。
第三步:启用控制中心。
如果前两步都通过,调用 session.enableControlCenter(true) 请求系统启用控制中心,让影随人动生效。启用成功后设置状态为"影随人动已启用";如果启用过程中抛出异常(如权限不足、资源冲突等),捕获异常并设置状态为"接管失败(错误码)"。
三步能力链的设计体现了 HarmonyOS 能力查询的最佳实践:先查能力是否存在、再查具体能力项、最后尝试启用。这种渐进式查询可以提供精确的失败原因诊断,而不是笼统地告诉用户"功能不可用"。
8.3 手动对焦模式
8.3.1 switchToPhotoMode 切换拍照会话
async switchToPhotoMode() {
if (this.sessionMode === 'photo') { return; }
if (this.sessionMode === 'video') { await this.releaseSession(); }
if (!this.surfaceReady) { this.framingState = 'Surface 未就绪'; return; }
const granted = await this.requestCameraPermission();
if (!granted) { this.framingState = '权限被拒'; return; }
try {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined || ctx === null) { this.framingState = '上下文未就绪'; return; }
const manager = camera.getCameraManager(ctx);
let device: camera.CameraDevice | undefined = undefined;
for (const d of manager.getSupportedCameras()) {
if (d.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK) {
device = d; break;
}
}
if (device === undefined) { this.framingState = '未发现后摄'; return; }
const capability = manager.getSupportedOutputCapability(device, camera.SceneMode.NORMAL_PHOTO);
const profile = capability.previewProfiles.length > 0
? capability.previewProfiles[0] : undefined;
if (profile === undefined) { this.framingState = '无预览Profile'; return; }
this.cameraInput = manager.createCameraInput(device);
await this.cameraInput.open();
this.previewOutput = manager.createPreviewOutput(profile,
this.previewController.getXComponentSurfaceId());
this.photoSession = manager.createSession<camera.PhotoSession>(camera.SceneMode.NORMAL_PHOTO);
this.photoSession.on('error', (err: BusinessError) => {
console.error(`session error: ${err.code}`);
});
this.photoSession.beginConfig();
this.photoSession.addInput(this.cameraInput);
this.photoSession.addOutput(this.previewOutput);
await this.photoSession.commitConfig();
await this.photoSession.start();
this.sessionMode = 'photo';
this.queryFocusSupport();
} catch (e) {
const err = e as BusinessError;
this.framingState = `拍照会话失败(${err.code})`;
await this.releaseSession();
}
}
switchToPhotoMode 方法负责从其他模式切换到 PhotoSession 模式,是手动对焦功能的宿主。整体流程与 startVideoMode 非常相似,区别在于:
- 使用
NORMAL_PHOTO场景而非NORMAL_VIDEO - 创建
PhotoSession而非VideoSession - 会话启动后调用
queryFocusSupport()查询手动对焦能力而非影随人动能力
这种"视频/拍照双模式"的设计反映了 Camera Kit 的架构——不同的场景模式(SceneMode)对应不同类型的会话,每种会话支持不同的能力集。VideoSession 支持视频录制和控制中心效果(影随人动),PhotoSession 支持拍照和手动对焦控制。
8.3.2 queryFocusSupport 对焦能力查询
queryFocusSupport() {
if (this.photoSession === undefined) { this.focusSupported = false; return; }
try {
this.focusSupported = this.photoSession.isFocusDistanceSupported();
} catch (e) {
this.focusSupported = false;
}
}
queryFocusSupport 方法调用 photoSession.isFocusDistanceSupported() 查询当前设备是否支持设置对焦距离。这是一个同步方法,直接返回布尔值。
异常处理中,如果调用抛出异常(如 7400102 会话状态错误、7400103 会话不存在等),降级为"不支持"。这种"异常即不支持"的降级策略保证了即使在异常情况下 UI 也能正常显示——只是手动对焦功能不可用而已,不会导致整个页面崩溃。
8.3.3 applyFocus 设置与读回验证
applyFocus() {
if (this.photoSession === undefined) {
this.focusRecords.unshift(new FocusRecord(this.focusDistance, -1, '失败(无会话)'));
if (this.focusRecords.length > 20) { this.focusRecords.pop(); }
return;
}
try {
this.photoSession.setFocusDistance(this.focusDistance);
const readBack = this.photoSession.getFocusDistance();
const ok = Math.abs(readBack - this.focusDistance) < 0.01 ? '已生效' : '读回偏差';
this.focusRecords.unshift(new FocusRecord(this.focusDistance, readBack, ok));
if (this.focusRecords.length > 20) { this.focusRecords.pop(); }
} catch (e) {
const err = e as BusinessError;
this.focusRecords.unshift(new FocusRecord(this.focusDistance, -1, `失败(${err.code})`));
if (this.focusRecords.length > 20) { this.focusRecords.pop(); }
}
}
applyFocus 方法是手动对焦三接口的核心演示,实现了"设置 → 读回 → 校验"的完整流程。
执行流程:
- 前置检查:如果
photoSession不存在,记录一条失败记录并返回 - 设置对焦距离:调用
setFocusDistance(this.focusDistance)将当前滑杆值设置为对焦距离 - 读回对焦距离:调用
getFocusDistance()立即读回实际的对焦距离值 - 校验比较:计算设置值与读回值的绝对差值,小于 0.01 判为"已生效",否则判为"读回偏差"
- 记录日志:创建一条
FocusRecord记录操作结果,unshift置顶到记录列表 - 列表裁剪:如果记录数超过 20 条,
pop移除最旧的一条
为什么需要读回校验? 这是一个很有价值的设计。在实际的硬件操作中,设置值不一定能完全生效——可能因为硬件精度限制、驱动缓存、或其他系统层面的原因,实际对焦距离与设置值存在微小偏差。通过"设置+读回"的闭环验证,用户可以确切知道对焦是否真的生效了,而不是仅仅"以为设置成功了"。
0.01 阈值的意义。 选择 0.01 作为判定阈值经过了考量:它足够小(占全量程的 1%),保证对焦精度;又不至于过于严苛,允许硬件有微小的公差。如果阈值设得太小(如 0.001),大部分设备可能都达不到,导致"读回偏差"成为常态,反而失去了校验的意义。
8.4 会话释放
async releaseSession() {
const session = this.videoSession ?? this.photoSession;
const preview = this.previewOutput;
const input = this.cameraInput;
this.videoSession = undefined;
this.photoSession = undefined;
this.previewOutput = undefined;
this.cameraInput = undefined;
try {
if (session !== undefined) { session.off('error'); await session.stop(); await session.release(); }
if (preview !== undefined) { await preview.release(); }
if (input !== undefined) { await input.close(); }
this.sessionMode = 'idle';
} catch (e) {
console.error(`release failed: ${(e as BusinessError).message}`);
}
}
releaseSession 方法实现了五步释放链,是相机资源管理的关键方法。
先暂存后置空的设计模式:
方法开头先将四个引用(session、previewOutput、cameraInput)暂存到局部变量,然后立即将成员变量置为 undefined。这种"先置空再释放"的写法有两个重要好处:
- UI 状态即时更新:成员变量置空后,依赖这些变量的 UI 会立即响应(如会话模式标签变为 idle),用户能立刻看到"正在停止"的反馈
- 避免重复释放:如果在释放过程中用户再次点击停止按钮,由于成员变量已经是 undefined,不会导致重复释放
五步释放链:
session.off('error'):移除 error 事件监听,防止释放过程中触发错误回调session.stop():停止会话(停止预览/录制)session.release():释放会话资源previewOutput.release():释放预览输出cameraInput.close():关闭相机输入
释放顺序遵循"后创建的先释放"原则——会话先停止释放,然后是输出,最后是输入。这与创建顺序(input → output → session)相反,是资源管理的通用最佳实践。
异常处理:
释放过程中的异常只打印日志不抛出,因为释放通常是在清理场景下调用的,即使某一步释放失败,也不应该影响整体流程。这是"尽力释放"的策略——能释放多少算多少,不因为局部失败导致整个清理流程中断。
九、Notification Kit 方法群详解
9.1 通知授权
requestAuth() {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) { return; }
notificationManager.requestEnableNotification(hostCtx).then(() => {
this.granted = true;
}).catch((err: BusinessError) => {
notificationManager.openNotificationSettings(hostCtx).then(() => {
}).catch(() => { this.granted = false; });
});
}
requestAuth 方法负责请求通知授权,处理了首次授权和拒绝后引导两种场景。
首次授权:
调用 notificationManager.requestEnableNotification(hostCtx) 请求通知授权。注意这里传入了 hostCtx 参数——无参版本已废弃,必须传入上下文。如果用户同意授权,then 回调中将 granted 设为 true。
拒绝后引导:
如果用户曾拒绝过授权,requestEnableNotification 会返回错误码 1600004(用户已拒绝)。此时调用 notificationManager.openNotificationSettings(hostCtx) 拉起系统通知设置页,引导用户手动开启通知。这是 HarmonyOS 通知授权的标准流程——首次弹系统授权框,被拒后引导用户去设置页开启。
上下文获取:
使用 this.getUIContext().getHostContext() 获取宿主上下文,并强制转换为 common.UIAbilityContext 类型。这是因为 getHostContext() 返回的是通用 Context 类型,而通知管理的方法需要 UIAbilityContext 类型。使用前进行判空,确保上下文可用时才继续操作。
9.2 沙箱铃声写入
9.2.1 saveRingToSandbox 写入沙箱文件
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;
const dir = appCtx.filesDir;
const path = dir + '/' + 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) {
}
return path;
}
saveRingToSandbox 方法将生成的 WAV 音频写入 EL1 沙箱 filesDir 目录,是自定义铃声链路的关键一环。
沙箱路径构建:
- 获取宿主上下文和应用上下文
- 设置
appCtx.area = contextConstant.AreaMode.EL1,指定在 EL1 加密等级区域操作 - 获取
appCtx.filesDir,即 EL1 区域的 files 目录路径 - 拼接文件名得到完整的沙箱路径
为什么必须是 EL1? 这是因为通知自定义铃声的 sound 字段只支持从 EL1 沙箱读取。EL1 是 HarmonyOS 的一级加密区域,应用的私有数据默认存储在 EL1 区域,保证了数据的安全性。
文件写入流程:
- 调用
buildWavBytes(freq, durationMs)生成正弦波 WAV 字节流 - 调用
fs.openSync(path, mode)以同步方式打开文件 - 打开模式使用位或组合:
CREATE(不存在则创建)|WRITE_ONLY(只写)|TRUNC(清空原有内容) - 调用
fs.writeSync(file.fd, data)写入音频数据 - 调用
fs.closeSync(file)关闭文件描述符
异常处理: 写入失败时静默吞掉异常,返回空路径。这是一种降级策略——如果铃声写入失败,发布通知时会回退到系统默认铃声,不影响通知的基本功能。
9.2.2 importRing 导入铃声
importRing(idx: number) {
if (idx < 0 || idx >= this.ringList.length) { return; }
const ring = this.ringList[idx];
const path = this.saveRingToSandbox(ring.file, ring.freq, ring.duration);
if (path === '') {
return;
}
if (!ring.inSandbox) {
ring.inSandbox = true;
}
ring.size = wavSizeText(ring.duration);
this.ringList = this.ringList.slice();
}
importRing 方法封装了铃声导入沙箱的完整流程,包括写入文件、更新状态和触发 UI 刷新。
流程解析:
- 边界检查:索引越界则直接返回
- 调用
saveRingToSandbox写入沙箱,获取沙箱路径 - 如果写入失败(路径为空),直接返回
- 更新
inSandbox为 true(如果尚未标记) - 计算并更新文件大小展示文案
this.ringList = this.ringList.slice()触发数组引用变化
最后一行的奥秘。 this.ringList = this.ringList.slice() 这行代码看起来是多余的——我们明明已经修改了 ring.inSandbox 和 ring.size,为什么还要重新赋值数组?
答案在于 @Observed 的工作机制和数组更新的微妙差异。虽然 @Observed 装饰的类实例属性变化可以被检测到,但在某些情况下(如属性是基本类型且数组较大),通过 slice() 创建新数组并重新赋值可以更可靠地触发 UI 刷新。slice() 不带参数时返回数组的浅拷贝,这是一种常用的"强制刷新"技巧——通过改变数组引用来确保框架检测到变化。
9.3 通知发布
9.3.1 publishNotice 发布通知
publishNotice(title: string, text: string) {
if (this.currentRingIdx < 0 || this.currentRingIdx >= this.ringList.length) {
this.addNoticeLog('发布失败', '铃声库为空,请先生成告警铃声');
return;
}
const ring = this.ringList[this.currentRingIdx];
if (!ring.inSandbox) {
this.importRing(this.currentRingIdx);
}
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
this.addNoticeLog(title, '宿主上下文获取失败,通知未发布');
return;
}
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1;
const sandboxPath = appCtx.filesDir + '/' + ring.file;
const uri = fileUri.getUriFromPath(sandboxPath);
const soundVal = 'uri::' + uri;
const request: notificationManager.NotificationRequest = {
id: this.notifyId++,
notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: title,
text: text,
additionalText: '铃声:' + ring.name
}
},
sound: soundVal
};
notificationManager.publish(request).then(() => {
this.addNoticeLog(title, text);
}).catch((err: BusinessError) => {
this.addNoticeLog('发布失败', `错误码 ${err.code},请先开启通知授权`);
});
}
publishNotice 是告警通知发布的核心方法,实现了沙箱自定义铃声的完整链路。这是整个 Notification Kit 特性的集大成者。
第一步:铃声校验与自动导入。
检查当前默认铃声索引是否有效,如果铃声库为空则记录失败日志。检查铃声是否已导入沙箱,如果未导入则自动调用 importRing 导入。这种"按需导入"的策略避免了应用启动时批量生成铃声的开销。
第二步:构建沙箱路径与 URI。
这是最关键的一步,也是 6.1.1 新特性的核心:
- 获取宿主上下文和应用上下文
- 设置 EL1 区域
- 拼接沙箱文件路径
- 调用
fileUri.getUriFromPath(sandboxPath)将文件路径转换为 URI - 拼接
'uri::' + uri得到sound字段的值
sound 字段支持 'uri::' + 沙箱URI 的格式是 HarmonyOS 6.1.1 的重要更新。在此之前,sound 字段只能传 rawfile 文件名,意味着铃声必须预先打包在应用的 rawfile 目录中,无法动态生成。而新特性允许使用沙箱中动态生成的音频文件作为通知铃声,大大扩展了自定义铃声的可能性。
第三步:构建 NotificationRequest。
id:通知 ID,通过notifyId++自增管理,从 200 开始避免与系统通知 ID 冲突notificationSlotType:通知渠道类型,使用SOCIAL_COMMUNICATION(社交通信类)content:通知内容,使用基础文本类型(NOTIFICATION_CONTENT_BASIC_TEXT)normal.title:通知标题normal.text:通知正文normal.additionalText:附加文本,显示当前使用的铃声名
sound:自定义铃声 URI,使用前面构建的soundVal
第四步:发布通知。
调用 notificationManager.publish(request) 发布通知。成功则记录一条发布历史,失败则记录错误信息。最常见的失败原因是 1600004(未授权),此时引导用户去授权卡开启通知权限。
9.3.2 submitAlarm 上报表单提交
submitAlarm() {
if (this.alarmTitle.trim() === '') {
this.addNoticeLog('发布失败', '告警标题不能为空,请填写异常点位标题');
return;
}
const text = this.alarmDesc.trim() === ''
? `巡检中发现${this.alarmLevel}级异常,请责任人尽快到场处置`
: this.alarmDesc.trim();
this.publishNotice(`园区巡卫 · ${this.alarmLevel}告警`, this.alarmTitle.trim() + ':' + text);
this.alarmTitle = '';
this.alarmDesc = '';
}
submitAlarm 方法是告警上报表单的提交入口,负责将表单数据组装后调用 publishNotice 发布通知。
表单校验: 检查标题是否为空,如果为空则记录失败日志,不进行发布。这是基本的输入校验,防止发布空标题的无意义通知。
正文组装: 如果描述为空,使用默认模板"巡检中发现X级异常,请责任人尽快到场处置";如果有描述,使用用户输入的描述。这种"用户输入优先、默认模板兜底"的策略兼顾了灵活性和便捷性。
发布后清理: 发布成功后清空标题和描述字段,为下一次上报做准备。注意这里不检查发布是否成功——无论成功失败都清空表单,因为发布结果已经通过 addNoticeLog 记录到历史中了,用户可以在发布历史里查看。
9.3.3 getSoundValue sound 字段预览
getSoundValue(): string {
if (this.currentRingIdx < 0 || this.currentRingIdx >= this.ringList.length) {
return "sound: ''(尚未设定铃声)";
}
const ring = this.ringList[this.currentRingIdx];
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return "sound: ''(上下文不可用)";
}
const appCtx = hostCtx.getApplicationContext();
const path = appCtx.filesDir + '/' + ring.file;
return "sound: 'uri::" + fileUri.getUriFromPath(path) + "'";
}
getSoundValue 方法返回当前通知请求的 sound 字段实时值,用于在告警 Tab 中展示 sound 字段的预览。这个方法的逻辑与 publishNotice 中的 sound 构建逻辑完全一致,只是返回的是格式化的展示字符串而非直接用于发布。
这种"实时预览"的设计非常有教学价值——用户可以在发布通知之前就看到 sound 字段的实际值,理解 'uri::' + fileUri.getUriFromPath(沙箱路径) 这一关键链路的具体形态。对于学习 Notification Kit 新特性的开发者来说,能直观看到 sound 字段的格式比阅读文档更有帮助。
十、头部渐变横幅详解
@Builder
headerBanner() {
Column({ space: 10 }) {
Row() {
Column({ space: 4 }) {
Text('园区巡卫 · 物业安全巡检').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor(COLORS.onMain)
Text(this.currentTab === 0 ? `任务 · 今日 ${this.taskList.length} 项巡检`
: this.currentTab === 1 ? '相机 · 影随人动取证预览'
: this.currentTab === 2 ? '对焦 · 手动对焦三接口'
: this.currentTab === 3 ? '频道 · 楼宇×检查项双层 Tabs'
: this.currentTab === 4 ? '日志 · nestedScroll 时间线'
: this.currentTab === 5 ? '告警 · 沙箱自定义铃声'
: '巡检员中心').fontSize(11).fontColor(COLORS.onMain).opacity(0.85)
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Circle({ width: 10, height: 10 })
.fill(COLORS.onMain)
.opacity(this.breath ? 0.9 : 0.45)
}.width('100%')
Row({ space: 8 }) {
Row({ space: 6 }) {
Text('📋').fontSize(10)
Text(`巡检 ${this.taskList.length} 项`).fontSize(10).fontColor(COLORS.sub)
}.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(12).backgroundColor(COLORS.mask)
Row({ space: 4 }) {
Circle({ width: 6, height: 6 })
.fill(this.sessionMode === 'idle' ? COLORS.text3
: this.sessionMode === 'video' ? COLORS.green : COLORS.orange)
Text(`相机 ${this.sessionMode}`).fontSize(10).fontColor(COLORS.sub)
}.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(12).backgroundColor(COLORS.mask)
Row({ space: 4 }) {
Circle({ width: 6, height: 6 }).fill(this.granted ? COLORS.green : COLORS.red)
Text(this.granted ? '通知已授权' : '通知未授权').fontSize(10).fontColor(COLORS.sub)
}.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(12).backgroundColor(COLORS.mask).layoutWeight(1)
Row({ space: 4 }) {
Circle({ width: 6, height: 6 })
.fill(this.nestedMode === TabsNestedScrollMode.SELF_FIRST ? COLORS.blue : COLORS.orange)
Text(`嵌套 ${modeShort(this.nestedMode)}`).fontSize(10).fontColor(COLORS.sub)
}.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(12).backgroundColor(COLORS.mask)
}.width('100%')
}.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.width('100%')
.linearGradient({
angle: 160,
colors: [[COLORS.orangeD, 0], [COLORS.bg, 1]]
})
}
headerBanner 是全页面的视觉锚点,承载了应用标识、Tab 副标题、三特性状态胶囊和呼吸动效等多重信息。
10.1 渐变背景设计
横幅使用 160 度 linearGradient 从 orangeD(深橙 #E06A10)渐变到 bg(蓝灰黑 #14171C)。160 度的角度意味着渐变方向是从左上偏上位置向右下偏下位置扩散,模拟安全警示灯由近及远的衰减效果——左上角最亮(警示感最强),向右下逐渐融入页面背景色,过渡自然不突兀。
所有文字使用 onMain(深暖黑 #231507)确保橙底可读性。这个颜色是专门为橙底文字设计的,微暖调的暗色与鲜艳的橙色形成柔和对比,避免了纯黑在橙底上产生的刺眼硬边感。
10.2 上行:应用名与呼吸圆点
上行左侧是应用信息区:主标题"园区巡卫 · 物业安全巡检"使用 20px 粗体,副标题随 currentTab 切换显示 7 种不同文案。这种"Tab 联动副标题"的设计让用户随时知道自己当前在哪个功能模块、该模块的核心特性是什么。
上行右侧是呼吸圆点——一个 10px 的圆形,填充色为 onMain,透明度由 breath 状态控制(0.9 或 0.45)。每秒翻转一次的呼吸效果传递出"系统运行中、实时监控中"的心理暗示,与安全巡检的场景氛围高度契合。
10.3 下行:四状态胶囊
下行排列四个状态胶囊,实时反映平台三大前沿特性的系统级状态:
任务胶囊: 显示任务总数,图标为 📋,文字为"巡检 N 项"。这个胶囊是业务状态的快速概览。
特性 A 胶囊(相机): 显示相机会话模式,左侧有一个 6px 的状态圆点:idle 模式灰色、video 模式绿色、photo 模式橙色。用户一眼就能看出相机是否在工作、处于哪种模式。
特性 B 胶囊(通知): 显示通知授权状态,状态圆点绿色表示已授权、红色表示未授权。通知授权是使用自定义铃声的前提条件,这个胶囊让用户随时掌握授权状态。
特性 C 胶囊(嵌套): 显示嵌套滚动模式,状态圆点蓝色表示 SELF_FIRST、橙色表示 SELF_ONLY。使用 modeShort 函数返回精简文案。
四个胶囊的背景色统一使用 COLORS.mask(半透黑),与渐变横幅背景形成层次。胶囊的圆角为 12px,内边距上下 6px 左右 10px,呈现出小巧精致的视觉效果。通知胶囊使用 layoutWeight(1) 占据剩余空间,确保四个胶囊均匀分布。
十一、Tab0 任务面板深度分析
11.1 整体布局架构
@Builder
tabTask() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
// 第一段:完成率统计卡
Column({ space: 12 }) { ... }.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
// 第二段:任务清单头
Row() { ... }.width('100%')
// 第三段:任务进度条清单
Column({ space: 10 }) {
ForEach(this.taskList, (item: TaskItem, idx: number) => {
this.taskCard(item, idx)
}, ...)
}.width('100%')
// 第四段:月度隐患柱状图
this.chartCard()
// 底部说明卡
Column({ space: 5 }) { ... }.padding(10).borderRadius(12).backgroundColor(COLORS.dark).width('100%')
}.width('100%')
}.scrollBar(BarState.Off).width('100%').layoutWeight(1)
}.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
}
任务面板采用"Scroll 包裹 Column"的经典滚动布局,内部按"统计卡 → 清单头 → 进度条清单 → 柱状图 → 说明卡"的顺序纵向排列,形成"总览-明细-趋势-说明"的信息层级。
外层 Column 设置了 height('100%'),内部 Scroll 设置了 layoutWeight(1),这是 ArkUI 中实现"可滚动区域占满剩余空间"的标准写法——外层容器确定总高度,内层 Scroll 通过 layoutWeight(1) 自动计算并占满剩余空间,超出部分可滚动。scrollBar(BarState.Off) 隐藏滚动条,使界面更简洁。
内边距采用左右 12px、上 4px、下 8px 的非对称设计——顶部因紧邻分割线所以留白较少,底部因紧邻 Tab 栏所以留白稍多,形成视觉上的平衡感。
11.2 完成率统计卡
完成率统计卡是任务 Tab 的视觉焦点,使用大数字和渐变色强调整体完成情况。
Column({ space: 12 }) {
Row({ space: 14 }) {
Text(`${this.taskRate()}`).fontSize(46).fontWeight(FontWeight.Bold)
.fontColor(COLORS.orange).fontFamily('monospace')
Column({ space: 5 }) {
Text('今日巡检完成率').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(`已完成 ${this.taskDone()} / 共 ${this.taskList.length} 项 · 数据每 30 分钟同步工单系统`)
.fontSize(9).fontColor(COLORS.text3).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}.width('100%')
Row() {
Column().width(`${this.taskRate()}%`).height('100%')
.linearGradient({ angle: 0, colors: [[COLORS.orange, 0], [COLORS.green, 1]] })
}.width('100%').height(8).borderRadius(4).backgroundColor(COLORS.dark)
Row({ space: 8 }) {
Column({ space: 3 }) {
Text(`${this.taskDoing()}`).fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.orange)
Text('进行中').fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.dark).borderRadius(10)
Column({ space: 3 }) {
Text(`${this.taskReview()}`).fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.blue)
Text('待复查').fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.dark).borderRadius(10)
Column({ space: 3 }) {
Text(`${this.taskList.length - this.taskDone() - this.taskDoing() - this.taskReview()}`)
.fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.red)
Text('已逾期').fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.dark).borderRadius(10)
}.width('100%')
}
大数字区: 完成率百分比使用 46px 的超大字号和 monospace 等宽字体,警示橙颜色。等宽字体的选择很有讲究——数字在等宽字体下每个字符宽度一致,百分比变化时数字不会左右跳动,视觉上更稳定。右侧是完成率说明,包含标题和详细数据说明。
渐变完成条: 进度条使用 0 度线性渐变(从左到右),从橙色过渡到绿色,象征"从进行中走向完成"的语义。进度条高度仅 8px,是一个细而精致的视觉元素,底色为 dark 次级容器色。进度条宽度使用 ${this.taskRate()}% 的百分比语法,直接绑定完成率计算结果。
三格统计: 进行中(橙)、待复查(蓝)、已逾期(红)三个统计格,使用 layoutWeight(1) 三等分宽度。每格上方是 16px 粗体数字(颜色与状态对应),下方是 9px 灰色说明文字。已逾期数通过总数减去已完成、进行中、待复查计算得出,确保四个状态的数量之和等于任务总数。
11.3 任务清单与 taskCard
任务清单头包含标题和"+ 新增"按钮,标题显示任务总数,新增按钮触发新建弹窗。
@Builder
taskCard(item: TaskItem, idx: number) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text(item.building).fontSize(10).fontColor(COLORS.sub)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.dark).borderRadius(6)
Text(item.status).fontSize(9).fontColor(taskStatusColor(item.status))
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.dark).borderRadius(6)
Blank()
Text('编').fontSize(9).fontColor(COLORS.sub).padding({ left: 7, right: 7, top: 3, bottom: 3 })
.backgroundColor(COLORS.dark).borderRadius(7)
.onClick(() => { this.openEdit(idx); })
Text('删').fontSize(9).fontColor(COLORS.red).padding({ left: 7, right: 7, top: 3, bottom: 3 })
.backgroundColor(COLORS.dark).borderRadius(7)
.onClick(() => { this.openDel(idx); })
}.width('100%')
Text(item.item).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Progress({ value: item.progress, total: 100, type: ProgressType.Linear })
.layoutWeight(1).color(taskStatusColor(item.status))
.backgroundColor(COLORS.dark).borderRadius(4)
Text(`${item.progress}%`).fontSize(10).fontFamily('monospace').fontColor(COLORS.sub)
}.width('100%')
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}
taskCard 是任务清单的基本单元,每张卡片分为三行:
第一行:徽章与操作区。 左侧是楼宇徽标(灰色背景)和状态徽章(状态色文字+灰色背景),右侧是"编"和"删"两个操作按钮。状态徽章的文字色通过 taskStatusColor(item.status) 动态映射,四种状态对应四种颜色。编辑按钮为灰色文字,删除按钮为红色文字——用颜色区分操作的严重程度。
第二行:任务标题。 13px 粗体标题文字,单行显示超出省略。这是卡片的核心信息,使用最大字号和最重字重确保视觉优先级。
第三行:进度条与百分比。 左侧是线性进度条(ProgressType.Linear),填充色与状态色一致,实现"状态色即进度条色"的视觉统一。右侧是等宽字体的百分比数字。
整个卡片使用 12px 内边距和 12px 圆角,卡片底色为 card 色,与页面背景形成柔和对比。卡片间通过外层 Column 的 space: 10 形成 10px 的间距。
11.4 月度隐患柱状图
柱状图的详细分析见第十二章。
十二、月度隐患柱状图深度解析
@Builder
chartCard() {
Column({ space: 10 }) {
Row() {
Text('📊 月度隐患发现数').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Blank()
Text('近 6 个月 · 单位处').fontSize(9).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 10 }) {
ForEach(MONTH_HAZARD, (val: number, idx: number) => {
Column({ space: 5 }) {
Text(val.toString()).fontSize(8).fontColor(COLORS.sub)
Column().width('100%').height(this.barHeight(idx)).borderRadius(5)
.linearGradient({ angle: 180, colors: [[COLORS.orange, 0], [COLORS.orangeD, 1]] })
Text(MONTH_NAME[idx]).fontSize(8).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}, (val: number, idx: number) => val.toString() + '_' + idx.toString())
}.width('100%').alignItems(VerticalAlign.Bottom)
}.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
12.1 柱高计算逻辑
barHeight(i: number): number {
const base = MONTH_HAZARD[i] / MONTH_MAX * 96;
const wave = (i % 2 === 0) === this.breath ? 1.06 : 0.94;
return Math.max(8, Math.round(base * wave));
}
barHeight 方法计算每个月的柱高,由基础高度和呼吸波动因子两部分组成。
基础高度计算: MONTH_HAZARD[i] / MONTH_MAX * 96,即以最大值 16 为基准,将 96px 作为最高柱的高度,按比例换算各月的柱高。例如,5 月份 15 处隐患的基础高度为 15/16 × 96 = 90px。
呼吸波动因子: (i % 2 === 0) === this.breath ? 1.06 : 0.94。这是一个非常巧妙的表达式,利用奇偶索引和 breath 状态的异或关系,实现奇偶柱交替放大缩小的效果:
- 当
breath为 true 时:偶数列放大 6%、奇数列缩小 6% - 当
breath为 false 时:偶数列缩小 6%、奇数列放大 6%
由于 breath 每秒翻转一次,柱状图就呈现出奇偶柱此起彼伏的呼吸效果,模拟了数据实时更新的动态感。
最小高度保护: Math.max(8, ...) 确保柱高至少为 8px。即使隐患数为 0,也会显示一个 8px 的矮柱,避免出现"空无一物"的视觉断点,保持柱状图的完整性。
12.2 纯声明式柱状图的技术意义
这个柱状图的实现方式很有代表性——不使用 Canvas、不使用第三方图表库,完全通过 ArkUI 的声明式组件(Column + ForEach + linearGradient)构建。这种"传统柱状方式"有几个优势:
- 零依赖:不需要引入任何图表库,减少应用体积
- 完全可控:每一根柱子的样式、动画、交互都可以精确控制
- 响应式天然支持:柱子的高度直接绑定状态变量,数据变化自动触发重绘
- 性能可预期:Column 和 Text 都是 ArkUI 的基础组件,渲染效率高
当然,这种方式也有局限性——只适合简单的柱状图,对于复杂的图表(折线图、饼图、雷达图等)还是需要 Canvas 或专业图表库。但对于展示 6 个月隐患数这样的简单场景,纯声明式实现完全够用,且代码量更少、维护成本更低。
十三、Tab1 相机面板深度分析
13.1 整体布局架构
@Builder
tabCamera() {
Column({ space: 10 }) {
// 授权状态卡
Column({ space: 8 }) { ... }.width('100%').padding(10).backgroundColor(COLORS.card).borderRadius(12)
// XComponent 预览区
Stack({ alignContent: Alignment.BottomEnd }) {
XComponent({ id: 'patrolCamPreview', type: XComponentType.SURFACE,
controller: this.previewController })
.layoutWeight(1).width('100%').borderRadius(12)
.backgroundColor(COLORS.dark)
.onLoad(() => { this.surfaceReady = true; })
Text(sessionLabel(this.sessionMode)).fontSize(9).fontColor(COLORS.title)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.backgroundColor(COLORS.mask).margin(8)
}.layoutWeight(1).width('100%').borderRadius(12)
// 模式切换行
Row({ space: 8 }) { ... }.width('100%')
// 滚动信息区
Scroll() {
Column({ space: 10 }) {
// 影随人动状态卡
Column({ space: 8 }) { ... }.width('100%').padding(10).backgroundColor(COLORS.card).borderRadius(12)
// 效果类型枚举表
Column({ space: 6 }) { ... }.width('100%').padding(10).backgroundColor(COLORS.card).borderRadius(12)
}.width('100%')
}.height(286).width('100%').scrollBar(BarState.Off)
}.width('100%').height('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
相机面板由四部分纵向排列构成:授权状态卡、XComponent 预览区、模式切换行、滚动信息区。与任务 Tab 的全 Scroll 布局不同,相机 Tab 采用"固定预览区 + 底部滚动区"的分层布局——因为 XComponent 相机预览需要有固定的高度,不能随内容滚动。
布局权重分配:
- 授权状态卡:固定高度(内容自适应)
- XComponent 预览区:
layoutWeight(1),占满剩余空间的主要部分 - 模式切换行:固定高度(约 40px)
- 滚动信息区:固定 286px 高度,内部可滚动
这种布局设计保证了相机预览区域始终可见且尺寸最大化,而辅助信息(能力链状态、枚举表)可以滚动查看,不占用宝贵的预览空间。
13.2 XComponent 预览区
Stack({ alignContent: Alignment.BottomEnd }) {
XComponent({ id: 'patrolCamPreview', type: XComponentType.SURFACE,
controller: this.previewController })
.layoutWeight(1).width('100%').borderRadius(12)
.backgroundColor(COLORS.dark)
.onLoad(() => { this.surfaceReady = true; })
Text(sessionLabel(this.sessionMode)).fontSize(9).fontColor(COLORS.title)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.backgroundColor(COLORS.mask).margin(8)
}.layoutWeight(1).width('100%').borderRadius(12)
XComponent 是相机预览的宿主组件,使用 SURFACE 类型提供原生 Surface 缓冲区。previewController 是 XComponentController 实例,用于获取 Surface ID 和控制组件。
关键属性解析:
id: 'patrolCamPreview':组件唯一标识,用于框架内部管理type: XComponentType.SURFACE:Surface 类型,提供原生图形缓冲区,适合相机预览、视频播放等场景controller: this.previewController:控制器对象,通过它可以获取 Surface ID、设置 Surface 尺寸等onLoad回调:XComponent 加载完成、Surface 就绪时触发,此时设置surfaceReady = true,表示可以开始绑定相机预览
右下角会话标签: 使用 Stack 的 Alignment.BottomEnd 对齐方式,在预览区右下角叠加一个会话模式标签。标签使用半透明黑色背景(COLORS.mask)和白色文字,圆角 8px,外边距 8px,既不遮挡太多预览画面,又能清晰显示当前会话模式。
未启动时的占位: 在相机会话未启动时,XComponent 显示 dark 色背景,右下角显示"idle · 未启动会话"标签。用户点击"开启影随人动"后,会话启动,相机画面渲染到 Surface 上,标签变为"VideoSession · 影随人动宿主"。
13.3 授权状态卡
授权状态卡显示 CAMERA 权限状态和 Surface 就绪状态,提供"申请相机权限"操作按钮。
Column({ space: 8 }) {
Row() {
Text('📷 CAMERA 权限').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Blank()
Text(this.permState).fontSize(10).fontColor(this.permState === '已授权' ? COLORS.green : COLORS.orange)
}.width('100%')
Row({ space: 8 }) {
Text('申请相机权限').fontSize(10).fontColor(COLORS.sub).layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.dark).borderRadius(9)
.onClick(() => { this.requestCameraPermission(); })
Text(this.surfaceReady ? 'Surface 已就绪' : 'Surface 加载中…').fontSize(10)
.fontColor(this.surfaceReady ? COLORS.green : COLORS.text3).layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.dark).borderRadius(9)
}.width('100%')
}
卡片顶部显示权限状态文案,颜色根据是否授权动态变化(已授权绿色、其他橙色)。底部两列等宽按钮:左侧是"申请相机权限"操作按钮,右侧是 Surface 就绪状态显示(非按钮,纯展示)。
右侧的 Surface 状态虽然看起来像按钮,但实际上没有绑定点击事件——它只是一个状态指示器。使用与左侧按钮相同的样式(深色背景、圆角)是为了视觉上的对称平衡。
13.4 模式切换行
Row({ space: 8 }) {
Text('开启影随人动').fontSize(11).fontColor(this.sessionMode === 'video' ? COLORS.onMain : COLORS.title)
.fontWeight(this.sessionMode === 'video' ? FontWeight.Bold : FontWeight.Normal)
.layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 10, bottom: 10 })
.backgroundColor(this.sessionMode === 'video' ? COLORS.orange : COLORS.card).borderRadius(10)
.onClick(() => { this.startVideoMode(); })
Text('停止会话').fontSize(11).fontColor(COLORS.red).layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.card).borderRadius(10)
.enabled(this.sessionMode !== 'idle')
.onClick(() => { this.releaseSession(); })
}.width('100%')
模式切换行提供两个操作按钮,使用 layoutWeight(1) 等分宽度:
开启影随人动按钮: 未激活时卡片底+白色文字,激活(video 模式)时橙色底+深暖黑文字+粗体。这种"选中态高亮"的设计让用户清楚知道当前模式。
停止会话按钮: 始终是红色文字+卡片底,使用 enabled(this.sessionMode !== 'idle') 在 idle 模式下禁用——没有会话时自然不需要停止。
两个按钮一正一反(一个是"开始"主操作、一个是"停止"危险操作),颜色和样式形成对比,降低误操作概率。
13.5 影随人动状态卡
Column({ space: 8 }) {
Row() {
Text('影随人动能力链').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Blank()
Text(this.framingState).fontSize(10).fontColor(framingStateColor(this.framingState))
}.width('100%')
Text('isControlCenterSupported → getSupportedEffectTypes → enableControlCenter(true)')
.fontSize(8).fontColor(COLORS.text3).fontFamily('monospace').maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(this.framingSupported ? '本机已声明 AUTO_FRAMING' : 'AUTO_FRAMING 未声明/未查询').fontSize(9)
.fontColor(this.framingSupported ? COLORS.green : COLORS.text3).layoutWeight(1)
Text('巡查跟拍:人员始终居中').fontSize(9).fontColor(COLORS.sub)
}.width('100%')
}
状态卡展示影随人动能力链的执行结果,包含三行信息:
- 状态标题行: 左侧是卡片标题,右侧是当前状态(颜色由
framingStateColor函数映射) - 能力链公式行: 使用等宽字体展示三步能力链的调用顺序,字号仅 8px,作为技术参考信息
- 能力声明行: 左侧显示本机是否声明 AUTO_FRAMING 能力,右侧是功能描述
这种"结果+过程+说明"的三层结构,既满足了普通用户快速查看状态的需求,又满足了开发者了解技术细节的需求。
13.6 效果类型枚举表
Column({ space: 6 }) {
Text('ControlCenterEffectType 枚举').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
ForEach(EFFECT_INFOS, (info: EffectInfo) => {
Row({ space: 8 }) {
Text(`${info.type}`).fontSize(10).fontColor(COLORS.sub).fontFamily('monospace')
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(COLORS.dark).borderRadius(7)
Text(info.name).fontSize(11).fontColor(COLORS.title).fontFamily('monospace').layoutWeight(1)
Text(info.desc).fontSize(9).fontColor(COLORS.text3)
if (info.type === 2 && this.framingSupported) {
Text('已声明').fontSize(8).fontColor(COLORS.green).padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.dark).borderRadius(6)
}
}.width('100%').padding({ top: 4, bottom: 4 })
}, (info: EffectInfo) => `${info.type}_${info.name}`)
}
枚举表逐行展示 ControlCenterEffectType 的三个枚举值,每行四列:type 数值(等宽字体+深色背景徽章)、英文枚举名(等宽字体)、中文说明、可选的"已声明"徽章(仅 AUTO_FRAMING 且设备支持时显示)。
这个枚举表的教学价值大于实用价值——它让开发者直观了解控制中心支持哪些效果类型、每个类型的枚举值是多少、以及哪些是新版本新增的。对于普通用户来说,可能只需要知道"影随人动是否可用"就够了,但对于学习 Camera Kit 的开发者来说,完整的枚举展示是宝贵的参考资料。
十四、Tab2 对焦面板深度分析
14.1 整体布局架构
@Builder
tabFocus() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 12 }) {
// 能力查询卡
Column({ space: 8 }) { ... }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
// 三档预设行
Row({ space: 8 }) { ... }.width('100%')
// 焦距滑杆卡
Column({ space: 8 }) { ... }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
// 应用按钮行
Row({ space: 8 }) { ... }.width('100%')
// 对焦记录时间线
Column({ space: 8 }) { ... }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}.width('100%')
}.scrollBar(BarState.Off).width('100%').layoutWeight(1)
}.width('100%').height('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
对焦面板采用全 Scroll 布局,内部按"能力查询 → 三档预设 → 滑杆控制 → 操作按钮 → 记录时间线"的顺序纵向排列,形成"能力确认-快速选档-精细调节-执行校验-历史记录"的完整交互流。
与相机 Tab 不同,对焦 Tab 没有独占的相机预览区——它与相机 Tab 共用同一个 XComponent Surface(只是当前 Tab 看不到)。用户需要先在相机 Tab 中熟悉预览画面,再切到对焦 Tab 进行精确对焦操作,或者在对焦 Tab 中启动对焦会话后切回相机 Tab 查看效果。这种"两 Tab 协作"的设计是因为相机预览和手动对焦控制都需要较大的屏幕空间,放在同一个 Tab 里会显得拥挤。
14.2 能力查询卡
Column({ space: 8 }) {
Row() {
Text('🎯 手动对焦能力').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Blank()
Text(this.focusSupported ? '支持设置对焦距离' : '未启动拍照会话').fontSize(10)
.fontColor(this.focusSupported ? COLORS.green : COLORS.text3)
}.width('100%')
Row({ space: 8 }) {
Text('当前会话:' + sessionLabel(this.sessionMode)).fontSize(9).fontColor(COLORS.sub).layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text('启动对焦会话').fontSize(10).fontColor(COLORS.onMain)
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.backgroundColor(this.sessionMode === 'photo' ? COLORS.orangeD : COLORS.orange).borderRadius(9)
.onClick(() => { this.switchToPhotoMode(); })
}.width('100%')
Text('手动对焦三接口仅挂在 PhotoSession(ManualFocus),预览面在相机 Tab 共用同一 Surface')
.fontSize(8).fontColor(COLORS.text3).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
能力查询卡是进入对焦 Tab 后的第一个信息节点,告诉用户当前手动对焦功能是否可用、如何启动。
卡片包含三行:
- 标题行: 左侧标题+图标,右侧能力状态(绿色表示支持、灰色表示未启动会话)
- 操作行: 左侧当前会话模式,右侧"启动对焦会话"按钮
- 说明行: 技术说明文字,解释手动对焦与 PhotoSession 的关系以及预览面的位置
"启动对焦会话"按钮有两种状态色:photo 模式下使用深橙色(orangeD),其他模式下使用亮橙色(orange)。这种细微的颜色差异暗示了"已激活和未激活"的区别——已激活时颜色更深沉(稳定态),未激活时颜色更鲜亮(吸引点击)。
14.3 三档预设行
Row({ space: 8 }) {
ForEach(FOCUS_PRESETS, (preset: FocusPreset) => {
Column({ space: 5 }) {
Text(preset.label).fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(this.focusDistance === preset.distance ? COLORS.onMain : COLORS.title)
Text(preset.distance.toFixed(1)).fontSize(13).fontFamily('monospace')
.fontColor(this.focusDistance === preset.distance ? COLORS.onMain : COLORS.orange)
Text(preset.scene).fontSize(8)
.fontColor(this.focusDistance === preset.distance ? COLORS.onMain : COLORS.text3)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 10, bottom: 10 })
.backgroundColor(this.focusDistance === preset.distance ? COLORS.orange : COLORS.card).borderRadius(10)
.onClick(() => { this.focusDistance = preset.distance; })
}, (preset: FocusPreset) => preset.label)
}
三档预设以三列等宽卡片形式呈现,是快速选择对焦距离的便捷入口。每张卡片包含三行信息:
- 档位名(12px 粗体):如"铭牌近拍"“设备中距”“环境远距”
- 距离值(13px 等宽字体):如"0.1""0.5"“0.9”
- 场景描述(8px 弱文本):如"0.1 · 铭牌/序列号"
选中态(this.focusDistance === preset.distance)使用橙色底+深暖黑文字,未选中态使用卡片底+正常文字色。点击卡片直接设置 focusDistance 为对应值,Slider 滑杆同步更新。
这种"预设+滑杆"的双轨控制模式很实用:大多数情况下用户可以直接点击预设档位快速选择,需要精细调节时再拖动 Slider。两者双向联动,兼顾了效率和精度。
14.4 焦距滑杆卡
Column({ space: 8 }) {
Row() {
Text('对焦距离').fontSize(12).fontColor(COLORS.title)
Blank()
Text(this.focusDistance.toFixed(2)).fontSize(15).fontColor(COLORS.orange)
.fontFamily('monospace').fontWeight(FontWeight.Bold)
}.width('100%')
Slider({ value: this.focusDistance, min: 0, max: 1, step: 0.01 }).width('100%')
.blockColor(COLORS.orange)
.trackColor(COLORS.dark).selectedColor(COLORS.orange)
.onChange((value: number) => {
this.focusDistance = value;
})
Text(distanceLabel(this.focusDistance)).fontSize(10).fontColor(COLORS.text3)
}
焦距滑杆卡是手动对焦的核心交互区域,提供 0.0~1.0 范围内的连续调节。
顶部数值显示: 右侧使用 15px 等宽字体的橙色粗体数字,实时显示当前对焦距离(保留两位小数)。等宽字体确保数字变化时不会左右跳动。
Slider 组件:
value: this.focusDistance:双向绑定当前对焦距离min: 0, max: 1:对焦距离的取值范围(0=最近,1=最远)step: 0.01:步进精度为 0.01,即 1% 的精细粒度blockColor:滑块(圆形按钮)颜色为橙色trackColor:轨道未选中部分颜色为深灰色selectedColor:轨道已选中部分颜色为橙色
底部景别文案: 调用 distanceLabel(this.focusDistance) 实时显示当前对焦距离对应的巡检景别,帮助用户理解抽象数值的实际含义。用户拖动滑杆时,文案会在"近拍→中距→远距"之间切换,阈值分别为 0.3 和 0.7。
14.5 应用按钮行
Row({ space: 8 }) {
Text('设置并读回校验').fontSize(11).fontColor(COLORS.onMain).fontWeight(FontWeight.Bold)
.layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.orange).borderRadius(10)
.enabled(this.sessionMode === 'photo' && this.focusSupported)
.onClick(() => { this.applyFocus(); })
Text('刷新能力查询').fontSize(11).fontColor(COLORS.orange).layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 }).borderRadius(10).border({ width: 1, color: COLORS.orange })
.onClick(() => { this.queryFocusSupport(); })
}
应用按钮行包含两个操作,左右等分:
设置并读回校验(主按钮): 橙色填充+深暖黑文字+粗体,是对焦 Tab 的核心操作。点击后调用 applyFocus() 方法执行"设置→读回→校验"三步流程。使用 enabled 绑定可用性条件——只有在 photo 模式且支持对焦距离时才可点击,避免无效操作。
刷新能力查询(次按钮): 橙色边框+橙色文字的描边按钮样式,点击后重新查询对焦能力支持情况。这个按钮的存在是因为对焦能力可能在会话生命周期中发生变化(虽然实际很少见),提供手动刷新的入口让用户感觉更可控。
主按钮使用填充样式、次按钮使用描边样式,通过视觉重量的差异区分操作优先级——用户一眼就能看出哪个是主要操作。
14.6 对焦记录时间线
Column({ space: 8 }) {
Row() {
Text('📜 对焦记录').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Blank()
Text(`共 ${this.focusRecords.length} 条`).fontSize(9).fontColor(COLORS.text3)
}.width('100%')
Scroll() {
Column({ space: 6 }) {
ForEach(this.focusRecords, (rec: FocusRecord) => {
Row({ space: 8 }) {
Text(rec.time).fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
Text(rec.distance.toFixed(2)).fontSize(11).fontColor(COLORS.title).fontFamily('monospace')
Text('→').fontSize(10).fontColor(COLORS.text3)
Text(rec.readback >= 0 ? rec.readback.toFixed(2) : '—')
.fontSize(11).fontColor(COLORS.sub).fontFamily('monospace')
Blank()
Text(rec.ok).fontSize(9).fontColor(focusOkColor(rec.ok))
.padding({ left: 7, right: 7, top: 3, bottom: 3 }).backgroundColor(COLORS.dark).borderRadius(7)
}.width('100%').padding(8).backgroundColor(COLORS.dark).borderRadius(8)
}, (rec: FocusRecord) => rec.time + '_' + rec.ok)
}.width('100%')
}.height(150).width('100%').scrollBar(BarState.Off)
}
对焦记录时间线是手动对焦功能的"历史回溯"区域,记录每次对焦操作的结果。
记录行结构: 每行记录从左到右依次为:
- 时间戳(9px 等宽字体,弱文本色)
- 设置值(11px 等宽字体,标题色)
- 箭头"→"(分隔符)
- 读回值(11px 等宽字体,副标题色;失败时显示"—")
- 校验结论徽章(右侧,颜色由
focusOkColor映射)
时间线容器: 使用固定 150px 高度的内嵌 Scroll,确保记录区域不会无限撑高页面。记录数超过可视区域时可上下滚动查看。记录使用 unshift 置顶新增,最新记录在最上方,符合用户"先看最新"的阅读习惯。
记录行使用深色背景(dark 色)+ 8px 圆角,与卡片底色形成微妙的层次区分。每行 8px 内边距,行间距 6px,整体紧凑但不拥挤。
十五、Tab3 频道面板深度分析
15.1 整体布局架构
@Builder
tabChannel() {
Column({ space: 10 }) {
// 模式说明 + 切换 chips
Row({ space: 8 }) { ... }.width('100%')
// 当前双层位置说明行
Row({ space: 6 }) { ... }.width('100%')
// 外层宿主 Tabs
Tabs({ barPosition: BarPosition.Start }) {
ForEach(OUTER_CHANNELS, (ch: ChannelItem) => {
TabContent() {
this.innerTabs(ch)
}.tabBar(`${ch.icon} ${ch.name}`)
}, (ch: ChannelItem) => ch.name)
}
.barMode(BarMode.Scrollable)
.onChange((index: number) => { ... })
.layoutWeight(1).width('100%')
// 底部特性说明
Text('内层检查项滑到边缘后是否联动外层楼宇,由 nestedScroll 模式决定(★ 6.1.1 新特性)')
.fontSize(9).fontColor(COLORS.text3).width('100%')
}.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
}
频道面板是嵌套滚动特性的核心演示区,采用"模式控制区 + 双层Tabs + 说明文字"的三段式布局。外层 Tabs 占据主要空间(layoutWeight(1)),上下各有一行辅助信息。
与其他 Tab 不同,频道 Tab 的核心交互区域不是 Scroll 而是 Tabs 组件——因为嵌套滚动的演示主体就是 Tabs 组件本身。外层 Tabs 承载 5 个楼宇频道,每个楼宇频道内又嵌套一个内层 Tabs(5 个检查项),内层 Tabs 才是 nestedScroll 特性的挂载点。
15.2 模式切换 chips
Row({ space: 8 }) {
Text(`嵌套模式:${modeLabel(this.nestedMode)}`)
.fontSize(11).fontColor(COLORS.sub).layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
ForEach([TabsNestedScrollMode.SELF_ONLY, TabsNestedScrollMode.SELF_FIRST],
(m: TabsNestedScrollMode) => {
Text(modeShort(m)).fontSize(10)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.fontColor(this.nestedMode === m ? COLORS.onMain : COLORS.text3)
.backgroundColor(this.nestedMode === m ? COLORS.orange : COLORS.card)
.onClick(() => { this.nestedMode = m; })
}, (m: TabsNestedScrollMode) => `mode_${m}`)
}.width('100%')
模式切换区左侧显示当前嵌套模式的完整文案(modeLabel),右侧是两个模式切换 chips:
- SELF_ONLY(仅内层):内层滑到边缘后不向外层传递,内外层滚动完全独立
- SELF_FIRST(先内后外):内层优先消费滚动事件,滑到边缘后剩余手势传递给外层
选中态使用橙色底+深暖黑文字,未选中态使用卡片底+弱文本色。chips 的圆角为 12px,内边距左右 10px 上下 5px,呈现小巧精致的胶囊形状。
点击切换模式后,内层 Tabs 的 nestedScroll 属性会即时响应(因为它绑定了 this.nestedMode),用户可以立即体验到两种模式的行为差异。
15.3 双层位置说明行
Row({ space: 6 }) {
Circle({ width: 6, height: 6 }).fill(COLORS.orange)
Text(`外层 ${OUTER_CHANNELS[this.outerIndex].name}`).fontSize(10).fontColor(COLORS.sub)
Blank()
Circle({ width: 6, height: 6 }).fill(COLORS.blue)
Text(`内层 ${INNER_TABS[this.innerIndex]}(第 ${this.innerIndex + 1}/5 页)`)
.fontSize(10).fontColor(COLORS.sub)
}.width('100%')
双层位置说明行用两个彩色圆点(外层橙、内层蓝)+ 文字的形式,实时显示当前所在的楼宇频道和检查项页签。右侧还显示"第 N/5 页"的页码信息。
这行文字的作用类似于面包屑导航——让用户随时知道自己在"楼宇×检查项"二维矩阵中的位置。尤其是在嵌套滚动模式下,外层可能因为内层滑到边缘而自动切换,如果没有位置指示,用户可能会感到困惑。
15.4 外层 Tabs 与 onChange 回调
Tabs({ barPosition: BarPosition.Start }) {
ForEach(OUTER_CHANNELS, (ch: ChannelItem) => {
TabContent() {
this.innerTabs(ch)
}.tabBar(`${ch.icon} ${ch.name}`)
}, (ch: ChannelItem) => ch.name)
}
.barMode(BarMode.Scrollable)
.onChange((index: number) => {
this.swipeLogs.unshift(new SwipeLog('外层楼宇', OUTER_CHANNELS[index].name,
this.outerIndex, index, modeLabel(this.nestedMode)));
this.outerIndex = index;
if (this.swipeLogs.length > 40) { this.swipeLogs.pop(); }
})
.layoutWeight(1).width('100%')
外层 Tabs 使用 BarMode.Scrollable 横滑页签模式,5 个楼宇频道各对应一个 TabContent,内容为 innerTabs(ch) 构建函数(即内层 Tabs)。Tab 栏显示"图标 + 名称"的格式。
onChange 回调在外层 Tab 切换时触发,执行三个操作:
- 记录一条
SwipeLog翻页日志(layer=‘外层楼宇’) - 更新
outerIndex为新索引 - 如果日志数超过 40 条,移除最旧的一条
这个 onChange 回调是嵌套滚动"接力"效果的验证点——当 nestedMode 为 SELF_FIRST 时,在内层 Tabs 上持续滑动,滑到内层边缘后,继续滑动会触发外层 Tabs 的 onChange。这就是"边缘接力"的直接证据:外层切换了,但用户的手指始终在内层区域滑动。
15.5 内层 Tabs 与 nestedScroll 挂载
@Builder
innerTabs(channel: ChannelItem) {
Tabs({ barPosition: BarPosition.Start }) {
ForEach(INNER_TABS, (name: string) => {
TabContent() {
List({ space: 10 }) {
ForEach(innerMockData(channel, name), (item: InnerCard) => {
ListItem() {
Column({ space: 6 }) {
Row() {
Text(`${channel.icon} ${name}类检查`).fontSize(13)
.fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Blank()
Text(item.tag).fontSize(10).fontColor(COLORS.sub)
}.width('100%')
Text(item.title).fontSize(12).fontColor(COLORS.sub).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.desc).fontSize(11).fontColor(COLORS.text3).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(`${channel.name}责任区`).fontSize(9).fontColor(COLORS.orange)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.dark)
Text('拍照取证必填').fontSize(9).fontColor(COLORS.green)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.dark)
}.width('100%')
}.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
}
}, (item: InnerCard) => item.id)
}.width('100%').height('100%').scrollBar(BarState.Off)
}.tabBar(name)
}, (name: string) => name)
}
.barMode(BarMode.Scrollable)
.onChange((index: number) => {
this.swipeLogs.unshift(new SwipeLog('内层检查项', INNER_TABS[index],
this.innerIndex, index, modeLabel(this.nestedMode)));
this.innerIndex = index;
if (this.swipeLogs.length > 40) { this.swipeLogs.pop(); }
})
.nestedScroll(this.nestedMode)
.layoutWeight(1).width('100%')
}
内层 Tabs 是嵌套滚动特性的核心挂载点,也是整个频道 Tab 最关键的代码行——.nestedScroll(this.nestedMode)。
15.5.1 nestedScroll 挂载点
.nestedScroll(this.nestedMode)
这行代码是 HarmonyOS 6.1.1 新特性的挂载点,也是整个嵌套滚动演示的灵魂。它的作用是告诉内层 Tabs 组件:当你滚动到边缘时,如何处理剩余的滚动手势。
- TabsNestedScrollMode.SELF_FIRST:内层 Tabs 优先消费滚动事件。当内层滚动到边缘后,如果还有剩余的滚动手势,就将其传递给外层容器(外层 Tabs),触发外层的 Tab 切换。这就是"边缘接力"效果。
- TabsNestedScrollMode.SELF_ONLY:内层 Tabs 只消费自身范围内的滚动事件。即使滚动到了边缘,剩余手势也不会传递给外层,内外层完全独立滚动。
为什么挂载在内层而不是外层? 这是嵌套滚动的设计原则——nestedScroll 总是挂载在被嵌套的内层组件上,由内层决定是否向外层传递事件。外层作为父容器,默认接收子组件传递的事件。这种"子组件主动上报"的模式使得嵌套关系可以任意深度——每一层只需决定是否向自己的父层传递。
15.5.2 内层检查卡片内容
每个检查项页签内是一个 List 组件,包含 8 条检查卡片(由 innerMockData 生成)。卡片内容结构:
- 标题行:频道图标 + 检查类别名 + 类别标签
- 检查点标题:如"【消防】灭火器压力表指针核查"
- 检查内容描述:详细说明检查要求
- 标签行:责任区标签(橙色)+ 拍照取证必填标签(绿色)
卡片使用卡片底色 + 10px 圆角 + 12px 内边距,整体风格与任务 Tab 的 taskCard 一致但更简洁。
生成 8 条卡片是为了确保内容超过一屏——只有内容超屏,内层 List 才能滚动,也才能产生"滑到边缘"的事件,进而演示 nestedScroll 的接力效果。这是一个精心设计的前提条件。
十六、Tab4 日志面板深度分析
16.1 整体布局架构
@Builder
tabLogs() {
Column({ space: 10 }) {
// 顶部计数 + 清空按钮
Row() { ... }.width('100%')
// 图例说明行
Row({ space: 12 }) { ... }.width('100%')
// 时间轴日志列表
if (this.swipeLogs.length === 0) {
// 空态卡
Column({ space: 6 }) { ... }.width('100%').padding({ top: 40, bottom: 40 })
.borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
} else {
List({ space: 0 }) {
ForEach(this.swipeLogs, (log: SwipeLog) => {
ListItem() {
Row({ space: 10 }) {
// 时间列
Column({ space: 4 }) { ... }.width(52).height('100%')
// 竖线
Column().width(3).height('100%').borderRadius(2)
.backgroundColor(...).opacity(0.6)
// 内容卡
Column({ space: 4 }) { ... }.layoutWeight(1).height('100%')
}.width('100%').height(72).alignItems(VerticalAlign.Center)
.margin({ bottom: 6 })
}
}, ...)
}.width('100%').layoutWeight(1).scrollBar(BarState.Off)
}
}.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
}
日志面板以时间轴形式呈现嵌套滚动翻页事件,采用"计数行+图例行+列表区"的三段式布局。列表区有两种状态:无记录时显示空态引导卡,有记录时显示时间轴列表。
使用 List 而非 Column + Scroll 是因为日志列表的条目数可能较多(最多 40 条),List 组件有内置的虚拟化和回收机制,性能更优。不过对于 40 条以内的列表,两者的性能差异可以忽略不计。
16.2 顶部计数与清空
Row() {
Text(`已记录 ${this.swipeLogs.length} 次翻页`).fontSize(13)
.fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Blank()
Button('清空日志')
.fontSize(11).height(32).borderRadius(10)
.fontColor(COLORS.sub).backgroundColor(COLORS.dark)
.enabled(this.swipeLogs.length > 0)
.onClick(() => { this.clearLogs(); })
}.width('100%')
顶部左侧显示总记录数,右侧是"清空日志"按钮。按钮使用 enabled(this.swipeLogs.length > 0) 控制可用性——没有日志时按钮禁用,避免无意义的操作。
清空操作调用 clearLogs() 方法,该方法只需一行代码:this.swipeLogs = []。将数组设为空数组就触发了 UI 的清空刷新。
16.3 图例说明行
Row({ space: 12 }) {
Row({ space: 5 }) {
Circle({ width: 6, height: 6 }).fill(COLORS.orange)
Text('外层楼宇翻页').fontSize(9).fontColor(COLORS.sub)
}
Row({ space: 5 }) {
Circle({ width: 6, height: 6 }).fill(COLORS.blue)
Text('内层检查项翻页').fontSize(9).fontColor(COLORS.sub)
}
Blank()
Text(`当前 ${modeLabel(this.nestedMode)}`).fontSize(9).fontColor(COLORS.text3)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.width('100%')
图例行解释时间轴中两种颜色的含义:橙色圆点代表外层楼宇翻页、蓝色圆点代表内层检查项翻页。右侧显示当前嵌套模式,便于用户将日志与模式对应起来。
这个图例虽然简单,但是理解日志面板的关键——没有图例的话,用户可能不知道橙色和蓝色竖线分别代表什么。好的 UX 设计就是这样:即使信息很明显,也要明确告知,消除用户的认知负担。
16.4 空态引导卡
Column({ space: 6 }) {
Text('🌀 暂无翻页记录').fontSize(13).fontColor(COLORS.text3)
Text('去「频道」Tab 滑动内外层页签,SELF_FIRST 下内层滑到边缘会接力切换外层楼宇')
.fontSize(9).fontColor(COLORS.text3).textAlign(TextAlign.Center)
}.width('100%').padding({ top: 40, bottom: 40 })
.borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
空态卡不是简单地说"暂无数据",而是提供了引导信息——告诉用户去"频道"Tab 操作,并解释了 SELF_FIRST 模式下的边缘接力效果。这种"空态即教学"的设计思路,把空状态从一个"缺陷"变成了一个"教育机会"。
16.5 时间轴列表项
Row({ space: 10 }) {
// 时间列(等高固定列)
Column({ space: 4 }) {
Text(log.time).fontSize(11).fontFamily('monospace').fontColor(COLORS.sub)
Text(log.layer === '外层楼宇' ? 'OUT' : 'IN').fontSize(8)
.fontColor(log.layer === '外层楼宇' ? COLORS.orange : COLORS.blue)
}.width(52).height('100%').alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
// 竖线(固定行高内填满,外橙内蓝)
Column().width(3).height('100%').borderRadius(2)
.backgroundColor(log.layer === '外层楼宇' ? COLORS.orange : COLORS.blue).opacity(0.6)
// 内容卡
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(log.layer === '外层楼宇' ? '外层楼宇' : '内层检查项').fontSize(9)
.fontColor(log.layer === '外层楼宇' ? COLORS.orange : COLORS.blue)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.dark).borderRadius(6)
Text(log.tabName).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Blank()
Text(`${log.fromIdx}→${log.toIdx}`).fontSize(11).fontFamily('monospace').fontColor(COLORS.sub)
}.width('100%')
Text(`${log.mode} · ${log.time}`).fontSize(10).fontColor(COLORS.text3)
}.layoutWeight(1).height('100%').justifyContent(FlexAlign.Center)
}.width('100%').height(72).alignItems(VerticalAlign.Center)
时间轴列表项是日志面板的核心视觉元素,采用"时间列 + 竖线 + 内容卡"的三栏结构。
时间列(固定52px宽): 上方是时间戳(等宽字体),下方是 OUT/IN 标识(外层橙色、内层蓝色)。固定宽度确保时间列对齐整齐,不会因为时间数字不同而左右偏移。
竖线(3px宽): 这是时间轴的标志性元素,使用 height('100%') 填满行高(72px),圆角 2px,60% 不透明度。颜色根据层级切换——外层翻页橙色、内层翻页蓝色。竖线贯穿整行,是时间轴视觉连贯性的关键。
内容卡(自适应宽度): 包含两行信息:
- 第一行:层标签(深色背景的小徽章)+ 页签名(粗体标题)+ 索引变化(等宽字体,如"0→1")
- 第二行:嵌套模式 + 时间戳(弱文本色)
整行固定高度 72px,使用 alignItems(VerticalAlign.Center) 使内容垂直居中。每行底部有 6px 外边距,形成行与行之间的间隔。
这种时间轴设计的优点是信息密度高、视觉层次清晰、色彩编码明确——用户扫一眼就能分辨出哪些是外层翻页、哪些是内层翻页,以及切换的方向和模式。
十七、Tab5 告警面板深度分析
17.1 整体布局架构
@Builder
tabAlarm() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
// 区块①:异常上报表单
Column({ space: 10 }) { ... }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
// 区块②:通知授权状态卡
Column({ space: 8 }) { ... }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
// 区块③:告警铃声行
Column({ space: 8 }) { ... }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
// sound 实时预览
Column({ space: 5 }) { ... }.width('100%').padding(10).borderRadius(8).backgroundColor(COLORS.dark)
// 区块⑤:发布历史
Column({ space: 8 }) { ... }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}.width('100%')
}.scrollBar(BarState.Off).width('100%').layoutWeight(1)
}.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
}
告警面板以压缩模式整合了 Notification Kit 自定义铃声的全链路,包含五大功能区块,全部在一个 Scroll 内纵向排列。与其他 Tab 相比,告警 Tab 的信息密度最高——它把铃声生成、沙箱写入、授权管理、通知发布、历史记录等多个功能模块压缩到一个 Tab 中展示。
这种"压缩模式"的设计是有意为之的。通知铃声链路虽然涉及多个环节(生成→写入→授权→发布→历史),但每个环节的 UI 体量不大,如果分散到多个 Tab 会显得单薄。集中在一个 Tab 内展示,用户可以从上到下走通完整流程,更直观地理解"沙箱自定义铃声"这个特性的全貌。
17.2 异常上报表单
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🚨 异常上报').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Blank()
Text(this.alarmLevel).fontSize(9).fontColor(levelColor(this.alarmLevel))
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.dark).borderRadius(6)
}.width('100%')
TextInput({ placeholder: '异常点位标题,如:B2 车道喷淋管渗漏' })
.fontSize(12).height(40).fontColor(COLORS.title)
.placeholderColor(COLORS.text3).backgroundColor(COLORS.dark).borderRadius(10)
.onChange((value: string) => { this.alarmTitle = value; })
Row({ space: 8 }) {
ForEach(ALARM_LEVELS, (level: string) => {
Text(level).fontSize(11)
.fontColor(this.alarmLevel === level ? COLORS.onMain : COLORS.sub)
.padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(this.alarmLevel === level ? levelColor(level) : COLORS.dark)
.onClick(() => { this.alarmLevel = level; })
}, (level: string) => `level_${level}`)
}.width('100%')
TextArea({ placeholder: '异常描述:位置 / 现象 / 初步判断(选填)' })
.fontSize(11).height(66).fontColor(COLORS.title)
.placeholderColor(COLORS.text3).backgroundColor(COLORS.dark).borderRadius(10)
.onChange((value: string) => { this.alarmDesc = value; })
Text('🔔 发布告警通知').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.onMain)
.width('100%').textAlign(TextAlign.Center).padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.orange).borderRadius(12)
.onClick(() => { this.submitAlarm(); })
Text('通知 id 自增管理,sound 走当前默认铃声的沙箱 uri 链路')
.fontSize(9).fontColor(COLORS.text3).width('100%')
}
异常上报表单是告警 Tab 的主操作区,包含标题输入、等级选择、描述输入和发布按钮。
标题行: 左侧是表单标题"异常上报",右侧是当前告警等级徽章(颜色由 levelColor 映射)。徽章实时反映当前选中的等级,用户在选择等级时可以即时看到颜色变化。
标题输入框: TextInput 组件,40px 高度,深色背景,圆角 10px。placeholder 给出了示例格式(“B2 车道喷淋管渗漏”),引导用户填写规范的异常点位标题。
等级选择 chips: 三个等级(一般/严重/紧急)横排排列,选中态使用对应等级色填充+深暖黑文字,未选中态使用深色背景+弱文字色。chips 使用 14px 圆角,呈现饱满的胶囊形状。点击切换 alarmLevel 状态变量。
描述输入框: TextArea 组件,66px 高度,支持多行输入。placeholder 提示用户填写位置、现象、初步判断等信息。标注为"选填",如果用户不填,发布时会使用默认模板文案。
发布按钮: 全宽橙色按钮,12px 粗体深暖黑文字,圆角 12px。点击调用 submitAlarm() 提交表单并发布通知。这是整个告警 Tab 的核心操作按钮。
底部说明: 一行小字技术说明,告诉用户通知 ID 的管理方式和 sound 字段的链路来源。
17.3 通知授权状态卡
Column({ space: 8 }) {
Row() {
Text('🛡️ 通知授权状态').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Blank()
Text(this.granted ? '已授权' : '未授权').fontSize(10)
.fontColor(this.granted ? COLORS.green : COLORS.red)
}.width('100%')
Row({ space: 8 }) {
Text('重新查询').fontSize(10).fontColor(COLORS.sub).layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.dark).borderRadius(9)
.onClick(() => {
notificationManager.isNotificationEnabled().then((enabled: boolean) => { this.granted = enabled; }).catch(() => {});
})
Text('申请授权').fontSize(10).fontColor(COLORS.onMain).layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.orange).borderRadius(9)
.onClick(() => { this.requestAuth(); })
}.width('100%')
Text('requestEnableNotification(hostCtx) 首次弹系统授权框;曾被拒绝返回 1600004 时拉起 openNotificationSettings(hostCtx) 设置页')
.fontSize(8).fontColor(COLORS.text3).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
授权状态卡提供通知授权的查询和申请功能,是发布通知的前置条件。
状态显示: 顶部右侧显示"已授权"(绿色)或"未授权"(红色),与头部横幅的通知胶囊状态一致。
两个操作按钮:
- 重新查询(次操作,灰色):调用
notificationManager.isNotificationEnabled()重新查询授权状态。适用于用户在设置页修改了通知权限后,回到应用内手动刷新状态的场景。 - 申请授权(主操作,橙色):调用
requestAuth()方法发起授权申请。首次调用弹系统授权框,被拒后拉起设置页。
底部说明: 一行 8px 的技术说明,解释授权申请的两种场景和错误码。虽然字号很小,但对于想了解底层机制的开发者来说是有价值的信息。
17.4 告警铃声行
Column({ space: 8 }) {
Row({ space: 8 }) {
Column({ space: 3 }) {
Text('🔔 默认告警铃声').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(this.currentRingIdx >= 0 && this.currentRingIdx < this.ringList.length
? this.ringList[this.currentRingIdx].name : '未设定').fontSize(10).fontColor(COLORS.orange)
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('生成告警铃声').fontSize(10).fontColor(COLORS.onMain)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.orange).borderRadius(9)
.onClick(() => { this.genAlarmRing(); })
}.width('100%')
ForEach(this.ringList, (ring: RingItem, idx: number) => {
this.ringRow(ring, idx)
}, (ring: RingItem, idx: number) => `${ring.file}_${ring.inSandbox}_${idx}`)
Text('buildWavBytes 正弦波(44 字节头 + 16bit PCM)→ saveRingToSandbox 写入 EL1 filesDir')
.fontSize(8).fontColor(COLORS.text3).width('100%')
}
铃声管理区包含默认铃声信息、生成按钮、铃声列表和技术说明。
默认铃声行: 左侧显示当前默认铃声名(橙色高亮),右侧是"生成告警铃声"主按钮。点击生成按钮调用 genAlarmRing()——生成当前默认铃声的 WAV 音频并写入沙箱,同时记录一条发布历史。
铃声列表: 通过 ForEach 遍历 ringList 数组,每项调用 ringRow 构建函数渲染。列表使用 ring.file_ring.inSandbox_idx 作为键,确保铃声状态变化时列表能正确刷新。
17.4.1 ringRow 铃声精简行
@Builder
ringRow(ring: RingItem, idx: number) {
Row({ space: 8 }) {
Column({ space: 3 }) {
Text(ring.name).fontSize(11).fontColor(idx === this.currentRingIdx ? COLORS.orange : COLORS.title)
.fontWeight(FontWeight.Bold).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${ring.freq}Hz · ${ring.duration}ms · ${ring.size}${ring.inSandbox ? ' · 已在沙箱' : ' · 未生成'}`)
.fontSize(8).fontColor(COLORS.text3).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('生成').fontSize(9).fontColor(COLORS.sub).padding({ left: 9, right: 9, top: 5, bottom: 5 })
.backgroundColor(COLORS.dark).borderRadius(8)
.onClick(() => { this.importRing(idx); })
Text('设默认').fontSize(9).fontColor(idx === this.currentRingIdx ? COLORS.onMain : COLORS.sub)
.padding({ left: 9, right: 9, top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(idx === this.currentRingIdx ? COLORS.orange : COLORS.dark)
.onClick(() => { this.setCurrentRing(idx); })
}.width('100%').padding({ top: 7, bottom: 7 }).borderRadius(9)
.backgroundColor(idx === this.currentRingIdx ? COLORS.dark : COLORS.bg)
.border({ width: idx === this.currentRingIdx ? 1 : 0, color: COLORS.orange })
}
ringRow 是铃声列表的单行组件,采用"信息区 + 生成按钮 + 设默认按钮"的三列布局。
信息区: 上方是铃声名(当前默认项橙色高亮),下方是铃声参数信息(频率/时长/大小/沙箱状态)。沙箱状态有两种显示——已生成时显示"已在沙箱",未生成时显示"未生成"。
生成按钮: 灰色小按钮,点击调用 importRing(idx) 导入对应铃声到沙箱。无论是否已导入,都可以点击重新生成(覆盖原有文件)。
设默认按钮: 当前默认项使用橙色填充+深暖黑文字,非默认项使用灰色背景+灰色文字。点击调用 setCurrentRing(idx) 设置为默认铃声。如果铃声尚未导入沙箱,setCurrentRing 会自动先导入再设为默认。
当前默认项的视觉强调: 使用三重视觉标记——铃声名橙色、行背景加深、橙色边框。三种标记叠加确保用户一眼就能认出哪个是当前默认铃声。
17.5 sound 字段实时预览
Column({ space: 5 }) {
Text('当前通知请求 sound 实时值:').fontSize(9).fontColor(COLORS.text3)
Text(this.getSoundValue()).fontSize(9).fontFamily('monospace').fontColor(COLORS.green)
.width('100%').maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
}.width('100%').padding(10).borderRadius(8).backgroundColor(COLORS.dark)
sound 字段实时预览是一个教学性质的功能块,使用深色背景(dark 色)+ 绿色等宽字体的"代码风格"展示,模拟了开发者调试时在控制台查看变量值的场景。
预览内容调用 getSoundValue() 方法,返回格式为 sound: 'uri::file:///data/storage/.../ring_evacuate.wav' 的完整字符串。用户可以直观地看到 'uri::' + fileUri.getUriFromPath(沙箱路径) 这一关键链路的最终形态。
对于学习 Notification Kit 自定义铃声特性的开发者来说,这个预览块价值很高——它把文档中描述的"sound 字段支持 uri:: 前缀格式"从抽象概念变成了具体可见的字符串,降低了理解门槛。
17.6 发布历史
Column({ space: 8 }) {
Row() {
Text('📜 发布历史').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Blank()
Text(`共 ${this.noticeLogs.length} 条`).fontSize(9).fontColor(COLORS.text3)
}.width('100%')
ForEach(this.noticeLogs, (log: NoticeLog, idx: number) => {
this.noticeRow(log, idx)
}, (log: NoticeLog, idx: number) => `${log.time}_${idx}_${log.title}`)
}
发布历史区域展示通知发布的历史记录,最新的在最上方(unshift 置顶)。列表封顶 8 条,超出时移除最旧的。
17.6.1 noticeRow 发布历史行
@Builder
noticeRow(log: NoticeLog, idx: number) {
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(log.title).fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(log.title.indexOf('失败') >= 0 ? COLORS.red : COLORS.title)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
Text(log.time).fontSize(9).fontFamily('monospace').fontColor(COLORS.text3)
}.width('100%')
Text(log.text).fontSize(10).fontColor(COLORS.sub).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
}.width('100%').padding({ top: 7, bottom: 7 }).borderRadius(9)
.backgroundColor(idx % 2 === 0 ? COLORS.dark : COLORS.bg)
}
noticeRow 是发布历史的单行组件,两行结构:标题行 + 正文行。
标题行: 左侧是通知标题(粗体),右侧是时间戳(等宽字体+弱文本色)。标题颜色有特殊规则——包含"失败"字样的标题使用红色高亮,让失败记录在列表中一眼就能识别出来。
正文行: 通知正文内容,最多显示两行,超出省略。失败记录的正文通常是失败原因(如"错误码 1600004,请先开启通知授权")。
斑马纹背景: 偶数行使用 dark 色背景,奇数行使用 bg 色背景。这种交替底色的"斑马纹"设计在列表项较多时可以帮助用户横向对齐,减少看错行的概率。虽然发布历史最多只有 8 条,斑马纹的实用性有限,但它增添了列表的精致感。
十八、Tab6 我的面板深度分析
18.1 整体布局架构
@Builder
tabMine() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 12 }) {
// 巡检员渐变大卡
Column({ space: 12 }) { ... }.width('100%').padding(16).borderRadius(14)
.linearGradient({ angle: 140, colors: [[COLORS.orangeD, 0], [COLORS.orange, 1]] })
// 绩效清单行
Row() { ... }.width('100%')
Column({ space: 0 }) {
ForEach(PERF_ROWS, (row: PerfRow, idx: number) => {
Row({ space: 10 }) { ... }.width('100%').padding({ top: 11, bottom: 11 })
.borderRadius(10).backgroundColor(COLORS.card)
.margin({ bottom: idx === PERF_ROWS.length - 1 ? 0 : 8 })
}, ...)
}.width('100%')
// 底部说明卡
Column({ space: 5 }) { ... }.padding(10).borderRadius(12).backgroundColor(COLORS.dark).width('100%')
}.width('100%')
}.scrollBar(BarState.Off).width('100%').layoutWeight(1)
}.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
}
我的面板由巡检员渐变大卡、绩效清单和底部说明卡三部分组成,整体采用全 Scroll 布局。与任务 Tab 的"数据驱动"风格不同,我的 Tab 更偏向"个人展示"风格——顶部是一个视觉冲击力强的渐变大卡,下方是结构化的绩效数据。
18.2 巡检员渐变大卡
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🧑🔧').fontSize(30)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.borderRadius(40).backgroundColor(COLORS.mask)
Column({ space: 4 }) {
Text('周正涛').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.onMain)
Text('工号 PB-2041 · 金牌巡检员 · 白班 08:00-20:00').fontSize(10)
.fontColor(COLORS.onMain).opacity(0.85).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}.width('100%')
Divider().strokeWidth(1).color(COLORS.mask)
Row({ space: 8 }) {
Column({ space: 3 }) {
Text('312').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.onMain)
Text('本月点位').fontSize(9).fontColor(COLORS.onMain).opacity(0.8)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('54').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.onMain)
Text('整改闭环').fontSize(9).fontColor(COLORS.onMain).opacity(0.8)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('46.8km').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.onMain)
Text('巡检里程').fontSize(9).fontColor(COLORS.onMain).opacity(0.8)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}.width('100%')
Text('责任区:1-3 号楼 · 地下车库 B1/B2 · 配电房 · 消防泵房').fontSize(9)
.fontColor(COLORS.onMain).opacity(0.75).width('100%').maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
渐变大卡是"我的"Tab 的视觉焦点,使用 140 度线性渐变从深橙到亮橙,营造温暖而专业的氛围。整卡 16px 内边距和 14px 圆角,显得饱满而精致。
头像与身份信息: 左侧是 30px 的巡检员 emoji 头像(🧑🔧),放在半透明黑色背景的圆角框中(borderRadius: 40,接近圆形)。右侧是姓名和职级信息:18px 粗体姓名、10px 次级信息(工号/职级/班次)。所有文字使用 onMain 深暖黑色,确保在橙底上的可读性。
分隔线: 使用 Divider 组件,1px 粗,颜色为半透明黑(COLORS.mask)。在渐变背景上使用半透明分隔线比使用实线更柔和,不会打断渐变的流动感。
三格统计: 本月点位、整改闭环、巡检里程三个核心数字,三等分布局。每格上方是 16px 粗体数字,下方是 9px 说明文字(80% 不透明度)。三个数字分别对应数量、质量、强度三个维度的绩效指标。
责任区文案: 卡片最底部一行小字,列出巡检员负责的区域。75% 不透明度,弱化处理,作为补充信息存在。
18.3 绩效清单
Column({ space: 0 }) {
ForEach(PERF_ROWS, (row: PerfRow, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`).fontSize(11).fontFamily('monospace').fontColor(COLORS.text3)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.dark).borderRadius(7)
Column({ space: 3 }) {
Text(row.label).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(row.note).fontSize(9).fontColor(COLORS.text3).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(row.value).fontSize(13).fontFamily('monospace').fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.orange : COLORS.sub)
}.width('100%').padding({ top: 11, bottom: 11 })
.borderRadius(10).backgroundColor(COLORS.card)
.margin({ bottom: idx === PERF_ROWS.length - 1 ? 0 : 8 })
}, (row: PerfRow, idx: number) => `perf_${row.label}_${idx}`)
}
绩效清单以卡片行的形式展示 6 项月度绩效指标,每行包含序号、指标名+说明、指标值三列。
序号徽标: 等宽字体的数字,放在深灰色圆角背景中,呈现"编号徽章"的视觉效果。序号从 1 开始递增。
指标信息: 左侧是 12px 粗体指标名,下方是 9px 弱文本补充说明。说明文字单行显示,超出省略。
指标值: 13px 等宽字体粗体,右对齐。前三行使用橙色高亮(核心指标),后三行使用蓝灰色(次要指标)。颜色编码让用户快速识别哪些是 KPI。
每行使用卡片底色 + 10px 圆角 + 11px 上下内边距,行间距 8px(最后一行无下边距)。整体风格简洁规整,数据感强。
十九、底部 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.switchTab(index); })
}, (tab: TabMeta) => tab.label)
}.width('100%')
.backgroundColor(COLORS.card)
.border({ width: { top: 1 }, color: COLORS.line })
}
19.1 自绘 Tab 栏的设计决策
底部 Tab 栏采用自绘单排方式渲染 7 个导航项,而非使用系统 Tabs 组件。这是一个经过权衡的设计决策,背后有几个考虑因素:
事件控制的完全性: 页面内部已经有两层 Tabs(外层楼宇 + 内层检查项),如果底部再使用系统 Tabs 组件,三层 Tabs 的 onChange 事件可能产生冒泡或混淆。而自绘 Tab 栏的点击事件完全由 switchTab 方法控制,事件流向清晰可预测。
会话互斥的需求: Tab 切换时需要执行相机会话的释放逻辑(离开相机/对焦 Tab 时释放 session)。自绘方式可以在 switchTab 方法中统一处理切换副作用,而系统 Tabs 的 onChange 回调时机和方式可能不够灵活。
样式定制的自由度: 自绘 Tab 栏可以完全控制图标大小、文字大小、间距、内边距、选中态样式等细节。虽然系统 Tabs 也支持一定程度的定制,但自绘方式的自由度更高。
当然,自绘 Tab 栏也有代价——失去了系统 Tabs 内置的滑动切换动画、滑动手势支持、Tab 栏滚动等特性。但对于一个以功能演示为核心的技术展示平台来说,可控性和清晰度比滑动动画更重要。
19.2 布局结构详解
Tab 栏使用 Row + ForEach + Column 结构,每个 Tab 项是一个纵向排列的图标+文字:
- 图标:17px emoji 图标,不随选中态变色(emoji 本身有颜色)
- 文字:9px 标签文字,选中态警示橙色、未选中态暗蓝灰色
- 间距:图标和文字之间 3px 间距
- 对齐:
justifyContent(FlexAlign.Center)垂直居中 - 宽度:
layoutWeight(1)等分宽度,7 个 Tab 每个占 1/7
Tab 栏整体使用卡片底色(COLORS.card),顶部有 1px 的分割线(COLORS.line),与内容区形成明确的层次边界。上下各 7px 内边距,使 Tab 项在垂直方向上有足够的点击区域。
19.3 点击与切换逻辑
点击 Tab 项调用 switchTab(index) 方法,该方法执行两个操作:
- 如果当前在相机/对焦 Tab 且目标不是,则释放相机会话
- 设置
currentTab = index触发内容区切换
这种"先清理、后切换"的顺序很重要——如果先切换 Tab 再释放会话,可能导致内容区已经切换到新 Tab 了,但相机资源还没释放的短暂不一致状态。虽然这个时间窗口很短,但从严谨性角度,先执行清理再切换状态是更稳妥的做法。
二十、弹窗系统详解
20.1 弹窗整体架构
@Builder
modalOverlay(onClose: () => void) {
Column() {
// 空白遮罩区(点击关闭弹窗)
Column().width('100%').layoutWeight(1)
.onClick(() => { onClose(); })
// 弹窗面板(按激活标志三选一)
if (this.addModal) {
this.panelAdd(onClose)
} else if (this.editModal) {
this.panelEdit(onClose)
} else if (this.delModal) {
this.panelDel(onClose)
}
}.width('100%').height('100%').backgroundColor(COLORS.mask)
.justifyContent(FlexAlign.End)
}
弹窗系统采用全屏遮罩层叠方案,由 modalOverlay 构建函数统一管理。遮罩层使用 Column 纵向排列:上方空白区占满高度(layoutWeight(1))并绑定点击关闭,下方为面板区根据三个布尔标志三选一渲染。
遮罩层设计要点:
- 使用
COLORS.mask(rgba(0,0,0,0.6))半透黑背景,60% 透明度既能遮挡下方内容、又不会完全遮挡 justifyContent(FlexAlign.End)使面板贴底弹出,符合移动端底部弹窗的常见交互模式- 遮罩层作为
Stack的顶层组件,覆盖整个页面(包括头部横幅和底部 Tab 栏),实现真正的全屏模态效果 - 点击上方空白区域关闭弹窗,是移动端弹窗的标准交互方式
三态条件渲染: addModal、editModal、delModal 三个布尔标志控制三种弹窗的显隐。正常情况下同一时间只有一个为 true——openAdd、openEdit、openDel 各自只设置自己的标志为 true,closeAllModals 统一将三个标志都设为 false。
函数参数传递: modalOverlay 接收一个 onClose 回调函数参数,在点击空白遮罩时调用。这个回调在根构建方法中传入的是 () => { this.closeAllModals(); },实现了"点击遮罩关闭所有弹窗"的统一行为。
20.2 panelAdd 新建弹窗
@Builder
panelAdd(onClose: () => void) {
Column({ space: 12 }) {
Text('新建巡检任务').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('录入楼宇分区与巡检项目,提交后置顶到任务清单,初始状态为进行中')
.fontSize(10).fontColor(COLORS.text3).width('100%')
TextInput({ placeholder: '楼宇/分区,如:4 号楼' })
.fontSize(12).height(40).fontColor(COLORS.title)
.placeholderColor(COLORS.text3).backgroundColor(COLORS.dark).borderRadius(10)
.onChange((value: string) => { this.formBuilding = value; })
TextInput({ placeholder: '巡检项目,如:防火门闭门器复查' })
.fontSize(12).height(40).fontColor(COLORS.title)
.placeholderColor(COLORS.text3).backgroundColor(COLORS.dark).borderRadius(10)
.onChange((value: string) => { this.formItem = value; })
Column({ space: 6 }) {
Row() {
Text('初始进度').fontSize(11).fontColor(COLORS.sub)
Blank()
Text(`${this.formProgress}%`).fontSize(13).fontFamily('monospace').fontColor(COLORS.orange)
}.width('100%')
Slider({ value: this.formProgress, min: 0, max: 100, step: 5 }).width('100%')
.blockColor(COLORS.orange).trackColor(COLORS.dark).selectedColor(COLORS.orange)
.onChange((value: number) => { this.formProgress = Math.round(value); })
}.width('100%')
Row({ space: 10 }) {
Button('取消')
.fontSize(12).height(38).borderRadius(10)
.fontColor(COLORS.sub).backgroundColor(COLORS.dark)
.layoutWeight(1)
.onClick(() => { onClose(); })
Button('创建')
.fontSize(12).height(38).borderRadius(10)
.fontColor(COLORS.onMain).backgroundColor(COLORS.orange)
.layoutWeight(1)
.onClick(() => { this.confirmAdd(); })
}.width('100%')
}.padding(16).borderRadius({ topLeft: 16, topRight: 16 })
.backgroundColor(COLORS.card).width('100%')
}
新建弹窗提供楼宇输入、项目名输入和初始进度设置三个表单字段,以及取消/创建两个操作按钮。
表单字段:
- 楼宇输入:
TextInput组件,默认值为"2 号楼"(在openAdd中设置),用户可修改 - 项目名输入:
TextInput组件,初始为空,placeholder 给出示例格式 - 初始进度:Slider 组件,步长 5(0/5/10/…/100),右侧实时显示百分比数值
操作按钮:
- 取消:灰色背景+次级文字色,点击调用
onClose()回调(即closeAllModals),不保存任何修改 - 创建:橙色背景+深暖黑文字,点击调用
confirmAdd()方法
confirmAdd 逻辑:
confirmAdd() {
const building = this.formBuilding.trim() === '' ? '未分配楼宇' : this.formBuilding.trim();
const item = this.formItem.trim() === '' ? '新增巡检点' : this.formItem.trim();
this.taskList.unshift(new TaskItem(building, item, this.formProgress,
this.formProgress >= 100 ? '已完成' : '进行中'));
this.closeAllModals();
}
创建时对输入进行兜底处理——楼宇为空则显示"未分配楼宇",项目名为空则显示"新增巡检点"。初始状态根据进度自动判断:100% 则为"已完成",否则为"进行中"。新任务使用 unshift 置顶插入任务列表,用户可以立即在清单顶部看到新建的任务。
20.3 panelEdit 编辑弹窗
@Builder
panelEdit(onClose: () => void) {
Column({ space: 12 }) {
Text('编辑巡检进度').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text(this.editIdx >= 0 && this.editIdx < this.taskList.length
? `${this.taskList[this.editIdx].building} · ${this.taskList[this.editIdx].item} · ${this.taskList[this.editIdx].status}`
: '').fontSize(11).fontColor(COLORS.sub).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
Column({ space: 6 }) {
Row() {
Text('巡检进度').fontSize(11).fontColor(COLORS.sub)
Blank()
Text(`${this.editProgress}%`).fontSize(13).fontFamily('monospace').fontColor(COLORS.orange)
}.width('100%')
Slider({ value: this.editProgress, min: 0, max: 100, step: 5 }).width('100%')
.blockColor(COLORS.orange).trackColor(COLORS.dark).selectedColor(COLORS.orange)
.onChange((value: number) => { this.editProgress = Math.round(value); })
}.width('100%')
Row({ space: 10 }) {
Button('取消')
.fontSize(12).height(38).borderRadius(10)
.fontColor(COLORS.sub).backgroundColor(COLORS.dark)
.layoutWeight(1)
.onClick(() => { onClose(); })
Button('保存')
.fontSize(12).height(38).borderRadius(10)
.fontColor(COLORS.onMain).backgroundColor(COLORS.orange)
.layoutWeight(1)
.onClick(() => { this.confirmEdit(); })
}.width('100%')
}.padding(16).borderRadius({ topLeft: 16, topRight: 16 })
.backgroundColor(COLORS.card).width('100%')
}
编辑弹窗与新建弹窗结构相似但更简洁——只包含进度 Slider,因为楼宇和项目名在编辑场景下通常不修改,只调整进度。
顶部任务信息: 显示当前编辑的任务的楼宇·项目名·状态,使用单行省略。这行信息让用户确认自己编辑的是正确的任务,避免"编辑错对象"的问题。
confirmEdit 逻辑:
confirmEdit() {
if (this.editIdx >= 0 && this.editIdx < this.taskList.length) {
this.taskList[this.editIdx].progress = this.editProgress;
this.taskList[this.editIdx].status = this.editProgress >= 100 ? '已完成' : '进行中';
}
this.closeAllModals();
}
保存时更新任务的 progress 和 status 两个字段。状态自动判定规则与新建一致——100% 为已完成,否则为进行中。注意这里直接修改数组元素的属性(this.taskList[this.editIdx].progress = ...),而不需要重新赋值数组——这得益于 @Observed 装饰器,框架能检测到对象内部属性的变化并触发 UI 刷新。
20.4 panelDel 删除确认弹窗
@Builder
panelDel(onClose: () => void) {
Column({ space: 12 }) {
Text('删除巡检任务').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text(`确认将「${this.delIdx >= 0 && this.delIdx < this.taskList.length
? this.taskList[this.delIdx].building + ' · ' + this.taskList[this.delIdx].item : ''}」移出今日任务清单?删除后不可恢复。`)
.fontSize(11).fontColor(COLORS.sub).width('100%')
Row({ space: 10 }) {
Button('取消')
.fontSize(12).height(38).borderRadius(10)
.fontColor(COLORS.sub).backgroundColor(COLORS.dark)
.layoutWeight(1)
.onClick(() => { onClose(); })
Button('删除')
.fontSize(12).height(38).borderRadius(10)
.fontColor(COLORS.onMain).backgroundColor(COLORS.red)
.layoutWeight(1)
.onClick(() => { this.confirmDel(); })
}.width('100%')
}.padding(16).borderRadius({ topLeft: 16, topRight: 16 })
.backgroundColor(COLORS.card).width('100%')
}
删除确认弹窗是三种弹窗中最简单的一个——只有确认文案和两个按钮。但它的设计最需要谨慎,因为删除是不可逆操作。
设计要点:
- 明确的标题:"删除巡检任务"直接告知用户这是什么操作
- 具体的对象:在确认文案中列出待删除任务的名称(楼宇·项目),让用户确认删除的是正确的对象
- 风险提示:"删除后不可恢复"明确告知操作的不可逆性
- 危险按钮样式:删除按钮使用红色背景,视觉上强化"危险操作"的语义
confirmDel 逻辑:
confirmDel() {
if (this.delIdx >= 0 && this.delIdx < this.taskList.length) {
this.taskList.splice(this.delIdx, 1);
}
this.closeAllModals();
}
使用 splice(this.delIdx, 1) 从数组中移除指定索引的元素。splice 会修改原数组并触发 UI 刷新,对应的任务卡片会从列表中消失。
20.5 closeAllModals 统一复位
closeAllModals() {
this.addModal = false;
this.editModal = false;
this.delModal = false;
this.editIdx = -1;
this.delIdx = -1;
this.formBuilding = '';
this.formItem = '';
this.formProgress = 0;
this.editProgress = 0;
}
closeAllModals 方法统一关闭所有弹窗并复位相关状态,包括三个弹窗标志、两个索引变量、以及所有表单字段。
统一复位的好处是避免状态残留——比如上次新建时填写了表单但取消了,如果不清空表单字段,下次打开新建弹窗时还会显示上次的内容,造成困惑。同样,编辑索引和删除索引也需要复位,避免下次打开时显示错误的任务信息。
二十一、功能模块对比表
| 模块 | 核心能力 | 关键 API / 组件 | 数据模型 | 交互特色 | 布局范式 |
|---|---|---|---|---|---|
| 任务面板 | 巡检进度管理 | Progress / ForEach / linearGradient / Slider | TaskItem(@Observed) | 大数字卡+进度条清单+柱状图+增删改弹窗 | 全Scroll + 四段式 |
| 相机面板 | 影随人动取证 | XComponent / VideoSession / isControlCenterSupported / getSupportedEffectTypes / enableControlCenter | sessionMode / framingState / framingSupported | Surface预览+三步能力链状态+枚举表+模式切换 | 固定预览区+底部滚动信息区 |
| 对焦面板 | 手动对焦三接口 | isFocusDistanceSupported / setFocusDistance / getFocusDistance / Slider | FocusRecord(@Observed) | 三档预设+滑杆微调+设置读回校验+记录时间线 | 全Scroll + 五段式 |
| 频道面板 | 嵌套滚动接力 | Tabs.nestedScroll(TabsNestedScrollMode) / List / ForEach | InnerCard(@Observed) / SwipeLog(@Observed) | 楼宇×检查项双层Tabs+模式切换+翻页日志 | Tabs嵌套+模式控制区 |
| 日志面板 | 翻页事件时间轴 | List / ForEach / Circle / Column | SwipeLog(@Observed) | 固定行高72+双色竖线+OUT/IN标识+空态引导 | 全Scroll + 时间轴列表 |
| 告警面板 | 沙箱自定义铃声 | buildWavBytes / fileUri.getUriFromPath / notificationManager.publish / TextInput / TextArea | RingItem(@Observed) / NoticeLog(@Observed) | 表单+授权+铃声行+sound预览+发布历史 | 全Scroll + 五区块压缩 |
| 我的面板 | 绩效看板 | linearGradient / ForEach / Column | PERF_ROWS(常量) | 渐变大卡+绩效清单行+核心指标橙色高亮 | 全Scroll + 卡+清单 |
| 弹窗系统 | 任务增删改 | Stack遮罩 / Slider / TextInput / Button | TaskItem(@Observed) | 全屏遮罩+三态面板+表单复位+底部弹出 | Stack层叠+底部Sheet |
| 头部横幅 | 状态总览 | linearGradient / Circle / Row / Column | currentTab / sessionMode / granted / nestedMode | Tab联动副标题+四状态胶囊+呼吸圆点 | 渐变背景+上下两行 |
| 底部Tab栏 | 页面导航 | Row / ForEach / Column | currentTab | 自绘七Tab+选中态高亮+会话互斥释放 | 单排等分+卡片底色 |
21.1 横向对比洞察
从对比表中可以提炼出几个有意思的架构洞察:
数据模型的差异化设计。 六个 @Observed 实体类各有特点:TaskItem 是完整的业务实体(4字段),FocusRecord 和 SwipeLog 是事件记录型实体(时间戳+事件数据),InnerCard 是内容展示型实体(标题+描述),RingItem 是资源型实体(参数+状态),NoticeLog 是消息型实体(标题+正文+时间)。不同类型的实体对应不同的业务场景,没有强求统一的结构。
布局范式的多样性。 7 个 Tab 使用了至少 5 种不同的布局范式:全Scroll四段式(任务)、固定预览区+滚动信息区(相机)、全Scroll五段式(对焦)、Tabs嵌套+控制区(频道)、时间轴列表(日志)、五区块压缩(告警)、卡+清单(我的)。这种多样性既是功能需求不同的结果,也展示了 ArkUI 声明式布局的灵活性——同样的基础组件(Column/Row/Scroll/Stack)可以组合出多种截然不同的界面形态。
状态颜色的贯穿性。 状态颜色映射函数(taskStatusColor、framingStateColor、focusOkColor、levelColor)在多个 Tab 和组件中被复用,确保同一语义状态在全平台范围内颜色一致。这种"语义色"而非"组件色"的设计思想,是大规模应用保持视觉一致性的关键。
@Observed 的合理使用。 6 个实体类都使用了 @Observed 装饰器,但使用场景各不相同:TaskItem 用于编辑时的局部刷新、FocusRecord 和 SwipeLog 用于追加式列表、InnerCard 用于静态展示、RingItem 用于状态更新、NoticeLog 用于追加式历史。这些场景共同的特点是:实体内部属性可能会变化,且变化需要反映到 UI 上。如果属性是只读的(如常量数据),就不需要 @Observed。
二十二、总结与展望
22.1 架构设计总结
本平台以"深蓝防线与警示橙"为视觉主线,在智慧物业安全巡检场景下系统化集成了 HarmonyOS 6.1.1 的四大前沿特性。
Camera Kit 双能力链: VideoSession 的影随人动能力链通过 isControlCenterSupported → getSupportedEffectTypes → enableControlCenter 三步实现巡查跟拍人员居中构图,PhotoSession 的手动对焦三接口通过 isFocusDistanceSupported → setFocusDistance → getFocusDistance 实现铭牌近拍到机房远距的精确对焦,设置值与读回值差值小于 0.01 的校验机制确保对焦可靠性可量化。两种会话互斥存在,通过 switchTab 方法和 releaseSession 五步释放链管理生命周期。
Notification Kit 沙箱铃声链路: 通过 buildWavBytes 正弦波合成 + EL1 沙箱写入 + fileUri.getUriFromPath URI 拼接 + sound: 'uri::' + uri 通知字段填充,实现了零音频文件依赖的告警铃声生成与发布。buildWavBytes 函数以纯代码方式构建 44 字节 WAV 头 + 16bit 单声道 PCM 数据,配合起音包络和自然衰减模拟真实铃声质感。
Tabs 嵌套滚动: 通过 nestedScroll(TabsNestedScrollMode) 实现楼宇频道与检查项的边缘接力,SELF_FIRST/SELF_ONLY 双模式切换让用户直观感知嵌套滚动的行为差异。SwipeLog 日志系统提供了接力效果的可视化验证,将抽象的滚动事件转化为可追溯的时间线。
22.2 工程实践启示
从工程实践角度,本平台的设计沉淀了几条有价值的经验:
单组件多 Builder 的组织模式。 在中等复杂度的应用中(1000~2000 行代码),将所有构建函数和状态变量集中在一个根组件内,可以避免组件间传参的繁琐和状态同步的复杂性。配合清晰的分区注释和命名规范,可维护性完全可以接受。只有当功能模块更加独立、需要团队并行开发时,才需要拆分为多个子组件。
状态变量的分层管理。 30+ 状态变量按照职责划分为 Tab 状态层、弹窗状态层、动画状态层、任务数据层、Camera 成员层、Notification 成员层、嵌套滚动成员层等多个层次,每层职责单一、边界清晰。这种"分层分组"的状态管理方式,比扁平化的变量列表更易于理解和维护。
纯函数的颜色映射体系。 所有状态到颜色的映射都通过独立的纯函数实现,既保证了跨组件一致性,又便于测试和修改。当需要调整某个状态的颜色时,只需修改对应函数一处,所有使用该函数的地方自动同步更新。
资源的生命周期管理。 相机作为高价值系统资源,其生命周期管理尤为关键。releaseSession 的五步释放链、switchTab 的会话互斥、aboutToDisappear 的资源清理,构成了完整的资源管理体系,避免了资源泄漏和状态不一致。
22.3 未来展望
展望未来,平台可在以下方向持续演进:
其一,AI 隐患识别能力。 通过 Camera Kit 的实时预览流接入端侧 AI 模型,自动识别消防器材缺失、通道堵塞、设备异常等常见隐患,变"人工发现"为"智能预警"。影随人动能力可以保证巡检人员始终在画面中,AI 则聚焦于人员周围的环境异常检测,两者结合实现"跟拍+识别"的智能巡检闭环。
其二,对焦与影随人动联动。 将手动对焦与影随人动模式联动,在 AUTO_FRAMING 跟拍过程中根据场景智能切换对焦距离——当检测到近处设备时自动切换为近拍模式,当检测到全景场景时自动切换为远距模式,实现"近拍铭牌→远拍全景"的无缝过渡。
其三,铃声语义化扩展。 沙箱铃声链路可进一步支持多段音频拼接与参数化合成,让告警铃声携带语义信息——如"消防"类铃声模拟消防车警笛频率、"电气"类铃声模拟电弧滋滋声、"管道"类铃声模拟水滴报警声,使用户仅凭铃声就能判断告警类型,提升通知辨识度。
其四,嵌套滚动的惯性接力。 结合手势速度实现惯性接力阈值控制——当内层快速滑动时提前触发外层切换,慢速滑动时仅在内层滚动,使接力行为更符合物理直觉。同时支持双向嵌套(上下滚动与左右滑动的接力),丰富嵌套滚动的应用场景。
其五,协同巡检能力。 引入分布式数据管理,实现多巡检员的任务分配与进度同步,告警通知同时推送给责任人与值班主管,支持任务转交和协同处理,构建端到端的物业安全闭环管理生态。
其六,巡检数据可视化。 引入 Canvas 绘制能力,将月度隐患数据从简单柱状图升级为更丰富的可视化图表——隐患类型饼图、巡检轨迹热力图、整改时效趋势图等,让数据驱动决策在巡检场景中落地。
综上所述,本平台在智慧物业安全巡检场景下系统化展示了 HarmonyOS 6.1.1 的四大核心特性,从色彩体系到数据模型、从组件架构到能力链封装,每一层都经过精心设计。四大特性并非孤立存在,而是围绕"巡检取证"这一核心场景形成有机整体——影随人动保证构图稳定、手动对焦保证细节清晰、嵌套滚动保证任务浏览高效、自定义铃声保证告警及时触达。这种"场景牵引、特性落地"的设计思路,对于其他行业应用的 HarmonyOS 适配也具有参考价值。
附录: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 应用的功能开发。
更多推荐



所有评论(0)