做架构之前,先要理解这些核心思想
作者:Bezier
链接:https://juejin.cn/post/6968673763218964493
前言
好多小伙伴都认为学习设计就是学习设计模式,这是一个误区,没有底层思想的支持写出来的设计模式无非就是生搬硬套罢了,这里的底层思想其实就是设计原则,而设计原则则是面向对象编程基于现实背景衍生出来的一套规则,用来解决开发中的痛点。
好的架构需要反复进行思考以及设计,今天我将从面向对象为出发点 来分享自己对设计/架构衍变过程的理解,尽量帮你理清背景 抓住本质
目录
1. 面向对象
1.1 四大特性
1.2 诞生背景
2. 六大设计原则才是一切设计的基石
2.1 单一设计原则
2.2 开闭原则
2.3 迪米特法则
2.4 接口隔离原则
2.5 里氏替换原则
2.6 依赖倒置原则
3. 设计模式只是设计原则的产物而已
3.1 设计模式该怎么去学?
3.2 "盐加少许" 只可意会
3.3 自创设计模式
1. 面向对象
什么是面向对象?估计这个问题能难倒一大片同学,相信读完本文你心里应该会有一个合适的答案。先来看下基本定义:
面向对象是一种风格,会以类作为代码的基本单位,通过对象访问,并拥有封装、继承、多态、抽象四种特性作为基石,可让其更为智能。代表语言Java
1.1 四大特性(也有人说三种,不要纠结)
封装
封装也也可称之为信息隐藏。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。举个例子解释下:
class User{
private String idNumber;
private String name;
public String getIdNumber() {
return idNumber;
}
public void setIdNumber(String idNumber) {
this.idNumber = idNumber;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
User类中包含身份证号、姓名等个人信息,这些属性一旦暴露那外界就可以随意修改,进而可能产生安全隐患。此时可通过private修饰符将其隐藏在内部,如果确实需要访问只能通过暴露出来的唯一入口getter,setter方法进行,这一过程就是封装
合理运用封装可以降低模块间依赖关系(松耦合)
继承
“继承”是面向对象中的第二特征,体现了类与类之间的“is-a”关系。当两个类进行继承关联绑定的时候,子类自动具备来自于的父类的属性和行为。可以提升复用性解决模板代码问题,提升开发效率的同时也解决了错写,漏写带来的问题
多态
一句话概括"多态":一个对象多种形态。举个例子说明下:
interface IFruit{
String getColor();
}
class Apple implements IFruit{
@Override
public String getColor() {
return "red";
}
}
IFruit fruit = new Apple();
fruit.getColor();
通过声明的IFruit
类型可以对其实现类Apple进行编程,好处就是扩展性强,当需要替换具体实现Apple时,对IFruit
的操作完全不用改
合理运用多态可以写出易扩展的代码,基于接口而非实现编程和开闭原则的核心
抽象
抽象的目的是为了隐藏方法的具体实现,让调用者只需要关心方法提供了哪些方法(功能),并不需要知道这些功能是如何实现的。在Java中体现方式是接口和抽象类。
接口和抽象类的区别如下:
接口更侧重于功能的设计,并且能将具体实现与调用者隔离,一般要以接口隔离原则设计接口既粒度越细越好 抽象类更侧重于提升复用性,在原有的基础上预留扩展点供开发者灵活实现 区别:接口可以降低模块间耦合性,抽象类可提升复用性。 相同点:均有较好的扩展性,符合开闭原则
面向对象的四大特性相信大家都很熟悉,本小结只是帮大家做一次简单的回忆,关于其背景和职责下面会详细描述
1.2 诞生背景
谈及面向对象必定磨不开面向过程,毕竟它就是由面向过程衍变而来,吸收其大部分优点并解决其痛点。那什么是面向过程呢?基本定义如下:
分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了,更侧重于功能的设计。代表语言C
用代码体现就是下面这样:
#java版面向过程
public class Wallet {
/**
* 余额
*/
int balance;
/**
* 存钱
*/
void saveMoney(int money){
balance += money;
}
/**
* 花钱
*/
void spendMoney(int money){
balance -= money;
}
}
无权限修饰符将内部信息全部暴露,简单粗暴很符合初级程序员的思维,但带来的问题很明显,外部可直接访问balance
修改钱包内余额,现象就是"我钱包都没掏出来但里面钱却变少/多了"。
面向过程在开发中带来的问题远不止这些,所以在此背景下诞生了面向对象 通过面向对象封装特性将面向过程代码做个改进,如下:
#java版面向对象
public class Wallet {
/**
* 余额
*/
private int balance;
/**
* 存钱
*/
void saveMoney(int money){
balance += money;
}
/**
* 花钱
*/
void spendMoney(int money){
balance -= money;
}
}
通过封装特性将balance
通过private
修饰,这样外部就没有权限直接修改金额,避免误操作带来的未知风险,满足松耦合特性
面向过程编程偏向于功能的开发,简单粗暴难以维护。而面向对象在编程之前需要基于四大特性对功能做建模设计,可以提高代码安全性、复用性、扩展性,更易于维护
既然面向对象这么智能为什么面向过程语言还没有被淘汰?其实面向对象语言的智能是针对我们开发者的,为了能让我们能写出易于维护的代码会多做一步设计,虽然离开发者更近了 但离机器却远了,毕竟机器只认识0和1而已。C语言规则简单易于形成机器码,所以执行效率高,这也是其没有被淘汰的原因。
tips: 不要以为用了面向对象语言写出的就是面向对象代码,如果没有利用其特性那可能还是面向过程,比如没有利用权限修饰符、一个类一把梭等等....
2. 六大设计原则才是一切设计的基石
设计原则是基于面向对象思想衍变出来的一些规则,用来解决实际开发中的一些痛点,是所有设计的底层思想,也是我个人认为是设计/架构领域最重要的知识,所以请大家务必掌握好
2.1 单一设计原则
单一原则很好理解,指一个函数或者一个类再或者一个模块,职责越单一复用性就越强,同时能够间接降低耦合性。
案例:本地获取用户信息,提交到网络
fun post(){
//创建数据库访问对象Dao
val userDao = ...(这一过程很复杂)
//从本地获取
val age = dao.getAge()
val name = dao.getName()
//....省略大量字段
//将个人信息提交至网络
http.request(age,name,....)
}
以上案例将创建、获取、提交三步操作写到同一个函数中,很显然违背了单一设计原则,面临的问题也很明显,当修改创建、获取、提交任一过程时都会影响到其他二者,千万不要说"我注意一点就不会出错"这种话,因为人不是机器改动就可能出错,此时可以通过单一设计原则做一次重构,代码如下:
fun getUserDao():UserDao{
...
return dao
}
fun getUserInfo():UserInfo{
val dao = getUserDao()
val userInfo = UserInfo()
userInfo.age = dao.getAge()
userInfo.name = dao.getName()
...
return userInfo
}
fun post(){
val userInfo = getUserInfo()
//将个人信息提交至网络
http.request(userInfo.age,userInfo.name,....)
}
三步操作被拆至三个函数 互不影响,从根本上杜绝因改动带来的一系列问题。所以使用面向对象语言开发时,不要急着写代码,要优先考虑下模块、类、函数...的设计是否足够单一
2.2 开闭原则
一句话概括开闭原则:对扩展开放,修改关闭。它即充分诠释抽象、多态特性,又是多数行为型设计模式的基础,遍布于各大优秀框架之中,是最重要的一条设计原则,仅这一条原则就能把你的设计能力提高40%
举个例子让大家感受一下:
需求:通过SQLite做CRUD操作
class SQLiteDao{
public void insert() {
//通过SQLite做insert
}
public void delete() {
//通过SQLite做insert
}
}
SQLiteDao dao = new SQLiteDao();
dao.insert();
...
以上是最简单粗暴的写法,但存在一个致命问题,如果某一天想替换SQLite业务层基本要动一遍,改动就存在出错的可能,并且需要做大量的重复操作
面对以上问题可以利用抽象、多态特性基于开闭原则做出重构,代码如下:
interface IDao{
void insert();
void delete();
}
class SQLiteDao implements IDao{
@Override
public void insert() {
//通过SQLite做insert
}
@Override
public void delete() {
//通过SQLite做insert
}
}
class RoomDao implements IDao{
@Override
public void insert() {
//通过Room做insert
}
@Override
public void delete() {
//通过Room做delete
}
}
//扩展点
IDao dao = new SQLiteDao();
dao.insert();
定义功能接口 IDao
定义类 SQLiteDao
、RoomDao
并实现IDao的功能业务层基于接口 IDao
进行编程
重构后,当需要将SQLite
替换至Room
时,只需将注释扩展点处SQLiteDao
替换成RoomDao
即可,其他地方完全不用改动。这就是所谓的扩展开放,修改关闭
在业务不断迭代情况下,唯一不变的就是改变,这种背景下我们能做的只有在代码中基于开闭原则多留扩展点以不变应万变。
2.3 迪米特法则
基本概念:不该有直接依赖关系的模块不要有依赖。有依赖关系的模块之间,尽量只依赖必要的接口。
迪米特法则很好理解并且非常实用,违背迪米特法则会产生什么问题?还以2.1面向过程代码举例:
class Wallet{
/**
* 余额
*/
int balance;
/**
* 存钱
*/
void saveMoney(int money){
balance += money;
}
/**
* 花钱
*/
void spendMoney(int money){
balance -= money;
}
}
Wallet
的设计违背了迪米特法则,毕竟外部只需要save
和spend
功能,将balance
暴漏使用者就有权限直接修改其值,可能会对整个Wallet
功能造成影响。此时应基于迪米特法则对Wallet
进行改造,将balance
通过封装特性增加private
修饰符
迪米特法则和单一设计原则很像,前者符合松耦合后者符合高内聚
2.4 接口隔离原则
基本概念:接口的调用者不应该依赖它不需要的接口。
乍一看与迪米特法则很相似。先来看下什么样的接口违背接口隔离原则:
interface Callback{
/**
* 点击事件回调方法
*/
void clickCallback();
/**
* 滚动事件回调方法
*/
void scrollCallback();
}
接口Callback
包含点击、滚动两个回调方法,面临的问题有两个:
某些特定场景使用者只需要依赖点击回调,那滚动回调便成了多余,把外部不需要的功能暴露出来就存在误操作的可能。 点击和滚动本来就是两种特性,强行揉到一块只能让接口更臃肿,进而降低其复用性
根据接口隔离原则改造后如下:
interface ClickCallback{
/**
* 点击事件回调方法
*/
void clickCallback();
}
interface ScrollCallback{
/**
* 滚动事件回调方法
*/
void scrollCallback();
}
基于单一设计原则把点击和滚动拆分成两个接口,将模块间隔离的更彻底。并且由于粒度更细,所以复用性也更高
接口隔离原则与迪米特法则目的很相似,都可以降低模块间依赖关系。但接口隔离更侧重于设计单一接口,提升复用性并间接降低模块间依赖关系,而迪米特法则是直接降低模块间依赖关
2.5 里氏替换原则
设计子类的时候,要遵守父类的行为约定。父类定义了函数的行为约定,子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。
里氏替换非常简单并且很容易遵守,在使用继承时,允许复写父类方法,但不要改变其功能。比如自定义View,子类的onMeasure
中一定要调用setMeasureaDimission()
方法(或者直接使用super),否则会影响父类方法功能(会抛异常),也既违背了里氏替换原则。
2.6 依赖倒置原则
控制反转:提及依赖倒置便不得不提控制反转,一句话概括:将复杂的程序操作控制权由程序员交给成熟的框架处理,程序员->成熟的框架为反转,框架应暴露出扩展点由程序员实现
什么是依赖倒置?
高层模块(使用者)不应依赖低层模块(被使用者),它们共同依赖同一个抽象,抽象不要依赖具体实现细节,具体实现细节依赖抽象。
其实核心点就是基于接口而非实现编程,2.2数据库案例也符合依赖倒置原则,高层模块(业务层)不依赖于低层模块(SQLiteDao/RoomDao),而是依赖于抽象(IDao),可见依赖倒置也是开闭原则扩展而来。区别是依赖倒置更侧重于指导框架的设计,框架层应该尽量将更多的细节隐藏在内部,对外只暴露抽象(抽象类/接口),指导框架设计这方面核心就是控制反转
3. 设计模式只是设计原则的产物而已
设计模式共有23种,详细描述都能出一本书出来。本小结仅会分享一些通用的思路,个人认为还是比较硬核的,毕竟设计主要还是思想,而非生搬硬套
3.1 设计模式该怎么去学?
本小节会分析几个常见的设计模式核心思想以及设计背景,用于抛砖引玉
工厂模式
基本概念:用于创建复杂对象
创建复杂对象常规写法如下:
class B{
...
}
class D{
void test(){
B b = ....(创建B的过程很复杂)
...
}
}
在使用的地方直接创建,如果直接new倒也没啥问题,但如果创建过程过于复杂,当修改创建过程时就会影响到`test()``,进而存在一些未知的隐患。
这一问题可通过迪米特法则进行改造:
class FactoryB{
...
static B createB(){
.....(B创建过程)
return b;
}
}
class D{
void test1(){
B b = FactoryB.create();
...
}
}
B的创建本身就于调用者无关,将创建过程转移到类FactoryB
中,根本上避免了创建过程对调用者的影响。改造后就是一个标准的简单工厂模式,所以简单工厂模式的核心思想就是迪米特法则
观察者模式
基本概念:当一个对象发生改变时需要通知到另一个对象
粗暴写法:
/**
* 观察者
*/
class Observer{
/**
* 接收通知
*/
void receive(){
//具体逻辑
}
}
/**
* 被观察者
*/
class Observable{
/**
* 发送通知
*/
void send(){
Observer observer = new Observer();
observer.receive();
}
}
Observable
(被观察者)内部直接持有Observer
(观察者),在合适的时机发出通知,但这种写法有两个很明显的问题:
扩展性差:当存在多个观察者Observer1,Observer2...时,Observable需要逐个手动创建发出通知 耦合性强:Observable直接持有Observer对象,而Observer可能暴露出一些Observable不需要的属性/方法,存在误操作的风险
面对以上两个问题可以利用开闭原则和接口隔离原则进行改造:
interface IObserver{
/**
* 接收通知
*/
void receive();
}
class Observable{
/**
* 观察者集合
*/
private final List<IObserver> observers = new ArrayList<>();
/**
* 发送通知
*/
void send(){
for (IObserver observer : observers){
observer.receive();
}
}
}
以上是一个标准的观察者模式。通过接口隔离原则设计IObserver
接口保证其单一性,避免模块之间依赖关系过强造成的安全隐患,解决了耦合性强问题。通过开闭原则维护一个observers
,当新增观察者时只需添加到observers
即可,符合扩展开放、修改关闭,解决类扩展性差问题。
所以开闭原则,接口隔离原则是观察者模式扩展性强,耦合性低的根本原因呐
以上三个案例足以表明设计模式的核心就是设计原则呐,所以学会设计模式的窍门就是先掌握设计原则
3.2 "盐加少许" 只可意会
据我所知有一部分小伙伴觉得用设计模式很酷,以至于拿着锤子看什么都是钉子,很简单的代码还非要用几个设计模式包装下。还有另一部分小伙伴对设计模式理解不够深刻,把握不好应用场景,经常生搬硬套做出多余的设计。以上两种现象 不但解决不了任何问题反而会降低代码的可读性
究竟什么场景下需要用设计模式呢?关于这个问题我只能回答合适的场景,因为它根本没有一个固定答案。就如同老师傅做饭时讲的少许盐、少许油一样,因为不同的食材需要的油盐不一样,所以不好去量化,只能根据自己的经验去放。
回归到代码中也是一样的,我们在实践中需要不断思考,尝试去发现开发中的痛点,设计模式就是用来解决这些痛点的,所以只有理清背景才能将设计模式用的恰到好处
3.3 自创设计模式
有一说一23种设计模式我也不是全懂,但由于我懂设计原则我一样可以写出易维护的代码,甚至自创设计模式
在接触LiveData
之前,其实我已经有意无意感受到了数据驱动的思想,在传统的MVP模式下我会在View层事先写好对应UI渲染逻辑,Presenter
由接口进行驱动(这其实也是数据驱动UI)。当LiveData/DataBinding
走进视线并且大家开始讨论数据驱动UI时,那一刻我仿佛找到了组织,自己的想法终于得到了验证。之所以我能够有意无意遵守数据驱动UI是因为我原本就掌握了控制反转思想
阅读Retrofit
源码前我甚至不知道门面模式的存在,但我依旧能理解ApiService
奥妙之所在,无非就是想将Retrofit的实现尽量屏蔽在其内部,尽可能降低模块间依赖关系,符合迪米特法则同时也是门面模式的一种写法。
说了这么多还是想告诉大家:设计原则才是根本
4. 如何做好架构?
掌握设计原则可以写出扩展性强、复用性高...的代码 掌握设计模式可以设计出易用性强、安全性高...成熟的框架 掌握设计原则、设计模式,可以设计出容错率更高的架构
那什么是架构?
架构是一个很笼统的概念,上至框架选型下至业务代码都能称为架构的一部分,比喻到盖房子 设计图,打地基,选料…都能称之为架构,总之能够提升项目稳定性以及开发效率就是好架构。好的架构不是一蹴而就,而是根据面临的问题不断添砖加瓦
架构是如何衍变的?
远古时代,基于Activity和XML开发,XML这种结构可以天然的将视图与Activity隔离,看起来很美妙,我也很开心.. 随着业务的发展,Activity代码不断壮大,各种逻辑全都揉到一块,常常改一处崩多处。我觉得不能再拖了,得赶紧基于单一设计原则将代码进行模块化。模块化后效果很明显,莫名其妙的bug少了很多.. 某一天网络请求时发现参数一直对不上,各种排查才发现原来是修改某个View时对应的数据却忘记改了,这个问题真的很头痛。偶然间发现LiveData、DataBinding,这玩意基于控制反转+观察者设计 改变数据就能修改UI,那我肯定毫不犹豫引入到项目中啊。从此我再也不用担心数据UI一致性问题了.. 数年后,项目工程逐渐庞大,编译一次都要好几分钟,找个文件找半天还容易改错,令大家苦不堪言。听说Android可以依据单一原则将代码拆分至多个module中并可以单独运行,试了试果然可以.. 最近有同事经常跟我抱怨:“每个Activity都有好多重复代码啊,而且一不留神容易错写、忘写”,这让我想到了模版设计模式,将通用功能封装在内部并暴露一些抽象方法(钩子方法),新来的同事也变得开心的了,基于这套模板他可以无障碍开发.. 未完待续...
以上是一个简单架构的衍变过程,选用的每一个库都是基于设计原则,设计模式拓展出来用来解决开发痛点的。但是团队开发人员水平可能参次不齐,不一定能领悟到架构的含义,仅从口头上约束可能作用不大,此时一般会通过模板模式将通用信息做封装,在内部协调好各模块间关系,并暴露出对应的泛型、抽象方法(钩子),这样开发人员在使用模板类的时候就会被强制遵守现有的规则。
tips: 关于面向对象、设计原则、设计模式如果详细讲解能写三大本书出来。本文主要描述其基本概念、设计背景以及三者之间的关系,起到抛砖引玉的作用。想真的学好设计、做好架构 需要不断从实践中体会、思考。
最后推荐一下我的Jetpack MVVM的完整开源项目,目前托管在Github,希望也能为你提供一些帮助。github地址
综上所述
面向对象解决了面向过程的开发痛点 设计原则是基于面向对象扩展出来的一套思想,用来解决开发中痛点 设计模式就是设计原则的产物 过度设计是设计模式的大忌 掌握好设计原则、设计模式才能做出优秀的架构
~ FIN ~
推荐阅读:
Compose 架构如何选?MVP & MVVM & MVI
↓关注公众号↓ | ↓添加微信交流↓ |
---|---|
加我好友拉你进技术交流群,每天干货聊不停~