组件化+Jetpack+Kotlin+MVVM
一、项目简介
该项目主要以组件化+Jetpack+MVVM
为架构,使用Kotlin
语言,集合了最新的Jetpack
组件,如Navigation
、Paging3
、Room
等,另外还加上了依赖注入框架Koin
和图片加载框架Coil
。
网络请求部分使用OkHttp
+Retrofit
,配合Kotlin的协程
,完成了对Retrofit和协程的请求封装
,结合LoadSir
进行状态切换管理,让开发者只用关注自己的业务逻辑,而不要操心界面的切换和通知。
对于具体的网络封装思路,可参考
【Jetpack篇】协程+Retrofit网络请求状态封装实战
【Jetpack篇】协程+Retrofit网络请求状态封装实战(2)
如果此项目对你有帮助和价值,烦请给个star,或者有什么好的建议或意见,也可以发个issues,感谢!
二、项目详情
2.1、组件化搭建项目时暴露出的问题
2.1.1、如何独立运行一个Module?
运行总App时,子Module是属于library
,而独立运行时,子Module是属于application
。那么我们只需要在根目录下gradle.properties
中添加一个标志位来区分一下子Module的状态,例如singleModule = false
,该标志位可以用来表示当前Module是否是独立模块,true
表示处于独立模块,可单独运行,false
则表示是一个library。
如何使用呢?
在每个Module
的build.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的时候,运行后你会发现桌面上会出现多个相同的图标,
其实每个图标都能够独立运行,但是到最后App发布的时候,肯定是只需要一个总入口就可以了。
发生这种情况的原因很简单,因为新建一个Module
,结构相当于一个project,AndroidManifest.xml包括Activity都存在,在AndroidManifest.xml
为Activity设置了action
和category
,当app运行时,也就在桌面上为webview这个模块生成了一个入口。
解决方案很简单,删除上图红色框框中的代码即可。
但是......
问题又双叒叕来了,删除了中代码,确实可以解决多个图标的问题,但是当该子Moudle需要独立运行时,由于缺少<intent-filter>
中的声明,该Module就无法正常运行
。
以下图项目为例:
我们可以在”webview“Module中,新建一个和java同层级的包,取名:manifest,将AndroidManifest.xml复制到该包下,并且将/manifest/AndroidManifest.xml中内容进行删除修改。
只留有一个空壳子,原来的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中BottomNavigationView
与Navigation
进行绑定时也做出了相应的改变。
/**
* 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()
方法。具体使用方法可参考此项目首页文章列表部分。
Room
和Paging3
结合时,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)
}
}
}
另外新创建了RemoteKey
和RemoteKeyDao
来管理列表的页数,具体请参考此项目home模块。
2.2.4、LiveData
关于LiveData
的使用和原理,可参考【Jetpack篇】LiveData取代EventBus?LiveData的通信原理和粘性事件刨析
还有很多好用的Jetpack组件,将在后续更新。
三、感谢
API: 鸿洋大大提供的 WanAndroid API
第三方开源库:
另外还有上面没列举的一些优秀的第三方开源库,感谢开源。
四、版本
持续更新
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
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.