Skip to content

💖组件化+Jetpack+Kotlin+MVVM项目实战,涉及Jetpack相关组件,Kotlin相关技术,协程+Retrofit,Paging3+Room等。

License

Notifications You must be signed in to change notification settings

fuusy/component-jetpack-mvvm

Repository files navigation

组件化+Jetpack+Kotlin+MVVM

Jetpack版

Flutter版


ivo.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的时候,运行后你会发现桌面上会出现多个相同的图标,

微信截图_20210522184326.png

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

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

image-20210425102207853.png

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

但是...... 问题又双叒叕来了,删除了中代码,确实可以解决多个图标的问题,但是当该子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

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

四、版本

持续更新

2021.5.20更新

1.Paging3和Room结合;

2.将Glide替换为Coil

2021.5.17更新

1.新增BasePagingAdapter,减少Paging3Adapter冗余代码;

2.删除App Module Fragment的依赖。

2021.5.12/13更新

1.新增启动页,icon;

2.网络请求新增局部状态管理,结合loadSir切换界面,更直观简便;

3.新增Koin

V1.0.0

1.提交WanAndroid第一版,包括首页、个人中心、项目模块

五、License

MIT License

Copyright (c) 2021 fuusy