在之前的文章中提到了很多次 Jetpack 實戰項目 PokemonGo(神奇寶貝),PokemonGo 基於 MVVM 架構和 Repository 設計模式開發的一個小型 App,包含了最新的架構和最新的技術,是一個非常好的學習項目,已經上傳到了 GitHub 歡迎前去查看
https://github.com/hi-dhl/PokemonGo
先來介紹一下 Jetpack 實戰項目 PokemonGo, 包含了以下功能:
PokemonGo 包含的技術:
Gradle Versions Plugin :檢查依賴庫是否存在最新版本Kotlin + Coroutines + Flow :flow 是對 Kotlin 協程的擴展,讓我們可以像運行同步代碼一樣運行異步代碼DataBinding :以聲明方式將可觀察數據綁定到界面上Room :在 SQLite 上提供了一個抽象層,流暢地訪問 SQLite 資料庫ViewModel :以注重生命周期的方式管理界面相關的數據Andriod KTX :編寫更簡潔、慣用的 Kotlin 代碼Retrofit2 & OkHttp3 :用於請求網路數據Coil :基於 Kotlin 開發的首個圖片加載庫material-components-android :模塊化和可定製的材料設計 UI 組件Motionlayout :MotionLayout 是一種布局類型,可幫助您管理應用中的動畫JProgressView :一個小巧靈活可定製的進度條,支持圖形:圓形、圓角矩形、矩形等等如果之前對這些技術沒有接觸過,或者只是聽說,對閱讀本文沒有什麼影響,本文會對這些技術結合著項目 PokemonGo 來分析,為了文章的簡潔性,本文不會細究技術細節,因為每個技術都需要花好幾篇文章才能分析清楚,我會在後續的文章去詳細分析。
如何檢查依賴庫最新版本在之前的文章 再見吧 buildSrc, 擁抱 Composing builds 提升 Android 編譯速度 分析過,到目前為止大概管理 Gradle 依賴提供了 4 種不同方法:
手動管理 :在每個 module 中定義插件依賴庫,每次升級依賴庫時都需要手動更改(不建議使用)使用 ext 的方式管理插件依賴庫 :這是 Google 推薦管理依賴的方法Kotlin + buildSrc:支持自動補全和單擊跳轉,依賴更新時 將重新 構建整個項目Composing builds:支持自動補全和單擊跳轉,依賴更新時 不會重新 構建整個項目新版的 AndroidStudio 只支持 ext 的方式 和 手動方式管理 檢查依賴庫是否存在最新版本,不支持 buildSrc、gradle-wrapper 版本的檢查。
滿足不了 PokemonGo 項目的需求,在 PokemonGo 項目中採用 buildSrc 方式去管理所有依賴庫,因為 PokemonGo 項目採用單模塊結構,而且支持 自動補全 和 單擊跳轉 很方便,所這裡用到了 Gradle Versions Plugin 插件去檢查依賴庫的最新版本,檢查結果如下所示:
The following dependencies have later release versions:
- androidx.swiperefreshlayout:swiperefreshlayout [1.0.0 -> 1.1.0]
https://developer.android.com/jetpack/androidx
- com.squareup.okhttp3:logging-interceptor [3.9.0 -> 4.7.2]
https://square.github.io/okhttp/
- junit:junit [4.12 -> 4.13]
http://junit.org
- org.koin:koin-android [2.1.5 -> 2.1.6]
- org.koin:koin-androidx-viewmodel [2.1.5 -> 2.1.6]
- org.koin:koin-core [2.1.5 -> 2.1.6]
Gradle release-candidate updates:
- Gradle: [6.1.1 -> 6.5.1]會列出所有需要更新的依賴庫的最新版本,並且 Gradle Versions Plugin 比 AndroidStudio 所支持的更加全面:
支持 buildSrc 方式管理依賴庫最新版本檢查那麼如何使用呢?只需要三步
1.將 PokemonGo 項目根目錄 checkVersions.gradle 文件拷貝到你的項目根目錄下面2.在項目的根目錄 build.gradle 文件夾內添加以下代碼apply from: './checkVersions.gradle'
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath "com.github.ben-manes:gradle-versions-plugin:0.28.0"
}
}./gradlew dependencyUpdates會在當前目錄下生成 build/dependencyUpdates/report.txt 文件。
MVVM 架構Jetpack 實戰項目 PokemonGo 基於 MVVM 架構和 Repository 設計模式,如今幾乎所有的 Android 開發者至少都聽過 MVVM 架構,在谷歌 Android 團隊宣布了 Jetpack 的視圖模型之後,它已經成為了現代 Android 開發模式最流行的架構之一,如下圖所示:
MVVM 有助於將應用程式的業務邏輯與 UI 完全分開。如果業務邏輯與 UI 邏輯之間的聯繫非常緊密,那麼維護將很困難,由於很難重用業務邏輯,因此編寫單元測試代碼非常困難,一堆重複的代碼和複雜的邏輯。
Jetpack 的視圖模型的 MVVM 架構由 View + DataBinding + ViewModel + Model 組成。
DataBindingDataBinding(數據綁定)實際上是 XML 布局中的另一個視圖結構層次,視圖 (XML) 通過數據綁定層不斷地與 ViewModel 交互。
我們來看一個例子,首頁上有個 RecyclerView 用來展示神奇寶貝數據(名字、圖片、點擊事件等等),每一個 item 對應一個 ViewHolder,來看一下 ViewHolder 的實現。
class PokemonViewModel(view: View) : DataBindingViewHolder<PokemonListModel>(view) {
private val mBinding: RecycleItemPokemonBinding by viewHolderBinding(view)
override fun bindData(data: PokemonListModel, position: Int) {
mBinding.apply {
pokemon = data
executePendingBindings()
}
}
}正如你所看到的,由於使用了數據綁定,ViewHolder 裡面的代碼變的非常簡單,可能這個例子不夠明顯,我們來看一個勁爆的,點擊首頁每一個 item 會跳轉到詳情頁面,詳情頁面如下圖所示:
詳情頁面(DetailActivity)展示了神奇寶貝的詳細數據,先查詢資料庫,如果沒有找到,讀取網路數據然後保存到資料庫,由於使用了數據綁定,代碼變得非常簡單,如下所示:
class DetailActivity : DataBindingAppCompatActivity() {
private val mBindingActivity: ActivityDetailsBinding by binding(R.layout.activity_details)
private val mViewModel: DetailViewModel by viewModels()
lateinit var mPokemonModel: PokemonListModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBindingActivity.apply {
mPokemonModel = requireNotNull(intent.getParcelableExtra(KEY_LIST_MODEL))
pokemonListModel = mPokemonModel
lifecycleOwner = this@DetailActivity
viewModel = mViewModel.apply {
fectchPokemonInfo(mPokemonModel.name)
.observe(this@DetailActivity, Observer {})
}
}
}
}正如你所見 DetailActivity 代碼變得非常簡單,如果以後我們想要改變網絡的 URL、Model、獲取或保存數據的方式等等,我們不需要改變 DetailActivity 中的任何代碼。
ViewModelViewModel 是 MVVM 架構中非常重要的設計,它在 activities 或 fragments 和業務邏輯中起到了非常重要的作用,它不依賴於 UI 組件,使得單元測試更加容易,ViewModel 以生命周期的方式管理界面相關的數據,直到 Activity 被銷毀。
LiveData 與 ViewModel 具有很好的協同作用,LiveData 持有從數據源獲取到的數據,並且它可以被 DataBinding 組件觀察,當 Activity 被銷毀時,它將被取消訂閱。
而詳情頁面(DetailActivity) 代碼之所以能這麼簡單得益於 ViewModel、LiveData、DataBinding 協同工作, 我們來看一下 ViewModel 代碼。
class DetailViewModel @ViewModelInject constructor(
val polemonRepository: Repository
) : ViewModel() {
private val _pokemon = MutableLiveData<PokemonInfoModel>()
val pokemon: LiveData<PokemonInfoModel> = _pokemon
@OptIn(ExperimentalCoroutinesApi::class)
fun fectchPokemonInfo(name: String) = liveData<PokemonInfoModel> {
polemonRepository.featchPokemonInfo(name)
.collectLatest {
_pokemon.postValue(it)
emit(it)
}
..
// 省略部分代碼,
}
}activity_details.xml 代碼
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.hi.dhl.pokemon.ui.detail.DetailViewModel" />
</data>
.
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/weight"
android:text="@{viewModel.pokemon.getWeightString}"/>
.
</layout>這是獲取神奇寶貝的詳細信息,通過 DataBinding 以聲明方式將數據(神奇寶貝的體重)綁定到界面上,更多使用參考項目中的代碼。
RepositoryRepository 設計模式是最流行、應用最廣泛的設計模式之一,在 Repository 層獲取網絡數據,並將數據存儲到資料庫中,在這一層中有兩個非常重要的成員 Paging3 庫中的 RemoteMediator 和 Data Mappers。
RemoteMediatorRemoteMediator 是 Paging3 當中一個非常重要的成員,用於實現 資料庫 和 網絡 訪問,所以這裡是對之前的文章一個補充。
RemoteMediator 很重要,需要單獨花一篇文章去分析,為了節省篇幅,在這裡不會詳細的去分析它,如果對 RemoteMediator 不太理解沒有關係,我會在後續的文章裡面詳細的分析它。
項目中網絡訪問用的是 Retrofit2 & OkHttp3 用來請求網絡數據,使用 Room 作為資料庫存儲,將獲得的數據保存到資料庫中,Room 在 SQLite 上提供了一個抽象層,流暢地訪問 SQLite 資料庫,同時擁有了 SQLite 全部功能,在編譯的時候進行錯誤檢查。
@OptIn(ExperimentalPagingApi::class)
class PokemonRemoteMediator(
val api: PokemonService,
val db: AppDataBase
) : RemoteMediator<Int, PokemonEntity>() {
val mPageKey = 0
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, PokemonEntity>
): MediatorResult {
try {
.
val pageKey = when (loadType) {
// 首次訪問 或者調用 PagingDataAdapter.refresh()
LoadType.REFRESH -> null
// 在當前加載的數據集的開頭加載數據時
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
// 在當前數據集末尾添加數據
LoadType.APPEND -> {
.
if (remoteKey == null || remoteKey.nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = true)
}
remoteKey.nextKey
}
}
.
// 使用 Retrofit2 獲取網絡數據
val page = pageKey ?: 0
val result = api.fetchPokemonList(
state.config.pageSize,
page * state.config.pageSize
).results
..
db.withTransaction {
if (loadType == LoadType.REFRESH) { // 當首次加載,或者下拉刷新的時候,清空當前數據 }
.
// 存儲獲取到的數據
remoteKeysDao.insertAll(entity)
pokemonDao.insertPokemon(item)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (e: IOException) {
return MediatorResult.Error(e)
} catch (e: HttpException) {
return MediatorResult.Error(e)
}
}
}注意:使用了 @OptIn(ExperimentalPagingApi::class) 需要在 App 模塊 build.gradle 文件內添加以下代碼。
android {
kotlinOptions {
freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
}
}在 RemoteMediator 的實現類 PokemonRemoteMediator 中的核心部分是關於參數 LoadType 的判斷。
LoadType.REFRESH:首次訪問 或者調用 PagingDataAdapter.refresh() 觸發,這裡不需要做任何操作,返回 null 就可以LoadType.PREPEND:在當前列表頭部添加數據的時候時觸發,實際在項目中基本很少會用到直接返回 MediatorResult.Success(endOfPaginationReached = true) ,參數 endOfPaginationReached 表示沒有數據了不在加載LoadType.APPEND:下拉加載更多時觸發,這裡獲取下一頁的 key, 如果 key 不存在,表示已經沒有更多數據,直接返回 MediatorResult.Success(endOfPaginationReached = true) 不會在進行網絡和資料庫的訪問接下來的邏輯和之前請求網絡數據的邏輯沒有什麼區別了,使用 Retrofit2 獲取網絡數據,然後使用 Room 將數據保存到資料庫中。
接下來聊一下 Repository 中另外一個重要的成員 Data Mapper,在項目中起到了非常的重要,在一個快速開發的項目中,為了越快完成第一個版本交付,下意識的將數據源和 UI 綁定到一起,當業務逐漸增多,數據源變化了,上層也要一起變化,導致後期的重構工作量很大,核心的原因耦合性太強了。
Data Mapper(個人建議)Data Mapper 的意識非常重要,在項目中起到了非常的重要,使用 Data Mapper 分離數據源的 Model 和 頁面顯示的 Model,不要因為數據源的增加、修改或者刪除,導致上層頁面也要跟著一起修改,換句話說使用 Data Mapper 做一個中間轉換,如下圖所示,來源於網絡:
使用 Data Mapper(數據映射)優點如下:
糟糕的後端實現不會影響上層的業務 ( 想像一下,如果你被迫執行2個網絡請求,因為後端不能在一個請求中提供你需要的所有信息,你會讓這個問題影響你的整個代碼嗎? )Data Mapper 便於做單元測試,確保不會因為數據源的變化,而影響上層的業務如果在一個大型項目中直接使用 Data Mapper 會有適得其反的效果,所以需要結合設計模式來完善,這不在本文討論範圍之內,其實在這裡我想表達是,不要因為快速實現某個功能,下意識的將數據源的 model 和 UI 綁定在一起。
Data Mappe 實現方式有很多種,可以手動實現,也可以通過引入第三方框架,其中有名框架 modelmapper ,在 PokemonGo 項目中是手動實現的。
Kotlin Flow停止使用 RxJava,嘗試一下 Flow,不僅簡單而且功能很強大,Retrofit2 和 Room 也都提供了對應的支持。
Flow 庫是在 Kotlin Coroutines 1.3.2 發布之後新增的庫,也叫做異步流,類似 RxJava 的 Observable,在 PokemonGo 項目中也用到了 Flow。
override suspend fun featchPokemonInfo(name: String): Flow<PokemonInfoModel> {
return flow {
val pokemonDao = db.pokemonInfoDao()
var infoModel = pokemonDao.getPokemon(name)
// 查詢資料庫是否存在,如果不存在請求網絡
if (infoModel == null) {
// 網絡請求
val netWorkPokemonInfo = api.fetchPokemonInfo(name)
.
pokemonDao.insertPokemon(infoModel) // 插入更新資料庫
}
val model = mapper2InfoModel.map(infoModel) // 數據轉換
emit(model)
}.flowOn(Dispatchers.IO)
}在這裡做了三件事:
將數據源的 Model 轉換為頁面顯示的 Model依賴注入Hilt、Dagger、Koin 等等都是依賴注入庫,使用依賴注入庫有以下優點:
依賴注入庫會自動釋放不再使用的對象,減少資源的過度使用。在配置 scopes 範圍內,可重用依賴項和創建的實例,提高代碼的可重用性,減少了很多模板代碼。在 PokemonGo 項目中使用的是 Hilt,Hilt 是在 Dagger 基礎上進行開發的,減少了在項目中進行手動依賴,Hilt 集成了 Jetpack 庫和 Android 框架類,並刪除了大部分模板代碼,讓開發者只需要關注如何進行綁定,同時 Hilt 也繼承了 Dagger 優點,編譯時正確性、運行時性能、並且得到了 Android Studio 的支持,來看一下 Hilt 與 Room 在一起使用的例子。
@Module
@InstallIn(ApplicationComponent::class)
object RoomModule {
/**
* @Provides 常用於被 @Module 註解標記類的內部的方法,並提供依賴項對象。
* @Singleton 提供單例
*/
@Provides
@Singleton
fun provideAppDataBase(application: Application): AppDataBase {
return Room
.databaseBuilder(application, AppDataBase::class.java, "dhl.db")
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
.build()
}
@Singleton
@Provides
fun provideTasksRepository(
db: AppDataBase
): Repository {
return PokemonFactory.makePokemonRepository(db)
}
}這裡需要用到 @Module 註解,使用 @Module 註解的普通類,在其內部提供 Room 的實例,更多使用可以查看 PokemonGo 項目。
小巧靈活的進度條神奇寶貝詳情頁的進度條使用的是 JProgressView :一個小巧靈活可定製的進度條,支持圖形:圓形、圓角矩形、矩形等等,效果如下圖所示:
https://github.com/hi-dhl/JProgressView起源於當時想用一個現成的庫,但是在網上找了很多,沒有一個合適自己的,要不大而全,要不作者好久沒更新了,要不不兼容 DataBinding,於是乎就自己封裝了一個小巧靈活的進度條,項目長期維護並持續更新,如果有更好的建議歡迎告知我,JProgressView 使用非常的簡單,根據自己的需求去配置即可。
<com.hi.dhl.jprogressview.JProgressView
android:layout_width="match_parent"
android:layout_height="18dp"
android:layout_below="@+id/exp"
android:translationZ="100dp"
app:maxProgressValue="@{viewModel.pokemon.maxExp}"
app:progressValue="@{viewModel.pokemon.exp}"
app:progress_animate_duration="@integer/progress_animate_duration"
app:progress_color="@color/color_progress_4"
app:progress_color_background="@color/color_progress_bg"
app:progress_paint_bg_width="@dimen/circle_stroke_width"
app:progress_paint_value_width="@dimen/circle_stroke_width"
app:progress_text_color="@android:color/black"
app:progress_text_size="@dimen/text_size_12sp"
app:progress_type="@integer/porgress_tpye_round_rect" />
名稱值類型默認值備註progress_typeinteger圓形:1矩形:0;矩形:0;矩形:0progress_animate_durationinteger2000動畫運行時間progress_colorcolorColor.GRAY當前進度顏色progress_color_backgroundcolorColor.GRAY進度條背景顏色progress_paint_bg_widthdimen10進度條背景畫筆的寬度progress_paint_value_widthdimen10當前進度畫筆的寬度progress_text_colorcolorColor.BLUE進度條上的文字的顏色progress_text_sizedimensp2Px(20f)進度條上的文字的大小progress_text_visibleboolean默認不顯示:false是否顯示文字progress_valueinteger0當前進度progress_value_maxinteger100當前進度條的最大值進度條 JProgressView 已經上傳到倉庫,歡迎前去查看
https://github.com/hi-dhl/JProgressView全文到這裡就結束了,為了節省篇幅,更多技術細節會在後續的系列文章中分析。
PokemonGo 倉庫地址:
https://github.com/hi-dhl/PokemonGo
推薦閱讀:
最後推薦我一直在更新維護的項目和網站:
最新的 AndroidX Jetpack 相關組件的實戰項目 以及 原理分析的文章
https://github.com/hi-dhl/AndroidX-Jetpack-PracticeLeetCode / 劍指 offer / 國內外大廠面試題 / 多線程 題解,語言 Java 和 kotlin,包含多種解法、解題思路、時間複雜度、空間複雜度分析
劍指 offer:https://offer.hi-dhl.com
LeetCode:https://leetcode.hi-dhl.com最新 Android 10 源碼分析系列文章
https://github.com/hi-dhl/Android10-Source-Analysis一系列國外的技術文章,每篇文章都會有譯者思考部分,對原文的更加深入的分析
https://github.com/hi-dhl/Technical-Article-Translation「為網際網路人而設計,國內國外名站導航」涵括新聞、體育、生活、娛樂、設計、產品、運營、前端開發、Android 開發等等網址
https://site.51git.cn