Hi,我是阿昌
,今天学习记录的是关于消息组件Kotlin+MVVM重构
的内容。
随着项目不断的迭代,新的技术栈也会持续不断地演进。
适时使用新的技术栈,可以帮助我们提高效率以及代码质量。
安全高效地为遗留系统升级技术栈,具体会使用新的语言 Kotlin
以及新的架构模式 MVVM
,来重构消息组件。
选择 Kotlin + MVVM,有两方面考量:
不过技术栈不同了,流程方法仍然相同,这里会继续使用组件内分层重构的方法。
对于遗留系统来说,通常使用的开发语言都是 Java,那么在选择 Kotlin 语言时,通常会有 2 种选择:
混编
完全使用 Kotlin
替换 Java。至于哪种方式更好,它们之间有什么差异?
第一种方法使用 Java 与 Kotlin 混编,这个做法的好处是不需要改动原来的代码
,只需要用 Kotlin 语言编写扩展的代码就可以了。
但是缺点就是由于 Kotlin 的语言高度依赖编辑器生成转换代码,所以有些语法通过 Java 来调用 Kotlin 会比较啰嗦,例如伴生函数的调用。
//定义
class KotlinClass {
companion object {
fun doWork() {
/* … */
}
}
}
//使用Kotlin调用Kotlin
KotlinClass.doWork();
//使用Java调用Kotlin(方式一)
public final class JavaClass {
public static void main(String... args) {
KotlinClass.Companion.doWork();
}
}
//使用Java调用Kotlin(方式二)
class KotlinClass {
companion object {
@JvmStatic fun doWork() {
/* … */
}
}
}
public final class JavaClass {
public static void main(String... args) {
KotlinClass.doWork();
}
}
从例子可以看出,虽然可以通过 @JvmStatic
注解来简化调用,但是始终没有 Kotlin 调用 Kotlin 那么方便。
第二种方法使用 Kotlin 替换 Java 的好处就是,可以减少一些跨语言调用编写问题
,但是缺点是需要将原有的代码改动成 Kotlin。
好在官方也提供了将 Java 语言转换为 Kotlin 语言的功能,转换起来很方便。
对于 Sharing 项目来说,已经覆盖了基本的自动化测试功能,可以在转换后进行验证,所以这里采用将 Java 代码替换成 Kotlin 代码的方式,具体的操作你可以参考后面的图片。
注意转换完成后如果有一些代码提示编译错误,需要先进行调整,保证基本的编译正常。
另外由于是从 Java 代码转换来的,所以有很多代码虽然转换成 Kotlin,但也还带着浓浓的 Java 味道,可以继续结合 Kotlin 的语法特点重构代码。转换后的代码是后面这样。
最后当确定编译通过后,需要运行基本的冒烟自动化测试,保证运行通过。
下面开始对消息组件进行 MVVM 重构,同样是七个步骤。
先来看业务分析。消息组件的展示逻辑基本与文件组件的类似,都有异常逻辑处理的区分。
消息组件与文件组件最主要的区别是增加了本地缓存,当网络异常时会判断本地是否存在缓存数据,如果有,则优先展示缓存数据,如下图所示。
根据流程图可以看出,主要的用户操作场景是这样的:
分析消息组件主页面的关键业务逻辑代码,先看看原有代码设计。:
@Route(path = "/messageFeature/message")
class MessageFragment : Fragment() {
//... ...
fun getMessageList() {
Thread {
val message = android.os.Message()
try {
val messageList = messageController?.getMessageList()
message.what = 1
message.obj = messageList
} catch (e: NetworkErrorException) {
message.what = 0
message.obj = "网络异常,请点击重试。"
e.printStackTrace()
}
mHandler.sendMessage(message)
}.start()
}
var mHandler = Handler { msg ->
if (msg.what == 1) {
showTip(false)
//显示网络数据
val messageList = msg.obj as MutableList<Message>
if (messageList.size == 0) {
showTip(true)
//显示空数据
tvMessage!!.text = "没有数据,请点击重试。"
} else {
val fileListAdapter = MessageListAdapter(messageList, activity)
messageListRecycleView!!.addItemDecoration(
DividerItemDecoration(
activity, DividerItemDecoration.VERTICAL
)
)
//设置布局显示格式
messageListRecycleView!!.layoutManager = LinearLayoutManager(activity)
messageListRecycleView!!.adapter = fileListAdapter
//从网络中更新到数据保存到缓存之中
messageController!!.saveMessageToCache(messageList)
}
} else if (msg.what == 0) {
//尝试从缓存中读取数据
val messageList = messageController?.getMessageListFromCache()
if (messageList == null || messageList.size == 0) {
showTip(true)
//显示异常提醒数据
tvMessage!!.text = msg.obj.toString()
} else {
val fileListAdapter = MessageListAdapter(messageList, activity)
messageListRecycleView!!.addItemDecoration(
DividerItemDecoration(
activity, DividerItemDecoration.VERTICAL
)
)
//设置布局显示格式
messageListRecycleView!!.layoutManager = LinearLayoutManager(activity)
messageListRecycleView!!.adapter = fileListAdapter
}
}
false
}
}
从上述代码可以看出,消息组件的核心问题有 2 个。
过大类
的问题,这节课里将其重构为 MVVM 架构。SQL 拼写
的方式补充自动化验收测试。
根据前面的业务分析,梳理出核心的 4 个用例。
将这些用例进行自动化,代码是后面这样。
//测试用例1
@Test
fun `show show message list when get success`() {
//given
ShadowMessageController.state = ShadowMessageController.State.SUCCESS
//when
val scenario: FragmentScenario<MessageFragment> =
FragmentScenario.launchInContainer(MessageFragment::class.java)
scenario.onFragment() {
//then
onView(withText("张三共享文件到消息中...")).check(matches(isDisplayed()))
onView(withText("大型Android遗留系统重构.pdf")).check(matches(isDisplayed()))
onView(withText("2021-03-17 14:47:55")).check(matches(isDisplayed()))
onView(withText("李四共享视频到消息中...")).check(matches(isDisplayed()))
onView(withText("修改代码的艺术.pdf")).check(matches(isDisplayed()))
onView(withText("2021-03-17 14:48:08")).check(matches(isDisplayed()))
}
}
//测试用例2
@Test
fun `show show message list when net work exception but have cache`() {
//given
ShadowMessageController.state = ShadowMessageController.State.CACHE
//when
val scenario: FragmentScenario<MessageFragment> =
FragmentScenario.launchInContainer(MessageFragment::class.java)
scenario.onFragment() {
//then
onView(withText("张三共享文件到消息中...")).check(matches(isDisplayed()))
onView(withText("大型Android遗留系统重构.pdf")).check(matches(isDisplayed()))
onView(withText("2021-03-17 14:47:55")).check(matches(isDisplayed()))
onView(withText("李四共享视频到消息中...")).check(matches(isDisplayed()))
onView(withText("修改代码的艺术.pdf")).check(matches(isDisplayed()))
onView(withText("2021-03-17 14:48:08")).check(matches(isDisplayed()))
}
}
//测试用例3
@Test
fun `show show error tip when net work exception and not have cache`() {
//given
ShadowMessageController.state = ShadowMessageController.State.ERROR
//when
val scenario: FragmentScenario<MessageFragment> =
FragmentScenario.launchInContainer(MessageFragment::class.java)
scenario.onFragment() {
//then
onView(withText("网络异常,请点击重试。")).check(matches(isDisplayed()))
}
}
//测试用例4
@Test
fun `show show empty tip when not has data`() {
//given
ShadowMessageController.state = ShadowMessageController.State.EMPTY
//when
val scenario: FragmentScenario<MessageFragment> =
FragmentScenario.launchInContainer(MessageFragment::class.java)
scenario.onFragment() {
//then
onView(withText("没有数据,请点击重试。")).check(matches(isDisplayed()))
}
}
这里补充一个编程技巧,用 Kotlin 语言编写测试用例的时候,建议使用引号来标识用例名,避免用下划线串联用例名
,这样代码阅读体验更好。
后面是测试用例的执行结果,用例成功通过。
这次的分层架构选择使用 MVVM
。
首先来了解一下 MVVM 的架构设计模式,以及基于该模式我们需要定义哪些核心的类以及数据模型。
MVVM 架构的主要特点是业务逻辑和视图分离,ViewModel 和视图之间通过直接绑定,不用定义大量的接口。
结合后面的 MVVM 架构设计图来加深理解。
ViewModel 与 View 之间会通过 DataBindng 自动进行双向同步,所以需要先定义好关键的数据。
// 数据列表
val messageListLiveData: LiveData>
// 异常信息
val errorMessageLiveData: LiveData
由于 MVVM 需要用到双向绑定,所以通常情况下使用 MVVM 架构都会沿用官方提供的组件进行开发,这里需要引入对应的组件。
//使用LiveData及ViewModel来管理数据及与View交互
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0'
//使用协程管理线程调度
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1'
做完设计,就可以进行小步安全重构了。
将整个重构分为了几个关键的步骤,每个步骤都附上了用编辑器自动化重构的演示动图。
由于 Kotlin 语言 IDE 不支持移动方法,所以在操作过程有很多地方需要用手工进行移动,可以参考对比一下上节课,感受一下自动和手动的差别。
首先将 MessageFragment 以及 MessageController 中的主要业务逻辑移动至独立的 ViewModel 类中,包含获取列表、上传消息以及缓存消息。
从上面的演示可以看出,手动挪动代码的问题就是效率低,而且非常容易出错。
然后将展示列表数据、展示异常信息以及空数据等操作提取为独立的方法。
在 MessageViewModel 类中添加对应的 LiveData 数据,同时将原本使用 Thread 创建异步的方法调整为使用协程来进行统一管理。
由于这部分都是新增代码,所以下面直接展示调整后的最终代码。
class MessageViewModel(mContext: Context?) : ViewModel() {
val messageListLiveData: MutableLiveData<MutableList<Message>> = MutableLiveData()
val errorMessageLiveData: MutableLiveData<String> = MutableLiveData();
fun getMessageList() {
viewModelScope.launch {
try {
val messageList = messageRepository.getMessageList()
messageListLiveData.value = messageList
saveMessageToCache(messageList)
} catch (e: NetworkErrorException) {
val messageList = getMessageListFromCache()
if (messageList == null || messageList.isEmpty()) {
errorMessageLiveData.value = "网络异常,请点击重试。"
} else {
messageListLiveData.value = messageList
}
}
}
}
}
对数据源进行管理,通过提取 DataSource 接口来管理本地的缓存数据读取。
通过配置 databinding 来绑定数据。
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View" />
<variable
name="message"
type="com.jkb.junbin.sharing.feature.message.Message" />
data>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="10dp">
<TextView
android:id="@+id/tv_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_gravity="right"
android:text="@{message.formatDate}" />
<TextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_gravity="left"
android:layout_toLeftOf="@id/tv_date"
android:text="@{message.content}" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/tv_content"
android:layout_marginTop="10dp">
<ImageView
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@mipmap/icon_qz" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/tv_content"
android:layout_marginTop="10dp"
android:orientation="vertical">
<TextView
android:id="@+id/tv_filename"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="5dp"
android:text="@{message.fileName}" />
<TextView
android:id="@+id/tv_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="5dp"
android:text='@{"文件浏览量:"+message.downloadCount}'
android:visibility='@{message.downloadCount==null?View.GONE:View.VISIBLE}' />
LinearLayout>
LinearLayout>
RelativeLayout>
layout>
具体调整代码比较多,但调整思路和上面的例子类似,就不在这里一一展示了,可以通过这个链接查看整体的代码。
以 MessageViewModel 为例,对它补充对应的中小型测试。
MessageViewModelTest 将对主要的业务逻辑进行测试,同样也不会涉及 UI 部分,只会校验最终 LiveData 的数据是否正确。
class DynamicViewModelTest {
private val testDispatcher = TestCoroutineDispatcher()
@get:Rule
val rule = InstantTaskExecutorRule()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
ARouter.openDebug()
ARouter.init(ApplicationProvider.getApplicationContext())
}
@After
fun tearDown() {
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
@Test
fun `show show message list when get success`() = runBlocking {
//given
ShadowMessageRepository.state = ShadowMessageRepository.State.SUCCESS
val messageViewModel = MessageViewModel(ApplicationProvider.getApplicationContext())
//when
messageViewModel.getMessageList()
//then
val messageOne = LiveDataTestUtil.getValue(messageViewModel.messageListLiveData)[0]
assertThat(messageOne.id).isEqualTo(1)
assertThat(messageOne.content).isEqualTo("张三共享文件到消息中...")
assertThat(messageOne.fileName).isEqualTo("大型Android遗留系统重构.pdf")
assertThat(messageOne.formatDate).isEqualTo("2021-03-17 14:47:55")
val messageTwo = LiveDataTestUtil.getValue(messageViewModel.messageListLiveData)[1]
assertThat(messageTwo.id).isEqualTo(2)
assertThat(messageTwo.content).isEqualTo("李四共享视频到消息中...")
assertThat(messageTwo.fileName).isEqualTo("修改代码的艺术.pdf")
assertThat(messageTwo.formatDate).isEqualTo("2021-03-17 14:48:08")
}
@Test
fun `show show dynamic list when net work exception but have cache`() = runBlocking {
//given
ShadowMessageRepository.state = ShadowMessageRepository.State.CACHE
val messageViewModel = MessageViewModel(ApplicationProvider.getApplicationContext())
//when
messageViewModel.getMessageList()
//then
val messageOne = LiveDataTestUtil.getValue(messageViewModel.messageListLiveData)[0]
assertThat(messageOne.id).isEqualTo(1)
assertThat(messageOne.content).isEqualTo("张三共享文件到消息中...")
assertThat(messageOne.fileName).isEqualTo("大型Android遗留系统重构.pdf")
assertThat(messageOne.formatDate).isEqualTo("2021-03-17 14:47:55")
val messageTwo = LiveDataTestUtil.getValue(messageViewModel.messageListLiveData)[1]
assertThat(messageTwo.id).isEqualTo(2)
assertThat(messageTwo.content).isEqualTo("李四共享视频到消息中...")
assertThat(messageTwo.fileName).isEqualTo("修改代码的艺术.pdf")
assertThat(messageTwo.formatDate).isEqualTo("2021-03-17 14:48:08")
}
@Test
fun `show show error tip when net work exception and not have cache`() = runBlocking {
//given
ShadowMessageRepository.state = ShadowMessageRepository.State.ERROR
val messageViewModel = MessageViewModel(ApplicationProvider.getApplicationContext())
//when
messageViewModel.getMessageList()
//then
val errorMessage = LiveDataTestUtil.getValue(messageViewModel.errorMessageLiveData)
assertThat(errorMessage).isEqualTo("网络异常,请点击重试。")
val messageList = LiveDataTestUtil.getValue(messageViewModel.messageListLiveData)
assertThat(messageList).isNull()
}
@Test
fun `show show empty tip when not has data`() = runBlocking {
//given
ShadowMessageRepository.state = ShadowMessageRepository.State.EMPTY
val messageViewModel = MessageViewModel(ApplicationProvider.getApplicationContext())
//when
messageViewModel.getMessageList()
//then
val messageList = LiveDataTestUtil.getValue(messageViewModel.messageListLiveData)
assertThat(messageList).isEmpty()
}
}
保证 APP 模块中的架构守护测试用例和基本冒烟测试通过,操作和类似,这里不再进行演示了。
相比重构前 MessageFragment 将所有的逻辑都写在一个类中,这次重构,解决了业务与 UI 的逻辑分离、线程调度管理、覆盖自动化测试等问题。
这次我们使用了新的语言 Kotlin 以及新的分层架构 MVVM。
可以看到尽管使用的语法与架构不一样,但是流程方法还是一样都是相通的,可以参考下表所示的 3 个维度和 7 个步骤。
在实际的重构过程中需要注意,如果之前的代码都是采用 Java 语言开发,虽然 Kotlin 语言支持混编,但是 Java 代码调用 Kotlin 代码还是比较麻烦,需要进行一些特殊的处理。
另一种选择是使用工具将原有的代码转换成 Kotlin 代码,但是这也会引入新的问题,就是转换后代码还需要继续进行优化调整,才能编译通过。在实际的项目中,可以结合团队成员技术栈以及代码规模来考虑选择哪种方式。
由于 Kotlin 语言 IDE 不支持移动方法,所以重构时会比较麻烦,需要部分进行手工移动代码,需要在移动后频繁运行守护测试,避免修改出现问题。
在代码分析步骤提到的另外一个问题就是,消息组件中的缓存数据保存到数据库操作都是采用 SQL 拼写的方式。