【开源篇】组件化+Jetpack+MVVM项目实战,涉及协程+Retrofit,Paging3+Room等

052022374231_0f.png

一、项目简介

微信截图_20210521163936.png

该项目主要以组件化+Jetpack+MVVM为架构,使用Kotlin语言,集合了最新的Jetpack组件,如NavigationPaging3Room等,另外还加上了依赖注入框架Koin和图片加载框架Coil

网络请求部分使用OkHttp+Retrofit,配合Kotlin的协程,完成了对Retrofit和协程的请求封装,结合LoadSir进行状态切换管理,让开发者只用关注自己的业务逻辑,而不要操心界面的切换和通知。

对于具体的网络封装思路,可参考【Jetpack篇】协程+Retrofit网络请求状态封装实战【Jetpack篇】协程+Retrofit网络请求状态封装实战(2)

项目地址:https://github.com/fuusy/wanandroid_jetpack_kt

如果此项目对你有帮助和价值,烦请给个star⭐⭐,或者有什么好的建议或意见,可以发个issues,感谢!

二、项目详情

2.1、组件化搭建项目时暴露出的问题

2.1.1、如何独立运行一个Module?

运行总App时,子Module是属于library,而独立运行时,子Module是属于application。那么我们只需要在根目录下gradle.properties中添加一个标志位来区分一下子Module的状态,例如singleModule = false ,该标志位可以用来表示当前Module是否是独立模块,true表示处于独立模块,可单独运行,false则表示是一个library。

image-20210425094424273.png

如何使用呢?

在每个Modulebuild.gradle中加入singleModule的判断,以区分是application还是library。如下:

if (!singleModule.toBoolean()) {
    apply plugin: 'com.android.library'
} else {
    apply plugin: 'com.android.application'
}

......
dependencies {
}

如果需要独立运行只需要修改gradle.properties标志位singleModule的值。

2.1.2、编译运行后,桌面会出现多个相同图标;

当新建多个Moudle的时候,运行后你会发现桌面上会出现多个相同的图标,

image-20210425100807316.png

其实每个图标都能够独立运行,但是到最后App发布的时候,肯定是只需要一个总入口就可以了。

发生这种情况的原因很简单,因为新建一个Module,结构相当于一个project,AndroidManifest.xml包括Activity都存在,在AndroidManifest.xml为Activity设置了actioncategory,当app运行时,也就在桌面上为webview这个模块生成了一个入口。

image-20210425102207853.png

解决方案很简单,删除上图红色框框中的代码即可。

但是...... 问题又双叒叕来了,删除了<intent-filter>中代码,确实可以解决多个图标的问题,但是当该子Moudle需要独立运行时,由于缺少<intent-filter>中的声明,该Module就无法正常运行

以下图项目为例:

image-20210425103221979.png

我们可以在”webview“Module中,新建一个和java同层级的包,取名:manifest,将AndroidManifest.xml复制到该包下,并且将/manifest/AndroidManifest.xml中内容进行删除修改。

image-20210425104829329.png

只留有一个空壳子,原来的AndroidManifest.xml则保持不变。同时在webview的build.gradle中利用sourceSets进行区分。

android{
    sourceSets{
        main {
            if (!singleModule.toBoolean()) {
                //如果是library,则编译manifest下AndroidManifest.xml
                manifest.srcFile 'src/main/manifest/AndroidManifest.xml'
            } else {
                //如果是application,则编译主目录下AndroidManifest.xml
                manifest.srcFile 'src/main/AndroidManifest.xml'
            }
        }
    }
}

通过修改SourceSets中的属性,可以指定需要被编译的源文件,根据singleModule.toBoolean()来判断当前Module是属于application还是library,如果是library,则编译manifest下AndroidManifest.xml,反之则直接编译主目录下AndroidManifest.xml。

上述处理后,子Moudule当作library时不会出现多个图标的情况,同时也可以独立运行。

2.1.3、组件间通信

主要借助阿里的路由框架ARouter,具体使用请参考https://github.com/alibaba/ARouter

2.2、Jetpack组件

2.2.1、Navigation

Navigation是一个管理Fragment切换的组件,支持可视化处理。开发者也完全不用操心Fragment的切换逻辑。基本使用请参考官方说明

在使用Navigation的过程中,会出现点击back按键,界面会重新走了onCreate生命周期,并且将页面重构。例如Navigation与BottomNavigationView结合时,点击tab,Fragment会重新创建。目前比较好的解决方法是自定义FragmentNavigator,将内部replace替换为show/hide

另外,官方对于与BottomNavigationView结合时的情况也提供了一种解决方案。
官方提供了一个BottomNavigationView的扩展函数NavigationExtensions

将之前共用一个navigation分为每个模块单独一个navigation,例如该项目分为首页项目我的三个tab,相应的新建了三个navigation:R.navigation.navi_home, R.navigation.navi_project, R.navigation.navi_personal
Activity中BottomNavigationViewNavigation进行绑定时也做出了相应的改变。

    /**
     * navigation绑定BottomNavigationView
     */
    private fun setupBottomNavigationBar() {
        val navGraphIds =
            listOf(R.navigation.navi_home, R.navigation.navi_project, R.navigation.navi_personal)

        val controller = mBinding?.navView?.setupWithNavController(
            navGraphIds = navGraphIds,
            fragmentManager = supportFragmentManager,
            containerId = R.id.nav_host_container,
            intent = intent
        )
        
        currentNavController = controller
    }

官方这么做的目的在于让每个模块单独管理自己的Fragment栈,在tab切换时,不会相互影响。

2.2,2、Paging3

Paging是一个分页组件,主要与Recyclerview结合分页加载数据。具体使用可参考此项目“每日一问”部分,如下:

UI层:

class DailyQuestionFragment : BaseFragment<FragmentDailyQuestionBinding>() {
...

private fun loadData() {
        lifecycleScope.launchWhenCreated {
            mViewModel.dailyQuestionPagingFlow().collectLatest {
                dailyPagingAdapter.submitData(it)
            }
        }
    }
...
}

ViewModel层:

class ArticleViewModel(private val repo: HomeRepo) : BaseViewModel(){
    /**
     * 请求每日一问数据
     */
    fun dailyQuestionPagingFlow(): Flow<PagingData<DailyQuestionData>> =
        repo.getDailyQuestion().cachedIn(viewModelScope)

}

Repository层

class HomeRepo(private val service: HomeService, private val db: AppDatabase) : BaseRepository(){
    /**
     * 请求每日一问
     */
    fun getDailyQuestion(): Flow<PagingData<DailyQuestionData>> {

        return Pager(config) {
            DailyQuestionPagingSource(service)
        }.flow
    }
}

PagingSource层:

/**
 * @date:2021/5/20
 * @author fuusy
 * @instruction: 每日一问数据源,主要配合Paging3进行数据请求与显示
 */
class DailyQuestionPagingSource(private val service: HomeService) :

    PagingSource<Int, DailyQuestionData>() {
    override fun getRefreshKey(state: PagingState<Int, DailyQuestionData>): Int? = null

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, DailyQuestionData> {
        return try {
            val pageNum = params.key ?: 1
            val data = service.getDailyQuestion(pageNum)
            val preKey = if (pageNum > 1) pageNum - 1 else null
            LoadResult.Page(data.data?.datas!!, prevKey = preKey, nextKey = pageNum + 1)

        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
}
2.2.3、Room

Room是一个管理数据库的组件,此项目主要将Paging3与Room相结合。2.3小节主要介绍了Paging3从网络上加载数据分页,而这不同的是,结合Room需要RemoteMediator的协同处理。

RemoteMediator主要作用是:可以使用此信号从网络加载更多数据并将其存储在本地数据库中,PagingSource可以从本地数据库加载这些数据并将其提供给界面进行显示。 当需要更多数据时,Paging 库从 RemoteMediator 实现调用load()方法。具体使用方法可参考此项目首页文章列表部分

RoomPaging3结合时,UI层ViewModel层的操作与2.3小节一致,主要修改在于Repository层。

Repository层:

class HomeRepo(private val service: HomeService, private val db: AppDatabase) : BaseRepository() {
   /**
     * 请求首页文章,
     * Room+network进行缓存
     */
    fun getHomeArticle(articleType: Int): Flow<PagingData<ArticleData>> {
        mArticleType = articleType
        return Pager(
            config = config,
            remoteMediator = ArticleRemoteMediator(service, db, 1),
            pagingSourceFactory = pagingSourceFactory
        ).flow
    }
}

DAO:

@Dao
interface ArticleDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertArticle(articleDataList: List<ArticleData>)

    @Query("SELECT * FROM tab_article WHERE articleType =:articleType")
    fun queryLocalArticle(articleType: Int): PagingSource<Int, ArticleData>

    @Query("DELETE FROM tab_article WHERE articleType=:articleType")
    suspend fun clearArticleByType(articleType: Int)
    
}

RoomDatabase:

@Database(
    entities = [ArticleData::class, RemoteKey::class],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {

    abstract fun articleDao(): ArticleDao
    abstract fun remoteKeyDao(): RemoteKeyDao

    companion object {
        private const val DB_NAME = "app.db"

        @Volatile
        private var instance: AppDatabase? = null

        fun get(context: Context): AppDatabase {
            return instance ?: Room.databaseBuilder(context, AppDatabase::class.java,
                DB_NAME
            )
                .build().also {
                    instance = it
                }
        }
    }
}

自定义RemoteMediator:

/**
 * @date:2021/5/20
 * @author fuusy
 * @instruction:RemoteMediator 的主要作用是:在 Pager 耗尽数据或现有数据失效时,从网络加载更多数据。
 * 可以使用此信号从网络加载更多数据并将其存储在本地数据库中,PagingSource 可以从本地数据库加载这些数据并将其提供给界面进行显示。
 * 当需要更多数据时,Paging 库从 RemoteMediator 实现调用 load() 方法。这是一项挂起功能,因此可以放心地执行长时间运行的工作。
 * 此功能通常从网络源提取新数据并将其保存到本地存储空间。
 * 此过程会处理新数据,但长期存储在数据库中的数据需要进行失效处理(例如,当用户手动触发刷新时)。
 * 这由传递到 load() 方法的 LoadType 属性表示。LoadType 会通知 RemoteMediator 是需要刷新现有数据,还是提取需要附加或前置到现有列表的更多数据。
 */
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
    private val api: HomeService,
    private val db: AppDatabase,
    private val articleType: Int
) : RemoteMediator<Int, ArticleData>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, ArticleData>
    ): MediatorResult {

        /*
        1.LoadType.REFRESH:首次访问 或者调用 PagingDataAdapter.refresh() 触发
        2.LoadType.PREPEND:在当前列表头部添加数据的时候时触发,实际在项目中基本很少会用到直接返回 MediatorResult.Success(endOfPaginationReached = true) ,参数 endOfPaginationReached 表示没有数据了不在加载
        3.LoadType.APPEND:加载更多时触发,这里获取下一页的 key, 如果 key 不存在,表示已经没有更多数据,直接返回 MediatorResult.Success(endOfPaginationReached = true) 不会在进行网络和数据库的访问
         */
        try {
            Log.d(TAG, "load: $loadType")
            val pageKey: Int? = when (loadType) {
                LoadType.REFRESH -> null
                LoadType.PREPEND -> return MediatorResult.Success(true)
                LoadType.APPEND -> {
                    //使用remoteKey来获取下一个或上一个页面。
                    val remoteKey =
                        state.lastItemOrNull()?.id?.let {
                            db.remoteKeyDao().remoteKeysArticleId(it, articleType)
                        }

                    //remoteKey' null ',这意味着在初始刷新后没有加载任何项目,也没有更多的项目要加载。
                    if (remoteKey?.nextKey == null) {
                        return MediatorResult.Success(true)
                    }
                    remoteKey.nextKey
                }
            }

            val page = pageKey ?: 0
            //从网络上请求数据
            val result = api.getHomeArticle(page).data?.datas
            result?.forEach {
                it.articleType = articleType
            }
            val endOfPaginationReached = result?.isEmpty()

            db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    //清空数据
                    db.remoteKeyDao().clearRemoteKeys(articleType)
                    db.articleDao().clearArticleByType(articleType)
                }
                val prevKey = if (page == 0) null else page - 1
                val nextKey = if (endOfPaginationReached!!) null else page + 1
                val keys = result.map {
                    RemoteKey(
                        articleId = it.id,
                        prevKey = prevKey,
                        nextKey = nextKey,
                        articleType = articleType
                    )
                }
                db.remoteKeyDao().insertAll(keys)
                db.articleDao().insertArticle(articleDataList = result)
            }
            return MediatorResult.Success(endOfPaginationReached!!)
        } catch (e: IOException) {
            return MediatorResult.Error(e)
        } catch (e: HttpException) {
            return MediatorResult.Error(e)
        }

    }
}

另外新创建了RemoteKeyRemoteKeyDao来管理列表的页数,具体请参考此项目home模块。

2.2.4、LiveData

关于LiveData的使用和原理,可参考【Jetpack篇】LiveData取代EventBus?LiveData的通信原理和粘性事件刨析

还有很多好用的Jetpack组件,将在后续更新。

三、感谢

API:
鸿洋大大提供的 WanAndroid API

第三方开源库:

✔️Retrofit

✔️OkHttp

✔️Gson

✔️Coil

✔️Koin

✔️Arouter

✔️LoadSir

另外还有上面没列举的一些优秀的第三方开源库,感谢开源。

四、License©️

License
Copyright 2021 fuusy

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

项目地址https://github.com/fuusy/wanandroid_jetpack_kt

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容