ArkTS官方代码-智能家居本专题用于记录阅读官方代码时做的笔记这富文本编辑器和markdown互换也太难用了markdown改不了图片大小富文本直接复制markdown代码块格式复制不过来首先我们来看代码的基础架构这是一个典型的MVVM架构。我们可以对这个架构进行分类层级职责对应文件夹View 层UI 展示、用户交互、页面/组件pages、view、common仅 UI 组件部分ViewModel 层状态持有、业务逻辑协调、调用 ServiceviewModelModel 层数据实体、数据源管理本地/网络model、services工具/基础设施层纯函数、跨层通用能力utils、common仅常量/工具部分MVVM:Model、View、ViewModel我们可以先preview以下这个项目看看这一连串的代码实现了什么样的效果接下来我将从工具/基础设施层---Model层---ViewModel层---View层的顺序对这个项目进行阅读解析工具/基础设施层commonTypes.etsexport enum DeviceType { Light light, AirConditioner ac, Lock lock, } ​ export enum Location { All all, LivingRoom livingRoom, Bedroom bedroom, Entrance entrance }可以看到里面定义了两个枚举类DeviceType和Location分别记录设备和设备运作的地点。utilsDeviceUtils.etsexport function delay(ms: number): Promisevoid { return new Promise(resolve setTimeout(resolve, ms)); } ​ export const logAction (msg: string): void { console.log([DeviceLog] ${new Date().toISOString()}: ${msg}); };这里是两个工具函数delay()用于模拟设备开启时的的延迟返回值setTimeout相信各位初学时看见这一串new Promise(resolve setTimeout(resolve, ms));会看的很懵。没关系我们来一点一点拆解实际上这是两个内部函数的嵌套被箭头函数简化的结果new Promse(function(resolve) { //函数体 } ​ setTimeout(function() { //延迟后执行的代码 }, 延迟的秒数 )上面是setTimeout创建和将函数包装成promise的基础包装方式。而在智能家居代码中由于需要模拟不同设备在开始时的不同延迟所以将其封装为通用的工具函数。为了避免陷入回调地狱所以需要用箭头函数对其进行简化。function delay(ms: number) { function delay(ms: number) { return new Promise(function(resolve) { return new Promise((resolve) { setTimeout(function() { setTimeout(() { resolve() resolve() }, ms) }, ms) }) }) } }setTimeout本身第一个参数期望是一个函数而promise传入的resolve本身就是一个函数因此不需要再多设置一层resolve()所以上述代码可以简化为function delay(ms: number) { return new Promise((resolve) { setTimeout(resolve, ms) // ← 直接把 resolve 传给 setTimeout }) }而如果箭头函数只有一行表达式则可以省略{}和returnfunction delay(ms: number) { return new Promise(resolve setTimeout(resolve, ms)); }logAction操作日志工具函数至于这里为什么会是一个参数的形式因为这里利用了ArkTS-函数这一章的知识“函数类型“只是没有类型注解。以下三个函数的结果是一样的调用方式也一样// 写法 1直接写在变量上类型注解 const logAction(msg: string) void{ ... } ​ // 写法 2用 type 定义别名 type LogFunction (msg: string) void const logAction: LogFunction (msg) { ... } ​ // 写法 3不写类型注解让 TS 自动推断 const logAction (msg: string): void { ... } ​Model层model该模块下存在四个文件分别为存储公共父类的Device和空调、灯、智能锁三个独立设备子类。Device.ets内部首先声明了一个公共接口声明了四个所有设备公共的属性和两个必须实现的方法用于后续类的实现。这里再次重复接口中声明的无论是方法还是属性实现这个接口的类必须实现或初始化或者声明为抽象类型留给子类实现。export interface IDevice { id: string; // 设备唯一标识必填 name: string; // 设备名称用户可自定义 type: DeviceType; // 设备类型必须是DeviceType枚举值 location: Location; // 安装位置必须是Location枚举值 turnOn(): Promisevoid; // 开机方法返回Promise支持异步操作如设备联网通信 turnOff(): Promisevoid; // 关机方法同上 }接下来是Device这个父类的其实现了这样几个功能实现了IDevice接口新增了status方法用于标记设备的开关状态添加了getStatus和getInfo两方法用于获取设备状态和设备的详细信息。构造constructor用于对属性进行初始化构造Device对象将turnOn和turnOff定义为抽象方法供子类自行实现export abstract class Device implements IDevice { id: string ; name: string ; type: DeviceType DeviceType.Light; location: Location Location.All; ​ protected status: on | off off; ​ constructor(id: string, name: string, type: DeviceType, location: Location) { this.id id; this.name name; this.type type; this.location location; } ​ getStatus(): on | off { return this.status; } ​ abstract turnOn(): Promisevoid; abstract turnOff(): Promisevoid; ​ getInfo(): string { return ${this.name}${Location[this.location]} 位置的 ${DeviceType[this.type]} 设备; } }到这里我产生了一个问题为什么同样是属性status会在Device类中单独声明而不是在接口中一同声明我想大概有以下两点接口定义“公开行为”抽象类提供“内部状态”接口是对外暴露的其内部所有对象均为public意味着任何人都可以访问修改。在这里接口应该向调用者声明“这个设备可以干什么”而具体的状态如何储存、如何表示是实现细节不需要向外部展露。封装与访问控制接口应当小而专接口隔离原则ISP)应当只实现所有实现类的交集部分。且Device中可以将status定义为protected提供getStatus方法。子类可以直接修改this.status外部代码只能通过getStatus读取无法修改。如果在接口中声明则其内部所有成员都是public任何调用者都能直接写了会破坏封装。AirConditioner.ets、Light.ets和SmartLock.ets这三个子类没什么好说的都是对Device这个公共类的具体实现。他们主要实现了以下几点实现了Device类中抽象类拥有各自turnOn和turnOff根据自身需要实现的功能定义了自己的属性和方法实现了自己类的构造体特别需要提一嘴的是子类实现自己的构造体时需要先调用父类的构造体像这样constructor(id: string, name: string, location: Location) { super(id, name, DeviceType.Lock, location); //子类自己的新添构造代码 }serviceIDeviceService.ets这里定义了一个接口声明了设备服务的各个方法。方法作用initialize初始化设备加载默认设备初始化配置getDevice获取所有原始设备列表getFilteredDevices获取筛选后的设备列表getLocations获取所有设备位置getSelectedLocation获取筛选后的设备位置filterDevicesByLocation根据位置筛选设备可以注意到接口被设置为泛型接口方便后续利用泛型约束将实现接口的类的接收参数规定为Device。LocalDeviceService.etsObserved首先能看到的是Observed这是ArkUI中用于标记 一个类可为可观察的装饰器。他的核心作用是当被Observed装饰的类内部的属性发生变化时能够触发 UI 的自动更新。那么就有问题了Q1如果为了监听为什么不用State状态监听而是要用Oberved呢我们来看下面这个例子class Device { name: string ; isOn: boolean false; } ​ Component struct DeviceCard { State device: Device new Device(); ​ build() { Column() { Text(this.device.name) Button(开关) .onClick(() { this.device.isOn !this.device.isOn; //不能触发UI更新 }) } } }可以看到普通的类即使是有State属性其也不能自动监听到内部属性的变化。这是因为State只监听device这个引用本身的变化而不会深入监听device.isOn的改变。如果想要用State解决问题则需要一个个将device内部属性也标记为State。但是这样嵌套结构复杂时会很麻烦。为此引入了ObservedObjectLink。在类上使用Observed装饰器标记类而后在子组件中使用ObjectLink来接收该实例。这样就能实现内部属性变化时触发UI的自动刷新。注意Observed只能用于类不能用于接口或普通对象ObjectLink只能用在被Observed修饰的类上且不能用于State已经包装的对象。Observed同样也可以被State接收也就是浅响应 T Q2为什么这里接口类都被定义为泛型在这里我们能看到类为泛型类这是因为内部需要用到的设备列表中的类包含Device下的多个继承子类定义为泛型而后对其进行约束有助于让编译器提供更精准的类型提示和安全校验。还有这里this.devices [ new Light(L1, 客厅主灯, Location.LivingRoom), //... ] as T[];重点关注这个as T[]这是类型断言因为device内部的实例是不同类的所以这里进行断言可以绕过编译器判断防止编译报错实际上删掉就会报错所以还是加一加吧。[...数组名]在这个服务类中存在许多get方法。他们被用于返回类中直接定义的诸多属性值。但如果返回对象是数组的话return后直接跟数组名字的方式返回的是返回数组的引用地址也就是说会导致外部可以直接用这个引用直接修改原数组。这显然是和封装的概念相悖的。于是引入了[...数组名]的方式getLocations(): string[] { return [...this.locations]; }就拿上面这个get函举例。[...this.locations]实际上是对locations数组进行了一个浅拷贝返回的是该数组的副本而非数组本身有效避免了外部能够修改数组内部值的问题。filter()filter是 JavaScript 数组的一个内置方法它不会改变原数组而是返回一个新数组新数组中只包含那些使回调函数返回true的元素。this.filteredDevices this.devices.filter( device device.location.toLowerCase() location.toLowerCase() );回到我们代码中看这里实际上就是用devices列表调用了filter函数通过传入的回调函数做比对toLowerCase()全变成小写后根据返回的布尔值判断是否筛选。Set学过java的应该都很熟悉。Set是 JavaScript / TypeScript 中的一种内置对象它存储唯一的值任何类型的值包括原始值或对象引用并且可以按插入顺序迭代。说白了就是相比较Array而言有一个去重的效果在插入对象时会判断内部是否有一样的。private updateLocations(): void { const locationSet new Setstring(); this.devices.forEach(device locationSet.add(device.location)); this.locations Array.from(locationSet); }在这个代码中主要用于统计所有设备对应的全部位置。forEach的作用和上面的filter一样不过这只是遍历没有筛选功能。ViewModel层viewModel这一层其实作用和MVC架构中的Controller作用相同用于协调Model和View处理用户交互更新界面。具体来说就是进行事件监听调用它Model的方法决定渲染那个View将Model数据传递给View。以上是比较官方的定义。实际上按我的话来说就是把Model的方法属性什么的包一层不让Model直接调用View里的对象用的。ACViewModel.ets、LightViewModel.ets和LockViewModel.ets观察这三个ViewModel类其定义了暴露给页面用于实现显示逻辑的中间变量而后在构造体中调用对应设备对象的参数对这些中间变量进行初始化。最后定义了各自设备对应的方法“外壳”来调用各自的Model中方法。初次看到turnOn和turnOff中的状态修改语句this.status on,可能会纳闷为什么这里需要定义一个看似无用只是修改中间变量的值而不更新对象内部的状态值的状态修改呢实际上在Model中已经规定了对于status的修改交由turnOn和turnOff中同步修改所以实际上对象内部的值已经正确更改了。而外部的这个中间变量的修改实际上是为了供给View进行页面逻辑用的。View层modelACView.ets、LightView.ets和LockView.ets这三就没什么好说的了也就是各自生成组件小卡片用于显示。这个等后续详细了解页面构成后再进行解析。现在倒是可以看一下Oberved的两个接收ObjectLink device: Device; State light: LightViewModel new LightViewModel(this.device);Index.ets同样的这里先不去深入探讨页面样式相关的内容但倒是可以先关注一些页面逻辑。Row({ space: 8 }) { Button(this.getLocationDisplayName(all)) .width(60) //... ForEach(this.deviceService.getLocations(), (location: string) { Button(this.getLocationDisplayName(location)) .width(60) //... }) }可以想想这么一个问题为什么这里all要单独定义呢明明Types.ets中定义时是包含all的啊怎么不能一起遍历呢问题出在deviceService其返回的location来自localDeviceService.ets中的updateLocation函数筛选过的device.location而这个device再初始化时是这么定义的this.devices [ new Light(L1, 客厅主灯, Location.LivingRoom), new AirConditioner(AC1, 卧室空调, Location.Bedroom), new SmartLock(LOCK1, 入户门锁, Location.Entrance) ] as T[];发现了吗是按照设备单独定义的。而设备位置自然是没有all的所以这里单靠遍历无法覆盖到。这其实和filterDeviceByLoaction方法中分开判断location是一个道理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() ); } }四个关键生命周期方法哦对了倒是可以先了解以下这四个生命周期方法方法触发时机用途aboutToAppear组件即将被构建build执行之前初始化数据、订阅事件、启动动画等一次性准备工作onPageShow页面每次显示时首次进入、从后台返回、从其他页面返回刷新数据、恢复定时器等onPageHide页面每次隐藏时跳转到其他页面、按下 Home 键暂停动画、停止轮询、保存状态aboutToDisappear组件即将销毁从页面栈移除取消订阅、清理定时器、释放资源我的实践好的我们已经完整的了解了这个页面如何设计的整个流程。所以尝试一下自己加一个进去添加地点厨房添加设备厨房灯吸油烟机添加设备属性吸油烟机吸力大小、内置灯亮度厨房灯常规灯