玩安卓從 0 到 1 之架構思考

2020-10-29 12:01:08

前言

這篇文章是這個系列的第四篇文章了,下面是前三篇文章:

1、玩安卓從 0 到 1 之總體概覽

2、玩安卓從 0 到 1 之專案首頁

3、玩安卓從 0 到 1 之首頁框架搭建

按照慣例,放一下 Github 地址和 apk 下載地址吧!

apk 下載地址:www.pgyer.com/llj2

Github地址:github.com/zhujiang521…

起因

為什麼要寫這一篇文章?感覺寫著寫著又回到了原點。

在第一篇文章的評論中,有下面這麼一條:

掘金評論

在第一篇文章中我們搭建了 BaseActivity 和 BaseFragment,不清楚的可以去看下第一篇文章:玩安卓從 0 到 1 之總體概覽。裡面將一些公共用到的方法抽取了出來,還把 LCE 的操作:比如顯示錯誤、載入失敗、載入內容、網路錯誤等等狀態都放在了 BaseActivity 和 BaseFragment 中。

本來以為這樣寫挺方便,在需要不同狀態的頁面直接將 LCE 的頁面 include 進去即可,但是當看見這個叫 alienzh 的哥們評論之後,我也感覺到了自己這樣寫確實不好,因為這個小專案中很多頁面都需要 LCE,每個頁面都需要 include 一遍,在寫這個小專案的時候就覺得不對,每次還需要為了將 LCE 頁面新增進去而新增一個 FrameLayout 將頁面包裹起來,無形中就多巢狀了一層佈局,比如下面這個佈局:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".view.project.list.ProjectListFragment">

    <com.scwang.smartrefresh.layout.SmartRefreshLayout
        android:id="@+id/offListSmartRefreshLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/offListRecycleView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </com.scwang.smartrefresh.layout.SmartRefreshLayout>

    <include
        layout="@layout/layout_lce"/>

</FrameLayout>

本來一層的佈局直接搞成了這樣,看著也不美觀。所以就想著按照這個哥們的思路來搞一波嘗試下!

解決

BaseActivity增加LCE

翻了下官方檔案,發現在 Activity 中有個叫 addContentView 的方法,它不會移除先前新增的UI元件,會將新新增的空間累積上去,這不正好符合需求嘛!說幹就幹:

val view = View.inflate(this, R.layout.layout_lce, null)
val params = FrameLayout.LayoutParams(
    FrameLayout.LayoutParams.MATCH_PARENT,
    FrameLayout.LayoutParams.MATCH_PARENT
)
params.setMargins(0,
    ConvertUtils.dp2px(if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 70f else 55f),0,0
)
addContentView(view, params)

直接通過 View 來把 LCE 的佈局 inflate 進來,然後根據橫豎屏來將 TitleBar 的高度預留出來,不然顯示的時候就沒有頭佈局了。

下面需要做的就很簡單了,和之前一樣就行:

loading = view.findViewById(R.id.loading)
noContentView = view.findViewById(R.id.noContentView)
badNetworkView = view.findViewById(R.id.badNetworkView)
loadErrorView = view.findViewById(R.id.loadErrorView)
loadFinished()

和之前一樣進行 findViewById 即可,只不過需要通過剛剛 inflate 的 View 來 findViewById,最後別忘記加上 loadFinished(),因為預設是要能正常顯示佈局的。

OK了!很簡單,但是省了很大的事,好多地方會用到。

BaseFragment增加LCE

是不是有人納悶我為什麼要分的這麼清楚,Fragment 和 Activity 不是一樣嘛!直接還用 addContentView 方法不得了嘛!我最初也是這樣想的,但是後來發現自己想錯了。。。。。。

為什麼想錯了呢?大家可以去 Fragment 中看看,根本沒有這樣類似的方法啊(也許有,但我沒找見,知道的可以在評論區告訴我,感激不盡)!

這。。。咋辦呢?

先來看下咱們平時寫 Fragment 的時候怎樣載入佈局吧:

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
    return inflater.inflate(getLayoutId(), container, false)
}

上面的 getLayoutId() 是個抽象方法,用來獲取子類的佈局。

發現了沒?直接 return 了一個 inflate 出來的 View,那麼這就好說了。

再來想一下,咱們的目的是什麼,是要把 LCE 的佈局給新增進去,在上面的佈局檔案中咱們是怎樣操作的?沒錯,用了一個 FrameLayout 包裹了一下,然後裡面放了一個 LCE 的佈局,既然 View 已經知道是什麼了,那咱們自己用程式碼建立一個 FrameLayout 來包裹不就可以了嘛!說幹就幹:

val frameLayout = FrameLayout(context!!)

很簡單,下面直接用 View 來把 LCE 佈局給 inflate 進來:

val lce = View.inflate(context, R.layout.layout_lce, null)
val params = FrameLayout.LayoutParams(
    FrameLayout.LayoutParams.MATCH_PARENT,
    FrameLayout.LayoutParams.MATCH_PARENT
)
val isPort = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
params.setMargins(0,ConvertUtils.dp2px(if (isPort) 70f else 55f),0,0)
lce.layoutParams = params

現在也拿到 LCE 的 View 了,FrameLayout 咱們也建立出來了,原本的佈局用抽象方法已經拿到了,萬事俱備,只欠把這兩個佈局新增進去了,來看下最後的程式碼:

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
    val frameLayout = FrameLayout(context!!)
    val lce = View.inflate(context, R.layout.layout_lce, null)
    val params = FrameLayout.LayoutParams(
        FrameLayout.LayoutParams.MATCH_PARENT,
        FrameLayout.LayoutParams.MATCH_PARENT
    )
		val isPort = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
		params.setMargins(0,ConvertUtils.dp2px(if (isPort) 70f else 55f),0,0)
    lce.layoutParams = params
    val content = inflater.inflate(getLayoutId(), container, false)
    frameLayout.addView(content)
    frameLayout.addView(lce)
    onCreateView(lce)
    return frameLayout
}

這不就可以了嘛!是不是有種恍然大明白的感覺!這裡需要注意一下,frameLayout 在 addView 的時候一定要注意先後順序,我在這裡吃過虧,之前順序搞反了,結果 LCE 佈局的點選時間無法進行使用,後來才發現要把 LCE 放在上面,也就是在後面 addView 就可以了。

繼續探索

上面的 BaseActivity 和 BaseFragment 中將 LCE 佈局提取到了父類別中,雖然減輕了一些子類的負擔,但還是感覺有哪塊不對勁,咱們來看下之前子類中觀察 LiveData 的程式碼:

viewModel.getData().observe(this, Observer {
     if (it.isSuccess) {
         loadFinished()
         val projectTree = it.getOrNull()
         if (projectTree != null) {
             // 執行操作
         } else {
              showLoadErrorView()
         }
     } else {
         showBadNetworkView(View.OnClickListener { initData() })
     }
})

基本上 ViewModel 中用到 LiveData 的都是相同的流程,那麼也可以抽出來啊,之前一直不知道該怎樣進行抽取,但後來想了下,寫一個方法,將 LiveData 傳入進去,在回撥出來在子類進行對應的操作不得了!

第一版優化

說幹就幹,先來看第一版程式碼:

    fun <T> setDataStatus(dataLiveData: LiveData<Result<T>>){
        dataLiveData.observe(this){
            if (it.isSuccess) {
                val articleList = it.getOrNull()
                if (articleList != null) {
                    loadFinished()
                    setData(articleList)
                } else {
                    showLoadErrorView()
                }
            } else {
                showBadNetworkView { initData() }
            }
        }
    }

    protected open fun <T> setData(data: T){

    }

來簡單說下上面程式碼的意思吧!引數很簡單,就是將 LiveData 傳進來,然後進行判斷,然後在成功獲取資料的地方對資料進行賦值,讓子類實現 setData 方法進行對應操作,來隨便看一個子類的寫法吧:

setDataStatus(viewModel.projectTreeLiveData)

直接將 LiveData 扔進去,然後接下來重寫 setData 方法:

override fun <T> setData(data: T){
    data as List<ProjectClassify>
    // 進行對應操作
}

是不是也不難,但是好像感覺哪裡不對,咋還需要強轉一下呢?應該是直接獲取到對應型別才對啊!當時感覺走到了死衚衕,背後好多路等著走偏不回頭,非得死磕,還想到了 Kotlin 的泛型實化、行內函式、crossinline,但後來一想都沒啥關係啊!

第二版優化

有時候寫程式碼就是這樣,思路一下子定住就出不來了!後來一想在方法上再接受一個介面回撥不得了,於是又有了第二版:

fun <T> setDataStatus(dataLiveData: LiveData<Result<T>>, onDataStatus: DataStatusListener<T>) {
    dataLiveData.observe(this) {
        if (it.isSuccess) {
            val dataList = it.getOrNull()
            if (dataList != null) {
                loadFinished()
                onDataStatus.onDataStatus(dataList)
            } else {
                showLoadErrorView()
            }
        } else {
            showBadNetworkView { initData() }
        }
    }
}

interface DataStatusListener<T> {
    fun onDataStatus(t: T)
}

這樣不就可以了嘛!來看下使用方法有什麼改變:

setDataStatus(dd.getDataLiveData(), collect -> {
   // 執行對應操作     
});

第三版探索

這樣只是增加了個藉口就完美解決了剛才那樣需要強轉的問題,不對!這是 Kotlin 啊,不需要藉口回撥啊,Kotlin 可以都幹掉啊,高階函數不就是幹這個事的嘛!腦子真的瓦特掉了!

fun <T> setDataStatus(dataLiveData: LiveData<Result<T>>, onDataStatus: (T) -> Unit) {
    dataLiveData.observe(this) {
        if (it.isSuccess) {
            val dataList = it.getOrNull()
            if (dataList != null) {
                loadFinished()
                onDataStatus(dataList)
            } else {
                showLoadErrorView()
            }
        } else {
            showBadNetworkView { initData() }
        }
    }
}

這樣寫不香嘛😂!搞那麼多花裡胡哨的!要什麼藉口,不要了!

遇到的問題

這個專案我接入了騰訊的 Bugly 來檢視使用中出現的 Crash,發現一直有個問題:

Bugly Crash

問題原因

這就給我整懵逼了,知道是哪塊程式碼出了問題,但就是不知道該怎樣改,百度、Google 找了不知道多久都沒有一絲頭緒,先給大家看下出問題的程式碼:

protected open fun fragmentManger(position: Int) {
    mViewModel.setPage(position)
    val targetFg: Fragment = mFragments!![position]
    val transaction = mFragmentManager!!.beginTransaction()
    if (currentFragment != null) {
        transaction.hide(currentFragment!!)
    }
    if (!targetFg.isAdded) {
        transaction.add(R.id.flHomeFragment, targetFg).commit()
    } else {
        // 這裡報錯
        transaction.show(targetFg).commit()
    }
    currentFragment = targetFg
}

很簡單的一段程式碼,只是切換了個 Fragment 而已,就一直報上面的錯誤,大家也可以隨便去百度,這個問題當時給我噁心壞了,總感覺應該是一個很小的錯誤導致的,但就是找不到這個錯誤在哪!

這種感覺很噁心,但還是會經常遇到。我也不詳細描述解決的過程吧,挺艱辛的,但解決方法和原因都非常簡單。。。。

來看下問題詳情:

一看問題描述就知道是因為 HomePageFragment 已經 attached 了 FragmentManager 了,就不能再次 attached。問題很簡單,但為啥呢???為啥不行呢,其他地方也沒有錯誤啊!

最後,罪魁禍首竟然是因為我使用了單例。。。。。

object FragmentFactory {

    private val mHomeFragment: HomePageFragment by lazy { HomePageFragment.newInstance() }
    private val mProjectFragment: ProjectFragment by lazy { ProjectFragment.newInstance() }
    private val mObjectListFragment: OfficialAccountsFragment by lazy { OfficialAccountsFragment.newInstance() }
    private val mProfileFragment: ProfileFragment by lazy { ProfileFragment.newInstance() }

    fun getCurrentFragment(index: Int): Fragment? {
        return when (index) {
            0 -> mHomeFragment
            1 -> mProjectFragment
            2 -> mObjectListFragment
            3 -> mProfileFragment
            else -> null
        }
    }
    
}

之前為了 Fragment 能夠重用而不用重新新建而建立的單例,結果一切問題都是因為它!因為單例導致生命週期不一致從而引發的問題!看來以後單例也不敢瞎用了!一定要考慮清楚。

解決方法

解決方法很簡單,直接將 Fragment 放到空間中,保持生命週期一致即可,這裡就不貼程式碼了,和上面程式碼是一致的。想看的可以去 Github 下載程式碼看:com.zj.play.view.main.BaseHomeBottomTabWidget。

總結

也寫了不少了,亂七八糟說了一大堆,這一篇文章並沒有繼續往前寫這個小專案,而是回頭來看了下是否應該這樣寫,感覺比之前的幾篇文章更有用。

能力一般、水平有限,對大家有幫助的話別忘了三連,有 Github 賬號的幫忙點個 Star ,感激不盡!

就這樣,下回再見!!!