JunBin

一花一世界,一码一浮生


  • 首页

  • 归档

  • 标签

  • 关于

移动应用遗留系统重构(9)- 路由篇

发表于 2021-05-25 |

前言

上一篇移动应用遗留系统重构(8)- 依赖注入篇 最后我们通过IDE的依赖分析发现,App模块主界面直接依赖了file Bundle的FileFragment,存在直接的编译依赖。

跨模块对Activity或Fragment的依赖是应用内最常见的。但是如果有直接代码上的依赖,我们就无法做到业务模块独立编译调试,后续做动态化也没办法统一管理。本篇我们主要分为3个部分,第一部分是路由的原理,第二部分是业内优秀的路由框架实践,最后我们将继续对CloudDisk中UI跳转进行重构。

路由原理

Android中常用的页面跳转就是通过直接的依赖方式。

1
2
Intent intent = new Intent(MainActivity.this, LoginActivity.class);
startActivity(intent);

但其实Intent还有另外一个API支持使用类名进行隐式跳转。

1
2
3
Intent intent = new Intent();
intent.setClassName(this,"com.cloud.disk.platform.login.LoginActivity");
startActivity(intent);

这种方式就不会存在编译的问题。但当整个应用内的页面跳转量很大时,我们就很难全局进行统一维护。并且很多场景需要动态推送页面跳转,我们需要统一管理所有页面的地址,这个时候我们就需要有统一的方案进行路由管理。

那么如何进行统一的管理呢?其实一个很自然的思路就是建立一个统一的映射,例如:

1
uri://user/login -> com.cloud.disk.platform.login.LoginActivity

然后通过一个统一的方式进行管理,这就是所谓的路由表。当应用进行跳转时,输入虚拟的地址,经过路由表进行查询得到实际的地址,然后就可进行跳转。并且有了这一层转换,我们就可以做很多扩展,例如降级、拦截等等

下面就让我们一起来看看一些业内的优秀实践。

业内优秀实践

ARouter

ARouter主要采用的也是路由表的方式,具体的使用和原理,网上有很多资料。这里主要列出官网上介绍的一些主要的功能。

  • 支持直接解析标准URL进行跳转,并自动注入参数到目标页面中
  • 支持多模块工程使用
  • 支持添加多个拦截器,自定义拦截顺序
  • 支持依赖注入,可单独作为依赖注入框架使用
  • 映射关系按组分类、多级管理,按需初始化
    支持用户指定全局降级与局部降级策略
    页面、拦截器、服务等组件均自动注册到框架
    支持多种方式配置转场动画
  • 支持获取Fragment
  • 完全支持Kotlin以及混编
  • 支持第三方 App 加固(使用 arouter-register 实现自动注册)
  • 支持生成路由文档
  • 提供 IDE 插件便捷的关联路径和目标类

更多详细的介绍和使用说明,可以参考Github上的介绍

这里我们从Github上的介绍发现,同样采用了注解和Gradle插件在编译时生成文件,但ARouter并没有像Hilt那样有完善的测试套件支持,所以如果使用Robolectric在JVM上进行测试会有影响。

DeepLinkDispatch

DeepLinkDispatch是airbnb开源的一个路由框架,原理也是采用路由表的方式。

提供声明性的、基于注释的API来定义应用程序深度链接。
可以注册一个Activity来处理特定的深度链接,方法是使用@DeepLink和URI对其进行注释。DeepLinkDispatch将解析URI并将深度链接与URI中指定的任何参数一起发送到适当的Activity。

相比之下,功能没有ARouter强大,且国内的社区活跃度没有ARouter高,具体的使用方式可以参考官方的介绍

CloudDisk路由重构示例

经过对比,我们决定使用功能相对强大且社区活跃度高的ARouter,对CloudDisk进行改造。具体的完整代码示例Github。这里我们贴出前后代码使用的比较。

改造前:

1
fragments.add(FileFragment.newInstance());

改造后:

1
2
3
4
5
6
//声明
@Route(path = "/bundle/file")
public class FileFragment extends Fragment

//调用
fragments.add((Fragment) ARouter.getInstance().build("/bundle/file").navigation());

但当我们运行冒烟测试的时候发现出现空异常,如下

ARouter的navigation并不能找到实例,上面我们有提到ARouter同样采用了注解和Gradle插件在编译时生成文件,但ARouter并没有像Hilt那样有完善的测试套件支持,在JVM上进行测试会有影响。这里我们采用的方案是Shadow,将实际ARouter的跳转Mock掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Implements(Postcard.class)
public class ShadowPostCard {

@RealObject
public Postcard postcard;

@Implementation
public Object navigation() {
if ("/bundle/file".equals(postcard.getPath())) {
try {
return Class.forName("com.cloud.disk.bundle.file.FileFragment").newInstance();
} catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
e.printStackTrace();
}
}
return null;
}
}

我们还可以考虑把测试用例放入androidTest,使用真机运行测试。但为了得到更快的反馈速度,我们决定先沿用shadow的方案。

总结

使用路由除了能解耦开编译时的依赖,统一了路由地址也能更好的满足应用的跳转场景。目前CloudDisk已经解耦了lib和file bundle 2个模块,并且基础的注入和路由也已经有了,下一篇单体移动应用“模块化”演进之旅(10)- 解耦重构演示篇(二)我们将继续分享对platform、user、dynamic进行依赖解除重构,将会分享更多的实战解耦手法。

CloudDisk示例代码

CloudDisk

系列链接

移动应用遗留系统重构(1)- 开篇

移动应用遗留系统重构(2)-架构篇

移动应用遗留系统重构(3)-示例篇

移动应用遗留系统重构(4)-分析篇

移动应用遗留系统重构(5)- 重构方法篇

移动应用遗留系统重构(6)- 测试篇

移动应用遗留系统重构(7)- 解耦重构演示篇(一)+视频演示

移动应用遗留系统重构(8)- 依赖注入篇

大纲

关于

欢迎关注CAC敏捷教练公众号。微信搜索:CAC敏捷教练。

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin

移动应用遗留系统重构(8)- 依赖注入篇

发表于 2021-05-17 |

前言

上一篇移动应用遗留系统重构(7)- 解耦重构演示篇(一)我们对file包进行了重构,抽取了对应的UserState接口,但我们发现UserState接口的层层传递,我们需要手工维护好多的构造方法及对应的注入,这样非常不便于进行代码的管理及维护。同时随着解耦的接口越来越多,就会产生更多的样板代码,所以我们需要更好的方式进行统一的管理。

这篇我们主要分为3个部分,第一部分是常见依赖注入方式,第二部分是业内优秀的依赖注入实践,最后我们将继续对CloudDisk进行依赖注入的重构。

依赖注入方式

静态注入(在编译时连接依赖项的代码)

静态注入是最常用的方式,在类中依赖抽象,暴露接缝,在调用的地方进行实现的注入。常用的注入方式有2种。

  1. 构造函数注入。您将某个类的依赖项传入其构造函数(上一篇的CloudDisk就是采用这种方式)。

  2. 字段注入(或 setter 注入)。某些 Android 框架类(如 Activity 和 Fragment)由系统实例化,因此无法进行构造函数注入。使用字段注入时,依赖项将在创建类后实例化

由于是编译时连接依赖项,所以编辑阶段会进行类型的检查。

动态注入 (在运行时连接依赖项)

动态注入最常见的方式就是通过反射的机制,在运行时进行注入。

由于是运行时连接依赖项,所以编译阶段没有检查,且有反射带来的性能问题。但好处是灵活,如果有模块是动态加载的,利用这种方式处理起来更简单。

对比

静态注入 动态注入
类型安全,编译时检查 运行时绑定,编译时没有依赖
性能无损失 有反射带来的性能损失
较适合整包编译 较适合模块动态加载场景
开源实现多 开源实现较少

业内优秀实践

前面提到适用手工的方式进行依赖注入的管理,是一项非常困难和有挑战的事情。所幸,业内已经有成熟的解决方案。这一章我们来看下业内优秀的实践。

hilt

Hilt 采用的是静态注入的方式,在依赖项注入库Dagger 的基础上构建而成,提供了一种将 Dagger 纳入 Android 应用的标准方法。

详细的介绍及使用大家可以查看官方的介绍说明

https://developer.android.com/training/dependency-injection/hilt-android

这里我们重点分享几点这个库的一些优点。

  1. 相比Dragger,使用其实更简单
  2. IDE支持链接跳转,开发体验好
  3. 完整的测试套件,方便进行自动化测试编写
  4. Jetpack生态组件,生态链完整,社区活跃度搞

koin

Koin 是一个用于 Kotlin 的实用型轻量级依赖注入框架,采用纯 Kotlin 编写而成,仅使用功能解析,无代理、无代码生成、无反射。

详细的介绍及使用同样大家可以查看官方的介绍说明

https://insert-koin.io/docs/quickstart/kotlin

这里我们同样分享几点这个库的一些优点。

  1. 使用简单,没有复杂的注解
  2. 更轻量,相比hilt编译时间更快、生成代码更少

当然,由于koin本身是用kotlin语言编写的,所以最好项目也是使用kotlin编写。相比hilt,没有那么细的生命周期管理以及IDE的支持,并且也没有Jetpack的生态丰富。

CloudDisk依赖注入重构示例

前面我们分享了业内一些优秀的依赖注入实践,由于CloudDisk是采用java语言开发,且考虑后续会往JetPack生态迁移,所以决定采用Hilt的方案。

具体的Hilt改造过程我们就不演示,参考官网的文档进行操作则可。这里我们对比一下改造前后的代码片段。

具体的代码:github链接

使用手工注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class FileFragment extends Fragment {

FileController fileController;

public FileFragment(UserState userState) {
fileController = new FileController(userState);
}

public static FileFragment newInstance(UserState userState) {
FileFragment fragment = new FileFragment(userState);
Bundle args = new Bundle();
fragment.setArguments(args);
return fragment;
}
}

使用Hilt注入:

1
2
3
4
5
6
7
8
9
10
11
12
@AndroidEntryPoint
public class FileFragment extends Fragment {

@Inject
FileController fileController;

public static FileFragment newInstance() {
FileFragment fragment = new FileFragment();
Bundle args = new Bundle();
fragment.setArguments(args);
return fragment;
}

Hilt本质上通过注解及gradle插件,在编译时生成代码及注入到我们的类中。使用框架帮我们减少了很多模板代码,统一配置管理。

这里注意,测试代码也需要做相应的配置。

1
2
3
4
5
6
7
8
@RunWith(AndroidJUnit4.class)
@LargeTest
@HiltAndroidTest
@Config(application = HiltTestApplication.class)
public class SmokeTesting {
@Rule
public HiltAndroidRule hiltRule = new HiltAndroidRule(this);
}

总结

本章我们介绍了常见的依赖注入方式及业内优秀的实践,同时我们也将CloudDisk进行了改造,使用Hilt统一管理注入。通过IDE的依赖分析我们可以发现,App依赖了fileBundle的Fragment,UI上存在编译的依赖。

下一篇,移动应用遗留系统重构(9)- 路由篇,我们将分享常见的页面路由方式及业内优秀的实践,并对DiskCloud继续进行改造优化。

参考资料

Android 中的依赖项注入

CloudDisk示例代码

CloudDisk

系列链接

移动应用遗留系统重构(1)- 开篇

移动应用遗留系统重构(2)-架构篇

移动应用遗留系统重构(3)-示例篇

移动应用遗留系统重构(4)-分析篇

移动应用遗留系统重构(5)- 重构方法篇

移动应用遗留系统重构(6)- 测试篇

移动应用遗留系统重构(7)- 解耦重构演示篇(一)+视频演示

大纲

关于

欢迎关注CAC敏捷教练公众号。微信搜索:CAC敏捷教练。

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin

移动应用遗留系统重构(7)- 解耦重构演示篇(一)+视频演示

发表于 2021-05-07 |

前言

移动应用遗留系统重构(5)- 重构方法篇中我们分享了重构流程,主要为4个操作步骤。

  1. 识别一个内聚的包
  2. 解除该包的异常依赖
  3. 移动该包对应的代码及资源到新的模块
  4. 包解耦验收

移动应用遗留系统重构(6)- 测试篇中我们为CloudDisk补充了一组基本的冒烟测试。有了基本的测试守护后,本篇我们将挑选library(基础组件库)及file(文件业务模块)2个包进行重构演示。文章中包含操作的流程,同时了录制了视频。

library重构代码演示视频:https://mp.weixin.qq.com/s/C0nQRbgmp1cLhwoWzGpqFw

file重构代码演示视频:https://mp.weixin.qq.com/s/xbAIu6bWS7pLLAgHe-a8Bg

安全重构演示

library包重构

  1. 依赖分析

通过分析我们发现library包存在对上层模块的反向依赖,该依赖为异常依赖,须要解除。

  1. 安全重构

重构前代码:

1
2
3
4
5
6
7
8
9
10
public class HttpUtils {
public static void post(String url) {
//发送http post请求,需要用到userId做标识
String params = UserController.userId;
}
public static void get(String url) {
//发送http get请求,需要用到userId做标识
String params = UserController.userId;
}
}

重构手法:提取方法参数

重构后代码:

1
2
3
4
5
6
7
8
9
10
public class HttpUtils {
public static void post(String url, String userId) {
//发送http post请求,需要用到userId做标识
String params = userId;
}
public static void get(String url, String userId) {
//发送http get请求,需要用到userId做标识
String params = userId;
}
}

  1. 代码移动

代码移动至独立的library,加上对应的Gradle依赖:

移动后模块结构如下:

  1. 功能验证

执行冒烟测试,验证功能

1
./gradlew app:testDebug --tests SmokeTesting

代码演示:https://mp.weixin.qq.com/s/C0nQRbgmp1cLhwoWzGpqFw

具体的代码:github链接

file包重构

  1. 依赖分析

通过分析我们发现file包存在横向bundle模块的依赖,该依赖为异常依赖,须要解除。

  1. 安全重构
    重构前代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public class FileController {
    public List<FileInfo> getFileList() {
    return new ArrayList<>();
    }

    public FileInfo upload(String path) {
    //上传文件
    LogUtils.log("upload file");
    HttpUtils.post("http://file", UserController.userId);
    return new FileInfo();
    }

    public FileInfo download(String url) {
    //下载文件
    if (!UserController.isLogin) {
    return null;
    }
    return new FileInfo();
    }
    }

重构手法:

2.1 抽取getUserId、isLogin方法

重构后代码如下:

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
public class FileController {
public List<FileInfo> getFileList() {
return new ArrayList<>();
}

public FileInfo upload(String path) {
//上传文件
LogUtils.log("upload file");
HttpUtils.post("http://file", getUserId());
return new FileInfo();
}

public FileInfo download(String url) {
//下载文件
if (!isLogin()) {
return null;
}
return new FileInfo();
}

private String getUserId() {
return UserController.userId;
}

private boolean isLogin() {
return UserController.isLogin;
}
}

2.2 抽取代理类,UserState

重构后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class FileController {
private final UserState userState = new UserState();

public List<FileInfo> getFileList() {
return new ArrayList<>();
}

public FileInfo upload(String path) {
//上传文件
LogUtils.log("upload file");
HttpUtils.post("http://file", userState.getUserId());
return new FileInfo();
}

public FileInfo download(String url) {
//下载文件
if (!userState.isLogin()) {
return null;
}
return new FileInfo();
}
}

2.3 抽取接口

重构后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class FileController {
private final UserState userState = new UserStateImpl();

public List<FileInfo> getFileList() {
return new ArrayList<>();
}

public FileInfo upload(String path) {
//上传文件
LogUtils.log("upload file");
HttpUtils.post("http://file", userState.getUserId());
return new FileInfo();
}

public FileInfo download(String url) {
//下载文件
if (!userState.isLogin()) {
return null;
}
return new FileInfo();
}
}

2.4 提取构造函数,依赖接口注入

重构后代码如下:

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
public class FileController {
private final UserState userState;

public FileController(UserState userState) {
this.userState = userState;
}

public List<FileInfo> getFileList() {
return new ArrayList<>();
}

public FileInfo upload(String path) {
//上传文件
LogUtils.log("upload file");
HttpUtils.post("http://file", userState.getUserId());
return new FileInfo();
}

public FileInfo download(String url) {
//下载文件
if (!userState.isLogin()) {
return null;
}
return new FileInfo();
}
}

同样FileFragment我也也做相同的构造及接口注入。

  1. 代码移动

同样使用moduraize进行移动,代码移动至独立的fileBundle,加上对应的Gradle依赖:

移动后模块结构如下:

  1. 功能验证
    执行冒烟测试,验证功能
1
./gradlew app:testDebug --tests SmokeTesting

每一小步重构时都可以频繁运行测试,提前发现问题

代码演示:https://mp.weixin.qq.com/s/xbAIu6bWS7pLLAgHe-a8Bg

具体的代码:github链接

总结

本篇我们按着重构的4个步骤,借助IDE的安全重构,小步的重构了library和file2个包。虽然依赖解除了,代码也移动到独立的模块里面。但是我们还是发现了一些问题。

  1. UserState接口的层层注入,我们需要手工维护了好多构造方法及对应的注入
  2. App依赖了fileBundle的Fragment,UI跳转上存在编译的依赖

下一篇,单体移动应用“模块化”演进之旅(8)- 依赖注入篇,我们将分享常见的注入方式及业内优秀的实践,并对DiskCloud继续进行改造优化。

CloudDisk示例代码

CloudDisk

系列链接

移动应用遗留系统重构(1)- 开篇

移动应用遗留系统重构(2)-架构篇

移动应用遗留系统重构(3)-示例篇

移动应用遗留系统重构(4)-分析篇

移动应用遗留系统重构(5)- 重构方法篇

移动应用遗留系统重构(6)- 测试篇

大纲

关于

欢迎关注CAC敏捷教练公众号。微信搜索:CAC敏捷教练。

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin

移动应用遗留系统重构(6)- 测试篇

发表于 2021-04-24 |

前言

上一篇移动应用遗留系统重构(5)- 重构方法篇我们分享了进行依赖解除的重构流程。主要为4个操作步骤,识别内聚包、解除依赖、移动、验收。同时最后也提出了一个问题,重构时如何保证功能的正确性,不会修改出新问题?

其实这个问题容易但又不简单。容易的是把修改得功能仔细测一篇保证所有功能正常就可以了。不简单的是如何全面、高效、可重复的执行这个过程。我们很容易联想到的方案就是自动化测试。但最大的问题是,对大部分遗留系统来说都是没有任何自动化测试。而且大量的坏味道代码,可测试性低,我们也很难补充充分的自动化测试。那么我们有什么折中的策略吗?

测试策略

我们先来看看Google Android开发者官网上对于测试的介绍,将不同的类型的测试分为三类测试(即小型、中型和大型测试)。

图片来源developer.android.com

  • 小型测试是指单元测试,用于验证应用的行为,一次验证一个类。
  • 中型测试是指集成测试,用于验证模块内堆栈级别之间的互动或相关模块之间的互动。
  • 大型测试是指端到端测试,用于验证跨越了应用的多个模块的用户操作流程。

前面提到对于遗留单体系统来说通常没有任何自动化测试,并且通常内部结构耦合严重,所以实施中小型的成本非常高。显然显然对于遗留系统,测试金字塔模型适用度较低。 所以对于遗留系统,可能比较适合的策略模型如下:

对于遗留单体系统,一个可行的思路是先补充中大型的测试,作为基本的冒烟测试,重构优化内部结构后再及时补充中小型测试。

CloudDisk示例

对于我们这个浓缩版的CloudDisk,界面上也比较简单。主要是有一个主界面,主界面上主要为文件、动态、用户。(后续的MV*重构篇会持续补充页面交互及逻辑)


我们可以设计一组UI的测试验证基本的功能。主要的几个测试点如下:

  1. 主界面能正常运行并显示3个Fragment
  2. 3个Fragment能正常显示
  3. 点击登录按钮,能够跳转到登录页面

测试设计的用例如下:

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
@RunWith(AndroidJUnit4.class)
@LargeTest
public class SmokeTesting {

@Test
public void should_show_fragment_list_when_activity_launch() {
//given
ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class);
scenario.onActivity(activity -> {
//when
onView(withText(R.string.tab_user)).perform(click());
//then
List<Fragment> fragments = activity.getSupportFragmentManager().getFragments();
assertThat(fragments.size() == 3);
assertThat(fragments.get(0) instanceof FileFragment);
assertThat(fragments.get(1) instanceof DynamicFragment);
assertThat(fragments.get(2) instanceof UserCenterFragment);
});
}

@Test
public void show_show_file_ui_when_click_tab_file() {
//given
ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class);
scenario.onActivity(activity -> {
//when
onView(withText(R.string.tab_file)).perform(click());
//then
onView(withText("Hello file fragment")).check(matches(isDisplayed()));
});
}

@Test
public void show_show_dynamic_ui_when_click_tab_dynamic() {
//given
ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class);
scenario.onActivity(activity -> {
//when
onView(withText(R.string.tab_dynamic)).perform(click());
//then
onView(withText("Hello dynamic fragment")).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)));
});
}

@Test
public void show_show_user_center_ui_when_click_tab_dynamic() {
//given
ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class);
scenario.onActivity(activity -> {
//when
onView(withText(R.string.tab_user)).perform(click());
//then
onView(withText("Hello user center fragment")).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)));
});
}

@Test
public void show_show_login_ui_when_click_login_button() {
//given
ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class);
scenario.onActivity(activity -> {
Intents.init();
//when
onView(withId(R.id.fab)).perform(click());
//then
intended(IntentMatchers.hasComponent("com.cloud.disk.platform.login.LoginActivity"));
Intents.release();
});
}
}

详细代码见Github提交

我们可以将用例运行在Robolectric上,提高反馈的速度,执行命令如下:

1
./gradlew testDebug --tests SmokeTesting

测试执行结果如下:

当然实际的项目里情况更复杂,数据可能来自网络服务、数据库等等。我们还需要进行Mock。后续的MV*重构篇会持续补充常见坏味道示例代码及更多的自动化测试用例。

更多测试框架及设计可以参考Google官方
在 Android 平台上测试应用

总结

这一篇我们介绍了常用的测试分类及遗留系统的测试策略,对于遗留单体系统,一个可行的思路是先补充中大型的测试,作为基本的冒烟测试,重构优化内部结构后再及时补充中小型测试。同时也给CloudDisk补充了一组基础的大型测试作为冒烟测试,作为后续重构的基本守护测试。

下一篇移动应用遗留系统重构(7)- 解耦重构演示篇(一) 我们将基于方法篇的流程开始对CloudDisk进行重构的改造,具体的解耦操作会以视频的方式展示。

参考资料

developer.android.com

CloudDisk示例代码

CloudDisk

系列链接

移动应用遗留系统重构(1)- 开篇

移动应用遗留系统重构(2)-架构篇

移动应用遗留系统重构(3)-示例篇

移动应用遗留系统重构(4)-分析篇

移动应用遗留系统重构(5)- 重构方法篇

大纲

关于

欢迎关注CAC敏捷教练公众号。微信搜索:CAC敏捷教练。

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin

移动应用遗留系统重构(5)- 重构方法篇

发表于 2021-04-18 |

前言

上一篇移动应用遗留系统重构(4)-分析篇我们根据CloudDisk未来的架构,借助ArchUnit进行架构测试守护以及Intellij的Dependendencies分析出了按照未来的架构设计需要解决的异常依赖。

这一篇开始我们将分享进行依赖解除的重构流程、方法以及常用的工具使用。

重构流程

1
2
3
4
5
6
7
1.识别一个内聚的包

2.解除该包的异常依赖

3.移动该包对应的代码及资源到新的模块

4.包解耦验收

1.识别内聚的包

对于移动应用通常我们可以通过产品的业务划分进行领域的识别划分。例如CloudDisk这个产品的相对还是比较清晰,业务上主要分为文件、动态及个人中心。

对于部分遗留系统来说,旧代码可能散落在不同的包下,或者原先的代码组织方式是以功能划分,而非业务划分。就像CloudDisk的代码一样,第一步我们得先把相关的业务代码组织到同一包下,这个阶段我们可以先不管是否存在异常依赖,因为只有先组织到一个内聚的包下才方便我们进行依赖分析及代码重构。

2.解除异常依赖

这里我们将介绍几种通用的依赖解除手法。包含下沉、接口提取、路由跳转。

后续的演示篇会通过视频进行具体的操作演示

依赖解除手法 使用场景
下沉 原本类功能属于Library或者Platform的,直接下沉。例如LogUtil 或 DateUtil等
接口提取 适用于Bundle间有数据或者行为依赖。例如某个BundleA中的classA需要触发BundleB的某个业务行为
路由跳转 适用于UI页面间跳转。例如某个BundleA中的Activity1,需要跳转到BundleB的Activity2

重构手法:

  1. 类下沉
  • 将具体类移动到适当的 Lib 模块中
  • 在调用模块增加对该 Lib 的依赖
  1. 接口提取
    • 在适当的公用模块中创建空的接口
    • 将调用具体页面类的跳转代码块所在的包中建立新的实现类实现该接口
  • (自动)将调用代码块通过 Extract method 提取成新方法

    如已经是独立方法跳过此步

  • (自动)在原调用逻辑所属的类中增加实现类的成员变量作为delegate

    需要预留 Inject 接口,建议采用 Constructor Inject,静态成员提供setter

  • (自动)将新方法调用转移到delegate中

    如果是静态方法先通过 Change Method Signature 将 delegate 作为参数传给该方法

  • (自动)将新方法 Pull up 到接口中

  • (自动)将实现类移动到壳程序中
  • 在壳程序中实现实现类的Inject
  1. 路由跳转
  • 在跳转类定义对应的映射Path
  • 在调用处使用对应的path进行跳转

3.移动代码及资源

当包的异常依赖全部解耦完后,就可以直接进行移动了。这里我们分享2中常用的代码移动方式。

  1. Move

这种方式大家应该比较常用,选择一个File或者Directory,按下F6选择希望移动后的目录则可。

但是这种方式会存在一个问题,就是被移动的类如果依赖了其他的类或者资源,移动后会出现依赖异常。

适用场景:移动的File或Directory没有其他的依赖

  1. Modularize

Modularize能够分析出移动的File存在的相关依赖,并一起关联移动,很好解决Move的痛点,非常适用于跨Module的移动。

选择移动的Module后点击Preview。

这里注意,有一些划线的文件,那是因为这个文件同时被多处引用,如果跟随一起移动,那么其他的地方会报错。所以我们需要将划线的文件先移动至公用的合适位置。待Preview没有任何的文件划线时,就可以进行移动。

4.包解耦验收

  • 所有模块编译通过
  • 所有新增模块符合模块依赖规则
  • 通过架构守护测试

总结

这一篇我们分享了进行依赖解除的重构流程,主要为4个操作步骤,识别内聚包、解除依赖、移动、验收。同时也介绍了Intellij中非常好用的Modularize功能。接下来我们就可以开始动手进行代码重构,但此时我们又面临着另外一个问题,也是很多同学在做重构时经常担心的一个问题。重构时如何保证功能的正确性,不会修改出新问题。

下一篇移动应用遗留系统重构(6)- 测试篇,我们将分享对于单体移动应用遗留系统,如何制定测试策略及有效补充自动化测试,更好为重构保驾护航。

系列链接

移动应用遗留系统重构(1)- 开篇

移动应用遗留系统重构(2)-架构篇

移动应用遗留系统重构(3)-示例篇

移动应用遗留系统重构(4)-分析篇

大纲

关于

欢迎关注CAC敏捷教练公众号。微信搜索:CAC敏捷教练。

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin

移动应用遗留系统重构(4)-分析篇

发表于 2021-04-12 |

前言

上一篇移动应用遗留系统重构(3)-示例篇我们介绍了CloudDisk的业务及代码现状。分享了“理想”(未来的架构设计)与“现实”(目前的代码现状),接下来在我们开始动手进行重构时,我们首先得知道往理想的设计架构演化,中间存在多少问题。一方面作为开始重构的输入,另外一方面我们有数据指标,也能更好评估工作量及衡量进度。

接下来我们将根据架构篇团队采用的架构设计,结合目前的代码,总结分析工具及方法。

架构设计

我们先回忆一下架构篇里团队采用的架构设计。

  1. 代码复用
  • 公共能力复用,有一层专门统一管理应用公用的基础能力,如图片、网络、存储能力、安全等
  • 公用业务能力复用,有一层专门统一管理应用的业务通用组件,如分享、推送、登录等
  1. 低耦合
  • 业务模块间通过API方式依赖,不依赖具体的模块实现
  • 依赖方向清晰,上层模块依赖下层模块
  1. 并行研发
  • 业务模块支持独立编译调试
  • 业务模块独立发布

结合该4层架构、已有的代码,以及业务的后续演化,团队设计的新架构如下

分析工具

ArchUnit

有了架构设计后,我们就能识别代码的边界,这里我们可以通过Archunit进行边界约束描述。我们可以得到2条通用的守护规则。

  1. 垂直方向,下层模块不能反向依赖上层
  2. 横向方向,组件之间不能存在相互的依赖

转化为ArchUnit的测试用例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@ArchTest
public static final ArchRule architecture_layer_should_has_right_dependency =layeredArchitecture()
.layer("Library").definedBy("..cloud.disk.library..")
.layer("PlatForm").definedBy("..cloud.disk.platform..")
.layer("FileBundle").definedBy("..cloud.disk.bundle.file..")
.layer("DynamicBundle").definedBy("..cloud.disk.bundle.dynamic..")
.layer("UserBundle").definedBy("..cloud.disk.bundle.user..")
.layer("AllBundle").definedBy("..cloud.disk.bundle..")
.layer("App").definedBy("..cloud.disk.app..")
.whereLayer("App").mayOnlyBeAccessedByLayers()
.whereLayer("FileBundle").mayOnlyBeAccessedByLayers("App")
.whereLayer("DynamicBundle").mayOnlyBeAccessedByLayers("App")
.whereLayer("UserBundle").mayOnlyBeAccessedByLayers("App")
.whereLayer("PlatForm").mayOnlyBeAccessedByLayers("App","AllBundle")
.whereLayer("Library").mayOnlyBeAccessedByLayers("App","AllBundle","PlatForm");

当然这个用例的执行是失败的,因为我们基本的包结构还没有调整。但有了架构守护测试用例,我们就可以逐步把代码移动到对应的Package中,直到守护用例运行通过为止。

接下来我们先运用IDE工具进行基础的包结构调整,调整后的结构如下

调整后运行ArchUnit测试运行结果如下

这些异常的提示就是我们需要处理的异常依赖。但是ArchUnit的这个提示比较不不友好,接下来我们介绍另外一种分析异常依赖的方式,使用Intellij Dependencies 。

Intellij Dependencies

我们选择对应的Package,选择Analyze菜单,点击Dependencies,可以找出该Package所依赖的相关类。

我们选择dynamic Package进行分析后,发现根据现有的架构约束,存在横向的Bundle依赖需要进行解除依赖。

我是在实际重构过程中,我们可以频繁借助该功能验证耦合解除情况,并且同时通过ArchUnit测试做好守护。

详细代码见Cloud Disk

总结

这一篇我们分享了如何借助工具进行异常依赖的分析。当我们有了未来的架构设计后,可以借助ArchUnit进行架构测试守护,通过Intellij的Dependendencies 我们可以方便以Package或者Class为单位进行依赖分析。

当我们已经分析出需要处理的异常依赖,接下来我们就可以逐步进行重构。下一篇,我们将给大家分享实践总结的一些重构套路,移动应用遗留系统重构(5)- 重构方法篇。

系列链接

移动应用遗留系统重构(1)- 开篇

移动应用遗留系统重构(2)-架构篇

移动应用遗留系统重构(3)-示例篇

大纲

关于

欢迎关注CAC敏捷教练公众号。微信搜索:CAC敏捷教练。

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin

移动应用遗留系统重构(3)-示例篇

发表于 2021-04-06 |

前言

上一篇移动应用遗留系统重构(2)-架构篇我们介绍了业内的优秀架构实践以及CloudDisk团队根据业务情况设计的分层架构。

这一篇我们将介绍一个浓缩版的示例,示例中我们设计了一些常见的异常依赖,后续的重构篇我们也将基于这个示例进行操作演示。为了简化代码及对业务上下文的理解,示例中的部分实现都是空实现,重点体现异常的耦合依赖。

仓库地址:CloudDisk

CloudDisk示例

项目概述

CloudDisk是一个类似于Google Drive的云存储应用。该应用主要拥有3大核心业务模块。

  1. 文件模块:用于管理用户云端文件系统。用户能够上传、下载、浏览文件。
  2. 动态模块:类似微信朋友圈,用于可以在动态上分享信息及文件
  3. 个人中心模块:用于管理用户个人信息

问题说明

该项目已经维护超过10年以上,目前有用开发人员100+。代码在一个大单体模块中,约30w行左右,编译时间5分钟以上。团队目前主要面临几个问题。

  1. 开发效率低,编译时间长,经常出现代码合并冲突
  2. 代码质量差,经常修改出新问题
  3. 市场响应慢,需要对齐各个模块进行整包发布

代码分析

代码在一个Module中,且在一个Git仓中管理。采用”MVC”结构,按功能进行划分Package。

包结构如下:

主要包说明:

包名 功能说明
adapter ViewPager RecycleView等适配器类
callback 接口回调
controller 主要的业务逻辑
model 数据模型
ui Activity、Fragment相关界面
util 公用工具类

主要类说明:

类名 功能说明
MainActivity 应用主界面,用于加载显示各个模块的Fragment
CallBack 网络接口操作回调
DynamicController 动态模块主要业务逻辑,包含发布及获取列表
FileController 文件模块主要业务逻辑,主要包含上传、下载、获取文件列表
UserController 用户模块主要业务逻辑,主要包含登录,获取用户信息
HttpUtils 网络请求,用于发送get及post请求
LogUtils 主要用于进行日志记录

详细源码见CloudDisk

为了简化业务上下文理解,代码都是空实现,只体现模块的异常依赖,后续的MV*重构篇会持续补充常见坏味道示例代码。

总结

CloudDisk在业务最初发展的时候,采用了单一Module及简单“MVC”架构很好的支持了业务的发展,但随着业务的演化及人员膨胀,这样的模式已经很难高效的支持业务及团队的发展。

前面我们已经分享了“理想”(未来的架构设计)与“现实”(目前的代码现状),接下来在我们开始动手进行重构时,我们首先得知道往理想的设计架构演化,中间存在多少问题。一方面作为开始重构的输入,另外一方面我们有数据指标,也能评估工作量及衡量进度。

下一篇,我们将给大家分享移动应用遗留系统重构(4)-分析篇。介绍常用的分析工具及框架,并对CloudDisk团队目前的代码进行分析。

系列链接

移动应用遗留系统重构(1)- 开篇
移动应用遗留系统重构(2)-架构篇

大纲

关于

欢迎关注CAC敏捷教练公众号。微信搜索:CAC敏捷教练。

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin

移动应用遗留系统重构(2)-架构篇

发表于 2021-03-30 |

前言

上一篇移动应用遗留系统重构(1)- 开篇我们分享了移动应用遗留系统常见的问题。那么好的实践或者架构设计是怎样的呢?

这一篇我们将整理业内优秀的移动应用架构设计,包含微信、淘宝、支付宝以及美团外卖。其中的部分产品也经历过遗留系统的重构改造,具有非常好的参考意义。

优秀实践

微信

从微信对外分享的架构演进文章中可知,微信应用其实也是经历了从大单体到模块化的演进。

图片来源 微信Android模块化架构重构实践

我们看下介绍中后续改造后的架构设计。

图片来源 微信Android模块化架构重构实践

设计中提到重构主要3个目标

  • 改变通信方式 (API化)
  • 重新设计模块 (中心化业务代码回归各自业务)
  • 约束代码边界 (pins工程结构,更细粒度管控边界)

我们可以发现重构后架构比原来的单体应用的一些变化。

  1. 业务模块独立编译调试,耦合度低
  2. 代码复用高,有统一公共的组件库及kernel
  3. 模块职责、代码边界清晰,强约束

更多信息可阅读原文,微信Android模块化架构重构实践

手淘

从手机淘宝客户端架构探索实践的分享中介绍到手机淘宝从1.0用单工程编写开始,东西非常简陋;到2.0为索引许多三方库的庞大的单工程;再到3.0打破了单工程开发模式实现业务复用。

图片来源 手机淘宝客户端架构探索实践

淘宝架构主要分为四层,最上层是组件Bundle(业务组件),往下是容器(核心层),中间件Bundle(功能封装),基础库Bundle(底层库)。

文章提到架构演化的一些优点及变化很值得深思。

  1. 业务复用,减少人力
  2. 基础复用,做深做精
  3. 敏捷开发,快速试错

支付宝

在支付宝mPass实践讨论分析一文中,提到支付宝客户端的总体架构图如下。

图片来源 开篇 | 模块化与解耦式开发在蚂蚁金服 mPaaS 深度实践探讨

分享文章中介绍到5层架构设计如下:

  • 最底层是支付宝框架的容器层,包括类加载资源加载和安全模块;

  • 第二层是我们抽离出来的组件层,包括网络库,日志库,缓存库,多媒体库,日志等等,简单说这些是一些通用的能力层;

  • 第三层是我们定制的框架层,这是关键部分,是我们得以实现上千人,上千多个工程共同开发一个 App 的基础。

  • 第四层是基于框架封装出来的业务服务层;

  • 第五层便是具体的业务模块,其中每一个模块都是一个或多个具体的工程;

文章中介绍到关于工程之间的依赖关系的处理比较特别。

在支付宝的架构里,编译参与的部分是和运行期参与的部分是分离的:编译期使用 bundle 的接口包,运行期使用 bundle 包本身。bundle 的接口包是 bundle 包的一部分,即刚才说的 bundle 的代码部分。bundle 的资源包同时打进接口包,在编译期提供给另一个 bundle 引用。

更多信息可阅读原文,开篇 | 模块化与解耦式开发在蚂蚁金服 mPaaS 深度实践探讨

美团

最后看另外一个跨平台技术架构相关的分享,在外卖客户端容器化架构的演进分享中提到了美团外包的整体架构如下。

图片来源 外卖客户端容器化架构的演进

特别的一点是是采用了容器化架构,根据业务场景及PV,支持多种容器技术。在文末的总结提到,容器化架构相对于传统的移动端架构而言,充分地利用了现在的跨端技术,将动态化的能力最大化的赋予业务。通过动态化,带来业务迭代周期缩短、编译的加速、开发效率的提升等好处。同时,也解决了面临着的多端复用、平台能力、平台支撑、单页面多业务团队、业务动态诉求强等业务问题。但对线上的可用性、容器的可用性、支撑业务的线上发布上提出了更加严格的要求。

更多信息可阅读原文,外卖客户端容器化架构的演进

总结

架构是为了解决业务的问题,没有银弹。 但通过这些业内的优秀实践分享,我们可以发现一些优秀的设计范式。

  1. 代码复用
  • 公共能力复用,有专门统一管理应用公用的基础能力,如图片、网络、存储能力、安全等
  • 公用业务能力复用,有专门统一管理应用的业务通用组件,如分享、推送、登录等
  1. 低耦合,高内聚
  • 业务模块间通过API方式依赖,不依赖具体的模块实现
  • 依赖方向清晰,上层模块依赖下层模块
  1. 并行研发
  • 业务模块支持独立编译调试
  • 业务模块独立发布

结合这些特点及CloudDiks团队的业务,团队采用的架构设计如下。

下一篇,移动应用遗留系统重构(3)- 示例篇,我们将继续介绍CloudDisk的业务及团队问题,分析现有的代码。

参考

微信Android模块化架构重构实践

手机淘宝客户端架构探索实践

参考来自阿里云开发者社区,但链接已失效

开篇 | 模块化与解耦式开发在蚂蚁金服 mPaaS 深度实践探讨

外卖客户端容器化架构的演进

系列链接

移动应用遗留系统重构(1)- 开篇

大纲

关于

欢迎关注CAC敏捷教练公众号。微信搜索:CAC敏捷教练。

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin

移动应用遗留系统重构(1)-开篇

发表于 2021-03-25 |

前言

2008年9月22日,谷歌正式对外发布第一款Android手机。苹果公司最早于2007年1月9日的MacWorld大会上公布IOS系统。移动应用领域的发展已经超过10年。在App Annie 最新的移动市场报告中分享2020应用下载量已经达到2180亿次,同比增加7%。根据Statista的统计,2020年度Google Play的应用数量为3148932个。

在移动互联网的高速发展及竞争中,更快及更高质量的交付用户,显然尤为重要。但很多产品随着移动互联网的发展,已经迭代超过10年。在这个过程中人员流动、技术债务累计、技术生态更新,使得产生了大量的遗留系统。就像一辆低排量的破旧汽车,再大的马路,技术再好的驾驶员,达到车辆本身的系统瓶颈,速度就很难再提升起来。

遗留系统

在以往的项目中,遇到了大量的这种遗留系统。这些系统具有以下一些特点。

  • 大泥球架构,代码量上百万行,开发人员超过100+
  • 内部耦合高,代码修改维护牵一发动全身,质量低
  • 编译集成调试慢,没有任何自动化测试,开发效率低
  • 技术栈陈旧,祖传代码,无人敢动

在这样的背景下,个别少的团队选择重写,当然没有良好的过程管理及方法,好多重写完又成了新的遗留系统。也有的团队选择重构,但目前相关的方法及教程比较少。这里推荐一下《重构(第2版)》,书中有基本的重构手法。另外一本《修改代码的艺术》,书中有很多基于遗留代码开发的示例。但对于开发人员来说,缺少比较贴近移动应用项目实战的重构指导及系统方法。很多团队依旧没有解决遗留系统根本的原因,仅靠不断的堆人,恶性循环。

CloudDisk 演示示例

CloudDisk是一个类似于Google Drive的云存储应用。该应用主要拥有3大核心业务模块,文件、动态及个人中心。

该项目已经维护超过10年以上,目前有用开发人员100+。目前代码在一个大模块中,约30w行左右,编译时间10分钟以上。团队目前主要还面临几个问题。

  1. 开发效率低

编译时间长,经常出现代码合并冲突。遗留大量技术债务,团队疲于交付需求

  1. 代码质量差

经常修改出新问题,版本提测问题多,没有任何自动化测试。

  1. 版本发布周期长

往往需要1个月以上,市场反馈响应慢。

我们希望通过一个更贴近实际工程项目的浓缩版遗留系统示例,持续解决团队在产品不断迭代中遇到的问题。从架构设计与分析、安全重构、基础生态设施、流水线、编译构建等方面,一步一步介绍如何进行持续演化。我们将通过文章及视频演示的方式进行分享,希望通过这个系列文章,大家可以更系统的掌握移动应用项目中实战的重构技巧及落地方法。

大纲

关于

欢迎关注我的个人公众号

微信搜索:一码一浮生,或者搜索公众号ID:life2code

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin

遗留系统开发之道

发表于 2020-08-25 |

遗留系统之痛

问题

在软件这个行业里,有一个有意思的名词叫“祖传代码”。泛指那些结构混乱的遗留系统代码。相信大家或多或少在工作中都会遇到过遗留系统,你是否遇到过下面的问题?

  1. 应用大泥球的结构,代码看起来都很费劲,更别说改
  2. 代码已经改不动了,想要重构,但却一点信心都没有
  3. 修一个bug,又引起了另外的bug

原因

遗留系统常常有2个非常明显的特点。

  1. 代码耦合度高,相互依赖性高
  2. 没有足够的自动化测试覆盖,完成改动后要比较长的时间来反馈事情是否做对

这使得我们对代码修改的成本非常大,并且往往容易出错。

依赖性是软件开发中最为关键的问题之一。在处理遗留代码的过程中很大一部分工作都是围绕着“接触依赖性以便使改动变得更容易”这个目标来进行的

遗留代码开发心法

对付依赖代码的工作其实就是动手改,但不是随意地改。我们必须在做出能够带来价值的功能性改动的同时使系统中的更多部分覆盖足够的测试。同时在这个过程中也有一些套路及手法可以参考,我们不需要摸着石头过河。

选择恰当时机

  • 在修改遗留代码之前,对覆盖遗留代码的场景/服务增加大型/中型测试。
  • 在对功能扩展和修改时,对原有代码进行重构并加上对方法的小型测试
  • 在修复 Bug 时增加自动化测试保证问题不要再次出现

确定改动点

  • 仔细阅读被测代码、文档、用例,或找其他同事了解,最大程度理解要修改的功能相关代码

  • 参考常用的架构模式(MV*)和通用的设计原则,作为我们重构时的基准

找出测试点

  • 遗留系统中核心算法或者业务逻辑一般混杂在其他代码中,我们需要需要去识别核心的测试点

  • 合理设置测试策略,避免贸然修改代码带来的风险,最大化重构&自动化测试的 ROI

解依赖

解依赖往往是最难的地方。我们通常会遇到两个方面问题:一是难以在测试用例中实例化目标对象;二是难以在测试用例中运行方法。

这时候就需要我用运用恰当的解依赖手法进行重构,接触依赖。遗留代码解依赖三板斧

编写测试

  • 取一个“看名知其意”的测试名
  • 选择合适的测试替身
  • 编写测试代码

修改重构

对被测试保护起来的代码进行清理,遵守整洁代码及消除坏味道,提高代码可读性

遗留系统解依赖三板斧

参数化方法

假设你有一个方法,该方法在内部创建了某个对象,而你想要通过替换该对象来实现感知或分离。往往最简单的方法就是从外面将你的对象传进来

步骤

  1. 找出目标方法,将它复制一份
  2. 给其中一份增加一个参数,并将方法体中相应的对象创建语句去掉,改为使用刚增加的这个参数
  3. 将另一份复制的方法体删掉,代以对被参数化了的那个版本的调用,记得创建相应的对象作参数

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TestCase {
public void run() {
TestResult result = new TestResult();
result.runTest();
}
}

class TestResult {
boolean flag = false;

void runTest() {
flag = true;
}
}

重构

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestCase {
public void run(TestResult result) {
result.runTest();
}
}

class TestResult {
boolean flag = false;

void runTest() {
flag = true;
}
}

测试

1
2
3
4
5
6
7
8
9
10
public class TestCaseTest {

@Test
public void should_return_flag_is_true_when_call_run() {
TestCase testCase = new TestCase();
TestResult mockTestResult = new TestResult();
testCase.run(mockTestResult);
Assert.assertTrue(mockTestResult.flag);
}
}

参数适配

当无法对一个参数的类型使用接口提取,或者当该参数难以“伪装”的时候,可采用参数适配手法

步骤

  1. 创建将被用于该方法的新接口,该接口越简单且能表达意图越好。但也要注意,该接口不应导致需要对该方法的代码作大规模修改
  2. 为新接口创建一个用于产品代码的实现
  3. 为新接口创建一个用于测试的“伪造”实现
  4. 编写一个简单的测试用例,将伪对象传给该方法
  5. 对该方法作必要的修改以使其能使用新的参数
  6. 运行测试来确保你能使用伪对象来测试该方法

示例

1
2
3
4
5
6
7
8
9
10
11
public class ARMDispatcher {

List<String> marketBinding = new ArrayList<>();

public void populate(HttpsParameters parameters) {
String[] values = parameters.getCipherSuites();
if (values != null && values.length > 0) {
marketBinding.add(values[0]);
}
}
}

重构

1
2
3
4
5
6
7
8
9
10
11
12
public class ARMDispatcher {

List<String> marketBinding = new ArrayList<>();

public void populate(ParameterSource parameterSource) {
String values = parameterSource.getParameterValue();
if (values != null) {
marketBinding.add(values);
}
}

}
1
2
3
interface ParameterSource {
String getParameterValue();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class HttpParameterSource implements ParameterSource {
HttpsParameters mHttpsParameters;

public HttpParameterSource(HttpsParameters httpsParameters) {
mHttpsParameters = httpsParameters;
}

@Override
public String getParameterValue() {
String[] values = mHttpsParameters.getCipherSuites();
if (values != null && values.length > 0) {
return values[0];
}
return "";
}
}

测试

1
2
3
4
5
6
7
8
9
class FakeParameterSource implements ParameterSource {

public String value;

@Override
public String getParameterValue() {
return value;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
public class ARMDispatcherTest {

@Test
public void testPopulate() {
ARMDispatcher armDispatcher = new ARMDispatcher();
FakeParameterSource fakeParameterSource = new FakeParameterSource();
fakeParameterSource.value = "hello world";
armDispatcher.populate(fakeParameterSource);
assertThat(armDispatcher.marketBinding.size(),is(1));
assertThat(armDispatcher.marketBinding.get(0),is("hello world"));
}
}

接口提取

提取接口时并不一定要提取类上的所有公有方法,考虑递增式地扩充该接口。

步骤

  1. 创建一个新接口,给它起一个好名字。暂时不要往里面添加任何方法
  2. 令你提取接口的目标类实现该接口。这一步不会破坏任何东西,因为接口上还没有任何方法。但你也可以编译确认一下
  3. 将你想要使用伪对象的地方从引用原类改为引用你新建的接口
  4. 编译系统。如果编译器汇报接口上缺少某某方法,则添加对应的方法(同时也往伪类上面添加一个空的实现),直到编译通过

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PaydayTransaction {
TransactionLog transactionLog;

public PaydayTransaction(TransactionLog transactionLog) {
this.transactionLog = transactionLog;
}

public void run() {
transactionLog.saveTransaction();
}
}

class TransactionLog {
public void saveTransaction() {
//call database
}

public void recordError(int code) {

}
}

重构

1
2
3
public interface TransactionRecorder {
void saveTransaction();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class PaydayTransaction {
TransactionRecorder transactionRecorder;

public PaydayTransaction(TransactionRecorder transactionRecorder) {
this.transactionRecorder = transactionRecorder;
}

public void run() {
transactionRecorder.saveTransaction();
}
}

class TransactionLog implements TransactionRecorder {
@Override
public void saveTransaction() {
//call database
}

public void recordError(int code) {

}
}

测试

1
2
3
4
5
6
7
8
class FakeTransactionLog implements TransactionRecorder {
public boolean isSave = false;

@Override
public void saveTransaction() {
isSave = true;
}
}
1
2
3
4
5
6
7
8
9
10
public class PaydayTransactionTest {

@Test
public void testPayday() {
FakeTransactionLog fakeTransactionLog = new FakeTransactionLog();
PaydayTransaction paydayTransaction = new PaydayTransaction(fakeTransactionLog);
paydayTransaction.run();
Assert.assertThat(fakeTransactionLog.isSave, is(true));
}
}

更多解依赖手法的源码参考,LegacyCode

参考

《修改代码的艺术》

关于

欢迎关注我的个人公众号

微信搜索:一码一浮生,或者搜索公众号ID:life2code

  • 作者:黄俊彬
  • 博客:junbin.tech
  • GitHub: junbin1011
  • 知乎: @JunBin
123…11
黄俊彬

黄俊彬

一花一世界,一码一浮生

101 日志
15 标签
GitHub zhihu
© 2021 黄俊彬
由 Hexo 强力驱动
主题 - NexT.Pisces