移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(13)-??????一鏡到底访雪!MVP重構(gòu)示例篇
前言
上一篇移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(12)- 編譯調(diào)試篇 介紹敛纲,經(jīng)過(guò)了解耦糜值、分庫(kù)及編譯調(diào)試的優(yōu)化邓夕,一段時(shí)間內(nèi)括饶,CloudDisk的開發(fā)效率得到了很大的提升冯乘。
但隨著業(yè)務(wù)的演進(jìn)入录,由于歷史的原因蛤奥,代碼中存在很多Activity及Controller的上帝類聋丝。今天我們將拿File Bundle作為例子扣蜻,為大家總結(jié)重構(gòu)的流程颈嚼,演示如何一步一步將上帝類重構(gòu)為MVP架構(gòu)环鲤。
視頻演示地址: mp.weixin.qq.com/s/zjeln_eqA…
重構(gòu)流程
1. 梳理業(yè)務(wù)邏輯
通過(guò)往往這一步是最難的。由于人員更迭碳柱、產(chǎn)品的迭代藤滥,給這一步帶來(lái)很大的挑戰(zhàn)梗搅。我們可以嘗試從一下幾方面來(lái)補(bǔ)全信息。
- 找人:產(chǎn)品經(jīng)理衰猛、設(shè)計(jì)人員迟蜜、測(cè)試人員進(jìn)行確認(rèn)和答疑
- 找文檔:查看原有的需求文檔、設(shè)計(jì)文檔啡省、測(cè)試用例娜睛、設(shè)計(jì)稿
- 看代碼:從原有的代碼設(shè)計(jì)中去梳理業(yè)務(wù)
經(jīng)過(guò)梳理確認(rèn),F(xiàn)ile Bundle的現(xiàn)有的業(yè)務(wù)如下:
文件大小轉(zhuǎn)換為以B冕杠、K微姊、M酸茴、G單位顯示
2.分析原有的代碼設(shè)計(jì)
我們以主要上帝類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);
//顯示網(wǎng)絡(luò)數(shù)據(jù)
List<FileInfo> infoList = (List<FileInfo>) msg.obj;
FileListAdapter fileListAdapter = new FileListAdapter(infoList, getActivity());
fileListRecycleView.addItemDecoration(new DividerItemDecoration(
getActivity(), DividerItemDecoration.VERTICAL));
//設(shè)置布局顯示格式
fileListRecycleView.setLayoutManager(new LinearLayoutManager(getActivity()));
fileListRecycleView.setAdapter(fileListAdapter);
} else if (msg.what == 0) {
showTip(true);
//顯示異常提醒數(shù)據(jù)
tvMessage.setText(msg.obj.toString());
} else {
showTip(true);
//顯示空數(shù)據(jù)
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);
}
}
}
復(fù)制代碼
從代碼中我們可以看到主要的一些設(shè)計(jì)問(wèn)題,如下:
- 主要的獲取文件薪捍、異常邏輯判斷笼痹、界面刷新控制都是在一個(gè)類里面,不利于后續(xù)的擴(kuò)張及修改維護(hù)酪穿。我們希望類的職責(zé)更加單一凳干,邏輯和視圖能夠進(jìn)行分離。
- 存在粗暴的new Thread進(jìn)行管理
- Handler 存在內(nèi)存泄露風(fēng)險(xiǎn)
- 存在規(guī)范問(wèn)題被济,例如empty data字符串沒(méi)有使用xml進(jìn)行管理救赐、代碼中由無(wú)效的導(dǎo)包等
- 代碼中沒(méi)有任何守護(hù)測(cè)試
完整的所有代碼見(jiàn)Github
3. 補(bǔ)充守護(hù)測(cè)試
參考重構(gòu)篇中,我們制定的策略只磷,我們可以先做大型的測(cè)試经磅,做為守護(hù)測(cè)試。
@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("遺留代碼重構(gòu).pdf")).check(matches(isDisplayed()));
onView(withText("100.00K")).check(matches(isDisplayed()));
onView(withText("系統(tǒng)組件化.pdf")).check(matches(isDisplayed()));
onView(withText("9.67K")).check(matches(isDisplayed()));
});
}
}
復(fù)制代碼
我們發(fā)現(xiàn)這個(gè)用不會(huì)通過(guò)钮追,我們主要面臨一下2個(gè)問(wèn)題预厌。
- 網(wǎng)絡(luò)請(qǐng)求是異步的,異步邏輯可能在測(cè)試執(zhí)行完還沒(méi)有觸發(fā)到
- 網(wǎng)絡(luò)數(shù)據(jù)是動(dòng)態(tài)的元媚,我們斷言的數(shù)據(jù)沒(méi)辦法確定
所以我們采用的做法就是Mock轧叽,我不穩(wěn)定的依賴Mock掉。我們同樣使用Shadow來(lái)進(jìn)行多獲取網(wǎng)絡(luò)數(shù)據(jù)方法進(jìn)行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("遺留代碼重構(gòu).pdf", 102400));
infoList.add(new FileInfo("系統(tǒng)組件化.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);
}
}
復(fù)制代碼
調(diào)整后的測(cè)試用例如下:
@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("遺留代碼重構(gòu).pdf")).check(matches(isDisplayed()));
onView(withText("100.00K")).check(matches(isDisplayed()));
onView(withText("系統(tǒng)組件化.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()));
});
}
復(fù)制代碼
中間我們用允許測(cè)試腳步時(shí)發(fā)現(xiàn)炭晒,之前的舊代碼還有一處邏輯的錯(cuò)誤。數(shù)據(jù)為空的判斷應(yīng)該加在網(wǎng)絡(luò)數(shù)據(jù)回調(diào)中甥角。
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)
復(fù)制代碼
我們同步將異常邏輯進(jìn)行修改网严,如下:
最后我們完成了基本的守護(hù)測(cè)試,運(yùn)行結(jié)果如下:
完整的所有代碼見(jiàn)Github
4. 簡(jiǎn)單設(shè)計(jì)
MVP架構(gòu)
- 業(yè)務(wù)邏輯和視圖分離
- Presenter和View之間通過(guò)接口交互
- 為了更高效管理線程蜈膨,團(tuán)隊(duì)決定使用Rxjava進(jìn)行線程統(tǒng)一管理屿笼。架構(gòu)風(fēng)格參考architecture-samples
接口設(shè)計(jì)
- Contract接口設(shè)計(jì)
public interface FileListContract {
interface View {
showFileList(List<FileInfo> fileList);
showNetWorkException(String errorMessage);
showEmptyData();
}
interface Presenter {
void getFileList();
}
}
復(fù)制代碼
- 數(shù)據(jù)接口設(shè)計(jì)
public interface FileDataSource {
Flowable<List<FileInfo>> getFileList();
}
復(fù)制代碼
5.小步安全重構(gòu)
- 抽取FileFragment的業(yè)務(wù)邏輯到FilePresenter
- FileFragment 提取UI接口
- 抽取FileDataSource
重構(gòu)手法包含提取接口牺荠、移動(dòng)方法、移動(dòng)類驴一、抽取方法休雌、內(nèi)聯(lián)、提取變量等等肝断。過(guò)程還需要根據(jù)重構(gòu)適當(dāng)調(diào)整測(cè)試用例杈曲。
詳細(xì)的演示見(jiàn)視頻
- 補(bǔ)充測(cè)試用例
- 補(bǔ)充FileUtils計(jì)算文件大小測(cè)試
@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);
}
}
復(fù)制代碼
- 補(bǔ)充Presenter業(yè)務(wù)邏輯測(cè)試
@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("遺留代碼重構(gòu).pdf", 102400));
fileList.add(new FileInfo("系統(tǒng)組件化.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");
}
}
復(fù)制代碼
我們可以通過(guò)查看Coverage查看邏輯的覆蓋情況,判斷是否有重要的邏輯遺漏胸懈。
最后我們運(yùn)行file bundle所有的測(cè)試担扑,報(bào)告如下:
6.集成驗(yàn)收測(cè)試
- file bundle模塊發(fā)布1.0.1版本
implementation 'com.cloud.disk.bundle:file:1.0.1'
復(fù)制代碼
- 執(zhí)行守護(hù)測(cè)試
- 運(yùn)行檢查
總結(jié)
本篇介紹了文件模塊團(tuán)隊(duì)將文件主頁(yè)重構(gòu)為MVP架構(gòu),并且補(bǔ)充了自動(dòng)化測(cè)試趣钱。經(jīng)過(guò)重構(gòu)后涌献,團(tuán)隊(duì)的開發(fā)效率和版本質(zhì)量有了明顯的提升。有了文件模塊的打樣首有,給其他團(tuán)隊(duì)帶來(lái)了很大的信心燕垃。
接下來(lái),我們將繼續(xù)分享動(dòng)態(tài)模塊的重構(gòu)之旅井联。與文件模塊不一樣的是動(dòng)態(tài)模塊決定采用Kotlin+MVVM架構(gòu)卜壕。
下一篇,單體移動(dòng)應(yīng)用“模塊化”演進(jìn)之旅(14)- Kotlin+MVVM重構(gòu)示例篇
CloudDisk示例代碼
系列鏈接
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(1)- 開篇
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(2)-架構(gòu)篇
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(3)-示例篇
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(4)-分析篇
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(5)- 重構(gòu)方法篇
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(6)- 測(cè)試篇
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(7)- 解耦重構(gòu)演示篇(一)+視頻演示
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(8)- 依賴注入篇
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(9)- 路由篇
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(10)- 解耦重構(gòu)演示篇(二)
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(11)- 制品管理篇
移動(dòng)應(yīng)用遺留系統(tǒng)重構(gòu)(12)- 編譯調(diào)試篇