目录
背景规则
OOP实现:
分析OOP代码的设计缺陷
Entity-Component-System(ECS)架构
ECS介绍
ECS架构分析
ECS架构改造
现在公司用户中心提出一个需求,需要根据用户的会员等级实行不同的程度的打折,会员等级越高打折力度越大。其具体规则如下:
对于熟悉Object-Oriented Programming的同学,一个比较简单的实现是通过类的继承关系(此处省略部分非核心代码):
public abstract class Member {
public abstract double discount(double sourcePrice);
}
public class BronzeMember extends Member {
@Override
public double discount(double sourcePrice) {
return sourcePrice * 9.9 / 10;
}
}
public class SilverMember extends Member {
@Override
public double discount(double sourcePrice) {
return sourcePrice * 8.8 / 10;
}
}
public class GoldMember extends Member {
@Override
public double discount(double sourcePrice) {
return sourcePrice * 6.6 / 10;
}
}
public class DiamondMember extends Member {
@Override
public double discount(double sourcePrice) {
return sourcePrice * 5 / 10;
}
}
然后是简单的单元测试:
public void discountTest() {
Member bronzeMember = new BronzeMember();
Member silverMember = new SilverMember();
Member goldMember = new GoldMember();
Member diamondMember = new DiamondMember();
double sourcePrice = 10D;
Assert.assertEquals (bronzeMember.discount(sourcePrice), 9.9, 0);
Assert.assertEquals (silverMember.discount(sourcePrice), 8.8, 0);
Assert.assertEquals (goldMember.discount(sourcePrice), 6.6, 0);
Assert.assertEquals (diamondMember.discount(sourcePrice), 5, 0);
}
上述代码比较简单,就不做过多的阐述了。
知识级和操作级对象混用
知识级对象定义了对操作级对象的合法配置,根据相应的规则进行不同的约束;
操作级对象定义了模型常变化的部分。
如上述的例子中,抽取知识级公共对象rule作为规则父类,具体规则:
构建模型的规则:抽离可变因素,减少模型的变动,尽量将变化集中在更少的地方。
对象继承导致代码强依赖父类逻辑,违反开闭原则Open-Closed Principle(OCP)
开闭原则(OCP)规定“对象应该对于扩展开放,对于修改封闭“,继承虽然可以通过子类扩展新的行为,但因为子类可能直接依赖父类的实现,导致一个变更可能会影响所有对象。在这个例子里,我们需要给我们的会员增加一个积分的属性,根据不同的等级制度,在不同等级的人在购买商品之后赠送额外的积分。
然后我们需要修改代码,包括:
Member中添加积分(point)属性;
public class BronzeMember extends Member {
@Override
public double discount(double sourcePrice) {
return sourcePrice * 9.9 / 10;
}
@Override
public int addPoint() {
return this.point + 10;
}
}
在一个复杂的软件中为什么会建议“尽量”不要违背OCP?最核心的原因就是一个现有逻辑的变更可能会影响一些原有的代码,导致一些无法预见的影响。这个风险只能通过完整的单元测试覆盖来保障,但在实际开发中很难保障单测的覆盖率。OCP的原则能尽可能的规避这种风险,当新的行为只能通过新的字段/方法来实现时,老代码的行为自然不会变。
继承虽然能Open for extension,但很难做到Closed for modification。所以今天解决OCP的主要方法是通过Composition-over-inheritance,即通过组合来做到扩展性,而不是通过继承。
多对象行为类似,导致代码重复
当我们有不同的对象,但又有相同或类似的行为时,OOP会不可避免的导致代码的重复。就像上面例子中,我们每增加一个统一的行为,都需要在所有继承了Member类的实现类中重写这个方法,导致每个类都需要去改动。
问题总结
在这个案例里虽然从直觉来看OOP的逻辑很简单,但如果你的业务比较复杂,未来会有大量的业务规则变更时,简单的OOP代码会在后期变成复杂的一团浆糊,逻辑分散在各地,缺少全局视角,各种规则的叠加会触发bug。有没有感觉似曾相识?对的,电商体系里的优惠、交易等链路经常会碰到类似的坑。而这类问题的核心本质在于:
ECS架构模式是其实是一个很老的游戏架构设计,最早应该能追溯到《地牢围攻》的组件化设计,但最近因为Unity的加入而开始变得流行(比如《守望先锋》就是用的ECS)。要很快的理解ECS架构的价值,我们需要理解一个游戏代码的核心问题:
而ECS架构能很好的解决上面的几个问题,ECS架构主要分为:
ECS的一些核心性能优化包括将同类型组件放在同一个Array中,然后Entity仅保留到各自组件的pointer,这样能更好的利用CPU的缓存,减少数据的加载成本,以及SIMD的优化等。
组件化
在软件系统里,我们通常将复杂的大系统拆分为独立的组件,来降低复杂度。比如网页里通过前端组件化降低重复开发成本,微服务架构通过服务和数据库的拆分降低服务复杂度和系统影响面等。但是ECS架构把这个走到了极致,即每个对象内部都实现了组件化。通过将一个游戏对象的数据和行为拆分为多个组件和组件系统,能实现组件的高度复用性,降低重复开发成本。
行为抽离
这个在游戏系统里有个比较明显的优势。如果按照OOP的方式,一个游戏对象里可能会包括移动代码、战斗代码、渲染代码、AI代码等,如果都放在一个类里会很长,且很难去维护。通过将通用逻辑抽离出来为单独的System类,可以明显提升代码的可读性。另一个好处则是抽离了一些和对象代码无关的依赖,比如上文的delta,这个delta如果是放在Entity的update方法,则需要作为入参注入,而放在System里则可以统一管理。在第一章的有个问题,到底是应该Player.attack(monster) 还是 Monster.receiveDamage(Weapon, Player)。在ECS里这个问题就变的很简单,放在CombatSystem里就可以了。
数据驱动
即一个对象的行为不是写死的而是通过其参数决定,通过参数的动态修改,就可以快速改变一个对象的具体行为。在ECS的游戏架构里,通过给Entity注册相应的Component,以及改变Component的具体参数的组合,就可以改变一个对象的行为和玩法,比如创建一个水壶+爆炸属性就变成了“爆炸水壶”、给一个自行车加上风魔法就变成了飞车等。在有些Rougelike游戏中,可能有超过1万件不同类型、不同功能的物品,如果这些不同功能的物品都去单独写代码,可能永远都写不完,但是通过数据驱动+组件化架构,所有物品的配置最终就是一张表,修改也极其简单。这个也是组合胜于继承原则的一次体现。
定义规则:
public class Rule {
private String name;
}
public class DiscountRule extends Rule {
private double discount;
public double getDiscount() {
return discount;
}
}
定义entity:
public class Member {
private Map components = new HashMap<>(16); //存储实体规则对应的具体处理器
private MemberId memberId;
private class MemberId {
}
public Map getComponents() {
return components;
}
}
处理器:
public interface Handler {
double discountHandle(double sourcePrice, Rule rule);
}
//折扣处理器
public class DiscountHandler implements Handler {
@Override
public double discountHandle(double sourcePrice, Rule rule) {
if (rule instanceof DiscountRule) {
return sourcePrice * ((DiscountRule) rule).getDiscount();
}
return sourcePrice;
}
}
装配规则器:
public class Assember {
public List matchHandler(Rule rule, Map handlerMap) {
List handlers = new ArrayList<>();
rule.getRuleKeys().forEach(e -> {
if (handlerMap.get(e) != null) {
handlers.add(handlerMap.get(e));
}
});
return handlers;
}
}
打折系统:
public class DiscountSystem {
List handlers;
public double discount(double sourcePrice, Rule rule) {
double targetPrice = sourcePrice;
for (Handler h : handlers) {
targetPrice = h.discountHandle(targetPrice, rule);
}
return targetPrice;
}
}
采用这种方式的架构,我们需要增加积分赠送的需求,现在就只需要增加积分规则和增加积分处理的类就行了。无需改动原来的类,将模型可变都集中在一起,最小化模型的可变部分。