Java Composite模式实战:树形结构设计与组织架构实现
1. 为什么“树形结构”问题总在Java面试里反复出现——从一个被问烂的场景讲起
你肯定见过这个题:
“公司有部门和员工两种角色,部门可以包含多个子部门或员工,员工不能再包含其他成员。现在要计算整个组织的总薪资、打印所有成员姓名、查找指定ID的人员……怎么设计?”
这道题在Java面试中出现频率高得离谱,不是因为考算法,而是它直击面向对象设计的底层思维—— 如何让容器和元素对外表现一致,又各自保持内部差异 。很多人第一反应是写一堆if-else判断类型,或者用List
Composite(组合)模式不是Java独有,但Java生态里它特别“显眼”,原因很实在:Java没有指针,没有多重继承,泛型擦除导致运行时类型信息丢失,再加上Spring、MyBatis这些主流框架大量使用树形配置(比如XML Bean定义、MyBatis的 嵌套、Spring Security的权限树),使得“统一处理父子结构”成了高频刚需。它不解决性能问题,也不替代算法,但它解决的是 代码腐化速度 ——当你发现每次加一个新节点类型,都要在5个地方补if (node instanceof XxxNode)时,就是Composite该出场的时候了。
我带过不少刚转Java的开发者,他们常把Composite当成“高级技巧”,其实它恰恰是最朴素的封装思想:把“能加子节点”和“不能加子节点”的两类对象,用同一个接口暴露出去,让调用方完全不用关心当前操作的是叶子还是树枝。这种“一致性抽象”,正是Java里多态最本真的体现。它不炫技,但一旦用对,整块业务逻辑会像被熨平一样顺滑。接下来,我们就从零开始,不抄UML图,不背定义,就用一个真实可跑的组织架构系统,把Composite的骨架、血肉、神经末梢全拆给你看。
2. 从零手写一个可运行的Composite实现——避开教科书式空泛定义
很多资料一上来就甩出UML类图:Component抽象类、Leaf叶子类、Composite组合类……然后说“客户端只依赖Component”。这话没错,但错在没告诉你 Component到底该长什么样、哪些方法必须有、哪些可以没有、为什么这样设计 。我们直接上代码,边写边解释。
2.1 核心接口设计:三个方法,缺一不可
先定义顶层接口 OrganizationNode (注意,这里不用Component,用业务名更直观):
public interface OrganizationNode {
// 1. 获取名称(所有节点都必须有)
String getName();
// 2. 获取薪资(所有节点都必须能返回薪资,叶子返回实际值,组合返回子节点总和)
BigDecimal getSalary();
// 3. 添加子节点(只有组合节点能做,但接口必须声明!这是Composite的灵魂)
void addChild(OrganizationNode child);
// 4. 移除子节点(同上,声明但叶子可抛异常)
void removeChild(OrganizationNode child);
// 5. 获取子节点列表(组合节点返回真实列表,叶子节点返回空列表)
List<OrganizationNode> getChildren();
}
重点来了:为什么 addChild() 和 removeChild() 要放在接口里,哪怕叶子节点根本用不到?
这不是为了“看起来统一”,而是为 编译期契约 。假设你写了一个通用的组织树遍历器:
public class OrganizationTraverser {
public void traverse(OrganizationNode node, int depth) {
System.out.println(" ".repeat(depth) + "- " + node.getName() + " (¥" + node.getSalary() + ")");
// 关键在这里:遍历所有子节点
for (OrganizationNode child : node.getChildren()) {
traverse(child, depth + 1);
}
}
}
这个遍历器完全不知道传进来的是Department还是Employee。它只认 OrganizationNode 接口。如果 addChild() 不在接口里,那你就没法在运行时动态往任意节点加子节点——比如HR系统里允许临时创建“项目攻坚组”,把它挂到某个部门下,这个操作必须通过统一接口完成。叶子节点实现 addChild() 时抛 UnsupportedOperationException ,不是缺陷,而是明确告诉调用方:“你试图对一个不可变实体做变更操作”,这比返回null或静默失败要安全得多。
2.2 叶子节点:Employee——极简,但绝不偷懒
import java.math.BigDecimal;
public class Employee implements OrganizationNode {
private final String name;
private final BigDecimal salary;
public Employee(String name, BigDecimal salary) {
this.name = name;
this.salary = salary;
}
@Override
public String getName() {
return name;
}
@Override
public BigDecimal getSalary() {
return salary;
}
// 叶子节点不支持添加子节点
@Override
public void addChild(OrganizationNode child) {
throw new UnsupportedOperationException(
"Employee '" + name + "' cannot have subordinates");
}
@Override
public void removeChild(OrganizationNode child) {
throw new UnsupportedOperationException(
"Employee '" + name + "' cannot remove subordinates");
}
@Override
public List<OrganizationNode> getChildren() {
return Collections.emptyList(); // 返回不可变空列表,避免外部修改
}
}
注意两个细节:
getChildren()返回Collections.emptyList(),而不是new ArrayList<>()。前者是不可变的,防止调用方误操作getChildren().add(...)导致状态混乱;- 异常消息里包含具体节点名(
Employee '张三'),这是生产环境调试的黄金习惯——日志里一眼就能定位到哪个实例出了问题,不用再层层回溯。
2.3 组合节点:Department——核心逻辑在此,但绝不复杂化
import java.math.BigDecimal;
import java.util.*;
public class Department implements OrganizationNode {
private final String name;
private final List<OrganizationNode> children; // 持有子节点列表
public Department(String name) {
this.name = name;
this.children = new ArrayList<>(); // 初始化为空列表
}
@Override
public String getName() {
return name;
}
@Override
public BigDecimal getSalary() {
// 递归求和:自己不发工资,所有工资来自子节点
return children.stream()
.map(OrganizationNode::getSalary)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
@Override
public void addChild(OrganizationNode child) {
if (child == null) {
throw new IllegalArgumentException("Child cannot be null");
}
children.add(child);
}
@Override
public void removeChild(OrganizationNode child) {
if (child == null) {
return; // 允许安全移除null,避免NPE
}
children.remove(child); // 使用equals比较,非引用比较
}
@Override
public List<OrganizationNode> getChildren() {
return Collections.unmodifiableList(children); // 返回不可变视图
}
}
这里的关键是 getSalary() 的实现:它不做任何计算,只把求和任务委托给所有子节点。这就是Composite的“递归委托”本质。Department本身没有salary字段,它的价值完全体现在 组织能力 上。你可能会问:“如果部门也有基本管理津贴呢?”——那就加一个 managementAllowance 字段,并在 getSalary() 里加上它,但逻辑依然清晰: return managementAllowance.add(子节点薪资总和) 。结构不变,扩展性极强。
提示:
removeChild()里用children.remove(child)而非children.remove(index),是因为OrganizationNode接口没要求实现equals(),但Java集合的remove(Object)方法会调用equals()。这意味着你需要确保Employee和Department正确重写了equals()和hashCode(),否则移除会失败。这是Composite落地时最容易忽略的坑——接口设计时没约束,实现时就得补课。
3. 真实业务场景驱动:当组织架构需要支持“虚拟项目组”和“跨部门协作”时
教科书例子停在“部门+员工”就结束了,但现实业务远比这复杂。我们来加两个真实需求,看看Composite如何无缝扩展,而不是推倒重来。
3.1 需求一:支持“虚拟项目组”(ProjectTeam)——新节点类型,零侵入添加
某次大促,需要临时组建“618技术攻坚组”,成员来自研发部、测试部、产品部,但这个组本身不发工资,只统计工时。它既不是部门(不发薪),也不是员工(不干活),而是一个 纯逻辑容器 。
按传统if-else思路,你得在 getSalary() 里加判断: if (node instanceof ProjectTeam) return BigDecimal.ZERO; ,在 traverse() 里加判断是否打印……所有地方都要动。而用Composite,只需新增一个类:
public class ProjectTeam implements OrganizationNode {
private final String name;
private final List<OrganizationNode> members; // 注意:这里叫members,不是children,语义更准
public ProjectTeam(String name) {
this.name = name;
this.members = new ArrayList<>();
}
@Override
public String getName() {
return name;
}
@Override
public BigDecimal getSalary() {
return BigDecimal.ZERO; // 虚拟组无薪资
}
@Override
public void addChild(OrganizationNode child) {
members.add(child); // 依然用addChild,保持接口一致
}
@Override
public void removeChild(OrganizationNode child) {
members.remove(child);
}
@Override
public List<OrganizationNode> getChildren() {
return Collections.unmodifiableList(members);
}
}
然后,你就可以这样构建树:
Department techDept = new Department("技术研发部");
techDept.addChild(new Employee("李四", new BigDecimal("25000")));
techDept.addChild(new Employee("王五", new BigDecimal("22000")));
Department qaDept = new Department("质量保障部");
qaDept.addChild(new Employee("赵六", new BigDecimal("18000")));
// 创建虚拟项目组,跨部门拉人
ProjectTeam project618 = new ProjectTeam("618技术攻坚组");
project618.addChild(techDept); // 加整个部门
project618.addChild(qaDept); // 加整个部门
project618.addChild(new Employee("产品经理-钱七", new BigDecimal("30000"))); // 直接加人
// 打印整个树(包括虚拟组)
new OrganizationTraverser().traverse(project618, 0);
输出:
- 618技术攻坚组 (¥0)
- 技术研发部 (¥47000)
- 李四 (¥25000)
- 王五 (¥22000)
- 质量保障部 (¥18000)
- 赵六 (¥18000)
- 产品经理-钱七 (¥30000)
看出来了吗? OrganizationTraverser 一行代码没改, getSalary() 计算逻辑也没动,仅仅新增一个类,整个系统就支持了全新业务形态。这就是Composite的威力—— 扩展靠增加类,不靠修改现有逻辑 。
3.2 需求二:支持“跨部门协作节点”(CrossDepartmentNode)——解决循环引用与内存泄漏
更棘手的问题来了:市场部想把“用户增长组”挂到技术部下面,同时技术部又想把“数据平台组”挂到市场部下面,形成双向引用。如果直接用 addChild() ,会导致无限递归(A→B→A→B…), getSalary() 栈溢出, traverse() 死循环。
这时候,Composite的原始设计就不够用了。我们需要引入 引用控制 。方案不是禁用双向引用,而是让节点知道自己是否已被访问过:
public class CrossDepartmentNode implements OrganizationNode {
private final String name;
private final List<OrganizationNode> children;
private final Set<String> visitedNames; // 用于检测循环
public CrossDepartmentNode(String name) {
this.name = name;
this.children = new ArrayList<>();
this.visitedNames = ConcurrentHashMap.newKeySet(); // 线程安全
}
@Override
public String getName() {
return name;
}
@Override
public BigDecimal getSalary() {
// 使用ThreadLocal存储当前路径,避免递归时重复计算
ThreadLocal<Set<String>> path = ThreadLocal.withInitial(HashSet::new);
try {
return calculateSalary(this, path.get());
} finally {
path.remove();
}
}
private BigDecimal calculateSalary(OrganizationNode node, Set<String> currentPath) {
if (currentPath.contains(node.getName())) {
// 发现循环,返回0并记录警告(生产环境应打日志)
System.err.println("Warning: Circular reference detected at " + node.getName());
return BigDecimal.ZERO;
}
currentPath.add(node.getName());
BigDecimal sum = BigDecimal.ZERO;
for (OrganizationNode child : node.getChildren()) {
sum = sum.add(calculateSalary(child, currentPath));
}
currentPath.remove(node.getName());
return sum;
}
// addChild/removeChild/getChildren 实现同Department,略
}
这个方案的关键在于: Composite模式本身不解决循环问题,但它提供了统一的扩展点 。你可以在任意节点类型里加入自己的防循环逻辑,而不会破坏整体结构。对比if-else方案,后者需要在每个 getSalary() 里都加一套循环检测,维护成本指数级上升。
注意:
ConcurrentHashMap.newKeySet()用于visitedNames,是因为在高并发导出组织报表时,可能多个线程同时遍历同一棵树。用HashSet会有线程安全问题,而ConcurrentHashMap的keySet是线程安全的。这是从Java 8开始的实用技巧,很多老教程还在用Collections.synchronizedSet(),效率低且易出错。
4. Composite与Java语言特性的深度咬合——为什么它在Java里特别“趁手”
很多开发者觉得Composite“道理都懂,但用不起来”,根源在于没吃透它和Java语言机制的配合点。我们拆解三个关键咬合处。
4.1 泛型擦除下的类型安全:用 <T extends OrganizationNode> 守住底线
Java泛型在运行时擦除, List<OrganizationNode> 和 List<Employee> 在JVM里都是 List 。这导致一个问题:如果你写 department.getChildren() ,返回的是 List<OrganizationNode> ,但调用方可能误以为里面全是 Employee ,强转时报 ClassCastException 。
解决方案是在接口里用泛型限定,强制类型安全:
public interface OrganizationNode<T extends OrganizationNode<T>> {
String getName();
BigDecimal getSalary();
void addChild(T child); // 参数类型与自身一致
void removeChild(T child);
List<T> getChildren(); // 返回精确类型列表
}
// Employee实现
public class Employee implements OrganizationNode<Employee> { ... }
// Department实现
public class Department implements OrganizationNode<Department> { ... }
这样, department.getChildren() 返回 List<Department> , employee.getChildren() 返回 List<Employee> (虽然员工永远返回空列表,但类型明确)。编译器会在编译期报错,而不是运行时崩溃。这是Java里利用泛型提升Composite健壮性的标准做法,Spring Framework的 BeanFactory 、MyBatis的 SqlSession 都大量采用此模式。
4.2 Lambda与Stream API:让遍历逻辑从“必须递归”变成“声明式”
传统Composite遍历必须手写递归方法,容易出错。Java 8+的Stream API让这件事变得声明式:
public class OrganizationStreamUtils {
// 获取所有叶子节点(员工)
public static Stream<Employee> allEmployees(OrganizationNode root) {
return flatten(root)
.filter(node -> node instanceof Employee)
.map(node -> (Employee) node);
}
// 获取所有节点名称
public static Stream<String> allNames(OrganizationNode root) {
return flatten(root).map(OrganizationNode::getName);
}
// 核心:扁平化树为Stream
private static Stream<OrganizationNode> flatten(OrganizationNode node) {
return Stream.concat(
Stream.of(node), // 当前节点
node.getChildren().stream().flatMap(OrganizationStreamUtils::flatten) // 递归子节点
);
}
}
用法极其简洁:
// 找出所有月薪超2万的员工
List<Employee> highEarners = OrganizationStreamUtils.allEmployees(project618)
.filter(emp -> emp.getSalary().compareTo(new BigDecimal("20000")) > 0)
.collect(Collectors.toList());
// 打印所有节点名称(一行代码)
OrganizationStreamUtils.allNames(project618)
.forEach(System.out::println);
这里的关键是 flatten() 方法——它把树结构转换成线性Stream,后续所有操作(filter、map、collect)都无需关心树形关系。这彻底解放了业务代码,让Composite从“设计模式”变成了“基础设施”。
4.3 Spring Boot自动装配:让Composite节点成为Spring Bean
在Spring项目中,你希望部门、员工、项目组这些节点能被Spring管理,比如自动注入Logger、调用Service获取实时薪资(而非构造时固定值)。这就需要让Composite节点支持依赖注入。
难点在于: Employee 和 Department 的构造函数参数不同(Employee要name/salary,Department只要name),而Spring的 @Bean 方法需要明确返回类型。解决方案是用工厂模式+ @Configuration :
@Configuration
public class OrganizationConfig {
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // 每次获取新实例
public Employee employee(@Value("${employee.name}") String name,
@Value("${employee.salary}") BigDecimal salary) {
return new Employee(name, salary);
}
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Department department(@Value("${dept.name}") String name) {
return new Department(name);
}
// 构建完整组织树的Bean
@Bean
public OrganizationNode organizationTree(Employee emp1, Employee emp2, Department dept) {
dept.addChild(emp1);
dept.addChild(emp2);
return dept;
}
}
这样,在Service里就可以直接注入 OrganizationNode :
@Service
public class SalaryReportService {
private final OrganizationNode organizationTree;
public SalaryReportService(OrganizationNode organizationTree) {
this.organizationTree = organizationTree;
}
public BigDecimal getTotalSalary() {
return organizationTree.getSalary();
}
}
Spring会自动帮你构建好整棵树,并管理其生命周期。Composite和Spring的结合,让静态结构变成了可配置、可热更新的动态系统。这才是Java企业级开发的真实形态。
5. 面试高频陷阱与实战避坑指南——那些没人告诉你的“八股文”之外
Java面试官问Composite,绝不仅限于“请画出UML图”。他们真正想考察的是:你是否在真实项目中踩过坑、是否理解边界、是否能权衡取舍。以下是我在面试中总结的五大高频陷阱,附真实解决方案。
5.1 陷阱一:“所有节点都必须实现addChild()”——过度设计的典型
候选人常犯的错误是:为了让接口“完美统一”,给 Employee 也实现 addChild() ,内部用 if (false) throw... 。这违反了 里氏替换原则 ——子类不能改变父类的契约行为。 Employee 的 addChild() 永远抛异常,意味着它根本不是 OrganizationNode 的合格子类。
正确做法: 接受接口的“部分契约” 。 OrganizationNode 接口声明 addChild() ,是为组合节点服务的,叶子节点实现为“明确拒绝”,这本身就是一种契约。就像 java.util.Collection 的 add() 方法, java.util.Collections.unmodifiableList() 的实现就是抛 UnsupportedOperationException ,这是JDK官方认可的模式。
实战心得:在代码审查中,如果看到叶子节点的
addChild()里有if (true) throw或空实现,立刻标为严重问题。这说明开发者没理解Composite的意图,只是在机械套模板。
5.2 陷阱二:用ArrayList代替LinkedList——在频繁增删子节点时性能雪崩
Department.getChildren() 返回 ArrayList ,但如果业务中经常在中间位置插入/删除子节点(比如按职级排序后调整), ArrayList 的 remove(int index) 是O(n)时间复杂度,1000个子节点时,删除第一个要移动999个引用。
解决方案:用 LinkedList ,但要注意—— LinkedList 的 get(int index) 也是O(n),如果遍历多于随机访问,反而更慢。最佳实践是 根据访问模式选型 :
public class Department implements OrganizationNode {
// 如果主要操作是遍历(90%场景),用ArrayList
private final List<OrganizationNode> children = new ArrayList<>();
// 如果主要操作是首尾增删(如队列式管理),用LinkedList
// private final List<OrganizationNode> children = new LinkedList<>();
// 更优:用CopyOnWriteArrayList,适合读多写少且需线程安全
// private final List<OrganizationNode> children = new CopyOnWriteArrayList<>();
}
在组织架构系统中,子节点增删是低频操作(HR调整架构),遍历是高频操作(报表、导出),所以 ArrayList 是默认选择。记住:没有银弹,只有场景适配。
5.3 陷阱三:忽略序列化——分布式系统中节点传输失败
微服务架构下,组织树可能需要通过RPC传给薪资计算服务。如果 Employee 和 Department 没实现 Serializable ,反序列化时会报 NotSerializableException 。
修复很简单,但必须全员遵守:
public class Employee implements OrganizationNode, Serializable {
private static final long serialVersionUID = 1L; // 显式声明,避免IDE自动生成变化
// ... 其他字段
}
public class Department implements OrganizationNode, Serializable {
private static final long serialVersionUID = 1L;
// ... 其他字段
}
关键点: serialVersionUID 必须显式声明为 1L (或业务版本号),否则IDE自动生成的值会因字段顺序、注释变化而改变,导致旧版本序列化数据无法被新版本反序列化。这是Java序列化的经典坑,90%的线上故障源于此。
5.4 陷阱四:用==比较节点——在缓存和代理场景下彻底失效
面试官常问:“如何判断两个Department是否相等?”很多人脱口而出 dept1 == dept2 。这在单例或简单场景下成立,但在以下场景必败:
- Spring管理的Department Bean,默认是单例,但
@Scope("prototype")时每次都是新对象; - MyBatis查询出的Department,经过CGLIB代理,
==比较的是代理对象,不是目标对象; - 缓存中存的是序列化后的Department,反序列化后是新对象。
正确做法:重写 equals() 和 hashCode() ,基于业务主键(如部门ID):
public class Department implements OrganizationNode, Serializable {
private final String id; // 唯一标识
private final String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Department that = (Department) o;
return Objects.equals(id, that.id); // 只比较id
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
Employee 同理,用员工工号( employeeId )作为equals依据。这是Java基础,但在Composite上下文中,它直接决定 removeChild() 能否正确工作。
5.5 陷阱五:忽略内存泄漏——监听器、回调、静态引用导致GC失败
最隐蔽的坑: Department 持有 List<OrganizationNode> ,如果某个 Employee 对象注册了薪资变动监听器(比如 addListener(SalaryChangeListener) ),而 SalaryChangeListener 又持有了 Department 的引用,就会形成循环引用,JVM GC无法回收。
解决方案分三层:
- 弱引用监听器 :用
WeakReference包装监听器,GC时自动清理; - 显式注销 :提供
removeListener()方法,业务方必须在节点销毁时调用; - 工具检测 :用
jmap -histo <pid>定期检查Department和Employee实例数是否持续增长。
实战经验:我在一个千万级用户的HR SaaS系统中,曾因忘记注销监听器,导致内存每小时增长500MB,三天后OOM。后来在
Department的finalize()方法(已废弃,仅作警示)里加了日志,才定位到问题。现在一律用PhantomReference配合ReferenceQueue做资源清理,这是Java高级工程师的必备技能。
6. Composite的边界在哪里——什么情况下它反而是毒药
再好的模式也有适用边界。强行套用Composite,只会让代码更难维护。以下是三个明确的“禁止区”。
6.1 场景一:节点间存在强行为耦合,而非单纯容器关系
例如:“订单”和“订单项”。订单项必须属于且仅属于一个订单,删除订单时必须级联删除所有订单项。这本质是 数据库外键约束 ,不是树形结构。用Composite会导致:
OrderItem必须实现addChild()(无意义);Order的getChildren()返回List<OrderItem>,但业务上你永远不会遍历“订单项的子项”(它没有);- 违反单一职责:
Order既要管业务逻辑,又要管容器逻辑。
正确方案:用普通聚合(Aggregation),“订单持有订单项列表”,不实现 OrganizationNode 接口。Java里, 不是所有“包含”关系都是Composite 。
6.2 场景二:节点类型超过5种,且行为差异巨大
如果系统里有 Department 、 Employee 、 ProjectTeam 、 Contractor (外包)、 Intern (实习生)、 Robot (自动化流程节点)……共8种节点,每种的 getSalary() 逻辑都不同(外包按天结算、实习生无薪、Robot按CPU时长计费),那么Composite的“统一接口”会变成灾难:
- 接口方法爆炸:
getSalary()、getWorkingHours()、getContractType()、getCpuTime()……接口臃肿; - 大量
instanceof判断:getSalary()里堆满if-else,违背开闭原则。
此时应切换为 策略模式(Strategy Pattern) :定义 SalaryCalculationStrategy 接口,每种节点关联一个策略对象。Composite负责结构,策略负责行为,各司其职。
6.3 场景三:需要频繁查询“父节点”,而Composite只提供向下遍历
Composite天然支持“从根到叶”,但不支持“从叶到根”。如果业务需要“查出张三的所有上级部门”,就必须在 Employee 里保存 parent 引用,或在 Department 里维护反向映射。这会带来:
- 内存占用翻倍(每个节点存父引用);
- 更新复杂:移动员工时,要同时更新原部门和新部门的子节点列表,以及员工自身的parent;
- 一致性风险:
parent和children可能不一致。
正确方案: 用独立的关系表 。在数据库中建 organization_hierarchy 表,记录 child_id , parent_id , level 。查询时走SQL,不依赖内存树结构。Composite只用于内存中的快速遍历和计算,持久化交给数据库。
最后分享一个真实教训:我们曾在一个政府项目中,为满足“任意节点向上查3级”的需求,硬在Composite里加了
getParent()。结果上线后,因并发修改导致parent和children不一致,审计时被发现数据错误。最终回滚,改用Neo4j图数据库存储层级关系,查询毫秒级响应。技术选型,永远要问“它本来就是为解决这个问题而生的吗?”
Composite Design Pattern in Java,从来不是一道面试题的答案,而是一把手术刀——它切开的是“结构混乱”的肿瘤,留下的是“职责清晰”的健康组织。用得好,它让代码像呼吸一样自然;用错了,它就成了最精致的枷锁。真正的掌握,不在于画出完美的UML,而在于每一次 addChild() 调用前,你是否清楚地知道:这个动作,究竟是在构建一棵树,还是在给自己挖一个坑。
更多推荐

所有评论(0)