【开源篇】组件化+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

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

但是...... 问题又双叒叕来了,删除了中代码,确实可以解决多个图标的问题,但是当该子Moudle需要独立运行时,由于缺少中的声明,该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() {
...

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

ViewModel层:

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

}

Repository层

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

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

PagingSource层:

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

    PagingSource() {
    override fun getRefreshKey(state: PagingState): Int? = null

    override suspend fun load(params: LoadParams): LoadResult {
        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> {
        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)

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

    @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() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState
    ): 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

你可能感兴趣的:(【开源篇】组件化+Jetpack+MVVM项目实战,涉及协程+Retrofit,Paging3+Room等)