準備
首先進入安卓架構入門的程式碼倉庫:
Android Architecture Starter Templates:
https://github.com/android/architecture-templates
先看看介紹,簡單分析一下:
- 架構入門的模板
- UI 介面非常簡陋
- Navigation 導航
- 協程和Flow
- Hilt 依賴注入
- Hilt 虛假資料進行UI測試
提供了兩個模板,單模組和多模組,單模組和多模組沒有絕對的誰好誰壞。
- 單模組使用起來簡單快速,但開發維護會隨著專案變大越來越難。
- 多模組會增加額外的負擔,如:不同模組配置Build難以保持一致、模組間的互動需要精細化的設計,但開發維護的難度變化不會太大。
單模組
移除無用程式碼和檔案
先粗略點開app模組的所有程式碼,簡單看看。
build.gradle.kts
在plugins上方出現一個 @Suppress (忽略警告的註解)。點開連結,發現問題在Gradle 8.1+版本已經解決,這個註解可以刪掉。
@Suppress("DSL_SCOPE_VIOLATION") // Remove when fixed https://youtrack.jetbrains.com/issue/KTIJ-19369
在android內部出現一個 packagingOptions 棄用警告,點開發現被packaging替代,二者的引數是一樣的,都是Packaging介面的無參擴充套件函式,所以可以直接替換。
Theme.kt
發現在 SideEffect(每次重組後都會執行) 內的修改頂部狀態列的程式碼標記棄用。
(view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb()
ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme
改為:
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
MyApplication.kt
就只有一個簡單的 @HiltAndroidApp 註解,Hilt 依賴注入會附加到這個Application的生命週期,並提供依賴項。所有使用Hilt的應用都必須有這個註解。
MainActivity.kt
MainActivity上方有個 @AndroidEntryPoint 註解,也是 Hilt 依賴注入的註解。
setContent 使用了 theme 裡的 MyApplicationTheme ,然後一個鋪滿全屏的背景色。content呼叫了MainNavigation。
Navigation.kt
使用了NavHostController和NavHost,但和沒有用一樣,就只有一個MyModelScreen。
MyModelScreen.kt
fun MyModelScreen(modifier: Modifier = Modifier, viewModel: MyModelViewModel = hiltViewModel())
引數1 使用了官方推薦的寫法,modifier可以被傳入。
引數2 把viewModel的預設引數設定為hiltViewModel(),交由Hilt注入。也可以由Activity建立ViewModel然後傳遞過來。
val items by viewModel.uiState.collectAsStateWithLifecycle()
uiState 的型別是StateFlow<MyModelUiState>,也就是隻讀型別的Flow,StateFlow和Compose的State無關!。
collectAsStateWithLifecycle 是compose為協程增加的生命週期擴充套件的函式之一,可以只在Compose的生命週期裡收集協程傳來的資料。需要新增以下依賴:
androidx.lifecycle:lifecycle-runtime-compose
if (items is MyModelUiState.Success) {
MyModelScreen(
items = (items as MyModelUiState.Success).data,
onSave = viewModel::addMyModel,
modifier = modifier
)
}
當item是MyModelUiState.Success型別時,顯示MyModelScreen螢幕。
但是我感覺這裡不應該這樣寫,MyModelUiState密封介面有三個狀態,Loading、Error、Success,應該三種情況都要寫出來,應該改成這樣:
when(items){
MyModelUiState.Loading -> {
//TODO Loading
}
is MyModelUiState.Error -> {
//TODO ERROR
}
is MyModelUiState.Success -> {
MyModelScreen(
items = (items as MyModelUiState.Success).data,
onSave = viewModel::addMyModel,
modifier = modifier
)
}
}
MyModelScreen、DefaultPreview、PortraitPreview 這三個函式就是簡單的繪製和預覽,沒有什麼好說的。
MyModelViewModel.kt
在MyModelViewModel上方出現 @HiltViewModel 註解,使這個ViewModel可以提供給Hilt注入。主建構函式出現 @Inject 註解,注入MyModelRepository到myModelRepository。
sealed interface MyModelUiState {
object Loading : MyModelUiState
data class Error(val throwable: Throwable) : MyModelUiState
data class Success(val data: List<String>) : MyModelUiState
}
介面狀態,分為三種情況:
- Loading 載入中
- Error 載入失敗
- Success 載入成功
fun addMyModel(name: String) {
viewModelScope.launch {
myModelRepository.add(name)
}
}
使用和viewModel生命週期繫結的協程,向myModelRepository新增一個name。
val uiState: StateFlow<MyModelUiState> = myModelRepository
.myModels.map<List<String>, MyModelUiState>(::Success)
.catch { emit(Error(it)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)
我把程式碼拆一下:
val uiState: StateFlow<MyModelUiState> = myModelRepository
.myModels.map<List<String>, MyModelUiState>{
MyModelUiState.Success(it)
}
.catch {
emit(MyModelUiState.Error(it))
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MyModelUiState.Loading)
- myModelRepository .myModels 的型別是 Flow<List<String>>。
- map是flow的擴充函式,把Flow儲存的A型別轉為B型別。把 List<String> 型別作為入參的型別,把MyModelUiState作為出參的型別。MyModelUiState.Success(it) 裡的 it 就是原本的 List<String> 型別的引數。這裡泛型指定 MyModelUiState 的原因是下方的需要catch需要emit MyModelUiState.Error,如果不需要異常狀態可以去掉這裡的顯式指定異常。
- stateIn,繫結viewModel的協程生命週期,訂閱者和共享協程停止時的延遲,Flow在collect時預設引數。
- SharingStarted.WhileSubscribed(5000),在沒有Flow的訂閱者時,會停止collect,延遲設定為5000毫秒,如果5秒內有了新的訂閱者,就不會停止collect。與之對應的是SharingStarted.Lazily,永遠不會停止collect。
MyModelRepository.kt
interface MyModelRepository {
val myModels: Flow<List<String>>
suspend fun add(name: String)
}
- myModels 的型別是Flow<List<String>>,用來在協程中收集資料。
- add 函式前有 suspend 表示需要在協程中執行。
class DefaultMyModelRepository @Inject constructor(
private val myModelDao: MyModelDao
) : MyModelRepository {
override val myModels: Flow<List<String>> =
myModelDao.getMyModels().map { items -> items.map { it.name } }
override suspend fun add(name: String) {
myModelDao.insertMyModel(MyModel(name = name))
}
}
- myModels透過 myModelDao 獲取 Flow<List<MyModel>>型別的物件轉換為Flow<List<String>>型別。
- add 透過 myModelDao 插入一個 MyMode l物件。
DataModule.kt
提供虛假的資料給 androidTest 使用,沒什麼特殊的。
DatabaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
Hilt 單例繫結,注入Application。整個Application只會出現一個例項。
@Provides
fun provideMyModelDao(appDatabase: AppDatabase): MyModelDao {
return appDatabase.myModelDao()
}
@Provides 作用域是整個生命週期,告訴Hilt,這個函式可以提供MyModelDao型別的物件。
這整段程式碼的意思是,在Hilt註解需要注入MyModelDao型別的物件時,透過這個函式獲取。
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext appContext: Context): AppDatabase {
return Room.databaseBuilder(
appContext,
AppDatabase::class.java,
"MyModel"
).build()
}
@Singleton 整個Application的生命週期只會生成一次。
這整段程式碼的意思是建立一個AppDatabase型別的物件,這段程式碼只會執行一次,後續需要AppDatabase型別的物件時會一直使用這個物件。
AppDatabase.kt
@Database(entities = [MyModel::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun myModelDao(): MyModelDao
}
- @Database 註解 和 繼承 RoomDatabase,建立一個資料庫。
- entities = [MyModel::class] 資料庫中有個MyModel型別的表。
- myModelDao() 獲取MyModelDao例項物件。
MyModel.kt
@Entity
data class MyModel(
val name: String
) {
@PrimaryKey(autoGenerate = true)
var uid: Int = 0
}
- @Entity 提供給AppDatabase的資料表。
- name: String 資料表的欄位。
- @PrimaryKey(autoGenerate = true) 宣告這個資料表的主鍵。
多模組
單模組的程式碼已經講的很詳細了,這裡僅僅講一下差異。
app模組
程式碼檔案:
- MyApplication.kt
- MainActivity.kt
- MainNavigation.kt
build.gradle.kts
implementation(project(":core-ui"))
implementation(project(":feature-mymodel"))
core-ui
之前的theme目錄,放一些通用的compose可組合項,必須是通用的。
core-testing
自定義測試Application。
test-app
之前的 androidTest 目錄。
core-data
程式碼檔案:
- DataModule.kt
- MyModelRepository.kt
之前的data目錄。
core-database
之前的database目錄。
feature-mymodel
之前的mymodel目錄,值得注意的是這裡也有androidTest,這裡的測試針對的是當前模組的,並沒有使用FakeMyModelRepository,而是直接給了個list。可見多模組的情況下,官方對於測試也沒有很優雅的解決方案。