KCommon-使用Kotlin編寫,基於MVP的極速開發框架

BlackFlagBin發表於2019-03-02

KCommon-使用Kotlin編寫,基於MVP的極速開發框架

我們在開發Android應用程式的時候其實會有很多通用的程式碼,比方說很常見的頁面的幾種基本狀態的切換:正常、載入失敗、載入中、空頁面。又或者是下拉重新整理和如果資料需要分頁而帶來的上拉載入更多資料等等操作。當然,這其中最繁瑣的還是關於MVP相關模板程式碼的編寫,熟悉Android中MVP架構的小夥伴們應該都知道,嚴格按照MVP架構的話,我們每一個Activity或者Fragment都需要多寫一個介面和兩個實現類:MVPContract、MVPModel和MVPPresenter。而這些Contract、Model和Presenter又不近相似,所以在我之前的開發中,如果一個新的APP有30個頁面,那麼加上這些MVP架構所需的程式碼,我需要多新增90個檔案,即使是複製貼上這些程式碼當時也耗費了我將近2個多小時的時間(當然不僅僅是複製,還包括檔名,方法名稱的修改等等所需的細節)。當然,這也是促使我開源出KCommon這個使用Kotlin編寫的,基於MVP架構的極速開發框架的主要原因。

KCommon可以解決的開發中的痛點

  • 頁面狀態的切換,包括正常、載入失敗、載入中、空頁面和自定義的頁面
  • 對下拉重新整理和上拉載入更多的邏輯做了完善的處理,極大的減少了開發者的工作量
  • 對網路請求框架做了封裝,便於方便的請求網路和載入快取
  • 對網路請求返回的錯誤進行了封裝,方便根據錯誤碼進行錯誤處理
  • 提供了自動生成MVP相關檔案的模板程式碼,實現了真正一鍵建立MVP的所有程式碼

整合方法

  • api 'com.blackflagbin:kcommonlibrary:1.0.1'
  • 在根目錄的gradle檔案中新增:
allprojects {
    repositories {
        //新增這一行依賴
        maven { url "https://jitpack.io" }
    }
}

複製程式碼
  • 在自定義的Application類中的onCreate方法中初始化:
CommonLibrary.instance.initLibrary(this,
                BuildConfig.APP_URL,
                ApiService::class.java,
                CacheService::class.java,
                spName = "KCommonDemo",
                errorHandleMap = hashMapOf<Int, (exception: IApiException) -> Unit>(401 to { exception ->

                }, 402 to { exception ->

                }, 403 to { exception ->

                }),
                isDebug = BuildConfig.DEBUG)
複製程式碼

詳細功能使用說明

如果只是針對不同的模組進行介紹的話,可能不是那麼容易理解,這裡我結合一個Kotlin編寫的Demo,來一步一步詳細演示如何使用這個極速開發框架。

明確需求

首先我們這個APP的需求很明確,要有統一的網路錯誤處理、頁面的不同狀態切換、下拉重新整理和上拉載入更多、處理網路請求時的Loading效果、在無網路時載入快取資料,和使用MVP架構來編寫程式碼。在這裡我使用Kotlin編寫整個APP的程式碼,對Kotlin不熟悉的同學也不用害怕,Java和Kotlin的寫法基本是一致的,並且我的MVP模板檔案也提供了Kotlin和Java兩個版本的選項。

新增依賴,複製模板程式碼

這兩步在整合方法中已經介紹過了。

配置MultiDexEnable

由於KCommon為了方便開發依賴了很多開發中常用的第三方庫,完整的依賴如下所示:

dependencies {
    api fileTree(include: ['*.jar'], dir: 'libs')
    api 'com.android.support:appcompat-v7:27.1.1'
    api 'com.android.support:recyclerview-v7:27.1.1'
    api 'org.jetbrains.anko:anko:0.10.3'
    api 'androidx.core:core-ktx:0.3'
    api 'com.android.support:multidex:1.0.3'
    api 'com.squareup.okhttp3:okhttp:3.10.0'
    api 'com.squareup.okhttp3:logging-interceptor:3.9.1'
    api 'com.squareup.retrofit2:retrofit:2.4.0'
    api 'com.squareup.retrofit2:converter-gson:2.4.0'
    api 'com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0'
    api 'io.reactivex.rxjava2:rxandroid:2.0.1'
    api 'com.github.VictorAlbertos.RxCache:runtime:1.8.3-2.x'
    api 'io.reactivex.rxjava2:rxjava:2.1.7'
    api 'com.github.VictorAlbertos.Jolyglot:gson:0.0.4'
    api 'com.jakewharton.rxbinding2:rxbinding:2.0.0'
    api 'com.tbruyelle.rxpermissions2:rxpermissions:0.9.4@aar'
    api 'org.greenrobot:eventbus:3.0.0'
    api 'com.github.CymChad:BaseRecyclerViewAdapterHelper:2.9.35'
    api 'com.github.Kennyc1012:MultiStateView:1.3.0'
    api 'com.github.ybq:Android-SpinKit:1.1.0'
    api 'com.blankj:utilcode:1.17.1'
    api 'com.github.bumptech.glide:glide:3.8.0'
    api 'com.github.anzaizai:EasySwipeMenuLayout:1.1.2'
    api 'com.trello.rxlifecycle2:rxlifecycle:2.2.1'
    api 'com.trello.rxlifecycle2:rxlifecycle-components:2.2.1'
    api 'com.trello.rxlifecycle2:rxlifecycle-kotlin:2.2.1'
    api 'com.trello.rxlifecycle2:rxlifecycle-android-lifecycle-kotlin:2.2.1'
    api 'org.jetbrains.kotlin:kotlin-stdlib:1.2.51'
    api 'com.android.support:cardview-v7:27.1.1'
    api 'com.hx.multi-image-selector:multi-image-selector:1.2.1'
    api 'com.android.support:design:27.1.1'
}
複製程式碼

所以方法數基本上是要超過65535的,因此需要配置MultiDex:

android {
    compileSdkVersion 27
    buildToolsVersion '27.0.3'
    defaultConfig {
        minSdkVersion 19
        targetSdkVersion 27
        versionCode 1
        versionName "1.0"
        //在這裡配置multiDex
        multiDexEnabled true
    }
}
複製程式碼

建立專案的目錄結構

由於開發中需要配合KCommonTemplate一鍵生成相關MVP程式碼使用,所以對整個專案的目錄結構有著要求(如果專案目錄不正確的話,一鍵生成的模板程式碼檔案的位置會錯位)。

首先在專案的主包名下建立4個平級的package:appcommonmvpui

根據名稱大家也很好理解,app 包中存放我們自定義的Application,common 包中存放一些通用的基礎程式碼,比如常量、資料類、網路訪問介面類等等,mvp 包中存放我們MVP架構所需的元件類,ui 包中存放我們的Activity、Fragment和Adapter等等與介面相關的類。

  • app 包中建立我們自定義的Application,Application中的內容之後會詳細說明
  • common 包中建立 http 包,裡面建立兩個介面, ApiServiceCacheService 這兩個介面的名字是固定的,也是因為模板檔案中寫死了這兩個介面的名字。使用過Retrofit的同學應該都清楚,前者是存放網路請求的介面,而後者是結合RxCache使用的存放快取方法的介面。如果對RxCache不熟悉的同學或者不需要快取的同學可以把 CacheService 中的內容清空,保持一個空介面即可,但是 CacheService 這個介面檔案必須存在。
  • mvp 包中分別建立 contractmodelpresenter 三個包,對MVP架構熟悉的同學應該非常清楚這些,而這些包中的相關程式碼會在我們建立Activity或Fragment的時候一鍵生成。
  • ui 包中建立 activityfragment ,很好理解了,存放我們開發中的Activity和fragment。

上面這些目錄結構都是在一個新的專案開發前必須建立好的。有的同學可能看到我的Demo中在一些目錄中也新增了別的一些包,比如在 common 包下建立了 constantutilentity 等等包。其實除了之前提到的必須的目錄結構,你在Demo中看到的別的包都是可選的,這個隨你,只不過這些都是我個人的開發習慣。我習慣在 common 包下存放我專案中的常量、工具類、和資料實體類,同理,我也喜歡在 ui 包下存放adapter和自定義view。當然,這些都是經驗之談,我推薦你跟我採用相同的結構。我們最後來看一張圖片有個更明確的概念。

目錄結構

完成我們自定義的Application類

class App : Application() {

    companion object {
        fun startLoginActivity(context: Context, loginClazz: Class<*>) {
            CommonLibrary.instance.headerMap = hashMapOf(
                    "token" to SPUtils.getInstance("KCommonDemo").getString("token", "123"))
            context.startActivity(
                    Intent(
                            context,
                            loginClazz).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK))
        }
    }

    override fun attachBaseContext(base: Context) {
        super.attachBaseContext(base)
        MultiDex.install(this)

    }

    override fun onCreate() {
        super.onCreate()
        if (LeakCanary.isInAnalyzerProcess(this)) {
            // This process is dedicated to LeakCanary for heap analysis.
            // You should not init your app in this process.
            return;
        }
        LeakCanary.install(this)
        BlockCanary.install(this, AppBlockCanaryContext()).start()

        CommonLibrary.instance.initLibrary(this,
                BuildConfig.APP_URL,
                ApiService::class.java,
                CacheService::class.java,
                spName = "KCommonDemo",
                errorHandleMap = hashMapOf<Int, (exception: IApiException) -> Unit>(401 to { exception ->

                }, 402 to { exception ->

                }, 403 to { exception ->

                }),
                isDebug = BuildConfig.DEBUG)
    }
}
複製程式碼

上面是Demo中自定義的Application,首先要重寫這個方法,處理Multidex:

    //Multidex
    override fun attachBaseContext(base: Context) {
        super.attachBaseContext(base)
        MultiDex.install(this)
    }
複製程式碼

然後再onCreate方法中初始化我們的 CommonLibrary

CommonLibrary.instance.initLibrary(this,
                BuildConfig.APP_URL,
                ApiService::class.java,
                CacheService::class.java,
                spName = "KCommonDemo",
                errorHandleMap = hashMapOf<Int, (exception: IApiException) -> Unit>(401 to { exception ->

                }, 402 to { exception ->

                }, 403 to { exception ->

                }),
                isDebug = BuildConfig.DEBUG)
複製程式碼

這裡傳入的第一個引數是Application本身,第二個引數是BaseUrl,之後是我們之前提到的APIService和CacheService,之後傳入了一個spName,這個表示SharedPrefrence的檔名稱,之後是errorHandleMap,這存放了根據不同的網路錯誤碼對應的回撥,最後傳入一個isDebug表示debug環境下會開啟網路日誌輸入,release環境下會關閉網路日誌輸出。下面是詳細說明:

    /**
     * 初始化
     * @param context Application
     * @param baseUrl retrofit所需的baseUrl
     * @param apiClass retrofit使用的ApisService::Class.java
     * @param cacheClass rxcache使用的CacheService::Class.java
     * @param spName Sharedpreference檔名稱
     * @param isDebug 是debug環境還是release環境。debug環境有網路請求的日誌,release反之
     * @param startPage 分頁列表的起始頁,有可能是0,或者是2,這個看後臺
     * @param pageSize 分頁大小
     * @param headerMap 網路請求頭的map集合,便於在網路請求新增統一的請求頭,比如token之類
     * @param errorHandleMap 錯誤處理的map集合,便於針對相關網路請求返回的錯誤碼來做相應的處理,比如錯誤碼401,token失效需要重新登入
     * @param onPageCreateListener 對應頁面activity或fragment相關生命週期的回撥,便於在頁面相關時機做一些統一處理,比如加入友盟統計需要在所有頁面的相關生命週期加入一些處理
     * @param onPageDestroyListener 對應頁面activity或fragment相關生命週期的回撥,便於在頁面相關時機做一些統一處理,比如加入友盟統計需要在所有頁面的相關生命週期加入一些處理
     * @param onPageResumeListener 對應頁面activity或fragment相關生命週期的回撥,便於在頁面相關時機做一些統一處理,比如加入友盟統計需要在所有頁面的相關生命週期加入一些處理
     * @param onPagePauseListener 對應頁面activity或fragment相關生命週期的回撥,便於在頁面相關時機做一些統一處理,比如加入友盟統計需要在所有頁面的相關生命週期加入一些處理
     *
     */
    fun initLibrary(
            context: Application,
            baseUrl: String,
            apiClass: Class<*>,
            cacheClass: Class<*>,
            spName: String = "kcommon",
            isDebug: Boolean = true,
            startPage: Int = 1,
            pageSize: Int = 20,
            headerMap: Map<String, String>? = null,
            errorHandleMap: Map<Int, (exception: IApiException) -> Unit>? = null,
            onPageCreateListener: OnPageCreateListener? = null,
            onPageDestroyListener: OnPageDestroyListener? = null,
            onPageResumeListener: OnPageResumeListener? = null,
            onPagePauseListener: OnPagePauseListener? = null)
複製程式碼

當然這些引數中前4個引數都是必須的,因為很明顯嘛,它們都沒有預設值。其餘的引數如果有需要的話是可以按需配置的。

如果是跟著Demo一起看的話,有的小夥伴可能會對APP這段程式碼中的:

companion object {
        fun startLoginActivity(context: Context, loginClazz: Class<*>) {
            CommonLibrary.instance.headerMap = hashMapOf(
                    "token" to SPUtils.getInstance("KCommonDemo").getString("token", "123"))
            context.startActivity(
                    Intent(
                            context,
                            loginClazz).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK))
        }
    }
複製程式碼

感到疑惑,這裡其實是一個伴生物件,可以理解為Java中的靜態方法,主要是方便當token過期的時候跳轉到登入頁面並將之前網路請求中的token這個請求頭清空。當然,在我們正在開發的這個Demo中是沒有用到的,這裡只是提供一種token過期,跳轉登入頁的思路。

完成網路資料類的編寫

common 包下建立 entity 資料類包,之後再 entity 包下建立 net 網路資料類包,在 net 中建立 DataEntity 這個資料檔案。

這個資料檔案如下所示:

//最外層資料類
data class HttpResultEntity<T>(
        private var code: Int = 0,
        private var message: String = "",
        private var error: Boolean = false,
        private var results: T) : IHttpResultEntity<T> {
    override val isSuccess: Boolean
        get() = !error
    override val errorCode: Int
        get() = code
    override val errorMessage: String
        get() = message
    override val result: T
        get() = results
}


data class DataItem(
		@SerializedName("desc") var desc: String = "",
		@SerializedName("ganhuo_id") var ganhuoId: String = "",
		@SerializedName("publishedAt") var publishedAt: String = "",
		@SerializedName("readability") var readability: String = "",
		@SerializedName("type") var type: String = "",
		@SerializedName("url") var url: String = "",
		@SerializedName("who") var who: String = ""
)

複製程式碼

我個人習慣將所有的資料類都寫到一個檔案中,因為資料類都是很簡單的,全部寫在一個檔案中看起來比較清晰,也便於管理。

DataEntity 中有兩個資料類,第一個是我們網路返回資料的最外層資料類,可以看到,它實現了一個 KCommon 庫中定義的一個介面 IHttpResultEntity ,我們來看一下這個介面:

interface IHttpResultEntity<T> {
    //網路請求結果是否成功
    val isSuccess: Boolean

    //錯誤碼
    val errorCode: Int

    //錯誤資訊
    val errorMessage: String

    //返回的有效資料
    val result: T
}
複製程式碼

這個介面的意思其實很明顯了,註釋寫的很清楚。那麼有些還沒進入公司的小夥伴們可能會有問題了:為什麼必須要新增一個實現了 IHttpResultEntity 介面的資料類呢?

我們先來看一下公司開發中實際返回的資料結構:

{"data":null,"code":200,"message":null,"success":true}
複製程式碼

可以看到返回的資料包含4個部分,正好對應著介面中的4個部分。大多數的公司都會返回類似的資料結構,當然有些會欄位名稱不一樣,也有一些會缺一些欄位,這時候我們應該靈活應變。

第二個 DataItemGankApi 返回的資料結構,比方說下面就是一條資料:

{
          "desc": "\u8fd8\u5728\u7528ListView\uff1f", 
          "ganhuo_id": "57334c9d67765903fb61c418", 
          "publishedAt": "2016-05-12T12:04:43.857000", 
          "readability": "", 
          "type": "Android", 
          "url": "http://www.jianshu.com/p/a92955be0a3e", 
          "who": "\u9648\u5b87\u660e"
        }
複製程式碼

一鍵生成主頁面的MVP相關程式碼

我們平常寫MVP架構的程式碼,雖然整體頁面邏輯看起來非常清晰:Model只管理資料的獲取、Presenter管理資料和頁面的互動邏輯、View只處理ui相關的事件。但這個清晰是有代價的,文章開篇已經提到過了:我們要多寫3個檔案 -> ContractModelPresenter 。對,,沒錯,一個Activity或者Fragment就要多寫三個檔案,在我短短的開發生涯中,除此之外我還遇到過更過分的,說到這裡,可能有的小夥伴要跟我想到一塊去了:沒錯,就是 Dagger2 ,這個東西首先理解起來有些費勁,其次就是一個Activity或者Fragment也是要多配置2個檔案: ComponentModule 。相信使用過 Dagger2 的朋友都懂我在說什麼,那麼問題來了,我只想寫一個頁面,但卻要多寫5個檔案,這簡直不可忍受( Dagger2 我已經在新開發的專案中移除了,而且以後也不打算再使用,原因嘛很簡單:使用很繁瑣,而且基本上沒什麼好的效果,本質上就是把new物件的程式碼放在了別處。如果沒有用過 Dagger2 的同學,請你聽我一句勸:珍惜生命,遠離 Dagger2 )。

那回到我們的主題,我們現在要建立一個主頁面。這個主頁面要有以下幾點功能:

  1. 使用MVP架構
  2. 包含頁面的載入、成功、失敗和空頁面的邏輯
  3. 這個頁面並不具有上拉載入更多的功能

要建立這樣一個頁面我們有這麼幾個步驟:

  • 用滑鼠選中專案的根包名,比方說Demo中的包名為 kcommonproject ,記住,一定要是根包名(當然也不是說不能選中其他包,只不過選中其他包的話生成的相關程式碼檔案的位置會不太正確),如下圖所示:
    KCommon-使用Kotlin編寫,基於MVP的極速開發框架
  • Mac上是Command+N,Windows上是Ctrl+N,彈出新建檔案的彈框,因為我們要建立的是Activity,所以找到Activity的選項,進入之後可以看到我們的模板檔案的選項因為我們使用Kotlin開發,而且也沒不需要上拉載入更多的功能,所以我們選擇 Kotlin MVP Activity 這個選項,點選生成相關程式碼,如下圖所示:
    KCommon-使用Kotlin編寫,基於MVP的極速開發框架

接下來就是見證奇蹟的時刻,你會發現你的mvp包中生成了相關MVP的程式碼,並且在Activity中預設會有一些配置程式碼,並且XML檔案中也生成了便於切換頁面Loading、成功、失敗、空佈局的程式碼,簡而言之,一鍵生成了你所需的一切。

接下來我們來看一下這些生成的檔案。

  • mvp 包下的 contract 包中生成了一個 MainContract 的介面:
interface MainContract {
    interface IMainModel

    interface IMainPresenter : IBasePresenter

    interface IMainView : IBaseView<Any?>
}
複製程式碼

這是一個主頁面的 Contract 介面,包含了我們 mvp 的三個介面,由於我們在 MainActivity 中並不做關於網路請求相關的操作,所以我們並沒有修改這個介面中的任何程式碼。

  • mvp 包下的 model 包中生成了一個 MainModel 的實現類:
class MainModel : BaseModel<ApiService, CacheService>(), MainContract.IMainModel
複製程式碼

可以看到我們的 MainModel 繼承了 BaseModel 並且實現了我們 MainContract 中的 MainContract.IMainModel 。由於並不涉及網路資料的獲取,所以並沒有任何內容。

  • mvp 包下的 presenter 包中生成了一個 MainPresenter 的實現類:
class MainPresenter(iMainView: MainContract.IMainView) :
        BasePresenter<MainContract.IMainModel, MainContract.IMainView>(iMainView),
        MainContract.IMainPresenter {
    override val model: MainContract.IMainModel
        get() = MainModel()

    override fun initData(dataMap: Map<String, String>?) {
    }

}
複製程式碼

可以看到生成的 MainPresenter 繼承了 BasePresenter 並且實現了我們 MainContract 中的 MainContract.IMainPresenter 。同時還持有了一個 MainModel 的引用物件 model ,這個主要是方便我們在presenter中呼叫model中的方法獲取網路資料。

還有一個 initData(dataMap: Map<String, String>?) 方法,這個方法從名稱也可以理解,頁面載入資料的方法,需要傳入一個 Map 型別的引數。為什麼要傳一個 Map 物件呢?主要原因還是在實際開發中我們請求網路介面所需的引數個數是不確定的,可能不需要傳引數,比方說退出登入的介面其實是不需要傳參的;當然也可能傳個數不同的引數,比方說你有一個根據條件查詢資料的介面,然而這些條件並不是必須的,可以有,也可以不傳,這個時候針對引數個數不同的問題,我們需要一個資料結構來滿足我們的需求,而 Map 這個資料結構正好滿足我們的需求。

同樣的情況,由於在 MainActivity 中我們並不處理網路,所以程式碼是不需要修改的。

  • ui 包下的 activity 包中生成了 MainActivity
class MainActivity : BaseActivity<ApiService, CacheService, MainPresenter, Any?>(),
        MainContract.IMainView {
    private val AVATAR_URL = "https://avatars2.githubusercontent.com/u/17843145?s=400&u=d417a5a50d47426c0f0b6b9ff64d626a36bf0955&v=4"
    private val ABOUT_ME_URL = "https://github.com/BlackFlagBin"
    private val READ_ME_URL = "https://github.com/BlackFlagBin/KCommonProject/blob/master/README.md"
    private val MORE_PROJECT_URL = "https://github.com/BlackFlagBin?tab=repositories"
    private val mTypeArray: Array<String> by lazy {
        arrayOf("all", "Android", "iOS", "休息視訊", "福利", "擴充資源", "前端", "瞎推薦", "App")
    }


    override val swipeRefreshView: SwipeRefreshLayout?
        get() = null

    override val multiStateView: MultiStateView?
        get() = null

    override val layoutResId: Int
        get() = R.layout.activity_main

    override val presenter: MainPresenter
        get() = MainPresenter(this)

    override fun initView() {
        super.initView()
        setupSlidingView()
        setupViewPager()
        rl_right.onClick {
            startActivity(
                    WebActivity::class.java, bundleOf("url" to ABOUT_ME_URL, "title" to "關於作者"))
        }
        ll_read_me.onClick {
            startActivity(
                    WebActivity::class.java, bundleOf("url" to READ_ME_URL, "title" to "ReadMe"))
        }
        ll_more_project.onClick {
            startActivity(
                    WebActivity::class.java, bundleOf("url" to MORE_PROJECT_URL, "title" to "更多專案"))
        }
        ll_clear_cache.onClick { clearCache() }


    }

    override fun initData() {
    }

    override fun showContentView(data: Any?) {
    }

    private fun setupSlidingView() {
        val slidingRootNav = SlidingRootNavBuilder(this).withToolbarMenuToggle(
                tb_main).withMenuOpened(
                false).withContentClickableWhenMenuOpened(false).withMenuLayout(
                R.layout.menu_main_drawer).inject()
        ll_menu_root.onClick { slidingRootNav.closeMenu(true) }
        Glide.with(this).load(
                AVATAR_URL).placeholder(
                R.mipmap.avatar).error(R.mipmap.avatar).dontAnimate().transform(
                GlideCircleTransform(
                        this)).into(iv_user_avatar)
    }

    private fun setupViewPager() {
        vp_content.adapter = MainPagerAdapter(supportFragmentManager)
        tl_type.setupWithViewPager(vp_content)
        vp_content.offscreenPageLimit = mTypeArray.size - 1
    }

    private fun clearCache() {
        val cache = CacheUtils.getInstance(cacheDir)
        val cacheSize = Formatter.formatFileSize(
                this, cache.cacheSize)
        cache.clear()
        toast("清除快取$cacheSize")


    }

}
複製程式碼

我們需要關注的是帶 override 部分的成員和方法:

    override val swipeRefreshView: SwipeRefreshLayout?
        get() = null
複製程式碼

如果需要下拉重新整理,需要在佈局XML檔案中加入 SwipeRefreshView 並將這個View賦值給它。我們的 MainActivity 不需要下拉重新整理,所以預設是 null

    override val multiStateView: MultiStateView?
        get() = null
複製程式碼

這是負責頁面Loading、成功、失敗、空佈局切換的一個自定義View, 需要在佈局檔案中加入 並賦值給它。額,實際上因為模板生成的佈局檔案中會預設帶有 MultiStateView ,所以其實不需要我們主動在佈局檔案中加入。因為 MainActivity 並沒有網路資料的載入,不需要切換頁面狀態,所以賦值為 null

    override val layoutResId: Int
        get() = R.layout.activity_main

    override val presenter: MainPresenter
        get() = MainPresenter(this)
複製程式碼

這兩個放在一起,前者是佈局檔案的 id ,後者是我們當前頁面的 presenter ,都是模板自動生成的,沒什麼可多說的。

override fun initView() {
        super.initView()
        setupSlidingView()
        setupViewPager()
        rl_right.onClick {
            startActivity(
                    WebActivity::class.java, bundleOf("url" to ABOUT_ME_URL, "title" to "關於作者"))
        }
        ll_read_me.onClick {
            startActivity(
                    WebActivity::class.java, bundleOf("url" to READ_ME_URL, "title" to "ReadMe"))
        }
        ll_more_project.onClick {
            startActivity(
                    WebActivity::class.java, bundleOf("url" to MORE_PROJECT_URL, "title" to "更多專案"))
        }
        ll_clear_cache.onClick { clearCache() }
    }
複製程式碼

從名字可以看出來,初始化介面佈局,所有關於頁面 不需要網路資料 的UI的初始化程式碼推薦放在這裡。

    override fun initData() {
    }
複製程式碼

很明顯了,在 initData 中我們推薦的是載入網路資料,通常會呼叫 mPresenter.initData(mDataMap) 來實現我們網路資料的載入。但 MainActivity 不需要載入網路資料,所以我們保持空置。

    override fun showContentView(data: Any?) {
    }
複製程式碼

這個方法的呼叫時機是在我們請求網路介面返回正確的資料之後,所以在這個方法中我們可以獲取所需的網路資料,並修改UI。這裡同樣因為 MainActivity 不需要載入網路資料,所以我們保持空置。

  • layout 下的佈局檔案 :
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <com.kennyc.view.MultiStateView
            android:id="@+id/multi_state_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:msv_emptyView="@layout/layout_empty"
            app:msv_errorView="@layout/layout_error"
            app:msv_loadingView="@layout/layout_loading"
            app:msv_viewState="content">


            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:background="@color/white"
                android:orientation="vertical">


                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:elevation="3dp"
                    android:orientation="vertical">

                    <android.support.v7.widget.Toolbar
                        android:id="@+id/tb_main"
                        android:layout_width="match_parent"
                        android:layout_height="48dp"
                        android:background="@android:color/transparent"
                        android:elevation="10dp">

                        <RelativeLayout
                            android:id="@+id/rl_right"
                            android:layout_width="50dp"
                            android:layout_height="match_parent"
                            android:layout_gravity="right"
                            android:gravity="center">

                            <ImageView
                                android:layout_width="20dp"
                                android:layout_height="20dp"
                                android:src="@mipmap/about_me"/>
                        </RelativeLayout>

                    </android.support.v7.widget.Toolbar>
                </LinearLayout>

                <android.support.design.widget.CoordinatorLayout
                    android:layout_width="match_parent"
                    android:layout_height="match_parent">

                    <android.support.design.widget.AppBarLayout
                        android:id="@+id/appbar_layout"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content">

                        <android.support.design.widget.CollapsingToolbarLayout
                            android:layout_width="match_parent"
                            android:layout_height="wrap_content"
                            android:minHeight="0dp"
                            app:layout_scrollFlags="scroll|enterAlways|snap">

                            <android.support.design.widget.TabLayout
                                android:id="@+id/tl_type"
                                android:layout_width="match_parent"
                                android:layout_height="48dp"
                                android:layout_gravity="center_horizontal"
                                android:background="@color/white"
                                android:elevation="1dp"
                                app:layout_collapseMode="parallax"
                                app:layout_collapseParallaxMultiplier="0.1"
                                app:tabGravity="center"
                                app:tabIndicatorHeight="0dp"
                                app:tabMode="scrollable"
                                app:tabSelectedTextColor="@color/colorPrimary"
                                app:tabTextColor="@color/gray_text">
                            </android.support.design.widget.TabLayout>

                        </android.support.design.widget.CollapsingToolbarLayout>


                    </android.support.design.widget.AppBarLayout>


                    <android.support.v4.view.ViewPager
                        android:id="@+id/vp_content"
                        android:layout_width="match_parent"
                        android:layout_height="match_parent"
                        app:layout_behavior="@string/appbar_scrolling_view_behavior">

                    </android.support.v4.view.ViewPager>


                </android.support.design.widget.CoordinatorLayout>


            </LinearLayout>


        </com.kennyc.view.MultiStateView>
    </LinearLayout>

</android.support.design.widget.CoordinatorLayout>
複製程式碼

值得注意的是 MultiStateView 中的這幾個屬性:

            app:msv_emptyView="@layout/layout_empty"
            app:msv_errorView="@layout/layout_error"
            app:msv_loadingView="@layout/layout_loading"
            app:msv_viewState="content"
複製程式碼

需要我們傳入我們自己編寫的空佈局、錯誤佈局、載入中佈局和當前頁面的狀態,當然你可以直接使用我寫的預設佈局。頁面的狀態有4種 contentloadingerrorempty ,你需要當前頁面的初始狀態是什麼,就改變 msv_viewState 這個屬性值即可。一般來說如果需要載入網路資料,初始狀態應該是 loading ,如果不需要載入網路資料,就像我們的 MainActivity 一樣的話,初始狀態應該是 content

一鍵生成 MainFragment 的MVP相關程式碼

由於這個App採用的是ViewPager+Fragment的UI結構,所以我們還需要建立一個 MainPageFragment 。和建立 MainActivity 的時候幾乎是一模一樣的,唯一的區別在於以選擇一鍵生成的選項不是 Kotlin MVP Activity 而是 Kotlin RefreshAndLoadMore MVP Fragment ,如下圖所示:

KCommon-使用Kotlin編寫,基於MVP的極速開發框架

因為 MainPageFragment 需要下拉重新整理和上拉載入更多,所以我們建立的是 Kotlin RefreshAndLoadMore MVP Fragment 而不是 Kotlin MVP Fragment 。這裡需要注意一點的是有的同學會建立成 MainFragment ,結構發現fragment的MVP程式碼覆蓋了 MainActivity 的MVP程式碼,所以在建立Activity或者Fragment的時候應該避免二者前面的名字重複,否則後者的MVP程式碼會覆蓋前者。

和一鍵生成 MainActivity 時候一樣,這時候專案目錄中也會出現 MainPageContractMainPageModelMainPagePresenter 和我們的 MainPageFragment 。跟之前的流程一樣,我們來看一下這些生成的程式碼:

  • MainPageContract
interface MainPageContract {
    interface IMainPageModel {
        fun getData(type: String, pageNo: Int, limit: Int): Observable<Optional<List<DataItem>>>
    }

    interface IMainPagePresenter : IBaseRefreshAndLoadMorePresenter

    interface IMainPageView : IBaseRefreshAndLoadMoreView<List<DataItem>>
}
複製程式碼

跟之前的 MainContract 類似,區別在於我們的頁面是帶 RefreshAndLoadMore 的,所以可以看到 presenter 介面繼承了 IBaseRefreshAndLoadMorePresenter ,而 view 介面繼承了 IBaseRefreshAndLoadMoreView 。值得注意的是 ** IBaseRefreshAndLoadMoreView <List<DataItem>> ** 帶有一個泛型 ** List<DataItem> ** ,這個型別與我們的 override fun showContentView(data: List<DataItem> ) 中引數的型別對應。

同樣要注意的點是在 IMainPageModel 這個介面中我們定義了一個 getData(type: String, pageNo: Int, limit: Int) 的方法用於獲取分頁資料。要強調的是網路介面實際返回的是 **List<DataItem> ** 型別的資料,但這個 getData 的返回值我們需要在外面包上一層 Optional。這個 OptionalKCommon 庫中定義的一個類,其實很簡單:

/**
 * Created by blackflagbin on 2018/3/29.
 * 解決rxjava2不能處理null的問題,我們把所有返回的有效資料包一層Optional,通過isEmpty判斷是否為空
 */
data class Optional<T>(var data: T)  {

    fun isEmpty(): Boolean {
        return data == null
    }
}
複製程式碼

這其實就是在網路返回的原始資料上包了一層,那麼有的人會不理解了,為什麼要包這一層,這不是多此一舉麼?

使用過 RxJava 的同學應該很清楚,在我們實際開發中,網路介面間的順序呼叫是一個很常見的事情。比方說我在首頁想展示一個使用者當前小區下的通知公告,那其實肯定要有兩個介面,獲取使用者當前小區的介面、根據小區id獲取通知公告的介面。這兩個介面之間存在著先後的邏輯關係,必須先拿到小區id,才能獲取通知公告。

通常來說,用過 RxJava 的同學會使用 flatMap 這個操作符,即使是使用者沒有小區,介面返回的小區資料為 null ,這在 RxJava1 的時候是不會存在任何問題的。然而,在新的 RxJava2 中,一旦介面返回的是 null ,而你又使用了 flatMap ,那麼很抱歉,程式會報錯,原因就是在 RxJava2 中不支援 null 值事件的傳遞。

其實我們可以讓後臺不給我們返回 null 來避免這個問題,但往往在實際開發中後臺為了圖省事也是不會處理這種事情的,而且最常見的理由就是為什麼IOS可以返回 null,Android就不行?

所以為了避免跟後端的同學過多的撕逼,我們只能在返回的真實資料上包上一層 Optional ,來處理 RxJava2 中無法傳遞 null 事件的問題。這也是我目前可以想到的最好的處理方案,如果大家有更好的處理辦法,不妨通過留言告訴我,我們共同學習,共同進步。

  • MainPageModel
class MainPageModel : BaseModel<ApiService, CacheService>(), MainPageContract.IMainPageModel {
    override fun getData(type: String, pageNo: Int, limit: Int): Observable<Optional<List<DataItem>>> {
        return if (NetworkUtils.isConnected()) {
            mCacheService.getMainDataList(
                    mApiService.getMainDataList(
                            type, limit, pageNo).compose(DefaultTransformer()),
                    DynamicKeyGroup(type, pageNo),
                    EvictDynamicKeyGroup(true)).subscribeOn(Schedulers.io()).observeOn(
                    AndroidSchedulers.mainThread())
        } else {
            mCacheService.getMainDataList(
                    mApiService.getMainDataList(
                            type, limit, pageNo).compose(DefaultTransformer()),
                    DynamicKeyGroup(type, pageNo),
                    EvictDynamicKeyGroup(false)).subscribeOn(Schedulers.io()).observeOn(
                    AndroidSchedulers.mainThread())
        }
    }
}
複製程式碼

在我們的 MainPageModel 中,實現了 getData 這個在 IMainPageModel 中定義的介面方法。因為頁面的邏輯是網路正常時獲取網路資料,無網路時載入快取資料,所以方法中會有關於網路狀態的判斷。我們需要注意的是兩個變數: mApiServicemCacheService 。這兩個變數都是 BaseModel 中的成員變數,我們在 BaseModel 的繼承類中都可以直接拿來使用。關於網路請求和快取我使用的是 RetrofitRxCache ,如果有不太瞭解的同學可以自行檢視相關的文件,我這裡就不再贅述了。這裡會有小夥伴問:如果我不需要快取怎麼辦?如果不需要快取就更好辦了,這個方法直接返回

mApiService.getMainDataList(
                            type, limit, pageNo).compose(DefaultTransformer())
複製程式碼

就可以了。

有的同學可能還會有疑問,為什麼請求介面後面要加上 compose(DefaultTransformer()) 這麼一句,可不可以省略?其實這一句是 KCommon 中網路處理的關鍵部分,這句程式碼對我們網路請求返回的結果做了相應的錯誤處理和 Optional 的包裝,所以這一句是必不可少的。對它的實現原理感興趣的同學可以直接通過 Android Studio 檢視原始碼,原理並不繁瑣。

  • MainPagePresenter
class MainPagePresenter(iMainPageView: MainPageContract.IMainPageView) :
        BasePresenter<MainPageContract.IMainPageModel, MainPageContract.IMainPageView>(iMainPageView),
        MainPageContract.IMainPagePresenter {
    override val model: MainPageContract.IMainPageModel
        get() = MainPageModel()

    override fun initData(dataMap: Map<String, String>?) {
        initData(dataMap, CommonLibrary.instance.startPage)
    }

    override fun initData(dataMap: Map<String, String>?, pageNo: Int) {
        if (!NetworkUtils.isConnected()) {
            mView.showTip("網路已斷開,當前資料為快取資料")
        }
        if (pageNo == CommonLibrary.instance.startPage) {
            //如果請求的是分頁的首頁,必須先呼叫這個方法
            mView.beforeInitData()
            mModel.getData(
                    dataMap!!["type"].toString(),
                    pageNo,
                    CommonLibrary.instance.pageSize).bindToLifecycle(mLifecycleProvider).subscribeWith(
                    NoProgressObserver(mView, object : ObserverCallBack<Optional<List<DataItem>>> {
                        override fun onNext(t: Optional<List<DataItem>>) {
                            mView.showSuccessView(t.data)
                            mView.dismissLoading()
                        }

                        override fun onError(e: Throwable) {
                            mView.showErrorView("")
                            mView.dismissLoading()
                        }
                    }))
        } else {
            mModel.getData(
                    dataMap!!["type"].toString(),
                    pageNo,
                    CommonLibrary.instance.pageSize).bindToLifecycle(mLifecycleProvider).subscribeWith(
                    NoProgressObserver(
                            mView, mIsLoadMore = true))
        }
    }
}
複製程式碼

可以看到和 MainPresenter 的結構大同小異,區別在於多了 override fun initData(dataMap: Map<String, String>?, pageNo: Int) 這個方法,很明顯這是載入分頁用的。要注意我這個方法中程式碼的寫法,需要注意的有這麼幾個點:

mView.beforeInitData() 在請求分頁的首頁時,必須先呼叫這行程式碼進行資料的整理。之後再去使用 mModel 中的方法請求網路。

.bindToLifecycle(mLifecycleProvider) 這句的目的是將網路請求和當前頁面(Activity或Fragment)的生命週期繫結,當頁面結束的時候會終止網路請求,防止記憶體洩漏,使用的是 RxLifeCycle ,有興趣的同學可以自行研究。我的建議是所有的 presenter 中的網路請求呼叫中都要加上這一句,防止記憶體洩漏。

NoProgressObserver 由於整個網路框架使用的是 Rxjava+Retrofit+OKHttp ,所以最終是需要一個 Observer 來最終處理我們網路請求返回的資料。在 KCommon 中內建了兩種 ObserverNoProgressObserverProgressObserver 。從名字也可以明白,分別是沒有載入動畫的和有載入動畫的 Observer 。那麼二者的使用時機分別是什麼呢?簡而言之就是當你請求一個網路時頁面需要有Loading動畫的顯示時使用 NoProgressObserver ,請求網路時不需要Loading動畫時使用 ProgressObserver

mIsLoadMore = true 這是 NoProgressObserverProgressObserver 中都存在的一個預設引數,預設為 false ,意思是 當前網路請求是否是載入更多的請求 。當我們載入非首頁的時候將之置為 true

mView.showSuccessView(t.data) 當網路請求成功時必須呼叫。作用是將當前頁面從Loading狀態切換到成功的狀態。

mView.showErrorView("網路連線異常") 當網路請求失敗時必須呼叫。作用是將當前頁面從Loading狀態切換到失敗的狀態。傳入的引數我們可以自定義錯誤的原因,這個隨便寫。

mView.dismissLoading() 這個主要是帶有下拉重新整理的頁面中當獲取到網路資料(無論成功或者失敗)後,必須呼叫。

  • MainPageFragment
@SuppressLint("ValidFragment")
class MainPageFragment() :
        BaseRefreshAndLoadMoreFragment<ApiService, CacheService, MainPageContract.IMainPagePresenter, List<DataItem>>(),
        MainPageContract.IMainPageView {
    private val mTypeArray: Array<String> by lazy {
        arrayOf("all", "Android", "iOS", "休息視訊", "福利", "擴充資源", "前端", "瞎推薦", "App")
    }

    private lateinit var mType: String

    override val adapter: BaseQuickAdapter<*, *>?
        get() = MainPageAdapter(arrayListOf())

    override val recyclerView: RecyclerView?
        get() = rv_list

    override val layoutManager: RecyclerView.LayoutManager?
        get() = FixedLinearLayoutManager(activity)

    override val swipeRefreshView: SwipeRefreshLayout?
        get() = swipe_refresh

    override val multiStateView: MultiStateView?
        get() = multi_state_view

    override val layoutResId: Int
        get() = R.layout.fragment_main_page

    override val presenter: MainPageContract.IMainPagePresenter
        get() = MainPagePresenter(this)

    constructor(position: Int) : this() {
        mType = mTypeArray[position]
    }

    override fun initData() {
        mDataMap["type"] = mType
        mPresenter.initData(mDataMap)
    }

    override fun showContentView(data: List<DataItem>) {
        mAdapter?.onItemClickListener = BaseQuickAdapter.OnItemClickListener { adapter, view, position ->
            startActivity(
                    WebActivity::class.java,
                    bundleOf(
                            "url" to (mAdapter?.data!![position] as DataItem).url,
                            "title" to (mAdapter?.data!![position] as DataItem).desc))
        }
    }
}
複製程式碼

可以看到,和 MainActivity 相似處很多,我這裡著重說明一下不同的地方:

override val adapter: BaseQuickAdapter<*, *>?
        get() = MainPageAdapter(arrayListOf())
複製程式碼

因為要有上拉載入更多的列表,所以很明顯需要一個 AdapterKCommon 中依賴了 BaseRecyclerViewAdapterHelper 這個第三方庫,我平常用起來挺方便的,而且功能很強大,在這裡也推薦大家使用。這個三方庫的具體使用方法我就不贅述了,大家可以自行去 GitHub 上檢視它的文件。

    override val recyclerView: RecyclerView?
        get() = rv_list
複製程式碼

不用多說了吧,需要一個 RecyclerView 物件。

    override val layoutManager: RecyclerView.LayoutManager?
        get() = FixedLinearLayoutManager(activity)
複製程式碼

大家肯定都知道要使用 LayoutManager ,但這裡必須使用我在 KCommon 中定義的幾個帶 Fixed 打頭的 LayoutManager ,這樣會避免一些詭異的異常。

    override fun initData() {
        mDataMap["type"] = mType
        mPresenter.initData(mDataMap)
    }
複製程式碼

initData 中我們首先將型別引數存放進了 mDataMap 中,然後呼叫了 mPresenter.initData(mDataMap) 進行了網路請求。

沒錯,只需要配置這麼幾個引數,我們的一個帶有下拉重新整理和上拉載入更多的頁面就完成了。相信做過類似功能頁面的同學應該很清楚要實現同樣的功能如果全部自己寫的話會很繁瑣,但使用了 KCommon ,一切都會變得非常容易。

額外的提醒

到此為止,通過一個簡單Demo的講解, KCommon 基礎的用法已經全部介紹完畢了,除了我上面說的之外,在 KCommon 中還整合依賴了一些我個人在實際專案開發中經常用到的,而且非常好用的第三方庫,這裡大家有興趣的話可以嘗試瞭解一下。

最後要說的是我會長期維護和改進 KCommon ,如果大家在使用的過程中存在疑惑,可以在 GitHub 上提出 issue ,我會一一解答。感謝大家花時間看這麼一篇文章,如果我的努力解決了大家實際開發中的問題,提高了大家的效率,希望可以順手給個 star ,謝謝。

GitHub地址

相關文章