Kotlin重构实践

阿凡达2018-07-09 09:17

今年的 Google I/O 大会上,Google 宣布为 Kotlin 提供最佳支持,未来将成为 Android 的第一语言。最近在项目中已经开始用到,也踩过不少坑,这里做一个阶段性的总结。

特性

Kotlin 作为一种新的 JVM 语言,在设计时就考虑了 Java 互操作性,可以和 Java 代码混着一起写,也可以单独放在 src/main/kotlin 下。为了不破坏现有工程代码,将 Kotlin 代码单独存放,便于重构和开发。

举个例子,要使用某个控件,传统的写法是先 findViewById ,然后强转类型拿到引用,如果布局比较复杂,将会看到一大坨恶心代码。Kotlin 可以通过id直接使用控件,所以重构过程中将ButterKnife给淘汰了。。

Android Extensions

Kotlin 帮我们做了一些事情,只需一行代码

import kotlinx.android.synthetic.main.<布局>.*

便能扩展对应的控件属性。 插件实现代码在 plugins/android-extensions 目录下,

AndroidPackageFragmentProviderExtension.kt

        // Packages with synthetic properties
    for (variantData in moduleData.variants) {
        for ((layoutName, layouts) in variantData.layouts) {
            fun createPackageFragment(fqName: String, forView: Boolean, isDeprecated: Boolean = false) {
                val resources = layoutXmlFileManager.extractResources(AndroidLayoutGroupData(layoutName, layouts), module)
                val packageData = AndroidSyntheticPackageData(moduleData, forView, isDeprecated, resources)
                val packageDescriptor = AndroidSyntheticPackageFragmentDescriptor(
                        module, FqName(fqName), packageData, lazyContext, storageManager, isExperimental,
                        lookupTracker, layoutName
                )
                packagesToLookupInCompletion += packageDescriptor
                allPackageDescriptors += packageDescriptor
            }

            val packageFqName = AndroidConst.SYNTHETIC_PACKAGE + '.' + variantData.variant.name + '.' + layoutName

            createPackageFragment(packageFqName, false)
            createPackageFragment(packageFqName + ".view", true)
        }
    }

packageFqName 表示import的虚拟包名,建立布局文件的引用。解析布局文件的逻辑在 AndroidLayoutXmlFileManager.kt 中,看它的 extractResources 方法!

fun extractResources(layoutGroupFiles: AndroidLayoutGroupData, module: ModuleDescriptor): List<AndroidResource> {
    return filterDuplicates(doExtractResources(layoutGroupFiles, module))
}
...
protected abstract fun doExtractResources(layoutGroup: AndroidLayoutGroupData, module: ModuleDescriptor): AndroidLayoutGroup

主要逻辑在 doExtractResources 方法中,它其实是一个抽象方法,具体实现在 IDEAndroidLayoutXmlFileManager.kt

override fun doExtractResources(layoutGroup: AndroidLayoutGroupData, module: ModuleDescriptor): AndroidLayoutGroup {
    val layouts = layoutGroup.layouts.map { layout ->
        val resources = arrayListOf<AndroidResource>()
        layout.accept(AndroidXmlVisitor { id, widgetType, attribute ->
            resources += parseAndroidResource(id, widgetType, attribute.valueElement)
        })
        AndroidLayout(resources)
    }

    return AndroidLayoutGroup(layoutGroup.name, layouts)
}

读取 xml 标签的逻辑在 AndroidXmlVisitor.kt 中,上面传入了一个回调方法作为参数,用来记录遍历标签的 Id 和 Tpye 信息。

class AndroidXmlVisitor(val elementCallback: (ResourceIdentifier, String, XmlAttribute) -> Unit) : XmlElementVisitor() {

    ...

    override fun visitXmlTag(tag: XmlTag?) {
        val localName = tag?.localName ?: ""
        if (isWidgetTypeIgnored(localName)) {
            tag?.acceptChildren(this)
            return
        }

        val idAttribute = tag?.getAttribute(AndroidConst.ID_ATTRIBUTE_NO_NAMESPACE, AndroidConst.ANDROID_NAMESPACE)
        if (idAttribute != null) {
            val idAttributeValue = idAttribute.value
            if (idAttributeValue != null) {
                val xmlType = tag.getAttribute(AndroidConst.CLASS_ATTRIBUTE_NO_NAMESPACE)?.value ?: localName
                val name = androidIdToName(idAttributeValue)
                if (name != null) elementCallback(name, xmlType, idAttribute)
            }
        }

        tag?.acceptChildren(this)
    }
}

androidIdToName 方法用正则表达式提取出id的名称。代码在 AndroidConst.kt

object AndroidConst {
    fun androidIdToName(id: String): ResourceIdentifier? {
        val values = AndroidConst.IDENTIFIER_REGEX.matchEntire(id)?.groupValues ?: return null
        val packageName = values[3]
        return ResourceIdentifier(values[4], if (packageName.isEmpty()) null else packageName)
    }
}

到这里大概就能明白,Activity 中为啥能够直接使用布局中的View了。。

扩展

Kotlin 能够扩展一个类的新功能而无需继承该类,前面介绍了类属性的扩展,那么如何扩展一个类的方法呢?声明一个扩展函数,使用被扩展的类作为前缀,就拿 Context 类来说:

//ContextExtensions.kt
inline fun <reified T : Activity> Context.startActivity(
        params: Map<String, String?>?) {
    val intent = Intent(this, T::class.java)
    var setEntry = params?.entries
    setEntry?.forEach { intent.putExtra(it.key, it.value) }
    startActivity(intent)
}

函数在 Kotlin 中作为第一等公民,可以在文件的最顶层声明,无需要像 Java 一样创建一个类来保存。这里 this 关键字对应函数的调用者对象。 最后,在 Activity 定义的 startActivity 方法中调用:

class InviteMemberActivity : BaseActivity(), IView {
     ...
    companion object {
        fun startActivity(context: Context?, prjId: String?, prjName: String?, shortUrl: String?) {
            context?.let {
                val params = mapOf(PROJECT_ID to prjId, PROJECT_NAME to prjName, SHORT_URL to shortUrl)
                it.startActivity<InviteMemberActivity>(params)
            }
        }
    }
}

说明一下, Kotlin 中没有 static 方法,因此相应的方法应该放在 companion object 中,如果从 Java 中调用这些方法,需要添加 @JvmStatic 注解。另外,let 函数默认将当前对象作为闭包的 it 参数,返回值是函数里面最后一行,或者指定 return。如果上面 context 为 null,let 方法将不会执行!

数据类

Kotlin 提供了一种数据类,只需要在 class 前面加上 data 关键字修饰,默认为所有属性实现了 getter、setter、equal、hashcode、toString 等方法,所以直接将项目中的 Lombok 框架给移除了。

Java 中一个普通的 POJO 可以如下定义:

//POJO
data class ProjectDetailDTO(
    var prjStatus: Int = 0,
    var prjId: String? = null,
    var prjTitle: String? = null,
    var prjLabel: String? = null,
    var prjDesc: String? = null, 
    var prjNotice: String? = null,
     ...
)

//java
public class MapperOnData {
    ...
    public static ProjectDetailDTO fromPO(ProjectDetailPO src) {
        if (src == null) {
            return null;
        }
        ProjectDetailDTO dest = new ProjectDetailDTO();
        ...
        return dest;
    } 
    ...
}    

这里要注意,数据类不能被 abstract, open, sealed、inner 关键字修饰。为了兼容 MapperOnData 工具类,需要包含一个无参构造函数,所以给所有属性都指定了默认值。

Kotlin 的语法不仅简洁、高效,同时还支持 Lamada 表达式、操作符、空安全、类型检查与转换等等,实际开发中代码精简行数接近一半。这里我不再一一举例,可以参考 语法Style guide

单元测试

重构是为了优化代码结构,使用上新语言的特性,让代码更容易理解。重构过程中,为了确保业务逻辑不丢失,通过单元测试覆盖所有场景,每次编译都会跑一遍测试用例,只有所有用例通过才能构建成功。

由于项目的原子性业务逻辑主要集中在 Presenter 和 UseCase 类中,因此也非常方便写测试用例。项目中使用了 Junit + Mockito + Powermock + Robolectric 框架,测试代码编译成 class 文件直接运行在 JVM 上。

引入 Powermock 是因为它支持 static、final、private 方法的 mock, Powermock 会创建一个新的 MockClassLoader 来加载测试用例,然后修改字节码来实现对 static 、 final 等方法的 mock。

Kotlin 中类、方法都默认为 final 的,除非使用 open 来修饰,否则 mock 桩类会报错。

如何进行测试?

1、添加依赖:

testCompile "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"

提供了4个注解:@Test、 @Ignore、 @BeforeTest 和 @AfterTest,这些注解会映射到相应 JUnit 4 注解

然后在 src/test/resourcesorg/powermock/extensions/configuration.properties 中添加:

mockito.mock-maker-class=mock-maker-inline

注:Powermock 在1.7.0后实现了自己的 PowerMockMaker,目前只是简单代理了 Mockito 的 MockMaker 和修复一些已知 bug。

2、编写测试用例。包括三个部分:

  • 数据准备。mock桩类、预设条件返回值。
  • 待测试方法、成员变量调用。
  • 验证。方法是否被调用、逻辑分支是否覆盖完全、返回值或属性状态是否符合预期。

3、执行用例。

 ./gradlew testDevReleasegUnitTest

我们的 UseCase 类中包含由 Retrofit 框架提供的 Observable,同时封装了 RxJava 的线程管理逻辑。这里使用 RxJava 提供的 TestSubscriber 类来测试,看代码:

import org.mockito.Mockito.`when` as _when

@Before
fun setup() {
    mSubscriber = TestSubscriber()
}

@Test
@Throws(Exception::class)
fun testSetTabRemind_Person_Successs() {
       //1、准备
    _when(mRepository.updateMyTabRemindUsingPOST(ArgumentMatchers.anyString())).thenReturn(Observable.just(true))

    //2、调用
    mUseCase.setParam(ArgumentMatchers.anyString(), TabPerson)
    mUseCase.execute(mSubscriber)
    mSubscriber.awaitTerminalEvent()

    //3、验证
    verify<IRepository>(mRepository, never()).updateToDoTabRemindUsingPOST(ArgumentMatchers.anyString())
    verify<IRepository>(mRepository, times(1)).updateMyTabRemindUsingPOST(ArgumentMatchers.anyString())
    mSubscriber.assertNoErrors()
    mSubscriber.assertValue(true)
    mSubscriber.assertCompleted()
}

由于 when 是 Kotlin 的保留关键字,所以对方法名做了转换 when as _when

下面测试 MyProjectPresenter 的一个方法:

@Before
override fun setUp() {
    super.setUp()
    view = PowerMockito.mock(IMyProjectView::class.java)
    mHomeUseCase = PowerMockito.spy(HomeUseCase())
    mPresenter = PowerMockito.spy(MyProjectPresenter())

    mPresenter.mMyProjectFragment = view
    mPresenter.mHomeUseCase = mHomeUseCase
}

@Test
@PrepareForTest(AppInfo::class)
fun testGetMyProjects_WhenLoadData_ThenSuccess() {
       //1、准备
    PowerMockito.mockStatic(AppInfo::class.java)
    var appInfo = PowerMockito.mock(AppInfo::class.java)
    PowerMockito.`when`(appInfo.userId).thenReturn("123")
    PowerMockito.`when`(AppInfo.getInstance()).thenReturn(appInfo)

    var projectDetail = ProjectDetailDTO()
    var list = ArrayList<ProjectDetailDTO>()
    list.add(projectDetail)
    mockUcBusiness(mHomeUseCase, Observable.just(list))

    //2、调用
    mPresenter.getMyProjects()

    //3、验证
    verifyBusiness(mHomeUseCase)
    verify(view).hideInitial()
    verify(view).refreshComplete()
    verify(view).render(any())
}

另外,测试用例不可避免会调用Android系统的API,在编译阶段我们依赖的是 android.jar,它没有任何实现,所有方法都是 throw new RuntimeException("Stub!"),只有运行在真实的Android系统上才会去加载 Framework 层的实现代码。那么运行在 JVM 上的测试代码便会出错,如果采用Mock桩类的方式,用例会比较繁琐。所以又引入 Robolectric 来解决这个问题,它实现了一套能运行在 JVM 上的 Android Shadow 代码,模拟系统 API 的调用过程。

Dagger、Retrofit & RxJava

Kotlin 依然支持 Retrofit、RxJava、Dagger2 等开源框架。通过 kapt 编译器插件支持注解处理器,kapt 同样能够处理 Java 文件,是时候替换掉默认的 annotationProcessor 了。下面介绍如何使用 Dagger2:

在 build.gradle 中添加:

apply plugin: 'kotlin-kapt'
dependencies {
    kapt "com.google.dagger:dagger-compiler:${DAGGER_VERSION}"
 }

先实现主模块!

ApplicationComponent

//java
@Singleton
@Component(modules = { ApplicationModule.class })
public interface ApplicationComponent {
    void inject(BimApplication application);
    ...
}

ApplicationModule

@Module
class ApplicationModule(val application: BimApplication) {
    @Provides
    fun provideBimApplication() = application

     @Provides
    fun provideBimRetrofit(okHttpClient: OkHttpClient): Retrofit{
        ...
    }
     ...
}

在 BimApplication 的 OnCreat 方法中注入:

//java
DaggerApplicationComponent.builder()
            .applicationModule(new ApplicationModule(this))
            .build().inject(this)

其中,ApplicationModule 提供全局使用的实例对象。

接下来,在 Activity 的生命周期内实现分模块:

定义 Base Module 类 AbsActivityModule

@Module
abstract class AbsActivityModule<T : Activity>(@JvmField var mActivity: T) {

    @Provides
    fun provideHostActivity(): T {
        return mActivity
    }
}

ProjectNoticeComponent

@PerActivity
@Component(dependencies = [ApplicationComponent::class], modules = [ProjectNoticeComponent.ProjectNoticeModule::class])
interface ProjectNoticeComponent : MembersInjector<ProjectNoticeActivity> {

    @Module
    class ProjectNoticeModule(activity: ProjectNoticeActivity) : AbsActivityModule<ProjectNoticeActivity>(activity)
}

与Java不同,Kotlin 中默认为静态内部类,成员内部类则用 inner 关键字修饰。

为了方便,将 Activity 的 Component 和 Module 放在了一起。最后,Activity中注入:

class ProjectNoticeActivity : BaseActivity(), IProjectNoticeView {
    override fun onCreate(savedInstanceState: Bundle?) {
           super.onCreate(savedInstanceState)
        DaggerProjectNoticeComponent.builder()
                .applicationComponent(applicationComponent)
                .projectNoticeModule(ProjectNoticeComponent.ProjectNoticeModule(this))
                .build()
                .injectMembers(this)
    }

Kotlin 中使用 Dagger2 是不是非常简单?Module 负责生产对象,需要使用的地方直接 @Inject 即可,项目中所有的 Presenter 和 UseCase 都是可以直接使用的,让代码进一步解耦。

第一节中我们使用 data 关键字定义了数据类,它可以用来承载 Json 转换过来的 Response 数据。下面将实现一个标准的网络请求。

创建一个API Interface文件 BimApi.kt:

interface BimApi {
    ...
    @FormUrlEncoded
    @POST("api/m/project/modify")
    fun modifyProject(@Field("prjId") prjId: String?, @Field("prjTitle") prjTitle: String?,
                      @Field("prjNotice") prjNotice: String?): Observable<ProjectDetailDTO>
}

使用 Dagger 注入全局的 Retrofit 对象:

@Singleton
class KCloudDataStore
@Inject
constructor(retrofit: Retrofit){
    ...
    fun modifyProject(prjId: String?, prjTitle: String?, prjNotice: String?): Observable<ProjectDetailDTO> {
        return retrofit.create(BimApi::class.java).modifyProject(prjId, prjTitle, prjNotice)
    }
}

实现了一个访问网络的方法供上层调用,并且采用 RxJava 来管理异步线程。如图:

从网络层拿到 Observable 对象,然后在 UseCase 中 subscribe

Observable<ProjectDetailDTO> 
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe (object : DefaultSubscriber<ProjectDetailDTO>() {
        override fun onNext(projectDetailDTO: ProjectDetailDTO?) {
            super.onNext(projectDetailDTO)
            //UI Thread
            //TODO
        }
    })

object 关键字声明匿名内部类

最后

目前,新的功能已完全使用 Kotlin 进行开发,底层封装的一些通用模块暂时兼容 Java,后续会不断进行重构,直至完全切换到 Kotlin。同时,新的语言还需要一个熟悉的过程,使用上难免会产生疏忽和偏差,比如,lateinit var修饰的属性,使用前如果未初始化,运行时会报 kotlin.UninitializedPropertyAccessException。

尚未使用的特性,如反射、协程等。除此之外,Kotlin 几乎可以用于任何类型的开发,无论是服务器端、Web、Android 还是 Native。

参考资料

https://kotlinlang.org/docs/reference

https://android.github.io/kotlin-guides/style.html

https://github.com/JetBrains/kotlin

https://github.com/powermock/powermock/wiki/Mockito

https://blog.jetbrains.com/kotlin

https://android.jlelse.eu/keddit-part-9-unit-test-with-kotlin-mockito-spek-76709812e3b6


本文来自网易实践者社区,经作者潘威授权发布。