组合模式实战:从文件系统到Android ViewGroup的树形结构设计
组合模式在GoF那本《设计模式》里的分类属于结构型模式。我第一次读它的时候觉得名字起得玄乎认真翻了几遍才反应过来——这不就是在处理树形结构吗。说白了组合模式就是为了让叶子节点和容器节点能用同一种方式去调用调用方不需要关心我手里拿的到底是一个文件还是一个文件夹。这篇文章我会用Java把组合模式从定义到实战完整走一遍再结合Android源码和几个我实际踩过的坑来聊。不管你是准备《设计模式》期末复习、正在憋一个大作业还是想在真实项目里把这个模式用起来都可以直接参考里面的代码和思路。1. 从文件夹与文件理解组合模式的本质1.1 为什么需要组合模式先看一个特别常见的场景文件系统。一个文件夹里面可以放文件也可以继续放文件夹而子文件夹里又可以放文件、再放文件夹。这种结构往深了能套很多层呈现出来的就是一棵树。假设没有组合模式你要实现一个输出目录下所有内容的功能可能会写出这样的代码public class Folder { private String name; private ListFile files new ArrayList(); private ListFolder folders new ArrayList(); } public class File { private String name; }然后写遍历的时候就得分开处理public static void show(Folder folder) { for (File file : folder.getFiles()) { System.out.println(file.getName()); } for (Folder sub : folder.getFolders()) { show(sub); } }这样写本身没什么问题但是你有没有发现客户端在使用Folder和File的时候必须明确区分它们否则代码根本没法写。再往后如果我想增加一个快捷方式节点它可能既像文件又像文件夹这时候调用方就疯了每一处遍历逻辑都要跟着改。组合模式解决的就是这个问题。它把单个对象和组合对象抽象成同一个接口客户端以一致的方式对待它们。上面那个例子如果我们把File和Folder都实现同一个Component接口遍历的逻辑就统一了递归也变得更加自然。我刚开始学的时候总觉得这个模式有点过于简单不就是递归加多态吗但实际用过之后才发现让部分和整体一致对待这个思想远比表面看起来重要。它能让代码从针对具体类型编程变成针对抽象接口编程扩展性是质的变化。1.2 组合模式的定义与适用场景组合模式的定义教科书上一般这么写将对象组合成树形结构以表示部分-整体的层次结构使得客户端对单个对象和组合对象的使用具有一致性。这里有几个关键词树形结构数据天然是树状的时候用组合模式最顺手。部分-整体叶子是部分容器是整体但对外不区分。一致性客户端操作叶子时不需要知道它是叶子操作容器时也不需要特殊处理。那到底什么时候该用根据我的经验下面几个信号特别明显你发现代码里出现了多层嵌套的集合对象比如ListList...。你希望通过一次调用就能递归处理整棵对象树。你需要对叶子对象和容器对象提供统一的操作入口。你希望新增一种节点类型时不影响客户端调用逻辑。反过来说如果对象层级非常固定只有一层而且以后大概率不会变那组合模式就是过度设计。硬套上去反而增加理解成本。设计模式最忌讳的就是为了用而用。1.3 组合模式在日常开发中的真实形态很多人觉得组合模式只存在于教科书和面试题里实际上它无处不在。最典型的例子就是菜单。你在点餐软件里看到的经典菜单一级分类下面是二级分类二级分类下面才是具体的菜品这就是一棵树。再比如电商后台的商品分类。大类、中类、小类类目可以无限嵌套最后挂SPU和SKU。如果把这些分类设计成组合模式那做类目导航、筛选、权限控制都会方便很多。还有组织架构系统。公司、部门、小组、员工天然就是树。用组合模式实现组织架构的权限继承、消息推送、人员统计都非常合适。我后面会写一个商品分类树的完整案例就是这个思路。XML和JSON的解析也是组合模式思想的体现。一个节点要么是叶子节点要么是含有子节点的容器节点解析器对外提供统一的方法。很多开源的XML DOM解析器内部就是组合模式。所以理解组合模式不只是为了考试它有非常广泛的实际应用土壤。2. 三类角色与最小Java骨架2.1 Component、Leaf、Composite组合模式里有三个核心角色名字都挺直白Component抽象组件定义了叶子和容器的公共接口可以包含默认实现。Leaf叶子节点没有子节点实现Component接口。它是最小单位比如文件、菜品、员工。Composite容器节点可以包含叶子节点也可以包含其他容器节点比如文件夹、分类、部门。关键点在于Composite 和 Leaf 都要实现同一个 Component 接口。这样对于客户端来说它们就是同一种东西调用方式完全一致。接口里通常需要定义两类方法一类是业务方法比如getName()、show()另一类是管理子节点的方法比如add()、remove()。麻烦也出在这里叶子节点根本没有子节点那add()、remove()对叶子来说就是无意义的操作。这就引出了透明式和安全式两种设计我放在第3节详细讲。2.2 最小实现目录树的Java代码先写一个最精简的版本让没有接触过组合模式的人也能快速看懂结构。// 抽象组件 public abstract class FileSystemNode { protected String name; public FileSystemNode(String name) { this.name name; } public abstract void display(int depth); // 默认抛异常叶子节点不需要覆写 public void add(FileSystemNode child) { throw new UnsupportedOperationException(当前节点不支持添加子节点); } public void remove(FileSystemNode child) { throw new UnsupportedOperationException(当前节点不支持移除子节点); } }// 叶子节点文件 public class FileLeaf extends FileSystemNode { public FileLeaf(String name) { super(name); } Override public void display(int depth) { StringBuilder prefix new StringBuilder(); for (int i 0; i depth; i) { prefix.append(--); } System.out.println(prefix name); } }// 容器节点文件夹 public class FolderComposite extends FileSystemNode { private ListFileSystemNode children new ArrayList(); public FolderComposite(String name) { super(name); } Override public void display(int depth) { StringBuilder prefix new StringBuilder(); for (int i 0; i depth; i) { prefix.append(--); } System.out.println(prefix [ name ]); for (FileSystemNode child : children) { child.display(depth 1); } } Override public void add(FileSystemNode child) { children.add(child); } Override public void remove(FileSystemNode child) { children.remove(child); } }// 客户端调用 public class Client { public static void main(String[] args) { FolderComposite root new FolderComposite(根目录); FolderComposite documents new FolderComposite(文档); FileLeaf readme new FileLeaf(readme.md); FileLeaf designDoc new FileLeaf(设计说明.pdf); documents.add(readme); documents.add(designDoc); FolderComposite pictures new FolderComposite(图片); FileLeaf logo new FileLeaf(logo.png); pictures.add(logo); root.add(documents); root.add(pictures); root.display(0); } }这段代码跑起来输出结构是清晰的树形层级[根目录] --[文档] ----readme.md ----设计说明.pdf --[图片] ----logo.png你发现没有root.display(0)这一句话就把整棵树都打印出来了。调用方完全不需要知道root下面到底有多少层、每个节点是文件还是文件夹。这就是组合模式最直接的价值。2.3 为什么说递归是组合模式的灵魂组合模式本质上是一个递归多态的结合体。容器节点的display()方法会遍历所有子节点调用子节点的display()如果子节点也是容器它又会继续遍历自己的子节点一层层往下走。到了叶子节点没有子节点直接输出完事递归终止。这种写法的妙处在于节点类型的变化被接口屏蔽了。以后新增一种节点比如快捷方式节点只要它实现了FileSystemNode整个树不用大改调用的地方也不用改只需要在合适的位置把它add进树里就行。不过要特别提醒一点递归方法一定要有终止条件。在组合模式里终止条件就是叶子节点。如果你的树结构不小心出现了环——比如节点 A 把自身的父节点 B add 进来了那递归就会死循环栈溢出 Directly。我在后面第6节会讲怎么排查这个问题。3. 从理论到落地透明式与安全式的取舍3.1 什么是透明式写法透明式写法就是把管理子节点的方法add、remove全都定义在抽象组件 Component 里面。这样叶子节点和容器节点对外暴露的方法完全一样客户端可以把所有节点都当作同一种类型来处理代码写起来非常统一。刚才第2节的例子就是透明式。透明式最大的好处是接口统一调用方不需要做任何类型判断。但坏处也很明显叶子对象本身没有子节点它的add()和remove()根本没法实现。要么直接抛异常要么静默失败。前者还算能接受后者就容易埋坑——你调了半天add()代码不报错但数据其实没加进去排查起来心态会崩。在面向对象设计里这其实违背了接口隔离原则。让一个叶子类去实现它根本用不上的方法本身就是一种设计妥协。3.2 什么是安全式写法安全式写法则相反把add()、remove()这类方法只定义在 Composite 容器类中。抽象组件只定义业务方法比如display()、getName()。这样叶子节点根本不会暴露出可以添加子节点这种误导性操作安全性更好。但代价是客户端如果想把一个 Component 当作容器来操作就得先做类型判断if (node instanceof FolderComposite) { ((FolderComposite) node).add(child); }这样写确实安全但调用方就得知道具体类型了又破坏了一致性。3.3 怎么选我的建议这个选择没有标准答案主要看使用场景。我自己在项目里的经验是如果对外的接口是给比较外围的代码用的不想让他们看到太多细节优先用透明式。接口统一调用简单代价是容器方法在叶子上是空操作或抛异常只要文档写清楚就行。如果这个组件要被很多团队复用而且调用方容易误用推荐用安全式。类型判断虽然啰嗦但能避免很多低级错误。还可以做一个折中抽象组件里定义默认的add()和remove()方法默认实现直接throw new UnsupportedOperationException()。这样接口是统一的但误调用时能快速暴露问题而不是静默失败。Java 的AbstractList就是这种思路子类可以不实现add()但一旦调用就会抛异常告诉你不支持。Java 集合框架里的addAll、removeAll这些默认方法也都体现了类似的理念。所以组合模式的选型和实现其实和语言本身的习惯强相关。4. 实战设计一个商品分类树4.1 需求背景我前阵子做了一个电商后台的类目管理系统核心需求是这样的类目支持无限层级最底层挂商品。运营人员需要按层级查看整个类目树。前端导航需要按树形结构渲染。统计每个分类及其子分类下的商品总数。当时第一个想法就是组合模式。这个场景简直是为它量身定做的类目天然是树需要递归操作而且以后大概率会加新的节点类型。4.2 代码落地我先定义一个通用的节点抽象public abstract class CategoryComponent { protected Long id; protected String name; public CategoryComponent(Long id, String name) { this.id id; this.name name; } public abstract void show(); public Long getId() { return id; } public String getName() { return name; } // 默认不支持添加子节点 public void add(CategoryComponent component) { throw new UnsupportedOperationException(当前节点不支持添加子分类); } public int countProducts() { return 0; } }叶子节点表示一个具体的商品public class ProductLeaf extends CategoryComponent { public ProductLeaf(Long id, String name) { super(id, name); } Override public void show() { System.out.println( ——商品 name); } Override public int countProducts() { return 1; } }容器节点表示一个商品分类public class CategoryComposite extends CategoryComponent { private ListCategoryComponent children new ArrayList(); public CategoryComposite(Long id, String name) { super(id, name); } Override public void add(CategoryComponent component) { children.add(component); } Override public void show() { System.out.println(分类 name); for (CategoryComponent child : children) { child.show(); } } Override public int countProducts() { int total 0; for (CategoryComponent child : children) { total child.countProducts(); } return total; } }客户端初始化一棵树public class CategoryClient { public static void main(String[] args) { CategoryComposite root new CategoryComposite(1L, 家用电器); CategoryComposite tvCategory new CategoryComposite(2L, 电视); CategoryComposite phoneCategory new CategoryComposite(3L, 手机); ProductLeaf tv1 new ProductLeaf(1001L, 55英寸智能电视); ProductLeaf tv2 new ProductLeaf(1002L, 65英寸激光电视); tvCategory.add(tv1); tvCategory.add(tv2); ProductLeaf phone1 new ProductLeaf(2001L, 旗舰手机); ProductLeaf phone2 new ProductLeaf(2002L, 千元机); phoneCategory.add(phone1); phoneCategory.add(phone2); root.add(tvCategory); root.add(phoneCategory); root.show(); System.out.println(商品总数 root.countProducts()); } }运行结果很简单分类家用电器 分类电视 ——商品55英寸智能电视 ——商品65英寸激光电视 分类手机 ——商品旗舰手机 ——商品千元机 商品总数4这个例子里countProducts()的递归统计特别实用。你想统计家用电器下所有商品数量不需要写一堆循环和判断直接把root.countProducts()的结果拿出来就行。组合模式的价值就在这里复杂的树操作被封装在了对象内部。4.3 踩坑记录这个项目里我踩了几个坑跟你们分享一下。第一个坑是 JSON 序列化的循环引用。类目树里父子节点互相持有引用用 Fastjson 或 Jackson 序列化的时候如果不处理直接栈溢出。我当时把节点转换成了 DTO只保留 id、name、children 三个字段才把问题绕过去。以后你们做类似功能最好直接从数据库查出平铺数据再在内存里组装成树而不是让 ORM 层直接映射成嵌套对象。第二个坑是删除节点时没有处理子节点。我在remove()方法里只写了children.remove(child)结果删父分类的时候子分类还挂在内存里没人管导致统计数量对不上。后来改成递归删除子节点或者删除前先校验才解决。第三个坑是权限控制。树形结构配上组合模式之后权限继承非常方便但要注意一个节点可能被多个父节点引用的情况。如果出现多对多的关系树就退化成图了组合模式那套递归逻辑就不够用了。遇到这种需求建议先确认数据模型到底是树还是图。5. 组合模式在Android源码和Java生态中的应用5.1 Android 的 View 与 ViewGroup如果你看过《Android 源码设计模式解析与实战》这类书会发现组合模式在 Android 里最能打的案例就是 View 和 ViewGroup。View 是所有 UI 组件的基类相当于 Component。ViewGroup 继承自 View同时内部维护了一个子 View 列表相当于 Composite。Button、TextView 这些控件是叶子节点LinearLayout、FrameLayout、ConstraintLayout 这些布局是容器节点。最明显的一个证据是ViewGroup.addView(View child)和ViewGroup.removeView(View view)。你往 LinearLayout 里塞 Button 是调用addView你把一个 LinearLayout 塞进另一个 LinearLayout 也是调用addView。对调用方来说Button 和 LinearLayout 都是 View操作方式完全一致。这正是组合模式的一致性体现。再看事件分发那套流程dispatchTouchEvent从 ViewGroup 一路往子 View 递归调用Touch 事件要么被子 View 处理要么逐层返回。这套递归逻辑和组合模式的树形遍历如出一辙。所以理解了组合模式再去看 Android 的触摸事件分发会轻松很多。5.2 Java AWT 与 Swing 的 ContainerJava 老牌 GUI 框架里组合模式也是核心。Component是抽象基类Container继承自Component内部维护Component[] component。Button、Label是叶子Panel、Frame是容器。有意思的是AWT 里Container既能包含Button也能包含另一个Container。.add(Component comp)这个方法在容器上调用叶子节点没有。这和组合模式安全式的做法基本一致。其实你去看很多 GUI 框架不管是 Swing、JavaFX还是前端的 React/Vue 组件树本质都是组合模式。组件可以嵌套组件叶子组件和容器组件统一渲染。这个概念跨越了语言和平台可见组合模式的生命力。5.3 其他开源框架中的组合影子Java 集合框架本身也有组合模式的味道。Map是容器但Map.Entry是叶子HashMap里的TreeNode红黑树节点也是树状结构。MyBatis、Hibernate 这类 ORM 框架里的sql片段、动态 SQL 节点也可以看成树状结构。if、where、foreach可以嵌套每个标签对外统一执行apply()方法最终拼出完整的 SQL。这和组合模式的思路是相通的。还有规则引擎、工作流引擎复杂的规则表达式、审批流节点基本也是树。你如果看 ThreadPoolExecutor 的execute流程那不算组合模式但看 AQS 的等待队列也是链表结构和树不是一类。所以前面我说那里都有组合模式不是夸张只不过很多框架不会特别强调自己用了这个模式。你能认出它的身份说明你确实看懂了。6. 常见问题与排查技巧6.1 常见问题速查表我整理了一些实际开发中容易踩的问题做了一个速查表方便你排查。问题原因解决方案叶子节点调add()报UnsupportedOperationException透明式写法叶子没有子节点在客户端调用前判断节点类型或改用安全式display()出现栈溢出 StackOverflowError树结构成环递归没有终止条件在add()时禁止加入自身或祖先节点打印时额外做节点访问标记序列化 JSON 时出现循环引用父子节点互相持有引用转 DTO 时丢弃反向引用或用JsonIgnore注解删除容器后子节点还在内存remove()没有递归处理子节点删除前先判断是否有子节点递归移除统计数量翻倍同一节点被添加到多个父节点确认数据模型是树还是图树不允许共享子节点新增节点类型后客户端大量改动抽象接口设计得不完整把公共方法抽象到 Component把差异封装在具体节点里6.2 组合模式和策略模式别搞混热搜词里有策略模式这里我也顺带说一句因为总有人把这两个模式放一起复习结果越看越乱。组合模式是结构型模式关注的是对象之间的组织方式。它解决的是树形结构下让叶子节点和容器节点用一致的方式被调用。你看到树层级递归大概率是组合模式。策略模式是行为型模式关注的是算法的封装与替换。它把不同的算法封装成独立的策略类客户端可以在运行时切换。你看到多种实现方式可替换的算法避免大段 if-else那是策略模式。简单说组合模式帮助你组织数据结构策略模式帮助你组织行为算法。做期末复习的时候只要抓住这个核心区别就不容易混。6.3 期末复习和大作业怎么用如果是为了准备《设计模式》期末我建议把文章里的目录树代码亲手敲一遍再把show()改成count()自己体会一下递归的调用过程。笔试里经常出现的题型就是请画出组合模式的类图并给出关键代码你把 Component、Leaf、Composite 三个类写熟练基本能拿分。如果是做大作业可以把第4节的商品分类树扩展一下加上数据库存储、前端树形展示做一个完整的管理系统。这个题目既能体现设计模式的应用又贴近真实场景老师一般会比较喜欢。我自己当年期末复习组合模式时用的一个笨办法就是画树在纸上画一棵公司组织架构然后对着类图一步步捋。等你能把一棵树从输出到删除都实现一遍组合模式就真的跑不掉了。最后再分享一个小技巧判断一个设计模式用没用对最简单的方法是反过来问自己——如果以后新增一种节点类型现有代码需要改动几个地方改动越少说明抽象越到位。组合模式之所以经典就是因为它让新增节点这件事变得非常低成本。你用多了之后会发现很多看起来复杂的树形业务用组合模式组织代码真的会有一种树长好了功能自己就出来了的顺畅感。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →