Now in Android 架构模式全面分析
1. UiState 密封接口模式(Sealed Interface Pattern)
NIA 使用 sealed interface 来建模 UI 状态,这是 Kotlin 最佳实践:
kotlin
// OnboardingUiState.kt --- 典型的 UiState 密封层级
sealed interface OnboardingUiState {
data object Loading : OnboardingUiState
data object LoadFailed : OnboardingUiState
data object NotShown : OnboardingUiState
data class Shown(
val topics: List<FollowableTopic>,
) : OnboardingUiState {
val isDismissable: Boolean get() = topics.any { it.isFollowed }
}
}
关键设计要点:
-
使用 sealed interface(而非 sealed class),更灵活
-
Loading / LoadFailed 用 data object(单例,无数据)
-
有数据的状态用 data class
-
派生属性(如 isDismissable)直接定义在数据类中,避免在 UI 层计算
-
NewsFeedUiState 也遵循相同模式:Loading + Success(feed: List)
应用到你的车载 Launcher:
kotlin
sealed interface LauncherUiState {
data object Loading : LauncherUiState
data class Success(
val weatherInfo: WeatherInfo,
val musicState: MusicState,
val vehicleStatus: VehicleStatus,
) : LauncherUiState
data class Error(val message: String) : LauncherUiState
}
2. ViewModel 模式 --- StateFlow + stateIn
NIA 的 ForYouViewModel 展示了标准的响应式 ViewModel 模式:
less
@HiltViewModel
class ForYouViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
syncManager: SyncManager,
private val userDataRepository: UserDataRepository,
userNewsResourceRepository: UserNewsResourceRepository,
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
// 1. 所有 StateFlow 在构造时声明,用 stateIn 转换
val feedState: StateFlow<NewsFeedUiState> =
userNewsResourceRepository.observeAllForFollowedTopics()
.map(NewsFeedUiState::Success) // 方法引用简洁映射
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // 关键!
initialValue = NewsFeedUiState.Loading,
)
// 2. 多流合并用 combine
val onboardingUiState: StateFlow<OnboardingUiState> =
combine(
shouldShowOnboarding,
getFollowableTopics(),
) { shouldShow, topics ->
if (shouldShow) OnboardingUiState.Shown(topics = topics)
else OnboardingUiState.NotShown
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = OnboardingUiState.Loading,
)
// 3. 用户操作方法简洁明了
fun updateTopicSelection(topicId: String, isChecked: Boolean) {
viewModelScope.launch {
userDataRepository.setTopicIdFollowed(topicId, isChecked)
}
}
}
关键模式:
-
SharingStarted.WhileSubscribed(5_000):5 秒超时。当屏幕旋转时保持订阅(配置变更通常小于 5 秒),但离开屏幕后停止订阅以节省资源
-
initialValue:始终提供初始值(通常是 Loading)
-
方法引用:.map(NewsFeedUiState::Success) 比 .map { NewsFeedUiState.Success(it) } 更简洁
-
flatMapLatest:处理依赖性流转换(如 deepLink → newsResource)
-
用户操作:简单的 viewModelScope.launch 包裹 suspend 函数
3. Compose 屏幕结构 --- 状态提升(State Hoisting)
NIA 使用经典的双层 Composable 函数模式:
// 第一层:ViewModel 连接层(对外公开)
kotlin
@Composable
fun ForYouScreen(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(),
) {
// 从 ViewModel 收集所有状态
val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
// 委托给无状态版本
ForYouScreen(
isSyncing = isSyncing,
onboardingUiState = onboardingUiState,
feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier,
)
}
// 第二层:纯 UI 层(internal,可独立预览)
@Composable
internal fun ForYouScreen(
isSyncing: Boolean,
onboardingUiState: OnboardingUiState,
feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit,
modifier: Modifier = Modifier,
) {
// 纯 UI 逻辑,不依赖 ViewModel
}
关键设计:
- 第一层:公开的、带 ViewModel 的入口函数
- 第二层:internal 的纯 UI 函数,所有数据通过参数传入
- 使用 collectAsStateWithLifecycle()(而非 collectAsState()),生命周期感知
- 回调使用 方法引用(viewModel::updateTopicSelection),简洁且避免 lambda 重建
- modifier 参数始终有默认值 Modifier,且放在必填参数之后
4. Theme 和 Design System 架构
NIA 的主题系统非常优雅:
kotlin
@Composable
fun NiaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
androidTheme: Boolean = false,
disableDynamicTheming: Boolean = true,
content: @Composable () -> Unit,
) {
// 1. 根据条件选择 ColorScheme
val colorScheme = when {
androidTheme -> if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme
!disableDynamicTheming && supportsDynamicTheming() -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
}
// 2. 自定义扩展主题通过 CompositionLocal 传递
CompositionLocalProvider(
LocalGradientColors provides gradientColors,
LocalBackgroundTheme provides backgroundTheme,
LocalTintTheme provides tintTheme,
) {
MaterialTheme(
colorScheme = colorScheme,
typography = NiaTypography,
content = content,
)
}
}
关键设计:
- 多主题策略:支持默认主题、Android 主题、动态主题三种模式
- CompositionLocalProvider 扩展 MaterialTheme:在 MaterialTheme 之外提供自定义主题数据(GradientColors、BackgroundTheme、TintTheme)
- ColorScheme 用 @VisibleForTesting 标记以便测试
- supportsDynamicTheming() 用 @ChecksSdkIntAtLeast 注解做 API 等级检查
Background 组件也体现了分层设计:
less
// 基础背景
@Composable
fun NiaBackground(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val color = LocalBackgroundTheme.current.color
val tonalElevation = LocalBackgroundTheme.current.tonalElevation
Surface(
color = if (color == Color.Unspecified) Color.Transparent else color,
tonalElevation = if (tonalElevation == Dp.Unspecified) 0.dp else tonalElevation,
modifier = modifier.fillMaxSize(),
) { ... }
}
// 渐变背景(仅用于特定屏幕)
@Composable
fun NiaGradientBackground(
modifier: Modifier = Modifier,
gradientColors: GradientColors = LocalGradientColors.current,
content: @Composable () -> Unit,
) { ... }
5. App 层级架构 --- NiaApp
NiaApp 展示了顶层应用结构:
scss
@Composable
fun NiaApp(
appState: NiaAppState,
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
NiaBackground(modifier = modifier) {
NiaGradientBackground(
gradientColors = if (shouldShowGradientBackground) {
LocalGradientColors.current
} else {
GradientColors() // 空渐变 = 无渐变
},
) {
// Snackbar + 网络状态管理
val snackbarHostState = remember { SnackbarHostState() }
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
LaunchedEffect(isOffline) {
if (isOffline) {
snackbarHostState.showSnackbar(
message = notConnectedMessage,
duration = Indefinite,
)
}
}
// 委托给内部版本
NiaApp(appState, showSettingsDialog, ...)
}
}
}
关键设计:
- 嵌套背景:NiaBackground → NiaGradientBackground → 内容,层层叠加
- Scaffold 使用 Color.Transparent:让背景颜色由外层控制
- WindowInsets 精细处理:consumeWindowInsets、safeDrawing、ime 等
- testTagsAsResourceId = true:用于 UI 自动化测试
- 条件渐变:不同页面可以有不同的渐变背景
6. Preview 最佳实践
NIA 定义了可复用的多预览注解:
less
// 自定义多预览注解
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme")
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme")
annotation class ThemePreviews
// 在 Background 组件上使用
@ThemePreviews
@Composable
fun BackgroundDefault() {
NiaTheme(disableDynamicTheming = true) {
NiaBackground(Modifier.size(100.dp), content = {})
}
}
ForYouScreen 使用 @DevicePreviews + @PreviewParameter:
less
@DevicePreviews
@Composable
fun ForYouScreenPopulatedFeed(
@PreviewParameter(UserNewsResourcePreviewParameterProvider::class)
userNewsResources: List<UserNewsResource>,
) {
NiaTheme {
ForYouScreen(
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(feed = userNewsResources),
// 所有回调用空 lambda
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
...
)
}
}
关键实践:
- 多个 Preview 覆盖不同状态(Loading、Success、带 Onboarding、离线等)
- 使用 @PreviewParameter 注入预览数据,避免硬编码
- 预览始终作用在无状态版本的 Composable 上(不依赖 ViewModel)
- 每个预览都包裹在 NiaTheme {} 中
7. 数据模型模式
kotlin
data class UserNewsResource internal constructor(
val id: String,
val title: String,
val content: String,
val url: String,
val headerImageUrl: String?,
val publishDate: Instant,
val type: String,
val followableTopics: List<FollowableTopic>,
val isSaved: Boolean,
val hasBeenViewed: Boolean,
) {
// 辅助构造函数:从领域模型转换
constructor(newsResource: NewsResource, userData: UserData) : this(
id = newsResource.id,
title = newsResource.title,
// ... 映射逻辑
isSaved = newsResource.id in userData.bookmarkedNewsResources,
)
}
// 扩展函数用于批量转换
fun List<NewsResource>.mapToUserNewsResources(userData: UserData): List<UserNewsResource> =
map { UserNewsResource(it, userData) }
关键设计:
- 主构造函数 internal,防止外部随意构造
- 辅助构造函数封装领域 → UI 模型的转换逻辑
- 扩展函数提供批量转换的便利方法