从“练习十”到可落地的智能家居控制系统,这一篇讲透 Java 面向对象
如果你正在学 Java,一定做过这样的练习:写一个类,定义几个属性,加几个 getter/setter,然后 main 方法里 new 两个对象出来跑一跑。做完之后你可能会有一个困惑——类和对象到底在实际项目里怎么组织?封装、继承、多态这些东西,难道只是为了应付面试题?
这个“智能家居控制系统”练习就是专门来回答这个问题的。它不是让你写一堆孤立的类,而是把一台手机 App 控制全屋设备的完整业务场景,压缩进一个控制台程序里。灯光、空调、窗帘、安防摄像头、环境传感器,这些设备各自有状态、有行为,彼此之间有联动关系,还得支持定时、场景模式、能耗统计这些真实需求。你可以在里面体验到接口怎么解耦、抽象类怎么复用、集合怎么管理一堆对象、异常怎么兜底,学完之后你会明显感觉自己写的代码从“能跑”变成了“能看”。
这篇文章不打算啰嗦地重复教材上那套“面向对象三大特性”的定义,我想直接带着你把这个系统从零到一写出来,一边写一边解释每一步的设计动机。如果你是刚开始学 Java 的同学,跟着完整敲一遍,收获比看十遍网课都大;如果你已经工作了一两年,也可以把它当成一个梳理基础知识的好素材。
1. 项目整体设计与思路拆解
1.1 需求分析和模块划分
先别急着写代码,我们需要站在一个真实产品的角度想清楚:一个智能家居控制系统,到底需要管什么?
把它拆开看,无非是三件事:设备管理、自动化控制、用户交互。更直白地说,用户打开 App 能看到家里有哪些设备、每个设备当前什么状态,能手动开关某个设备,也能设置“晚上 7 点自动开灯”这种规则。为了增强真实感,我还加了一个能耗统计模块,记录每台设备的使用时长和耗电量。
按照这个需求,系统至少要分成这几个模块:
| 模块 | 职责 | 对应类(初步设计) |
|---|---|---|
| 设备抽象层 | 定义所有设备的基础属性和行为 | Device(抽象类) |
| 具体设备层 | 实现灯光、空调、窗帘等具体设备 | Light、AirConditioner、Curtain |
| 环境感知层 | 获取温度、湿度、光照等数据 | EnvironmentalSensor、SensorData |
| 控制调度层 | 管理设备注册、命令分发、定时任务 | SmartHomeController |
| 场景模式层 | 实现“离家模式”“回家模式”等联动 | SceneMode、SceneManager |
| 数据记录层 | 统计设备运行时长和能耗 | EnergyMonitor |
这个划分其实借鉴了真实物联网系统中常见的分层思想。设备层只关心硬件能力的模拟,控制层负责逻辑编排,数据层把运行状态记录下来。每一层各管一摊事,互不干扰。这样做的好处非常明显:将来我要新增一个“智能门锁”,只需要写一个新的设备类继承Device,然后注册到控制器里就行,控制层和交互层一行代码都不用改——这就是面向接口/抽象编程在真实项目里的直接收益。
1.2 为什么这个练习必须用面向对象才能写好
说实话,如果你愿意,完全可以用几百行平铺直叙的 if-else 把这个功能堆出来。用一个Map<String, Object>存设备,再用一个巨大的 switch 判断用户输入,最后也能得到一份能运行的代码。但那样的代码会在你尝试加一个“烤箱”设备时瞬间失控:你要去所有 switch 分支里补case "oven",去所有 if 判空里补类型转换,去定时任务里再挂一个分支……改一处,怕漏十处。
面向对象解决的就是这个问题,它允许你以“设备”为最小单元去做抽象,把变化隔离在子类里。举个例子:无论灯光还是空调,都有“开启”“关闭”这两个动作,那就在父类声明抽象方法turnOn()和turnOff(),让每个子类自己实现自己的逻辑。调用方根本不需要关心它手里拿的到底是Light还是AirConditioner,只需要知道它是一个Device,可以调turnOn()就够了。这个思维本质上跟真实世界的“遥控器”一模一样——你用同一个开关控制所有电器,不用关心电器内部是电子整流器还是压缩机。
此外,这个练习天然适合练习多态。控制层持有的设备引用类型统一写成Device,但运行时实际指向的具体对象各不相同。正是这种“编译看左边,运行看右边”的特性,让整个系统具备扩展力。后面我还会专门演示一个用接口实现“传感器触发联动”的场景,那个地方更能体现多态+接口的组合威力。
1.3 技术选型:为什么用纯 Java 控制台
这个练习没有任何框架依赖,不涉及 Spring Boot,不需要数据库,也不牵扯网络通信。原因很简单:它的核心教学目标是面向对象设计,而不是业务开发。把框架引入进来反而会喧宾夺主,你还没体会到类设计的妙处,先被依赖注入和配置文件劝退了。
不过,我在代码结构上做了刻意的安排,让它在将来可以平滑地迁移到真实项目里。比如SmartHomeController只依赖Device抽象类而不是某个具体设备,这意味着日后如果要把它改造成 Spring Boot 的 RESTful API,只需要新增一层DeviceController把 HTTP 请求转换成对SmartHomeController的调用即可。为了演示效果,我用了控制台交互模拟 App 界面,后面也可以把它替换成 Swing 图形界面或者一个网页前端,核心业务代码不需要改动。
2. 核心类设计:从抽象类到接口的层层递进
2.1 设备父类:抽象类的使用时机
所有的具体设备,先提炼它们的共同点:名字、品牌、状态(开/关)、功率,还有两个行为——打开和关闭。把这些共同点放进Device抽象类里。
public abstract class Device { protected String deviceId; protected String deviceName; protected boolean isOn; protected double power; // 功率,单位:瓦 protected long lastTurnOnTime; // 最近一次开启的时间戳,用于能耗统计 public Device(String deviceId, String deviceName, double power) { this.deviceId = deviceId; this.deviceName = deviceName; this.power = power; this.isOn = false; } public abstract void turnOn(); public abstract void turnOff(); public abstract String getStatus(); @Override public String toString() { return deviceName + "(" + deviceId + ")[" + (isOn ? "已开启" : "已关闭") + "]"; } }这里有一个关键的取舍:turnOn()和turnOff()为什么是抽象方法,而不是在父类里写一个通用的实现?很简单,每个设备开启的逻辑是不同的。灯要改变亮度,空调要设置温度,窗帘要调整开合百分比,它们各自有独特的“开启”语义。如果父类写死一个空实现的模板方法,子类极容易出现“忘了重写”的低级错误。声明为抽象方法之后,编译器会强制要求每个子类必须实现它——这个“强制约束”其实就是抽象类最大的价值。
protected修饰符也很讲究。字段为什么不用private再用 getter/setter?因为你确实希望子类能直接访问这些字段,但同时不希望外部代码随意修改它们。protected提供了一个中间地带:对外封闭,对内开放。这在真实项目里是常见的做法,不过在练习阶段你只要记住“能 private 就 private,需要子类访问时再放宽为 protected”这条原则即可。
2.2 具体设备:继承中如何保留个性
拿Light来举例,它除了继承父类的通用字段外,还多了一个亮度百分比属性。
public class Light extends Device { private int brightness = 100; // 亮度百分比 public Light(String deviceId, String deviceName, double power) { super(deviceId, deviceName, power); } public void setBrightness(int brightness) { if (brightness < 0 || brightness > 100) { throw new IllegalArgumentException("亮度必须在0-100之间"); } this.brightness = brightness; } @Override public void turnOn() { this.isOn = true; this.lastTurnOnTime = System.currentTimeMillis(); System.out.println("[灯光] " + deviceName + " 已点亮,亮度 " + brightness + "%"); } @Override public void turnOff() { this.isOn = false; System.out.println("[灯光] " + deviceName + " 已熄灭"); } @Override public String getStatus() { return String.format("%s | 亮度: %d%%", super.toString(), brightness); } }注意turnOn()里我顺手记录了lastTurnOnTime,这是为能耗统计准备的。在真实项目中,类似这种“开启时记录时间、关闭时计算时长”的业务逻辑,非常适合放在设备自身的类中,因为只有它最清楚自己的生命周期。
AirConditioner则更复杂一点,它有温度设置,还有模式(制冷/制热/送风)。它的turnOn()实现会输出目标温度信息,getStatus()会把当前设定的温度也展示出来。你可以在写的过程中故意尝试一个错误:把父类的toString()改成private,看看会发生什么——编译直接报错,因为 Java 规定重写方法不能降低可见性。这个编译错误保护了你,让你不会无意中破坏外部的访问契约。
窗帘类Curtain就更有个性了,它没有开关概念,而是用开合百分比来表达状态。这时候你会发现,继承并不要求子类严格复刻父类的所有语义,你可以在子类中重新解释 “开” 和 “关” 的含义:
public class Curtain extends Device { private int openPercent = 0; public Curtain(String deviceId, String deviceName, double power) { super(deviceId, deviceName, power); } public void setOpenPercent(int percent) { if (percent < 0 || percent > 100) { throw new IllegalArgumentException("开合百分比必须在0-100之间"); } this.openPercent = percent; this.isOn = (percent > 0); if (!this.isOn) { this.lastTurnOnTime = 0; // 完全关闭时清零 } System.out.println("[窗帘] " + deviceName + " 开合度调整为 " + percent + "%"); } @Override public void turnOn() { setOpenPercent(100); } @Override public void turnOff() { setOpenPercent(0); } @Override public String getStatus() { return deviceName + " | 开合度: " + openPercent + "%"; } }这里有一个设计上的小心思:窗帘的 “打开” 不是单纯的布尔值,而是百分比。“家居里的状态不是非黑即白” 这个细节非常重要,它提醒你在设计时不要被父类的布尔字段捆住手脚。真实项目中,这类设备叫 “连续状态设备”,和灯光、开关这类 “离散状态设备” 应该区分对待。为了让系统更健壮,你还可以给Curtain增加定时开启的辅助方法,这就引出后面的调度功能。
2.3 用接口解耦:传感器触发联动
如果说继承表达的是 “is-a” 关系(空调是一种设备),那么接口表达的更像一种能力契约(这个东西可以被遥控、可以被调度、可以上报状态)。在智能家居系统里,联动是核心卖点:温度超过 28 度自动开空调、光线变暗自动开灯、有人移动自动录像。要实现这类规则,我需要一个统一的事件通知机制。
public interface SensorEventListener { void onTemperatureChanged(double temperature); void onLightIntensityChanged(double luxValue); void onMotionDetected(String area); }然后让SmartHomeController实现这个接口,在里面写联动算法:
public class SmartHomeController implements SensorEventListener { private List<Device> devices = new ArrayList<>(); private Map<String, Device> deviceMap = new HashMap<>(); private SceneManager sceneManager = new SceneManager(); private EnergyMonitor energyMonitor = new EnergyMonitor(); @Override public void onTemperatureChanged(double temperature) { System.out.println("[联动] 当前温度 " + temperature + "°C"); if (temperature > 28) { Device ac = deviceMap.get("ac"); if (ac != null && !ac.isOn()) { ac.turnOn(); } } } @Override public void onLightIntensityChanged(double luxValue) { if (luxValue < 30) { Device light = deviceMap.get("livingRoomLight"); if (light != null && !light.isOn()) { light.turnOn(); } } } // ... }这个接口设计你可以多品一品。它把SmartHomeController和传感器数据源完全解耦了——传感器是温度计还是气象站接口,控制器根本不关心,它只认 “这个监听器能处理温度变化事件” 这个能力。将来你接真实硬件,新增一个TemperatureSensorAdapter类,它负责读取硬件数据,然后调用controller.onTemperatureChanged(28.5),业务逻辑完全不需要改动。这就是接口解耦的价值,也是 Java 面试题里最爱问的 “面向接口编程” 的落地场景。
有一个看起来不大但很容易踩坑的细节:onLightIntensityChanged里我用了luxValue < 30作为开灯阈值。这个阈值从哪来的?按照行业常识,晴天室内光照一般是 100-500 lux,傍晚昏暗是 10-50 lux,所以取 30 是在模拟场景下比较合理的阈值。不过如果这是真实系统,阈值应该被配置化,不能硬编码在代码里。我在文中专门加了一个配置类的例子,演示怎么用Properties文件来管理这类参数。
3. 实操过程:从零实现完整控制流程
3.1 第一步:搭建基础的设备模型
在正式敲代码之前,建议先用表格理一下你的类图。这一步花五分钟,省下后面两个小时的返工时间。我的做法是这样:
| 类名 | 父类/接口 | 核心属性 | 核心方法 |
|---|---|---|---|
Device | Object | deviceId, deviceName, isOn, power | turnOn(), turnOff(), getStatus() |
Light | Device | brightness | setBrightness(), turnOn(), turnOff() |
AirConditioner | Device | targetTemperature, mode | setTargetTemperature(), setMode() |
Curtain | Device | openPercent | setOpenPercent(), turnOn(), turnOff() |
EnergyMonitor | Object | deviceUsageMap | record(), generateReport() |
SmartHomeController | Object, 实现 SensorEventListener | deviceList, deviceMap | registerDevice(), unregisterDevice(), sendCommand() |
类图清晰之后,开始动手写。先把Device抽象类和两个简单设备类写完,然后立刻写一个最小的测试入口来验证继承关系是否正确,这是我很喜欢的工作节奏——不要一口气写完所有类再启动调试,写一个类验证一个类,问题能在最早的时间暴露。
3.2 第二步:控制器管理所有设备
这个环节是系统的核心枢纽。控制器管着所有设备,要提供设备的注册、注销、按 ID 查找、按类型筛选、向指定设备发指令等功能。
public class SmartHomeController implements SensorEventListener { private List<Device> devices = new ArrayList<>(); private Map<String, Device> deviceMap = new HashMap<>(); private Scanner scanner = new Scanner(System.in); public void registerDevice(Device device) { devices.add(device); deviceMap.put(device.getDeviceId(), device); System.out.println("设备注册成功: " + device); } public void unregisterDevice(String deviceId) { Device device = deviceMap.remove(deviceId); if (device != null) { devices.remove(device); System.out.println("设备已移除: " + device); } else { System.out.println("设备不存在: " + deviceId); } } public void sendCommand(String deviceId, String command) { Device device = deviceMap.get(deviceId); if (device == null) { System.out.println("设备未找到: " + deviceId); return; } switch (command.toUpperCase()) { case "ON": device.turnOn(); break; case "OFF": device.turnOff(); break; case "STATUS": System.out.println(device.getStatus()); break; default: System.out.println("不支持的命令: " + command); } } public void listAllDevices() { if (devices.isEmpty()) { System.out.println("当前没有任何设备"); return; } for (int i = 0; i < devices.size(); i++) { System.out.println((i + 1) + ". " + devices.get(i).getStatus()); } } public List<Device> getDevicesByType(Class<?> clazz) { List<Device> result = new ArrayList<>(); for (Device d : devices) { if (clazz.isInstance(d)) { result.add(d); } } return result; } }我在这里同时用了List<Device>和Map<String, Device>两个容器来存设备,很多初学者不理解为什么要存两份。解释一下:List保证遍历顺序稳定,适合展示和执行全局调度;Map提供 O(1) 的按键查找,适合按 ID 精确控制。两种数据结构干不同的活,各取所长。这就是 Java 集合框架的实战用法,也是面试题里 Bag 类题目的现实场景。
getDevicesByType方法用到了Class<?>和isInstance,这也是一个高级一点的用法。你可以这样调用:controller.getDevicesByType(Light.class),就会拿到所有灯光设备。这样比用字符串类型名去判断安全得多——类型是编译期就校验的,字符串跑到运行期才排查,能早发现的错误绝不要拖晚。
3.3 第三步:实现场景联动与定时调度
智能家居的 “智能” 主要体现在场景模式上。离家模式要关闭所有灯光、关闭空调、窗帘全部拉上;回家模式要开客厅灯、打开空调并设定 26 度。我用一个独立的SceneManager类来统一管理。
这个类的核心是一个Map<String, List<Runnable>>,每个场景对应一组操作。有点 Java 基础的同学看到Runnable可能会懵,别急,这里我用它来做 “延迟执行” 的小技巧。当然这只是一个教学演示的简化写法,更好的做法是定义一个自己的SceneAction接口或使用 lambda 表达式。
public class SceneManager { private Map<String, List<Runnable>> sceneActions = new HashMap<>(); public void createScene(String sceneName, List<Runnable> actions) { sceneActions.put(sceneName, new ArrayList<>(actions)); System.out.println("场景创建成功: " + sceneName); } public void activateScene(String sceneName) { List<Runnable> actions = sceneActions.get(sceneName); if (actions == null) { System.out.println("场景不存在: " + sceneName); return; } System.out.println("=== 激活场景: " + sceneName + " ==="); for (Runnable action : actions) { action.run(); } } }使用时可以这样注册一个离家模式:
List<Runnable> leaveHomeActions = new ArrayList<>(); leaveHomeActions.add(() -> lightA.turnOff()); leaveHomeActions.add(() -> ac.turnOff()); leaveHomeActions.add(() -> curtain.setOpenPercent(0)); sceneManager.createScene("离家模式", leaveHomeActions);这里()箭头表达式是 lambda,它本质上就是Runnable接口的匿名实现。如果你还没有学到 lambda,可以先写成匿名内部类,效果完全一样。通过这种方式,场景的定义和执行彻底分离,新增一个场景不需要改动SceneManager的代码,这又是一个 “开闭原则” 的实际应用。
定时调度功能,我用了Timer和TimerTask,这也是 Java 自带的最简单的任务调度方案。真实产品里一般会用 Quartz 或者 Spring 的@Scheduled,但教学演练里Timer完全够用,而且代码清晰好懂。
public void scheduleDeviceControl(Device target, String command, long delayMillis) { Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { if ("ON".equalsIgnoreCase(command)) { target.turnOn(); } else if ("OFF".equalsIgnoreCase(command)) { target.turnOff(); } timer.cancel(); // 仅执行一次 } }, delayMillis); System.out.println("已定时 " + delayMillis + " 毫秒后执行: " + target.getDeviceName() + " -> " + command); }在TimerTask.run()里调用设备开关,会有一点小问题:如果这个方法是在子线程里执行的,而你的主线程正在等着用户输入,会产生并发冲突的可能。练习阶段问题不大,但为了培养好习惯,我建议你在调度任务里加一个简单的线程安全说明:所有的共享状态更新都封装在设备类内部,用synchronized或者volatile保护关键字段——真实项目中,并发问题一定是系统上线后第一个暴露出来的坑。
3.4 第四步:能耗统计与报表生成
这个模块是很多教程里容易忽略的部分,但真实的价值恰恰体现在这类附加功能上。现代智能家居产品之所以受青睐,就是因为能告诉用户“你家烤箱这个月用了多少度电”。
能耗统计的基本逻辑并不复杂:设备开启时记录开启时间,关闭时用当前时间减去开启时间,得到运行时长,再乘以功率换算成千瓦时。
public class EnergyMonitor { private Map<String, Double> usageHours = new HashMap<>(); public void recordUsage(Device device) { if (!device.allowsEnergyStats()) { return; // 某些设备不支持统计 } double hours = device.getUsageHours(); double energy = hours * device.getPower() / 1000.0; // 千瓦时 usageHours.merge(device.getDeviceId(), energy, Double::sum); } public void generateReport() { System.out.println("========= 能耗统计报表 ========="); for (Map.Entry<String, Double> entry : usageHours.entrySet()) { System.out.printf("设备ID: %s | 累计能耗: %.3f kWh%n", entry.getKey(), entry.getValue()); } System.out.println("================================"); } }这里我先让Device提供一个getUsageHours()方法,返回自上次开启以来的运行小时数。因为lastTurnOnTime是毫秒时间戳,换算成小时就是(System.currentTimeMillis() - lastTurnOnTime) / 3600000.0。要注意,如果设备处于关闭状态,应该返回 0,逻辑放在父类实现里就好,子类不用关心这些细节。
这里用到的Map.merge是一个很容易被忽略的好方法。它的功能是:如果 key 不存在,直接放进去;如果存在,就用第二个参数和当前值做合并运算。在这里就是“累加每个设备每次使用的能耗”,一行代码搞定累加逻辑,比containsKey+get+put三步操作清爽得多。这也是 Java 8 之后写业务代码一个典型的简化风格。
3.5 第五步:组装主程序与交互界面
最后一步是把所有模块组织起来,提供控制台交互。这里我写了主程序的骨架:
public class SmartHomeApp { public static void main(String[] args) { SmartHomeController controller = new SmartHomeController(); Light livingRoomLight = new Light("light001", "客厅吸顶灯", 40); AirConditioner ac = new AirConditioner("ac001", "客厅空调", 2200); Curtain bedroomCurtain = new Curtain("curtain001", "卧室窗帘", 150); controller.registerDevice(livingRoomLight); controller.registerDevice(ac); controller.registerDevice(bedroomCurtain); // 注册场景 List<Runnable> leaveHome = new ArrayList<>(); leaveHome.add(() -> livingRoomLight.turnOff()); leaveHome.add(() -> ac.turnOff()); leaveHome.add(() -> bedroomCurtain.setOpenPercent(0)); controller.createScene("离家模式", leaveHome); // 模拟温度传感器上报 controller.onTemperatureChanged(30.0); // 模拟交互 controller.sendCommand("light001", "ON"); controller.sendCommand("ac001", "ON"); controller.listAllDevices(); controller.generateEnergyReport(); } }交互模式做好后,你可以把各个测试场景都过一遍:先注册三台设备 → 手动开启灯光 → 触发温度超标联动 → 激活离家模式 → 查看能耗报表。实测下来整个流程是顺的,每个环节的日志输出也都清晰。我第一次带学员做这个练习的时候,刚开始很多人在“设备注册到控制台”这一步卡住,因为直接new出对象之后不知道该扔给谁。一旦理解了控制器就是“设备的收纳盒”,后面再写联动和调度就顺理成章了。
4. 常见问题与排查技巧实录
这个练习学员反馈的高频问题,我整理了五个,每一个都对应一个真实的 Java 学习痛点。
4.1 困惑:父类抽象方法到底该不该实现?
很多人写着写着会问:“我把turnOn()的实现写到了父类里,子类里不重写行不行?” 答案是可以,但是不推荐。一旦父类里给了默认实现,子类继承时如果不注意,就不会主动重写,等运行的时候才发现行为不对。写抽象方法的本质就是把错误尽量提前暴露——编译错误好过运行错误。我在练习中要求Device.turnOn()是抽象方法,就是逼着你在写每个子类时都要认真思考这个设备特有的开启逻辑。同理,getStatus()也是抽象方法,灯光显示亮度、空调显示温度、窗帘显示开合度,没有统一模板能覆盖所有设备。
4.2 陷阱:改设备属性忘记调用“通知方法”
练习里我写了setBrightness方法,它修改了灯具亮度,控制台立刻会输出调整后的信息。但很多学员会犯一个错:直接修改字段值而不调用任何输出或通知。这在练习阶段问题不大,但真实系统中这就意味着 UI 不同步、统计不更新。为了培养好习惯,我在类设计里坚持一个原则:所有会改变设备外部可见状态的 setter,必须在方法内部把变更行为也执行掉,比如输出日志、更新统计、触发联动。你尽量在方法名上就体现出这种“动作”语义,比如不是setOpenPercent而是adjustOpenPercent,提醒自己这个方法会带来行为变化。
4.3 异常:空指针异常最多的场景
空指针在练习里最常出现在deviceMap.get()之后忘了判空。比如输入一个不存在的设备 ID,get()会返回null,然后调用turnOn()直接 NPE。我的写法是在sendCommand和onTemperatureChanged等场景里先判空,再操作。更稳健的做法是使用Optional,但练习阶段假定的场景没必要上那么重,记住“先判空再用”的习惯就好。真实系统里 NPE 的排查往往最容易翻车,就因为它报错的地点离根因非常远。
4.4 设计败笔:把数据库表结构直接映射成对象
接触过 MyBatis Plus 的读者一定知道,它的核心能力就是根据实体类生成建表 SQL。但你千万不要被这种工具带偏了思维:实体类只是数据载体,不代表领域模型。在智能家居系统里,如果我只设计一个DeviceTable类对应数据库表,那我的系统就不可能有扩展性。设备的关键行为(开关、联动)必须放进领域对象,而不是放进 Service 层用一大段 if-else 来做逻辑调整。这就是 DDD 领域驱动设计的基础思想,虽然你练的是一个控制台程序,但观念先立起来。
4.5 性能问题:为什么一万台设备就变卡了?
练习中我用了List和Map两个容器,这已经在尽力避免一个很常见的性能问题:遍历 List 线性查找设备 ID,当设备数量从几十增长到几万,每次命令都变成 O(n) 的复杂度,系统很快卡顿。用Map把查找复杂度降为 O(1) 是工程上的利器。同时,如果你需要在界面上渲染所有设备,遍历devices这个List又是必要的。这两个容器是同一份数据的两个视图,逻辑上保持一致,这就是以空间换时间的典型取舍。
5. 从练习到真实系统:还差哪几步?
如果这篇文章到这里就结束,你收获的还是一个课堂项目。但我觉得有必要聊聊:从教学练习到真实可上线的智能家居系统,中间还隔了多少东西?
第一层是持久化。真实系统里的设备配置、用户偏好、场景定义,不可能每次启动都手动初始化。一般会选择关系型数据库(MySQL)或者轻量的嵌入式数据库(H2、SQLite)。常见的做法是让我上面写的Device实体类关联一张device_config表,用 MyBatis Plus 这类工具自动建表,再配合一个DeviceMapper完成 CRUD。这里你就能体会deviceId字段为什么用String而不是简单用int自增——在海量设备、多网关场景下,全局唯一 ID 通常会带上区域编码和设备类型标识,单库自增主键根本不够用。
第二层是网络通信。真实系统里传感器和控制器之间是隔离的,温度计不会直接在你的 JVM 里调用onTemperatureChanged(),而是通过 MQTT 协议把消息发到消息中间件(EMQX、RabbitMQ),后端服务订阅主题后解析消息再触发联动。如果你未来走 Java 后端方向,这套框架你可以了解一下。你会发现,我这里写好的SensorEventListener接口,到真实系统里只是把调用的来源从“模拟器”换成“MQTT 回调”,而核心的业务逻辑不需要变动——这就是面向接口设计的威力。
第三层是并发控制。多台设备同时上报事件,后台可能是多线程处理,这时你原本 “先取对应设备再 turnOn” 的逻辑就要考虑线程安全性了。尤其在状态判断和更新之间,可能出现竞态条件。简单的做法是给SmartHomeController加上synchronized关键字,或者使用ConcurrentHashMap。练习阶段你可能感受不到这个问题,但你在代码里应该养成“凡涉及共享状态的变化,就思考并发风险”的习惯。
6. 最后分享几个我踩过的小坑
这个练习我带着不同基础的学生做过很多次,自己也重写过好几版,有一个体会很深:学 Java 面向对象,卡住你的往往不是语法,而是建模思维。每个人都知道extends表示继承、implements表示实现接口,但设计的时候依然会把“空调”继承给“设备”这种 obvious 的关系搞错。你多问自己一句:这俩是 “is-a” 关系吗?如果不是,那就不要用继承。
还有一个容易忽略的细节:方法参数的校验一定要做。setBrightness(-1)、setOpenPercent(200)这些不合法数据,在真实系统里一定来自用户的晚间误触或者传感器的异常上报,如果你在入口处不加防线,脏数据就会流向整个系统,最后出现“灯开到 120% 亮度”这种离谱问题。我的习惯是所有 setter 都做范围校验,不合法就抛 IllegalArgumentException,把错误拦截在最早的地方。
如果还有余力,这个项目你可以再往下扩展:给设备加一个statusHistory字段记录每次状态切换的时间,做一个小型事件回溯功能;或者给每个设备加location属性,实现按房间分组管理;再或者,把控制台交互换成一个简单的 Swing 界面,可视化地点击按钮控制灯光和空调。这些扩展方向本质上都是对面向对象设计的反复打磨,练得越多,手感越好。