玩安卓从 0 到 1 之架构思考

5,313 阅读8分钟

前言

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

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 ,感激不尽!

就这样,下回再见!!!