手把手帶你搭建一個優(yōu)秀的Android項目架構

本文章已授權微信公眾號 guolin_blog (郭霖)獨家發(fā)布。
發(fā)布地址:手把手帶你搭建一個優(yōu)秀的Android項目架構

目錄

前言

最近公司準備上線新項目,由筆者來負責搭建項目架構,正好也把之前學的Kotlin等相關知識鞏固一下,于是把搭建的成果抽取出來作為開源項目分享給大家。另外,該項目也是大家學習Kotlin一個很好的示例,另外該項目稍作修改完全可以作為一個新項目的藍本。

下面放上幾張項目效果圖:

首頁
二次元
發(fā)現
我的

架構總體介紹

一個良好的架構需要什么,根據設計原則,有以下:

  • 實現項目所需要的功能,為業(yè)務需求打下基礎
  • 可擴展性、可配置性足夠強大
  • 易用性,方便新成員學習和上手
  • 代碼高可復用性,添加新功能的時候可以重用大部分已有代碼

在開始之前,看了公司內部很多項目的架構,大部分都不如人意,諸如以下的問題滿天飛:

  • 原有API十分難用,比如說添加一個簡單的埋點,大部分情況需要去翻看和拷貝已有的代碼才能使用
  • Adapter很多很亂,每一個實現都參差不齊,列表Item沒有復用,本來應該復用的Item寫了多次,Adapter也是一個頁面寫一個,非常難以統一管理
  • 網絡架構十分亂,同一個項目由于歷史原因會有多個網絡架構;并且API十分難以使用,每次使用還要手動去創(chuàng)建一次Retrofit的Service才能調用API
  • 大量的findViewById,想要修改或者重構代碼的時候需要修改大量內容
  • 包管理劃分混亂等等

下面是學習該架構可以學習、鞏固的知識:

  • Kotlin各種語法等
  • Jetpack:主要是ViewModel、LifeCycle、LiveData、Room、ViewBinding
  • Kotlin協程
  • 思考哪些地方可能會存在多線程帶來的線程同步問題以及處理方案
  • Retrofit+OkHttp
  • MultiType
  • MMKV
  • 等等

下面先來看一下項目總體的包劃分:

項目總體架構

base:存放所有業(yè)務的基礎類,包括BaseActivity、BaseFragment、BaseViewModel、列表等功能的封裝
bean:存放所有Bean類,一般多為Kotlin的data class
constant:存放所有常量
eventbus:項目封裝XEventBus,基于LiveData
item:存放所有可重用的列表Item
module:存放以業(yè)務功能劃分(一般是以頁面為劃分界限)的所有模塊,每一個模塊的package包含模塊所需要的類,一般為Activity/Fragment以及與之對應的ViewModel
network:基于Retrofit+OkHttp+協程的網絡架構封裝
persistence:存放數據庫以及鍵值對等持久化相關的類
util:工具類,包含Kotlin擴展屬性、擴展函數
widget:存放所有自定義控件
XArchApplication:項目的Application

由于是作為示例項目,就暫不考慮多module劃分之類的問題了。

Gradle配置統一管理

搭建一個項目,先從Gradle入手,把所有需要的依賴都依賴進來,為后面的工作打下基礎。

對于Gradle配置統一管理這一塊,筆者寫了一個config.gradle腳本:

/**
 * 依賴庫版本管理
 */
def versions = [:]
versions.androidx_appcompat = "1.3.1"
...
ext.versions = versions

/**
 * APP版本號、插件版本、編譯相關版本管理
 */
def build_versions = [:]
build_versions.min_sdk = 21
build_versions.app_version_name = "1.0.0"
...
ext.build_versions = build_versions

/**
 * 路徑常量
 */
def paths = [:]
paths.room_schema = "$projectDir/schemas"
ext.paths = paths

/**
 * 倉庫地址管理
 */
def addRepos(RepositoryHandler handler) {
    handler.maven {
        allowInsecureProtocol true
        url 'http://maven.aliyun.com/nexus/content/groups/public/'
    }
    ...
}

ext.addRepos = this.&addRepos

/**
 * 讀取本機配置,主要用于本地差異化構建(local.properties不會提交到倉庫)
 */
def readLocalProperty(String key) {
    boolean value = false
    def file = rootProject.file('local.properties')
    if (file.exists() && file.isFile()) {
        Properties properties = new Properties()
        properties.load(file.newDataInputStream())
        value = Boolean.parseBoolean(properties.getProperty(key, 'false'))
    }
    println(String.format("property key=%s value=%S", key, value))
    return value
}

ext.readLocalProperty = this.&readLocalProperty

其中,

  • versions是所有第三方依賴庫的版本
  • build_versions是所有構建相關的版本,比如最小SDK、APP版本號等
  • paths是所有路徑常量
  • addRepos是所有倉庫地址
  • readLocalProperty是讀取本機的配置,從而做一些差異化配置。

這里再解釋一下什么是本機配置,本機的配置在local.properties,而該文件不會提交到Git,所以在local.properties配置的屬性,只用于改變你本地的構建。比如我要在本地調試的時候使用一個線程排查的工具,但是又不想影響持續(xù)集成編譯出來的APK包,那么我們可以在local.properties里面增加以下一行:

THREAD_POOL_SHRINK=false

然后你可以在build.gradle里面增加以下配置,這樣就可以達到只有你自己本機才能開啟這個插件,而不會影響持續(xù)集成編譯出來的APK包。這個是筆者比較常用的一個小技巧。

// 線程池優(yōu)化Gradle插件,測試穩(wěn)定后再上線,目前僅用于線程池排查
if (readLocalProperty("THREAD_POOL_SHRINK")) {
    apply from: "thread.gradle"
}

介紹完全局配置腳本config.gradle,接下來在項目的根項目里面apply一下,就可以全局使用config.gradle所定義的信息了:

buildscript {
    apply from: 'config.gradle'
    addRepos(repositories)
    dependencies {
        classpath "com.android.tools.build:gradle:$build_versions.android_gradle_plugin"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${build_versions.kotlin}"
    }
}

allprojects {
    addRepos(repositories)
}
...

至此,Gradle配置統一管理這一塊就實現好了,為后面多module打下堅實的基礎。

當然,關于Gradle配置統一管理這一塊可以展開的內容實在太多了,針對多module甚至多項目網上也有很多解決方案,這里針對目前的項目需求,采用最簡單的方式就好了,此方法適合大部分中小型項目的需要。

基類封裝

下面正式開始寫代碼,先從最簡單的基類的封裝入手,直接上代碼:

abstract class BaseActivity : SwipeBackActivity(), IGetPageName {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setSwipeBackEnable(swipeBackEnable())
    }

    override fun onStart() {
        super.onStart()
        // 這里可以添加頁面打點
    }

    override fun onStop() {
        super.onStop()
        // 這里可以添加頁面打點
    }

    /**
     * 默認開啟左滑返回,如果需要禁用,請重寫此方法
     */
    protected open fun swipeBackEnable() = true

    ...
}
  1. 從整體來看,基類的設計必須是一個abstract class,并且提供必要的鉤子函數給子類定制以及提供公共的常用的函數
  2. 在基類的生命周期函數里面可以做一些統一的操作,一般來說是頁面的打點,其中pageName可以通過基礎基類實現IGetPageName來提供
  3. 對于Activity而言,有一些頁面可能需要右滑返回功能,我們直接讓BaseActivity繼承SwipeBackActivity即可

其他的BaseFragment、BaseViewModel都比較簡單,就不再贅述了。

特別要說明一點的是,筆者傾向于不往基類添加一些額外的方法,盡量保持一個類的純粹。就拿BaseActivity來說吧,筆者不會添加諸如doCreate、getContentView之類的奇奇怪怪的方法,因為這樣會給初次使用的同事帶來困惑,還得時不時去翻看基類的實現。

在具體使用方面,筆者建議是將所有模塊都劃分一個package,例如main package里面一個MainActivity和MainViewModel:

包劃分

具體可以參考谷歌提供的官方架構圖:

推薦架構

本項目省略了Repository層,考慮是中小型示例項目以及大家的學習成本,暫時沒有做一層,有需要的話大家可以自己實現。

視圖綁定

提到視圖綁定,我們一般會想到以下幾個點:

  • findViewById:重復繁瑣,無法規(guī)避空指針和強轉時類型錯誤問題(目前通過有泛型可以規(guī)避)
  • DataBinding:這個是實現MVVM雙向綁定的工具,嚴格來說定位上不屬于視圖綁定工具,視圖綁定只是DataBinding的部分功能
  • ButterKnife/Kotlin-Android-Extention:視圖綁定工具,目前由于從AGP-5.0版本開始,R類生成的值不再是常量,這兩個工具已廢棄(參考: https://blog.csdn.net/c10WTiybQ1Ye3/article/details/113695548
  • ViewBinding:視圖綁定工具,不用手寫findViewById,而且避免了findViewById可能會帶來的空指針和強轉時類型錯誤問題(*)

基于以上考慮,項目決定采用ViewBinding。

以下是在BaseActivity和BaseFragment中對ViewBinding的封裝:

/**
 * Activity基類
 */
abstract class BaseActivity<T : ViewBinding> : SwipeBackActivity() {

    protected lateinit var viewBinding: T
    protected abstract val inflater: (inflater: LayoutInflater) -> T
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewBinding = inflater(layoutInflater)
        setContentView(viewBinding.root)
    }
}

/**
 * Fragment基類
 */
abstract class BaseFragment<T : ViewBinding> : Fragment() {

    protected lateinit var viewBinding: T
    protected abstract val inflater: (LayoutInflater, container: ViewGroup?, attachToRoot: Boolean) -> T
    
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        viewBinding = inflater(inflater, container, false)
        return viewBinding.root
    }
}

為了避免給Activity/Fragment添加其它構造函數,采用泛型+抽象屬性的方式來封裝基類,繼承基類的時候,除了傳入具體的泛型之外,還需要重寫抽象屬性。以MainActivity為例子:

/**
 * 首頁
 */
class MainActivity : BaseActivity<ActivityMainBinding>() {

    override val inflater: (inflater: LayoutInflater) -> ActivityMainBinding
        get() = ActivityMainBinding::inflate
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 可以直接使用viewBinding了
        viewBinding.xxx
    }
}

底部導航欄的實現

底部導航欄基本上是一個項目的標配了,目前的實現方案有很多,筆者挑選了比較成熟文檔且可擴展性強的改造FragmentTabHost方案。

相關文章可以參考:Android 底部導航欄(底部Tab)最佳實踐

底部導航欄的實現筆者采用FragmentTabHost+Fragment來實現,只不過FragmentTabHost是經過簡單修改,防止Fragment在切換過程中Fragment銷毀。

示例代碼參考MainActivity.kt:

/**
 * 初始化底欄
 */
private fun initTabs() {
    val tabs = listOf(
        Tab(TabId.HOME, getString(R.string.page_home), R.drawable.selector_btn_home, HomeFragment::class),
        Tab(TabId.SMALL_VIDEO, getString(R.string.page_small_video), R.drawable.selector_btn_small_video, SmallVideoFragment::class),
        Tab(TabId.ACGN, getString(R.string.page_acgn), R.drawable.selector_btn_acgn, AcgnFragment::class),
        Tab(TabId.GOLD, getString(R.string.page_gold), R.drawable.selector_btn_gold, GoldFragment::class),
        Tab(TabId.MINE, getString(R.string.page_mine), R.drawable.selector_btn_mine, MineFragment::class)
    )

    viewBinding.fragmentTabHost.run {
        // 調用setup()方法,設置FragmentManager,以及指定用于裝載Fragment的布局容器
        setup(this@MainActivity, supportFragmentManager, viewBinding.fragmentContainer.id)
        tabs.forEach {
            // 這里是解構的語法
            val (id, title, icon, fragmentClz) = it
            val tabSpec = newTabSpec(id).apply {
                setIndicator(TabIndicatorView(this@MainActivity).apply {
                    viewBinding.tabIcon.setImageResource(icon)
                    viewBinding.tabTitle.text = title
                })
            }
            addTab(tabSpec, fragmentClz.java, null)
        }

        setOnTabChangedListener { tabId ->
            currentTabId = tabId
            updateTitle()
        }
    }
}

/**
 * 設置當前選中的TAB
 */
private fun setCurrentTab(@TabId tabID: String) {
    viewBinding.fragmentTabHost.setCurrentTabByTag(tabID)
}

在initTabs函數中,我們通過調用FragmentTabHost的setup方法設置FragmentManager,以及指定用于裝載Fragment的布局容器。然后通過addTab方法把創(chuàng)建好的TabSpec傳進去即可。其中TabIndicatorView是我們自定義的每一個底部導航欄顯示的控件。

事件總線框架封裝

提到事件總線,我們不外乎會想到:

  • EventBus庫
  • RXJava
  • LiveData

既然上了Jetpack這條賊船,我們就用LiveData來實現一個簡單可用的事件總線框架。慣例先來看看成果:

在任何地方通過XEventBus的post方法發(fā)送一個事件:

XEventBus.post(EventName.REFRESH_HOME_LIST, "領現金頁面通知首頁刷新數據")

訂閱方接收:

XEventBus.observe(viewLifecycleOwner, EventName.REFRESH_HOME_LIST) { message: String ->
    Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}

總體類預覽如下:

事件總線架構

一共幾個類搞定,下面開始講解實現原理。

熟悉LiveData的朋友都知道,LiveData在添加新的Observer的時候是會收到最后一條消息,實質上是一種粘性訂閱,如果不需要粘性訂閱,那么就需要對Observer進行改造了:

class EventObserverWrapper<T>(
    liveData: EventLiveData<T>,
    sticky: Boolean,
    private val observerDelegate: Observer<in T>
) : Observer<T> {

    private var preventNextEvent = false

    companion object {
        private const val START_VERSION = -1
    }

    init {
        if (!sticky) {
            val version = ReflectHelper.of(liveData).getField("mVersion") as? Int ?: START_VERSION
            preventNextEvent = version > START_VERSION
        }
    }

    override fun onChanged(t: T) {
        if (preventNextEvent) {
            preventNextEvent = false
            return
        }
        observerDelegate.onChanged(t)
    }
}

我們通過代理Observer,在構造的時候傳入LiveData和sticky粘性訂閱參數,在init中判斷如果調用方不需要粘性訂閱,那么根據LiveData的版本號mVersion來跳過下一次onChanged的觸發(fā)。

其中LiveData的mVersion需要通過反射來獲取。

接下來我們封裝一個EventLiveData,添加在訂閱的時候,增加了一個sticky參數,把傳進來的Observer用EventObserverWrapper包裝一下:

class EventLiveData<T> : MutableLiveData<T>() {

    fun observe(owner: LifecycleOwner, sticky: Boolean, observer: Observer<in T>) {
        observe(owner, wrapObserver(sticky, observer))
    }

    private fun wrapObserver(sticky: Boolean, observer: Observer<in T>): Observer<T> {
        return EventObserverWrapper(this, sticky, observer)
    }
}

最后再對外提供一個門面類:

object XEventBus {

    private val channels = HashMap<String, EventLiveData<*>>()

    private fun <T> with(@EventName eventName: String): EventLiveData<T> {
        synchronized(channels) {
            if (!channels.containsKey(eventName)) {
                channels[eventName] = EventLiveData<T>()
            }
            return (channels[eventName] as EventLiveData<T>)
        }
    }

    fun <T> post(@EventName eventName: String, message: T) {
        val eventLiveData = with<T>(eventName)
        eventLiveData.postValue(message!!)
    }

    fun <T> observe(owner: LifecycleOwner, @EventName eventName: String, sticky: Boolean = false, observer: Observer<T>) {
        with<T>(eventName).observe(owner, sticky, observer)
    }
}

在這個XEventBus對象里面,channels存儲了所有EventLiveData,通過with函數就可以根據eventName獲取一個EventLiveData,這里需要注意多線程訪問HashMap的問題。

我們還對外提供了post、observe兩個函數:

  • post用于發(fā)送事件,需要傳入事件名和具體的消息,最終調用LiveData的postValue方法
  • observe用于訂閱事件,需要傳入事件名和Observer,最終調用LiveData的observe方法

通過LiveData封裝事件總線,我們省去了手動取消訂閱的操作,但是還有一個比較麻煩的事件還沒解決,就是通過observe而不是observeForever來訂閱,只能在LifecycleOwner活躍的情況下才能收到消息,例如給一個已經pause的Activity發(fā)送一個事件,只能在返回這個Activity的時候才能收到消息。

類似這種“給一個已經pause的Activity/Fragment發(fā)送一個事件”這種情況,其實在實際應用中是非常常見的,其實我們完全可以通過observeForever來訂閱,但是這種訂閱需要手動取消訂閱,會帶來API使用的不便利。為了能夠利用observe這種自動取消訂閱的便利性,又能夠在pause狀態(tài)下收到事件,筆者決定自己移植LiveData的源碼來達到這個效果。

把LiveData包里面的幾個類拷貝到自己的項目下面,修改觸發(fā)事件回調的considerNotify方法,去掉判斷Observer是否活躍的邏輯就可以了:

private void considerNotify(ObserverWrapper observer) {
    /* 修改源碼,實現后臺收消息功能
    if (!observer.mActive) {
        return;
    }
    // Check latest state b4 dispatch. Maybe it changed state but we didn't get the event yet.
    //
    // we still first check observer.active to keep it as the entrance for events. So even if
    // the observer moved to an active state, if we've not received that event, we better not
    // notify for a more predictable notification order.
    if (!observer.shouldBeActive()) {
        observer.activeStateChanged(false);
        return;
    }
    */

    if (observer.mLastVersion >= mVersion) {
        return;
    }
    observer.mLastVersion = mVersion;
    observer.mObserver.onChanged((T) mData);
}

對于沒有LifecycleOwner的場景,需要自己實現LifecycleOwner即可,大部分情況下通過Activity/Fragment都是可以直接獲取LifecycleOwner的。

至此,事件總線架構的封裝就完成了。

列表架構封裝

這一塊是整個項目的重中之重,也是項目里面最為常用和復雜的功能,如果封裝得不好,會很影響開發(fā)效率和項目質量,這一塊的痛點需求有:

  • Adapter和Item的高可復用
  • 與ViewModel打通,通過ViewModel加載數據
  • 可配置性足夠強大,但是又無需重復配置一些累贅的屬性,比如Adapter、LayoutManager等
  • 支持下拉刷新、上拉加載的開啟和關閉
  • 支持數據預加載
  • 空白頁、異常頁面
  • 支持本地數據加載、網絡數據加載。在加載網絡數據異常的情況下根據網絡狀態(tài)自動重試
  • 支持常見的監(jiān)聽,比如短按、長按、Item子View的點擊監(jiān)聽
  • 等等

基于以上思考,筆者為項目封裝了一個XRecyclerView控件,使用的方法很簡單:

  1. 在xml里面放置一個XRecyclerView
  2. 在代碼里面進行簡單的配置
  3. 繼承BaseRecyclerViewModel并且實現里面的最核心的loadData方法

XRecyclerView的配置示例代碼在HomeFragment.kt,如下:

viewBinding.rvList.init(
    XRecyclerView.Config()
        .setViewModel(viewModel)
        .setOnItemClickListener(object : XRecyclerView.OnItemClickListener {
            override fun onItemClick(parent: RecyclerView, view: View, viewData: BaseViewData<*>, position: Int, id: Long) {
                Toast.makeText(context, "條目點擊: ${viewData.value}", Toast.LENGTH_SHORT).show()
            }
        })
        .setOnItemChildViewClickListener(object : XRecyclerView.OnItemChildViewClickListener {
            override fun onItemChildViewClick(parent: RecyclerView, view: View, viewData: BaseViewData<*>, position: Int, id: Long, extra: Any?) {
                if (extra is String) {
                    Toast.makeText(context, "條目子View點擊: $extra", Toast.LENGTH_SHORT).show()
                }
            }
        })
)

通過調用XRecyclerView的init方法,傳入一個包含著所有配置信息的XRecyclerView.Config對象即可。其中大部分配置如果無需配置的話可以不進行配置直接使用推薦的默認值。

在示例代碼當中,我們設置了Item的點擊監(jiān)聽和Item上面子View的點擊監(jiān)聽,另外我們還傳入了一個BaseRecyclerViewModel對象,主要負責為XRecyclerView提供數據來源,實現可參考HomeViewModel.kt,如下:

class HomeViewModel : BaseRecyclerViewModel() {

    override fun loadData(isLoadMore: Boolean, isReLoad: Boolean, page: Int) {
        viewModelScope.launch {
            // 模擬網絡數據加載
            delay(1000L)

            val time = DateFormat.format("MM-dd HH:mm:ss", System.currentTimeMillis())

            val viewDataList: List<BaseViewData<*>>
            if (!isLoadMore) {
                viewDataList = listOf<BaseViewData<*>>(
                    Test1ViewData("a-$time"),
                    ...
                )
            } else {
                // 在第5頁模擬網絡異常
                if (page == 5) {
                    postError(isLoadMore)
                    return@launch
                }
                viewDataList = listOf<BaseViewData<*>>(
                    Test1ViewData("a-$time"),
                    ...
                )
            }
            postData(isLoadMore, viewDataList)
        }
    }

    @PageName
    override fun getPageName() = PageName.HOME
}

在示例代碼中,我們主要做了以下幾件事:

  1. 繼承BaseRecyclerViewModel,實現最重要的loadData函數
  2. 在loadData,我們可以獲取到當前加載是否為首頁數據加載或者是更多數據加載,是否為重試加載,頁碼以及游標偏移等
  3. 根據上述參數,開啟協程,請求數據,把數據封裝成BaseViewData<*>列表,通過調用父類提供的postData提交數據
  4. 也可以通過調用父類提供的postError提交加載出錯

下面開始簡要說明列表架構的實現原理。整體架構如下圖所示:

列表架構

要考慮Item和Adapter的復用,我們通過源碼的方式引入MultiType,封裝了一個BaseAdapter,并且在里面提供一些最通用的函數:

open class BaseAdapter : MultiTypeAdapter() {

    init {
        register(LoadMoreViewDelegate())
        ...
    }

    open fun setViewData(viewData: List<BaseViewData<*>>) {
        items.clear()
        items.addAll(viewData)
        notifyDataSetChanged()
    }

    ...
}

另外,根據MultiType的用法,我們封裝一個BaseItemViewDelegate:

abstract class BaseItemViewDelegate<T : BaseViewData<*>, VH : RecyclerView.ViewHolder> : ItemViewDelegate<T, VH>() {

    @CallSuper
    override fun onBindViewHolder(holder: VH, item: T) {
        holder.itemView.setOnClickListener {
            performItemClick(it, item, holder)
        }
        holder.itemView.setOnLongClickListener {
            return@setOnLongClickListener performItemLongClick(it, item, holder)
        }
    }

    /**
     * 條目點擊監(jiān)聽
     */
    protected fun performItemClick(view: View, item: BaseViewData<*>, holder: RecyclerView.ViewHolder) {
        val recyclerView = getRecyclerView(view)
        if (null != recyclerView) {
            val position: Int = holder.absoluteAdapterPosition
            val id = holder.itemId
            recyclerView.performItemClick(view, item, position, id)
        }
    }

    /**
     * 條目長按監(jiān)聽
     */
    protected fun performItemLongClick(view: View, item: BaseViewData<*>, holder: RecyclerView.ViewHolder): Boolean {
        var consumed = false
        val recyclerView = getRecyclerView(view)
        if (null != recyclerView) {
            val position: Int = holder.absoluteAdapterPosition
            val id = holder.itemId
            consumed = recyclerView.performItemLongClick(view, item, position, id)
        }
        return consumed
    }

    /**
     * 子View點擊監(jiān)聽
     */
    protected fun performItemChildViewClick(view: View, item: BaseViewData<*>, holder: RecyclerView.ViewHolder, extra: Any?) {
        val recyclerView = getRecyclerView(view)
        if (null != recyclerView) {
            val position: Int = holder.absoluteAdapterPosition
            val id = holder.itemId
            recyclerView.performItemChildViewClick(view, item, position, id, extra)
        }
    }

    /**
     * 獲取裝載自己的XRecyclerView
     */
    private fun getRecyclerView(child: View): XRecyclerView? {
        var recyclerView: XRecyclerView? = null
        var parent: ViewParent = child.parent
        while (parent is ViewGroup) {
            if (parent is XRecyclerView) {
                recyclerView = parent
                break
            }
            parent = parent.getParent()
        }
        return recyclerView
    }

}

在BaseItemViewDelegate里面,我們處理了RecyclerView的所有點擊監(jiān)聽,包括短按、長按、Item子View的點擊監(jiān)聽,通過不斷回溯父View的方式,最終將點擊事件委托給我們將要封裝的XRecyclerView來處理,最終交由使用方(Activity/Fragment等)來回調。

MultiType的核心思想是一種class對應一種Item,為了進一步隔離并且使得相同的class可以對應多種Item,我們抽象了一個BaseViewData包裝類:

open class BaseViewData<T>(var value: T) {
    ...
}

那么通過繼承實現不同的BaseViewData就可以不同的Item,同時,我們也需要把MultiType的源碼作相應修改。

既然實現不同的BaseViewData就可以不同的Item,那么我們很自然地就想到我們的上拉加載怎么實現了,實現一個LoadMoreViewData和LoadMoreViewDelegate,當成是普通的Item來處理就好了。

class LoadMoreViewData(@LoadMoreState loadMoreState: Int) : BaseViewData<Int>(loadMoreState) {
}

class LoadMoreViewDelegate : BaseItemViewDelegate<LoadMoreViewData, LoadMoreViewDelegate.ViewHolder>() {

    ...

    class ViewHolder(val viewBinding: ViewRecyclerFooterBinding) : RecyclerView.ViewHolder(viewBinding.root) {

    }
}

接下來繼續(xù)實現加載更多的功能,我們現在需要繼承BaseAdapter封裝一個LoadMoreAdapter,核心思路是將LoadMoreViewData始終作為列表的最后一項來處理,并且對外提供setLoadMoreState函數來設置加載更多的狀態(tài)。

簡要示例代碼如下:

class LoadMoreAdapter : BaseAdapter() {

    private val loadMoreViewData = LoadMoreViewData(LoadMoreState.LOADING)

    /**
     * 重寫setViewData,添加加載更多條目
     */
    override fun setViewData(viewData: List<BaseViewData<*>>) {
        val mutableViewData = viewData.toMutableList()
        mutableViewData.add(loadMoreViewData)
        super.setViewData(mutableViewData)
    }

    fun setLoadMoreState(@LoadMoreState loadMoreState: Int) {
        val position = itemCount - 1
        if (isLoadMoreViewData(position)) {
            loadMoreViewData.value = loadMoreState
            notifyItemChanged(position)
        }
    }

    ...
}

接下來實現預加載這一塊,核心思路是先封裝一個LoadMoreRecyclerView,原理是通過addOnScrollListener來判斷RecyclerView的滾動狀態(tài)和數量,觸發(fā)預加載的onLoadMore回調:

class LoadMoreRecyclerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {

    private var onLoadMoreListener: OnLoadMoreListener? = null
    private lateinit var scrollChangeListener: LoadMoreRecyclerScrollListener

    override fun setAdapter(adapter: Adapter<*>?) {
        // 傳進來的Adapter必須是BaseLoadMoreAdapter
        val loadMoreAdapter = adapter as LoadMoreAdapter
        // 必須先設置LayoutManager再設置Adapter
        scrollChangeListener = object : LoadMoreRecyclerScrollListener(layoutManager!!) {
            override fun onLoadMore(page: Int, totalItemsCount: Int) {
                // 觸發(fā)預加載
                if (canLoadMore) {
                    onLoadMoreListener?.onLoadMore(page, totalItemsCount)
                }
            }
        }
        addOnScrollListener(scrollChangeListener)
        super.setAdapter(adapter)
    }

    fun setOnLoadMoreListener(listener: OnLoadMoreListener) {
        this.onLoadMoreListener = listener
    }

    interface OnLoadMoreListener {
        fun onLoadMore(page: Int, totalItemsCount: Int)
    }

    ...
}

加載更多完成后,我們開始考慮下拉刷新怎么實現了。這一塊就不詳細說明了,主要是利用PtrFrameLayout來進行封裝一個PullRefreshLayout:

class PullRefreshLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : PtrFrameLayout(context, attrs, defStyleAttr), PtrUIHandler {

    ...

}

我們還有一個問題,就是數據的來源,我們需要一個通用的BaseRecyclerViewModel基類:

abstract class BaseRecyclerViewModel : BaseViewModel() {

    /**
     * 首頁/下拉刷新的數據
     */
    val firstViewDataLiveData = MutableLiveData<List<BaseViewData<*>>>()

    /**
     * 更多的數據
     */
    val moreViewDataLiveData = MutableLiveData<List<BaseViewData<*>>>()

    /**
     * 頁碼
     */
    private var currentPage = AtomicInteger(0)


    /**
     * 子類重寫這個函數加載數據
     * 數據加載完成后通過postData提交數據
     * 數據加載完成后通過postError提交異常
     *
     * @param isLoadMore 當次是否為加載更多
     * @param isReLoad   當次是否為重新加載(此時page等參數不應該改變)
     * @param page       頁碼
     */
    abstract fun loadData(isLoadMore: Boolean, isReLoad: Boolean, page: Int)

    fun loadDataInternal(isLoadMore: Boolean, isReLoad: Boolean) {
        if (needNetwork() && !isNetworkConnect()) {
            postError(isLoadMore)
            return
        }
        if (!isLoadMore) {
            currentPage.set(0)
        } else if (!isReLoad) {
            currentPage.incrementAndGet()
        }
        loadData(isLoadMore, isReLoad, currentPage.get())
    }

    /**
     * 提交數據
     */
    protected fun postData(isLoadMore: Boolean, viewData: List<BaseViewData<*>>) {
        if (isLoadMore) {
            moreViewDataLiveData.postValue(viewData)
        } else {
            firstViewDataLiveData.postValue(viewData)
        }
    }

    /**
     * 提交加載異常
     */
    protected fun postError(isLoadMore: Boolean) {
        if (isLoadMore) {
            moreViewDataLiveData.postValue(LoadError)
        } else {
            firstViewDataLiveData.postValue(LoadError)
        }
    }

    ...
}

BaseRecyclerViewModel的核心功能是提供loadDataInternal函數給將要封裝的XRecyclerView來調用,觸發(fā)數據加載邏輯,然后BaseRecyclerViewModel的子類可以重寫loadData函數來實現具體的數據加載邏輯。由于子類一般會在loadData里面開啟線程來加載數據,所以這里的頁碼等信息我們需要使用原子類來包裝處理。

數據加載完成后,通過postData或者postError向LiveData發(fā)送數據,在XRecyclerView做個監(jiān)聽就可以拿到這些數據,最終交給Adapter來處理刷新RecyclerView。

最后,我們實現一個門面控件XRecyclerView,將所有功能包裝起來:

class XRecyclerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : ConstraintLayout(context, attrs) {

    fun init(config: Config) {
        config.check(context)
        this.config = config
        initView()
        initData()
    }

    private fun initView() {
    }

    private fun initData() {
    }

    class Config {

    }

    ...
}

XRecyclerView是一個自定義的組合控件,通過Config來對外提供配置入口,封裝了諸如空白異常頁、Loading等控件。另外我們還監(jiān)聽了網絡狀態(tài)實現了自動重試,這些就不仔細展開了。

網絡架構搭建

網絡架構這一塊,采用Retrofit+OkHttp+協程來進行封裝。先來看一下總體預覽:

網絡架構

我們還是先看一下封裝成果。

在網絡請求之前,我們先在定義網絡接口:

interface INetworkService {

    @GET("videodetail")
    suspend fun requestVideoDetail(@Query("id") id: String): BaseResponse<VideoBean>
}

然后一個網絡interface對應創(chuàng)建一個簡單的BaseNetworkApi類型的對象,比如NetworkApi:

object NetworkApi : BaseNetworkApi<INetworkService>("http://172.16.47.112:8080/XArchServer/") {

    suspend fun requestVideoDetail(id: String) = getResult {
        service.requestVideoDetail(id)
    }
}

在繼承并創(chuàng)建BaseNetworkApi對象的時候,我們需要傳入baseUrl給BaseNetworkApi的構造行數,泛型參數傳入我們剛剛定義好的網絡interface。最后對外提供網絡API的掛起函數,里面調用service.xxx()函數進行具體的網絡請求,而service就是網絡interface的具體實現。

另外我們還用getResult包裝了一下,目的是做網絡錯誤處理和請求重試,以及將BaseResponse<XXX>轉換成帶異常信息的Result<XXX>,其中Result這個類是Kotlin給我們提供的一個標準的類。

最后,我們在ViewModel里面開啟一個協程,僅僅通過調用NetworkApi的requestXXX方法就可以拿到網絡請求結果了:

class SmallVideoViewModel : BaseViewModel() {

    val helloWorldLiveData = MutableLiveData<Result<VideoBean>>()

    fun requestVideoDetail(id: String) {
        viewModelScope.launch {
            val result = NetworkApi.requestVideoDetail(id)
            helloWorldLiveData.value = result
        }
    }
}

到這里為止,就是一個最簡單的網絡請求示例了,記得要先啟動服務端的Tomcat才能測試成功,對應的服務端源碼在這里(用IDEA打開即可):https://github.com/huannan/XArchServer

服務端就是最簡單的Java Web項目,封裝了最基礎的Servlet,以及引入了FastJson,代碼都比較簡單就不詳細解釋了,有興趣的可以看一下。項目架構如下:

服務端項目架構

下面開始講解網絡框架里面最重要的基類BaseNetworkApi:

abstract class BaseNetworkApi<I>(private val baseUrl: String) : IService<I> {

    protected val service: I by lazy {
        getRetrofit().create(getServiceClass())
    }

    protected open fun getRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(baseUrl)
            .client(getOkHttpClient())
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    private fun getServiceClass(): Class<I> {
        val genType = javaClass.genericSuperclass as ParameterizedType
        return genType.actualTypeArguments[0] as Class<I>
    }

    private fun getOkHttpClient(): OkHttpClient {
        val okHttpClient = getCustomOkHttpClient()
        if (null != okHttpClient) {
            return okHttpClient
        }
        return defaultOkHttpClient
    }

    protected open fun getCustomOkHttpClient(): OkHttpClient? {
        return null
    }

    protected open fun getCustomInterceptor(): Interceptor? {
        return null
    }

    protected suspend fun <T> getResult(block: suspend () -> BaseResponse<T>): Result<T> {
        for (i in 1..RETRY_COUNT) {
            try {
                val response = block()
                if (response.code != ErrorCode.OK) {
                    throw NetworkException.of(response.code, "response code not 200")
                }
                if (response.value == null) {
                    throw NetworkException.of(ErrorCode.VALUE_IS_NULL, "response value is null")
                }
                return Result.success(response.value)
            } catch (throwable: Throwable) {
                if (throwable is NetworkException) {
                    return Result.failure(throwable)
                }
                if ((throwable is HttpException && throwable.code() == ErrorCode.UNAUTHORIZED)) {
                    // 這里刷新token,然后重試
                }
            }
        }
        return Result.failure(NetworkException.of(ErrorCode.VALUE_IS_NULL, "unknown"))
    }

    companion object {
        private const val RETRY_COUNT = 2
        private val defaultOkHttpClient by lazy {
            val builder = OkHttpClient.Builder()
                .callTimeout(10L, TimeUnit.SECONDS)
                .connectTimeout(10L, TimeUnit.SECONDS)
                .readTimeout(10L, TimeUnit.SECONDS)
                .writeTimeout(10L, TimeUnit.SECONDS)
                .retryOnConnectionFailure(true)

            builder.addInterceptor(CommonRequestInterceptor())
            builder.addInterceptor(CommonResponseInterceptor())
            if (BuildConfig.DEBUG) {
                val loggingInterceptor = HttpLoggingInterceptor()
                loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
                builder.addInterceptor(loggingInterceptor)
            }

            builder.build()
        }
    }
}

首先,我們利用了泛型擦除的特性,在創(chuàng)建一個帶泛型參數的Interface也就是IService,那么在BaseNetworkApi里面就可以通過getServiceClass函數來獲取子類傳進來的泛型參數。

然后就是對外提供了service的實現,service是通過lazy來延遲加載,具體就是Retrofit+OkHttp那一套東西,相信大家都爛熟于心了。其中defaultOkHttpClient筆者放到了伴生對象里面,目的是保證defaultOkHttpClient有且只有一個。

最后也就是最復雜的網絡重試和異常處理這一塊,對子類提供了一個getResult函數,核心思路是將網絡請求保證成一個高階函數,在循環(huán)中調用,循環(huán)的次數就是網絡重試的次數。在循環(huán)中,我們可以根據網絡返回的信息進行異常處理和重試(即控制是否return)。

另外,在getResult函數里面,我們還將BaseResponse<T>的換成了Result<T>,目的是將異常信息也帶回給調用方。

持久化

這一塊主要是Room的使用和MMKV的簡單封裝,示例代碼如下:

@Database(entities = [User::class], version = 1)
abstract class XDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao

    companion object {
        private val db: XDatabase by lazy {
            Room.databaseBuilder(
                XArchApplication.instance,
                XDatabase::class.java, "database-name"
            ).build()
        }

        fun userDao(): UserDao {
            return db.userDao()
        }
    }
}

@Dao
interface UserDao {

    @Query("SELECT * FROM user")
    suspend fun getAll(): List<User>

    ...
}
/**
 * 本類為MMKV的封裝類,防止代碼入侵
 */
object XKeyValue {

    fun init(application: Application) {
        MMKV.initialize(application)
    }

    fun putBoolean(@Key key: String, value: Boolean) {
        MMKV.defaultMMKV().encode(key, value)
    }

    fun getBoolean(@Key key: String, defaultValue: Boolean = false): Boolean {
        return MMKV.defaultMMKV().decodeBool(key, defaultValue)
    }

    fun putString(@Key key: String, value: String) {
        MMKV.defaultMMKV().encode(key, value)
    }

    fun getString(@Key key: String, defaultValue: String = ""): String {
        return MMKV.defaultMMKV().decodeString(key, defaultValue)!!
    }

    fun putInt(@Key key: String, value: Int) {
        MMKV.defaultMMKV().encode(key, value)
    }

    fun getInt(@Key key: String, defaultValue: Int = 0): Int {
        return MMKV.defaultMMKV().decodeInt(key, defaultValue)
    }

    ...
}

需要提一下的是Room的API已經支持返回掛起函數了。

這一塊比較簡單就不贅述了。

期望和總結

文章主要帶大家實現了Gradle配置統一管理、基類封裝、視圖綁定、底部導航欄的實現、事件總線框架封裝、列表架構封裝、網絡架構搭建、持久化,講的都是筆者在搭建整個架構的核心思路,里面其實還有大量邏輯和細節(jié),可以直接查閱源碼:

一個完整的項目還有諸如下面等大量工作需要實現:

  • 路由管理這一塊還沒實現
  • 引入DiffUtil
  • 組件化改造,將各種業(yè)務無關的功能抽取到lib-base模塊并且解決模塊間的通信和路由
  • 完善Repository層等等

這些功能筆者會在后面持續(xù)更新,如果覺得這個架構還不錯或者有任何問題,可以加筆者微信huannan88,大家一起來討論。
首頁_gaitubao_288x640.png
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容