一、項目簡介
該項目主要以組件化+Jetpack+MVVM
為架構(gòu),使用Kotlin
語言,集合了最新的Jetpack
組件,如Navigation
、Paging3
、Room
等,另外還加上了依賴注入框架Koin
和圖片加載框架Coil
。
網(wǎng)絡(luò)請求部分使用OkHttp
+Retrofit
,配合Kotlin的協(xié)程
,完成了對Retrofit和協(xié)程的請求封裝
,結(jié)合LoadSir
進(jìn)行狀態(tài)切換管理,讓開發(fā)者只用關(guān)注自己的業(yè)務(wù)邏輯,而不要操心界面的切換和通知。
對于具體的網(wǎng)絡(luò)封裝思路,可參考【Jetpack篇】協(xié)程+Retrofit網(wǎng)絡(luò)請求狀態(tài)封裝實戰(zhàn)和【Jetpack篇】協(xié)程+Retrofit網(wǎng)絡(luò)請求狀態(tài)封裝實戰(zhàn)(2)
如果此項目對你有幫助和價值,煩請給個star??,或者有什么好的建議或意見,可以發(fā)個issues,感謝!
二、項目詳情
2.1、組件化搭建項目時暴露出的問題
2.1.1、如何獨立運行一個Module?
運行總App時,子Module是屬于library
,而獨立運行時,子Module是屬于application
。那么我們只需要在根目錄下gradle.properties
中添加一個標(biāo)志位來區(qū)分一下子Module的狀態(tài),例如singleModule = false
,該標(biāo)志位可以用來表示當(dāng)前Module是否是獨立模塊,true
表示處于獨立模塊,可單獨運行,false
則表示是一個library。
如何使用呢?
在每個Module
的build.gradle
中加入singleModule
的判斷,以區(qū)分是application
還是library
。如下:
if (!singleModule.toBoolean()) {
apply plugin: 'com.android.library'
} else {
apply plugin: 'com.android.application'
}
......
dependencies {
}
復(fù)制代碼
如果需要獨立運行只需要修改gradle.properties
標(biāo)志位singleModule
的值。
2.1.2、編譯運行后,桌面會出現(xiàn)多個相同圖標(biāo);
當(dāng)新建多個Moudle的時候,運行后你會發(fā)現(xiàn)桌面上會出現(xiàn)多個相同的圖標(biāo),
其實每個圖標(biāo)都能夠獨立運行,但是到最后App發(fā)布的時候,肯定是只需要一個總?cè)肟诰涂梢粤恕?/p>
發(fā)生這種情況的原因很簡單,因為新建一個Module
,結(jié)構(gòu)相當(dāng)于一個project,AndroidManifest.xml包括Activity都存在,在AndroidManifest.xml
為Activity設(shè)置了action
和category
,當(dāng)app運行時,也就在桌面上為webview這個模塊生成了一個入口。
解決方案很簡單,刪除上圖紅色框框中的代碼即可。
但是......
問題又雙叒叕來了,刪除了中代碼,確實可以解決多個圖標(biāo)的問題,但是當(dāng)該子Moudle需要獨立運行時,由于缺少<intent-filter>
中的聲明,該Module就無法正常運行
。
以下圖項目為例:
我們可以在”webview“Module中,新建一個和java同層級的包,取名:manifest,將AndroidManifest.xml復(fù)制到該包下,并且將/manifest/AndroidManifest.xml中內(nèi)容進(jìn)行刪除修改。
只留有一個空殼子,原來的AndroidManifest.xml
則保持不變。同時在webview的build.gradle
中利用sourceSets
進(jìn)行區(qū)分。
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'
}
}
}
}
復(fù)制代碼
通過修改SourceSets
中的屬性,可以指定需要被編譯的源文件,根據(jù)singleModule.toBoolean()
來判斷當(dāng)前Module是屬于application
還是library
,如果是library,則編譯manifest下AndroidManifest.xml,反之則直接編譯主目錄下AndroidManifest.xml。
上述處理后,子Moudule當(dāng)作library時不會出現(xiàn)多個圖標(biāo)的情況,同時也可以獨立運行。
2.1.3、組件間通信
主要借助阿里的路由框架ARouter,具體使用請參考github.com/alibaba/ARo…
2.2、Jetpack組件
2.2.1、Navigation
Navigation是一個管理Fragment切換的組件,支持可視化處理。開發(fā)者也完全不用操心Fragment的切換邏輯?;臼褂谜垍⒖?a target="_blank">官方說明
在使用Navigation
的過程中,會出現(xiàn)點擊back按鍵,界面會重新走了onCreate
生命周期,并且將頁面重構(gòu)。例如Navigation與BottomNavigationView結(jié)合時
,點擊tab,F(xiàn)ragment會重新創(chuàng)建。目前比較好的解決方法是自定義FragmentNavigator
,將內(nèi)部replace替換為show/hide
。
另外,官方對于與BottomNavigationView
結(jié)合時的情況也提供了一種解決方案。 官方提供了一個BottomNavigationView
的擴(kuò)展函數(shù)NavigationExtensions
,
將之前共用一個navigation
分為每個模塊單獨一個navigation
,例如該項目分為首頁
、項目
、我的
三個tab,相應(yīng)的新建了三個navigation:R.navigation.navi_home
, R.navigation.navi_project
, R.navigation.navi_personal
, Activity中BottomNavigationView
與Navigation
進(jìn)行綁定時也做出了相應(yīng)的改變。
/**
* 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
}
復(fù)制代碼
官方這么做的目的在于讓每個模塊單獨管理自己的Fragment棧
,在tab切換時,不會相互影響。
2.2,2、Paging3
Paging是一個分頁組件,主要與Recyclerview結(jié)合分頁加載數(shù)據(jù)。具體使用可參考此項目“每日一問”部分
,如下:
UI層:
class DailyQuestionFragment : BaseFragment<FragmentDailyQuestionBinding>() {
...
private fun loadData() {
lifecycleScope.launchWhenCreated {
mViewModel.dailyQuestionPagingFlow().collectLatest {
dailyPagingAdapter.submitData(it)
}
}
}
...
}
復(fù)制代碼
ViewModel層:
class ArticleViewModel(private val repo: HomeRepo) : BaseViewModel(){
/**
* 請求每日一問數(shù)據(jù)
*/
fun dailyQuestionPagingFlow(): Flow<PagingData<DailyQuestionData>> =
repo.getDailyQuestion().cachedIn(viewModelScope)
}
復(fù)制代碼
Repository層
class HomeRepo(private val service: HomeService, private val db: AppDatabase) : BaseRepository(){
/**
* 請求每日一問
*/
fun getDailyQuestion(): Flow<PagingData<DailyQuestionData>> {
return Pager(config) {
DailyQuestionPagingSource(service)
}.flow
}
}
復(fù)制代碼
PagingSource層:
/**
* @date:2021/5/20
* @author fuusy
* @instruction: 每日一問數(shù)據(jù)源,主要配合Paging3進(jìn)行數(shù)據(jù)請求與顯示
*/
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)
}
}
}
復(fù)制代碼
2.2.3、Room
Room
是一個管理數(shù)據(jù)庫的組件,此項目主要將Paging3與Room相結(jié)合
。2.3小節(jié)主要介紹了Paging3
從網(wǎng)絡(luò)上加載數(shù)據(jù)分頁,而這不同的是,結(jié)合Room
需要RemoteMediator
的協(xié)同處理。
RemoteMediator
的主要作用是:可以使用此信號從網(wǎng)絡(luò)加載更多數(shù)據(jù)并將其存儲在本地數(shù)據(jù)庫中,PagingSource
可以從本地數(shù)據(jù)庫加載這些數(shù)據(jù)并將其提供給界面進(jìn)行顯示。 當(dāng)需要更多數(shù)據(jù)時,Paging 庫從 RemoteMediator
實現(xiàn)調(diào)用load()
方法。具體使用方法可參考此項目首頁文章列表部分。
Room
和Paging3
結(jié)合時,UI層
和ViewModel層
的操作與2.3小節(jié)一致,主要修改在于Repository
層。
Repository層:
class HomeRepo(private val service: HomeService, private val db: AppDatabase) : BaseRepository() {
/**
* 請求首頁文章,
* Room+network進(jìn)行緩存
*/
fun getHomeArticle(articleType: Int): Flow<PagingData<ArticleData>> {
mArticleType = articleType
return Pager(
config = config,
remoteMediator = ArticleRemoteMediator(service, db, 1),
pagingSourceFactory = pagingSourceFactory
).flow
}
}
復(fù)制代碼
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)
}
復(fù)制代碼
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
}
}
}
}
復(fù)制代碼
自定義RemoteMediator:
/**
* @date:2021/5/20
* @author fuusy
* @instruction:RemoteMediator 的主要作用是:在 Pager 耗盡數(shù)據(jù)或現(xiàn)有數(shù)據(jù)失效時,從網(wǎng)絡(luò)加載更多數(shù)據(jù)。
* 可以使用此信號從網(wǎng)絡(luò)加載更多數(shù)據(jù)并將其存儲在本地數(shù)據(jù)庫中,PagingSource 可以從本地數(shù)據(jù)庫加載這些數(shù)據(jù)并將其提供給界面進(jìn)行顯示。
* 當(dāng)需要更多數(shù)據(jù)時,Paging 庫從 RemoteMediator 實現(xiàn)調(diào)用 load() 方法。這是一項掛起功能,因此可以放心地執(zhí)行長時間運行的工作。
* 此功能通常從網(wǎng)絡(luò)源提取新數(shù)據(jù)并將其保存到本地存儲空間。
* 此過程會處理新數(shù)據(jù),但長期存儲在數(shù)據(jù)庫中的數(shù)據(jù)需要進(jìn)行失效處理(例如,當(dāng)用戶手動觸發(fā)刷新時)。
* 這由傳遞到 load() 方法的 LoadType 屬性表示。LoadType 會通知 RemoteMediator 是需要刷新現(xiàn)有數(shù)據(jù),還是提取需要附加或前置到現(xiàn)有列表的更多數(shù)據(jù)。
*/
@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:首次訪問 或者調(diào)用 PagingDataAdapter.refresh() 觸發(fā)
2.LoadType.PREPEND:在當(dāng)前列表頭部添加數(shù)據(jù)的時候時觸發(fā),實際在項目中基本很少會用到直接返回 MediatorResult.Success(endOfPaginationReached = true) ,參數(shù) endOfPaginationReached 表示沒有數(shù)據(jù)了不在加載
3.LoadType.APPEND:加載更多時觸發(fā),這里獲取下一頁的 key, 如果 key 不存在,表示已經(jīng)沒有更多數(shù)據(jù),直接返回 MediatorResult.Success(endOfPaginationReached = true) 不會在進(jìn)行網(wǎng)絡(luò)和數(shù)據(jù)庫的訪問
*/
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
//從網(wǎng)絡(luò)上請求數(shù)據(jù)
val result = api.getHomeArticle(page).data?.datas
result?.forEach {
it.articleType = articleType
}
val endOfPaginationReached = result?.isEmpty()
db.withTransaction {
if (loadType == LoadType.REFRESH) {
//清空數(shù)據(jù)
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)
}
}
}
復(fù)制代碼
另外新創(chuàng)建了RemoteKey
和RemoteKeyDao
來管理列表的頁數(shù),具體請參考此項目home模塊。
2.2.4、LiveData
關(guān)于LiveData
的使用和原理,可參考【Jetpack篇】LiveData取代EventBus?LiveData的通信原理和粘性事件刨析
還有很多好用的Jetpack組件,將在后續(xù)更新。
三、感謝
API: 鴻洋大大提供的 WanAndroid API
第三方開源庫:
??Retrofit
??OkHttp
??Gson
??Coil
??Koin
??Arouter
??LoadSir
另外還有上面沒列舉的一些優(yōu)秀的第三方開源庫,感謝開源。
四、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
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/Android-Alvin/Android-LearningNotes 中已收錄,里面包含不同方向的自學(xué)編程路線、面試題集合/面經(jīng)、及系列技術(shù)文章等,資源持續(xù)更新中...