阅读 1879

玩安卓从 0 到 1 之项目首页

前言

上一篇文章玩安卓从 0 到 1 之总体概览感觉没写好,果不其然,反响不咋地。当然也正常,既没有好看的页面(只是最简单的md文档),又因为好久没写文章了,国庆假期也刚过,有点懵逼,写的时候有好多地方想好好说一说但不知道该怎样描述,于是乎一通粘贴代码。结果。。。

以后写文章可不能这样了,好看的文章不太会排版,还是尽量注重内容吧。现在写文章不仅仅是自己的笔记了,写的不对的话有可能误人子弟;不过反过来说,想要不被误导直接去看官网不得了,那里虽然又可能也有错误但毕竟是少数。

牢骚发到此为止,下面就开始今天的正文吧。

正文

这篇文章说的依然是玩安卓这个 app,按照惯例,还是放一下 Github 地址和 apk 下载地址吧。

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

Github地址:github.com/zhujiang521…

看到标题应该清楚咱们今天要实现的项目的首页,先来看一下实现好的样子吧!

看起来是不是很简单!结构很清晰,最上面是标题栏,往下是 Banner ,再往下就是文章列表了,很简单的一个首页。实现方式有几种,要么直接使用 RecyclerView 直接排列下来,要么用 LinearLayout 一个一个往下排,其实并没有哪种实现方式更好,喜欢使用哪种就用哪种不得了!我在这里选择的使用 LinearLayout 一个一个往下排,简单清晰明了,挺好!

TitleBar 标题栏

咱们就一个一个来吧!先来看下 TitleBar 在首页需要的功能:中间的标题、右上角的搜索和点击事件,之前写过一篇文章就写的怎样自定义 TitleBar :构建安卓项目通用TitleBar,有需要的可以看下。

来看下怎样使用吧:

    <com.zj.core.util.TitleBar
        android:id="@+id/homeTitleBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:backImageVisiable="false"
        app:titleName="首页" />
复制代码

很简单吧,在布局中可以直接指定标题名称和是否显示返回按钮,这里的首页明显不需要返回按钮,所以设置的 false 。咱们来看下在代码中怎样设置这两个属性:

    homeTitleBar.setTitle("标题")
    homeTitleBar.setBackImageVisiable(false)
复制代码

刚才还提到右上角要显示一个搜索的文本并要有点击事件,咱们来看下怎样写:

    homeTitleBar.setRightText("搜索")
    homeTitleBar.setRightTextOnClickListener {
        // 点击事件要实现的逻辑
    }
复制代码

是不是很简单,这就完事了,当然如果想写一个布局每个页面进行 include 也不是不可以,只是有点麻烦而已,实现效果其实是一样的,没有什么对错好坏之分。

Banner

Banner 的话为了简单省事就直接使用三方库了,之前也写过一篇这个三方库的简单使用,可以参考:安卓实现Banner轮播图自定义图片(非网络图片)

三方库的依赖如下:

implementation 'com.youth.banner:banner:2.1.0'
复制代码

写一下使用吧:

<com.youth.banner.Banner
    android:id="@+id/homeBanner"
    android:layout_width="match_parent"
    android:layout_height="@dimen/dp_180" />
复制代码

布局很简单,直接写上就可以了。代码也不难,只需要写一个适配器然后放进去设置开始即可。

先写一个适配器吧:

open class ImageAdapter(private val mContext: Context, mData: List<BannerBean>) :
    BannerAdapter<BannerBean?, ImageAdapter.BannerViewHolder?>(mData) {
    override fun onCreateHolder(parent: ViewGroup,viewType: Int): BannerViewHolder {
        val imageView = ImageView(parent.context)
        imageView.layoutParams = ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT
        )
        imageView.scaleType = ImageView.ScaleType.CENTER_CROP
        return BannerViewHolder(imageView)
    }

    class BannerViewHolder(view: ImageView) :
        RecyclerView.ViewHolder(view) {
        var imageView: ImageView = view
    }

    override fun onBindView(holder: BannerViewHolder?,data: BannerBean?,
                            position: Int,size: Int) {
        Glide.with(mContext).load(if (data?.filePath == null) data?.imagePath else data.filePath).into(holder!!.imageView)
    }
}
复制代码

二十来行代码,核心就是通过 Glide 加载一下图片。

上面说了,要把适配器放进去,放到哪呢?当然是 Banner 中了:

val bannerAdapter = ImageAdapter(context!!, viewModel.bannerList)
homeBanner.adapter = bannerAdapter
// 设置为圆形指示器并开始
homeBanner.setIndicator(CircleIndicator(context)).start()
复制代码

到这里就差不多了,但是为了避免内存泄露和提升性能,需要在 onResume 页面可见的时候开始滚动,在 onPause 页面不可见的时候停止滚动:

    override fun onResume() {
        super.onResume()
        homeBanner.start()
    }
    
    override fun onPause() {
        super.onPause()
        homeBanner.stop()
    }
复制代码

RecyclerView

排到这里就该用 RecyclerView 来展示文章了,这个布局就不贴了,太简单了,但想了想还是需要贴一下,因为这里需要有下拉刷新和上拉加载,这里用到了一个三方库,大家应该都不陌生,下面是依赖:

implementation 'com.scwang.smartrefresh:SmartRefreshLayout:1.1.2'
复制代码

接下来是布局使用方法:

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

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

</com.scwang.smartrefresh.layout.SmartRefreshLayout>
复制代码

RecyclerView 的适配器我用的是泓洋大神的开源库,下面是依赖:

api 'com.zhy:base-rvadapter:3.0.3'
复制代码

先来看一下下拉刷新和上拉加载怎么使用吧:

homeSmartRefreshLayout.apply {
    setOnRefreshListener { reLayout ->
        reLayout.finishRefresh(measureTimeMillis {
             page = 1
             getArticleList(true)
        }.toInt())
    }
    setOnLoadMoreListener { reLayout ->
        val time = measureTimeMillis {
             page++
             getArticleList(true)
        }.toInt()
        reLayout.finishLoadMore(if (time > 1000) time else 1000)
    }
}
复制代码

通过回调的名称也能猜出来怎么调用。

适配器的代码就不往上贴了,大家感兴趣的话可以去上面 Github 中下载代码看。

OK了,首页布局这就完成了,接下来就该获取数据了!

获取数据

Banner数据

这里需要思考一下,Banner 数据是否会实时更新,据我所知,Banner 数据最多一周更新一回,目前市面上的玩安卓 app 大部分都是每次进入都会请求下网络,然后再重新加载,这样无疑会增加网络成本,更何况都是图片,会消耗用户的 money 啊,虽然说现在流量不值钱,但也需要尽可能地省啊!

所以这里的 Banner 数据我进行了一些操作,先去本地数据库中查看是否有 Banner 的数据,如果有,并且和上回刷新的时间间隔在一天以内(这里为了预防更新,所以暂定的一天),那么就把数据库中的数据返回,如果没有数据或者和上回刷新的时间间隔在一天以外的话就去请求网络数据,请求网络数据又分为成功或失败,如果失败则返回失败信息,页面进行显示对应页面;如果成功则记录下当前的时间,如果数据库查出来有数据并且和请求到的数据一样,那么就还返回数据库中的数据;反之,则把数据库中的 Banner 数据删除,并且把请求到的数据插入到数据库中,然后把数据返回。

这里说的有点绕,我还是画个图给大家看看吧,好理解一些:

没怎么画过类似的流程图,之前画过都是在大学的时候,平时话也就是在本上随便画画,画的不好或者不对的地方各位多多包涵!大概说下这张图,意思其实和上面那段话描述的意思一致,从中间粉色的 查看本地数据库 开始,分为各种情况下的数据获取方式。

好了,说了这么多又画了这么多,是时候看看代码实现了:

    fun getBanner(application: Application) = fire {
        val spUtils = SPUtils.getInstance()
        val downImageTime by Preference(DOWN_IMAGE_TIME, System.currentTimeMillis())
        val bannerBeanDao = PlayDatabase.getDatabase(application).bannerBeanDao()
        val bannerBeanList = bannerBeanDao.getBannerBeanList()
        if (bannerBeanList.isNotEmpty() && downImageTime > 0 && downImageTime - System.currentTimeMillis() < ONE_DAY) {
            Result.success(bannerBeanList)
        } else {
            val bannerResponse = PlayAndroidNetwork.getBanner()
            if (bannerResponse.errorCode == 0) {
                val bannerList = bannerResponse.data
                spUtils.put(DOWN_IMAGE_TIME, System.currentTimeMillis())
                if (bannerBeanList.isNotEmpty() && bannerBeanList[0].url == bannerList[0].url) {
                    Result.success(bannerBeanList)
                } else {
                    bannerBeanDao.deleteAll()
                    insertBannerList(application, bannerBeanDao, bannerList)
                    Result.success(bannerList)
                }
            } else {
                Result.failure(RuntimeException("response status is ${bannerResponse.errorCode}  msg is ${bannerResponse.errorMsg}"))
            }
        }
    }
复制代码

其实刚才的逻辑如果看懂了的话这段代码应该看着很简单,就是按照上面的逻辑来写的,对了,插入数据库的 insertBannerList 方法还没写:

    private suspend fun insertBannerList(
        application: Application,
        bannerBeanDao: BannerBeanDao,
        bannerList: List<BannerBean>
    ) {
        bannerList.forEach {
            val file = Glide.with(application)
                .load(it.imagePath)
                .downloadOnly(SIZE_ORIGINAL, SIZE_ORIGINAL)
                .get()
            it.filePath = file.absolutePath
            bannerBeanDao.insert(it)
        }
    }
复制代码

代码都很简单,重要的是这块的思路,接下来该看文章列表的数据了。

文章列表数据

文章的数据获取其实和 Banner 差不多,逻辑基本一样,都是从数据库中读取文件,然后判断是否需要刷新,这块的时间改为了四小时,因为文章可能一直在更新,所以取了个比较小的值,可以根据需求自己来定义。文章列表的数据和 Banner 的不同之处在于文章列表需要请求两次,需要判断当前是第几页,如果是第 0 页的话需要把置顶的文章添加到最前面,如果不是第 0 页的话则只需要把后面的文章添加上。

这里的实现其实我偷懒了,但也不算是偷懒。。。为什么这样说呢?因为我只缓存了第一页的文章列表数据,但其实并不是偷懒,因为文章列表数据不定,可能更新频率很快,缓存了太多页的数据到后来又需要全部更新,亦或者全部删除再重新插入,得不偿失。缓存第一页的数据在于用户之前已经打开过项目了,数据也都正常显示,如果突然没网了,再次重新打开应用不至于大白页或者显示没有网络,显示出缓存的数据比较优雅,如果用户下拉刷新或者上拉加载的话提醒用户当前没有网络即可。

既然 Banner 的数据都画了个图,那么文章列表的数据也得来画一个!

这张图其实有偷懒了,这里判断完是否为第一页之后还要依次判断置顶文章和列表文章,根据数据库的数据和网络请求数据是否一致来判断是否更新数据库的数据,再将数据返回到 ViewModel。

来吧,看下代码吧,这块代码有点多,我只展示下大概的逻辑吧,如果想看完整代码,还是去 Github 上直接下载代码就行:

    fun getArticleList(application: Application, query: QueryHomeArticle) = fire {
        coroutineScope {
            val res = arrayListOf<Article>()
            if (query.page == 1) {
                val spUtils = SPUtils.getInstance()
                val downArticleTime by Preference(DOWN_ARTICLE_TIME, System.currentTimeMillis())
                val articleListDao = PlayDatabase.getDatabase(application).browseHistoryDao()
                val articleListTop = articleListDao.getTopArticleList(HOME_TOP)
                val downTopArticleTime by Preference(
                    DOWN_TOP_ARTICLE_TIME,
                    System.currentTimeMillis()
                )
                if (articleListTop.isNotEmpty() && downTopArticleTime > 0 &&
                    downTopArticleTime - System.currentTimeMillis() < FOUR_HOUR && !query.isRefresh
                ) {
                    res.addAll(articleListTop)
                } else {
                    val topArticleListDeferred =
                        async { PlayAndroidNetwork.getTopArticleList() }
                    val topArticleList = topArticleListDeferred.await()
                    if (topArticleList.errorCode == 0) {
                        if (articleListTop.isNotEmpty() && articleListTop[0].link == topArticleList.data[0].link && !query.isRefresh) {
                            res.addAll(articleListTop)
                        } else {
                            res.addAll(topArticleList.data)
                            topArticleList.data.forEach {
                                it.localType = HOME_TOP
                            }
                            spUtils.put(DOWN_TOP_ARTICLE_TIME, System.currentTimeMillis())
                            articleListDao.deleteAll(HOME_TOP)
                            articleListDao.insertList(topArticleList.data)
                        }
                    }
                }
            } else {
                val articleListDeferred =
                    async { PlayAndroidNetwork.getArticleList(query.page - 1) }
                val articleList = articleListDeferred.await()
                if (articleList.errorCode == 0) {
                    res.addAll(articleList.data.datas)
                    Result.success(res)
                } else {
                    Result.failure(
                        RuntimeException(
                            "response status is ${articleList.errorCode}" + "  msg is ${articleList.errorMsg}"
                        )
                    )
                }
            }
        }
    }

复制代码

大家可以看到我只展示了置顶文章的数据缓存,文章列表的原理一样,就不赘述了。

再放一下这几个模块用到的常量吧:

const val ONE_DAY = 1000 * 60 * 60 * 24
const val FOUR_HOUR = 1000 * 60 * 60 * 4
const val DOWN_IMAGE_TIME = "DownImageTime"
const val DOWN_TOP_ARTICLE_TIME = "DownTopArticleTime"
const val DOWN_ARTICLE_TIME = "DownArticleTime"
const val DOWN_PROJECT_ARTICLE_TIME = "DownProjectArticleTime"
const val DOWN_OFFICIAL_ARTICLE_TIME = "DownOfficialArticleTime"
复制代码

ViewModel

都写的差不多了就该 ViewModel 登场了,ViewModel 的代码比较简单,我直接放上,然后下面简单描述下吧:

class HomePageViewModel(application: Application) : AndroidViewModel(application) {

    private val pageLiveData = MutableLiveData<QueryHomeArticle>()

    private val refreshLiveData = MutableLiveData<Boolean>()

    val bannerList = ArrayList<BannerBean>()

    val articleList = ArrayList<Article>()

    val articleLiveData = Transformations.switchMap(pageLiveData) { query ->
        HomeRepository.getArticleList(application, query)
    }

    val bannerLiveData = Transformations.switchMap(refreshLiveData) { isRefresh ->
        HomeRepository.getBanner(application,isRefresh)
    }

    fun getBanner(isRefresh: Boolean) {
        refreshLiveData.value = isRefresh
    }

    fun getArticleList(page: Int, isRefresh: Boolean) {
        pageLiveData.value = QueryHomeArticle(page, isRefresh)
    }

}

data class QueryHomeArticle(var page: Int, var isRefresh: Boolean)
复制代码

和上一篇文章一样,同样用的是 AndroidViewModel ,因为在 Repository 中需要用到数据库,所以要使用。

ViewModel 中的逻辑很清晰,定义两个 ArrayList 来存放数据,使用 LiveData 来观察数据的改变,两个 get 方法来调动方法执行以改变数据。

横竖屏适配

到这里应该整个逻辑都理通了,代码应该也都写的差不多了,那么来运行下看看吧!

运行之后发现竖屏运行显示正常,但当横屏显示的时候,页面完全无法正常使用!Banner 基本上把所有的空间都占了,文章列表根本无法进行使用!

这个时候就需要横竖屏适配了,其实横竖屏适配很简单,只需要在 res 目录下建立一个 layout-land 的文件夹,把横屏的布局放入进去即可,和竖屏布局的名称一样就行。

在这里大家可以根据需求来重新摆放横屏布局的控件位置。我在这里将屏幕分为两半,左边用来显示 Banner ,右面用来显示文章列表,大家来简单看下布局:

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

            <com.youth.banner.Banner
                android:id="@+id/homeBanner"
                android:layout_width="0dp"
                android:layout_weight="1.5"
                android:layout_height="match_parent" />

            <com.scwang.smartrefresh.layout.SmartRefreshLayout
                android:id="@+id/homeSmartRefreshLayout"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="2">

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

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

        </LinearLayout>
复制代码

完成之后变为如下样式,是不是比刚才好看的多,而且更加容易操作了。

差不多就先写到这里吧,其他的下一篇文章再来!

总结

每次觉得没多少东西的地方写着写着就写多了,每回想着要好好写的东西却死活不知道如何下手写。这一篇文章简单走了一遍一个应用首页的简单实现逻辑,并带给大家横竖屏的简单实现。

写着写着就过了午夜12点了,好久没有写到这么晚了,是自己这个程序员当的太不称职了,也好久没努力地去学习了,连续好久没有进行主动学习,基本一直处于被动学习的局面,不能这样,自己要加油。

努力,共勉。