一、技术前置概述

1.1 HarmonyOS 与全场景智慧生态

在这里插入图片描述

HarmonyOS(鸿蒙)是华为面向全场景智慧生活打造的分布式操作系统,其核心理念是"一次开发,多端部署"。与传统的移动操作系统不同,HarmonyOS 从底层设计之初就采用了分布式软总线、分布式数据管理、分布式任务调度等核心技术,使得手机、平板、智慧屏、车机、穿戴设备能够以统一的语言协同工作。对于开发者而言,HarmonyOS 提供了一套完整的开发框架——基于 ArkUI 的声明式 UI 范式和 ArkTS 语言,让开发者可以用接近自然思维的方式描述界面与逻辑。

在这里插入图片描述
在 HarmonyOS 的技术体系中,应用以"Ability"为基本单位。一个 Ability 可以是页面型的(PageAbility / UIAbility),也可以是服务型的(ServiceAbility / ServiceExtensionAbility)。UIAbility 承载用户可感知的界面,通过窗口与用户交互。本应用作为一个单页面复杂应用,其入口由 @Entry 装饰器标注的 struct 构成,这正是 ArkUI 声明式范式在 HarmonyOS 上的标准入口模式。

在这里插入图片描述

1.2 ArkUI 声明式 UI 范式

ArkUI 是 HarmonyOS 的 UI 开发框架,提供两套开发范式:类 Web 范式(基于 HTML/CSS/JS)和声明式范式(基于 ArkTS)。本应用采用的是声明式范式,这是 HarmonyOS 官方主推的方向。声明式范式的核心思想是:开发者描述界面"是什么",而非"怎么做"。通过链式调用的方式,将组件的属性、事件、子组件以声明的方式串联起来,框架负责在状态变化时高效地更新 DOM 树。

在这里插入图片描述
ArkUI 声明式范式有以下几个核心概念:

  • struct 与装饰器:每个 UI 组件是一个被 @Component 装饰的 struct,入口组件额外标注 @Entry。struct 内部通过 build() 方法描述 UI 结构。
  • 状态管理:@State、@Prop、@Link、@Observed、@ObjectLink 等装饰器构成状态管理体系。@State 是组件内部状态,变化时触发组件重渲染;@Observed 标注的类实例在属性变化时也能驱动 UI 更新,这对于管理列表数据的增删改尤为重要。
  • @Builder:用于将 UI 片段抽取为可复用的函数,类似于其他框架中的"渲染函数"或"组件模板"。@Builder 函数可以接收参数,实现参数化视图。
  • 链式属性:组件的样式(fontSize、fontColor、padding、backgroundColor、borderRadius 等)和布局(width、height、layoutWeight 等)均通过链式调用设置,读起来接近自然语言。

在这里插入图片描述

1.3 ArkTS 语言特性

ArkTS 是 TypeScript 的超集,在 TypeScript 的基础上做了面向声明式 UI 的约束和扩展。本应用中大量使用了 ArkTS 的类型系统:interface 定义数据结构、泛型类管理状态、枚举值传递语义。值得注意的是,ArkTS 对空安全有严格要求,代码中随处可见的可选链(?.)和空值合并(??)正是这一约束的体现。

在这里插入图片描述

1.4 Camera Kit 相机开发套件

Camera Kit 是 HarmonyOS 提供的相机能力集合,涵盖了从相机设备管理、预览输出、拍照、录像到高级能力(如美颜、人像、影随人动)的全链路接口。本应用深入使用了 Camera Kit 的两大核心场景:

在这里插入图片描述
VideoSession 与 AUTO_FRAMING 影随人动:VideoSession 是录像模式的会话对象,它独有"控制中心"(ControlCenter)能力。通过 isControlCenterSupported() 查询设备是否支持控制中心,getSupportedEffectTypes() 获取支持的效果类型枚举列表,再通过 enableControlCenter(true) 让系统接管构图。AUTO_FRAMING(影随人动)是 HarmonyOS 6.1.1 版本新增的效果类型,其值为 2,能够在人物移动时自动调整画面构图,使人始终居中。这一能力在"巡园跟拍"场景中极具实用价值——园丁在阳台走动照料植物时,相机始终自动追踪人物,确保浇花、修剪等操作全程入镜。

PhotoSession 与手动对焦三接口:PhotoSession 是拍照模式的会话对象,挂载了 ManualFocus 手动对焦能力。三个核心接口构成完整的对焦控制链路:isFocusDistanceSupported() 同步查询设备是否支持手动对焦距离设置;setFocusDistance(value) 设置对焦距离,值域为 [0.0, 1.0],0.0 代表最近对焦(微距),1.0 代表最远对焦(远景);getFocusDistance() 读回当前实际对焦距离值,用于校验设置是否生效。这种"设置 → 读回 → 校验"的模式是硬件能力验证的经典范式。

Camera Kit 的会话管理遵循严格的配置流程:beginConfig() → addInput() → addOutput() → commitConfig() → start()。同一时间一个 cameraInput 只能绑定一个 Session,因此 VideoSession 和 PhotoSession 之间是互斥关系,切换时必须先释放旧会话再创建新会话。

1.5 Image Kit 图像处理套件

Image Kit 提供图像编解码、像素图操作、图像元数据读写等能力。本应用聚焦于 WebP 格式的元数据读写,这是 HarmonyOS API 24(对应 6.1.1 版本)新增的能力。

WebP 元数据五字段:WebP 格式的元数据包含五个关键字段——canvasWidth(画布宽)、canvasHeight(画布高)、delayTime(钳制后的帧延迟,单位 ms,系统会将其钳制到 [100, 65535] 区间)、unclampedDelayTime(未钳制的原始帧延迟)、loopCount(循环次数,0 表示不限次数)。这五个字段中,所有字段都是可选的,当字段不存在时返回 undefined,应用层用 -1 占位表示"未提供"。

类型化元数据读取:readImageMetadataByType(types, index) 是类型化读取接口,接收一个 MetadataType[] 数组(如 [image.MetadataType.WEBP_METADATA])和帧索引(静态 WebP 恒传 0),返回包含各类型元数据的对象。这种类型化设计避免了不同格式元数据混在一起,让调用方按需读取。

元数据写回:writeImageMetadata(meta) 接收一个 ImageMetadata 对象(其 webPMetadata 属性挂载 WebP 五字段),将元数据写回文件。写回要求文件以 READ_WRITE 方式打开。写入后必须重建 ImageSource 再读,因为同一实例可能命中解码缓存,读到旧值。

ImagePacker 编码:image.createImagePacker() 创建编码器,packToData(pixelMap, options) 将 PixelMap 编码为目标格式(如 WebP)的字节流,quality 参数控制编码质量。

1.6 Canvas 2D 绘制能力

ArkUI 提供的 Canvas 组件基于 CanvasRenderingContext2D,与 Web 标准的 Canvas 2D API 高度一致。支持路径绘制、渐变填充、文本渲染、线条样式等全套 2D 绘制能力。Canvas 组件通过 onReady 回调通知画布就绪,在就绪之前不能进行绘制操作。本应用利用 Canvas 实现了两个核心可视化:养护完成率进度环(drawRing)和近 12 天阳台温湿度折线图(drawLine),两者均通过 setInterval 定时器驱动的 breath 布尔值实现"呼吸动画"——弧长波动和数据点半径波动的视觉效果。

1.7 CoreFileKit 与沙箱文件系统

fileIo 来自 @kit.CoreFileKit,提供同步和异步的文件操作接口。HarmonyOS 应用在沙箱内有专属文件目录(ctx.filesDir),应用可以自由读写,无需额外权限。本应用在沙箱目录下创建 plant_snapshot.webp 文件,作为 WebP 元数据读写的载体。文件以 READ_WRITE | CREATE | TRUNC 模式打开,确保文件不存在时创建、存在时清空重写。

1.8 AbilityKit 与权限管理

abilityAccessCtrl 来自 @kit.AbilityKit,提供权限申请与校验能力。CAMERA 权限属于 user_grant 级别,需要在运行时动态申请。通过 createAtManager() 获取 AtManager 实例,调用 requestPermissionsFromUser(context, permissions) 弹出系统权限申请弹窗,用户授权后 authResults[0] === 0 表示已授权。本应用在启动相机会话前都会先申请权限,确保操作合法。

二、应用全景与架构总览

2.1 应用定位与业务场景

本应用名为"绿手指·智慧阳台园艺",定位为家庭植物养护的智能助手。它将"家庭园艺"这一生活场景与 HarmonyOS 的高端设备能力深度融合:用影随人动相机记录巡园养护过程,用手动对焦拍摄植物病斑细节,用 WebP 元数据读写管理生长快照档案,用 Canvas 可视化呈现养护数据趋势。应用围绕一个阳台园丁的日常展开——管理在养植物、跟踪养护任务、记录环境数据、管理肥料工具、回顾月度养护成果。

2.2 整体架构图

以下是应用的架构层次图(文本格式):

┌─────────────────────────────────────────────────────────────────┐
│                      绿手指 · 智慧阳台园艺应用                     │
├─────────────────────────────────────────────────────────────────┤
│  入口层:@Entry @Component struct Page(aboutToAppear 生命周期)   │
├─────────────────────────────────────────────────────────────────┤
│  状态管理层                                                       │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐            │
│  │ Tab状态   │ │ 弹窗状态  │ │ 动画状态  │ │ 业务状态  │           │
│  │currentTab│ │addModal  │ │ breath   │ │plantList │           │
│  │          │ │editModal │ │ timer    │ │taskList  │           │
│  │          │ │delModal  │ │          │ │focusRecs │           │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘            │
├─────────────────────────────────────────────────────────────────┤
│  能力套件层                                                       │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐              │
│  │ Camera Kit   │  │ Image Kit   │  │   Canvas    │             │
│  │ VideoSession │  │ WebP编码    │  │ drawRing    │             │
│  │ PhotoSession │  │ 元数据读写  │  │ drawLine    │             │
│  │ AUTO_FRAMING │  │ 回读校验    │  │ 呼吸联动    │             │
│  └─────────────┘  └─────────────┘  └─────────────┘              │
├─────────────────────────────────────────────────────────────────┤
│  视图层(@Builder)                                               │
│  ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────────┐           │
│  │头部│ │花园│ │相机│ │对焦│ │工坊│ │元数│ │ 我的   │           │
│  │    │ │Tab │ │Tab │ │Tab │ │Tab │ │Tab │ │ Tab    │           │
│  └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────────┘           │
│  ┌──────────────────────────────────────┐                        │
│  │  弹窗系统:panelAdd / panelEdit / panelDel  │                  │
│  └──────────────────────────────────────┘                        │
├─────────────────────────────────────────────────────────────────┤
│  基础设施层                                                       │
│  ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐     │
│  │AbilityKit │  │CoreFileKit│  │BasicSvcKit│  │ 颜色/常量  │     │
│  │权限申请    │  │沙箱文件    │  │BusinessErr│  │ 数据模型  │     │
│  └───────────┘  └───────────┘  └───────────┘  └───────────┘     │
└─────────────────────────────────────────────────────────────────┘

2.3 页面导航与数据流

应用采用底部 6 Tab 单排导航,各 Tab 承载不同业务域:

                    ┌─── 花园 Tab(index=0)
                    │    植物状态横滑 → 进度环 → 温湿度折线
                    │    → 植物列表 → 今日任务
                    │
                    ├─── 相机 Tab(index=1)
                    │    权限卡 → 模式切换 → XComponent预览
                    │    → 影随人动能力链 → 效果枚举表
                    │
                    ├─── 对焦 Tab(index=2)
           currentTab─┤    能力查询 → 三档预设 → 焦距滑杆
                    │    → 设置/读回 → FocusRecord时间线
                    │
                    ├─── 工坊 Tab(index=3)
                    │    纹理三选一 → 编码参数 → WebP生成器
                    │    → 像素画预览 → 沙箱落盘卡
                    │
                    ├─── 元数据 Tab(index=4)
                    │    五字段卡 → 写入控制台 → 回读校验
                    │    → MetaOpLog操作日志
                    │
                    └─── 我的 Tab(index=5)
                         园丁大卡 → 肥料工具清单
                         → 月度柱状图 → 特性栈速览

2.4 三大核心特性数据流

【特性 A:Camera Kit 影随人动 + 手动对焦】
XComponent.onLoad → surfaceReady=true
       ↓
用户点击"开启影随人动"
       ↓
requestCameraPermission → getCameraManager → 选后摄
       ↓
createSession<VideoSession>(NORMAL_VIDEO)
       ↓
beginConfig → addInput → addOutput → commitConfig
       ↓
queryFraming:
  isControlCenterSupported → getSupportedEffectTypes
       ↓                ↓
  不支持→结束       includes(AUTO_FRAMING)
                          ↓
                   enableControlCenter(true) → 影随人动已启用

用户点击"切换手动对焦"
       ↓
releaseSession(VideoSession) → createSession<PhotoSession>(NORMAL_PHOTO)
       ↓
queryFocusSupport: isFocusDistanceSupported()
       ↓
applyFocus: setFocusDistance → getFocusDistance → 校验差值<0.01
       ↓
FocusRecord 入时间线(设置值 → 读回值 → 结论)
【特性 B:Image Kit WebP 元数据读写】
工坊Tab:texPixelAt生成像素画 → createPixelMap → ImagePacker.packToData
       ↓
fileIo.openSync(READ_WRITE|CREATE|TRUNC) → writeSync → closeSync
       ↓
沙箱文件 plant_snapshot.webp 就绪

元数据Tab:
  readMeta: createImageSource(fd) → readImageMetadataByType([WEBP_METADATA],0)
       ↓
  五字段快照(canvasWidth/Height/delayTime/unclampedDelay/loopCount)
       ↓
  writeMeta: 字面量构造WebPMetadata → writeImageMetadata → release
       ↓
  verifyRead: 重建ImageSource → 再读一遍 → 比对delayTime/loopCount
       ↓
  MetaOpLog日志流(生成/读取/写入/回读四类)
【特性 C:Canvas 绘制 + 呼吸动画】
aboutToAppear → setInterval(1000ms) → breath = !breath
       ↓                           ↓
  ringReady? → drawRing()     lineReady? → drawLine()
       ↓                           ↓
  背景环 + 进度弧              背景网格 + 渐变填充
  + 中心百分比                  + 温湿度双折线
  + 弧长0.85~1.0波动            + 末点半径呼吸放大

三、逐段代码深度解析

3.1 模块导入:套件依赖声明

import { camera } from '@kit.CameraKit';
import { abilityAccessCtrl } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';

应用的第一行代码就揭示了其技术栈全貌。五个 import 语句分别从五个 Kit 中引入核心模块:

camera 来自 CameraKit,是本应用最重的依赖。它提供了从相机设备枚举、输入流创建、预览输出绑定、会话管理到效果控制的全套接口。应用中用到的 camera.getCameraManager()、camera.CameraPosition.CAMERA_POSITION_BACK、camera.SceneMode.NORMAL_VIDEO、camera.ControlCenterEffectType.AUTO_FRAMING、camera.VideoSession、camera.PhotoSession 等全部来自这个模块。

abilityAccessCtrl 来自 AbilityKit,专用于权限管理。CAMERA 权限是 user_grant 级别,不能在配置文件中静态授予,必须在运行时通过 requestPermissionsFromUser 弹窗让用户授权。这个模块是相机功能的前置门槛。

image 来自 ImageKit,承担 WebP 编码、PixelMap 创建、元数据读写三项任务。image.createPixelMap()、image.createImagePacker()、image.createImageSource()、image.MetadataType.WEBP_METADATA、image.WebPMetadata、image.ImageMetadata 等都是 ImageKit 的核心 API。

fileIo 来自 CoreFileKit,负责沙箱文件的打开、写入、关闭。WebP 元数据写回要求文件以可写方式打开,因此文件操作是元数据读写链路中不可或缺的一环。

BusinessError 来自 BasicServicesKit,是 HarmonyOS 统一的错误类型。所有 try-catch 块中捕获的异常都被转型为 BusinessError,通过 err.code 和 err.message 获取错误码和描述信息。错误码是诊断问题的关键线索,例如 7700202 表示"不支持该元数据类型",7700204 表示"参数非法"。

3.2 颜色系统:统一主题色管理

interface ColorPalette {
  bg: string;      // 页面背景(晨露白)
  card: string;    // 卡片底色(纯白)
  chip: string;    // 内嵌块底色(豆绿浅底)
  title: string;   // 主标题(森墨绿)
  sub: string;     // 副标题(苔绿)
  text3: string;   // 三级弱文本(灰绿)
  green: string;   // 森绿(主色)
  greenD: string;  // 森绿深色
  orange: string;  // 陶土橙(辅助暖色)
  blue: string;    // 信息蓝
  red: string;     // 警示红(病虫害 / 移除)
  line: string;    // 分割线
  tabOn: string;   // Tab 选中色
  mask: string;    // 弹窗遮罩
  greenFade: string; // 森绿渐隐(折线图渐变终点)
}
const COLORS: ColorPalette = {
  bg: '#F4F7F2',
  card: '#FFFFFF',
  chip: '#E8EFE4',
  title: '#24301F',
  sub: '#5E7055',
  text3: '#8FA088',
  green: '#4C8C4A',
  greenD: '#37703A',
  orange: '#D97E4A',
  blue: '#4E8BB0',
  red: '#D95E5E',
  line: '#DFE8DA',
  tabOn: '#4C8C4A',
  mask: 'rgba(0,0,0,0.5)',
  greenFade: 'rgba(76,140,74,0.06)'
};

颜色系统是应用视觉一致性的基石。这里采用了"接口定义结构 + 常量实例化"的模式:先用 interface ColorPalette 定义颜色面板的完整字段结构,每个字段都附带注释说明其用途,然后用 const COLORS 实例化。这种做法的好处是双重的——一方面 TypeScript 的类型检查会在编译时验证所有颜色字段都已赋值,另一方面 IDE 的智能提示能在使用 COLORS. 时列出所有可选字段及注释。

从配色策略看,应用采用了"浅色·晨露白 + 森绿 + 陶土橙"的三色系。#F4F7F2 的晨露白作为页面背景,比纯白多了一丝绿意,营造自然氛围;#4C8C4A 的森绿是主色,贯穿 Tab 选中、进度环、植物健康状态、按钮主色等所有"正向"语义;#D97E4A 的陶土橙是辅助暖色,用于缺水警示、对焦模式、待完成进度等"需关注"语义;#D95E5E 的警示红专门用于病虫害和移除操作。此外还有 blue 信息蓝用于湿度折线和参数标注,text3 灰绿用于三级弱文本。mask 使用 rgba(0,0,0,0.5) 半透明黑色作为弹窗遮罩,greenFade 使用极低透明度的森绿作为折线图渐变填充的终点色。

3.3 常量定义:Tab 元信息与业务预设

interface TabMeta {
  icon: string;
  label: string;
}
const TAB_LIST: TabMeta[] = [
  { icon: '🌱', label: '花园' },
  { icon: '📷', label: '相机' },
  { icon: '🔬', label: '对焦' },
  { icon: '🖼️', label: '工坊' },
  { icon: '📊', label: '元数据' },
  { icon: '👤', label: '我的' }
];

底部导航栏的 6 个 Tab 以常量数组形式定义。TabMeta 接口只有 icon 和 label 两个字段,分别用 emoji 图标和中文标签表示。将 Tab 配置抽为常量有两个好处:一是底部导航栏的 @Builder 函数可以用 ForEach 遍历渲染,代码简洁;二是修改 Tab 配置时只需改这一处,不用在 UI 代码中逐个搜索。

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 新增' }
];

这段定义了 Camera Kit 控制中心支持的三种效果类型。type 字段对应 ControlCenterEffectType 枚举的数值:0 是美颜(BEAUTY),从 API 20 开始支持;1 是人像(PORTRAIT),同样从 API 20 开始;2 是影随人动(AUTO_FRAMING),是 6.1.1(API 24)新增的能力。在 UI 中,这三条信息以枚举表的形式展示,当 type 为 2 且本机已声明 AUTO_FRAMING 时,会额外显示"已声明"标签,让用户直观了解设备能力。

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 · 阳台全景' }
];

手动对焦的预设档位定义了三档,对应园艺场景的三种典型拍摄需求。微距(0.1)用于拍摄叶片病斑、虫体细节;中距(0.5)用于记录整株形态和株型;远景(0.9)用于拍摄阳台全景布局。将预设抽为常量,让用户一键切换到常用焦距,比每次拖滑杆更高效。

const WEBP_QUALITY: number = 90;
const CANVAS_SIZE: number = 96;
const DELAY_PRESETS: number[] = [120, 200, 500];
const LOOP_PRESETS: number[] = [0, 1, 3, 5];

WebP 编码的常量参数:WEBP_QUALITY 90 是默认编码质量(高质量),CANVAS_SIZE 96 定义了像素画的边长(96×96 像素,既保证细节又控制文件大小)。DELAY_PRESETS 是帧延迟预设,120ms/200ms/500ms 都在系统钳制区间 [100, 65535] 内。LOOP_PRESETS 是循环次数预设,0 表示不限次数,1/3/5 分别表示循环 1/3/5 次。

interface TextureInfo {
  key: string;
  name: string;
  algo: string;
}
const TEXTURES: TextureInfo[] = [
  { key: 'diag', name: '斜纹', algo: '对角斜纹 (row+col)%N' },
  { key: 'checker', name: '棋盘', algo: '棋盘 (⌊row/8⌋+⌊col/8⌋)%N' },
  { key: 'band', name: '横带', algo: '横带 ⌊row/6⌋%N' }
];

生长快照的纹理三选一定义了三种像素画算法。斜纹纹理使用 (row+col) % N 对角线取模,生成 45 度斜条纹;棋盘纹理使用 (⌊row/8⌋+⌊col/8⌋) % N 以 8 像素为块的棋盘格;横带纹理使用 ⌊row/6⌋ % N 以 6 像素为高的横条纹。N 是五色调色板的长度,三种算法都从同一组五色中取色,但排列规律不同,生成风格迥异的视觉纹理。

3.4 环境数据与月度数据常量

interface EnvPoint {
  day: string;   // 日期标签
  temp: number;  // 白天气温(℃)
  hum: number;   // 空气湿度(%)
}
const ENV_DATA: EnvPoint[] = [
  { day: '8/24', temp: 27, hum: 62 },
  { day: '8/25', temp: 29, hum: 55 },
  // ... 共 12 个数据点
  { day: '9/4', temp: 26, hum: 71 }
];
const TEMP_MAX: number = 35;   // 气温纵轴满量程(℃)
const HUM_MAX: number = 100;   // 湿度纵轴满量程(%)

环境数据是 Canvas 折线图的数据源,包含近 12 天的白天气温和空气湿度。12 个数据点刚好对应折线图的横轴密度——太稀疏看不出趋势,太密集标签会重叠。TEMP_MAX 35℃ 和 HUM_MAX 100% 是纵轴满量程,绘制时用 temp / TEMP_MAX 计算归一化坐标。从数据看,8/26-8/27 出现高温低湿(31℃/48%),8/28-8/29 转为低温高湿(24℃/81%),这种温湿度反相关的关系在折线图上一目了然。

const MONTH_NAME: string[] = ['04', '05', '06', '07', '08', '09'];
const MONTH_CARE: number[] = [42, 56, 68, 74, 89, 96];
const MONTH_MAX: number = 100;

月度养护完成次数是"我的"Tab 柱状图的数据源,展示近 6 个月的养护趋势。从 04 月的 42 次逐月上升到 09 月的 96 次,呈现出园丁养护习惯逐步养成的正向曲线。MONTH_MAX 100 是柱状图满量程,柱高按 val / MONTH_MAX 归一化。

3.5 辅助函数:颜色转换与状态映射

function hexToRgba(hex: string): number {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return 0xFF000000 | (b << 16) | (g << 8) | r;
}

这个函数将 #RRGGBB 格式的十六进制颜色字符串转换为 0xFFBBGGRR 格式的 32 位整数。转换的关键在于字节序——HarmonyOS 的 RGBA_8888 像素缓冲区按 R、G、B、A 四字节排列,但在 Uint32Array 中以小端序存储,因此 32 位整数的字节布局是 AABBGGRR(高位到低位)。函数先用 parseInt 解析出 R、G、B 三个分量,然后通过位运算组装:0xFF000000 是 Alpha 通道固定 255(完全不透明),b << 16 将蓝色放在第三字节,g << 8 将绿色放在第二字节,r 放在最低字节。最后用按位或合并。这个函数是像素画生成的核心工具——所有纹理算法最终都要通过它将 COLORS 中的颜色字符串转换为可写入像素缓冲区的数值。

function fmtField(v: number, unit: string): string {
  return v < 0 ? '未提供' : `${v}${unit}`;
}

元数据字段格式化函数。WebP 元数据的五个字段都是可选的,当字段不存在时应用层用 -1 占位。这个函数在渲染时将 -1 转换为用户友好的"未提供"文案,非负值则拼接单位(如"96 px"、“200 ms”、“3 次”)。这种"占位值 → 显示文案"的转换在 UI 层统一处理,避免了数据层与展示层的耦合。

function plantStateColor(s: string): string {
  if (s === '健康') return COLORS.green;
  if (s === '缺水') return COLORS.orange;
  if (s === '病虫害') return COLORS.red;
  return COLORS.text3;
}

植物状态到颜色的映射函数。健康(森绿)、缺水(陶土橙)、病虫害(警示红)、休眠(灰绿弱化)四种状态各自对应一种颜色。这个函数在花园 Tab 的植物状态横滑条、植物列表左色条、状态标签等处反复调用,确保状态颜色的一致性。

function readStateColor(s: string): string {
  if (s.indexOf('已生成') >= 0 || s.indexOf('成功') >= 0 || s.indexOf('一致') >= 0)
    return COLORS.green;
  if (s.indexOf('失败') >= 0 || s.indexOf('差异') >= 0)
    return COLORS.red;
  if (s.indexOf('中') >= 0)
    return COLORS.blue;
  return COLORS.text3;
}

WebP 生成/读取状态的颜色映射。这个函数使用字符串包含匹配而非精确匹配,因为状态文案是动态拼接的——如"已生成 12.3KB"、“失败(7700204)”、"一致"等都包含关键词。成功类(森绿)、失败类(红)、进行中(蓝)、空闲(灰绿)四档颜色让用户一眼就能判断操作结果。

function framingStateColor(s: string): string {
  if (s === '影随人动已启用') return COLORS.green;
  if (s === '控制中心不支持' || s === 'AUTO_FRAMING 未声明')
    return COLORS.orange;
  if (s.indexOf('失败') >= 0 || s.indexOf('被拒') >= 0 || s.indexOf('未就绪') >= 0)
    return COLORS.red;
  return COLORS.text3;
}

影随人动状态的颜色映射,区分了"已启用"(森绿,理想状态)、“不支持/未声明”(陶土橙,能力缺失但不报错)、“失败/被拒/未就绪”(红,异常状态)三档。

function sessionLabel(mode: string): string {
  if (mode === 'video') return 'VideoSession · 影随人动宿主';
  if (mode === 'photo') return 'PhotoSession · 手动对焦宿主';
  return 'idle · 未启动会话';
}

会话模式到中文说明的映射。这个函数在相机 Tab 和对焦 Tab 都有使用,让用户清楚当前会话的类型和它承载的能力——VideoSession 是影随人动的宿主,PhotoSession 是手动对焦的宿主,idle 表示未启动。

function distanceLabel(v: number): string {
  if (v < 0.3) return '微距 · 叶片病斑与虫体细节';
  if (v < 0.7) return '中距 · 整株形态与株型记录';
  return '远景 · 阳台全景与区域布局';
}

对焦距离到景别文案的映射。0.3 和 0.7 两个阈值将 [0, 1] 区间三分为微距、中距、远景,每个景别附带园艺拍摄场景说明。当用户拖动焦距滑杆时,下方实时显示当前景别,帮助用户选择合适的对焦距离。

function focusOkColor(ok: string): string {
  if (ok === '已生效') return COLORS.green;
  if (ok.indexOf('失败') >= 0) return COLORS.red;
  return COLORS.orange;
}

对焦校验结果的颜色映射。"已生效"为森绿,"失败"为红,"读回偏差"为陶土橙。这个函数在对焦记录时间线中为每条记录的结论标签着色。

3.6 数据模型:@Observed 实体类

@Observed export class PlantCard {
  name: string;
  breed: string;
  lastWater: string;
  state: string;
  waterGap: number;
  constructor(name: string, breed: string, lastWater: string, state: string, waterGap: number) {
    this.name = name;
    this.breed = breed;
    this.lastWater = lastWater;
    this.state = state;
    this.waterGap = waterGap;
  }
}

PlantCard 是在养植物的数据实体,标注了 @Observed 装饰器。@Observed 的作用是让类的实例在被 @State 或 @ObjectLink 持有时,其属性变化能驱动 UI 重渲染。如果没有 @Observed,修改数组元素属性不会触发 ForEach 更新。PlantCard 的五个字段涵盖了植物管理的核心信息:名称(如"琴叶榕")、品种与摆放位置(如"客厅南窗位 · 直射 4h")、上次浇水时间、当前状态、浇水周期天数。

const PLANT_LIST: PlantCard[] = [
  new PlantCard('琴叶榕', '客厅南窗位 · 直射 4h', '昨天 08:12', '健康', 5),
  new PlantCard('龟背竹', '北阳台花架 · 散射光', '前天 19:40', '健康', 7),
  new PlantCard('蝴蝶兰', '书房飘窗 · 水苔栽培', '3 天前 10:05', '缺水', 4),
  new PlantCard('薄荷', '厨房小窗 · 每日掐尖', '今早 07:30', '健康', 2),
  new PlantCard('玉露', '南阳台多肉区 · 控水', '6 天前 15:20', '休眠', 12),
  new PlantCard('柠檬树', '露台东侧 · 全日照', '昨天 18:02', '病虫害', 3),
  new PlantCard('绿萝', '玄关鞋柜上 · 耐阴', '前天 09:15', '健康', 6),
  new PlantCard('文竹', '茶室角落 · 忌暴晒', '4 天前 16:48', '缺水', 5)
];

初始植物列表包含 8 株常见家庭植物,每株都有具体的位置、浇水时间和状态。这些数据覆盖了健康、缺水、病虫害、休眠四种状态,让 UI 能展示全谱状态色彩。品种信息中的位置描述(“客厅南窗位”、"北阳台花架"等)帮助园丁快速定位植物。

@Observed export class TaskItem {
  icon: string;
  name: string;
  target: string;
  done: number;
  total: number;
  constructor(icon: string, name: string, target: string, done: number, total: number) {
    this.icon = icon;
    this.name = name;
    this.target = target;
    this.done = done;
    this.total = total;
  }
}
const TASK_LIST: TaskItem[] = [
  new TaskItem('💧', '浇水巡检', '按植物卡周期逐盆补水', 4, 6),
  new TaskItem('🌱', '施肥补养', '水溶肥 1:1000 兑水灌根', 2, 3),
  new TaskItem('✂️', '修剪黄叶', '摘除病叶疏剪过密枝', 1, 2),
  new TaskItem('🌡️', '温湿度巡检', '校准阳台传感器读数', 5, 5),
  new TaskItem('🐛', '病虫害检查', '翻叶背查红蜘蛛蚧壳虫', 0, 2)
];

TaskItem 是今日养护任务实体,done 和 total 字段分别记录已完成数和计划总数。5 条任务覆盖浇水、施肥、修剪、温湿度巡检、病虫害检查五项日常养护,每条的 done/total 汇总后就是进度环的分子分母。温湿度巡检已完成 5/5(全完成),病虫害检查 0/2(未开始),形成了鲜明的完成度对比。

@Observed export class FocusRecord {
  time: string;
  distance: number;
  readback: number;
  ok: string;
  constructor(distance: number, readback: number, ok: string) {
    this.distance = distance;
    this.readback = readback;
    this.ok = ok;
    const d = new Date();
    this.time = `${String(d.getHours()).padStart(2, '0')}:` +
      `${String(d.getMinutes()).padStart(2, '0')}:` +
      `${String(d.getSeconds()).padStart(2, '0')}`;
  }
}

FocusRecord 是手动对焦操作记录实体。构造函数接收设置值、读回值和校验结论,在构造时自动生成 HH:mm:ss 格式的时间戳。padStart(2, '0') 确保时分秒都是两位数(如 09:05:03 而非 9:5:3)。readback 为 -1 时表示读回失败,UI 中显示"—“。ok 字段存储"已生效”、“读回偏差"或"失败(错误码)”。

@Observed export class WebpMetaSnapshot {
  canvasWidth: number;
  canvasHeight: number;
  delayTime: number;
  unclampedDelayTime: number;
  loopCount: number;
  constructor(w: number, h: number, d: number, u: number, l: number) {
    this.canvasWidth = w;
    this.canvasHeight = h;
    this.delayTime = d;
    this.unclampedDelayTime = u;
    this.loopCount = l;
  }
}

WebpMetaSnapshot 是 WebP 元数据五字段的快照实体。所有字段用 -1 占位表示"未提供"(undefined),渲染时通过 fmtField 转为"未提供"文案。这个类被读取结果和回读校验共用——读取结果用 metaSnapshot 字段持有,回读校验用 verifySnapshot 字段持有,两者结构完全一致但来源不同(前者是首次读取,后者是写入后重开 ImageSource 再读)。

@Observed export class MetaOpLog {
  op: string;
  detail: string;
  time: string;
  constructor(op: string, detail: string) {
    this.op = op;
    this.detail = detail;
    const d = new Date();
    this.time = `${String(d.getHours()).padStart(2, '0')}:` +
      `${String(d.getMinutes()).padStart(2, '0')}:` +
      `${String(d.getSeconds()).padStart(2, '0')}`;
  }
}

MetaOpLog 是元数据操作日志实体,记录每次操作的类型(生成样图/读取元数据/写入元数据/回读校验)和结果描述(含错误码)。日志通过 unshift 置顶,最新的操作始终在最上方。

@Observed export class SupplyItem {
  icon: string;
  name: string;
  kind: string;
  note: string;
  constructor(icon: string, name: string, kind: string, note: string) {
    this.icon = icon;
    this.name = name;
    this.kind = kind;
    this.note = note;
  }
}
const SUPPLY_LIST: SupplyItem[] = [
  new SupplyItem('🌿', '奥绿 318s 缓释肥', '缓释肥', '60 天缓释 · 换盆时撒施'),
  new SupplyItem('💧', '花多多 1 号', '水溶肥', '生长期 1:1000 兑水灌根'),
  new SupplyItem('🍋', '硫酸亚铁', '调酸肥', '柠檬栀子 15 天一次调酸'),
  new SupplyItem('🪴', '泥炭珍珠岩', '介质', '3:7 配比 · 透气防烂根'),
  new SupplyItem('✂️', '圆头修枝剪', '工具', '摘黄叶疏枝 · 不伤茎皮'),
  new SupplyItem('🌡️', '三合一土检仪', '工具', '湿度/光照/PH 一插即读')
];

SupplyItem 是肥料与工具清单实体,6 条数据覆盖缓释肥、水溶肥、调酸肥、介质、工具五大类。kind 字段用于左色条颜色区分——缓释肥/水溶肥为森绿,调酸肥为蓝,介质/工具为陶土橙。

3.7 页面结构体与状态管理

@Entry
@Component
struct Page1293 {
  @State currentTab: number = 0;
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = -1;
  @State delIdx: number = -1;
  @State breath: boolean = false;
  timer: number = -1;

页面结构体由 @Entry @Component 标注,是应用的唯一入口。状态变量分为几组:Tab 状态(currentTab)控制当前显示的页面;弹窗状态(addModal/editModal/delModal 三个布尔值 + editIdx/delIdx 两个索引)控制三个弹窗的显示和操作目标;动画状态(breath 布尔值 + timer 定时器 ID)驱动 Canvas 呼吸动画。

  @State plantList: PlantCard[] = PLANT_LIST;
  @State taskList: TaskItem[] = TASK_LIST;
  @State streakDays: number = 128;
  @State harvestCount: number = 23;
  @State formName: string = '';
  @State formBreed: string = '';
  @State formGap: number = 5;
  @State editGap: number = 5;

花园业务状态:plantList 和 taskList 分别持有植物列表和任务列表,初始值来自常量数组。streakDays 128 天连续打卡和 harvestCount 23 次收获是园丁画像数据。formName/formBreed/formGap 是添加植物弹窗的表单字段,editGap 是编辑浇水周期弹窗的滑杆值。

  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 = 0.5;
  @State focusRecords: FocusRecord[] = [];
  @State permState: string = '未申请';

Camera Kit 成员变量分为两类:private 修饰的是不触发 UI 更新的原生对象(XComponentController、CameraInput、PreviewOutput、VideoSession、PhotoSession),@State 修饰的是需要驱动 UI 更新的状态(surfaceReady、sessionMode、framingState 等)。这种区分很关键——原生对象变化不需要重渲染,用 private 避免不必要的渲染开销。

sessionMode 是三态变量:idle(未启动)、video(VideoSession 运行中)、photo(PhotoSession 运行中)。framingState 是影随人动能力链的状态文案,framingSupported 记录本机是否声明 AUTO_FRAMING。focusDistance 是当前对焦距离,focusRecords 是对焦记录时间线。permState 是 CAMERA 权限状态。

  @State texIdx: number = 0;
  @State webpQuality: number = WEBP_QUALITY;
  @State pixelMap?: image.PixelMap = undefined;
  @State webpPath: string = '';
  @State genState: string = '待生成';
  @State metaSnapshot?: WebpMetaSnapshot = undefined;
  @State writeDelay: number = 200;
  @State writeLoop: number = 3;
  @State verifySnapshot?: WebpMetaSnapshot = undefined;
  @State opLogs: MetaOpLog[] = [];

WebP 成员变量:texIdx 是当前选中的纹理索引(0/1/2),webpQuality 是编码质量,pixelMap 是生成的像素图(用于预览),webpPath 是沙箱文件路径,genState 是生成状态文案。metaSnapshot 是读取到的五字段快照,writeDelay/writeLoop 是待写入的帧延迟和循环次数,verifySnapshot 是写入后回读的快照,opLogs 是操作日志流。

  private ringCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
  private lineCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
  @State ringReady: boolean = false;
  @State lineReady: boolean = false;

Canvas 成员:两个 CanvasRenderingContext2D 实例分别服务于进度环和折线图。RenderingContextSettings(true) 开启抗锯齿。ringReady/lineReady 两个布尔值标记画布是否就绪——Canvas 组件的 onReady 回调触发后才置为 true,在此之前不能调用 drawRing/drawLine。

3.8 生命周期方法

  aboutToAppear() {
    this.focusRecords.unshift(new FocusRecord(0.9, 0.9, '已生效'));
    this.focusRecords.unshift(new FocusRecord(0.5, 0.5, '已生效'));
    this.focusRecords.unshift(new FocusRecord(0.1, 0.12, '读回偏差'));
    this.opLogs.unshift(new MetaOpLog('初始化', '生长快照工坊就绪,请先在「工坊」生成 WebP 样图'));
    this.timer = setInterval(() => {
      this.breath = !this.breath;
      if (this.ringReady) {
        this.drawRing();
      }
      if (this.lineReady) {
        this.drawLine();
      }
    }, 1000);
  }

aboutToAppear 是组件生命周期方法,在组件创建后、build 执行前调用。这里做了四件事:

第一,预置 3 条对焦记录作为时间线的种子数据。这三条记录精心设计了三种校验结果:0.9 远景设置后读回 0.9(已生效)、0.5 中距设置后读回 0.5(已生效)、0.1 微距设置后读回 0.12(读回偏差,差值 0.02 超过 0.01 阈值)。这让用户在首次进入对焦 Tab 时就能看到时间线的完整展示。

第二,预置 1 条初始化操作日志,引导用户先去工坊 Tab 生成 WebP 样图。

第三,启动 1000ms 间隔的 setInterval 定时器。每次触发时翻转 breath 布尔值,然后检查两个画布是否就绪——如果就绪则调用对应的 draw 方法重绘。这就是"呼吸动画"的实现机制:breath 的翻转驱动 drawRing 中的弧长系数在 0.85~1.0 之间波动,驱动 drawLine 中的末点半径在 3.5~4.5 之间波动,形成微妙的"活着"的视觉效果。定时器 ID 保存在 this.timer 中,供 aboutToDisappear 清理。

  aboutToDisappear() {
    clearInterval(this.timer);
    this.releaseSession();
  }

aboutToDisappear 在组件销毁前调用,做两件清理:清除定时器(防止内存泄漏和后台持续执行)、释放相机会话(防止后台占用摄像头资源)。这两步是资源管理的最佳实践——任何在 aboutToAppear 中获取的资源,都应在 aboutToDisappear 中释放。

  switchTab(idx: number) {
    if (this.currentTab === 1 && idx !== 1) {
      this.releaseSession();
    }
    this.currentTab = idx;
  }

Tab 切换方法有一个关键的资源管理逻辑:当从相机 Tab(index=1)切换到其他 Tab 时,必须释放相机会话。这是因为 cameraInput 同一时间只能绑定一个 session,而且相机是排他性资源——如果应用切到后台或切到其他 Tab 后不释放相机,其他应用将无法使用相机。这个逻辑确保了离开相机页时资源被及时回收。

3.9 Camera Kit 方法群:权限申请

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

权限申请是相机功能的前置步骤。方法的执行流程是:

  1. 获取 UI 上下文和宿主上下文。getUIContext().getHostContext() 返回 AbilityContext,这是权限申请的必要参数。上下文为空时设置状态为"上下文未就绪"并返回 false。
  2. 创建 AtManager 实例。abilityAccessCtrl.createAtManager() 是权限管理的入口。
  3. 调用 requestPermissionsFromUser 弹出系统权限申请弹窗。这个方法是异步的,用户点击"允许"或"拒绝"后返回结果对象。
  4. 判断授权结果。result.authResults 是授权结果数组,与请求的权限列表一一对应。authResults[0] === 0 表示已授权(0 = GRANT),其他值表示被拒或需重新申请。
  5. 更新 permState 状态并返回布尔值。

整个方法被 try-catch 包裹,捕获异常时设置"申请失败(错误码)"状态并打印错误日志。这种防御式编程确保权限申请过程中的任何异常都不会导致应用崩溃。

3.10 Camera Kit 方法群:影随人动模式

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

影随人动模式的启动方法,开头是四层前置检查:

第一层检查 Surface 就绪状态。XComponent 的 Surface 是相机预览的渲染目标,必须先就绪才能创建 PreviewOutput。如果 Surface 未就绪(XComponent 的 onLoad 回调未触发),直接返回。

第二层检查会话模式。如果已经是 video 模式,说明 VideoSession 已在运行,无需重复启动。

第三层处理互斥切换。如果当前是 photo 模式,必须先释放 PhotoSession,因为 cameraInput 同一时间只能绑定一个 session。

第四层申请权限。CAMERA 权限是运行时权限,每次启动相机前都需确认已授权。

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

获取 CameraManager 后,遍历设备支持的所有摄像头,找到后摄(CAMERA_POSITION_BACK)。巡园跟拍场景使用后摄作为主镜头,因为后摄通常有更高的分辨率和更丰富的能力。如果设备没有后摄(罕见情况),设置状态并返回。

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

查询后摄在录像模式(NORMAL_VIDEO)下支持的输出能力,取第一个预览 Profile 作为预览输出配置。Profile 包含分辨率、帧率等信息,取第一个是最简单的选择策略。

      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);

三步创建相机管线的核心对象:CameraInput(从设备创建,需异步 open)、PreviewOutput(从 Profile 和 Surface ID 创建,Surface ID 来自 XComponentController)、VideoSession(通过泛型方法 createSession 创建,传入场景模式 NORMAL_VIDEO)。泛型 createSession 是 Camera Kit 的设计亮点——同一个方法通过不同泛型参数返回不同类型的 Session 对象。

      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';

Session 的标准配置流程:先注册 error 事件监听(会话级错误通过回调通知),然后 beginConfig 进入配置模式,addInput 绑定相机输入,addOutput 绑定预览输出,commitConfig 提交配置。配置提交后、start 之前,调用 queryFraming 执行影随人动能力链。最后 start 启动会话,更新 sessionMode 为 video。

注意 queryFraming 在 start 之前调用——这是因为 enableControlCenter 需要在会话配置阶段操作,而非运行时动态切换。

3.11 AUTO_FRAMING 能力链三步

  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})`;
    }
  }

这是本应用 Camera Kit 的核心特性之一——AUTO_FRAMING 影随人动能力链,严格三步:

第一步:isControlCenterSupported()。这是同步方法,查询当前 VideoSession 是否支持"控制中心"机制。控制中心是 Camera Kit 的高级能力容器,美颜、人像、影随人动等效果都挂在控制中心下。如果设备不支持控制中心,能力链在此终止,设置状态为"控制中心不支持"。

第二步:getSupportedEffectTypes() + includes(AUTO_FRAMING)。在控制中心支持的前提下,获取设备声明的所有效果类型列表,检查是否包含 AUTO_FRAMING(值为 2)。不同设备支持的效果不同——有些设备支持控制中心但不支持 AUTO_FRAMING(可能只支持美颜和人像),此时设置状态为"AUTO_FRAMING 未声明"。

第三步:enableControlCenter(true)。在设备声明了 AUTO_FRAMING 的前提下,调用 enableControlCenter 传入 true,让系统接管构图。系统会自动检测画面中的人物,在人移动时调整画面构图和缩放,使人始终居中。这一步用 try-catch 包裹,因为硬件操作可能失败(如传感器未就绪),失败时设置状态为"接管失败(错误码)"。

三步能力链的设计哲学是"渐进降级"——每一步都有明确的失败状态,用户能清楚知道能力链在哪一步中断。这比一个笼统的"不支持"要友好得多。

3.12 Camera Kit 方法群:手动对焦模式

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

切换到手动对焦模式的逻辑与 startVideoMode 对称:检查是否已在 photo 模式、如果在 video 模式则先释放、检查 Surface 就绪、申请权限。四个前置检查确保切换过程的安全性。

    try {
      const ctx = this.getUIContext().getHostContext();
      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;
        }
      }
      const capability = manager.getSupportedOutputCapability(device, camera.SceneMode.NORMAL_PHOTO);
      const profile = capability.previewProfiles.length > 0
        ? capability.previewProfiles[0] : undefined;
      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();

与 startVideoMode 的流程几乎一致,区别在于:场景模式从 NORMAL_VIDEO 改为 NORMAL_PHOTO,Session 类型从 VideoSession 改为 PhotoSession,能力查询从 queryFraming 改为 queryFocusSupport。会话启动后立即查询手动对焦能力,让用户知道当前设备是否支持 setFocusDistance。

3.13 会话释放与对焦三接口

  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}`);
    }
  }

会话释放是五步链路:先保存引用到局部变量,再将成员变量置 undefined(防止异步操作期间被其他代码引用),然后依次 off 错误监听 → stop 会话 → release 会话 → release 预览输出 → close 相机输入。这个顺序是严格的后到前释放——后创建的对象先释放,先创建的对象后释放,符合资源管理的"栈式"释放原则。

先置 undefined 再释放的设计值得注意:如果在 await session.stop() 期间,其他代码(如定时器回调)访问 this.videoSession,会得到 undefined 而非一个正在释放的会话对象,避免了竞态条件。

  queryFocusSupport() {
    if (this.photoSession === undefined) {
      this.focusSupported = false;
      return;
    }
    try {
      this.focusSupported = this.photoSession.isFocusDistanceSupported();
    } catch (e) {
      this.focusSupported = false;
      console.error(`focus support failed: ${(e as BusinessError).message}`);
    }
  }

手动对焦能力查询。isFocusDistanceSupported() 是 PhotoSession 的同步方法,返回布尔值表示设备是否支持手动设置对焦距离。这个方法只在 PhotoSession 上存在,VideoSession 没有此接口——这就是为什么手动对焦必须切换到 PhotoSession。查询结果存入 focusSupported,UI 中的"设置焦距"和"读回校验"按钮根据此值启用/禁用。

  applyFocus() {
    if (this.photoSession === undefined) {
      this.focusRecords.unshift(new FocusRecord(this.focusDistance, -1, '失败(未启动拍照会话)'));
      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})`));
    }
  }

applyFocus 是手动对焦三接口的完整验证流程——设置 → 读回 → 校验:

setFocusDistance(value):设置对焦距离,值域 [0.0, 1.0]。0.0 是最近对焦距离(微距),1.0 是最远(无限远)。这个方法是同步的,调用后硬件立即调整对焦马达。

getFocusDistance():读回当前实际对焦距离。同样是同步方法。读回值可能与设置值不同——硬件可能有最小步进精度,或对焦马达到达了物理极限。

校验:用 Math.abs(readBack - setting) < 0.01 判断设置是否生效。0.01 的阈值考虑到硬件步进精度,差值在此范围内认为已生效。差值超过阈值则标记为"读回偏差",让用户知道设置未完全生效。

每次操作生成一条 FocusRecord,通过 unshift 置顶到时间线。时间线超过 20 条时 pop 移除最旧的一条,防止无限增长。这种"置顶 + 上限"的模式在操作日志、对焦记录等"最近 N 条"场景中通用。

3.14 Image Kit 方法群:像素画生成

  texPixelAt(texIdx: number, row: number, col: number): number {
    const palette: string[] = [COLORS.green, COLORS.orange, COLORS.blue, COLORS.greenD, COLORS.red];
    if (texIdx === 0) {
      return hexToRgba(palette[(row + col) % palette.length]);
    } else if (texIdx === 1) {
      return hexToRgba(palette[(Math.floor(row / 8) + Math.floor(col / 8)) % palette.length]);
    } else {
      return hexToRgba(palette[Math.floor(row / 6) % palette.length]);
    }
  }

纹理像素算法,根据纹理索引和像素坐标返回对应的颜色值。五色调色板从 COLORS 中取五种颜色(森绿、陶土橙、信息蓝、森绿深、警示红),三种纹理分别用不同的数学公式将坐标映射到调色板索引:

斜纹纹理 (row + col) % 5:对角线上的像素 row+col 值相同,因此同一条对角线上颜色相同,形成 45 度斜条纹。

棋盘纹理 (⌊row/8⌋ + ⌊col/8⌋) % 5:将画布按 8×8 像素分块,每块取调色板中的一种颜色,形成棋盘格。

横带纹理 ⌊row/6⌋ % 5:将画布按 6 像素高分行,每行取一种颜色,形成水平条纹。

三种算法虽然简单,但生成的纹理视觉效果差异明显,足以演示 WebP 编码的能力。

  async genWebpFile() {
    this.genState = '生成中…';
    try {
      const total = CANVAS_SIZE * CANVAS_SIZE;
      const buf = new ArrayBuffer(total * 4);
      const pixels = new Uint32Array(buf);
      for (let i = 0; i < total; i++) {
        const row = Math.floor(i / CANVAS_SIZE);
        const col = i % CANVAS_SIZE;
        pixels[i] = this.texPixelAt(this.texIdx, row, col);
      }

WebP 文件生成方法。第一步是生成像素画:创建 96×96×4 = 36864 字节的 ArrayBuffer,用 Uint32Array 视图操作(每个 32 位整数代表一个像素)。遍历所有像素,将行列坐标转换为线性索引,调用 texPixelAt 获取颜色值写入。这里用 Uint32Array 而非 Uint8Array 是因为每像素 4 字节刚好是一个 32 位整数,写入效率更高。

      const opts: image.InitializationOptions = {
        size: { width: CANVAS_SIZE, height: CANVAS_SIZE },
        pixelFormat: image.PixelMapFormat.RGBA_8888
      };
      const pm = await image.createPixelMap(buf, opts);
      if (this.pixelMap !== undefined) {
        this.pixelMap.release();
      }
      this.pixelMap = pm;

将像素缓冲区创建为 PixelMap。createPixelMap 是异步方法,接收 ArrayBuffer 和初始化选项(尺寸 + 像素格式 RGBA_8888)。创建后先释放旧的 PixelMap(如果存在),防止重复生成时内存增长——每次生成都创建新的 PixelMap 但不释放旧的,会导致内存泄漏。

      const packer = image.createImagePacker();
      const webpBuf = await packer.packToData(pm, { format: 'image/webp', quality: this.webpQuality });
      await packer.release();

用 ImagePacker 将 PixelMap 编码为 WebP 字节流。packToData 接收 PixelMap 和编码选项(格式 + 质量),返回 Uint8Array 格式的字节流。编码完成后立即 release packer,释放编码器资源。

      const ctx = this.getUIContext().getHostContext();
      const dir = ctx ? ctx.filesDir : '';
      if (dir === '') {
        this.genState = '沙箱目录未就绪';
        return;
      }
      const path = dir + '/plant_snapshot.webp';
      const file = fileIo.openSync(path,
        fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
      fileIo.writeSync(file.fd, webpBuf);
      fileIo.closeSync(file);
      this.webpPath = path;
      this.genState = `已生成 ${(webpBuf.byteLength / 1024).toFixed(1)}KB`;

将 WebP 字节流写入沙箱文件。文件路径是 ctx.filesDir + '/plant_snapshot.webp',filesDir 是应用专属沙箱目录,无需额外权限。文件以 READ_WRITE | CREATE | TRUNC 模式打开——READ_WRITE 确保后续元数据写回时可写,CREATE 在文件不存在时创建,TRUNC 在文件存在时清空。writeSync 将字节流同步写入文件,closeSync 关闭文件描述符。最后更新 webpPath 和 genState(含文件大小,单位 KB)。

      this.metaSnapshot = undefined;
      this.verifySnapshot = undefined;
      this.opLogs.unshift(new MetaOpLog('生成样图',
        `${TEXTURES[this.texIdx].name}纹理 ${CANVAS_SIZE}×${CANVAS_SIZE} · 质量${this.webpQuality}`));
    } catch (e) {
      const err = e as BusinessError;
      this.genState = `生成失败(${err.code})`;
      this.opLogs.unshift(new MetaOpLog('生成样图', `失败:code ${err.code},${err.message}`));
    }
  }

生成成功后清除旧的元数据快照(metaSnapshot 和 verifySnapshot),因为文件已重新生成,旧快照不再有效。然后添加操作日志记录纹理名称、画布尺寸和编码质量。异常时设置失败状态并记录错误码和消息。

3.15 Image Kit 方法群:元数据读取

  async readMeta() {
    if (this.webpPath === '') {
      this.opLogs.unshift(new MetaOpLog('读取元数据', '请先在「工坊」生成 WebP 样图'));
      return;
    }
    try {
      const file = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
      const source = image.createImageSource(file.fd);
      const types: image.MetadataType[] = [image.MetadataType.WEBP_METADATA];
      const meta = await source.readImageMetadataByType(types, 0);
      const webp = meta.webPMetadata;
      this.metaSnapshot = new WebpMetaSnapshot(
        webp?.canvasWidth ?? -1, webp?.canvasHeight ?? -1,
        webp?.delayTime ?? -1, webp?.unclampedDelayTime ?? -1,
        webp?.loopCount ?? -1);
      await source.release();
      fileIo.closeSync(file);

元数据读取方法。前置检查确保已生成 WebP 文件。然后以 READ_WRITE 模式打开文件(虽然读取只需 READ,但统一用 READ_WRITE 为后续写入预留),用文件描述符创建 ImageSource。

核心调用 readImageMetadataByType(types, 0):types 是元数据类型数组,这里只请求 WEBP_METADATA;index 是帧索引,静态 WebP 恒传 0(多帧动画 WebP 才需要指定帧号)。返回的 meta 对象的 webPMetadata 属性包含五个字段,用可选链 ?. 和空值合并 ?? 将 undefined 转为 -1 占位。这种"undefined → -1"的转换在数据层统一处理,UI 层只需用 fmtField 渲染。

读取完成后释放 ImageSource 和关闭文件,然后添加操作日志记录五字段摘要。

3.16 Image Kit 方法群:元数据写入与回读校验

  async writeMeta() {
    if (this.webpPath === '') {
      this.opLogs.unshift(new MetaOpLog('写入元数据', '请先在「工坊」生成 WebP 样图'));
      return;
    }
    try {
      const file = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
      const source = image.createImageSource(file.fd);
      const webpMeta: image.WebPMetadata = {
        canvasWidth: CANVAS_SIZE,
        canvasHeight: CANVAS_SIZE,
        delayTime: this.writeDelay,
        unclampedDelayTime: this.writeDelay,
        loopCount: this.writeLoop
      };
      const meta: image.ImageMetadata = { webPMetadata: webpMeta };
      await source.writeImageMetadata(meta);
      await source.release();
      fileIo.closeSync(file);
      this.opLogs.unshift(new MetaOpLog('写入元数据',
        `帧延迟=${this.writeDelay}ms,循环=${this.writeLoop === 0 ? '不限' : this.writeLoop} 次`));
      await this.verifyRead();

元数据写入方法。WebPMetadata 用字面量构造,五个字段直接赋值:canvasWidth/Height 设为 CANVAS_SIZE(96),delayTime 和 unclampedDelayTime 都设为 writeDelay(用户选择的帧延迟),loopCount 设为 writeLoop(用户选择的循环次数)。然后将其挂在 ImageMetadata 的 webPMetadata 属性上,调用 writeImageMetadata 写入。

写入成功后立即调用 verifyRead 进行回读校验。这种"写入后立即验证"的模式是数据完整性的保障——确保写入的数据确实被正确持久化。

  async verifyRead() {
    try {
      const file = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
      const source = image.createImageSource(file.fd);
      const meta = await source.readImageMetadataByType([image.MetadataType.WEBP_METADATA], 0);
      const webp = meta.webPMetadata;
      this.verifySnapshot = new WebpMetaSnapshot(
        webp?.canvasWidth ?? -1, webp?.canvasHeight ?? -1,
        webp?.delayTime ?? -1, webp?.unclampedDelayTime ?? -1,
        webp?.loopCount ?? -1);
      await source.release();
      fileIo.closeSync(file);
      const ok = this.verifySnapshot!.delayTime === this.writeDelay
        && this.verifySnapshot!.loopCount === this.writeLoop;
      this.opLogs.unshift(new MetaOpLog('回读校验',
        ok ? '已生效:delayTime/loopCount 与写入值一致'
          : `差异:delayTime=${fmtField(this.verifySnapshot!.delayTime, 'ms')},` +
          `loopCount=${fmtField(this.verifySnapshot!.loopCount, ' 次')}`));

回读校验的关键在于"重建 ImageSource"——不是复用写入时的 ImageSource 实例,而是重新 openSync + createImageSource。这是因为同一 ImageSource 实例可能命中解码缓存,读到写入前的旧值。重建实例强制重新解码文件,确保读到的是写入后的最新数据。

校验逻辑比较 delayTime 和 loopCount 两个字段是否与写入值一致。canvasWidth/Height 不参与校验,因为它们是文件固有属性,写入时也是设为 CANVAS_SIZE,不太可能出错。校验结果记录到操作日志,“已生效"或"差异”。

3.17 Canvas 绘制方法群:进度环

  drawRing() {
    const ctx = this.ringCtx;
    ctx.antialias = true;
    const cx = 90;
    const cy = 90;
    const r = 66;
    const total = this.totalCount();
    const progress = total > 0 ? this.doneCount() / total : 0;
    const breathVal = this.breath ? 1.0 : 0.85;

进度环绘制方法。圆心 (90, 90) 和半径 66 基于 180×180 的画布尺寸计算。progress 是已完成任务数与计划总数的比值。breathVal 是呼吸动画系数——breath 为 true 时为 1.0(满弧长),false 时为 0.85(弧长缩短 15%),形成弧长波动的视觉效果。

    // ① 背景环
    ctx.beginPath();
    ctx.arc(cx, cy, r, 0, Math.PI * 2);
    ctx.strokeStyle = COLORS.chip;
    ctx.lineWidth = 12;
    ctx.stroke();

第一步绘制背景环:一个完整的圆,使用豆绿浅底色(COLORS.chip),线宽 12px。这个环是进度弧的"底衬",未完成的部分显示为浅色。

    // ② 进度弧
    ctx.beginPath();
    ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * progress * breathVal);
    ctx.strokeStyle = COLORS.green;
    ctx.lineWidth = 12;
    ctx.lineCap = 'round';
    ctx.stroke();

第二步绘制进度弧:从 12 点方向(-Math.PI/2)起笔,顺时针绘制 2π × progress × breathVal 弧度。森绿色,线宽 12px,lineCap 设为 round 让弧的两端是圆头而非平头。progress × breathVal 让弧长随呼吸动画波动——breath 为 false 时弧长缩短到 85%,true 时恢复 100%,形成"呼吸"效果。

    // ③ 中心百分比
    ctx.fillStyle = COLORS.title;
    ctx.font = 'bold 26px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(Math.round(progress * 100).toString() + '%', cx, cy + 2);
    // ④ 中心副标签
    ctx.font = '10px sans-serif';
    ctx.fillStyle = COLORS.text3;
    ctx.fillText('今日养护完成率', cx, cy + 24);

第三步和第四步在圆心绘制文字:26px 粗体的百分比大字和 10px 的副标签"今日养护完成率"。textAlign = 'center' 让文字水平居中,cy + 2 和 cy + 24 是垂直位置的微调——前者略向下偏移补偿字体的视觉中心偏上,后者放在下方作为说明。

3.18 Canvas 绘制方法群:温湿度折线

  drawLine() {
    const ctx = this.lineCtx;
    ctx.antialias = true;
    const w = ctx.width > 0 ? ctx.width : 340;
    const h = 170;
    const pad = 26;
    const n = ENV_DATA.length;
    const stepX = (w - pad * 2) / (n - 1);
    const plotH = h - pad * 2 - 14;
    const baseY = h - pad - 14;

折线图绘制方法。画布宽度优先取 ctx.width(实际渲染宽度),fallback 到 340。pad 26 是四周留白,n 是数据点数(12),stepX 是相邻数据点的水平间距,plotH 是绘图区高度,baseY 是基线 Y 坐标。14 是底部日期标签的预留高度。

    // ① 背景网格
    ctx.strokeStyle = COLORS.line;
    ctx.lineWidth = 1;
    for (let i = 0; i <= 3; i++) {
      const y = pad + plotH * i / 3;
      ctx.beginPath();
      ctx.moveTo(pad, y);
      ctx.lineTo(w - pad, y);
      ctx.stroke();
    }

绘制 3 条横向网格线(四等分绘图区),使用分割线色(COLORS.line),1px 线宽。网格线帮助用户估算数据值的大致范围。

    // ② 湿度渐变填充
    const grad = ctx.createLinearGradient(0, pad, 0, baseY);
    grad.addColorStop(0, COLORS.blue);
    grad.addColorStop(1, COLORS.greenFade);
    ctx.globalAlpha = 0.18;
    ctx.beginPath();
    ctx.moveTo(pad, baseY);
    for (let i = 0; i < n; i++) {
      const x = pad + i * stepX;
      const y = baseY - (ENV_DATA[i].hum / HUM_MAX) * plotH;
      ctx.lineTo(x, y);
    }
    ctx.lineTo(w - pad, baseY);
    ctx.closePath();
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.globalAlpha = 1;

湿度折线下方的渐变填充区域。先创建从上(信息蓝)到下(极淡森绿)的线性渐变,设置 globalAlpha 为 0.18(18% 透明度),然后绘制湿度折线路径加上底部基线形成闭合区域,填充渐变。绘制完成后必须将 globalAlpha 复位为 1,否则后续绘制都会被叠加 18% 透明度——这是 Canvas 绘制中常见的"状态泄漏"陷阱,代码中用注释明确标注了这一点。

    // ③ 湿度折线
    ctx.beginPath();
    for (let i = 0; i < n; i++) {
      const x = pad + i * stepX;
      const y = baseY - (ENV_DATA[i].hum / HUM_MAX) * plotH;
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
    ctx.strokeStyle = COLORS.blue;
    ctx.lineWidth = 2;
    ctx.stroke();
    // ④ 温度折线
    ctx.beginPath();
    for (let i = 0; i < n; i++) {
      const x = pad + i * stepX;
      const y = baseY - (ENV_DATA[i].temp / TEMP_MAX) * plotH;
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
    ctx.strokeStyle = COLORS.green;
    ctx.lineWidth = 2;
    ctx.stroke();

湿度折线(信息蓝)和温度折线(森绿)的绘制逻辑完全对称:遍历数据点,第一个点 moveTo,后续点 lineTo,最后 stroke。两条线使用不同的纵轴满量程(湿度 100%、温度 35℃),因此在同一绘图区内可能交叉——这正是温湿度反相关关系的直观体现。

    // ⑤ 温度数据点
    for (let i = 0; i < n; i++) {
      const x = pad + i * stepX;
      const y = baseY - (ENV_DATA[i].temp / TEMP_MAX) * plotH;
      ctx.beginPath();
      ctx.arc(x, y, i === n - 1 ? (this.breath ? 4.5 : 3.5) : 3, 0, Math.PI * 2);
      ctx.fillStyle = COLORS.card;
      ctx.fill();
      ctx.strokeStyle = COLORS.green;
      ctx.lineWidth = 1.5;
      ctx.stroke();
    }

在温度折线的每个数据点位置绘制圆点。圆点采用"白填充 + 绿描边"的风格,直径 6px(半径 3)。最后一个数据点(最新一天)的半径随 breath 在 3.5~4.5 之间波动——这是呼吸动画在折线图上的体现,末点"跳动"暗示数据在实时更新。

    // ⑥ 横轴日期标签
    ctx.font = '9px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillStyle = COLORS.text3;
    for (let i = 0; i < n; i += 2) {
      const x = pad + i * stepX;
      ctx.fillText(ENV_DATA[i].day, x, h - 4);
    }
  }

横轴日期标签隔点绘制(i += 2),即每隔一个数据点画一个标签,避免 12 个标签拥挤。9px 字体,居中对齐,灰绿色,位于画布最底部。

3.19 根构建方法

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        if (this.currentTab === 1) {
          this.tabCamera()
        } else {
          Scroll() {
            Column({ space: 12 }) {
              if (this.currentTab === 0) {
                this.tabGarden()
              } else if (this.currentTab === 2) {
                this.tabFocus()
              } else if (this.currentTab === 3) {
                this.tabStudio()
              } else if (this.currentTab === 4) {
                this.tabMeta()
              } else {
                this.tabMine()
              }
            }.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 16 })
          }.layoutWeight(1).width('100%').scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
        }
        this.tabBar()
      }.width('100%').height('100%')
      if (this.addModal) this.panelAdd(() => { this.addModal = false; })
      if (this.editModal) this.panelEdit(() => { this.editModal = false; })
      if (this.delModal) this.panelDel(() => { this.delModal = false; })
    }.width('100%').height('100%').backgroundColor(COLORS.bg)
  }

build 方法定义了页面的整体结构。最外层是 Stack(层叠容器),包含两层:

底层 Column:垂直排列头部、分割线、内容区、底部导航栏。内容区有两种模式——当 currentTab 为 1(相机 Tab)时直接渲染 tabCamera(不进 Scroll,因为 XComponent 需要有界高度且不需要滚动);其他 Tab 进 Scroll 容器,支持上下滚动,scrollBar 隐藏,edgeEffect 设为 Spring 提供弹性回弹效果。

顶层弹窗层:三个弹窗通过 if 条件渲染,各自接收一个关闭回调。弹窗的 Stack 层叠在内容之上,通过半透明遮罩覆盖底层内容。这种"Stack + 条件弹窗"的模式是 ArkUI 中实现模态弹窗的常见做法。

3.20 Builder 视图群:头部

  @Builder
  headerMain() {
    Column({ space: 12 }) {
      Column({ space: 8 }) {
        Row() {
          Text('🌱 绿手指').fontSize(18).fontColor(COLORS.bg).fontWeight(FontWeight.Bold)
          Blank()
          Row({ space: 5 }) {
            Circle({ width: 6, height: 6 }).fill(this.breath ? COLORS.bg : COLORS.greenD)
            Text(this.breath ? '晨露滋养中' : '阳光正好').fontSize(9).fontColor(COLORS.bg)
          }.padding({ left: 10, right: 10, top: 5, bottom: 5 }).backgroundColor(COLORS.card).borderRadius(11)
          .opacity(this.breath ? 1 : 0.78)
        }.width('100%')
        Text('影随人动巡园 · 生长快照工坊 · WebP 元数据读写').fontSize(11).fontColor(COLORS.bg).opacity(0.85)
        Row({ space: 8 }) {
          Text(`在养 ${this.plantList.length} 株`).fontSize(9)...
          Text(`今日养护 ${this.doneCount()}/${this.totalCount()}`).fontSize(9)...
          Text(`连续打卡 ${this.streakDays} 天`).fontSize(9)...
        }.width('100%')
      }.width('100%').padding(14).borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.greenD, 0], [COLORS.green, 0.6], [COLORS.greenD, 1]] })

头部由渐变 Banner 和搜索条两部分组成。Banner 使用 120 度线性渐变(深森绿 → 森绿 → 深森绿),营造自然光影效果。Banner 内部三行:品牌名 + 呼吸圆点(breath 翻转时圆点颜色和文案交替变化,opacity 也波动)、特性标语、三枚数据胶囊(在养株数、今日养护进度、连续打卡天数)。

呼吸圆点的实现值得一提:Circle 的 fill 颜色在 COLORS.bg(晨露白)和 COLORS.greenD(深森绿)之间切换,文案在"晨露滋养中"和"阳光正好"之间切换,opacity 在 1 和 0.78 之间切换。三个维度同时变化,形成丰富的呼吸感。

      Row({ space: 8 }) {
        Row({ space: 6 }) {
          Text('🔍').fontSize(12)
          Text('搜植物 / 翻看生长档案').fontSize(10).fontColor(COLORS.text3)
        }.layoutWeight(1).height(34).padding({ left: 10, right: 10 }).backgroundColor(COLORS.card).borderRadius(17)
        .onClick(() => { this.switchTab(1); })
        Text('+ 添加植物').fontSize(10).fontColor(COLORS.bg).padding({ left: 12, right: 12, top: 9, bottom: 9 })
          .backgroundColor(COLORS.green).borderRadius(17)
          .onClick(() => { this.formName = ''; this.formBreed = ''; this.formGap = 5; this.addModal = true; })
      }.width('100%')

搜索条点击后跳转到相机 Tab(暗示用相机搜索植物),添加植物按钮点击后清空表单字段并打开添加弹窗。

3.21 Builder 视图群:花园 Tab

花园 Tab 是信息密度最高的 Tab,包含四个区块:

植物状态速览横滑条:横向滚动的 Row,每个植物一个状态色点 + 名称胶囊。横滑让用户快速浏览所有植物的状态,无需上下滚动。

养护完成率进度环卡:左侧 Canvas 180×180 绘制进度环,右侧文字说明(总数、已完成、待完成)和图例。onReady 回调中置 ringReady = true 并首绘。

温湿度折线卡:标题行 + Canvas 折线图 + 图例。Canvas 宽度 100%、高度 170px,onReady 回调中置 lineReady = true 并首绘。

植物左色条列表:ForEach 遍历 plantList,每株植物一张卡片。左侧 4px 宽的色条根据状态着色(plantStateColor),右侧名称、品种、浇水信息、状态标签。每张卡片有"改"和"删"两个按钮,分别打开编辑和删除弹窗。

今日任务进度条清单:ForEach 遍历 taskList,每条任务一行。进度条用 Column 的 width 百分比实现——${Math.round(task.done / task.total * 100)}%,颜色根据 done >= total 判断(森绿表示全完成,陶土橙表示进行中)。

3.22 Builder 视图群:相机 Tab

相机 Tab 是唯一不进 Scroll 的内容区,因为它包含 XComponent 预览需要占据有界高度。结构为:权限状态卡 → 模式切换行 → XComponent 预览(layoutWeight(1) 占满剩余高度) → 滚动信息区(影随人动能力链卡 + 效果枚举表,固定 268px 高度)。

XComponent 的配置值得注意:id: 'greenFingerCam' 是组件标识,type: XComponentType.SURFACE 指定为 Surface 类型(用于相机预览),controller: this.previewController 传入控制器实例。onLoad 回调中置 surfaceReady = true,这是启动相机会话的前置条件之一。

3.23 Builder 视图群:对焦 Tab

对焦 Tab 包含五个区块:能力查询卡(isFocusDistanceSupported 结果 + 会话状态 + 跳转相机 Tab 按钮)、三档预设(微距/中距/远景,点击切换 focusDistance)、焦距滑杆卡(0.0~1.0 步进 0.01 + 景别文案)、应用按钮行(设置焦距 + 读回校验,都调用 applyFocus)、对焦记录时间线(Scroll + ForEach,固定 150px 高度)。

三档预设的选中态用 this.focusDistance === preset.distance 判断,选中时背景色为森绿、文字为白色,未选中时背景为白色、文字为森绿。这种"二态切换"的视觉反馈在 Tab 切换、纹理选择、预设选择等场景中反复使用。

3.24 Builder 视图群:工坊 Tab

工坊 Tab 是 WebP 生成的控制台,包含:纹理三选一(Flex wrap 换行布局,三张卡片各占 31.5% 宽度)、编码参数卡(画布尺寸标注 + 质量滑杆 60~100 步进 5)、生成按钮 + 状态文案、像素画预览(Image 渲染 PixelMap,未生成时显示占位)、沙箱落盘状态卡(路径 + 生成状态,仅 webpPath 非空时显示)。

像素画预览使用了 ArkUI 的条件渲染:if (this.pixelMap) 为 true 时用 Image 组件直接渲染 PixelMap 对象,false 时显示占位 UI。Image 组件支持直接接收 PixelMap 作为数据源,这是 ArkUI 的便利特性。

3.25 Builder 视图群:元数据 Tab

元数据 Tab 是 WebP 元数据读写的控制台,包含:读取按钮 + 五字段卡(或未读取占位)、写入控制台(帧延迟三档 + 循环次数四档 + 写入按钮 + 规则提示)、回读校验卡(写入后显示,高亮底色区分)、操作日志流(Scroll + ForEach,固定 140px 高度)。

五字段卡由 metaCard Builder 函数渲染,接收标题、快照、是否高亮三个参数。读取结果用普通白色背景,回读校验用豆绿浅底高亮,让用户一眼区分两者。五字段的渲染有特殊处理:loopCount 为 0 时显示"0(不限)“,为 -1 时显示"未提供”,其他值显示"N 次"。这种"0 = 不限"与"-1 = 未提供"的严格区分是元数据语义的关键。

3.26 Builder 视图群:我的 Tab

我的 Tab 是园丁画像页,包含:园丁渐变大卡(135 度线性渐变 + 身份信息 + 三枚数据)、肥料工具清单(左色条 + 名称 + 用法 + 类型标签,ForEach 遍历 SUPPLY_LIST)、月度柱状图(传统 Column + ForEach 实现,渐变柱 + 数值标注 + 月份标签 + 呼吸微动)、特性栈速览(Camera Kit / Image Kit / Canvas 三行声明)。

月度柱状图的实现值得一提——没有用 Canvas,而是用 Column 组件的 height 百分比模拟柱状图。每根柱子的高度为 (val / MONTH_MAX) * 88 + (this.breath ? 4 : 0),breath 为 true 时柱子多 4px 高度,形成微妙的呼吸效果。柱子使用 180 度线性渐变(森绿 → 深森绿),顶部标注数值,底部标注月份。

3.27 弹窗系统

应用有三套弹窗,统一采用 Stack + 半透明遮罩 + 居中面板的模式:

  @Builder
  modalOverlay(onClose: () => void) {
    Stack() {
      Column().width('100%').height('100%').backgroundColor(COLORS.mask)
    }.width('100%').height('100%').alignContent(Alignment.Center).onClick(() => onClose())
  }

modalOverlay 是公共遮罩 Builder,接收一个 onClose 回调。全屏半透明黑色(rgba(0,0,0,0.5)),点击任意位置触发 onClose 关闭弹窗。三个弹窗都先调用 modalOverlay 铺底,再在其上叠加面板内容。

panelAdd(添加植物):植物名称 TextInput + 品种位置 TextInput + 浇水周期 Slider(2~14 天步进 1) + 取消/保存按钮。保存调用 savePlant,将新植物 unshift 到列表顶部。

panelEdit(修改浇水周期):显示植物名称和品种(只读)+ 浇水周期 Slider(1~21 天步进 1)+ 取消/保存按钮。保存调用 updatePlant,更新指定索引植物的 waterGap。

panelDel(移除植物确认):垃圾桶图标 + 确认文案 + 植物信息 + 说明文字 + 取消/移除按钮。移除按钮使用警示红色背景,调用 delPlant 通过 splice 移除指定索引的植物。

3.28 弹窗操作方法

  savePlant() {
    this.plantList.unshift(new PlantCard(
      this.formName === '' ? '新植物' : this.formName,
      this.formBreed === '' ? '阳台待定位 · 光照待观察' : this.formBreed,
      '刚刚记录',
      '健康',
      Math.round(this.formGap)));
    this.addModal = false;
  }

添加植物方法。空输入给默认值(名称"新植物"、品种"阳台待定位 · 光照待观察"),浇水时间固定为"刚刚记录",初始状态"健康"。Math.round 确保 waterGap 是整数。新植物通过 unshift 置顶到列表,这样新添加的植物立即出现在列表最上方。

  updatePlant() {
    if (this.editIdx >= 0 && this.editIdx < this.plantList.length) {
      this.plantList[this.editIdx].waterGap = Math.round(this.editGap);
    }
    this.editModal = false;
  }

更新浇水周期方法。先做边界检查(editIdx 在有效范围内),然后直接修改数组元素的 waterGap 属性。由于 PlantCard 标注了 @Observed,属性修改会触发 ForEach 重渲染,左色条列表中对应植物的"周期 N 天"会立即更新。

  delPlant() {
    if (this.delIdx >= 0 && this.delIdx < this.plantList.length) {
      this.plantList.splice(this.delIdx, 1);
    }
    this.delModal = false;
  }

移除植物方法。同样做边界检查,然后 splice 移除指定索引的元素。splice 会触发 @State 数组的变化通知,ForEach 自动更新列表。

四、技术对比表

以下从多个维度对比本应用涉及的三大技术套件:

对比维度Camera KitImage KitCanvas 2D
套件定位相机硬件抽象与高级能力图像编解码与元数据管理2D 图形绘制
核心 APIgetCameraManager / createSession / VideoSession / PhotoSessioncreatePixelMap / createImagePacker / createImageSource / readImageMetadataByType / writeImageMetadataCanvasRenderingContext2D / arc / lineTo / fill / stroke / createLinearGradient
会话/上下文管理需显式管理 Session 生命周期(beginConfig→commit→start→stop→release)需显式管理 ImageSource 和 ImagePacker 生命周期(release)需管理 CanvasRenderingContext2D 状态(globalAlpha 等需复位)
异步性大量异步方法(open/commitConfig/start/stop/release)大量异步方法(createPixelMap/packToData/readImageMetadataByType/writeImageMetadata)全同步方法(绘制即生效)
权限要求需动态申请 CAMERA 权限(user_grant)沙箱内文件操作无需额外权限无权限要求
硬件依赖强依赖(相机硬件、传感器、对焦马达)弱依赖(编解码可纯软件实现)无硬件依赖
资源排他性高(cameraInput 同一时间只能绑一个 Session,相机为系统级排他资源)低(可同时创建多个 ImageSource)无(每个 Canvas 独立)
错误处理BusinessError + error 事件回调(会话级错误异步通知)BusinessError(try-catch 捕获同步/异步错误)无错误抛出(绘制失败静默忽略)
能力查询isControlCenterSupported / getSupportedEffectTypes / isFocusDistanceSupported无显式能力查询(不支持时 API 调用返回错误码)无能力查询(API 全平台一致)
版本特性AUTO_FRAMING 为 6.1.1 新增(API 24)readImageMetadataByType / writeImageMetadata 为 API 24 新增全版本可用
数据流向输入:相机硬件 → 输出:预览 Surface / 拍照数据输入:PixelMap/文件 → 输出:编码字节流/元数据输入:绘制指令 → 输出:像素缓冲区
性能考量预览帧率、对焦延迟、会话切换耗时编码质量与速度权衡、像素缓冲区内存管理画布尺寸、重绘频率、状态泄漏
生命周期绑定aboutToAppear/aboutToDisappear + Tab 切换时释放生成后释放 Packer、读取后释放 ImageSourceonReady 后绘制 + setInterval 定时重绘
典型异常码会话失败码、权限拒绝码7700202(不支持)、7700204(参数非法)无异常码

五、深度总结

5.1 架构亮点:单页面多 Tab 的信息密度管理

本应用在一个 @Entry 页面中承载了 6 个功能丰富的 Tab,每个 Tab 都有独立的业务逻辑和可视化展示。这种设计的关键在于状态管理的分层:Tab 状态(currentTab)控制页面切换,各 Tab 的业务状态(plantList、focusRecords、opLogs 等)独立维护,互不干扰。当用户切换 Tab 时,只有 currentTab 变化触发的条件渲染会执行,其他 Tab 的状态保持不变。

相机 Tab 的特殊处理体现了架构的精细度——它是唯一不进 Scroll 容器的 Tab,因为 XComponent 需要有界高度来承载相机预览。其他 Tab 的内容区使用 Scroll + layoutWeight(1) + EdgeEffect.Spring,既支持滚动又有弹性回弹效果。Tab 切换时的资源管理(离开相机 Tab 释放会话)确保了排他性资源的及时回收。

5.2 状态管理:@State 与 @Observed 的协同

应用的状态体系是 ArkUI 声明式范式的典型实践。@State 装饰的基础类型状态(boolean、number、string)变化时直接触发组件重渲染。@Observed 装饰的类实例(PlantCard、TaskItem、FocusRecord 等)在作为 @State 数组元素被修改时,也能驱动 ForEach 的局部更新——这是 @Observed 的核心价值。

值得注意的设计是 private 与 @State 的区分。Camera Kit 的原生对象(CameraInput、PreviewOutput、VideoSession、PhotoSession)用 private 修饰,不触发 UI 更新;而需要驱动 UI 的状态(surfaceReady、sessionMode、framingState 等)用 @State 修饰。这种区分避免了原生对象变化时的不必要渲染,优化了性能。

5.3 Camera Kit 的互斥会话与能力链设计

VideoSession 和 PhotoSession 的互斥关系是 Camera Kit 设计的核心约束。同一时间一个 cameraInput 只能绑定一个 Session,因此从 VideoSession 切换到 PhotoSession 时必须先 release 旧会话。releaseSession 方法采用"先保存引用 → 置空成员变量 → 异步释放"的三步模式,避免了异步操作期间的竞态条件。

AUTO_FRAMING 能力链的三步设计(isControlCenterSupported → getSupportedEffectTypes → enableControlCenter)体现了"渐进降级"的哲学。每一步都有明确的失败状态和用户友好的文案,让用户知道能力链在哪一步中断,而非笼统的"不支持"。手动对焦三接口(isFocusDistanceSupported → setFocusDistance → getFocusDistance)同样遵循"查询能力 → 执行操作 → 验证结果"的范式,0.01 的校验阈值兼顾了硬件步进精度。

5.4 Image Kit 的元数据读写与回读校验

WebP 元数据读写是本应用的技术亮点。readImageMetadataByType 的类型化设计(MetadataType 数组 + 帧索引)让调用方按需读取,避免了不同格式元数据的混淆。writeImageMetadata 的字面量构造模式简单直观——WebPMetadata 的所有字段都是可选的,可以只写需要修改的字段。

回读校验的设计尤其值得称道。写入后不信任写入成功,而是重建 ImageSource 再读一遍,比对关键字段。重建实例的原因是防止解码缓存命中旧值——这是一个容易被忽略但至关重要的细节。校验结果以"已生效"或"差异"的形式呈现给用户,让用户对数据完整性有直观感知。

undefined 用 -1 占位的约定也值得学习。WebP 元数据的五个字段都是可选的,undefined 在 UI 层不好处理。在数据层统一转为 -1,UI 层用 fmtField 转为"未提供"文案,实现了数据与展示的解耦。loopCount 的 0(不限次数)与 -1(未提供)的严格区分更是体现了对元数据语义的精确理解。

5.5 Canvas 绘制的状态管理与呼吸动画

Canvas 绘制中最容易出错的点是"状态泄漏"。drawLine 中设置 globalAlpha 为 0.18 后必须复位为 1,否则后续绘制的温度折线和数据点都会叠加 18% 透明度。代码中用注释明确标注了复位操作,这是防御式编程的典范。

呼吸动画的实现简洁而有效。1000ms 的 setInterval 翻转 breath 布尔值,drawRing 和 drawLine 根据 breath 值调整绘制参数——进度环的弧长在 0.85~1.0 之间波动,折线图末点半径在 3.5~4.5 之间波动。这种"定时器 + 布尔翻转 + 条件参数"的模式比属性动画更轻量,适合需要同步驱动多个独立 Canvas 的场景。

onReady 回调的 ringReady/lineReady 标志位设计也很关键。Canvas 组件在 onReady 之前不能绘制(画布未就绪),aboutToAppear 中的定时器可能在此之前就触发。通过 ready 标志位的检查,确保只有在画布就绪后才调用 draw 方法,避免了绘制失败。

5.6 健壮性:防御式编程的全面覆盖

应用的防御式编程覆盖了所有可能出错的地方:

空值检查:上下文为空(ctx === undefined || ctx === null)、设备未发现(device === undefined)、Profile 不存在(profile === undefined)、会话未启动(this.photoSession === undefined)等场景都有明确的错误状态和处理路径。

边界检查:updatePlant 和 delPlant 都检查 editIdx/delIdx 是否在有效范围内,防止索引越界。focusRecords 超过 20 条时 pop 移除最旧的,防止无限增长。

异常捕获:所有异步操作(权限申请、相机启动、WebP 生成、元数据读写)都被 try-catch 包裹,异常时设置用户可读的错误状态并记录操作日志。错误码直接展示给用户,方便诊断。

资源释放:aboutToDisappear 清理定时器和相机会话,Tab 切换时释放相机,WebP 重复生成时释放旧 PixelMap,ImageSource 使用后 release,文件操作后 closeSync——每一处资源获取都有对应的释放。

5.7 产品融合:园艺场景与技术能力的深度结合

本应用最出色的设计不是某个单独的技术点,而是将 HarmonyOS 的高端设备能力与家庭园艺这一生活场景深度融合。影随人动不再是冰冷的技术演示,而是"巡园跟拍"的实用功能——园丁在阳台走动照料植物时,相机自动追踪人物,全程记录养护过程。手动对焦三档预设(微距病斑、中距整株、远景全景)直接对应园艺拍摄的三种典型场景,而非技术参数的裸露展示。

WebP 元数据读写也不是孤立的 API 调用,而是"生长快照工坊"的核心——生成的 WebP 文件承载植物生长快照,元数据中的 delayTime 和 loopCount 让快照可以以动画形式播放,记录植物的生长过程。Canvas 绘制的进度环和折线图将养护数据可视化,让园丁直观了解养护完成率和环境趋势。

颜色系统与园艺主题高度统一:森绿主色呼应植物,陶土橙暗示土壤和缺水警示,晨露白背景营造清晨阳台的氛围。所有文案都使用园艺术语(“巡园”、“掐尖”、“控水”、“调酸”),而非通用的技术词汇。这种"技术服务于场景"的设计理念,是应用从"技术 Demo"升级为"产品"的关键。

5.8 工程启示与最佳实践总结

从工程角度看,本应用提供了以下可复制的最佳实践:

常量集中管理:颜色系统(COLORS)、Tab 配置(TAB_LIST)、相机预设(FOCUS_PRESETS、EFFECT_INFOS)、WebP 参数(WEBP_QUALITY、CANVAS_SIZE、DELAY_PRESETS、LOOP_PRESETS)、环境数据(ENV_DATA)、月度数据(MONTH_CARE)等全部以常量集中定义,修改配置时一目了然。

辅助函数职责单一:hexToRgba 只做颜色转换、fmtField 只做字段格式化、plantStateColor 只做状态映射。每个函数只负责一件事,函数名自解释,调用处可读性强。

数据模型与 UI 分离:@Observed 类只承载数据结构,不包含任何 UI 逻辑。UI 渲染完全在 @Builder 函数中完成,数据变更通过 @State 驱动 UI 更新。

错误状态用户可读:所有错误状态都以中文文案 + 错误码的形式呈现(如"会话失败(7700102)"),而非技术性的异常堆栈。操作日志流让用户可以追溯所有操作的执行结果。

资源生命周期严格管理:aboutToAppear 获取的资源在 aboutToDisappear 释放,Tab 切换时释放排他性资源,异步操作后释放临时对象。每一步都有注释说明释放原因。

本应用展示了 HarmonyOS ArkUI 开发的成熟实践——从颜色系统的统一管理到 Camera Kit 的能力链设计,从 Image Kit 的元数据回读校验到 Canvas 的呼吸动画驱动,从 @Observed 的状态管理到弹窗系统的条件渲染,每一层都体现了对框架特性的深入理解和工程规范性的追求。对于希望深入掌握 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 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init)

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法作用
@Entry标记为页面入口,可用于路由跳转
@Component声明为自定义组件
@State状态变量,数据变更时自动触发 UI 刷新
RelativeContainer相对布局容器,替代传统线性布局
.onClick()点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称阶段状态
HarmonyOS 6.1.1Release✅ 已安装

界面顶部提示:“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 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤操作关键点
创建项目欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成使用 Stage 模型 + ArkTS 语言
查看 SDK设置 → HarmonyOS SDKSDK 已内置,无需手动安装
跨平台扩展设置 → ArkUI-X根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐