HarmonyOS_ArkTS中级语法(下)开发实录
好,枚举搞定了,接下来是最核心的部分——定义设备到底是个什么东西。在面向对象编程里,有一个很重要的思想:先定规矩,再做东西。"规矩"就是接口和抽象类。打开/*** 设备服务核心接口:定义设备服务的公共契约* @template T - 设备类型,约束为 Device 子类*//*** 初始化设备服务:加载默认设备/初始化配置* 调用时机:应用启动时初始化服务*//*** 获取所有原始设备列表(未筛

前面我们聊了 ArkTS 的基础语法和中级语法的前两部分,这篇咱直接上一个大活儿 —— 做一个智能家居设备管理系统的完整案例。说真的,光看语法规则挺无聊的,但把一堆知识点串在一起做一个真实项目,那感觉就完全不一样了。
这个案例会用到:枚举、接口、抽象类、继承、泛型、异步编程(Promise/async/await)、模块化开发,还有 ArkTS 的声明式 UI 框架。基本上 ArkTS 中级语法该练的都练到了。
先看看最终做出来的东西长什么样:

一个挺清爽的智能家居遥控面板,上面有位置筛选(全部、客厅、卧室、入口),下面是设备卡片列表,每个卡片都能独立操作。
先搭骨架:代码工程结构
在写代码之前,咱先理清楚整个项目该怎么组织。一个好的代码结构就像一个收纳整齐的衣柜,找东西的时候不用翻半天。

整个工程大概分这几块:
- common/Types.ets —— 放枚举类型,比如设备类型(灯光、空调、锁)、安装位置(客厅、卧室、入口)
- model/ —— 放数据模型,有接口 IDevice、抽象类 Device,以及三个具体设备子类 SmartLock、AirConditioner、Light
- services/ —— 放服务层,定义了 IDeviceService 接口和 LocalDeviceService 实现类,负责设备的初始化、查询和筛选
- utils/ —— 放工具函数,比如延迟函数 delay 和日志函数 logAction
- viewModel/ —— 放 ViewModel 层,作为 View 和 Model 之间的桥梁(MVVM 模式)
- view/ —— 放 UI 组件,智能锁、空调、灯光各自的卡片组件
- pages/Index.ets —— 首页,整个 App 的入口界面
为什么要分这么多层?你想想,如果你的所有代码都塞在一个文件里,几百行代码摞在一起,以后要改个东西,光是找到它在哪里就得花半天。分层的好处就是——各管各的,改 UI 不会影响数据逻辑,改数据逻辑不会影响 UI 展示。这就像公司里分工一样,前端写页面,后端写接口,各司其职。
第一步:用枚举把"固定选项"管起来
先从一个最简单但特别有用的东西说起 —— 枚举(enum)。
在我们的智能家居系统里,有些东西是固定的、有限的。比如设备类型,就那么几种:灯光(light)、空调(ac)、智能锁(lock)。再比如安装位置,也就那几个:客厅(livingRoom)、卧室(bedroom)、入口(entrance)。
你可能会想,直接用字符串不就行了?比如 type = 'light'。确实可以,但问题来了——如果哪天你手一抖,写成了 type = 'ligt',编译器不会报错,运行时才会发现设备类型不对,排查起来特别痛苦。枚举就是来解决这个问题的。
打开 src/main/ets/common/Types.ets,看看代码:
import { Device } from '../model/Device';
/**
* 设备类型枚举:限定系统支持的智能设备种类
* 避免直接使用字符串(如'light')导致的拼写错误,且便于后续扩展新设备类型
*/
export enum DeviceType {
Light = 'light',
AirConditioner = 'ac',
Lock = 'lock',
}
/**
* 安装位置枚举:限定设备可安装的家庭区域
* 采用统一的命名规范(小驼峰),确保前后端交互一致性
*/
export enum Location {
All = 'all',
LivingRoom = 'livingRoom',
Bedroom = 'bedroom',
Entrance = 'entrance'
}
来逐行看看。export enum DeviceType 声明了一个叫 DeviceType 的枚举,并且用 export 导出,这样其他文件就能 import 来用。枚举里每个成员都有一个名字和对应的值,比如 Light = 'light',意思就是 DeviceType.Light 在运行时就是字符串 ‘light’。
这样做的好处是什么?假设你要创建一个灯光设备,你不能随便写一个字符串,必须写 DeviceType.Light。如果拼写错了,编辑器会直接给你画红线,编译时也会报错。这可比运行时才发现 bug 强太多了。
Location 枚举也是一样的道理,把家里可能安装设备的位置都列出来。All = 'all' 代表"所有位置",用在筛选功能上——用户点"全部"按钮时就用这个值。
还有一个小细节——注意枚举值用的都是小写字母开头的英文单词(light、ac、lock),而枚举的键名用的是大写开头的帕斯卡命名法(Light、AirConditioner、Lock)。这是 ArkTS(也是 TypeScript)的惯用命名约定,键名大写表示它是个常量,值小写表示它是实际数据。
第二步:接口和抽象类 —— 定义设备的"规矩"
好,枚举搞定了,接下来是最核心的部分——定义设备到底是个什么东西。
在面向对象编程里,有一个很重要的思想:先定规矩,再做东西。"规矩"就是接口和抽象类。
接口:设备的"行为契约"
先看接口。接口(interface)说白了就是一张"合同",上面写清楚"任何设备都必须具备哪些属性、能做哪些事"。但接口本身不做任何实现——它只管"要求什么",不管"怎么做"。
打开 src/main/ets/model/Device.ets:
import { DeviceType, Location } from '../common/Types';
/**
* 设备核心接口:定义所有智能设备必须遵守的规范
* 接口中的属性和方法默认是 public(无需显式声明)
*/
export interface IDevice {
// 设备唯一标识(必填)
id: string;
// 设备名称(用户可自定义)
name: string;
// 设备类型(必须是DeviceType枚举值)
type: DeviceType;
// 安装位置(必须是Location枚举值)
location: Location;
// 开机方法:返回Promise支持异步操作(如设备联网通信)
turnOn(): Promise<void>;
// 关机方法:同上
turnOff(): Promise<void>;
}
这个接口定义了五样东西:id、name、type、location 四个属性,以及 turnOn() 和 turnOff() 两个方法。
为什么要这么设计?你想啊,不管是灯、空调还是锁,它们都有"身份证号"(id),都有"名字"(name),都有"类型"(type),都装在某个"位置"(location),而且都能"开"和"关"。这些就是所有智能设备的共性。
用接口把共性抽象出来之后,不管以后你加什么新设备——扫地机器人、智能窗帘、智能音箱——只要它实现了 IDevice 接口,系统就能统一管理它,因为"合同"上写的东西它都有了。
你注意到没有,turnOn() 和 turnOff() 的返回类型是 Promise<void>。为什么不是直接 void?因为在真实场景中,操作设备通常是异步的——你给灯发一个开灯指令,灯不会瞬间亮起来,它可能需要连蓝牙、发信号、等硬件响应。用 Promise 就能处理这种"等一等再告诉我结果"的情况。
抽象类:把能复用的逻辑先写了
接口只有规矩没有实现,如果每个设备类都要从头写 id、name 这些属性的初始化,那就太重复了。这时候抽象类就派上用场了。
抽象类就像一个"半成品模板"——它实现了接口的一部分(共性的东西),把另一部分留给子类去填(个性化的东西)。
还是在 src/main/ets/model/Device.ets 里,接口下面紧跟着的就是抽象类:
/**
* 设备抽象基类:实现IDevice接口的共性逻辑,抽象个性化逻辑
* 继承关系:所有具体设备类(Light、AirConditioner等)都继承自此类
*/
export abstract class Device implements IDevice {
// 实现 IDevice 接口的属性(提供默认值,子类可覆盖)
id: string = '';
name: string = '';
type: DeviceType = DeviceType.Light;
location: Location = Location.All;
// 私有属性:状态通过内部方法暴露,避免外部直接修改(封装性)
protected status: 'on' | 'off' = 'off';
/**
* 构造函数:初始化设备的核心属性
* @param id 设备ID
* @param name 设备名称
* @param type 设备类型
* @param location 安装位置
*/
constructor(id: string, name: string, type: DeviceType, location: Location) {
this.id = id;
this.name = name;
this.type = type;
this.location = location;
}
/**
* 实现 IDevice 接口的 status 属性:通过getStatus()控制访问权限
* 外部只能读取状态,不能直接修改(需通过turnOn()/turnOff()方法)
*/
getStatus(): 'on' | 'off' {
return this.status;
}
/**
* 抽象方法:子类必须实现的个性化逻辑
* 原因:不同设备的开机逻辑不同(如灯光需初始化亮度,空调需初始化温度)
*/
abstract turnOn(): Promise<void>;
abstract turnOff(): Promise<void>;
/**
* 具体方法:所有设备共享的共性逻辑
* 作用:返回设备的详细描述信息,无需子类重复实现
*/
getInfo(): string {
return `${this.name}(${Location[this.location]} 位置的 ${DeviceType[this.type]} 设备)`;
}
}
这段代码信息量不小,咱们慢慢来。
首先,export abstract class Device implements IDevice —— 声明了一个抽象类 Device,它实现了 IDevice 接口。关键词 abstract 告诉编译器:"这个类不能直接 new 出来用,它只是个模板,必须有人继承它。"关键词 implements IDevice 告诉编译器:“我承诺会实现 IDevice 接口里规定的所有属性和方法。”
然后看属性。id、name、type、location 四个属性都给了默认值,这些对应了 IDevice 接口中要求的属性。特别注意的是 protected status 这个属性——它用了 protected 修饰符,意思是"只有我自己和我的子类能访问,外面的人不行"。为什么要这样?因为设备状态(开/关)不应该被外部随便改,必须通过 turnOn()/turnOff() 方法来改。这就是面向对象里"封装"的体现——不让你乱动我的内部数据,想改就走正门。
再看构造函数 constructor,它接收四个参数(id、name、type、location),然后赋值给对应的属性。以后子类创建实例时,会通过 super() 调用这个构造函数。
getStatus() 方法提供了一个只读的获取状态的方式——外部能看状态,但改不了。这是一种很好的保护机制。
然后是最关键的部分——两个抽象方法:
abstract turnOn(): Promise<void>;
abstract turnOff(): Promise<void>;
abstract 标记的方法只有签名,没有实现体(没有花括号和代码)。它的意思是:"我知道所有设备都需要开机和关机功能,但每种设备开机关机的具体做法不一样,所以这个交给子类去实现。"比如灯光开机可能要初始化亮度,空调开机要初始化温度,锁开机可能要做安全校验。这些个性化逻辑,抽象类管不了,就留给子类。
最后,getInfo() 是一个普通方法(不是抽象的),它已经写好了完整的实现。任何继承 Device 的子类都自动拥有这个方法,不用再写一遍。它用模板字符串把设备名称、位置、类型拼成一段描述文字,方便日志输出。
这里有个小技巧——Location[this.location] 这种写法。因为 Location 是枚举,this.location 的值是字符串(比如 ‘livingRoom’),而 Location['livingRoom'] 就能反向拿到枚举的键名 LivingRoom。这在需要显示给人看的名称时特别好用。
第三步:三个设备子类 —— 各有各的性格
抽象类准备好了,现在来创建具体的设备。每个子类要做的核心事情就是:调用父类构造函数初始化基础属性,然后实现那两个抽象方法。
智能锁 —— 最简单的那个
先看智能锁,打开 src/main/ets/model/SmartLock.ets:
import { DeviceType, Location } from '../common/Types';
import { Device } from './Device';
import { delay, logAction } from '../utils/DeviceUtils';
/**
* 智能锁子类:继承 Device 抽象类,实现锁具的个性化逻辑
*/
export class SmartLock extends Device {
/**
* 构造函数:调用父类构造函数初始化核心属性
* 注意:设备类型固定为DeviceType.Lock,无需外部传入(封装细节)
*/
constructor(id: string, name: string, location: Location) {
super(id, name, DeviceType.Lock, location);
}
/**
* 实现抽象方法:开锁逻辑
* 异步处理:模拟设备联网通信的延迟(300ms)
*/
async turnOn(): Promise<void> {
try {
await delay(100); // 模拟设备响应延迟(如蓝牙连接、云端校验)
this.status = 'on';
logAction(`${this.getInfo()} 已开锁`);
} catch(error) {
console.error(`error: ${(error as Error).message}`);
}
}
/**
* 实现抽象方法:关锁逻辑
* 延迟时间较短(100ms),符合锁具的实际响应速度
*/
async turnOff(): Promise<void> {
try {
await delay(100);
this.status = 'off';
logAction(`${this.getInfo()} 已关锁`);
} catch(error) {
console.error(`error: ${(error as Error).message}`);
}
}
}
来聊聊这段代码的几个要点。
export class SmartLock extends Device —— 用 extends 关键字表示 SmartLock 继承自 Device。继承之后,SmartLock 自动拥有 Device 的所有属性和方法(id、name、type、location、status、getStatus()、getInfo())。
构造函数只接收三个参数(id、name、location),注意——没有 type 参数。为什么?因为智能锁的类型永远是 Lock,不需要外部传入。在构造函数里用 super(id, name, DeviceType.Lock, location) 调用父类构造函数,其中 DeviceType.Lock 是硬编码的。这是"封装细节"的好例子——创建智能锁的人不需要操心设备类型是什么,系统已经帮你处理好了。
turnOn() 方法前面有 async 关键字,表示这是一个异步函数。函数体里 await delay(100) 会暂停 100 毫秒,模拟真实世界中设备通信的延迟。然后 this.status = 'on' 把状态改成"开"——注意这里能直接改 status 是因为 SmartLock 继承了 Device,而 status 在 Device 里是 protected 的,子类可以访问。最后 logAction() 打印一条日志。
整个操作被包在 try-catch 里,因为 await 的东西可能出错(比如网络请求失败),有异常处理才不会导致整个程序崩溃。
turnOff() 的逻辑几乎一样,就不重复说了。
空调 —— 带温度调节的大家伙
空调比锁复杂一些,因为它多了"温度调节"的功能。打开 src/main/ets/model/AirConditioner.ets:
import { DeviceType, Location } from '../common/Types';
import { Device } from './Device';
import { delay, logAction } from '../utils/DeviceUtils';
/**
* 空调子类:扩展温度控制功能
*/
export class AirConditioner extends Device {
// 私有属性:温度(默认26°C,仅内部或通过get方法/set方法访问)
private temperature: number = 26;
constructor(id: string, name: string, location: Location) {
super(id, name, DeviceType.AirConditioner, location);
}
/**
* 实现抽象方法:开机逻辑
* 开机时显示当前温度,符合空调的使用场景
*/
async turnOn(): Promise<void> {
try {
await delay(200); // 模拟空调启动延迟
this.temperature = 26; // 初始化空调温度
this.status = 'on';
logAction(`${this.getInfo()} 已开启,当前温度:${this.temperature}°C`);
} catch(error) {
console.error(`error: ${(error as Error).message}`);
}
}
/**
* 实现抽象方法:关机逻辑
*/
async turnOff(): Promise<void> {
try {
await delay(100);
this.status = 'off';
logAction(`${this.getInfo()} 已关闭`);
} catch(error) {
console.error(`error: ${(error as Error).message}`);
}
}
/**
* getTemperature方法:获取当前温度(外部只读,不能直接修改)
*/
getTemperature(): number {
return this.temperature;
}
/**
* setTemperature方法:设置温度(添加边界约束,避免无效值)
* @param temp 目标温度(16°C~30°C范围内)
*/
setTemperature(temp: number): void {
// 若设备已开机,打印温度更新日志
if (this.status === 'on') {
if (temp < 16) { this.temperature = 16; }
else if (temp > 30) { this.temperature = 30; }
else { this.temperature = temp; }
logAction(`${this.getInfo()} 温度已调整为:${this.temperature}°C`);
}
}
}
空调多了一个私有属性 private temperature: number = 26。private 意味着只有这个类自己能访问它,连子类都不行。为什么要私有?因为温度是一个敏感数据,不能让外面随便改——你想啊,如果有人直接把温度设成 -100 度或者 999 度,那不就乱套了吗?
所以温度的读写通过 getTemperature() 和 setTemperature() 两个方法来控制。尤其是 setTemperature(),里面做了边界检查——低于 16 度就自动设成 16 度,高于 30 度就设成 30 度。只有在这个合理范围内的值才会被接受。这种设计叫"输入验证",是写健壮代码的基本功。
还有一个细节——setTemperature() 里有 if (this.status === 'on') 的判断,意思是只有空调开着的时候才能调温度。你总不能在空调关着的时候还让它调温度吧?这种状态检查在真实项目中特别重要。
另外注意到空调开机的 delay 是 200ms,比锁的 100ms 长一些。这也合理——空调毕竟是大家电,启动可能确实慢一点。
灯光 —— 带亮度调节的温馨存在
灯光和空调的结构很类似,只是把温度换成了亮度。打开 src/main/ets/model/Light.ets:
import { DeviceType, Location } from '../common/Types';
import { Device } from './Device';
import { delay, logAction } from '../utils/DeviceUtils';
/**
* 灯光子类:扩展亮度控制功能,支持 UI 状态联动
*/
export class Light extends Device {
// 私有属性:亮度(0~100 范围内,默认50)
private brightness: number = 50;
constructor(id: string, name: string, location: Location) {
super(id, name, DeviceType.Light, location);
}
/**
* 实现抽象方法:开灯逻辑
* 开机时显示当前亮度,符合灯光的使用场景
*/
async turnOn(): Promise<void> {
try {
await delay(100); // 模拟灯光启动延迟
this.brightness = 50; // 初始化亮度
this.status = 'on';
logAction(`${this.getInfo()} 已开启,当前亮度:${this.brightness}%`);
} catch(error) {
console.error(`error: ${(error as Error).message}`);
}
}
/**
* 实现抽象方法:关灯逻辑
*/
async turnOff(): Promise<void> {
try {
await delay(100);
this.status = 'off';
logAction(`${this.getInfo()} 已关闭`);
} catch(error) {
console.error(`error: ${(error as Error).message}`);
}
}
/**
* getBrightness 方法:获取当前亮度
*/
getBrightness(): number {
return this.brightness;
}
/**
* setBrightness 方法:设置亮度(添加边界约束)
* @param level 目标亮度(0~100范围内)
*/
setBrightness(level: number): void {
// 用 Math.max/Math.min 简化边界判断,确保亮度在0~100之间
this.brightness = Math.max(0, Math.min(100, level));
if (this.status === 'on') {
logAction(`${this.getInfo()} 亮度已调整为:${this.brightness}%`);
}
}
}
注意灯光的 setBrightness() 和空调的 setTemperature() 在处理边界值时的写法不一样。空调用的是 if-else 判断,灯光用的是 Math.max(0, Math.min(100, level))。两种写法效果完全一样,但 Math.max/Math.min 更简洁——一行搞定,不用写三个分支。这提醒我们,同一个问题可以有多种解法,选哪种看个人习惯和场景。
还有一点,灯光的 setBrightness 没有像空调那样检查 this.status === 'on' 才允许修改。实际上它先无条件修改了 brightness 值,然后才判断是否打印日志。这意味着即使灯关着,你也可以预设亮度值,下次开灯时就直接用你设好的亮度。这也是一种合理的设计——想想看,你在手机 App 上先把灯的亮度调到 80%,然后开灯,灯就亮在 80%,这个体验很自然。
第四步:服务层 —— 把业务逻辑和 UI 分开
设备模型写好了,但我们还需要一个"中间人"来管理这些设备——创建它们、查询它们、按位置筛选它们。这个中间人就是"服务层"。
先定义服务接口
打开 src/main/ets/services/IDeviceService.ets:
/**
* 设备服务核心接口:定义设备服务的公共契约
* @template T - 设备类型,约束为 Device 子类
*/
export interface IDeviceService<T> {
/**
* 初始化设备服务:加载默认设备/初始化配置
* 调用时机:应用启动时初始化服务
*/
initialize(): void;
/**
* 获取所有原始设备列表(未筛选)
* @returns Promise<T[]> 设备数组(异步返回,模拟IO操作)
*/
getDevices(): Promise<T[]>;
/**
* 获取筛选后的设备列表
* @returns T[] 筛选后的设备数组(同步返回,已缓存最新状态)
*/
getFilteredDevices(): T[];
/**
* 获取所有设备位置(自动去重)
* @returns string[] 位置数组
*/
getLocations(): string[];
/**
* 获取当前选中的筛选位置
* @returns string 当前选中位置('all' 表示全部)
*/
getSelectedLocation(): string;
/**
* 根据位置筛选设备
* @param location 筛选位置('all' 表示全部)
*/
filterDevicesByLocation(location: string): void;
}
这里出现了一个新东西——泛型接口。IDeviceService<T> 中的 <T> 就是一个泛型参数,代表"某个设备类型"。具体是什么设备类型,由使用这个接口的地方来决定。比如你可以写 IDeviceService<Device> 表示通用的设备服务,也可以写 IDeviceService<Light> 表示专门针对灯光的服务。
为什么要有泛型?因为服务层本身不关心具体是什么设备,它只管"存设备、取设备、筛选设备"。泛型让代码更通用、更灵活,不用为每种设备写一个单独的服务接口。
接口里定义了六个方法:initialize() 初始化服务、getDevices() 获取所有设备、getFilteredDevices() 获取筛选后的设备、getLocations() 获取所有位置、getSelectedLocation() 获取当前选中的位置、filterDevicesByLocation() 按位置筛选。这六个方法基本上覆盖了一个设备管理系统的核心功能。
再实现具体的服务类
打开 src/main/ets/services/LocalDeviceService.ets:
import { Device } from '../model/Device';
import { Light } from '../model/Light';
import { AirConditioner } from '../model/AirConditioner';
import { SmartLock } from '../model/SmartLock';
import { delay } from '../utils/DeviceUtils';
import { IDeviceService } from './IDeviceService';
import { Location } from '../common/Types';
/**
* 泛型本地设备服务类
* @template T - 设备类型,约束为Device的子类
* 包含:设备初始化、设备获取、设备操作、设备筛选、位置管理
*/
@Observed
export class LocalDeviceService<T extends Device> implements IDeviceService<T> {
/** 私有设备列表:内部维护原始数据 */
private devices: T[] = [];
/** 筛选后的设备列表:供外部查询 */
private filteredDevices: T[] = [];
/** 所有位置列表:自动从设备中提取 */
private locations: string[] = [];
/** 当前选中的位置筛选条件 */
private selectedLocation: string = 'all';
/**
* 初始化设备:创建默认设备实例并初始化筛选状态
*/
initialize(): void {
// 初始化默认设备
this.devices = [
new Light('L1', '客厅主灯', Location.LivingRoom),
new AirConditioner('AC1', '卧室空调', Location.Bedroom),
new SmartLock('LOCK1', '入户门锁', Location.Entrance)
] as T[];
// 初始化位置列表和筛选设备
this.updateLocations();
this.filterDevicesByLocation('all');
console.log('本地设备服务初始化完成,已加载默认设备');
}
/**
* 获取所有设备(原始数据)
* @returns Promise<T[]> 设备列表(带延迟模拟)
*/
async getDevices(): Promise<T[]> {
await delay(400);
return [...this.devices]; // 返回拷贝,避免外部修改原始数据
}
/**
* 获取筛选后的设备列表
* @returns T[] 筛选后的设备
*/
getFilteredDevices(): T[] {
return [...this.filteredDevices]; // 返回拷贝,确保数据安全性
}
/**
* 获取所有位置(自动去重)
* @returns string[] 位置列表
*/
getLocations(): string[] {
return [...this.locations];
}
/**
* 获取当前选中的位置
* @returns string 当前选中位置
*/
getSelectedLocation(): string {
return this.selectedLocation;
}
/**
* 根据位置筛选设备
* @param location 筛选位置('all'表示全部)
*/
filterDevicesByLocation(location: string): void {
this.selectedLocation = location;
if (location === 'all') {
this.filteredDevices = [...this.devices];
} else {
// 筛选指定位置的设备(忽略大小写匹配)
this.filteredDevices = this.devices.filter(
device => device.location.toLowerCase() === location.toLowerCase()
);
}
}
/**
* 从设备列表中提取所有位置(自动去重)
*/
private updateLocations(): void {
const locationSet = new Set<string>();
this.devices.forEach(device => locationSet.add(device.location));
this.locations = Array.from(locationSet);
}
}
这个服务类的内容不少,咱们挑重点说。
首先是类声明:@Observed export class LocalDeviceService<T extends Device> implements IDeviceService<T>。@Observed 是 ArkTS 的状态管理装饰器,意思是"这个类的属性变化可以被观察"。这样当设备列表更新时,UI 层能自动感知到并刷新界面。<T extends Device> 是泛型约束——T 不只是任意类型,它必须是 Device 或 Device 的子类。implements IDeviceService<T> 表示这个类实现了前面定义的服务接口。
然后看四个私有属性——devices 存原始设备列表,filteredDevices 存筛选后的设备列表,locations 存所有位置,selectedLocation 存当前选中位置。全部用 private 修饰,外面只能通过公开方法访问。这是"封装"的好习惯。
initialize() 方法里创建三个默认设备:客厅的灯、卧室的空调、入口的锁。注意 as T[] 这个类型断言——因为我们创建的是 Light、AirConditioner、SmartLock 实例,但 devices 的类型是 T[],所以需要告诉编译器"放心,这些就是 T 类型的"。
getDevices() 方法里 return [...this.devices] 用了展开运算符(…),意思是返回一个新数组,内容和 devices 一样但不是同一个对象。为什么要这样做?如果直接 return this.devices,外部拿到的是原始数组的引用,可以随意修改里面的元素,那就破坏了封装性。返回拷贝就不会有这个问题。
filterDevicesByLocation() 方法的逻辑很清晰:如果传 ‘all’ 就返回所有设备,否则用 Array.filter() 筛选指定位置的设备。.toLowerCase() 确保匹配时不区分大小写,这样即使用户输入 ‘LivingRoom’ 也能正确匹配到 ‘livingRoom’。
updateLocations() 用了 Set 来去重——Set 天生不允许重复值,所以把所有设备的位置丢进去再拿出来,就自动去重了。Array.from(locationSet) 把 Set 转成数组。这种写法比手动写循环判断去重要优雅得多。
第五步:工具函数 —— 小东西大用处
在写上面的代码时,我们用到了 delay() 和 logAction() 两个工具函数。它们放在 src/main/ets/utils/DeviceUtils.ets 里:
/**
* 模拟异步延迟
* 作用:统一管理项目中的延迟逻辑,避免重复写 setTimeout
* @param ms 延迟时间(毫秒)
* @returns Promise<void> 延迟完成的 Promise
*/
export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 设备操作日志工具
* 特点:添加时间戳,统一日志格式,便于问题排查
* @param msg 日志内容
*/
export const logAction = (msg: string): void => {
console.log(`[DeviceLog] ${new Date().toISOString()}: ${msg}`);
};
delay() 函数看起来简单,但它解决了一个常见问题。在 ArkTS 里,原生的 setTimeout 不直接返回 Promise,如果你想在 async 函数里用 await 来等待一段时间,就需要自己包装一下。这个 delay 函数把 setTimeout 包在 Promise 里,这样就能 await delay(100) 这样优雅地使用了。
logAction 是一个箭头函数(用 const 声明,=> 定义),和普通 function 声明的区别在于——箭头函数没有自己的 this 绑定。在这个场景下无所谓,因为 logAction 不需要用到 this,用哪种写法都行。但代码里特意用了箭头函数,可能是为了保持风格一致,或者在更复杂的使用场景中有 this 绑定的考虑。
每个设备操作都通过 logAction 打印日志,格式统一都是 [DeviceLog] 时间: 内容。这样做的好处是——你可以在 HiLog 或 DevEco Studio 的日志面板里用 [DeviceLog] 关键词搜索,快速过滤出所有设备相关的日志,排查问题时特别方便。
第六步:UI 界面 —— 让代码活过来
数据模型和服务层都搞定了,最后一步就是做界面了。ArkTS 用的是声明式 UI——你描述界面"长什么样",框架自动帮你渲染和更新。
ViewModel —— View 和 Model 之间的翻译官
在写 UI 组件之前,先看看 ViewModel 是干什么的。以智能锁为例,打开 src/main/ets/viewModel/LockViewModel.ets:
import { Device } from '../model/Device';
import { SmartLock } from '../model/SmartLock';
@Observed
export class LockViewModel {
private lock: SmartLock;
name: string = '';
status: 'on' | 'off' = 'off';
constructor(device: Device) {
this.lock = device as SmartLock;
this.name = device.name;
this.status = this.lock.getStatus();
}
async turnOn(): Promise<void> {
await this.lock.turnOn();
this.status = 'on';
}
async turnOff(): Promise<void> {
await this.lock.turnOff();
this.status = 'off';
}
}
ViewModel 的作用是——把底层的数据(Device 对象)转换成 UI 能直接用的格式。注意 ViewModel 的属性(name、status)都是公开的,没有 getter/setter,UI 组件可以直接读写。而底层的 SmartLock 对象是 private 的,UI 组件碰不到。
这种设计叫 MVVM(Model-View-ViewModel)模式。它的好处是——UI 组件不需要知道底层设备类长什么样、有哪些方法,它只需要和 ViewModel 打交道就行。以后如果底层设备类改了,只需要改 ViewModel,不用动 UI 代码。
this.lock = device as SmartLock 这里做了一个类型断言——把 Device 类型的 device 强制转换成 SmartLock。之所以需要这样做,是因为从服务层拿到的设备是 Device 类型(父类),但 ViewModel 需要调用 SmartLock 特有的方法。
智能锁卡片组件
打开 src/main/ets/view/LockView.ets:
import { Device } from '../model/Device';
import { LockViewModel } from '../viewModel/LockViewModel';
/**
* 智能锁卡片组件:接收 SmartLock 实例,提供开关控制和状态显示
*/
@Component
export struct LockView {
/**
* @ObjectLink:父子组件状态联动,设备状态变化时自动刷新 UI
* 接收父组件传递的 Device 实例,向下转型为 SmartLock(需确保类型正确)
* SmartLock 原始数据封装为ViewModel,为View层的组件提供数据(推荐使用MVVM架构)
*/
@ObjectLink device: Device; // 接收父类对象
@State lock: LockViewModel = new LockViewModel(this.device);
build() {
// 垂直布局:卡片整体容器
Column() {
// 水平布局:状态显示与开关区域
Row() {
// 左侧设备名、开关和状态
Column() {
Row() {
Text(this.lock.name)
.fontSize(18)
.fontColor(Color.Black)
.fontWeight(FontWeight.Bold)
.margin({ right: 12 })
// 开关控件:绑定设备 status 状态,切换时调用 turnOn()/turnOff()
Toggle({ type: ToggleType.Switch, isOn: this.lock.status === 'on' })
.onChange((isOn: boolean)=>{
if (isOn) {
this.lock.turnOn();
} else {
this.lock.turnOff();
}
})
}
.margin({ top: 24 })
.justifyContent(FlexAlign.Start)
Text(this.lock.status === 'on' ? '已打开': '已关闭')
.margin({ top: 8 })
.fontSize(14)
.fontColor('#666666')
}
.alignItems(HorizontalAlign.Start)
.margin({left: 12})
// 弹性占位:推挤右侧图片到最右边
Blank()
.layoutWeight(1)
// 状态图标:根据设备状态切换开锁/关锁图标
Image((this.lock.status === 'on' ? $r('app.media.ic_unlock') : $r('app.media.ic_locked')))
.width(80)
}
}
.backgroundColor(Color.White)
.borderRadius(15)
.shadow({ radius: 16, color: '#5ec0d4f6', offsetY: 16 })
.height(99)
.margin({ left: 8, right: 8 })
}
}
逐段来看。
@Component 装饰器表示这是一个可复用的 UI 组件。struct LockView 定义了组件的结构。
@ObjectLink device: Device 是 ArkTS 的状态管理装饰器。@ObjectLink 的作用是——当父组件传递过来的 device 对象的属性发生变化时,子组件会自动刷新。比如你在这个组件里调用了 turnOn(),设备的 status 从 ‘off’ 变成 ‘on’,界面就会自动更新,不需要手动刷新。
@State lock: LockViewModel 是另一个状态装饰器。@State 标记的属性变化时,使用它的 UI 也会自动更新。lock 被初始化为 new LockViewModel(this.device),也就是用 ViewModel 包装了设备数据。
build() 方法是组件的 UI 描述。Column() 是垂直布局容器,Row() 是水平布局容器。整个卡片的结构是:外面一个垂直的 Column,里面一个水平的 Row;Row 左边是设备名+开关+状态文字,右边是通过 Blank() 推过去的图标。
Toggle 组件就是一个开关。isOn: this.lock.status === 'on' 让开关的初始状态和设备状态同步。.onChange() 回调在用户拨动开关时触发——如果打开就调用 turnOn(),如果关闭就调用 turnOff()。开关拨动 -> 调用方法 -> 状态改变 -> UI 自动刷新,这就是声明式 UI 的核心循环。
$r('app.media.ic_unlock') 是 ArkTS 的资源引用语法,用来引用项目里的图片资源。这里根据设备状态显示不同的图标——开锁状态显示开锁图标,关锁状态显示锁上图标。
底下的 .backgroundColor(Color.White).borderRadius(15).shadow(...) 这些是卡片容器的样式——白色背景、圆角 15、带阴影。让卡片看起来有立体感。
灯光和空调卡片
灯光和空调的卡片组件结构类似,都是在锁的基础上多了调节功能。灯光多了一个亮度滑块(Slider),空调多了加号减号按钮来调温度。
以灯光为例,关键代码在 Slider 部分:
Slider({
value: this.light.getBrightness(),
min: 0,
max: 100,
step: 1,
style: SliderStyle.InSet
})
.enabled(this.light.status === 'on')
.showTips(this.light.status === 'on')
.layoutWeight(1)
.blockColor(Color.White)
.selectedColor(this.light.status === 'on' ? '#0A59F7' : '#0d000000')
.trackThickness(20)
.width(120)
.onChange(value => {
this.light.setBrightness(value);
})
.margin({left: 12, right: 12})
Slider 组件创建了滑动条,value 设当前值,min/max 设范围,step 设步进值(每次滑动变化多少)。.enabled(this.light.status === 'on') 确保只有灯开着的时候才能调亮度。.onChange(value => { this.light.setBrightness(value); }) 在用户拖动滑块时实时更新亮度。
空调的温度调节用了两个 Image 按钮模拟加减号,通过 @Builder SetButton(isAdd: boolean) 来封装按钮逻辑——传入 true 就是加温度,false 就是减温度。如果空调关着的时候点了按钮,还会弹一个 Toast 提示"设备已关闭"。
首页 —— 把所有东西串起来
最后来看首页,打开 src/main/ets/pages/Index.ets:
import { Device } from '../model/Device';
import { LocalDeviceService } from '../services/LocalDeviceService';
import { ACView } from '../view/ACView';
import { LightView } from '../view/LightView';
import { LockView } from '../view/LockView';
@Entry
@Component
struct Index {
// 初始化设备服务(全局单例模式)
@State deviceService: LocalDeviceService<Device> = new LocalDeviceService<Device>();
aboutToAppear() {
this.initSystem();
}
/** 初始化系统:调用服务层的初始化方法 */
async initSystem() {
this.deviceService.initialize();
try {
// 触发设备加载(实际数据已在service初始化时加载,这里保持延迟模拟)
await this.deviceService.getDevices();
} catch (error) {
console.error(`设备加载失败: ${(error as Error).message}`);
}
}
/** 位置显示名称转换(UI层负责本地化) */
getLocationDisplayName(location: string): string | ResourceStr {
const locationMap: Record<string, Resource> = {
'livingRoom': $r('app.string.livingRoom'),
'bedroom': $r('app.string.bedroom'),
'entrance': $r('app.string.entrance'),
'all': $r('app.string.all')
};
return locationMap[location] || location;
}
build() {
Column() {
// 标题栏
Column() {
Text($r('app.string.smart_home_control'))
.fontSize(26)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 10, left: 16 })
}
// 位置筛选器(数据和逻辑都来自service)
Row({ space: 8 }) {
Button(this.getLocationDisplayName('all'))
.width(60)
.height(36)
.backgroundColor(this.deviceService.getSelectedLocation() === 'all' ? '#0A59F7' : '#0D000000')
.fontSize(14)
.fontColor(this.deviceService.getSelectedLocation() === 'all' ? '#ffffff' : '#E6000000')
.onClick(() => this.deviceService.filterDevicesByLocation('all'))
ForEach(this.deviceService.getLocations(), (location: string) => {
Button(this.getLocationDisplayName(location))
.width(60)
.height(36)
.backgroundColor(this.deviceService.getSelectedLocation() === location ? '#0A59F7' : '#0D000000')
.fontSize(14)
.fontColor(this.deviceService.getSelectedLocation() === location ? '#ffffff' : '#E6000000')
.onClick(() => this.deviceService.filterDevicesByLocation(location))
})
}
.margin({ top: 10, left: 16, bottom: 15 })
// 设备列表(展示筛选后的设备)
List() {
if (this.deviceService.getFilteredDevices().length === 0) {
ListItem() {
Text($r('app.string.device_not_found'))
.fontSize(16)
.fontColor('#666')
.padding(20)
.width('100%')
.textAlign(TextAlign.Center)
}
} else {
ForEach(this.deviceService.getFilteredDevices(), (device: Device) => {
ListItem() {
// 根据设备类型渲染对应组件
if (device.type === 'ac') {
ACView({
device: device,
})
} else if (device.type === 'light') {
LightView({
device: device,
})
} else {
LockView({
device: device,
})
}
}
.margin({ left: 8, right: 8, bottom: 12 })
})
}
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.ignoreLayoutSafeArea()
.height(LayoutPolicy.matchParent)
.padding({ top: 48 })
.width('100%')
.linearGradient({
direction: GradientDirection.Bottom,
colors: [['#CBD9F3', 0], [Color.White, 1]]
})
}
}
@Entry 装饰器标记这是应用的入口页面,@Component 标记这是一个组件。
@State deviceService: LocalDeviceService<Device> = new LocalDeviceService<Device>() 创建了一个带泛型参数的设备服务实例。因为加了 @State,当 deviceService 内部的 @Observed 属性变化时,整个页面会自动刷新。
aboutToAppear() 是组件即将显示时的生命周期回调,在这里调用了 initSystem() 来初始化系统。initSystem() 先调用 initialize() 创建默认设备,然后 getDevices() 模拟异步加载。
getLocationDisplayName() 做了一件很实用的事——把英文的 location 值映射成中文显示名。它用了一个 Record<string, Resource> 类型的对象做映射表,$r('app.string.livingRoom') 引用了国际化字符串资源。如果找不到对应的映射,就返回原始值。这种设计支持多语言——如果以后要做英文版,只需要在英文资源文件里提供对应的字符串就行。
首页的 UI 分三部分:标题栏、位置筛选按钮、设备列表。
位置筛选按钮部分——第一个是"全部"按钮,后面用 ForEach 遍历所有位置生成按钮。每个按钮的背景色和文字颜色根据当前选中状态动态切换:选中的是蓝色背景白色文字,未选中的是透明背景黑色文字。点击按钮时调用 this.deviceService.filterDevicesByLocation(location) 进行筛选。
设备列表部分——如果筛选结果为空,显示"暂无设备"提示;否则用 ForEach 遍历筛选后的设备,根据设备类型(device.type)渲染不同的卡片组件。if (device.type === 'ac') 判断是空调就用 ACView,device.type === 'light' 判断是灯光就用 LightView,否则默认用 LockView。
最后整个页面容器用了 .linearGradient() 添加了一个从淡蓝到白色的渐变背景,看起来更有质感。
回头看看我们做了什么
整个项目从头到尾走一遍,你会发现它其实就是一个完整的面向对象设计过程:
- 用枚举定义固定选项(设备类型、安装位置),防止拼写错误
- 用接口定义设备的统一规范(IDevice),所有设备必须遵守
- 用抽象类复用共性逻辑(Device),把个性化的部分留给子类
- 用继承创建具体设备(SmartLock、AirConditioner、Light),每个子类实现自己的开关逻辑
- 用泛型接口和泛型类设计服务层(IDeviceService、LocalDeviceService),让代码更通用
- 用模块化组织代码(common、model、services、utils、view、viewModel),各层各管各的
- 用MVVM 模式连接数据和 UI(ViewModel 做翻译官),让界面和数据解耦
- 用声明式 UI 框架做界面(@Component、@State、@ObjectLink),状态变了界面自动刷新
这些东西单独拿出来看可能有点抽象,但当你把它们全部用在一个真实项目里的时候,你会发现——每一层、每一个设计决策都不是多余的。接口让不同设备能被统一管理,抽象类避免了重复代码,泛型让服务层不绑定具体设备类型,ViewModel 让 UI 不直接依赖数据模型。这就像搭积木一样,每一块积木都有它的位置,抽掉任何一块整体就不稳固了。
更多推荐

所有评论(0)