android-expert by oimiragieo/agent-studio
npx skills add https://github.com/oimiragieo/agent-studio --skill android-expertCompose 中的状态向下流动,事件向上流动(单向数据流)。
状态提升模式:
// 无状态可组合项 — 接收状态和回调
@Composable
fun LoginForm(
email: String,
password: String,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSubmit: () -> Unit,
) {
Column {
TextField(value = email, onValueChange = onEmailChange, label = { Text("Email") })
TextField(value = password, onValueChange = onPasswordChange, label = { Text("Password") })
Button(onClick = onSubmit) { Text("Log in") }
}
}
// 有状态调用者 — 拥有状态并将其向下传递
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LoginForm(
email = state.email,
password = state.password,
onEmailChange = viewModel::onEmailChanged,
onPasswordChange = viewModel::onPasswordChanged,
onSubmit = viewModel::onSubmit,
)
}
remember 与 rememberSaveable:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
remember:仅能在重组中存活。用于临时 UI 状态。
rememberSaveable:能在重组和进程终止中存活(保存到 Bundle)。用于用户可见的状态(滚动位置、表单输入)。
// remember — 在配置变更 / 进程终止时丢失 var expanded by remember { mutableStateOf(false) }
// rememberSaveable — 在配置变更和进程终止中存活 var selectedTab by rememberSaveable { mutableIntStateOf(0) }
derivedStateOf: 当派生状态依赖于其他状态对象并且您希望避免不必要的重组时使用。
val isSubmitEnabled by remember {
derivedStateOf { email.isNotBlank() && password.length >= 8 }
}
使用结构化的副作用 API — 切勿在组合中启动协程或执行副作用。
| API | 何时使用 |
|---|---|
LaunchedEffect(key) | 启动与键绑定的协程;键更改时取消/重新启动 |
rememberCoroutineScope() | 获取用于事件驱动协程的作用域(按钮点击等) |
SideEffect | 在每次成功组合后运行非挂起副作用 |
DisposableEffect(key) | 带有清理功能的副作用(注册/注销回调) |
// 登录成功后导航到目的地
LaunchedEffect(uiState.isLoggedIn) {
if (uiState.isLoggedIn) navController.navigate(Route.Home)
}
// 用于点击驱动协程的作用域
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { /* ... */ } }) { Text("Save") }
// 注册/注销回调
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> /* ... */ }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
重组是 Compose 中主要的性能关注点。最小化其作用范围。
// 避免:不稳定的 lambda 捕获了整个父作用域
@Composable
fun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) {
LazyColumn {
items(items, key = { it.id }) { item ->
// 每次 ItemList 重组时都会创建新的 lambda 实例
ItemRow(item = item, onClick = { onItemClick(item) })
}
}
}
// 推荐:稳定的键 + remember 以避免不必要的子项重组
@Composable
fun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) {
val stableOnClick = rememberUpdatedState(onItemClick)
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item = item, onClick = { stableOnClick.value(item) })
}
}
}
稳定类型的规则:
基本类型和 String 始终是稳定的。
仅包含稳定字段的数据类,如果使用 @Stable 或 @Immutable 注解,则是稳定的。
标准库中的 List、Map、Set 是不稳定的 — 推荐使用 kotlinx.collections.immutable。
@Immutable data class UserProfile(val name: String, val avatarUrl: String)
修饰符顺序很重要: 按逻辑顺序应用修饰符(尺寸 → 内边距 → 背景 → 可点击)。
// 正确:内边距在可点击区域内
Modifier
.size(48.dp)
.clip(CircleShape)
.clickable(onClick = onClick)
.padding(8.dp)
// 自定义布局示例:徽章叠加层
@Composable
fun BadgeBox(badgeCount: Int, content: @Composable () -> Unit) {
Layout(content = {
content()
if (badgeCount > 0) {
Box(Modifier.background(Color.Red, CircleShape)) {
Text("$badgeCount", color = Color.White, fontSize = 10.sp)
}
}
}) { measurables, constraints ->
val contentPlaceable = measurables[0].measure(constraints)
val badgePlaceable = measurables.getOrNull(1)?.measure(Constraints())
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.placeRelative(0, 0)
badgePlaceable?.placeRelative(
contentPlaceable.width - badgePlaceable.width / 2,
-badgePlaceable.height / 2
)
}
}
}
使用 CompositionLocal 在组合树中传播环境数据,而无需通过每个可组合项显式传递。
// 定义
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
// 在高层级提供
CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) {
MyAppContent()
}
// 在下方任意位置消费
val snackbarHostState = LocalSnackbarHostState.current
何时使用: 用户偏好设置(主题、区域设置)、共享服务(分析、导航)。何时避免: 频繁更改的数据或应显式传递的数据。
// 简单的动画可见性
AnimatedVisibility(visible = showDetails) {
DetailsPanel()
}
// 动画值
val alpha by animateFloatAsState(
targetValue = if (isEnabled) 1f else 0.4f,
animationSpec = tween(durationMillis = 300),
label = "alpha",
)
// 共享元素过渡(Compose 1.7+)
SharedTransitionLayout {
AnimatedContent(targetState = selectedItem) { item ->
if (item == null) {
ListScreen(
onItemClick = { selectedItem = it },
animatedVisibilityScope = this,
sharedTransitionScope = this@SharedTransitionLayout,
)
} else {
DetailScreen(
item = item,
animatedVisibilityScope = this,
sharedTransitionScope = this@SharedTransitionLayout,
)
}
}
}
// ViewModel:使用 viewModelScope(在 VM 清除时自动取消)
class OrderViewModel @Inject constructor(
private val orderRepository: OrderRepository,
) : ViewModel() {
fun placeOrder(order: Order) {
viewModelScope.launch {
try {
orderRepository.placeOrder(order)
} catch (e: HttpException) {
// 处理错误
}
}
}
}
// Repository:返回 suspend fun 或 Flow,切勿在内部启动
class OrderRepositoryImpl @Inject constructor(
private val api: OrderApi,
private val dao: OrderDao,
) : OrderRepository {
override suspend fun placeOrder(order: Order) {
api.placeOrder(order.toRequest())
dao.insert(order.toEntity())
}
}
调度器指南:
Dispatchers.Main:UI 交互、状态更新
Dispatchers.IO:网络调用、文件/数据库 I/O
Dispatchers.Default:CPU 密集型计算
// withContext 为代码块切换调度器 suspend fun loadImage(url: String): Bitmap = withContext(Dispatchers.IO) { URL(url).readBytes().let { BitmapFactory.decodeByteArray(it, 0, it.size) } }
使用 Flow 处理响应式流。在 ViewModel 中优先使用 StateFlow/SharedFlow。
// Repository:暴露冷 Flow
fun observeOrders(): Flow<List<Order>> = dao.observeAll().map { entities ->
entities.map { it.toModel() }
}
// ViewModel:转换为 StateFlow 供 UI 使用
class OrderListViewModel @Inject constructor(repo: OrderRepository) : ViewModel() {
val orders: StateFlow<List<Order>> = repo.observeOrders()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
}
// Compose:通过生命周期感知安全地收集
val orders by viewModel.orders.collectAsStateWithLifecycle()
需要了解的 Flow 操作符:
flow
.filter { it.isActive }
.map { it.toUiModel() }
.debounce(300) // 搜索输入防抖
.distinctUntilChanged()
.catch { e -> emit(emptyList()) } // 内联处理错误
.flowOn(Dispatchers.IO) // 在 IO 调度器上运行上游
用于一次性事件的 SharedFlow:
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
// 从 ViewModel 发射
fun onSubmit() { viewModelScope.launch { _events.emit(UiEvent.NavigateToHome) } }
// 在可组合项中收集
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.NavigateToHome -> navController.navigate(Route.Home)
is UiEvent.ShowError -> snackbar.showSnackbar(event.message)
}
}
}
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val getProductUseCase: GetProductUseCase,
) : ViewModel() {
private val productId: String = checkNotNull(savedStateHandle["productId"])
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
init { loadProduct() }
private fun loadProduct() {
viewModelScope.launch {
_uiState.value = try {
val product = getProductUseCase(productId)
ProductDetailUiState.Success(product)
} catch (e: Exception) {
ProductDetailUiState.Error(e.message ?: "未知错误")
}
}
}
}
sealed interface ProductDetailUiState {
data object Loading : ProductDetailUiState
data class Success(val product: Product) : ProductDetailUiState
data class Error(val message: String) : ProductDetailUiState
}
@Entity(tableName = "orders")
data class OrderEntity(
@PrimaryKey val id: String,
val customerId: String,
val totalCents: Long,
val status: String,
val createdAt: Long,
)
@Dao
interface OrderDao {
@Query("SELECT * FROM orders ORDER BY createdAt DESC")
fun observeAll(): Flow<List<OrderEntity>>
@Query("SELECT * FROM orders WHERE id = :id")
suspend fun getById(id: String): OrderEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(order: OrderEntity)
@Delete
suspend fun delete(order: OrderEntity)
}
@Database(entities = [OrderEntity::class], version = 2)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun orderDao(): OrderDao
}
迁移示例:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE orders ADD COLUMN notes TEXT NOT NULL DEFAULT ''")
}
}
用于可延迟、保证执行的的后台工作。
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val syncRepository: SyncRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
syncRepository.sync()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
@AssistedFactory
interface Factory : ChildWorkerFactory<SyncWorker>
}
// 安排定期同步
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest,
)
@HiltAndroidApp
class MyApplication : Application()
// Activity/Fragment
@AndroidEntryPoint
class MainActivity : ComponentActivity() { /* ... */ }
// 网络模块
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideOrderApi(retrofit: Retrofit): OrderApi = retrofit.create(OrderApi::class.java)
}
// Repository 绑定
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindOrderRepository(impl: OrderRepositoryImpl): OrderRepository
}
| 作用域 | 组件 | 生命周期 |
|---|---|---|
@Singleton | SingletonComponent | 应用生命周期 |
@ActivityRetainedScoped | ActivityRetainedComponent | ViewModel 生命周期 |
@ActivityScoped | ActivityComponent | Activity 生命周期 |
@ViewModelScoped | ViewModelComponent | ViewModel 生命周期 |
@FragmentScoped | FragmentComponent | Fragment 生命周期 |
@HiltWorker
class UploadWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val uploadService: UploadService,
) : CoroutineWorker(context, params) { /* ... */ }
// 将目的地定义为可序列化的对象/类
@Serializable object HomeRoute
@Serializable object ProfileRoute
@Serializable data class ProductDetailRoute(val productId: String)
// 构建导航图
@Composable
fun AppNavGraph(navController: NavHostController) {
NavHost(navController, startDestination = HomeRoute) {
composable<HomeRoute> {
HomeScreen(onProductClick = { id ->
navController.navigate(ProductDetailRoute(id))
})
}
composable<ProductDetailRoute> { backStackEntry ->
val args = backStackEntry.toRoute<ProductDetailRoute>()
ProductDetailScreen(productId = args.productId)
}
composable<ProfileRoute> { ProfileScreen() }
}
}
composable<ProductDetailRoute>(
deepLinks = listOf(
navDeepLink<ProductDetailRoute>(basePath = "https://example.com/product")
)
) { /* ... */ }
在 AndroidManifest.xml 中声明:
<activity android:name=".MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" />
</intent-filter>
</activity>
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentBackStack by navController.currentBackStackEntryAsState()
val currentDestination = currentBackStack?.destination
Scaffold(
bottomBar = {
NavigationBar {
TopLevelDestination.entries.forEach { dest ->
NavigationBarItem(
icon = { Icon(dest.icon, contentDescription = dest.label) },
label = { Text(dest.label) },
selected = currentDestination?.hasRoute(dest.route::class) == true,
onClick = {
navController.navigate(dest.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
)
}
}
}
) { padding ->
AppNavGraph(navController = navController, modifier = Modifier.padding(padding))
}
}
@ExtendWith(MockKExtension::class)
class GetProductUseCaseTest {
@MockK lateinit var repository: ProductRepository
private lateinit var useCase: GetProductUseCase
@BeforeEach
fun setUp() {
useCase = GetProductUseCase(repository)
}
@Test
fun `当仓库成功时返回产品`() = runTest {
val product = Product(id = "1", name = "Widget", priceCents = 999)
coEvery { repository.getProduct("1") } returns product
val result = useCase("1")
assertThat(result).isEqualTo(product)
}
@Test
fun `当产品未找到时抛出异常`() = runTest {
coEvery { repository.getProduct("missing") } throws NotFoundException("missing")
assertThrows<NotFoundException> { useCase("missing") }
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class ProductDetailViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
private val repository = mockk<ProductRepository>()
private lateinit var viewModel: ProductDetailViewModel
@Before
fun setUp() {
viewModel = ProductDetailViewModel(
savedStateHandle = SavedStateHandle(mapOf("productId" to "abc")),
getProductUseCase = GetProductUseCase(repository),
)
}
@Test
fun `uiState 初始为 Loading 然后变为 Success`() = runTest {
val product = Product("abc", "Gizmo", 1299)
coEvery { repository.getProduct("abc") } returns product
val states = mutableListOf<ProductDetailUiState>()
val job = launch { viewModel.uiState.toList(states) }
advanceUntilIdle()
job.cancel()
assertThat(states).contains(ProductDetailUiState.Loading)
assertThat(states.last()).isEqualTo(ProductDetailUiState.Success(product))
}
}
class MainDispatcherRule(
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
}
}
class LoginScreenTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun `当字段为空时提交按钮被禁用`() {
composeTestRule.setContent {
LoginScreen(onLoginSuccess = {})
}
composeTestRule
.onNodeWithText("Log in")
.assertIsNotEnabled()
}
@Test
fun `在无效凭据时显示错误消息`() {
composeTestRule.setContent {
LoginScreen(onLoginSuccess = {})
}
composeTestRule.onNodeWithText("Email").performTextInput("bad@example.com")
composeTestRule.onNodeWithText("Password").performTextInput("wrongpass")
composeTestRule.onNodeWithText("Log in").performClick()
composeTestRule
.onNodeWithText("Invalid credentials")
.assertIsDisplayed()
}
}
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun 导航到详情屏幕() {
onView(withId(R.id.product_list))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click()))
onView(withId(R.id.product_title)).check(matches(isDisplayed()))
}
}
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NotificationHelperTest {
@Test
fun `使用正确的通道创建通知`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val helper = NotificationHelper(context)
helper.showOrderNotification(orderId = "42", message = "Your order shipped!")
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
assertThat(nm.activeNotifications).hasSize(1)
}
}
基线配置文件在应用安装期间预编译热路径,减少 JIT 开销。
// app/src/main/baseline-prof.txt (由 Macrobenchmark 自动生成)
// 或者使用 Baseline Profile Gradle Plugin:
// build.gradle.kts (app)
plugins {
id("androidx.baselineprofile")
}
// 生成:./gradlew :app:generateBaselineProfile
用于生成的宏基准测试:
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "com.example.myapp") {
pressHome()
startActivityAndWait()
// 与关键用户旅程交互
device.findObject(By.text("Products")).click()
device.waitForIdle()
}
}
// 添加自定义跟踪段
trace("MyExpensiveOperation") {
performExpensiveWork()
}
// Compose 编译器指标 — 添加到 build.gradle.kts
tasks.withType<KotlinCompile>().configureEach {
compilerOptions.freeCompilerArgs.addAll(
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${layout.buildDirectory.get()}/compose_metrics"
)
}
使用 Android Studio Memory Profiler 捕获堆转储。
查找静态字段中的 Bitmap 泄漏、Context 泄漏以及未关闭的 Cursor 对象。
在调试版本中使用 LeakCanary 进行自动泄漏检测。
// 避免 Context 泄漏:对长生命周期对象使用 applicationContext class ImageCache @Inject constructor( @ApplicationContext private val context: Context // 安全:应用作用域 ) { /* ... */ }
LazyColumn {
items(
items = itemList,
key = { item -> item.id }, // 稳定的键可防止不必要的重组
contentType = { item -> item.type }, // 通过类型启用项目回收
) { item ->
ItemRow(item = item)
}
}
// 定义配色方案
val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color(0xFFFFFFFF),
secondary = Color(0xFF625B71),
// ... 其他令牌
)
// 应用主题
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content,
)
}
@Composable
fun MyAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val context = LocalContext.current
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
// 顶部应用栏
TopAppBar(
title = { Text("Orders") },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
},
actions = {
IconButton(onClick = onSearch) { Icon(Icons.Default.Search, "Search") }
},
)
// 卡片
ElevatedCard(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Column(Modifier.padding(16.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium)
}
}
// 浮动操作按钮
FloatingActionButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
MVI 是 Compose 应用的推荐模式。状态单向流动;意图描述用户操作。
// 意图(用户操作)
sealed interface ProductListIntent {
data object LoadProducts : ProductListIntent
data class SearchQueryChanged(val query: String) : ProductListIntent
data class ProductClicked(val id: String) : ProductListIntent
}
// UI 状态
data class ProductListUiState(
val isLoading: Boolean = false,
val products: List<Product> = emptyList(),
val error: String? = null,
val searchQuery: String = "",
)
// 一次性效果
sealed interface ProductListEffect {
data class NavigateToDetail(val productId: String) : ProductListEffect
}
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val getProductsUseCase: GetProductsUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductListUiState())
val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
private val _effect = MutableSharedFlow<ProductListEffect>()
val effect: SharedFlow<ProductListEffect> = _effect.asSharedFlow()
fun handleIntent(intent: ProductListIntent) {
when (intent) {
is ProductListIntent.LoadProducts -> loadProducts()
is ProductListIntent.SearchQueryChanged -> updateSearch(intent.query)
is ProductListIntent.ProductClicked -> {
viewModelScope.launch { _effect.emit(ProductListEffect.NavigateToDetail(intent.id)) }
}
}
}
private fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
_uiState.update {
try {
it.copy(isLoading = false, products = getProductsUseCase())
} catch (e: Exception) {
it.copy(isLoading = false, error = e.message)
}
}
}
}
}
presentation/
ui/ — 可组合项、屏幕
viewmodel/ — ViewModel、UI 状态、意图
domain/
model/ — 领域实体(纯 Kotlin,无 Android 依赖)
repository/ — Repository 接口
usecase/ — 业务逻辑(每个文件一个用例)
data/
repository/ — Repository 实现
remote/ — API 服务接口、DTO、映射器
local/ — Room 实体、DAO、映射器
di/ — Hilt 模块
用例示例:
class GetFilteredProductsUseCase @Inject constructor(
private val productRepository: ProductRepository,
) {
suspend operator fun invoke(query: String): List<Product> =
productRepository.getProducts()
.filter { it.name.contains(query, ignoreCase = true) }
.sortedBy { it.name }
}
// build.gradle.kts (app)
android {
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 26
targetSdk = 35
versionCode = 10
versionName = "1.2.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
}
}
bundle {
language { enableSplit = true }
density { enableSplit = true }
abi { enableSplit = true }
}
}
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_PATH") ?: "release.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
# 保留用于序列化的数据类
-keep class com.example.myapp.data.remote.dto.** { *; }
# 保留 Hilt 生成的类
-keepnames @dagger.hilt.android.lifecycle.HiltViewModel class * extends androidx.lifecycle.ViewModel
# Retrofit
-keepattributes Signature, Exceptions
-keep class retrofit2.** { *; }
collectAsStateWithLifecycle() 收集 Flow — 切勿使用忽略生命周期的 collectAsState();collectAsStateWithLifecycle() 在应用进入后台时暂停收集,防止资源浪费。asStateFlow()/asSharedFlow() 暴露 StateFlow/SharedFlow;保持 MutableStateFlow/MutableSharedFlow 私有以防止外部修改。contentDescription 的情况下传达图标含义;切勿在交互元素中将 null 传递给图标。runBlocking — runBlocking 会阻塞调用线程;对所有协程启动使用 viewModelScope.launch 或 lifecycleScope.launch。LazyColumn/LazyRow 中提供稳定的键 — 缺少 key lambda 会导致任何数据更改时整个列表重组;始终使用 key = { item.id }。| 反模式 | 推荐做法 |
|---|---|
init {} 中的 StateFlow 没有 WhileSubscribed | 使用 SharingStarted.WhileSubscribed(5_000) 以避免没有 UI 存在时的上游流 |
在没有生命周期感知的情况下在 LaunchedEffect 中调用 collect | 使用 collectAsStateWithLifecycle() |
将 Activity/Fragment 上下文传递给 ViewModel | 使用 @ApplicationContext 或 SavedStateHandle |
| 可组合项中的业务逻辑 | 将逻辑放在 ViewModel/UseCase 中 |
将 mutableListOf() 用作 Compose 状态 | 使用 mutableStateListOf() 或 MutableStateFlow<List<T>> |
| 可组合项中的硬编码字符串 | 使用 stringResource(R.string.key) |
生产代码中的 runBlocking | 正确使用协程;runBlocking 会阻塞线程 |
GlobalScope.launch | 使用 viewModelScope 或 lifecycleScope |
| 从 ViewModel 暴露可变状态 | 暴露 StateFlow/SharedFlow;保持可变状态私有 |
// 为纯图标按钮提供内容描述
IconButton(onClick = onFavorite) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
)
}
// 为自定义组件使用语义角色
Box(
modifier = Modifier
.semantics {
role = Role.Switch
stateDescription = if (isChecked) "On" else "Off"
}
.clickable(onClick = onToggle)
)
// 合并后代以减少 TalkBack 冗长
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Icon(Icons.Default.Star, contentDescription = null) // null = 装饰性
Text("4.5 stars")
}
用户:"这个搜索模式正确吗?"
@Composable
fun SearchBar(onQueryChange: (String) -> Unit) {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it; onQueryChange(it) },
label = { Text("Search") }
)
}
审查:
State in Compose flows downward and events flow upward (unidirectional data flow).
State hoisting pattern:
// Stateless composable — accepts state and callbacks
@Composable
fun LoginForm(
email: String,
password: String,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSubmit: () -> Unit,
) {
Column {
TextField(value = email, onValueChange = onEmailChange, label = { Text("Email") })
TextField(value = password, onValueChange = onPasswordChange, label = { Text("Password") })
Button(onClick = onSubmit) { Text("Log in") }
}
}
// Stateful caller — owns state and passes it down
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LoginForm(
email = state.email,
password = state.password,
onEmailChange = viewModel::onEmailChanged,
onPasswordChange = viewModel::onPasswordChanged,
onSubmit = viewModel::onSubmit,
)
}
remember vs rememberSaveable:
remember: Survives recomposition only. Use for transient UI state.
rememberSaveable: Survives recomposition AND process death (saved to Bundle). Use for user-visible state (scroll position, form input).
// remember — lost on configuration change / process death var expanded by remember { mutableStateOf(false) }
// rememberSaveable — survives configuration change and process death var selectedTab by rememberSaveable { mutableIntStateOf(0) }
derivedStateOf: Use when derived state depends on other state objects and you want to avoid unnecessary recompositions.
val isSubmitEnabled by remember {
derivedStateOf { email.isNotBlank() && password.length >= 8 }
}
Use structured side effect APIs — never launch coroutines or perform side effects in composition.
| API | When to use |
|---|---|
LaunchedEffect(key) | Launch a coroutine tied to a key; cancels/relaunches when key changes |
rememberCoroutineScope() | Get a scope for event-driven coroutines (button click, etc.) |
SideEffect | Run non-suspend side effects after every successful composition |
DisposableEffect(key) | Side effects with cleanup (register/unregister callbacks) |
// Navigate to destination after login success
LaunchedEffect(uiState.isLoggedIn) {
if (uiState.isLoggedIn) navController.navigate(Route.Home)
}
// Scope for click-driven coroutine
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { /* ... */ } }) { Text("Save") }
// Register/unregister a callback
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> /* ... */ }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Recomposition is the main performance concern in Compose. Minimize its scope.
// AVOID: Unstable lambda captures the entire parent scope
@Composable
fun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) {
LazyColumn {
items(items, key = { it.id }) { item ->
// New lambda instance created on each recomposition of ItemList
ItemRow(item = item, onClick = { onItemClick(item) })
}
}
}
// PREFER: Stable key + remember to avoid unnecessary child recompositions
@Composable
fun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) {
val stableOnClick = rememberUpdatedState(onItemClick)
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item = item, onClick = { stableOnClick.value(item) })
}
}
}
Rules for stable types:
Primitive types and String are always stable.
Data classes with only stable fields are stable if annotated @Stable or @Immutable.
List, Map, Set from stdlib are unstable — prefer kotlinx.collections.immutable.
@Immutable data class UserProfile(val name: String, val avatarUrl: String)
Modifier ordering matters: Apply modifiers in logical order (size → padding → background → clickable).
// Correct: padding inside clickable area
Modifier
.size(48.dp)
.clip(CircleShape)
.clickable(onClick = onClick)
.padding(8.dp)
// Custom layout example: badge overlay
@Composable
fun BadgeBox(badgeCount: Int, content: @Composable () -> Unit) {
Layout(content = {
content()
if (badgeCount > 0) {
Box(Modifier.background(Color.Red, CircleShape)) {
Text("$badgeCount", color = Color.White, fontSize = 10.sp)
}
}
}) { measurables, constraints ->
val contentPlaceable = measurables[0].measure(constraints)
val badgePlaceable = measurables.getOrNull(1)?.measure(Constraints())
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.placeRelative(0, 0)
badgePlaceable?.placeRelative(
contentPlaceable.width - badgePlaceable.width / 2,
-badgePlaceable.height / 2
)
}
}
}
Use CompositionLocal to propagate ambient data through the composition tree without threading it explicitly through every composable.
// Define
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
// Provide at a high level
CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) {
MyAppContent()
}
// Consume anywhere below
val snackbarHostState = LocalSnackbarHostState.current
When to use: User preferences (theme, locale), shared services (analytics, navigation). When to avoid: Data that changes frequently or should be passed explicitly.
// Simple animated visibility
AnimatedVisibility(visible = showDetails) {
DetailsPanel()
}
// Animated value
val alpha by animateFloatAsState(
targetValue = if (isEnabled) 1f else 0.4f,
animationSpec = tween(durationMillis = 300),
label = "alpha",
)
// Shared element transition (Compose 1.7+)
SharedTransitionLayout {
AnimatedContent(targetState = selectedItem) { item ->
if (item == null) {
ListScreen(
onItemClick = { selectedItem = it },
animatedVisibilityScope = this,
sharedTransitionScope = this@SharedTransitionLayout,
)
} else {
DetailScreen(
item = item,
animatedVisibilityScope = this,
sharedTransitionScope = this@SharedTransitionLayout,
)
}
}
}
// ViewModel: use viewModelScope (auto-cancelled on VM cleared)
class OrderViewModel @Inject constructor(
private val orderRepository: OrderRepository,
) : ViewModel() {
fun placeOrder(order: Order) {
viewModelScope.launch {
try {
orderRepository.placeOrder(order)
} catch (e: HttpException) {
// handle error
}
}
}
}
// Repository: return suspend fun or Flow, never launch internally
class OrderRepositoryImpl @Inject constructor(
private val api: OrderApi,
private val dao: OrderDao,
) : OrderRepository {
override suspend fun placeOrder(order: Order) {
api.placeOrder(order.toRequest())
dao.insert(order.toEntity())
}
}
Dispatcher guidelines:
Dispatchers.Main: UI interactions, state updates
Dispatchers.IO: Network calls, file/database I/O
Dispatchers.Default: CPU-intensive computations
// withContext switches dispatcher for a block suspend fun loadImage(url: String): Bitmap = withContext(Dispatchers.IO) { URL(url).readBytes().let { BitmapFactory.decodeByteArray(it, 0, it.size) } }
Use Flow for reactive streams. Prefer StateFlow/SharedFlow in ViewModels.
// Repository: expose cold Flow
fun observeOrders(): Flow<List<Order>> = dao.observeAll().map { entities ->
entities.map { it.toModel() }
}
// ViewModel: convert to StateFlow for UI
class OrderListViewModel @Inject constructor(repo: OrderRepository) : ViewModel() {
val orders: StateFlow<List<Order>> = repo.observeOrders()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
}
// Compose: collect safely with lifecycle awareness
val orders by viewModel.orders.collectAsStateWithLifecycle()
Flow operators to know:
flow
.filter { it.isActive }
.map { it.toUiModel() }
.debounce(300) // search input debounce
.distinctUntilChanged()
.catch { e -> emit(emptyList()) } // handle errors inline
.flowOn(Dispatchers.IO) // run upstream on IO dispatcher
SharedFlow for one-shot events:
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
// Emit from ViewModel
fun onSubmit() { viewModelScope.launch { _events.emit(UiEvent.NavigateToHome) } }
// Collect in Composable
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.NavigateToHome -> navController.navigate(Route.Home)
is UiEvent.ShowError -> snackbar.showSnackbar(event.message)
}
}
}
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val getProductUseCase: GetProductUseCase,
) : ViewModel() {
private val productId: String = checkNotNull(savedStateHandle["productId"])
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
init { loadProduct() }
private fun loadProduct() {
viewModelScope.launch {
_uiState.value = try {
val product = getProductUseCase(productId)
ProductDetailUiState.Success(product)
} catch (e: Exception) {
ProductDetailUiState.Error(e.message ?: "Unknown error")
}
}
}
}
sealed interface ProductDetailUiState {
data object Loading : ProductDetailUiState
data class Success(val product: Product) : ProductDetailUiState
data class Error(val message: String) : ProductDetailUiState
}
@Entity(tableName = "orders")
data class OrderEntity(
@PrimaryKey val id: String,
val customerId: String,
val totalCents: Long,
val status: String,
val createdAt: Long,
)
@Dao
interface OrderDao {
@Query("SELECT * FROM orders ORDER BY createdAt DESC")
fun observeAll(): Flow<List<OrderEntity>>
@Query("SELECT * FROM orders WHERE id = :id")
suspend fun getById(id: String): OrderEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(order: OrderEntity)
@Delete
suspend fun delete(order: OrderEntity)
}
@Database(entities = [OrderEntity::class], version = 2)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun orderDao(): OrderDao
}
Migration example:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE orders ADD COLUMN notes TEXT NOT NULL DEFAULT ''")
}
}
Use for deferrable, guaranteed background work.
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val syncRepository: SyncRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
syncRepository.sync()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
@AssistedFactory
interface Factory : ChildWorkerFactory<SyncWorker>
}
// Schedule periodic sync
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest,
)
@HiltAndroidApp
class MyApplication : Application()
// Activity/Fragment
@AndroidEntryPoint
class MainActivity : ComponentActivity() { /* ... */ }
// Network module
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideOrderApi(retrofit: Retrofit): OrderApi = retrofit.create(OrderApi::class.java)
}
// Repository binding
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindOrderRepository(impl: OrderRepositoryImpl): OrderRepository
}
| Scope | Component | Lifetime |
|---|---|---|
@Singleton | SingletonComponent | Application lifetime |
@ActivityRetainedScoped | ActivityRetainedComponent | ViewModel lifetime |
@ActivityScoped | ActivityComponent | Activity lifetime |
@ViewModelScoped |
@HiltWorker
class UploadWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val uploadService: UploadService,
) : CoroutineWorker(context, params) { /* ... */ }
// Define destinations as serializable objects/classes
@Serializable object HomeRoute
@Serializable object ProfileRoute
@Serializable data class ProductDetailRoute(val productId: String)
// Build NavGraph
@Composable
fun AppNavGraph(navController: NavHostController) {
NavHost(navController, startDestination = HomeRoute) {
composable<HomeRoute> {
HomeScreen(onProductClick = { id ->
navController.navigate(ProductDetailRoute(id))
})
}
composable<ProductDetailRoute> { backStackEntry ->
val args = backStackEntry.toRoute<ProductDetailRoute>()
ProductDetailScreen(productId = args.productId)
}
composable<ProfileRoute> { ProfileScreen() }
}
}
composable<ProductDetailRoute>(
deepLinks = listOf(
navDeepLink<ProductDetailRoute>(basePath = "https://example.com/product")
)
) { /* ... */ }
Declare in AndroidManifest.xml:
<activity android:name=".MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" />
</intent-filter>
</activity>
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentBackStack by navController.currentBackStackEntryAsState()
val currentDestination = currentBackStack?.destination
Scaffold(
bottomBar = {
NavigationBar {
TopLevelDestination.entries.forEach { dest ->
NavigationBarItem(
icon = { Icon(dest.icon, contentDescription = dest.label) },
label = { Text(dest.label) },
selected = currentDestination?.hasRoute(dest.route::class) == true,
onClick = {
navController.navigate(dest.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
)
}
}
}
) { padding ->
AppNavGraph(navController = navController, modifier = Modifier.padding(padding))
}
}
@ExtendWith(MockKExtension::class)
class GetProductUseCaseTest {
@MockK lateinit var repository: ProductRepository
private lateinit var useCase: GetProductUseCase
@BeforeEach
fun setUp() {
useCase = GetProductUseCase(repository)
}
@Test
fun `returns product when repository succeeds`() = runTest {
val product = Product(id = "1", name = "Widget", priceCents = 999)
coEvery { repository.getProduct("1") } returns product
val result = useCase("1")
assertThat(result).isEqualTo(product)
}
@Test
fun `throws exception when product not found`() = runTest {
coEvery { repository.getProduct("missing") } throws NotFoundException("missing")
assertThrows<NotFoundException> { useCase("missing") }
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class ProductDetailViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
private val repository = mockk<ProductRepository>()
private lateinit var viewModel: ProductDetailViewModel
@Before
fun setUp() {
viewModel = ProductDetailViewModel(
savedStateHandle = SavedStateHandle(mapOf("productId" to "abc")),
getProductUseCase = GetProductUseCase(repository),
)
}
@Test
fun `uiState is Loading initially then Success`() = runTest {
val product = Product("abc", "Gizmo", 1299)
coEvery { repository.getProduct("abc") } returns product
val states = mutableListOf<ProductDetailUiState>()
val job = launch { viewModel.uiState.toList(states) }
advanceUntilIdle()
job.cancel()
assertThat(states).contains(ProductDetailUiState.Loading)
assertThat(states.last()).isEqualTo(ProductDetailUiState.Success(product))
}
}
class MainDispatcherRule(
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
}
}
class LoginScreenTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun `submit button disabled when fields are empty`() {
composeTestRule.setContent {
LoginScreen(onLoginSuccess = {})
}
composeTestRule
.onNodeWithText("Log in")
.assertIsNotEnabled()
}
@Test
fun `displays error message on invalid credentials`() {
composeTestRule.setContent {
LoginScreen(onLoginSuccess = {})
}
composeTestRule.onNodeWithText("Email").performTextInput("bad@example.com")
composeTestRule.onNodeWithText("Password").performTextInput("wrongpass")
composeTestRule.onNodeWithText("Log in").performClick()
composeTestRule
.onNodeWithText("Invalid credentials")
.assertIsDisplayed()
}
}
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun navigatesToDetailScreen() {
onView(withId(R.id.product_list))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click()))
onView(withId(R.id.product_title)).check(matches(isDisplayed()))
}
}
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NotificationHelperTest {
@Test
fun `creates notification with correct channel`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val helper = NotificationHelper(context)
helper.showOrderNotification(orderId = "42", message = "Your order shipped!")
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
assertThat(nm.activeNotifications).hasSize(1)
}
}
Baseline Profiles pre-compile hot paths during app installation, reducing JIT overhead.
// app/src/main/baseline-prof.txt (auto-generated by Macrobenchmark)
// Or use the Baseline Profile Gradle Plugin:
// build.gradle.kts (app)
plugins {
id("androidx.baselineprofile")
}
// Generate: ./gradlew :app:generateBaselineProfile
Macrobenchmark for generation:
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "com.example.myapp") {
pressHome()
startActivityAndWait()
// Interact with critical user journeys
device.findObject(By.text("Products")).click()
device.waitForIdle()
}
}
// Add custom trace sections
trace("MyExpensiveOperation") {
performExpensiveWork()
}
// Compose compiler metrics — add to build.gradle.kts
tasks.withType<KotlinCompile>().configureEach {
compilerOptions.freeCompilerArgs.addAll(
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${layout.buildDirectory.get()}/compose_metrics"
)
}
Use Android Studio Memory Profiler to capture heap dumps.
Look for Bitmap leaks, Context leaks in static fields, and unclosed Cursor objects.
Use LeakCanary in debug builds for automatic leak detection.
// Avoid Context leaks: use applicationContext for long-lived objects class ImageCache @Inject constructor( @ApplicationContext private val context: Context // Safe: application scope ) { /* ... */ }
LazyColumn {
items(
items = itemList,
key = { item -> item.id }, // Stable key prevents unnecessary recompositions
contentType = { item -> item.type }, // Enables item recycling by type
) { item ->
ItemRow(item = item)
}
}
// Define color scheme
val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color(0xFFFFFFFF),
secondary = Color(0xFF625B71),
// ... other tokens
)
// Apply theme
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content,
)
}
@Composable
fun MyAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val context = LocalContext.current
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
// Top App Bar
TopAppBar(
title = { Text("Orders") },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
},
actions = {
IconButton(onClick = onSearch) { Icon(Icons.Default.Search, "Search") }
},
)
// Card
ElevatedCard(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Column(Modifier.padding(16.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium)
}
}
// FAB
FloatingActionButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
MVI is the recommended pattern for Compose apps. State flows one direction; intents describe user actions.
// Intent (user actions)
sealed interface ProductListIntent {
data object LoadProducts : ProductListIntent
data class SearchQueryChanged(val query: String) : ProductListIntent
data class ProductClicked(val id: String) : ProductListIntent
}
// UI State
data class ProductListUiState(
val isLoading: Boolean = false,
val products: List<Product> = emptyList(),
val error: String? = null,
val searchQuery: String = "",
)
// One-shot effects
sealed interface ProductListEffect {
data class NavigateToDetail(val productId: String) : ProductListEffect
}
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val getProductsUseCase: GetProductsUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductListUiState())
val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
private val _effect = MutableSharedFlow<ProductListEffect>()
val effect: SharedFlow<ProductListEffect> = _effect.asSharedFlow()
fun handleIntent(intent: ProductListIntent) {
when (intent) {
is ProductListIntent.LoadProducts -> loadProducts()
is ProductListIntent.SearchQueryChanged -> updateSearch(intent.query)
is ProductListIntent.ProductClicked -> {
viewModelScope.launch { _effect.emit(ProductListEffect.NavigateToDetail(intent.id)) }
}
}
}
private fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
_uiState.update {
try {
it.copy(isLoading = false, products = getProductsUseCase())
} catch (e: Exception) {
it.copy(isLoading = false, error = e.message)
}
}
}
}
}
presentation/
ui/ — Composables, screens
viewmodel/ — ViewModels, UI State, Intents
domain/
model/ — Domain entities (pure Kotlin, no Android deps)
repository/ — Repository interfaces
usecase/ — Business logic (one use case per file)
data/
repository/ — Repository implementations
remote/ — API service interfaces, DTOs, mappers
local/ — Room entities, DAOs, mappers
di/ — Hilt modules
Use Case example:
class GetFilteredProductsUseCase @Inject constructor(
private val productRepository: ProductRepository,
) {
suspend operator fun invoke(query: String): List<Product> =
productRepository.getProducts()
.filter { it.name.contains(query, ignoreCase = true) }
.sortedBy { it.name }
}
// build.gradle.kts (app)
android {
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 26
targetSdk = 35
versionCode = 10
versionName = "1.2.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
}
}
bundle {
language { enableSplit = true }
density { enableSplit = true }
abi { enableSplit = true }
}
}
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_PATH") ?: "release.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
# Keep data classes used for serialization
-keep class com.example.myapp.data.remote.dto.** { *; }
# Keep Hilt-generated classes
-keepnames @dagger.hilt.android.lifecycle.HiltViewModel class * extends androidx.lifecycle.ViewModel
# Retrofit
-keepattributes Signature, Exceptions
-keep class retrofit2.** { *; }
collectAsStateWithLifecycle() — never use collectAsState() which ignores lifecycle; collectAsStateWithLifecycle() pauses collection when the app is backgrounded, preventing resource waste.StateFlow/SharedFlow via asStateFlow()/asSharedFlow(); keep MutableStateFlow/MutableSharedFlow private to prevent external mutation.| Anti-pattern | Preferred |
|---|---|
StateFlow in init {} without WhileSubscribed | Use SharingStarted.WhileSubscribed(5_000) to avoid upstreams when no UI is present |
Calling collect in LaunchedEffect without lifecycle awareness | Use collectAsStateWithLifecycle() |
Passing Activity/ context to ViewModel |
// Provide content descriptions for icon-only buttons
IconButton(onClick = onFavorite) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
)
}
// Use semantic roles for custom components
Box(
modifier = Modifier
.semantics {
role = Role.Switch
stateDescription = if (isChecked) "On" else "Off"
}
.clickable(onClick = onToggle)
)
// Merge descendants to reduce TalkBack verbosity
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Icon(Icons.Default.Star, contentDescription = null) // null = decorative
Text("4.5 stars")
}
User: "Is this pattern correct for search?"
@Composable
fun SearchBar(onQueryChange: (String) -> Unit) {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it; onQueryChange(it) },
label = { Text("Search") }
)
}
Review:
remember is appropriate for transient UI input.rememberSaveable if you want the query to survive configuration changes.debounce in the ViewModel rather than calling onQueryChange on every keystroke; this avoids unnecessary searches.modifier parameter for the caller to control layout.User: "My list recomposes entirely when one item changes"
Root cause and fix:
key = { item.id } to items() in LazyColumn so Compose can track items by identity.Item data class is @Stable or @Immutable with stable field types.kotlinx.collections.immutable.ImmutableList instead of List<T>.This skill is used by:
developer — Android feature implementationcode-reviewer — Android code reviewarchitect — Android architecture decisionsqa — Android test strategykotlin-expert, mobile-app-patterns, accessibility-tester, security-architect.claude/rules/android-expert.mdBefore starting:
cat .claude/context/memory/learnings.md
Check for:
After completing:
.claude/context/memory/learnings.md.claude/context/memory/issues.md.claude/context/memory/decisions.mdASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.
Weekly Installs
73
Repository
GitHub Stars
19
First Seen
Jan 27, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
github-copilot69
cursor69
opencode69
gemini-cli68
codex68
kimi-cli67
Kotlin Exposed ORM 模式指南:DSL查询、DAO、事务管理与生产配置
1,100 周安装
ViewModelComponent |
| ViewModel lifetime |
@FragmentScoped | FragmentComponent | Fragment lifetime |
contentDescriptionnullrunBlocking in production code — runBlocking blocks the calling thread; use viewModelScope.launch or lifecycleScope.launch for all coroutine launches.LazyColumn/LazyRow — missing key lambda causes full list recomposition on any data change; always use key = { item.id }.FragmentUse @ApplicationContext or SavedStateHandle |
| Business logic in Composables | Put logic in ViewModel/UseCase |
mutableListOf() as Compose state | Use mutableStateListOf() or MutableStateFlow<List<T>> |
| Hardcoded strings in Composables | Use stringResource(R.string.key) |
runBlocking in production code | Use coroutines properly; runBlocking blocks the thread |
GlobalScope.launch | Use viewModelScope or lifecycleScope |
| Mutable state exposed from ViewModel | Expose StateFlow/SharedFlow; keep mutable state private |