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**對象。
  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對象
  • Dispatchers.IO:此調度程序適合在主線程之外執行磁盤或者網絡I/O,例如:操作數據庫(使用Room)向文件中寫入數據或者從文件中讀取數據運行任何網絡操作
  • Dispatcher.Default:此調度程序適合在主線程之外執行佔用大量CPU資源的工作,例如:對列表排序解析JSON

指定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 = "[email protected]"
        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 = "[email protected]"
        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:譚嘉俊

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章