MVC、MVP、MVVM以及使用MVVM搭建GitHub客户端

本文章已授权微信公众号郭霖(guolin_blog)转载。

本文章讲解的内容是MVCMVPMVVM以及使用MVVM搭建GitHub客户端,以下是框架GitHub地址

Dagger2版本:Dagger2

Koin版本:Koin

在讲解之前,我想先聊一下MVCMVPMVVM相关的概念。

MVC

MVC(Model-View-Controller)的概念最早源自于Erich GammaRichard HelmRaplph JohnsonJohn Vlissides这四位大牛在讨论设计模式中的观察者模式时的想法;Trygve Reenskaug1979年5月的时候发表了一篇文章叫做Thing-Model-View-Editor,这篇文章中虽然没提到Controller,但是他提到的Editor就是非常接近这个概念,7个月后,他在发表的一篇叫做Models-Views-Controllers中正式提出了MVC这个概念。

  • Model(数据层):负责处理数据逻辑
  • View(视图层):负责处理视图显示,在Android中使用xml描述视图。
  • Controller(控制层):在Android中的ActivityFragment承担此层的重任,负责处理业务逻辑

这里要注意的是,ActivityFragment并非是标准的Controller,因为它们不仅要负责处理业务逻辑,还要去控制界面显示,这样导致的结果是随着业务的复杂度不断提高,ActivityFragment会变得非常臃肿,不利于代码的维护。

MVP

MVP(Model-View-Presenter)MVC进一步演化出来的,由MicrosoftMartin Fowler提出。

  • Model(数据层):负责处理数据逻辑
  • View(视图层):负责处理视图显示,在Android中使用xml或者Java/Kotlin代码去实现视图,ActivityFragment承担了此层的责任。
  • Presenter:负责连接Model层和View层,是这两层的中间纽带,负责处理业务逻辑

MVP中,Model层和View层之间不能有交互,要通过Presenter层进行交互,其中View层和Presenter层是通过接口进行交互,可以定义Contract(契约)接口来指定View层和Presenter之间的契约,官方代码如下:

interface AddEditTaskContract {

    interface View : BaseView<Presenter> {

        var isActive: Boolean

        fun showEmptyTaskError()

        fun showTasksList()

        fun setTitle(title: String)

        fun setDescription(description: String)

    }

    interface Presenter : BasePresenter {

        var isDataMissing: Boolean

        fun saveTask(title: String, description: String)

        fun populateTask()

    }

}

MVP中,View不会部署任何的业务逻辑,从而比较,它被称为被动视图(Passive View),意思是它没有任何的主动性,而且这样的设计也方便做单元测试,但是也会有如下问题:

  1. 尽管减少了View层的代码,但是随着业务的复杂度不断提高,Presenter层的代码也会变得越来越臃肿。
  2. View层和Presenter层是通过接口交互的,随着业务的复杂度不断提高,接口数量会大量增加
  3. 如果View层更新的话,就像UI的输入数据的变化,都需要主动去调用Presenter层的代码,缺乏自动性监听性
  4. MVP是以UI事件为驱动的传统模型更新UI需要保证能持有控件引用,而且更新UI需要考虑Activity或者Fragment的生命周期,防止内存泄漏

MVVM

MVVM(Model-View-ViewModel)MVP进一步演化出来的,它也是由MicrosoftMartin Fowler提出。

  • Model(数据层):负责处理数据逻辑
  • View(视图层):负责处理视图显示,在Android中使用xml或者Java/Kotlin代码去实现视图,ActivityFragment承担了此层的责任。
  • ViewModel:负责连接Model层和View层,是这两层的中间纽带,负责处理业务逻辑View层和ViewModel层是双向绑定的,View层的变动会自动反映在ViewModel层,ViewModel层的变动也会自动反映在View层。

使用MVVM后,每一层的职责也更加清晰了,也方便做单元测试,同时因为View层和ViewModel层是双向绑定,开发者不需要再去主动处理部分逻辑了,减少了不少胶水代码,如果使用了一些数据绑定的库,例如在Android中的DataBinding,可以减少更加多的胶水代码

实践

我使用GitHubAPI开发了一个简单的客户端,用MVVM来搭建,使用Kotlin编写,界面如下图所示:

登录

LoginPage.png

首页

MainPage.png

个人中心

PersonalCenterPage.png

架构设计

整体分为六部分,每一部分都按业务逻辑区分:

data

data存放数据相关的代码,如图所示:

data.png
  • local本地数据,存放本地存储逻辑(MMKV相关的逻辑),例如:UserLocalDataSource(用户本地数据源)
  • model数据类,存放请求数据类(request)响应数据类(response),例如:LoginRequestData(登录请求数据类)UserAccessTokenData(用户访问Token数据类)UserInfoData(用户信息数据类)ListData(基础的列表数据类)Repository(GitHub仓库请求和响应数据类)
  • remote远程数据,存放网络请求逻辑(OkHttp3和Retrofit2相关的逻辑),例如:UserRemoteDataSource(用户远程数据源)RepositoryRemoteDataSource(GitHub仓库远程数据源)
  • repository仓库,例如:UserInfoRepository(用户信息仓库)GitHubRepository(GitHub仓库)

Repository持有LocalDataSource(本地数据源)RemoteDataSource(远程数据源)引用,暴露相关的数据出去,外界不必关心repository内部是如何处理数据的。

di

di存放依赖注入相关的代码。

Dagger2版本:

如图所示:

diDagger2.png
  • ApplicationComponentApplication组件,将AndroidSupportInjectionModuleApplicationModuleNetworkModuleRepositoryModuleMainModuleUserModuleGitHubRepositoryModule注入到Application
  • ApplicationModule:提供跟随Application生命周期的业务模块,例如:LocalDataSource(本地数据源)RemoteDataSource(远程数据源)
  • GitHubRepositoryModule业务模块,提供GitHub仓库业务模块
  • MainModule业务模块,提供main(启动页和主页)业务模块
  • NetworkModule网络模块,例如:OkHttp3Retrofit2
  • RepositoryModule仓库模块,例如:UserInfoRepository(用户信息仓库)GitHubRepository(GitHub仓库)
  • UserModule业务模块,提供用户业务模块
  • ViewModelFactoryViewModel工厂,创建不同业务的ViewModel

Koin版本:

如图所示:

diKoin.png
  • ApplicationModule:存放ApplicationModuleNetworkModuleRepositoryModuleMainModuleUserModuleGitHubRepositoryModule,并且生成ApplicationModulesList提供Koin使用。

ui

ui存放UI相关的代码,例如:ActivityFragmentViewModel自定义View等等,如图所示:

ui.png
  • mainmain(启动页和主页)相关的ActivityViewModel代码。
  • recyclerviewRecyclerView相关的代码,包括BaseViewHolderBaseViewTypeNoDataViewTypeBaseDataBindingAdapterMultiViewTypeDataBindingAdapter
  • repositoryGitHub仓库相关的ActivityFragmentViewModelAdapter代码。
  • user用户相关的ActivityFragmentViewModel代码。
  • BaseActivityAcitivity基类
  • BaseFragmentFragment基类
  • BaseViewModelViewModel基类
  • NoViewModel:一个继承BaseViewModel的类,如果该Acitivity或者Fragment不需要用到ViewModel的话可以使用这个类。

ViewModel持有Repository引用,从Repository拿到想要的数据;ViewModel不会持有任何View层(例如:Activity(包括xml)Fragment(包括xml))的引用,通过双向绑定框架(DataBinding)获取View层反馈给ViewModel层的数据,并且对这些数据进行操作。

utils

utils存放工具文件,如图所示:

utils.png
  • ActivityExt:存放Activity扩展函数
  • BindingAdapters:存放使用DataBinding@BindingAdapters注解的代码。
  • BooleanExt:存放Boolean的扩展函数,如果想深入了解的话,可以看下我这篇文章:Kotlin系列——泛型型变
  • DateUtils:存放日期相关的代码。
  • FragmentExt:存放Fragment扩展函数
  • GsonExt:存放Gson相关的扩展函数
  • Language:存放GitHub仓库相关的名字图片
  • OnTabSelectedListenerBuilder:存放OnTabSelectedListener相关的代码,用作使用DSL,如果想深入了解的话,可以看下我这篇文章:Kotlin系列——DSL
  • Preferences:存放MMKV相关的代码,如果想深入了解的话,可以看下我这篇文章:Kotlin系列——封装MMKV及其相关Kotlin特性
  • SingleLiveEvent:一个生命周期感知观察对象,在订阅后只发送新的功能,可以用于导航SnackBar消息等事件,它可以避免一个常见问题,就是如果观察者处于活跃状态,在配置更改(例如:旋转)的时候是可以发射事件,这个类可以解决这个问题,它只在你显式地调用setValue()方法或者call()方法,它才会调用可观察对象
  • ToastExt:存放Toast扩展函数

前缀AndroidGenericFramework的文件

如图所示:

PrefixAndroidGenericFrameworkFile.png
  • AndroidGenericFrameworkAppGlideModule:定义在应用程序(Application)内初始化Glide时要使用的一组依赖项选项,要注意的是,在一个应用程序(Application)只能存在一个AppGlideModule,如果是库(Libraries)必须使用LibraryGlideModule
  • AndroidGenericFrameworkApplication:本框架的Application
  • AndroidGenericFrameworkConfiguration:存放本框架的配置信息
  • AndroidGenericFrameworkExtra:存放ActivityFragment附加数据的名称
  • AndroidGenericFrameworkFragmentTag:存放Fragment标记名,这个标记名是为了以后使用FragmentManagerfindFragmentByTag(String)方法的时候检索Fragment

单元测试

如图所示:

test.png
  • dataFakeDataSource用来创建假的数据源UserRemoteDataSourceTest(用户远程数据源测试类)RepositoryRemoteDataSourceTest(GitHub仓库远程数据源测试类)都是模拟API调用
  • utils:存放工具文件测试类
  • viewmodel:存放ViewModel测试类

下面我来介绍下使用到的Android架构组件

OkHttp3和Retrofit2

网络请求库使用了基于OkHttp3封装的Retrofit2,框架部分代码如下:

// NetworkModule.kt
/**
 * Created by TanJiaJun on 2020/4/4.
 */
@Suppress("unused")
@Module
open class NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(localDataSource: UserLocalDataSource): OkHttpClient =
            OkHttpClient.Builder()
                    .connectTimeout(AndroidGenericFrameworkConfiguration.CONNECT_TIMEOUT, TimeUnit.MILLISECONDS)
                    .readTimeout(AndroidGenericFrameworkConfiguration.READ_TIMEOUT, TimeUnit.MILLISECONDS)
                    .addInterceptor(BasicAuthInterceptor(localDataSource))
                    .build()

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit =
            Retrofit.Builder()
                    .client(client)
                    .addConverterFactory(ScalarsConverterFactory.create())
                    .addConverterFactory(GsonConverterFactory.create())
                    .baseUrl(String.format("%1\$s://%2\$s/", "https", AndroidGenericFrameworkConfiguration.HOST))
                    .build()

}

Retrofit2.6以后支持Kotlin协程,和旧版本有如下区别:

  1. 可以直接作用于挂起函数(suspend fun)
  2. 可以直接返回我们想要的数据对象,而不再返回Deferred<T>对象。
  3. 不再需要调用协程await函数,因为Retrofit已经帮我们调用了。

框架部分代码如下:

// RepositoryRemoteDataSource.kt
interface Service {

    @GET("search/repositories")
    suspend fun fetchRepositories(@Query("q") query: String,
                                  @Query("sort") sort: String = "stars"): ListData<RepositoryResponseData>

}

Glide v4

图片加载库使用了Glide v4,我这里用到DataBinding组件中的@BindingAdapter注解,框架部分代码如下:

// BindingAdapters.kt
@BindingAdapter(value = ["url", "placeholder", "error"], requireAll = false)
fun ImageView.loadImage(url: String?, placeholder: Drawable?, error: Drawable?) =
        Glide
                .with(context)
                .load(url)
                .placeholder(placeholder ?: context.getDrawable(R.mipmap.ic_launcher))
                .error(error ?: context.getDrawable(R.mipmap.ic_launcher))
                .transition(DrawableTransitionOptions.withCrossFade(DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build()))
                .into(this)

Android Jetpack

Android Jetpack是一套工具指南,可以帮助开发者更轻松地编写优质应用,这些组件可以帮助开发者遵循最佳做法,让开发者摆脱编写样板代码的工作,并且简化复杂任务,以便开发者将精力集中放在所需的代码上。我使用了DataBindingLifecycleLiveDataViewModel,下面我大概地介绍下。

DataBinding

DataBinding是实现MVVM核心架构组件,它有如下优点

  1. 可以降低布局和逻辑的耦合度,使代码逻辑更加清晰。
  2. 可以省去findViewById这样的代码,大量减少View层的代码。
  3. 数据能单向双向绑定到layout文件。
  4. 能够自动进行空判断,可以避免空指针异常

框架部分代码如下:

<!-- activity_personal_center.xml -->
<ImageView
    android:id="@+id/iv_head_portrait"
    error="@{@drawable/ic_default_avatar}"
    placeholder="@{@drawable/ic_default_avatar}"
    url="@{viewModel.avatarUrl}"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center_horizontal"
    android:layout_marginTop="16dp"
    android:contentDescription="@string/head_portrait"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/divider_line"
    tools:background="@drawable/ic_default_avatar" />

Lifecycle

Lifecycle组件可以执行操作来响应ActivityFragment生命周期状态的变化。

LiveDataViewModel都使用到Lifecycle组件,框架部分代码如下:

// LoginFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) =
        with(binding) {
            lifecycleOwner = this@LoginFragment
            viewModel = this@LoginFragment.viewModel
            handlers = this@LoginFragment
        }.also {
            registerLoadingProgressBarEvent()
            registerSnackbarEvent()
            observe()
        }

我们看下ViewDataBindingsetLifecycleOwner方法,代码如下:

// ViewDataBinding.java
@MainThread
public void setLifecycleOwner(@Nullable LifecycleOwner lifecycleOwner) {
    if (mLifecycleOwner == lifecycleOwner) {
        return;
    }
    if (mLifecycleOwner != null) {
        mLifecycleOwner.getLifecycle().removeObserver(mOnStartListener);
    }
    mLifecycleOwner = lifecycleOwner;
    if (lifecycleOwner != null) {
        if (mOnStartListener == null) {
            mOnStartListener = new OnStartListener(this);
        }
        lifecycleOwner.getLifecycle().addObserver(mOnStartListener);
    }
    for (WeakListener<?> weakListener : mLocalFieldObservers) {
        if (weakListener != null) {
            weakListener.setLifecycleOwner(lifecycleOwner);
        }
    }
}

这里的LifecyclerOwner是一个具有Android生命周期的类,自定义组件可以使用它的事件来处理生命周期更改,而无需在Activity或者Fragment实现任何代码。

LiveData

LiveData是一种可观察数据存储器类,它具有生命周期感知能力,遵循应用组件(例如:ActivityFragmentService(可以使用LifecycleService,它是实现了LifecycleOwner接口的Service))的生命周期,这种感知能力确保LiveData仅更新处于活跃生命周期状态应用组件观察者

我之前写过一篇关于LiveData的文章,大家可以阅读一下:

Android Jetpack系列——LiveData源码分析

框架部分代码如下:

// LoginViewModel.kt
val username = MutableLiveData<String>()
val password = MutableLiveData<String>()

private val _isLoginEnable = MutableLiveData<Boolean>()
val isLoginEnable: LiveData<Boolean> = _isLoginEnable

val isLoginSuccess = MutableLiveData<Boolean>()

fun checkLoginEnable() {
    _isLoginEnable.value = !username.value.isNullOrEmpty() && !password.value.isNullOrEmpty()
}

ViewModel

ViewModel是一个负责准备和管理Activity或者Fragment的类,它还可以处理ActivityFragment与应用程序其余部分的通信(例如:调用业务逻辑类)。

ViewModel总是在一个Activity或者一个Fragment创建的,并且只要对应的Activity或者Fragment处于活动状态的话,它就会被保留(例如:如果它是个Activity,就会直到它finished)。

换句话说,这意味着一个ViewModel不会因为配置的更改(例如:旋转)而被销毁,所有的新实例将被重新连接到现有的ViewModel

ViewModel的目的是获取保存Activity或者Fragment所需的信息,Activity或者Fragment应该能够观察到ViewModel中的变化,通常通过LiveData或者Android Data Binding公开这些信息。

我之前写过一篇关于ViewModel的文章,大家可以阅读一下:

Android Jetpack系列——ViewModel源码分析

框架部分代码如下:

// RepositoryViewModel.kt
/**
 * Created by TanJiaJun on 2020-02-07.
 */
class RepositoryViewModel @Inject constructor(
        private val repository: GitHubRepository
) : BaseViewModel() {

    private val _isShowRepositoryView = MutableLiveData<Boolean>()
    val isShowRepositoryView: LiveData<Boolean> = _isShowRepositoryView

    private val _repositories = MutableLiveData<List<RepositoryData>>()
    val repositories: LiveData<List<RepositoryData>> = _repositories

    fun getRepositories(languageName: String) =
            launch(
                    uiState = UIState(isShowLoadingView = true, isShowErrorView = true),
                    block = { repository.getRepositories(languageName) },
                    success = {
                        if (it.isNotEmpty()) {
                            _repositories.value = it
                            _isShowRepositoryView.value = true
                        }
                    }
            )

}

协程

协程源自SimulaModula-2语言,它是一种编程思想,并不局限于特定的语言,在1958年的时候,Melvin Edward Conway提出这个术语并用于构建汇编程序。在Android中使用它可以简化异步执行的代码,它是在版本1.3中添加到Kotlin

Android平台上,协程有助于解决两个主要问题:

  • 管理长时间运行的任务,如果管理不当,这些任务可能会阻塞主线程并导致你的应用界面冻结
  • 提供主线程安全性,或者从主线程安全地调用网络或者磁盘操作

管理长时间运行的任务

Android平台上,每个应用都有一个用于处理界面并且管理用户交互主线程。如果你的应用为主线程分配的工作太多,会导致界面呈现速度缓慢或者界面冻结对触摸事件的响应速度很慢,例如:网络请求JSON解析写入或者读取数据库遍历大型列表,这些都应该在工作线程完成。

协程在常规函数的基础上添加了两项操作,用于处理长时间运行的任务。在invoke或者callreturn之外,协程添加了suspendresume

  • suspend用于暂停执行当前协程,并保存所有的局部变量
  • resume用于让已暂停协程从其暂停处继续执行。

要调用suspend函数,只能从其他suspend函数进行调用,或者通过使用协程构建器(例如:launch)来启动新的协程

Kotin使用堆栈帧来管理要运行哪个函数以及所有的局部变量暂停协程时会复制并保存当前的堆栈帧以供稍后使用;恢复协程时会将堆栈帧从其保存位置复制回来,然后函数再次开始运行

使用协程确保主线程安全

Kotlin协程使用调度程序来确定哪些线程用于执行协程,所有协程都必须在调度程序中运行,协程可以自行暂停,而调度程序负责将其恢复

Kotlin提供了三个调度程序,可以使用它们来指定应在何处运行协程

  • Dispatchers.Main:使用此调度程序可在Android主线程上运行协程,只能用于界面交互执行快速工作,例如:调用suspend函数运行Android界面框架操作更新LiveData对象
  • Dispatcher.Default:此调度程序适合在主线程之外执行占用大量CPU资源的工作,例如:对列表排序解析JSON
  • Dispatchers.IO:此调度程序适合在主线程之外执行磁盘或者网络I/O,例如:操作数据库(使用Room)向文件中写入数据或者从文件中读取数据运行任何网络操作

指定CoroutineScope

在定义协程时,必须指定其CoroutineScopeCoroutineScope可以管理一个或者多个相关的协程,可以使用它在指定范围内启动新协程

与调度程序不同,CoroutineScope不运行协程。

CoroutineScope一项重要功能就是在用户离开应用中内容区域时停止执行协程可以确保所有正在运行的操作都能正确停止

Android平台上,可以将CoroutineScope实现与组件的生命周期相关联,例如:LifecycleViewModel,这样可以避免内存泄漏和不再对与用户相关的Activity或者Fragment执行额外的工作。

启动协程

可以通过以下两种方式来启动协程

  • launch可以启动新协程,但是不将结果返回给调用方。
  • async可以启动新协程,并且允许使用await暂停函数返回结果。

同时我还使用了Kotlin流(Flow),它的设计灵感来源于响应式流(Reactive Streams),所以如果开发者熟悉RxJava的话,也应该很快就能熟悉它。

我之前写过几篇关于RxJava的文章,大家可以阅读一下:

RxJava2源码分析——订阅

RxJava2源码分析——线程切换

RxJava2源码分析——Map操作符

RxJava2源码分析——FlatMap和ConcatMap及其相关并发编程分析

框架部分代码如下:

// LoginViewModel.kt
@ExperimentalCoroutinesApi
@FlowPreview
fun login() =
        launchUI {
            launchFlow {
                repository.run {
                    cacheUsername(username.value ?: "")
                    cachePassword(password.value ?: "")
                    authorizations()
                }
            }
                    .flatMapMerge {
                        launchFlow { repository.getUserInfo() }
                    }
                    .flowOn(Dispatchers.IO)
                    .onStart { uiLiveEvent.showLoadingProgressBarEvent.call() }
                    .catch {
                        val responseThrowable = ExceptionHandler.handleException(it)
                        uiLiveEvent.showSnackbarEvent.value = "${responseThrowable.errorCode}:${responseThrowable.errorMessage}"
                    }
                    .onCompletion { uiLiveEvent.dismissLoadingProgressBarEvent.call() }
                    .collect {
                        repository.run {
                            cacheUserId(it.id)
                            cacheName(it.login)
                            cacheAvatarUrl(it.avatarUrl)
                        }
                        isLoginSuccess.value = true
                    }
        }

Dagger2

Dagger2是针对JavaAndroid全静态编译阶段完成依赖注入框架

Dagger这个库的取名不仅仅是来自它的本意——匕首Jake Wharton在介绍Dagger的时候指出,Dagger的意思是DAG-erDAG的意思有向无环图(Directed Acyclic Graph),也就是说Dagger是一个基于有向无环图结构的依赖注入库,因此Dagger在使用过程中不能出现循环依赖

Square公司受到Guice的启发开发了Dagger,它是一种半静态半运行时依赖注入框架,虽然说依赖注入完全静态的,但是生成有向无环图还是基于反射来实现,这无论在大型服务端应用或者Android应用上都不是最优方案,然后Google的工程师fork了这个项目后,受到AutoValue项目的启发,对其进行改造,就有了现在这个Dagger2Dagger2Dagger比较的话,有如下区别:

  • 更好的性能Google声称提高13%处理性能,没有使用反射生成有向无环图,而是在编译阶段生成。
  • 更高效和优雅,而且更容易调试:作为升级版的Dagger,从半静态变成完全静态,从Map式API变成申明式API(例如:@Module),生成的代码更加高效优雅,一旦出错在编译阶段就能发现。

因为Dagger2没使用反射缺乏动态机制,所以丧失一定的灵活性,但是总体来说是利远远大于弊的。

我在主分支(master)使用的是Dagger2和相关的Dagger-Android,框架部分代码如下:

// ApplicationComponent.kt
/**
 * Created by TanJiaJun on 2020/3/4.
 */
@Singleton
@Component(
        modules = [
            AndroidSupportInjectionModule::class,
            ApplicationModule::class,
            NetworkModule::class,
            RepositoryModule::class,
            MainModule::class,
            UserModule::class,
            GitHubRepositoryModule::class
        ]
)
interface ApplicationComponent : AndroidInjector<AndroidGenericFrameworkApplication> {

    @Component.Factory
    interface Factory {

        fun create(@BindsInstance applicationContext: Context): ApplicationComponent

    }

}

Koin

Koin是一个面向Kotlin开发人员实用的轻量级依赖注入框架

官方声称是用纯Kotlin编写,只使用函数解析没有代理没有代码生成没有反射

我在分支mvvm-koin使用的是Koin,框架部分代码如下:

// ApplicationModule.kt
/**
 * Created by TanJiaJun on 2020/5/5.
 */
val applicationModule = module {
    single {
        UserLocalDataSource(MMKV.mmkvWithID(
                AndroidGenericFrameworkConfiguration.MMKV_ID,
                MMKV.SINGLE_PROCESS_MODE,
                AndroidGenericFrameworkConfiguration.MMKV_CRYPT_KEY
        ))
    }

    single { UserRemoteDataSource(get()) }

    single { RepositoryRemoteDataSource(get()) }
}

val networkModule = module {
    single<OkHttpClient> {
        OkHttpClient.Builder()
                .connectTimeout(AndroidGenericFrameworkConfiguration.CONNECT_TIMEOUT, TimeUnit.MILLISECONDS)
                .readTimeout(AndroidGenericFrameworkConfiguration.READ_TIMEOUT, TimeUnit.MILLISECONDS)
                .addInterceptor(BasicAuthInterceptor(get()))
                .build()
    }

    single<Retrofit> {
        Retrofit.Builder()
                .client(get())
                .addConverterFactory(ScalarsConverterFactory.create())
                .addConverterFactory(GsonConverterFactory.create())
                .baseUrl(String.format("%1\$s://%2\$s/", SCHEMA_HTTPS, AndroidGenericFrameworkConfiguration.HOST))
                .build()
    }
}

val repositoryModule = module {
    single { UserInfoRepository(get(), get()) }

    single { GitHubRepository(get()) }
}

val mainModule = module {
    scope<SplashActivity> {
        viewModel { SplashViewModel(get()) }
    }

    scope<MainActivity> {
        viewModel { MainViewModel(get()) }
    }
}

val userModule = module {
    scope<LoginFragment> {
        viewModel { LoginViewModel(get()) }
    }

    scope<PersonalCenterActivity> {
        viewModel { PersonalCenterViewModel(get()) }
    }
}

val githubRepositoryModule = module {
    scope<RepositoryFragment> {
        viewModel { RepositoryViewModel(get()) }
    }
}

val applicationModules = listOf(
        applicationModule,
        networkModule,
        repositoryModule,
        mainModule,
        userModule,
        githubRepositoryModule
)

private const val SCHEMA_HTTPS = "https"

MMKV

MMKV是基于mmap内存映射key-value组件,底层序列化/反序列化使用protobuf实现,性能高稳定性强,而且Android这边还支持多进程

我之前写过一篇关于MMKV的文章,大家可以阅读一下:

Kotlin系列——封装MMKV及其相关Kotlin特性

我使用MMKV代替Android组件中的SharedPreferences,作为本地存储数据组件,框架部分代码如下:

// Preferences.kt
/**
 * Created by TanJiaJun on 2020-01-11.
 */
private inline fun <T> MMKV.delegate(
        key: String? = null,
        defaultValue: T,
        crossinline getter: MMKV.(String, T) -> T,
        crossinline setter: MMKV.(String, T) -> Boolean
): ReadWriteProperty<Any, T> =
        object : ReadWriteProperty<Any, T> {
            override fun getValue(thisRef: Any, property: KProperty<*>): T =
                    getter(key ?: property.name, defaultValue)

            override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
                setter(key ?: property.name, value)
            }
        }

fun MMKV.boolean(
        key: String? = null,
        defaultValue: Boolean = false
): ReadWriteProperty<Any, Boolean> =
        delegate(key, defaultValue, MMKV::decodeBool, MMKV::encode)

fun MMKV.int(key: String? = null, defaultValue: Int = 0): ReadWriteProperty<Any, Int> =
        delegate(key, defaultValue, MMKV::decodeInt, MMKV::encode)

fun MMKV.long(key: String? = null, defaultValue: Long = 0L): ReadWriteProperty<Any, Long> =
        delegate(key, defaultValue, MMKV::decodeLong, MMKV::encode)

fun MMKV.float(key: String? = null, defaultValue: Float = 0.0F): ReadWriteProperty<Any, Float> =
        delegate(key, defaultValue, MMKV::decodeFloat, MMKV::encode)

fun MMKV.double(key: String? = null, defaultValue: Double = 0.0): ReadWriteProperty<Any, Double> =
        delegate(key, defaultValue, MMKV::decodeDouble, MMKV::encode)

private inline fun <T> MMKV.nullableDefaultValueDelegate(
        key: String? = null,
        defaultValue: T?,
        crossinline getter: MMKV.(String, T?) -> T,
        crossinline setter: MMKV.(String, T) -> Boolean
): ReadWriteProperty<Any, T> =
        object : ReadWriteProperty<Any, T> {
            override fun getValue(thisRef: Any, property: KProperty<*>): T =
                    getter(key ?: property.name, defaultValue)

            override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
                setter(key ?: property.name, value)
            }
        }

fun MMKV.byteArray(
        key: String? = null,
        defaultValue: ByteArray? = null
): ReadWriteProperty<Any, ByteArray> =
        nullableDefaultValueDelegate(key, defaultValue, MMKV::decodeBytes, MMKV::encode)

fun MMKV.string(key: String? = null, defaultValue: String? = null): ReadWriteProperty<Any, String> =
        nullableDefaultValueDelegate(key, defaultValue, MMKV::decodeString, MMKV::encode)

fun MMKV.stringSet(
        key: String? = null,
        defaultValue: Set<String>? = null
): ReadWriteProperty<Any, Set<String>> =
        nullableDefaultValueDelegate(key, defaultValue, MMKV::decodeStringSet, MMKV::encode)

inline fun <reified T : Parcelable> MMKV.parcelable(
        key: String? = null,
        defaultValue: T? = null
): ReadWriteProperty<Any, T> =
        object : ReadWriteProperty<Any, T> {
            override fun getValue(thisRef: Any, property: KProperty<*>): T =
                    decodeParcelable(key ?: property.name, T::class.java, defaultValue)

            override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
                encode(key ?: property.name, value)
            }
        }

可以这样使用,框架部分代码如下:

// UserLocalDataSource.kt
var accessToken by mmkv.string("user_access_token", "")
var userId by mmkv.int("user_id", -1)
var username by mmkv.string("username", "")
var password by mmkv.string("password", "")
var name by mmkv.string("name", "")
var avatarUrl by mmkv.string("avatar_url", "")

ViewPager2

框架中在展示GitHub仓库的时候用到了ViewPager2,比起ViewPager,有以下几个好处

  • 支持垂直方向分页ViewPager2除了支持水平方向分页,也支持垂直方向分页,可以通过android:orientation属性或者setOrientation()方法来启动垂直分页,代码如下:

    android:orientation="vertical"
    
  • 支持从右到做(RTL)ViewPager2会根据语言环境自动启动从右到做(RTL)分页,可以通过设置android:layoutDirection属性或者setLayoutDirection()方法来启动RTL分页,代码如下:

    android:layoutDirection="rtl"
    

框架部分代码如下:

<!-- activity_main.xml -->
<androidx.viewpager2.widget.ViewPager2
    android:id="@+id/vp_repository"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/tl_repository" />

MockK

MockK一个专门为Kotlin这门语言打造的测试框架。在Java中,我们常用的是Mockito,但是如果我们使用Kotlin的话,就会遇到一些问题,常见的问题如下:

  • 不能测试静态方法:可以使用PowerMock解决。

  • Mockito cannot mock/spy because:-final class:这是因为在Kotlin任何类预设都是final的,Mockito预设情况下不能mock一个final的类。

  • java.lang.illegalStateException:anyObjecet() must not be null:如果我们使用eq()any()capture()argumentCaptor()的话就会遇到这个问题了,因为这些方法返回的对象可能是null,如果作用在一个非空的参数的话,就会报这个异常了,解决办法是可以使用如下文件:

  • when要加上反引号才能使用:因为whenKotlin中的关键字

KotlinMockito同时使用会有如上说的种种不便,最后我决定使用MockK这个,我使用的测试相关的如下:

// build.gradle(:app)
testImplementation "junit:junit:$junitVersion"
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttpVersion"
testImplementation "io.mockk:mockk:$mockkVersion"
testImplementation "com.google.truth:truth:$truthVersion"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinxCoroutinesVersion"
testImplementation "android.arch.core:core-testing:$coreTestingVersion"
  • com.squareup.okhttp3:mockwebserver:用来模拟Web服务器的。

  • com.google.truth:truth:可以使测试断言失败消息更具有可读性,与AssertJ相似,它支持很多JDKGuava类型,并且可以扩展到其他类型。

我这边是对数据源ViewModel工具文件进行单元测试

框架部分代码如下:

// LoginViewModelTest.kt
@ExperimentalCoroutinesApi
@FlowPreview
@Test
fun login_success() {
    runBlocking {
        viewModel.username.value = "1120571286@qq.com"
        viewModel.password.value = "password"
        coEvery { repository.authorizations() } returns userAccessTokenData
        coEvery { repository.getUserInfo() } returns userInfoData
        viewModel.login()
        val observer = mockk<Observer<Boolean>>(relaxed = true)
        viewModel.isLoginSuccess.observeForever(observer)
        viewModel.viewModelScope.coroutineContext[Job]?.children?.forEach { it.join() }
        verify { observer.onChanged(match { it }) }
    }
}

@ExperimentalCoroutinesApi
@FlowPreview
@Test
fun login_failure() {
    runBlocking {
        viewModel.username.value = "1120571286@qq.com"
        viewModel.password.value = "password"
        coEvery { repository.authorizations() } returns userAccessTokenData
        coEvery { repository.getUserInfo() } throws Throwable("UnknownError")
        viewModel.login()
        val observer = mockk<Observer<String>>(relaxed = true)
        viewModel.uiLiveEvent.showSnackbarEvent.observeForever(observer)
        viewModel.viewModelScope.coroutineContext[Job]?.children?.forEach { it.join() }
        verify { observer.onChanged(match { it == "0:UnknownError" }) }
    }
}

我的GitHub:TanJiaJunBeyond

Android通用框架:Android通用框架

我的掘金:谭嘉俊

我的简书:谭嘉俊

我的CSDN:谭嘉俊

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