运行效果

前面我们聊了 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 接口里规定的所有属性和方法。”

然后看属性。idnametypelocation 四个属性都给了默认值,这些对应了 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 = 26private 意味着只有这个类自己能访问它,连子类都不行。为什么要私有?因为温度是一个敏感数据,不能让外面随便改——你想啊,如果有人直接把温度设成 -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() 添加了一个从淡蓝到白色的渐变背景,看起来更有质感。


回头看看我们做了什么

整个项目从头到尾走一遍,你会发现它其实就是一个完整的面向对象设计过程:

  1. 枚举定义固定选项(设备类型、安装位置),防止拼写错误
  2. 接口定义设备的统一规范(IDevice),所有设备必须遵守
  3. 抽象类复用共性逻辑(Device),把个性化的部分留给子类
  4. 继承创建具体设备(SmartLock、AirConditioner、Light),每个子类实现自己的开关逻辑
  5. 泛型接口和泛型类设计服务层(IDeviceService、LocalDeviceService),让代码更通用
  6. 模块化组织代码(common、model、services、utils、view、viewModel),各层各管各的
  7. MVVM 模式连接数据和 UI(ViewModel 做翻译官),让界面和数据解耦
  8. 声明式 UI 框架做界面(@Component、@State、@ObjectLink),状态变了界面自动刷新

这些东西单独拿出来看可能有点抽象,但当你把它们全部用在一个真实项目里的时候,你会发现——每一层、每一个设计决策都不是多余的。接口让不同设备能被统一管理,抽象类避免了重复代码,泛型让服务层不绑定具体设备类型,ViewModel 让 UI 不直接依赖数据模型。这就像搭积木一样,每一块积木都有它的位置,抽掉任何一块整体就不稳固了。

Logo

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

更多推荐