(一)代码重构

核心思想:拆细、公用
重构可以是修改变量名、重新安排目录这样简单的物理重构,也可以是抽取子函数、精简冗余设计这样稍许复杂的逻辑重构。但均不改变现有代码的功能。

1.了解敌人——丑陋的代码

臃肿的类
开发者缺乏对最基本的编码原则,即“单一职责原则”(SRP)的理解。开发者不去思考这些功能是不是应该放在这同一个类中,导致这些类会变得很臃肿,造成一个类几千行,让下一个接盘侠欲哭无泪。
臃肿的方法
好几十上百行的一个函数堆在一块,用面向过程的思想来写代码。
函数参数过多
函数参数过多会导致调用者对方法难以理解,参数弄混。想象一下一个函数连续传5个int值参数,能分清谁是谁吗?建议可以将参数组成一个对象传入。
层层嵌套的判断
如果逻辑不复杂尽量减少if-else的分支包裹,他人太难阅读。比如不满足条件了直接return,不走其他代码,这样可以减少一层嵌套。
满篇跑的常量值
一个类里面出现各种未命名的常量值。0,1,200等等铺天盖地。这种状态码意义改了,改代码会把你改哭的。难道就不能先声明一个统一的常量变量来使用吗。
模棱两可的命名
不能根据名字一眼看懂它的功能的命名不是一个好命名。当然生僻的单词除外。模糊的,没有功能意义的命名会给阅读造成很大困难。

2.重构之道

分拆大函数: Break Method
当函数比较大了,就可以根据功能节点分拆成多个小函数,也许其中的小函数还可以公用。比如结算购物车,包括计算各类商品的总价,再计算折扣,再计算满减优惠,如果一个方法执行完,那么别人要只要逻辑就要从头到尾读一遍。而分别拆分成三个,一眼就能看出这段逻辑先后做了什么。写方法切忌一口吃一个胖子。
封装到父类:
如果多各类要执行相似的功能和代码,可以把该方法放到它们的父类中,或者提取出来成业务工具类。
Move Method——方法迁移
遵守“单一职责”原则,当类中的方法不适合放在当前类中时,就应该为该方法寻找合适下家。移到与方法耦合大的类中。当一个方法被其他类使用比在它所在类中的使用还要频繁时,我们就需要使用迁移方法重构了——将方法迁移到更频繁地使用它的类中。
Move Field——搬移字段
当在一个类中的某一个字段,被另一个类的对象频繁使用时,我们就应该考虑将这个字段的位置进行更改了
Extract Class——提炼类
一个类如果过于复杂,做了好多的事情,违背了“单一职责”的原则,所以需要将其可以独立的模块进行拆分,当然有可能由一个类拆分出多个类。
对类的细化也是为了减少代码的重复性,以及提高代码的复用性,便于代码的维护。
提升方法、字段(Pull Up Method)
将方法向继承链上层迁移的过程。用于一个方法被多个实现者使用时。在继承的体系中,当多个类使用了相同或类似的方法,就可以考虑将该方法抽取到基类,没有基类就创建一个。字段提升同方法。
降低方法
即父类抽象方法让多个子类实现。多个子类有相同的功能但是有各个具体的实现方法,那么这种封装就可以用多态性了,父类创建一个抽象方法,将方法实现降低到子类。
重复代码的提炼
有时候为了赶项目进度,尽快完成功能,会偷懒将实现功能的一片代码复制一遍,直接套用。这种把多余的删掉,保留一个,也许只需传一两个参数就可以封成一个方法供多处调用。
重命名变量(类、方法、变量)
这个很重要,可以不夸张地说,命名的水平就体现了编程能力的高低。在重构的过程中,当发现类名,方法名在当前版本不符合它的功能含义,就该考虑对其重新命名。
补加注释
对于全局变量,公用函数,逻辑复杂的地方添加注释,弥补之前的遗漏。
将较长的判断或代码运算用临时变脸暂存
if(stateCode = OK && datas != null && canShow)
function(Math.random((num1-num2)num3))

如上这种长长的判断条件和参数会使 这种代码应该先将if判断条件写成一个变量,放入变量判断,将function参数写一个局部变量保存结果,再传入方法。
使用泛型封装成统一的方法或类
函数要避免过多的参数造成阅读的复杂性
public void requestPhoneThirdRegister(String loginway, String nickname, String openId, String token, String expires, String phone, final CallBackimpl callBackimpl)

用这样的方法直接传参数就太长了,严重降低代码可读性。我们可以将参数变量写到一个实体类中,通过构造方法初始化对象属性值,只需要传递一个对象就搞定,也解决了增减参数带来的变动问题。
嵌套条件分支优化
if(){
if(){
if(){
}
}
}else{
}

相信大家也见识过不少这样的箭头代码,像怎么也解不开的死结。遇到这种代码,一定要尽可能要优化。通常做法:判断语句,if条件成立,执行代码块,诶,这样就生成了一个嵌套层级。
优化的核心思想:直接判断不满足的条件,if条件成立,直接return,尽快跳出方法来减少嵌套的层级。
第二种:将条件判读合并
尽量避免双重否定的条件
private boolean isChecked(){
if(){
return true;
}
}

一个条件方法。
if(!isChecked()){
}

然后用否定来判断这个条件,这样可能会一时之间转变不过来导致条件判断反了。当然头脑灵活的忽略这条。
*去除东北乱炖的Util类

当我们在写代码中偶然间需要抽出公用方法时,一时之间找不到合适的类去放置,然后就随意地放进了XXUtil或XXManager类中。长此以往,该类所含功能越来越杂,dp和px转化在其中,屏幕尺寸相关方法在其中,日期转化在其中,加密的索性也放在其中,那有无网络,网络类型判断也加入吧。这不就像垃圾场了吗,各类杂物都堆在其中,不符合单一职责原则,应该按照如上的功能块分解成多个职责单一的类。类多不要紧,关键要做到职责单一。
将满篇跑的魔鬼数字和字符串用定义的常量表示。
如果只是某个类或者某个模块需要用到该常量,就声明到对应类中。如果是全局项目都会用到的常量,就提升到项目的常量配置文件中。

 好代码需要遵循什么?重构有哪些技巧?

(一)技巧一:起一个清晰、合理、有意义的命名


有意义的命名是体现表达力的一种方式。

方法名应当是动词或动词短语,表达你的意图。如 deletePage 或 savePage。


类名和对象名应该是名词或名词短语。如 Customer、WikiPage、Account和AddressParser。


不要以数字来命名,除非是 changeJson2Map() 这种情况。


单字母名称仅用于短方法中的本地变量。比如循环体内的 i,但是你不应该在类变量里使用 i ,名称长短应与其作用域大小相对应。


别给名称添加不必要的语境。 对于 Address 类的实体来说,AccountAddress 和 CustomerAddress 都是不错的名称,不过用在类名上就不太好了。


遵循专业的术语。如果名词无法用英文表达,一定要用中文拼音,则不能用拼音缩写。

命名我往往会修改好几次才会定下名字来,借助 IDE 重命名的代价极低,所以当你遇到不合理的命名时,不要畏惧麻烦,直接修改吧!

1.技巧二:保持函数短小、少的入参、同一抽象层级

我们都知道函数要尽量短小,职责要单一。但是你有没有忽视过其他的问题?
来看下这段糟糕的示例代码:

private String url;

private void startDownload(boolean isFormat, String userChannel, String userLevel, String regSource) {

if (isFormat) {

  1. **url **= String.format(URL, userChannel, userLevel, regSource);
  2. **if **(**url**.length() != 0) {
  3. service.getData(**url**)
  4. .onSuccess(**new **Action() {
  5. Toast.showToast(context, **"success"**).show();
  6. }).onError(**new **Action() {
  7. Toast.showToast(context, **"error"**).show();
  8. });<br /> }<br /> } **else **{
  9. // ...

}

}
这段代码至少犯了 4 个错误:

用了标识参数 isFormat,这样方法签名立刻变得复杂起来,也表示了函数不止做一件事情,这应该把函数一分为二。


使用了多元参数。如果函数需要两个、三个或三个以上的参数,就说明其中一些参数应该封装成类了。


使用了输出参数 url,输出参数比复杂的输入参数还要难以理解。读函数时,我们惯于认为信息通过参数输入函数,通过返回值从函数中输出。我们不期望信息通过参数输出,输出参数往往包含着陷阱。如果函数要对输入参数进行转换操作,转换结果就该体现为返回值上。

例: appendFooter(s);这个函数是把 s添加到什么东西后面吗?或者它把什么东西添加到了 s后面?s是输入参数还是输出参数?如果是要给s添加个Footer,最好是这样设计:s.appendFooter(); 。


没有保持同一抽象层级。函数中混杂不同抽象层级,去拼接代码的同时又发起了网络请求,还处理了请求结果,这往往让人迷惑。

思考下,你会怎么重构这段代码?
我们展示重构后的情况:

private void startDownloadWhenNotFormat() {

  // ...

}

private void startDownloadWhenFormat() {

  UserProperty property = createUserProperty();<br />      String url = jointUrl(property);<br />      startDownload(url);<br />   }

private UserProperty createUserProperty(){

  //...

}

private String jointUrl(UserProperty property) {

  **return **String.format(URL

        , property.getUserChannel()<br />            , property.getUserLevel()<br />            , property.getRegSource());<br />   }

private void startDownload(String url) {

  **if **(url.isEmpty()) {

     **return**;

  }<br />      service.getData(url)<br />            .onSuccess(**new **Action() {

           onGetDataSuccess();<br />            }).onError(**new **Action() {

     onGetDataError();<br />      });<br />   }

public class UserProperty {

private String userChannel;

private String userLevel;

private String regSource;

//…

}

阅读这样的代码你会觉得很舒服,代码拥有自顶向下的阅读顺序,主程序就像是一系列 TO 起头的段落,每一段都描述当前抽象层级,并引用位于下一抽象层级的后续 TO 起头段落,呈现出总-分的结构。

2.技巧三:短小、单一权责、内聚的类,暴露操作,隐藏数据细节


类的名称其实就表现了权责,如果无法为某个类命以精确的名称,说明这个类太长了,就应该拆分为几个高内聚的小类。
那么怎么评估类的内聚性?类中的方法和变量互相依赖、互相结合成一个逻辑整体,如果类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。
保持内聚性就会得到许多短小的类,仅仅是将较大的函数切分为小函数,就将导致更多的类出现。想想看一个有许多变量的大函数。你想把该函数中某一小部分拆解成单独的函数。不过,你想要拆出来的代码使用了该函数中声明的 4 个变量。是否必须将这 4 个变量都作为参数传递到新函数中去呢?
完全没必要!只要将 4 个变量提升为类的实体变量,完全无需传递任何变量就能拆解代码了。将函数拆分为小块后,你会发现类也丧失了内聚性,因为堆积了越来越多被少量函数共享的实体变量。
等一下!如果有些函数想要共享某些变量,为什么不让它们拥有自己的类呢?当类的变量越来越多,且变量的无关性越来越大,就拆分它! 所以,将大函数拆为许多小函数,往往也是将类拆分为多个小类的时机。
你以为这就结束了?停止你乱加取值器和赋值器的行为!我们不能暴露变量的数据细节和数据形态,应该以抽象形态表述数据。
著名的得墨忒耳律认为:模块不应了解它所操作对象的内部情形,即每个单元(对象或方法)应当对其他单元只拥有有限的了解,不应该有链式调用。
哈?我们觉得方便的链式调用风格,实际上暴露了其他单元的内部细节??
我认为是要区别情况来对待,链式调用风格比较整洁和有表现力,但是不能随意滥用,举个简单例子:
a.getB().getC().doSomething() 这种链式调用就违反了得墨忒耳定律,如果把a.getB().getC().doSomething() 改成 a.doSomething(),仍然违反了得墨忒耳定律。因为a里面会有b.getC().doSomething(),所以 b 类中还应该有一个doSomething()方法去调用 c 的 doSomething(),a.doSomething()再来调用b.doSomethine(),a对b的具体实现不可知。
链式风格用在 a.method1().method2().method3();这种情况会比较合理。所以能不能用链式,需要看链的是一个类的内部还是不同类的连接。

3.技巧四:分离不同的模块

系统应将初始化过程和初始化之后的运行时逻辑分离开,但我们经常看到初始化的代码被混杂到运行时代码逻辑中。下面就是个典型的例子:

public Service getService() {

if (service == null) {

  service = **new **MyServiceImpl(...);

}
return service;

}

你会自以为很优雅,因为延迟了初始化,在真正用到对象之前,无需操心这种对象的构造,而且也保证永远不会返回 null 值。
然而,就算我们不调用到getService()方法,MyServiceImpl 的依赖也需要导入,以保证顺利编译。 如果MyServiceImpl 是个重型对象,单元测试也会是个问题。我们必须给这些延迟初始化的对象指派恰当的测试替身(TEST DOUBLE) 或仿制对象(MOCK OBJECT)。
我们应当将这个初始化过程从正常的运行时逻辑中分离出来,方法有很多:
1. 交给 init 模块
将全部构造过程移到 init 模块中,设计其他模块时,无需关心对象是否已经构造,默认所有对象都已正确构造。
2. 抽象工厂方法
系统其他模块与如何构建对象的细节是分离开的,它只拥有抽象工厂方法的接口,具体细节是由 init 这边的接口实现类实现的。但其他模块能完全控制实体何时创建,甚至能给构造器传递参数。
3. 依赖注入中的控制反转
对象不负责实例化对自身的依赖,而是把工作移交给容器,实现控制的反转。比如 Android Dagger2 和 JavaEE Spring 都是这方面的实践。
4. Builder 模式
可以简单地把构造和构造的细节分离。
我们拆分了初始化和正常运行时逻辑,还有什么可以继续拆分的呢?
正常运行时逻辑除了业务逻辑,往往还混合了持久化、事务、打印日志、埋点等模块,如果说 OOP 是把问题划分到单个模块的话,那么 AOP 就是把涉及到众多模块的某一类问题进行统一管理。比如按 OOP 思想,设计一个打印日志 LogUtils 类,但是这个类是横跨并嵌入众多模块里的,在各个模块里分散得很厉害,到处都能见到。而利用 AOP 思想,我们无需再去到处调用 LogUtils 了,声明哪些方法需要打印日志,AOP 会在编译时把打印语句插进方法切面。AOP 思想有很多实践:
1. 代理
代理适用于简单的情况,例如在单独的对象或类中包装方法调用。然而,JDK提供的动态代理仅能与接口协同工作。对于代理类,你得使用字节码操作库,比如CGLIB、ASM或Javassist 。
2. AOP 框架
把持久化工作用 AOP 交给容器,使用描述性配置文件或 API 或注解来声明你的意图,驱动依赖注入(DI)容器,DI容器再实体化主要对象,并按需将对象连接起来。
Android中的 AOP 思想、框架选型和具体应用场景可详见: 一文读懂 AOP | 你想要的最全面 AOP 方法探讨
概言之, 最佳的系统架构由模块化的关注面领域组成,每个关注面均用纯 Java 对象实现。不同的领域之间用最不具有侵害性的「方面」或「类方面」工具整合起来。
技巧五:用异常代替错误码,但不传递异常,不传递 null
if (deletePage(page) == SUCCESS),咋看之下好像没什么问题,但是返回错误码,就是在要求调用者立刻处理错误。你马上就会看到这样的场景:


if (deletePage(page) == SUCCESS) {

  mView.onSuccess();<br />      } **else **{

  mView.onError();<br />      }<br />熟悉不?更恶心的是这种情况:<br /> <br />**int code **= deletePage(page);

if (code == CODE_404) {

  mView.onError1();<br />      } **else if**(code == CODE_403){

  mView.onError2();<br />      } **else if**(code == CODE_505){

  mView.onError3();<br />      }<br />当你开始编写错误码时,请注意!这意味着你可能在代码中到处存在 `if(code == CODE)`,其他许多类都得导入和使用这个错误类。当错误类修改时,所有这些其他的类都需要重新编译。 而且,错误码和状态码一样,会引入大量的 if-else 和 switch,随着状态扩展,if 就像面条一样拉长。回忆一下,你是不是用了不同的 code 来区分不同的错误?不同的用户状态?不同的表现场景?<br />所以忠告有 2 点:<br /> <br />使用异常替代返回错误码,将错误处理代码从主路径中分离<br /> <br /> <br />不仅仅分离错误处理代码,还要把 try-catch 代码块的主体部分抽离出来,另外形成函数,函数应该只做一件事。错误处理就是一件事。因此,处理错误的函数不该做其他事。<br /> <br />重构后:

public void delete(Page page) {

try {

  deletePageAndAllReferences(page);<br />   } **catch **(Exception e) {

  logError(e);<br />   }<br />}

private void deletePageAndAllReferences(Page page) throws Exception {

deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}

private void logError(Exception e) {

logger.log(e.getMessage());
}
在上例中,异常使我们把正常代码和错误代码隔离开来,但是我不建议你滥用异常,思考一下,如果你在低层的某个方法中抛出异常,而把 catch 放在高级层级,你就得在 catch 语句和抛出异常处之间的每个方法签名中声明该异常。每个调用这个函数的函数都要修改,捕获新异常,或在其签名中添加合适的throw子句。以此类推,最终得到的就是一个从最底端贯穿到最高端的修改链。封装完全被打破了,在抛出路径中的每个函数都要去了解下一层级的异常细节。
所以不要传递异常,在合适的地方,及时解决它!
还有另一种情况你经常看到:


UserData userData = service.getUserData(url);

if (userData != null) {

if (userData.getUserName != null && userData.getUserName.length > 0) {

userNameTextView.setText(userData.getUserName);
} else {

userNameTextView.setText(“—“);

}
if (userData.getRegChannel != null && userData.getRegChannel.length > 0) {

regChannelTextView.setText(userData.getRegChannel);
} else {

regChannelTextView.setText(“WHAN”);

}
}
真是可怕!到处都是判空和特殊操作!如果你打算在方法中返回 null 值,不如抛出异常,或是返回空对象或特例对象。你可以学习Collections.emptyList( )的实现,创建一个类,把异常行为封装到特例对象中。
对付返回 null 的第三方 API 也是如此,我们可以用新方法包装这个 API,从而干掉判空。

4.技巧六:保持边界整洁,掌控第三方代码

我们经常会使用第三方开源库,怎么将外来代码干净利落地整合进自己的代码中。是每个工程师需要掌握的技巧,我们希望每次替换库变得简单容易,所以首先要缩小库的引用范围!怎么缩小?

封装:不直接调用第三方api,而是包装多一层,从而控制第三方代码的边界,业务代码只知道包装层,不关心工具类的具体实现细节。在你测试自己的代码时,打包也有助于模拟第三方调用。 打包的好处还在于你不必绑死在某个特定厂商的API 设计上。你可以定义自己感觉舒服的API。


使用 ADAPTER 模式

代码整洁之道还提出个有意思的做法,为第三方代码编写学习性测试。
我们可以编写测试来遍览和理解第三方代码。在编写学习性测试中,我们通过核对试验来检测自己对 API 的理解程度。测试帮助我们聚焦于我们想从 API 得到的东西。
当第三方开源库发布了新版本,我们可以运行学习性测试,马上看到:程序包的行为有没有改变?是否与我们的需要兼容?是否影响了旧功能?

5.技巧七:保持良好的垂直格式和水平格式

垂直格式上
最顶部展示高层次的概念和算法,细节往下渐次展开,越是细节和底层,就应该放在源文件的越底部。
紧密相关或相似的代码应该互相靠近,调用者应该尽可能放在被调用者的上面,实体变量要靠近调用处,相关性弱的代码用空行隔开。
水平格式上
代码不宜太宽,避免左右拖动滚动条的差劲体验。
用空格字符把相关性较弱的事物分隔开。
遵守缩进规则。

6.技巧八:为代码添加必要的注释,维护注释

请注意,我说的是必要的注释,只有当代码无法自解释时,才需要注释。
好的代码可以实现自文档,自注释,只有差的代码才需要到处都注释。
如果你开始写注释了,就要思考下:是否代码有模糊不清的地方?命名是否有表达力?是否准确合理?函数是否职责过重,做了太多事情,所以你必须为这个函数写长长的注释?如果是这样,你应该重构代码,而不是写自认为对维护有帮助的注释。很多情况下只需要改下命名、拆分函数,就可以免去注释。
不要以为写完注释就完了,注释和代码一样,需要维护。

7.如何规避重构的风险?

在写代码之前,强烈建议你先完成单元测试,然后一边实现功能一边调整单测覆盖场景。
实现功能时,代码一开始都冗长而复杂,完成功能后,通过单元测试和验收测试,我们可以放心地重构代码,每改动一小块,就及时运行测试,看功能是否被破坏,不断分解函数、选用更好的名称、消除重复、切分关注面,模块化系统性关注面,缩小函数和类的尺寸,同时保持测试通过。

8.如何保持代码的优雅?

只要遵循以下规则,代码就能变得优雅:

编写更多的测试,用测试驱动设计和架构。
测试编写得越多,就越能持续走向编写较易测试的代码,持续走向简单的设计,系统就会越贴近 OOP 低耦合高内聚的目标。没有了测试,你就会失去保证生产代码可扩展的一切要素。正是单元测试让你的代码可扩展、可维护、可复用。原因很简单:有了测试,你就不担心对代码的修改!没有测试,每次修改都可能带来缺陷。无论架构多有扩展性,无论设计划分得有多好,没有了测试,你就很难做改动,因为你担忧改动会引入不可预知的缺陷。

保持重构,当加入新功能的时候,要思考是否合理,是否需要重构这个打开修改的模块。

不要重复,重复代码代表遗漏了抽象,重复代码可能成为函数或干脆抽成另一个类。

保持意图清晰,选用好的命名,短的函数和类,良好的单元测试提高代码的表达力。

尽可能减少类和方法的数量,避免一味死板地遵循以上 4 条原则,从而导致类和方法的膨胀。

9.开始重构,逐步改进

衡量成长比较简便的方法,就是看三个月前,一年前,自己写的代码是不是傻逼,越觉得傻逼就成长越快;或者反过来,看三个月前,一年前的自己,是不是能胜任当下的工作,如果完全没问题那就是没有成长。
既然聊到代码规范和重构技巧,Talk is cheap. Show me the code. 就以自己两年前的代码为例,但当我拿起两年前的项目时……

10.最后

技巧是可以学习掌握的,重点是有意识培养自己的代码感,培养解耦的思想。不要生搬硬套技巧,不要过度设计,选择当下最适合最简单的方案。
同时我们需要不断回顾自己写过的代码,如果觉得无需改动,要么是设计足够优秀,要么就是没有输入,没有成长。
如果你对自己有更高的要求,希望以下的资料可以帮助你。