移动应用遗留系统重构-MVP重构示例篇

移动应用遗留系统重构(13)-🔥🔥🔥一镜到底!MVP重构示例篇

前言

上一篇移动应用遗留系统重构(12)- 编译调试篇 介绍,经过了解耦、分库及编译调试的优化,一段时间内,CloudDisk的开发效率得到了很大的提升。

但随着业务的演进,由于历史的原因,代码中存在很多Activity及Controller的上帝类。今天我们将拿File Bundle作为例子,为大家总结重构的流程,演示如何一步一步将上帝类重构为MVP架构。

视频演示地址: mp.weixin.qq.com/s/zjeln_eqA…

重构流程

1. 梳理业务逻辑

通过往往这一步是最难的。由于人员更迭、产品的迭代,给这一步带来很大的挑战。我们可以尝试从一下几方面来补全信息。

  1. 找人:产品经理、设计人员、测试人员进行确认和答疑
  2. 找文档:查看原有的需求文档、设计文档、测试用例、设计稿
  3. 看代码:从原有的代码设计中去梳理业务

经过梳理确认,File Bundle的现有的业务如下:

文件大小转换为以B、K、M、G单位显示

2.分析原有的代码设计

我们以主要上帝类FileFragment为例,代码如下:

@AndroidEntryPoint
public class FileFragment extends Fragment {

    @Inject
    FileController fileController;

    private RecyclerView fileListRecycleView;
    private TextView tvMessage;

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

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {

        View view = inflater.inflate(R.layout.fragment_file, container, false);
        fileListRecycleView = view.findViewById(R.id.file_list);
        tvMessage = view.findViewById(R.id.tv_message);
        tvMessage.setOnClickListener(v -> getFileList());
        return view;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getFileList();
    }

    private void getFileList() {
        new Thread(() -> {
            Message message = new Message();
            try {
                List<FileInfo> infoList = fileController.getFileList();
                message.what = 1;
                message.obj = infoList;
            } catch (NetworkErrorException e) {
                message.what = 0;
                message.obj = "NetworkErrorException";
                e.printStackTrace();
            }
            mHandler.sendMessage(message);
        }).start();
    }

    Handler mHandler = new Handler(new Handler.Callback() {
        @Override
        public boolean handleMessage(@NonNull Message msg) {
            if (msg.what == 1) {
                showTip(false);
                //显示网络数据
                List<FileInfo> infoList = (List<FileInfo>) msg.obj;
                FileListAdapter fileListAdapter = new FileListAdapter(infoList, getActivity());
                fileListRecycleView.addItemDecoration(new DividerItemDecoration(
                        getActivity(), DividerItemDecoration.VERTICAL));
                //设置布局显示格式
                fileListRecycleView.setLayoutManager(new LinearLayoutManager(getActivity()));
                fileListRecycleView.setAdapter(fileListAdapter);
            } else if (msg.what == 0) {
                showTip(true);
                //显示异常提醒数据
                tvMessage.setText(msg.obj.toString());
            } else {
                showTip(true);
                //显示空数据
                tvMessage.setText("empty data");
            }
            return false;
        }
    });

    public void showTip(boolean show) {
        if (show) {
            tvMessage.setVisibility(View.VISIBLE);
            fileListRecycleView.setVisibility(View.GONE);
        } else {
            tvMessage.setVisibility(View.GONE);
            fileListRecycleView.setVisibility(View.VISIBLE);
        }
    }
}
复制代码

从代码中我们可以看到主要的一些设计问题,如下:

  1. 主要的获取文件、异常逻辑判断、界面刷新控制都是在一个类里面,不利于后续的扩张及修改维护。我们希望类的职责更加单一,逻辑和视图能够进行分离。
  2. 存在粗暴的new Thread进行管理
  3. Handler 存在内存泄露风险
  4. 存在规范问题,例如empty data字符串没有使用xml进行管理、代码中由无效的导包等
  5. 代码中没有任何守护测试

完整的所有代码见Github

3. 补充守护测试

参考重构篇中,我们制定的策略,我们可以先做大型的测试,做为守护测试。

@RunWith(AndroidJUnit4.class)
@LargeTest
@HiltAndroidTest
@Config(application = HiltTestApplication.class)
public class FileFragmentTest {

    @Rule
    public HiltAndroidRule hiltRule = new HiltAndroidRule(this);

    @Test
    public void show_show_file_list_when_get_success() {
        //given
        ActivityScenario<DebugActivity> scenario = ActivityScenario.launch(DebugActivity.class);
        scenario.onActivity(activity -> {
            //then
            onView(withText("遗留代码重构.pdf")).check(matches(isDisplayed()));
            onView(withText("100.00K")).check(matches(isDisplayed()));
            onView(withText("系统组件化.pdf")).check(matches(isDisplayed()));
            onView(withText("9.67K")).check(matches(isDisplayed()));
        });
    }
}
复制代码

我们发现这个用不会通过,我们主要面临一下2个问题。

  1. 网络请求是异步的,异步逻辑可能在测试执行完还没有触发到
  2. 网络数据是动态的,我们断言的数据没办法确定

所以我们采用的做法就是Mock,我不稳定的依赖Mock掉。我们同样使用Shadow来进行多获取网络数据方法进行Mock,代码如下:

@Implements(FileFragment.class)
public class ShadowFileFragment {

    @RealObject
    public FileFragment fileFragment;

    enum State {
        SUCCESS,
        ERROR,
        EMPTY
    }

    public static State state = State.SUCCESS;

    @Implementation
    protected void getFileList() {
        System.out.println("shadow .... .....");
        Message message = new Message();
        if (state == State.SUCCESS) {
            ArrayList<FileInfo> infoList = new ArrayList<>();
            infoList.add(new FileInfo("遗留代码重构.pdf", 102400));
            infoList.add(new FileInfo("系统组件化.pdf", 9900));
            message.what = 1;
            message.obj = infoList;
        } else if (state == State.ERROR) {
            message.what = 0;
            message.obj = "NetworkErrorException";
        } else if (state == State.EMPTY) {
            message.what = 1;
            message.obj = null;
        }
        fileFragment.mHandler.sendMessage(message);
    }
}
复制代码

调整后的测试用例如下:

 @Test
    public void show_show_file_list_when_get_success() {
        //given
        ShadowFileFragment.state = ShadowFileFragment.State.SUCCESS;
        //when
        ActivityScenario<DebugActivity> scenario = ActivityScenario.launch(DebugActivity.class);
        scenario.onActivity(activity -> {
            //then
            onView(withText("遗留代码重构.pdf")).check(matches(isDisplayed()));
            onView(withText("100.00K")).check(matches(isDisplayed()));
            onView(withText("系统组件化.pdf")).check(matches(isDisplayed()));
            onView(withText("9.67K")).check(matches(isDisplayed()));
        });
    }

    @Test
    public void show_show_error_tip_when_net_work_exception() {
        //given
        ShadowFileFragment.state = ShadowFileFragment.State.ERROR;
        //when
        ActivityScenario<DebugActivity> scenario = ActivityScenario.launch(DebugActivity.class);
        scenario.onActivity(activity -> {
            //then
            onView(withText("NetworkErrorException")).check(matches(isDisplayed()));
        });
    }

    @Test
    public void show_show_empty_tip_when_not_has_data() {
        //given
        ShadowFileFragment.state = ShadowFileFragment.State.EMPTY;
        //when
        ActivityScenario<DebugActivity> scenario = ActivityScenario.launch(DebugActivity.class);
        scenario.onActivity(activity -> {
            //then
            onView(withText("empty data")).check(matches(isDisplayed()));
        });
    }
复制代码

中间我们用允许测试脚步时发现,之前的旧代码还有一处逻辑的错误。数据为空的判断应该加在网络数据回调中。

Caused by: java.lang.NullPointerException
    at com.cloud.disk.bundle.file.FileFragment$1.handleMessage(FileFragment.java:96)
    at android.os.Handler.dispatchMessage(Handler.java:102)
复制代码

我们同步将异常逻辑进行修改,如下:

image

最后我们完成了基本的守护测试,运行结果如下:

image

完整的所有代码见Github

4. 简单设计

MVP架构

  1. 业务逻辑和视图分离
  2. Presenter和View之间通过接口交互
  3. 为了更高效管理线程,团队决定使用Rxjava进行线程统一管理。架构风格参考architecture-samples

接口设计

  1. Contract接口设计
public interface FileListContract {

    interface View  {
        showFileList(List<FileInfo> fileList);
        showNetWorkException(String errorMessage);
        showEmptyData();
    }

    interface Presenter {

        void getFileList();
    }
}

复制代码
  1. 数据接口设计
public interface FileDataSource {
     Flowable<List<FileInfo>> getFileList();
}
复制代码

5.小步安全重构

  • 抽取FileFragment的业务逻辑到FilePresenter
  • FileFragment 提取UI接口
  • 抽取FileDataSource

重构手法包含提取接口、移动方法、移动类、抽取方法、内联、提取变量等等。过程还需要根据重构适当调整测试用例。

详细的演示见视频

  • 补充测试用例
  1. 补充FileUtils计算文件大小测试
@RunWith(JUnit4.class)
@SmallTest
public class FileUtilsTest {

    @Test
    public void should_return_B_unit_when_file_size_in_its_range() {
        //given
        long fileSize = 100;
        //when
        String format = FileUtils.formatFileSize(fileSize);
        //then
        assertEquals("100.00B", format);
    }

    @Test
    public void should_return_K_unit_when_file_size_in_its_range() {
        //given
        long fileSize = 1034;
        //when
        String format = FileUtils.formatFileSize(fileSize);
        //then
        assertEquals("1.01K", format);
    }

    @Test
    public void should_return_M_unit_when_file_size_in_its_range() {
        //given
        long fileSize = 1084000;
        //when
        String format = FileUtils.formatFileSize(fileSize);
        //then
        assertEquals("1.03M", format);
    }

    @Test
    public void should_return_G_unit_when_file_size_in_its_range() {
        //given
        long fileSize = 1114000000;
        //when
        String format = FileUtils.formatFileSize(fileSize);
        //then
        assertEquals("1.04G", format);
    }
}
复制代码
  1. 补充Presenter业务逻辑测试
@RunWith(JUnit4.class)
@MediumTest
public class FilePresenterImplTest {

    @Rule
    public RxSchedulerRule rule = new RxSchedulerRule();

    @Test
    public void should_return_file_list_when_call_data_source_success() throws NetworkErrorException {
        //given
        FileListContract.View mockView = mock(FileListContract.View.class);
        FileDataSource mockFileDataSource = mock(FileDataSource.class);
        List<FileInfo> fileList = new ArrayList<>();
        fileList.add(new FileInfo("遗留代码重构.pdf", 102400));
        fileList.add(new FileInfo("系统组件化.pdf", 9900));
        when(mockFileDataSource.getFileList()).thenReturn(Flowable.fromArray(fileList));
        FileListContract.FilePresenter filePresenter = new FilePresenterImpl(mockFileDataSource, mockView);
        //when
        filePresenter.getFileList();
        //then
        verify(mockView).showFileList(anyList());
    }

    @Test
    public void should_show_empty_data_when_call_data_source_return_empty() throws NetworkErrorException {
        //given
        FileListContract.View mockView = mock(FileListContract.View.class);
        FileDataSource mockFileDataSource = mock(FileDataSource.class);
        when(mockFileDataSource.getFileList()).thenReturn(Flowable.fromArray(new ArrayList<>()));
        FileListContract.FilePresenter filePresenter = new FilePresenterImpl(mockFileDataSource, mockView);
        //when
        filePresenter.getFileList();
        //then
        verify(mockView).showEmptyData();
    }

    @Test
    public void should_show_network_exception_when_call_data_source_return_net_error() throws NetworkErrorException {
        //given
        FileListContract.View mockView = mock(FileListContract.View.class);
        FileDataSource mockFileDataSource = mock(FileDataSource.class);
        when(mockFileDataSource.getFileList()).thenThrow(new NetworkErrorException());
        FileListContract.FilePresenter filePresenter = new FilePresenterImpl(mockFileDataSource, mockView);
        //when
        filePresenter.getFileList();
        //then
        verify(mockView).showNetWorkException("NetworkErrorException");
    }
}
复制代码

我们可以通过查看Coverage查看逻辑的覆盖情况,判断是否有重要的逻辑遗漏。

最后我们运行file bundle所有的测试,报告如下:

6.集成验收测试

  1. file bundle模块发布1.0.1版本
implementation 'com.cloud.disk.bundle:file:1.0.1'
复制代码
  1. 执行守护测试
image
  1. 运行检查

总结

本篇介绍了文件模块团队将文件主页重构为MVP架构,并且补充了自动化测试。经过重构后,团队的开发效率和版本质量有了明显的提升。有了文件模块的打样,给其他团队带来了很大的信心。

接下来,我们将继续分享动态模块的重构之旅。与文件模块不一样的是动态模块决定采用Kotlin+MVVM架构。

下一篇,单体移动应用“模块化”演进之旅(14)- Kotlin+MVVM重构示例篇

CloudDisk示例代码

CloudDisk

系列链接

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

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

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

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

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

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

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

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

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

移动应用遗留系统重构(10)- 解耦重构演示篇(二)

移动应用遗留系统重构(11)- 制品管理篇

移动应用遗留系统重构(12)- 编译调试篇

大纲

关于

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容