SOLID

359 阅读15分钟

SOLID是一个助记符缩写,有助于定义五个基本的面向对象设计原则:

  • 单一责任原则
  • 开放原则
  • 里氏替换原则
  • 接口隔离原理
  • 依赖倒置原则

使用这五个基本的面向对象设计原则可以帮助开发人员设计可维护和可扩展的应用。

单一责任原则

定义:一个类应该只有一个改变的理由。

Android中的RecyclerView和Adapter可以作为一个例子。Adapter中运行最多的部分可以说是onBindViewHolder方法。

RecyclerView的Adapter有一个责任:将对象映射到将在屏幕上显示的相应视图。

public class LineItem {
    private String description; 
    private int quantity; 
    private long price; 
    // ... getters/setters
}

public class Order {
    private int orderNumber; 
    private List<LineItem> lineItems = new ArrayList<LineItem>();  
    // ... getters/setters
}

public class OrderRecyclerAdapter extends RecyclerView.Adapter<OrderRecyclerAdapter.ViewHolder> {
 
    private List<Order> items;
    private int itemLayout;
 
    public OrderRecyclerAdapter(List<Order> items, int itemLayout) {
        this.items = items;
        this.itemLayout = itemLayout;
    }
 
    @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View v = LayoutInflater.from(parent.getContext()).inflate(itemLayout, parent, false);
        return new ViewHolder(v);
    }
 
    @Override public void onBindViewHolder(ViewHolder holder, int position) {
        // TODO: 绑定视图 
    }
 
    @Override public int getItemCount() {
        return items.size();
    }
 
    public static class ViewHolder extends RecyclerView.ViewHolder {
        public TextView orderNumber;
        public TextView orderTotal;
 
        public ViewHolder(View itemView) {
            super(itemView);
            orderNumber = (TextView) itemView.findViewById(R.id.order_number);
            orderTotal = (ImageView) itemView.findViewById(R.id.order_total);
        }
    }
}

上述onBindViewHolder方法实现是空的。也许有人会按照下面方式实现。

@Override 
public void onBindViewHolder(ViewHolder holder, int position) {
    Order order = items.get(position);
    holder.orderNumber.setText(order.getOrderNumber().toString());
    long total = 0;
    for (LineItem item : order.getItems()) {
        total += item.getPrice();
    }
    holder.orderTotal.setText(String.valueof(total));
    holder.itemView.setTag(order);
}

上述代码违反了单一责任原则。Adapter应仅负责使Order对象适应其视图表示。 onBindViewHolder执行了额外的计算职责。

在一个类中包含多个职责可能会导致各种问题。 首先,订单的计算逻辑现在耦合到Adapter中。 如果需要在其他地方显示订单总数,则必须复制该逻辑。 一旦发生这种情况的应用程序就会遇到我们都熟悉的传统软件逻辑重复问题。当在一个地方更新代码,你会忘记在其他位置更新它。

通过将订单总计算提取到Order对象中,可以轻松修复此简单示例。 然后,订单也可以使用此格式化程序。

@Override 
public void onBindViewHolder(ViewHolder holder, int position) {
    Order order = items.get(position);
    holder.orderNumber.setText(order.getOrderNumber().toString());
    holder.orderTotal.setText(order.getOrderTotal()); // A String, the calculation and formatting moved elsewhere
    holder.itemView.setTag(order);
}

Adapter正在构建一个视图,并将订单绑定到视图,构建视图持有者。看似这个类有多个职责。

这些职责应该分开吗?

如果应用程序以影响视图组装方式及其连接功能(视图逻辑)的方式发生变化,设计会闻到“刚性”,因为一个变化需要另一个变化。视图结构的改变也需要改变适配器本身,导致设计变得僵硬。但是,如果应用程序没有以不同的方式改变需要不同功能的方式,那么就没有必要将它们分开。在这种情况下,将它们分开会增加不必要的复杂性。

“刚性”是什么?

假设一个新的产品要求在订单的总金额为零时,视图应该在屏幕上显示亮黄色的“FREE”图像而不是总量文本。 这个逻辑会在哪里? 在一个代码路径中,需要一个TextView,而在另一个代码路径中,您需要一个ImageView。 有两个地方代码需要更改:

  • 视图
  • 演示逻辑

如果在Adapter层面处理,这会强制在更改视图时更改Adapter。如果此逻辑位于Adapter中,则会强制更改Adapter中的逻辑以及视图的代码。 这增加了Adapter的另一个责任。

这正是MVP模式提供必要的解耦,以便类不会变得过于死板,同时为扩展,可组合性和测试提供灵活性的关键。例如,V将实现一个接口,该接口定义它将如何与之交互,并且P将执行必要的逻辑。 MVP模式中的P仅负责视图/显示逻辑,仅此而已。将此逻辑从Adapter移动到P将有助于使Adapter更多地遵守单一责任原则。

如果深入了解任何RecyclerView的Adapter,可能已经注意到Adapter正在做很多事情。 Adapter仍然做的事情:

  • 填充View
  • 创建ViewHolder
  • 回收ViewHolder
  • 提供View数量

毕竟,RecyclerView 的Adapter只是Adapter模式的一个实现。 在这种情况下,保持填充View和ViewHolder机制确实有意义; 这就是这个类的责任。但是,引入其他行为(如视图逻辑)会破坏单一职责,并且可以通过使用MVP模式或其他重构来避免。

开放原则

定义:软件实体(类,模块,函数等)应该是对扩展开放的,但是对修改关闭的。

基本概要是应该努力做到,每次编写需求更改时不必更改的代码。 在Android中,我们使用Java,因此可以使用继承和多态来实现此原则。

假设有一个计算所给形状的面积之和的需求。将面积计算抽象为AreaManager类。现在要计算一些长方形的面积和。

public class Rectangle {
    private double length;
    private double height; 
    // getters/setters ... 
}

public class AreaManager {
    public double calculateArea(ArrayList<Rectangle>... shapes) {
        double area = 0;
        for (Rectangle rect : shapes) {
            area += (rect.getLength() * rect.getHeight()); 
        }
        return area;
    }
}

接着需求出现,需要计算圆形的面积。因此需要改变AreaManager的实现。

public class Circle {
    private double radius; 
    // getters/setters ...
}

public class AreaManager {
    public double calculateArea(ArrayList<Object>... shapes) {
        double area = 0;
        for (Object shape : shapes) {
            if (shape instanceof Rectangle) {
                Rectangle rect = (Rectangle)shape;
                area += (rect.getLength() * rect.getHeight()); 
            } else if (shape instanceof Circle) {
                Circle circle = (Circle)shape;
                area += (circle.getRadius() * cirlce.getRadius() * Math.PI;
            } else {
                throw new RuntimeException("Shape not supported");
            }            
        }
        return area;
    }
}

如果有一个三角形显示,或任何其他多边形,我们将一遍又一遍地改变AreaManager类。此类违反了开放/封闭原则。 它不对修改关闭,也不对扩展开放。 每次出现新形状时,我们都必须修改AreaManager。

Java用继承实现开放/封闭原则。

由于AreaManager负责计算所有形状的总面积,并且因为形状计算对于每个单独的形状是唯一的,所以将每个形状的面积计算移动到其各自的类中似乎是合乎逻辑的。但这使得AreaManager仍然需要知道所有的形状,因为它如何知道它迭代的对象有一个面积方法。当然,这可以通过反射来解决,或者我们可以让每个形状类继承自接口:Shape接口(这也可以是抽象类):

public interface Shape {
    double getArea(); 
}

每个类都会实现这个接口或者从抽象类扩展。

public class Rectangle implements Shape {
    private double length;
    private double height; 
    // getters/setters ... 

    @Override
    public double getArea() {
        return (length * height);
    }
}

public class Circle implements Shape {
    private double radius; 
    // getters/setters ...

    @Override
    public double getArea() {
        return (radius * radius * Math.PI);
    }
}

public class AreaManager {
    public double calculateArea(ArrayList<Shape> shapes) {
        double area = 0;
        for (Shape shape : shapes) {
            area += shape.getArea();
        }
        return area;
    }
}

Android内置的视图,如Button,Switch和Checkbox都是TextView对象。这意味着Android视图系统已关闭修改但已打开扩展。 如果要通过创建自己的CurrencyTextView来更改文本的外观,只需从TextView扩展(继承)并在那里实现视图逻辑。 Android视图系统并不关心使用的是新的CurrencyTextView,它只关心类遵循TextView的特定合约。 Android将依赖特定方法将视图绘制到屏幕上。

ViewGroup类也是这样。有许多不同的ViewGroups(RelativeLayout,LinearLayout等),Android视图系统知道如何使用它们。 可以通过扩展ViewGroup轻松地创建自己的ViewGroup。 最后,可以编写依赖于ViewGroup,TextView甚至View类的代码来执行特殊操作。

依赖于View,TextView,ViewGroup等抽象,可以编写对修改关闭但对扩展开放的代码。

里氏替换原则

定义:程序中的对象应该可以替换为其子类型的实例,而不会改变该程序的正确性。

Java是一种静态类型语言。 编译器非常擅长捕获类型错误并通知我们。 尝试将字符串分配给Long,反之亦然,编译器会告诉错误。 编译器也非常擅长编写符合里氏替换替换原则的代码。

ArrayList<Integer> ids = getCustomerIds(); 
List<Customer> customers = customerRepository.getCustomersWithIds(ids); 

public interface CustomerRepository {
   List<Customer> getCustomersWithIds(List<Integer> ids); 
} 

public class CustomerRepositoryImpl implements CustomerRepository {
   @Override
   public List<Customer> getCustomersWithIds(List<Integer> ids) {
        ArrayList<Customer> customers = api.getWholeLottaCustomers(ids); 
        return customers;
   }
}

在上面的代码示例中,客户存储库需要一个客户ID列表,以便它可以获得这些客户。 客户存储库仅要求客户ID列表的类型为List <Integer>。 当我们调用存储库时,我们提供了一个ArrayList <Integer>,如下所示:

ArrayList<Integer> ids = getCustomerIds(); 
List<Customer> customers = customerRepository.getCustomersWithIds(ids);

由于ArrayList <Integer>List <Integer>的子类型,因此程序不会出错:我们正在用其子类型的实例(ArrayList <Integer>)替换所请求类型的实例(List <Integer>)。

换句话说,在上面的代码中,我们依赖于抽象(List <Integer>),因此我们可以提供一个子类型(ArrayList <Integer>),程序仍然会运行而没有问题。

原因是客户存储库取决于List接口提供的合同。 ArrayList是List接口的实现,因此,当程序运行时,客户存储库将不会看到类型是ArrayList,而是作为List的实例。

也就是说,如果S是T的子类型,则程序中类型T的对象可以用类型S的对象替换,而不改变该程序的任何期望属性(例如,正确性)。

Collection<Integer> ids = getCustomerIds(); 
List<Customer> customers = customerRepository.getCustomersWithIds(ids); 

上述代码将会报错。因为List <E>实际上实现了Collection <E>。getCustomersWithIds只接受List <Integer>。 List确实实现了Collection,但Collection没有实现List。 因此,虽然列表是集合,但集合不一定是列表。 在此示例中,编译器无法证明Collection <Integer>肯定是List <Integer>。 以这种方式呈现时,这些类型不兼容。可以依赖抽象而不必担心应用程序崩溃。

接口隔离原理

定义:创建特定于客户端的细粒度接口。

Android View类是所有Android视图的根超类。 它是TextView,Button,LinearLayout,CheckBox,Switch等的根。

可以创建一个名为OnClickListener的接口,该接口嵌套在View类中,如下所示:

public interface OnClickListener { 
    void onClick(View v);
}

随着时间的推移,你会发现你需要一个长按点击事件,你决定把它扔进OnClickListener。

public interface OnClickListener { 
    void onClick(View v);
    void onLongClick(View v); 
}

一些时间过去了,你意识到你还需要为视图添加一些触摸侦听器。

public interface OnClickListener { 
    void onClick(View v);
    void onLongClick(View v); 
    void onTouch(View v, MotionEvent event);
}

此时决定将接口的名称从OnClickListener更改为ViewInteractions,或类似的东西。主要是因为触摸事件与点击事件不同。 这个接口正在成为一个问题 - 它变得普遍和污染。

我想将一个点击监听器附加到一个按钮,像这样:

Button create = (Button)findViewById(R.id.create);
create.setOnClickListener(new View.OnClickListener {
    public void onClick(View v) {
       // assume this is a todo based app.
       myDatabase.createTask(...);
    }
    
    public void onLongClick(View v) {
        // do nothing, we're not long clicking
    }

    public void onTouch(View v, MotionEvent event) {
        // do nothing, we're not worried about touch
    } 
});

最后两个方法onLongClick和onTouch都没有做任何事情。 当然,我们可以在那里放一些代码,但是如果我不需要它呢? 我很可能只担心点击,而不是触摸,而不是长按。 当然,有些情况下您关注同一类中的这些事件,但大多数开发人员在这种情况下只需要点击监听器。

该接口过于通用,因为它要求客户端实现所有方法,即使它不需要它们。

客户端应该只需要实现它需要的接口,而不是更多。 在上面的示例中,客户端应用程序不需要onLongClick和onTouch。

如果开发人员想要对视图进行长按,则可以实现OnLongClickListener。 如果他们想要使用触摸事件,他们可以实现OnTouchListener。

无论何时编写接口并向其添加方法,请问自己是否应该在其位置创建更具体的客户端接口。

例如,Android TextView具有addTextChangedListener方法。 TextWatcher接口提供三种方法:

public interface TextWatcher extends NoCopySpan {
    
    public void beforeTextChanged(CharSequence s, int start, int count, int after);
  
    public void onTextChanged(CharSequence s, int start, int before, int count);

    public void afterTextChanged(Editable s);
}

接口隔离原则的目标是帮助解耦应用程序,以便更容易维护,更新和重新部署。

依赖倒置原则

依赖倒置原则表明我们作为开发人员应该遵循两条建议:

  • 高级模块不应该依赖于低级模块。两者都应该取决于抽象。
  • 抽象不应该依赖于细节。细节应取决于抽象。

我们将看看这个传统的分层架构,然后讨论我们如何对其进行更改,以便我们可以支持依赖倒置原则。

在传统的分层模式软件架构设计中,更高级别的模块依赖于较低级别的模块来完成其工作。 例如,这是一个非常常见的分层架构

Android UI → Business Rules → Data Layer

在上图中有三层。 UI层(在本例中为Android UI) - 这是我们所有的UI小部件,列表,文本视图,动画以及与Android UI相关的所有内容。 接下来,有业务层。 在此层中,实现了通用业务规则以支持核心应用程序功能。 这有时也称为“域层”或“服务层”。最后,存在数据层,其中应用程序的所有数据都驻留在其中。 数据可以在数据库,API,文件等中 - 它只是一个层,其唯一的责任是存储和检索数据。

假设我们有一个费用跟踪应用程序,允许用户跟踪他们的费用。 鉴于上述传统模型,当用户创建新费用时,我们将发生三种不同的操作。

  • UI层:允许用户输入数据。
  • 业务层:验证输入的数据是否与一组业务规则匹配。
  • 数据层:允许持续存储费用数据。

在UI层中,我们可能有一些类似于这个伪代码的代码:

findViewById(R.id.save_expense).setOnClickListener(new View.OnClickListener() {
    public void onClick(View v) {
        ExpenseModel expense = //... create the model from the view values
        BusinessLayer bl = new BusinessLayer();
        if (bl.isValid(expense)) {
           // Woo hoo! Save it and Continue to next screen/etc
        } else {
           Toast.makeText(context, "Shucks, couldnt save expense. Erorr: " + bl.getValidationErrorFor(expense), Toast.LENGTH_SHORT).show();
        }
    } 
}); 

在业务层中,我们可能有一些类似于这个伪代码的代码:

public int saveExpense(Expense expense) {
    // ... some code to check for validity ... then save
    // ... do some other logic, like check for duplicates/etc
    DataLayer dl = new DataLayer(); 
    return dl.insert(expense); 
}

上述代码的问题在于它打破了依赖性倒置原则:高级模块不应该依赖于低级模块。两者都应该取决于抽象。

UI取决于具有以下行的业务层的具体实例:

BusinessLayer bl = new BusinessLayer();

这永远将Android UI层与业务层联系起来,如果没有业务层,UI层将无法完成其工作。

业务层也违反了依赖性倒置,因为它取决于具有此行的数据层的具体实现:

DataLayer dl = new DataLayer();

如果更高级别的模块不应该依赖于更低级别的模块,那么应用程序如何完成其​​工作?

我们绝对不希望有一个简单的整体类来完成所有事情。

值得庆幸的是,我们可以依靠抽象来帮助在应用程序中实现这些小接缝。 这些接缝是允许我们实现依赖性倒置原则的抽象。 将应用程序从传统的分层实现更改为依赖性反转体系结构是通过称为“所有权反转”的过程完成的。

我们绝对不希望低级模块依赖于更高级别的模块。 我们需要从两端完全颠倒这种关系。

使用Java语言,我们可以通过几种方式创建抽象,例如抽象类或接口。 我更喜欢使用接口,因为它在应用层之间创建了一个干净的接缝。 接口只是一个契约,它向用户通知接口实现者可能具有的所有可能操作。

这允许每个层依赖于一个接口,这是一个抽象,而不是一个具体的实现.

在Android Studio中实现这一点非常简单。 假设你有那个DataLayer类,它看起来像这样:

由于我们想要依赖抽象,我们需要从类中提取一个接口。

现在有了一个可以依赖的界面! 但是,仍然需要使用它,因为业务层仍然依赖于具体的数据层。 回到业务层,您可以更改该代码以通过构造函数注入依赖关系,如下所示:

public class BusinessLayer {
    
    private IDataLayer dataLayer;

    public BusinessLayer(IDataLayer dataLayer) {
        this.dataLayer = dataLayer;
    }

    // in the business layer, return an ID of the expense
    public int saveExpense(Expense expense) {
        // ... some code to check for validity ... then save
        // ... do some other logic, like check for duplicates/etc
        return dataLayer.insert(expense);
    }
}

业务层现在依赖于抽象--IDataLayer接口。 现在通过构造函数通过所谓的“构造函数注入”注入数据层。

那么这个数据层来自哪里? 嗯,它来自创建业务层对象的人。 在这种情况下,它将是Android UI。 但是,我们知道我们之前的示例说明Android UI与业务层紧密耦合,因为它正在创建一个新实例。 我们需要业务层也是一个抽象。

public class MainActivity extends AppCompatActivity {

    IBusinessLayer businessLayer; 
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

UI层取决于业务层接口,业务层接口取决于数据层接口。

我如何依赖Android UI层中的抽象?有几种方法可以使用创建模式(如工厂或工厂方法模式或依赖注入框架)在Android中解决它。建议使用依赖注入框架来帮助您创建这些对象,这样您就不必手动创建它们。

public class MainActivity extends AppCompatActivity {

    @Inject IBusinessLayer businessLayer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        // businessLayer is null at this point, can't use it.  
        getInjector().inject(this); // this does the injection
        // businessLayer field is now injected valid and NOT NULL, we can use it
        
        // do something with the business layer ... 
        businessLayer.foo()
    }
}