代码坏味道系列之滥用OO

滥用OO

滥用面向对象,代码部分或完全地违背了面向对象编程原则。

switch惊悚现身

代码中有一个复杂的 switch 语句或 if 序列语句。

问题原因

面向对象程序的一个最明显特征就是:少用 switchcase 语句。从本质上说,switch 语句的问题在于重复(if 序列也同样如此)。你常会发现 switch 语句散布于不同地点。如果要为它添加一个新的 case 子句,就必须找到所有 switch 语句并修改它们。面向对象中的多态概念可为此带来优雅的解决办法。

大多数时候,一看到 switch 语句,就应该考虑以多态来替换它。

解决方案

  • 问题是多态该出现在哪?switch 语句常常根据类型码进行选择,你要的是“与该类型码相关的函数或类”,所以应该运用 提炼函数(Extract Method)switch 语句提炼到一个独立函数中,再以 搬移函数(Move Method) 将它搬移到需要多态性的那个类里。
  • 如果你的 switch 是基于类型码来识别分支,这时可以运用 以子类取代类型码(Replace Type Code with Subclass)以状态/策略模式取代类型码(Replace Type Code with State/Strategy)
  • 一旦完成这样的继承结构后,就可以运用 以多态取代条件表达式(Replace Conditional with Polymorphism) 了。
  • 如果条件分支并不多并且它们使用不同参数调用相同的函数,多态就没必要了。在这种情况下,你可以运用 以明确函数取代参数(Replace Parameter with Explicit Methods)
  • 如果你的选择条件之一是 null,可以运用 引入 Null 对象(Introduce Null Object)

收益

  • 提升代码组织性。

何时忽略

  • 如果一个 switch 操作只是执行简单的行为,就没有重构的必要了。
  • switch 常被工厂设计模式族(工厂方法模式(Factory Method)抽象工厂模式(Abstract Factory))所使用,这种情况下也没必要重构。

重构方法说明

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

以子类取代类型码(Replace Type Code with Subclass)

问题

你有一个不可变的类型码,它会影响类的行为。

解决

以子类取代这个类型码。

以状态/策略模式取代类型码(Replace Type Code with State/Strategy)

问题

你有一个类型码,它会影响类的行为,但你无法通过继承消除它。

解决

以状态对象取代类型码。

以多态取代条件表达式(Replace Conditional with Polymorphism)

问题

你手上有个条件表达式,它根据对象类型的不同而选择不同的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Bird {
//...
double getSpeed() {
switch (type) {
case EUROPEAN:
return getBaseSpeed();
case AFRICAN:
return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
case NORWEGIAN_BLUE:
return (isNailed) ? 0 : getBaseSpeed(voltage);
}
throw new RuntimeException("Should be unreachable");
}
}

解决

将这个条件表达式的每个分支放进一个子类内的覆写函数中,然后将原始函数声明为抽象函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
abstract class Bird {
//...
abstract double getSpeed();
}

class European extends Bird {
double getSpeed() {
return getBaseSpeed();
}
}
class African extends Bird {
double getSpeed() {
return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
}
}
class NorwegianBlue extends Bird {
double getSpeed() {
return (isNailed) ? 0 : getBaseSpeed(voltage);
}
}

// Somewhere in client code
speed = bird.getSpeed();

以明确函数取代参数(Replace Parameter with Explicit Methods)

问题

你有一个函数,其中完全取决于参数值而采取不同的行为。

1
2
3
4
5
6
7
8
9
10
11
void setValue(String name, int value) {
if (name.equals("height")) {
height = value;
return;
}
if (name.equals("width")) {
width = value;
return;
}
Assert.shouldNeverReachHere();
}

解决

针对该参数的每一个可能值,建立一个独立函数。

1
2
3
4
5
6
void setHeight(int arg) {
height = arg;
}
void setWidth(int arg) {
width = arg;
}

引入 Null 对象(Introduce Null Object)

问题

你需要再三检查某对象是否为 null。

1
2
3
4
5
6
if (customer == null) {
plan = BillingPlan.basic();
}
else {
plan = customer.getPlan();
}

解决

将 null 值替换为 null 对象。

1
2
3
4
5
6
7
8
9
10
11
12
class NullCustomer extends Customer {
Plan getPlan() {
return new NullPlan();
}
// Some other NULL functionality.
}

// Replace null values with Null-object.
customer = (order.customer != null) ? order.customer : new NullCustomer();

// Use Null-object as if it's normal subclass.
plan = customer.getPlan();

临时字段

临时字段(Temporary Field)的值只在特定环境下有意义,离开这个环境,它们就什么也不是了。

问题原因

有时你会看到这样的对象:其内某个实例变量仅为某种特定情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有变量。在变量未被使用的情况下猜测当初设置目的,会让你发疯。
通常,临时字段是在某一算法需要大量输入时而创建。因此,为了避免函数有过多参数,程序员决定在类中创建这些数据的临时字段。这些临时字段仅仅在算法中使用,其他时候却毫无用处。
这种代码不好理解。你期望查看对象字段的数据,但是出于某种原因,它们总是为空。

解决方案

  • 可以通过 提炼类(Extract Class) 将临时字段和操作它们的所有代码提炼到一个单独的类中。此外,你可以运用 以函数对象取代函数(Replace Method with Method Object) 来实现同样的目的。
  • 引入 Null 对象(Introduce Null Object) 在“变量不合法”的情况下创建一个 null 对象,从而避免写出条件表达式。

收益

  • 更好的代码清晰度和组织性。

重构方法说明

提炼类(Extract Class)

问题

某个类做了不止一件事。

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

以函数对象取代函数(Replace Method with Method Object)

问题

你有一个过长函数,它的局部变量交织在一起,以致于你无法应用提炼函数(Extract Method) 。

1
2
3
4
5
6
7
8
9
10
class Order {
//...
public double price() {
double primaryBasePrice;
double secondaryBasePrice;
double tertiaryBasePrice;
// long computation.
//...
}
}

解决

将函数移到一个独立的类中,使得局部变量成了这个类的字段。然后,你可以将函数分割成这个类中的多个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Order {
//...
public double price() {
return new PriceCalculator(this).compute();
}
}

class PriceCalculator {
private double primaryBasePrice;
private double secondaryBasePrice;
private double tertiaryBasePrice;

public PriceCalculator(Order order) {
// copy relevant information from order object.
//...
}

public double compute() {
// long computation.
//...
}
}

引入 Null 对象(Introduce Null Object)

问题

你需要再三检查某对象是否为 null。

1
2
3
4
5
6
if (customer == null) {
plan = BillingPlan.basic();
}
else {
plan = customer.getPlan();
}

解决

将 null 值替换为 null 对象。

1
2
3
4
5
6
7
8
9
10
11
12
class NullCustomer extends Customer {
Plan getPlan() {
return new NullPlan();
}
// Some other NULL functionality.
}

// Replace null values with Null-object.
customer = (order.customer != null) ? order.customer : new NullCustomer();

// Use Null-object as if it's normal subclass.
plan = customer.getPlan();

被拒绝的遗赠

子类仅仅使用父类中的部分方法和属性。其他来自父类的馈赠成为了累赘。

问题原因

有些人仅仅是想重用超类中的部分代码而创建了子类。但实际上超类和子类完全不同。

解决方案

  • 如果继承没有意义并且子类和父类之间确实没有共同点,可以运用 以委托取代继承(Replace Inheritance with Delegation) 消除继承。
  • 如果继承是适当的,则去除子类中不需要的字段和方法。运用 提炼超类(Extract Superclass) 将所有超类中对于子类有用的字段和函数提取出来,置入一个新的超类中,然后让两个类都继承自它。

收益

  • 提高代码的清晰度和组织性。

重构方法说明

以委托取代继承(Replace Inheritance with Delegation)

问题

某个子类只使用超类接口中的一部分,或是根本不需要继承而来的数据。

解决

  1. 在子类中新建一个字段用以保存超类;
  2. 调整子类函数,令它改而委托超类;
  3. 然后去掉两者之间的继承关系。

提炼超类(Extract Superclass)

问题

两个类有相似特性。

解决

为这两个类建立一个超类,将相同特性移至超类。

异曲同工的类

两个类中有着不同的函数,却在做着同一件事。

问题原因

这种情况往往是因为:创建这个类的程序员并不知道已经有实现这个功能的类存在了。

解决方案

  • 如果两个函数做同一件事,却有着不同的签名,请运用 函数改名(Rename Method) 根据它们的用途重新命名。
  • 运用 搬移函数(Move Method)添加参数(Add Parameter)令函数携带参数(Parameterize Method) 来使得方法的名称和实现一致。
  • 如果两个类仅有部分功能是重复的,尝试运用 提炼超类(Extract Superclass) 。这种情况下,已存在的类就成了超类。
  • 当最终选择并运用某种方法来重构后,也许你就能删除其中一个类了。

收益

  • 消除了不必要的重复代码,为代码瘦身了。
  • 代码更易读(不再需要猜测为什么要有两个功能相同的类)。

何时忽略

  • 有时合并类是不可能的,或者是如此困难以至于没有意义。例如:两个功能相似的类存在于不同的 lib 库中。

重构方法说明

函数改名(Rename Method)

问题

函数的名称未能恰当的揭示函数的用途。

1
2
3
class Person {
public String getsnm();
}

解决

修改函数名。

1
2
3
class Person {
public String getSecondName();
}
搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

添加参数(Add Parameter)

问题
某个函数需要从调用端得到更多信息。

1
2
3
class Customer {
public Contact getContact();
}

解决
为此函数添加一个对象函数,让改对象带进函数所需信息。

1
2
3
class Customer {
public Contact getContact(Date date);
}

令函数携带参数(Parameterize Method)

问题

若干函数做了类似的工作,但在函数本体中却包含了不同的值。

解决

建立单一函数,以参数表达哪些不同的值。

提炼超类(Extract Superclass)

问题

两个类有相似特性。

解决

为这两个类建立一个超类,将相同特性移至超类。

扩展阅读

参考资料

代码坏味道系列之膨胀剂

膨胀剂

代码中的类、函数、字段没有经过合理的组织,只是简单的堆砌起来。这一类型的问题通常在代码的初期并不明显,但是随着代码规模的增长而逐渐积累(特别是当没有人努力去根除它们时)。

过长方法

  • 一个函数含有太多行代码。一般来说,任何函数超过 10 行时,你就可以考虑是不是过长了。
  • 函数中的代码行数原则上不要超过 100 行。

问题的原因

通常情况下,创建一个新函数的难度要大于添加功能到一个已存在的函数。大部分人都觉得:“我就添加这么两行代码,为此新建一个函数实在是小题大做了。
”于是,张三加两行,李四加两行,王五加两行。。。函数日益庞大,最终烂的像一锅浆糊,再也没人能完全看懂了。
于是大家就更不敢轻易动这个函数了,只能恶性循环的往其中添加代码。所以,如果你看到一个超过 200 行的函数,通常都是多个程序员东拼西凑出来的。

解决方案

一个很好的技巧是:寻找注释。添加注释,一般有这么几个原因:代码逻辑较为晦涩或复杂;这段代码功能相对独立;特殊处理。
如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。
如果函数有一个描述恰当的名字,就不需要去看内部代码究竟是如何实现的。就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数中。

  • 为了给一个函数瘦身,可以使用 提炼函数(Extract Method)
  • 如果局部变量和参数干扰提炼函数,可以使用 以查询取代临时变量(Replace Temp with Query)引入参数对象(Introduce Parameter Object)保持对象完整(Preserve Whole Object)
  • 如果前面两条没有帮助,可以通过 以函数对象取代函数(Replace Method with Method Object) 尝试移动整个函数到一个独立的对象中。
  • 条件表达式和循环常常也是提炼的信号。对于条件表达式,可以使用 分解条件表达式(Decompose Conditional) 。至于循环,应该使用 提炼函数(Extract Method) 将循环和其内的代码提炼到独立函数中。

收益

  • 在所有类型的面向对象代码中,函数比较短小精悍的类往往生命周期较长。一个函数越长,就越不容易理解和维护。
  • 此外,过长函数中往往含有难以发现的重复代码。

性能

是否像许多人说的那样,增加函数的数量会影响性能?在几乎绝大多数情况下,这种影响是可以忽略不计,所以不用担心。
此外,现在有了清晰和易读的代码,在需要的时候,你将更容易找到真正有效的函数来重组代码和提高性能。

重构方法说明

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

以查询取代临时变量(Replace Temp with Query)

问题

将表达式的结果放在局部变量中,然后在代码中使用。

1
2
3
4
5
6
7
8
9
double calculateTotal() {
double basePrice = quantity * itemPrice;
if (basePrice > 1000) {
return basePrice * 0.95;
}
else {
return basePrice * 0.98;
}
}

解决

将整个表达式移动到一个独立的函数中并返回结果。使用查询函数来替代使用变量。如果需要,可以在其他函数中合并新函数。

1
2
3
4
5
6
7
8
9
10
11
double calculateTotal() {
if (basePrice() > 1000) {
return basePrice() * 0.95;
}
else {
return basePrice() * 0.98;
}
}
double basePrice() {
return quantity * itemPrice;
}

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

解决

以一个对象来取代这些参数。

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

1
2
3
int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);

解决

改为传递整个对象。

1
boolean withinPlan = plan.withinRange(daysTempRange);

以函数对象取代函数(Replace Method with Method Object)

问题

你有一个过长函数,它的局部变量交织在一起,以致于你无法应用提炼函数(Extract Method) 。

1
2
3
4
5
6
7
8
9
10
class Order {
//...
public double price() {
double primaryBasePrice;
double secondaryBasePrice;
double tertiaryBasePrice;
// long computation.
//...
}
}

解决

将函数移到一个独立的类中,使得局部变量成了这个类的字段。然后,你可以将函数分割成这个类中的多个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Order {
//...
public double price() {
return new PriceCalculator(this).compute();
}
}

class PriceCalculator {
private double primaryBasePrice;
private double secondaryBasePrice;
private double tertiaryBasePrice;

public PriceCalculator(Order order) {
// copy relevant information from order object.
//...
}

public double compute() {
// long computation.
//...
}
}

分解条件表达式(Decompose Conditional)

问题

你有复杂的条件表达式。

1
2
3
4
5
6
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
charge = quantity * winterRate + winterServiceCharge;
}
else {
charge = quantity * summerRate;
}

解决

根据条件分支将整个条件表达式分解成几个函数。

1
2
3
4
5
6
if (notSummer(date)) {
charge = winterCharge(quantity);
}
else {
charge = summerCharge(quantity);
}
  • 程序愈长就愈难理解
  • 函数过长阅读起来也不方便
  • 小函数的价值:解释能力、共享能力、选择能力

Tips:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立的函数中。记着,起个好名字!

过大的类

一个类含有过多字段、函数、代码行。

问题原因

类通常一开始很小,但是随着程序的增长而逐渐膨胀。

类似于过长函数,程序员通常觉得在一个现存类中添加新特性比创建一个新的类要容易。

解决方案

设计模式中有一条重要原则:职责单一原则。一个类应该只赋予它一个职责。如果它所承担的职责太多,就该考虑为它减减负。

  • 如果过大类中的部分行为可以提炼到一个独立的组件中,可以使用 提炼类(Extract Class)
  • 如果过大类中的部分行为可以用不同方式实现或使用于特殊场景,可以使用 提炼子类(Extract Subclass)
  • 如果有必要为客户端提供一组操作和行为,可以使用 提炼接口(Extract Interface)
  • 如果你的过大类是个 GUI 类,可能需要把数据和行为移到一个独立的领域对象去。你可能需要两边各保留一些重复数据,并保持两边同步。 复制被监视数据(Duplicate Observed Data) 可以告诉你怎么做。

收益

  • 重构过大的类可以使程序员不必记住一个类中大量的属性。
  • 在大多数情况下,分割过大的类可以避免代码和功能的重复。

重构方法说明

提炼类(Extract Class)

问题

某个类做了不止一件事。

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

提炼子类(Extract Subclass)

问题

一个类中有些特性仅用于特定场景。

解决

创建一个子类,并将用于特殊场景的特性置入其中。

提炼接口(Extract Interface)

问题

多个客户端使用一个类部分相同的函数。另一个场景是两个类中的部分函数相同。

解决

移动相同的部分函数到接口中。

复制被监视数据(Duplicate Observed Data)

问题

如果存储在类中的数据是负责 GUI 的。

解决

一个比较好的方法是将负责 GUI 的数据放入一个独立的类,以确保 GUI 数据与域类之间的连接和同步。

如果想利用单一类做太多事情,其内往往就会出现太多的成员变量。

  • 提取完成同一任务的相关变量到一个新的类。
  • 干太多事情的类,可以考虑把责任委托给其他类。

Tips:一个类如果拥有太多的代码,也是代码重复、混乱、死亡的绝佳滋生地点。

基本类型偏执

  • 使用基本类型而不是小对象来实现简单任务(例如货币、范围、电话号码字符串等)。
  • 使用常量编码信息(例如一个用于引用管理员权限的常量 USER_ADMIN_ROLE = 1 )。
  • 使用字符串常量作为字段名在数组中使用。

问题原因

类似其他大部分坏味道,基本类型偏执诞生于类初建的时候。一开始,可能只是不多的字段,随着表示的特性越来越多,基本数据类型字段也越来越多。

基本类型常常被用于表示模型的类型。你有一组数字或字符串用来表示某个实体。

还有一个场景:在模拟场景,大量的字符串常量被用于数组的索引。

解决方案

大多数编程语言都支持基本数据类型和结构类型(类、结构体等)。结构类型允许程序员将基本数据类型组织起来,以代表某一事物的模型。

基本数据类型可以看成是机构类型的积木块。当基本数据类型数量成规模后,将它们有组织地结合起来,可以更方便的管理这些数据。

  • 如果你有大量的基本数据类型字段,就有可能将其中部分存在逻辑联系的字段组织起来,形成一个类。更进一步的是,将与这些数据有关联的方法也一并移入类中。为了实现这个目标,可以尝试 以类取代类型码(Replace Type Code with Class)
  • 如果基本数据类型字段的值是用于方法的参数,可以使用 引入参数对象(Introduce Parameter Object)保持对象完整(Preserve Whole Object)
  • 如果想要替换的数据值是类型码,而它并不影响行为,则可以运用 以类取代类型码(Replace Type Code with Class) 将它替换掉。如果你有与类型码相关的条件表达式,可运用 以子类取代类型码(Replace Type Code with Subclass)以状态/策略模式取代类型码(Replace Type Code with State/Strategy) 加以处理。
  • 如果你发现自己正从数组中挑选数据,可运用 以对象取代数组(Replace Array with Object)

收益

  • 多亏了使用对象替代基本数据类型,使得代码变得更加灵活。
  • 代码变得更加易读和更加有组织。特殊数据可以集中进行操作,而不像之前那样分散。不用再猜测这些陌生的常量的意义以及它们为什么在数组中。
  • 更容易发现重复代码。

重构方法说明

以类取代类型码(Replace Type Code with Class)

问题

类之中有一个数值类型码,但它并不影响类的行为。

解决

以一个新的类替换该数值类型码。

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

解决

以一个对象来取代这些参数。

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

1
2
3
int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);

解决

改为传递整个对象。

1
boolean withinPlan = plan.withinRange(daysTempRange);

以子类取代类型码(Replace Type Code with Subclass)

问题

你有一个不可变的类型码,它会影响类的行为。

解决

以子类取代这个类型码。

以状态/策略模式取代类型码(Replace Type Code with State/Strategy)

问题

你有一个类型码,它会影响类的行为,但你无法通过继承消除它。

解决

以状态对象取代类型码。

以对象取代数组(Replace Array with Object)

问题

你有一个数组,其中的元素各自代表不同的东西。

1
2
3
String[] row = new String[3];
row[0] = "Liverpool";
row[1] = "15";

解决

以对象替换数组。对于数组中的每个元素,以一个字段来表示。

1
2
3
Performance row = new Performance();
row.setName("Liverpool");
row.setWins("15");

过长参数列

一个函数有超过 3、4 个入参。

问题原因

过长参数列可能是将多个算法并到一个函数中时发生的。函数中的入参可以用来控制最终选用哪个算法去执行。

过长参数列也可能是解耦类之间依赖关系时的副产品。例如,用于创建函数中所需的特定对象的代码已从函数移动到调用函数的代码处,但创建的对象是作为参数传递到函数中。因此,原始类不再知道对象之间的关系,并且依赖性也已经减少。但是如果创建的这些对象,每一个都将需要它自己的参数,这意味着过长参数列。

太长的参数列难以理解,太多参数会造成前后不一致、不易使用,而且一旦需要更多数据,就不得不修改它。

解决方案

  • 如果向已有的对象发出一条请求就可以取代一个参数,那么你应该使用 以函数取代参数(Replace Parameter with Methods) 。在这里,,“已有的对象”可能是函数所属类里的一个字段,也可能是另一个参数。
  • 你还可以运用 保持对象完整(Preserve Whole Object) 将来自同一对象的一堆数据收集起来,并以该对象替换它们。
  • 如果某些数据缺乏合理的对象归属,可使用 引入参数对象(Introduce Parameter Object) 为它们制造出一个“参数对象”。

收益

  • 更易读,更简短的代码。
  • 重构可能会暴露出之前未注意到的重复代码。

何时忽略

  • 这里有一个重要的例外:有时候你明显不想造成”被调用对象”与”较大对象”间的某种依赖关系。这时候将数据从对象中拆解出来单独作为参数,也很合情理。但是请注意其所引发的代价。如果参数列太长或变化太频繁,就需要重新考虑自己的依赖结构了。

重构方法说明

以函数取代参数(Replace Parameter with Methods)

问题

对象调用某个函数,并将所得结果作为参数,传递给另一个函数。而接受该参数的函数本身也能够调用前一个函数。

1
2
3
4
int basePrice = quantity * itemPrice;
double seasonDiscount = this.getSeasonalDiscount();
double fees = this.getFees();
double finalPrice = discountedPrice(basePrice, seasonDiscount, fees);

解决

让参数接受者去除该项参数,并直接调用前一个函数。

1
2
int basePrice = quantity * itemPrice;
double finalPrice = discountedPrice(basePrice);

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

1
2
3
int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);

解决

改为传递整个对象。

1
boolean withinPlan = plan.withinRange(daysTempRange);

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

解决

以一个对象来取代这些参数。

代码中有很多基本数据类型的数据。

Tips:如果看到一些基本类型数据,尝试定义一种新的数据类型,符合它当前所代表的对象类型。

数据泥团

有时,代码的不同部分包含相同的变量组(例如用于连接到数据库的参数)。这些绑在一起出现的数据应该拥有自己的对象。

问题原因

通常,数据泥团的出现时因为糟糕的编程结构或“复制-粘贴式编程”。

有一个判断是否是数据泥团的好办法:删掉众多数据中的一项。这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是个明确的信号:你应该为它们产生一个新的对象。

解决方案

  • 首先找出这些数据以字段形式出现的地方,运用 提炼类(Extract Class) 将它们提炼到一个独立对象中。
  • 如果数据泥团在函数的参数列中出现,运用 引入参数对象(Introduce Parameter Object) 将它们组织成一个类。
  • 如果数据泥团的部分数据出现在其他函数中,考虑运用 保持对象完整(Preserve Whole Object) 将整个数据对象传入到函数中。
  • 检视一下使用这些字段的代码,也许,将它们移入一个数据类是个不错的主意。

收益

  • 提高代码易读性和组织性。对于特殊数据的操作,可以集中进行处理,而不像以前那样分散。
  • 减少代码量。

何时忽略

  • 有时为了对象中的部分数据而将整个对象作为参数传递给函数,可能会产生让两个类之间不收欢迎的依赖关系,这中情况下可以不传递整个对象。

重构方法说明

提炼类(Extract Class)

问题

某个类做了不止一件事。

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

解决

以一个对象来取代这些参数。

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

1
2
3
int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);

解决

改为传递整个对象。

1
boolean withinPlan = plan.withinRange(daysTempRange);

扩展阅读

参考资料

Android自动化测试总结

测试金字塔

沿着金字塔逐级向上,从小型测试到大型测试,各类测试的保真度逐级提高,但维护和调试工作所需的执行时间和工作量也逐级增加。因此,您编写的单元测试应多于集成测试,集成测试应多于端到端测试。虽然各类测试的比例可能会因应用的用例不同而异,但我们通常建议各类测试所占比例如下:小型测试占70%,中型测试占20%,大型测试占10%

单元测试(小型测试)

用于验证应用的行为,一次验证一个类。

原则(F.I.R.S.T

Fast(快),单元测试要运行的足够快,单个测试方法一般要立即(一秒之内)给出结果。
Idependent(独立),测试方法之间不要有依赖(先执行某个测试方法,再执行另一个测试方法才能通过)。
Repeatable(重复),可以在本地或 CI 不同环境(机器上)上反复执行,不会出现不稳定的情况。
Self-Validating(自验证),单元测试必须包含足够多的断言进行自我验证。
Timely(及时),理想情况下应测试先行,至少保证单元测试应该和实现代码一起及时完成并提交。

除此之外,测试代码应该具备最好的可读性和最少的维护代价,绝大多数情况下写测试应该就像用领域特定语言描述一个事实,甚至不用经过仔细地思考

构建本地单元测试

当需要更快地运行测试而不需要与在真实设备上运行测试关联的保真度和置信度时,可以使用本地单元测试来验证应用的逻辑。

  • 如果测试对Android框架有依赖性(特别是与框架建立复杂交互的测试),则最好使用 Robolectric添加框架依赖项。

例:待测试的类同时依赖ContextIntentBundleApplicationAndroid Framework中的类时,此时我们可以引入Robolectric框架进行本地单元测试的编写。

  • 如果测试对Android框架的依赖性极小,或者如果测试仅取决于我们自己应用的对象,则可以使用诸如Mockito之类的模拟框架添加模拟依赖项。(BasicUnitAndroidTest)

例:待测试的类只依赖java api(最理想的情况),此时对于待测试类所依赖的其他类我们就可以利用Mockito框架mock其依赖类,再进行当前类的单元测试编写。(EmailValidatorTest)

例:待测试的类除了依赖java api外仅依赖Android FrameworkContext这个类,此时我们就可以利用Mockito框架mock Context类,再进行当前类的单元测试编写。(SharedPreferencesHelperTest)

设置测试环境

Android Studio项目中,本地单元测试的源文件存储在module-name/src/test/java/中。

在模块的顶级build.gradle文件中,将以下库指定为依赖项:

1
2
3
4
5
6
7
8
9
10
11
dependencies {
// Required -- JUnit 4 framework
testImplementation "junit:junit:$junitVersion"
// Optional -- Mockito framework
testImplementation "org.mockito:mockito-core:$mockitoCoreVersion"

// Optional -- Robolectric environment
testImplementation "androidx.test:core:$xcoreVersion"
testImplementation "androidx.test.ext:junit:$extJunitVersion"
testImplementation "org.robolectric:robolectric:$robolectricVersion"
}

如果单元测试依赖于资源,需要在module的build.gradle文件中启用includeAndroidResources选项。然后,单元测试可以访问编译版本的资源,从而使测试更快速且更准确地运行。

1
2
3
4
5
6
7
8
9
android {
// ...

testOptions {
unitTests {
includeAndroidResources = true
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RunWith(AndroidJUnit4::class)
@Config(manifest = Config.NONE)
class PeopleDaoTest {
private lateinit var database: PeopleDatabase

private lateinit var peopleDao: PeopleDao

@Before
fun `create db`() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
PeopleDatabase::class.java
).allowMainThreadQueries().build()

peopleDao = database.peopleDao()
}

@Test
fun `should return empty list when getPeople without inserted data`() {
val result = peopleDao.getPeople(pageId = 1)

assertThat(result).isNotNull()
assertThat(result).isEmpty()
}

如果单元测试包含异步操作时,可以使用awaitility库进行测试;当使用RxJava响应式编程库时,可以自定义rule:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class RxJavaRule : TestWatcher() {
override fun starting(description: Description?) {
super.starting(description)

RxJavaPlugins.setIoSchedulerHandler {
Schedulers.trampoline()
}
RxJavaPlugins.setNewThreadSchedulerHandler {
Schedulers.trampoline()
}
RxJavaPlugins.setComputationSchedulerHandler {
Schedulers.trampoline()
}

RxAndroidPlugins.setMainThreadSchedulerHandler {
Schedulers.trampoline()
}
RxAndroidPlugins.setInitMainThreadSchedulerHandler {
Schedulers.trampoline()
}
}

override fun finished(description: Description?) {
super.finished(description)

RxJavaPlugins.reset()

RxAndroidPlugins.reset()
}
}

TestSchedulertriggerActions的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@RunWith(JUnit4::class)
class FilmViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val rxJavaRule = RxJavaRule()

private val repository = mock(Repository::class.java)

private val testScheduler = TestScheduler()

private lateinit var viewModel: FilmViewModel

@Before
fun init() {
viewModel = FilmViewModel(repository)
}

@Test
fun `should return true when loadFilms is loading`() {
`when`(repository.getPopularFilms(1)).thenReturn(
Single.just(emptyList<Film>())
.subscribeOn(testScheduler)
)

viewModel.loadFilms(0)

assertThat(getValue(viewModel.isLoading)).isTrue()
testScheduler.triggerActions()
assertThat(getValue(viewModel.isLoading)).isFalse()
}

@Test
fun `should return films list when loadFilms successful`() {
`when`(repository.getPopularFilms(1)).thenReturn(
Single.just(
listOf(
Film(123, "", "", "", "", "", "", 1)
)
).subscribeOn(testScheduler)
)

viewModel.loadFilms(0)

assertThat(getValue(viewModel.films)).isNull()
testScheduler.triggerActions()
assertThat(getValue(viewModel.films)).isNotNull()
assertThat(getValue(viewModel.films).size).isEqualTo(1)
}
}

TestSubscriber的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@RunWith(JUnit4::class)
class WebServiceTest {
private lateinit var webService: WebService

private lateinit var mockWebServer: MockWebServer

@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()

@Before
fun `start service`() {
mockWebServer = MockWebServer()

webService = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build()
.create(WebService::class.java)
}

@Test
fun `should return fim list when getFilms successful`() {
assertThat(webService).isNotNull()

enqueueResponse("popular_films.json")

val testObserver = webService.getPopularFilms(page = 1)
.map {
it.data
}.test()

testObserver.assertNoErrors()
testObserver.assertValueCount(1)
testObserver.assertValue {
assertThat(it).isNotEmpty()
assertThat(it[0].id).isEqualTo(297761)
assertThat(it[1].id).isEqualTo(324668)
it.size == 2
}
testObserver.assertComplete()
testObserver.dispose()
}

@After
fun `stop service`() {
mockWebServer.shutdown()
}

private fun enqueueResponse(fileName: String) {
val inputStream = javaClass.classLoader?.getResourceAsStream("api-response/$fileName")
?: return
val source = inputStream.source().buffer()
val mockResponse = MockResponse()
mockWebServer.enqueue(
mockResponse
.setBody(source.readString(Charsets.UTF_8))
)
}
}

构建插桩单元测试

插桩单元测试是在物理设备和模拟器上运行的测试,此类测试可以利用Android框架API。插桩测试提供的保真度比本地单元测试要高,但运行速度要慢得多。因此,我们建议只有在必须针对真实设备的行为进行测试时才使用插桩单元测试。

设置测试环境

Android Studio项目中,插桩测试的源文件存储在module-name/src/androidTest/java/

在模块的顶级build.gradle文件中,将以下库指定为依赖项:

1
2
3
4
5
android {
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
1
2
3
4
5
6
7
8
9
dependencies {
androidTestImplementation "androidx.test.ext:junit:$extJunitVersion"
androidTestImplementation "androidx.test:core:$xcoreVersion"
androidTestImplementation "androidx.test:rules:$rulesVersion"
// Optional -- Truth library
androidTestImplementation "androidx.test.ext:truth:$androidxtruthVersion"
androidTestImplementation "org.mockito:mockito-core:$mockitoCoreVersion"
androidTestImplementation "org.mockito:mockito-android:$mockitoAndroidVersion"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@RunWith(AndroidJUnit4::class)
@SmallTest
class FilmDaoTest {
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()

private lateinit var database: FilmDatabase

private lateinit var filmDao: FilmDao

@Before
fun initDb() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
FilmDatabase::class.java
).build()

filmDao = database.filmData()
}

@Test
fun should_return_film_list_when_getFilms_with_inserted_film_list() {
filmDao.insert(
Film(100, "", "", "", "", "", "", 1)
)
filmDao.insert(
Film(101, "", "", "", "", "", "", 1)
)

val result = filmDao.getFilms(1)

assertThat(result).isNotNull()
assertThat(result).isNotEmpty()
assertThat(result.size).isEqualTo(2)
assertThat(result[0].id).isEqualTo(100)
assertThat(result[0].page).isEqualTo(1)
assertThat(result[1].id).isEqualTo(101)
}

@Test
fun should_return_film_list_with_size_1_when_getFilms_with_inserted_2_same_film() {
filmDao.insert(
Film(100, "", "", "", "", "", "", 1)
)
filmDao.insert(
Film(100, "1223", "111", "", "", "", "", 1)
)

val result = filmDao.getFilms(1)

assertThat(result).isNotNull()
assertThat(result).isNotEmpty()
assertThat(result.size).isEqualTo(1)
assertThat(result[0].id).isEqualTo(100)
assertThat(result[0].page).isEqualTo(1)
}

@Test
fun should_return_empty_list_when_getFilms_with_deleteAll_called() {
filmDao.insert(
Film(100, "", "", "", "", "", "", 1)
)
filmDao.deleteAll()

val newResult = filmDao.getFilms(1)

assertThat(newResult).isNotNull()
assertThat(newResult).isEmpty()
}

@After
fun closeDb() = database.close()
}

总结

  • 基于目前流行的MVPMVVM架构设计模式,MVPModel层和Presenter层尽量不依赖Android FrameworkMVVMModel层和ViewModel层尽量不依赖Android Framework

  • 类的设计做到单一职责原则,依赖其他类时提供方便mock的方式(例如作为构造方法参数传递),某一个方法依赖其他对象时,小重构该对象作为方法参数传入。

  • 方法尽量短小(方法太长时可以利用重构手法在方法中再提取方法)。

  • 只覆盖public方法单元测试,privite方法可以间接测试。

  • 当依赖Android Framework API非常少时,可以采用Mock Android api的方式。

  • 当严重依赖Android Framework API时,引入Robolectric库模拟Android环境或者放入AndroidTest目录作为插桩单元测试在物理设备上跑。

  • 使用Robolectric库写本地单元测试时,依赖的某些类的方法调用出问题导致测试failed时,可以使用shadow类提供默认实现。

  • 每条测试采用GivenWhenThen的方式进行区分.

    1
    2
    3
    4
    5
    6
    7
    8
    @Test
    public void should_do_something_if_some_condition_fulfills() {
    // Given 设置前置条件

    // When 执行被测方法

    // Then 验证方法结果
    }

集成测试(中型测试)

用于验证模块内堆栈级别之间的交互或相关模块之间的交互

  • 如果应用使用了用户不直接与之交互的组件(如ServiceContentProvider),应验证这些组件在应用中的行为是否正确。

设置测试环境

参考插桩单元测试环境设置

Service测试

  • 利用ServiceTestRule,可在单元测试方法运行之前启动服务,并在测试完成后关闭服务。
  • ServiceTestRule类不支持测试IntentService对象。如果需要测试IntentService对象,可以应将逻辑封装在一个单独的类中,并创建相应的单元测试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@MediumTest
@RunWith(AndroidJUnit4.class)
public class LocalServiceTest {
@Rule
public final ServiceTestRule mServiceRule = new ServiceTestRule();

@Test
public void testWithBoundService() throws TimeoutException {
// Create the service Intent.
Intent serviceIntent =
new Intent(getApplicationContext(), LocalService.class);

// Data can be passed to the service via the Intent.
serviceIntent.putExtra(LocalService.SEED_KEY, 42L);

// Bind the service and grab a reference to the binder.
IBinder binder = mServiceRule.bindService(serviceIntent);

// Get the reference to the service, or you can call public methods on the binder directly.
LocalService service = ((LocalService.LocalBinder) binder).getService();

// Verify that the service is working correctly.
assertThat(service.getRandomInt(), is(any(Integer.class)));
}
}

ContentProvider的测试

使用ProviderTestRule

1
2
3
4
5
6
7
8
9
10
11
12
@Rule
public ProviderTestRule mProviderRule =
new ProviderTestRule.Builder(MyContentProvider.class, MyContentProvider.AUTHORITY).build();

@Test
public void verifyContentProviderContractWorks() {
ContentResolver resolver = mProviderRule.getResolver();
// perform some database (or other) operations
Uri uri = resolver.insert(testUrl, testContentValues);
// perform some assertions on the resulting URI
assertNotNull(uri);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Rule
public ProviderTestRule mProviderRule =
new ProviderTestRule.Builder(MyContentProvider.class, MyContentProvider.AUTHORITY)
.setDatabaseCommands(DATABASE_NAME, INSERT_ONE_ENTRY_CMD, INSERT_ANOTHER_ENTRY_CMD)
.build();

@Test
public void verifyTwoEntriesInserted() {
ContentResolver mResolver = mProviderRule.getResolver();
// two entries are already inserted by rule, we can directly perform assertions to verify
Cursor c = null;
try {
c = mResolver.query(URI_TO_QUERY_ALL, null, null, null, null);
assertNotNull(c);
assertEquals(2, c.getCount());
} finally {
if (c != null && !c.isClosed()) {
c.close();
}
}
}
  • Android没有为BroadcastReceiver提供单独的测试用例类。要验证 BroadcastReceiver是否正确响应,可以测试向其发送Intent对象的组件。或者,可以通过调用ApplicationProvider.getApplicationContext()来创建BroadcastReceiver的实例,然后调用要测试的BroadcastReceiver方法(通常是onReceive()方法)。

端到端测试(大型测试)

用于验证跨越了应用的多个模块的用户操作流程

界面测试的一种方法是直接让测试人员对目标应用执行一系列用户操作,并验证其行为是否正常。不过,这种人工方法会非常耗时、繁琐且容易出错。一种更高效的方法是编写界面测试,以便以自动化方式执行用户操作。自动化方法可以以可重复的方式快速可靠地运行测试。

设置测试环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dependencies {
androidTestImplementation "androidx.test.ext:junit:$extJunitVersion"
androidTestImplementation "androidx.test:core:$xcoreVersion"
androidTestImplementation "androidx.test:rules:$rulesVersion"
// Optional -- Truth library
androidTestImplementation "androidx.test.ext:truth:$androidxtruthVersion"
androidTestImplementation "org.mockito:mockito-core:$mockitoCoreVersion"
androidTestImplementation "org.mockito:mockito-android:$mockitoAndroidVersion"
// Optional -- UI testing with Espresso
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion"
// Optional -- UI testing with UI Automator
androidTestImplementation "androidx.test.uiautomator:uiautomator:$uiautomatorVersion"
}
  • 涵盖单个应用的界面测试:这种类型的测试可验证目标应用在用户执行特定操作或在其 Activity 中输入特定内容时的行为是否符合预期。它可让您检查目标应用是否返回正确的界面输出来响应应用 Activity 中的用户交互。诸如 Espresso 之类的界面测试框架可让您以编程方式模拟用户操作,并测试复杂的应用内用户交互。(espresso测试单个应用的界面例子)

  • 涵盖多个应用的界面测试:这种类型的测试可验证不同用户应用之间交互或用户应用与系统应用之间交互的正确行为。例如,您可能想要测试相机应用是否能够与第三方社交媒体应用或默认的 Android 相册应用正确分享图片。支持跨应用交互的界面测试框架(如 UI Automator)可让您针对此类场景创建测试。(uiautomator测试多个应用的界面

参考例子testing-samples