六大设计原则——拿我的鸿蒙聊天室开刀
六大设计原则:拿我的鸿蒙聊天室开刀——6 个原则,6 处真代码
设计原则这东西,背起来谁都会:单一职责、开闭、里氏替换、依赖倒置、接口隔离、迪米特,一口气能报菜名。但真到看代码的时候,大多数人只会愣愣地来一句"写得挺规范啊"。这篇换个学法:拿我自己写的鸿蒙聊天室 MyChat 开刀,6 个原则逐个对照,每个原则都在代码里指出它的真身在哪。指不出来真身的原则,等于没学。
一、先立个总纲:六个原则其实在说同一件事
六大设计原则(Single Responsibility Principle 等六条的合称)听起来是六条规矩,内核其实只有一句话:
让"变化"关在笼子里。
| 原则 | 一句话 | 管什么 |
|---|---|---|
| 单一职责 | 一个类只有一个让它变化的理由 | 笼子里装哪种变化 |
| 开闭原则 | 加功能靠新代码,别改老代码 | 加东西别拆笼子 |
| 依赖倒置 | 使用方和实现方都只依赖抽象 | 笼子之间通过笼子缝对接 |
| 里氏替换 | 子类换进父类的位置,程序不许炸 | 换笼子里的东西不惊动笼子 |
| 接口隔离 | 别强迫实现类用不到的方法 | 笼子别焊死多余的门 |
| 迪米特法则 | 只跟直接朋友说话 | 站笼子外头,只敲门别翻里头 |
下面逐个拿真代码对号入座。
二、单一职责:一个类只有一个"让它变化的理由"
2.1 先拆一个我踩过的坑
我第一遍学时,曾把 WebSocketManager 里这行拎出来当"单一职责"的证据:
private static instance: WebSocketManager // 全局唯一实例
看起来"唯一"嘛,很"单一"。这是错的——这行是单例模式,管的是实例个数:整个 App 只允许存在一条 WebSocket 连接,所以聊天页、会话列表页、首页拿到的必须是同一个对象。
单一职责管的是职责个数:一个类只有一个"引起它变化的原因"。
两个词都带"唯一/单一",约束的完全不是一回事。一个类可以既是单例又身兼八职(配置+日志+网络+解析全干)——单例满分,单一职责零分。
2.2 可操作的判据:问"什么变了会逼我改它"
拿我的 WebSocketManager 判一遍:
| 什么在变 | 该不该改它 | 谁接盘 |
|---|---|---|
| 重连退避参数、加密握手超时 | 该改,这就是它的职责 | 自己 |
| 顶号处理规则 | 该改 | 自己 |
| 加密算法从 ECDH+AES-GCM 换掉 | 不该改 | CryptoManager |
| 新增一种消息类型 | 不该改 | MessageFactory |
| UI 改版 | 不该改 | ChatPage |
一类变化打动它,别的变化都有别人接盘——这就是单一职责。证据就在代码里:它 import 了 CryptoManager 而不是把加密算法攥在手里;dispatchToPages 只负责把解密后的明文转发给页面回调,构造消息对象的活全甩给 MessageFactory。这条界线划干净了,300 行的类也不散。
反例特征:一个"Manager"又管连接又管解析又管存储,改任何一个需求都要动它——这就是"万能类",改一处抖三处。
三、开闭原则:加新功能,动不动老代码?
3.1 判据就一句话
对扩展开放,对修改关闭:加新功能的时候,是写新代码,还是改老代码?
拿我的 MessageFactory 判:
static create(type: string, data: Record<string, Object>, isMine: boolean, clientId?: string): ChatMessage | null {
if (type === 'private_chat' || type === 'group_chat') {
return new TextMessage(/* 六个字段,省略 */)
}
if (type === 'group_private') {
return new GroupPrivateMessage(/* 六个字段,省略 */)
}
return null // 未知 type 返回 null——调用方必须兜底
}
现在要加一种图片消息 'image'?——得在这个方法里加第三个 if。改了老代码,严格说,违反开闭。
3.2 那为什么还说工厂"符合"开闭?
对比着看:
- 没有工厂时:ChatPage 三处调用(历史消息、实时消息、本地乐观插入),加上未来每一处
new的地方都要加分支,一次需求改 5 个文件 - 有工厂时:只改 MessageFactory 一个文件
工厂把"必须改的地方"从 N 处收缩到 1 处。这叫修改点集中,是开闭原则的"打折版"。
全价形态是注册表——新类型自己登记,工厂代码一个字不动:
// 全价版:每种消息类型把自己的构造器登记进来,工厂只看表
static registry: Record<string, (data: Record<string, Object>, isMine: boolean, clientId?: string) => ChatMessage> = {}
static register(type: string, fn: (data: Record<string, Object>, isMine: boolean, clientId?: string) => ChatMessage): void {
MessageFactory.registry[type] = fn
}
static create(type: string, data: Record<string, Object>, isMine: boolean, clientId?: string): ChatMessage | null {
let fn = MessageFactory.registry[type]
return fn !== undefined ? fn(data, isMine, clientId) : null
}
3.3 但我的项目里,if 链就是够用
消息类型三种、服务端协议定死,为"可能增长"提前上注册表,属于过度设计——过度设计比违反原则更糟。
我代码注释里那句话才是重点:“新增类型只动这里,不动 ChatPage”。开闭原则真正的受益者是调用方:加新消息类型,ChatPage 三处调用加三处渲染,一行都不用碰。
四、依赖倒置:名字最怪,其实就一句话
4.1 先看代码长什么样
ChatMessage 是底座,只有一个 displayText():
export class ChatMessage {
content: string // 消息正文
timestamp: string // 时间戳
// 渲染的统一入口:"显示出来是啥?"——基类返回原文,子类覆写换版本
// 为什么要有它:把"怎么显示"的知识收进消息自己身上,ChatPage 不用知道谁是谁
displayText(): string {
return this.content
}
}
export class GroupPrivateMessage extends TextMessage {
// 群内私信只多做一件事:显示时加'[私聊] '前缀,所以只覆写这一个方法
displayText(): string {
return '[私聊] ' + this.content
}
}
ChatPage 里,消息列表只认底座:
@State messages: ChatMessage[] = [] // 渲染的唯一数据源——只声明底座类型
// ...
Text(item.displayText()) // 三处渲染,都是这一句话
页面不知道也不关心 item 里装的是 TextMessage 还是 GroupPrivateMessage——只问一句话:“你显示出来是啥?”
4.2 "倒置"倒的是什么?
没有倒置的世界,ChatPage 得自己认类:
// 反例:上层直接使唤每一个具体类
if (item instanceof TextMessage && item.isPrivate) {
text = '[私聊] ' + item.content
} else if (item instanceof SystemMessage) {
text = item.content
} else {
text = item.content
}
看出问题了吗:上层(使用策略)直接依赖下层每一个具体类。依赖箭头:ChatPage → TextMessage / GroupPrivateMessage / SystemMessage。用的人使唤被用的具体东西——天经地义,这就是"正"的方向。代价:加一种消息类型,这个 if 链就要改;而渲染有三个分支,就得抄三遍。(我的聊天室改造前,'[私聊] ' 前缀正是手拼了三处。)
倒置之后,依赖方向变成:
- ChatPage → ChatMessage(抽象)
- 三个具体类 → ChatMessage(抽象,继承并实现它定的规矩)
- ChatPage 和具体类之间,一根直接连线都没有
"倒"就倒在这儿:本来是上层使唤下层;现在是下层遵照上层立的规矩长,两头都只跟抽象打交道。
规矩是谁定的?使用方定的,实现方来适配。就像插座:电器(天天换)和电线(埋墙里)要是直接对接,换个电器得砸墙;插座就是抽象——电器厂商按国标做插头,装修按国标装插座,两头谁都不直接认识谁,而国标是用电的一方定的。接口由使用方定义,实现方向使用方看齐,这就是依赖倒置。
4.3 诚实段:我的代码也没"倒置"干净
拿这把尺子量我自己的 ChatPage,具体类还是漏了几处进来:
// 106 行:按 clientId 找回"乐观插入"的那条消息,三道门全过才算匹配
// ① instanceof TextMessage——SystemMessage 没有 clientId 字段,先挡掉
if (item instanceof TextMessage && item.clientId !== undefined && item.clientId === clientId) { /* ... */ }
// 157 行:服务端确认消息后,按原类重建、只换真实时间戳
newList[idx] = new GroupPrivateMessage(old.content, serverTimestamp, old.isMine, old.senderId, old.senderUsername, old.senderAvatar)
// 494 行:渲染时按类型选模板——系统消息走灰条
if (item instanceof SystemMessage) { /* ... */ }
逐条判:
- 前两条是数据操作(按临时 ID 找回、服务端确认后重建),不是"行为调用"。依赖倒置主要保护行为契约,可接受。真想干净,就把"标记已确认"这类操作收进消息类自己的方法
- 第三条不算违反:按类型选模板是正当的类型分派。真把样式知识塞进 model 类反而更糟——model 不该知道 UI 长什么样
设计原则是取舍,不是教条。 知道哪处要紧、哪处无所谓,比全盘照背有用得多。
五、里氏替换:子类换进去,不许炸
定义一句话:凡是代码里写父类的地方,偷偷换成它的任何子类,程序行为不该坏。
我的聊天室里有一个现成的正向证据——SystemMessage:
// 系统消息("XX退出了群聊"这类灰条):
// 是 ChatMessage(有内容、有时间、要显示),但不是 TextMessage(没有人发、没有头像、不分左右)
// 直接站底座 = 不被迫继承 isMine/sender 三件套这些没意义的字段
export class SystemMessage extends ChatMessage {
constructor(content: string) {
super(content, new Date().toLocaleTimeString())
}
}
反过来想:如果硬让 SystemMessage 继承 TextMessage,列表里就会出现一个没有 senderId 的"TextMessage"——任何按父类假设去读 senderId 的代码(点头像进私聊、群聊显示昵称)当场炸裂。这就是违反里氏替换。
我注释里那句"is-a 只继承’真的是’的那条线",就是里氏替换的直觉版。
而 157 行那段"重建时必须保持原类",是同一个原则的另一副面孔:服务端确认消息时,私聊消息必须还按 GroupPrivateMessage 重建——换成父类 TextMessage,'[私聊] ' 前缀当场丢失。子类不是可替换的装饰品,它的行为是父类承诺的一部分。
教科书里最经典的反例长这样:
class Penguin extends Bird {
fly(): void {
throw new Error('企鹅不会飞!') // ❌ 子类把父类方法掏空,父类的承诺被打破
}
}
出现"子类覆写方法直接 throw 不支持",就是这个继承关系硬凑的铁证。
六、接口隔离:别把 40 针的 USB 口怼给充电器
定义:客户端不该被迫依赖它用不到的方法。
识别法很好用:看到一个"大而全"的接口或基类,实现类里一半方法是空着的或者 throw"未实现"——接口太胖,该拆。
鸿蒙开发里你天天见正确的示范——页面生命周期有一堆回调(onPageShow / onPageHide / onBackPress / aboutToAppear / aboutToDisappear……),ArkUI 没有逼你全实现,用哪个覆写哪个。这就是瘦接口的思路:接口按客户端需求切碎,别图省事搞全家桶。
我的 WebSocketManager 里也有这个影子——它对外其实是两拨方法:
| 分组 | 方法 | 谁在用 |
|---|---|---|
| 聊天业务 | send / addMessageListener / removeMessageListener | 聊天页面 |
| 连接生命周期 | connect / disconnect / setOnReconnect / addOnceOpenListener | 登录页、首页 |
页面通常只碰第一拨。严格做可以拆成两个接口(比如 IChatChannel 和 IConnectionLifecycle),现在没拆是因为规模小——但记住"胖接口"长什么样,以后看到"一个接口 20 个方法、实现类空着一半"的代码,就知道该拆。
七、迪米特法则:只跟直接朋友说话
迪米特法则(Law of Demeter,又叫"最少知识原则"):一个对象应该对别的对象有最少的了解。 说白了:只跟直接朋友说话,别跟朋友的朋友搭讪。
代码里的违反特征一秒可辨:
a.getB().getC().doSomething() // ❌ a 认识了 b 的朋友 c——点四五下,改最里层时最外层跟着炸
我的聊天室里,守门员就是 displayText():ChatPage 拿到消息对象,只问一句话,不伸手进对象肚子里掏 content / timestamp / sender 三件套自己拼。改造前那段 '[私聊] ' + msg.content 的手拼,就是违反——ChatPage 深入消息对象的内部细节拼显示,显示规则一改,三处手拼全得跟着改。
这就是为什么 displayText() 该存在:它既是多态,也是迪米特法则的守门员。
八、总结
- 单一职责:一个类只有一个"让它变化的理由"。单例管实例个数,单一职责管职责个数——别混
- 开闭原则:加功能靠新代码。工厂把修改点从 N 处收缩到 1 处是打折版;注册表是全价版。类型会不会野蛮生长,心里要有数,if 链够用就别上注册表
- 依赖倒置:使用方立契约(ChatMessage + displayText),实现方(子类)来适配,两头都只认抽象——这就是"倒置"
- 里氏替换:子类换进父类位置,程序不许炸。SystemMessage 拒绝继承 TextMessage 是守规矩;"重建必须保持原类"是同一原则的另一面;子类 throw"不支持"是硬凑的铁证
- 接口隔离:别把 40 针 USB 口怼给充电器。ArkUI 生命周期回调只覆写需要的,就是瘦接口
- 迪米特法则:只跟直接朋友说话。displayText() 一句话问完就走,别 a.b.c.d() 点四五下
六个原则凑齐了回头看,内核真就一句话:让"变化"关在笼子里——单一职责决定笼子里装哪种变化;开闭决定加东西别拆笼子;依赖倒置决定笼子之间怎么对接;里氏替换决定换东西不惊动笼子;接口隔离决定别焊死多余的门;迪米特决定站笼子外头只敲门。
学设计原则最大的误区,是把六条当名词背。正确的打开方式是:打开一个你自己写的项目,逐条问"这条原则的真身在哪?"指不出来,就是还没懂——指出来的那一刻,原则才真正是你的。
相关文章
- 《从零搭建HarmonyOS+Node.js全栈聊天室》(这个聊天室的诞生)
- 《观察者模式看穿ArkUI状态管理》(同款学法:拿天天用的框架,找模式藏身的真身)
更多推荐

所有评论(0)