尧图精选

抽象工厂模式实战:用产品族思想解决跨平台UI一致性

🕒 发布时间:2026/10/1 10:03:08 📁 来源:尧图网络
1. 先解决一个真问题产品成族出现时直连 new 会让你崩溃1.1 一个多端 UI 需求引发的噩梦很多朋友学设计模式学到抽象工厂这一节就开始打瞌睡。UML 图看了三遍每个角色名字都记住了合上书还是不知道它到底解决什么问题。我早年在做一套报表系统的时候也栽过同样的跟头——不是看不明白定义而是被一个真实的需求逼到墙角之后才发现那些抽象角色其实离日常编码非常近。抽象工厂模式Abstract Factory Pattern是创建型设计模式里最容易被误解、也最容易被低估的一个它解决的核心问题不是怎么生成一个对象而是怎么保证一组对象永远配套出现。如果你写过跨端 UI、适配多套主题、或者对接过多种数据库这篇文章应该能帮你在 20 分钟内把它彻底用明白。我先说一个很多前端和客户端开发都遇到过的场景。公司要做一套报表系统界面需要做两套风格Windows 上跑一套原生 Windows 观感Mac 上跑一套 Mac 观感。抛开真正跨平台方案不谈假设产品经理明确要求不同平台用不同控件库你最开始会怎么写新手写法通常是两层 if 嵌套if (isWindows) { new WindowsButton().render(); } else { new MacButton().render(); }一个按钮还好但报表系统怎么可能只有一个按钮很快你会遇到工具条上的按钮、查询区的文本框、筛选项里的下拉框、弹窗、分页组件……每一个控件都要判断一次当前平台。代码写到最后整个页面渲染方法变成了一个几十行的 if/else 大杂烩。最要命的是过两个月要新增一个 Linux 端你必须打开每一个渲染页面逐个补else if。漏改一次线上就会混出来一套四不像界面。这个场景不是我编的是很多人在小型项目里真实踩过的。我甚至见过一个老系统页面渲染逻辑里塞了七八个平台判断分支后来接新平台时运营在测试环境随便一点就发现弹窗还是旧风格查了半天才发现某个工具类里的分支没补上。它背后藏着两个痛点第一对象的创建逻辑散落在各处改一处漏一处第二一组对象之间必须保持风格一致但没有任何机制来保证这一点。1.2 每个对象都用工厂方法为什么还是不够有人会说创建逻辑散落那我用工厂方法模式把创建动作收拢起来不就行了工厂方法确实能解决单个对象怎么创建的问题。你可以写一个ButtonFactory根据平台参数返回不同按钮再写一个TextBoxFactory返回不同输入框。表面上你很优雅代码也好读了但一个隐藏问题马上冒出来谁来保证这两个工厂的选择是一致的换句话说ButtonFactory不知道TextBoxFactory选了哪个平台。如果 A 同事把按钮的创建逻辑写成了if (isWindows)B 同事把输入框的创建逻辑写成了if (!isMac)在 Windows 平台上你很可能得到一个 Windows 按钮 Mac 输入框的组合而程序本身完全没有报错直到测试截图发到群里你才发现界面已经乱了。所以当我们需要的不只是一个对象而是一组必须配套出现的对象时单纯拆出几个工厂方法是不够的。我们需要一个更高层的东西一次决定这一整套都用哪个风格。1.3 产品族与产品等级理解抽象工厂的第一把钥匙要彻底理解抽象工厂必须先分清两个词产品族Product Family和产品等级结构Product Hierarchy。拿家具举例。椅子和沙发是两种不同的产品类型椅子下面有现代椅、古典椅沙发下面有现代沙发、古典沙发。这里产品等级结构椅子、沙发这种同一类产品的继承/实现关系产品族现代风格椅 现代风格沙发 现代风格茶几这种风格一致的一组产品。每个具体产品其实是产品类型和产品风格这两条坐标轴交叉出来的格子。现代椅是现代与椅子的交叉古典沙发是古典与沙发的交叉。抽象工厂干的事翻译成生活语言就是你不需要自己分别去挑椅子和沙发你只需要告诉商家你要现代风格还是古典风格商家会把整套风格统一的家居给你配齐。订单里绝不会出现现代椅子配古典沙发的操作因为那是两个不同工厂干的事。提示判断一个场景适不适合用抽象工厂就看这一组对象如果混搭了系统会不会出问题。会出问题就是抽象工厂的主场不会出问题用工厂方法就够。2. 抽象工厂的结构其实就四张牌2.1 四个角色的分工抽象工厂模式整个结构里只有四个角色把它想成一张打牌的桌面就够了抽象工厂AbstractFactory定义一组创建方法的接口比如createChair、createSofa。它只负责声明我能创建哪些东西不关心到底会创建出什么具体对象。具体工厂ConcreteFactory不同风格的实现比如ModernFurnitureFactory、ClassicalFurnitureFactory。每个具体工厂负责把同一个风格的一整套具体产品组装好。抽象产品AbstractProduct一族产品的共同接口比如Chair、Sofa。具体产品ConcreteProduct某个具体工厂产出的具体对象比如ModernChair、ClassicalSofa。客户端调用者只依赖抽象工厂和抽象产品所有具体类都藏在具体工厂内部。这样客户端写代码时面对的就只有接口没有实例怎么来、从哪来的负担。2.2 结构骨架代码一眼看懂先别急着写业务我们用一组最简洁的骨架代码看看这套结构长什么样// 抽象产品 public interface Chair { void sit(); } public interface Sofa { void lie(); } // 抽象工厂约定一套家具应该包含哪些成员 public interface FurnitureFactory { Chair createChair(); Sofa createSofa(); } // 具体产品现代椅 public class ModernChair implements Chair { public void sit() { System.out.println(坐在现代椅上); } } // 具体产品古典沙发 public class ClassicalSofa implements Sofa { public void lie() { System.out.println(躺在古典沙发上); } } // 具体工厂现代风格负责生产一整套现代家具 public class ModernFurnitureFactory implements FurnitureFactory { public Chair createChair() { return new ModernChair(); } public Sofa createSofa() { return new ModernSofa(); } } // 具体工厂古典风格负责生产一整套古典家具 public class ClassicalFurnitureFactory implements FurnitureFactory { public Chair createChair() { return new ClassicalChair(); } public Sofa createSofa() { return new ClassicalSofa(); } }看仔细了ModernFurnitureFactory的createChair永远返回ModernChaircreateSofa永远返回ModernSofa。也就是说只要客户端拿到的是ModernFurnitureFactory它创建的整套产品就一定都是现代风格。风格的一致性在工厂内部就被约束住了而不是靠调用方自觉。这个约束比任何代码规范都可靠因为它是从类型层面强制出来的。2.3 客户端为什么能不知情地拿到正确对象这套结构能运转的底层逻辑其实就是面向接口编程 运行时多态两句话的事。第一客户端持有的工厂变量类型永远是抽象工厂FurnitureFactory。至于真正传进来的对象是ModernFurnitureFactory还是ClassicalFurnitureFactory客户端根本不在乎也不应该在乎。第二客户端持有的产品变量类型也永远是抽象产品Chair、Sofa。createChair()返回的实例在运行时是ModernChair但客户端调用sit()时根本不需要知道它的真实类型多态会自动帮你调用到正确实现。这两条加在一起客户端代码就成了一个只问接口、不问实现的纯消费者。真正决定用哪一套风格的地方往往只在程序入口处或者在一个配置加载模块里。这种工厂选择权上移的做法让后续扩展新风格变得格外轻松。3. 手把手实现跨平台 UI 组件库完整 Demo3.1 需求与建模两个抽象产品、两家具体工厂理论说完了下面用跨平台 UI 组件库这个经典例子完整写一遍。需求是这样的应用需要根据运行平台Windows / Mac创建对应风格的按钮和输入框而且要保证同一个平台下按钮和输入框一定来自同一风格。我故意不选数据库那种偏底层的例子因为 UI 场景最直观谁都能看懂按钮和输入框搭配不当有多违和。建模分三步抽出抽象产品Button按钮、TextBox输入框为每个平台各实现一套具体产品WindowsButton、WindowsTextBox、MacButton、MacTextBox抽出抽象工厂UIFactory里面声明createButton()和createTextBox()再为两个平台各写一个具体工厂。这个过程里最容易踩的坑是一上来就写接口实现结果接口设计得过大后面的具体类连继承关系都对不上。我的习惯是先列一张产品类型清单按钮、输入框再列一张平台清单Windows、Mac确认交叉格子是闭合的再动手写类。3.2 完整可运行代码下面是完整的 Java 实现我把所有类放在一个文件里展示方便你直接跑// ---------- 抽象产品 ---------- public interface Button { void render(); } public interface TextBox { void show(); } // ---------- 具体产品Windows 风格 ---------- public class WindowsButton implements Button { Override public void render() { System.out.println(渲染一个 Windows 风格的按钮); } } public class WindowsTextBox implements TextBox { Override public void show() { System.out.println(显示一个 Windows 风格的输入框); } } // ---------- 具体产品Mac 风格 ---------- public class MacButton implements Button { Override public void render() { System.out.println(渲染一个 Mac 风格的按钮); } } public class MacTextBox implements TextBox { Override public void show() { System.out.println(显示一个 Mac 风格的输入框); } } // ---------- 抽象工厂 ---------- public interface UIFactory { Button createButton(); TextBox createTextBox(); } // ---------- 具体工厂Windows 一族 ---------- public class WindowsFactory implements UIFactory { Override public Button createButton() { return new WindowsButton(); } Override public TextBox createTextBox() { return new WindowsTextBox(); } } // ---------- 具体工厂Mac 一族 ---------- public class MacFactory implements UIFactory { Override public Button createButton() { return new MacButton(); } Override public TextBox createTextBox() { return new MacTextBox(); } } // ---------- 客户端应用主体只认识抽象工厂 ---------- public class Application { private final UIFactory factory; private Button button; private TextBox textBox; public Application(UIFactory factory) { // 把工厂从外部注入而不是在这里自己选 this.factory factory; } public void init() { button factory.createButton(); textBox factory.createTextBox(); } public void paint() { button.render(); textBox.show(); } public static void main(String[] args) { String osName System.getProperty(os.name).toLowerCase(); UIFactory factory; // 工厂选择集中在入口处方便日后改成配置驱动 if (osName.contains(win)) { factory new WindowsFactory(); } else { factory new MacFactory(); } Application app new Application(factory); app.init(); app.paint(); } }运行起来很简单你在 Windows 上跑输出两行在 Mac 上跑输出另外两行。因为System.getProperty(os.name)拿到的是实际平台字符串所以这套代码天然跟随运行环境切换组件风格。3.3 运行结果与调用链分析这段程序的调用链是理解抽象工厂的关键我拆开说main方法根据系统属性拿到osName在入口处决定用哪一个具体工厂Application构造函数把工厂注入并保存应用内部此后不再出现任何WindowsFactory/MacFactory字样init()里调用factory.createButton()和factory.createTextBox()实际返回的是对应平台的WindowsButtonWindowsTextBox或者MacButtonMacTextBoxpaint()里只调用抽象产品的公共方法render()/show()由多态把调用分发到具体实现。注意第三步如果main选了WindowsFactory那init()里拿到的按钮和输入框就必然都是 Windows 风格。你想让它们变成一混一做不到因为这个工厂在内部就把配对写死了。这正是抽象工厂相对于工厂方法的不可替代之处——它不是靠约定也不是靠代码审查而是从对象创建的那一刻起就锁死了整族的属性。4. 实战验证新增一个 Linux 风格要改几行代码4.1 按步骤添加 Linux 风格现在需求方来说我们也要支持 Linux 桌面端。如果之前的代码里全是散落的 if/else这一步会让很多人头皮发麻但在抽象工厂的结构下步骤非常固定。第一步新增两个具体产品类public class LinuxButton implements Button { Override public void render() { System.out.println(渲染一个 Linux 风格的按钮); } } public class LinuxTextBox implements TextBox { Override public void show() { System.out.println(显示一个 Linux 风格的输入框); } }第二步新增一个具体工厂public class LinuxFactory implements UIFactory { Override public Button createButton() { return new LinuxButton(); } Override public TextBox createTextBox() { return new LinuxTextBox(); } }第三步只在入口的选择逻辑里加一个分支if (osName.contains(win)) { factory new WindowsFactory(); } else if (osName.contains(linux)) { factory new LinuxFactory(); } else { factory new MacFactory(); }完成。整个过程中我没有动过Application、Button、TextBox、WindowsButton、MacButton任何一个既有的类。新来的同事看到这段代码也不需要理解整个渲染链路只要照着 WindowsFactory 的样子抄一个 LinuxFactory 出来再把新工厂挂到入口处就行。这种照着既有模式抄作业的体验在真实团队协作里特别重要因为大部分维护代码的人根本不是你。4.2 改动面到底有多大符合开闭原则我们把改动清单列出来对比一下类型的类本次增加 Linux 时是否需要改动抽象产品接口Button/TextBox不需要抽象工厂接口UIFactory不需要客户端Application不需要既有具体产品Windows / Mac 系列不需要既有具体工厂Windows / Mac 工厂不需要新增 Linux 系列产品类新增 2 个类新增 Linux 工厂类新增 1 个类入口处平台判断增加 1 个分支这就是设计模式里经常说的对扩展开放对修改关闭开闭原则的直观体现。新增一个产品族新风格只需要新增类不需要修改已有类。不过我在这里要泼一盆冷水现实项目里入口处的那个分支通常也做不到百分百不动。但你把它收敛成了一个很小的决策点而不是散落在几十个页面里这已经是质变了。真正的可维护性从来不是一行代码都不改而是改动都集中在一个可预期、可控制的地方。4.3 反过来看新增一种产品种类时为什么疼抽象工厂最大的软肋不是新增风格而是新增产品类型。假设产品经理拍板所有平台都要加一个下拉框 Dropdown。你会遇到什么你需要在抽象工厂接口里加一个createDropdown()方法然后在WindowsFactory、MacFactory、LinuxFactory这三个具体工厂里全部实现一遍再写WindowsDropdown、MacDropdown、LinuxDropdown三个新类。这意味着每次扩展产品等级结构既有所有工厂都要跟着改。这就像卖家具的店铺每次新进一种座椅品类所有风格的门店仓库都要同步上架。如果业务演进速度很快产品类型隔三差五就加一种抽象工厂反而会成为修改风暴的中心。提示抽象工厂的优缺点是对称的——一族内配套好跨族难混搭一族好扩展一类难新增。选择前请务必对两个方向的演变频率做一次预判。这不是选型时的优点而是选型前就必须接受的代价。5. 抽象工厂和工厂方法别再傻傻分不清5.1 一张表讲清核心差异好多人在面试时会把抽象工厂和工厂方法说混其实两者的发力点完全不同。我用一张表把它们钉死对比维度工厂方法模式抽象工厂模式关注点单个产品对象的创建一组配套产品的创建产品数量一个产品等级结构多个产品等级结构配套保证不保证各管各的强制保证一族产品风格一致核心动作子类重写工厂方法来决定具体产品具体工厂实现多个创建方法整体返回一套产品扩展方向新增具体产品类时只需新增对应子类新增产品族容易新增产品类型较难典型结构一个抽象类 多个子类工厂方法一个抽象工厂接口 多个具体工厂类更直白一点工厂方法解决的是同一个产品由哪个子类去造抽象工厂解决的是一整套不同产品如何保证来自同一个家族。一个是点一个是面。5.2 按产品数量和产品族两个维度做选型我把实际选型时的判断逻辑总结成一句话先数产品等级再看要不要配套。如果系统里只有一种产品等级比如只有按钮不同平台只是按钮的不同实现那用工厂方法就够了不需要上抽象工厂如果系统里有多种产品等级按钮、输入框、下拉框而且这些产品强相关、必须保持一致那抽象工厂是天然选择如果这些产品等级之间没有配套要求也就是可以自由混搭其实你用几个独立的工厂方法或简单工厂都行抽象工厂反而会制造不必要的约束如果创建过程很复杂比如要配置颜色、尺寸、事件监听那抽象工厂里每个创建方法内部可以再结合 Builder 模式把复杂对象的组装细节继续封装下去。我在实践中还有一个更懒的判断法一家工厂接口里至少要出现两个以上的创建方法才值得叫抽象工厂如果只有一个创建方法它就是披着抽象工厂外衣的工厂方法。这个判断标准能帮你快速过滤掉很多为了模式而模式的类也经常在代码评审阶段帮我拦住那些过度抽象的同事。6. 现实世界里的抽象工厂数据库层、UI 主题、框架内部6.1 Swing 的 LookAndFeel教科书级别的现身说法Java 的老牌 GUI 框架 Swing内部就藏着一套抽象工厂的经典实例叫 LookAndFeel外观。你可以通过一行代码切换整个界面的风格UIManager.setLookAndFeel(new WindowsLookAndFeel());这行代码一执行界面上的按钮、菜单、滚动条、文件选择器全部变成 Windows 风格。你再看它的实现MetalLookAndFeel、WindowsLookAndFeel、MotifLookAndFeel 分别对应不同的具体工厂每个工厂都会成体系地返回自己那套组件外观实现。如果你能理解 LookAndFeel 的机制其实就相当于在一个真实的大型框架里看到了抽象工厂模式。6.2 数据库方言 Dialect产品族思想在 ORM 中的应用另一个非常典型、但很多人没意识到的地方是 ORM 框架里的数据库方言Dialect。以 Hibernate 为例MySQL、Oracle、PostgreSQL 对分页 SQL、序列生成、自增主键的语法各不相同。Hibernate 为每种数据库准备了一个Dialect子类例如MySQLDialect、OracleDialect。当你切换hibernate.dialect配置时框架内部会同时切换掉一整组 SQL 生成策略分页用的LIMIT写法、查询序列的方式、判断空值的函数……全部都来自同一个方言家族。你可以想象一下如果分页用一种数据库的写法序列查询用另一种数据库的写法应用上线后 Oracle 上跑 MySQL 的 SQL那画面得多美。必须整套配套使用这个约束放在数据库方言场景里几乎是生死线这正是抽象工厂适合的土壤。6.3 商业项目里抽象工厂为什么常被简化不过看多了业务代码你会发现真正在一线业务里手写AbstractFactory的场合并没有书里那么多。多数时候大家会用更轻量的方式达到同样的效果。比如 Spring 项目里你完全可以用Configuration加Bean把不同策略的实现注册成 Bean再根据配置项注入或者用一个MapString, SomeFactory做策略注册表。效果上它同样实现了把一组相关对象的创建集中起来、按需切换只是省去了那些抽象接口的繁琐声明。这不代表抽象工厂没有用而是说明设计模式的思想可以迁移载体可以变化。你真正需要带走的核心能力是识别哪些对象必须成套出现的建模嗅觉而不是背住某个模式的类图。看框架源码时能认出它的影子自己写业务代码时懂得用更顺手的等价物替代这比死记硬背模式名有用得多。7. 我踩过的坑抽象工厂最容易翻车的三个地方7.1 为了模式而模式只有一个产品线也硬上抽象工厂早年我见过一份代码抽象工厂接口里声明了createUserService()、createOrderService()、createReportService()三个方法看起来非常规范。但如果你仔细想这三个业务服务之间根本不存在风格必须一致的关系它们只是恰好都需要被创建而已。这是典型的为了模式而模式。抽象工厂的价值在于约束产品族之间的配套关系如果没有这种配套约束硬把不相关的对象塞进同一个工厂接口只会让工厂随着业务膨胀成一个新的上帝类比不用设计模式还难受。我现在的做法是先想清楚这些对象是不是真的互相绑定、必须成套如果不是就不要让它们住在同一家工厂里。有时候把创建逻辑拆成几个独立的小工厂或者干脆用依赖注入容器去管理反而是更干净的选择。7.2 产品族扩展难被产品种类牵着走这部分我在 4.3 里已经详细讲过产品族方向加新风格好扩展产品等级方向加新类型难扩展。这个不对称性很多人一开始根本没想到。解决方案也不是没有。如果你的业务注定要在产品等级方向频繁扩展可以考虑把抽象工厂退化为注册表 泛型创建形式让具体工厂通过一个create(ClassT type)方法来创建任意类型避免每加一个产品类型就改动所有工厂public interface UIFactory { T T create(ClassT componentType); }然后每个工厂内部维护一个从Class到具体构造函数的路由表。这种写法牺牲了一点类型安全换来了产品等级方向上的良好扩展性。用不用取决于产品类型变更的频率。如果三五年才加一个新控件老老实实用标准抽象工厂反而更稳。7.3 工厂越抽象越复杂不如用 Map 注册表 反射这是我在好几个真实项目里踩完之后总结的如果具体工厂的数量不多但入口选择逻辑总是变来变去那就别把工厂选择写死在 if/else 里直接用一个注册表来解决。public class UIFactoryRegistry { private static final MapString, UIFactory FACTORIES new HashMap(); static { FACTORIES.put(windows, new WindowsFactory()); FACTORIES.put(mac, new MacFactory()); FACTORIES.put(linux, new LinuxFactory()); } public static UIFactory get(String platform) { UIFactory factory FACTORIES.get(platform.toLowerCase()); if (factory null) { throw new IllegalArgumentException(未知平台: platform); } return factory; } }调用方只需要UIFactoryRegistry.get(linux)一行就能拿到对应的整套工厂。以后加新平台只需在注册表里多放一行不需要动任何业务逻辑。注意注册表方式把创建逻辑集中到了一个 Map 里牺牲掉的是一部分编译期类型检查——你不能再指望编译器提醒你某个工厂没实现某个新方法必须靠单元测试去兜底。这个取舍要权衡好。如果连初始化也想省我还会配合反射扫描注解让系统启动时自动发现所有带有UIFamily(windows)标记的工厂类并注册进去。这算是把抽象工厂和现代框架的组件扫描思路结合了一波项目里效果很好。8. 最后分享两个实践技巧8.1 技巧一让 IoC 容器来当工厂的妈妈在 Spring 这类容器框架里抽象工厂往往不需要你手动维护生命周期。你可以为每种产品族写一个独立的配置类容器一启动就把它变成 Bean调用方只依赖抽象工厂接口具体的工厂 Bean 由容器按条件自动注入Configuration public class UIFactoryConfig { Bean ConditionalOnProperty(name ui.platform, havingValue windows) public UIFactory windowsFactory() { return new WindowsFactory(); } Bean ConditionalOnProperty(name ui.platform, havingValue mac) public UIFactory macFactory() { return new MacFactory(); } }这样选择哪个工厂这件事就彻底从代码里挪到了配置里。换一套 UI 风格只需改配置文件连重新编译都不需要。这应该是现代项目里最舒服的落地方式编译期类型安全还在运行时切换还方便。8.2 技巧二先出现两个产品族再上抽象工厂最后说点个人经验。如果你的项目目前只有一套风格不管界面还是业务我建议先别急着抽象。我过去有一个习惯性动作提前把未来才可能出现的第二个产品族预算出来结果写出来的工厂接口又大又空等到真正需要第二个风格时发现当初的抽象方向跟实际需求对不上白白重构了一次。现在我给自己立了一条规矩至少在代码里已经明确出现两个产品族的需求时才动手引入抽象工厂。第一个产品族直接用最简单的方式实现第二个需求出现时再重构代价通常完全可控。这个两次以上再抽象的节奏能帮你规避掉 80% 的过度设计。抽象工厂模式讲到底不复杂它就是在告诉你当一批对象必须成团出现时给它们一个统一的团长。剩下的都是组织代码的手艺罢了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →