Now in Android 架构模式全面分析

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 模型的转换逻辑
  • 扩展函数提供批量转换的便利方法
相关推荐
二流小码农9 小时前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos
鹏程十八少9 小时前
4.Android 30分钟手写一个简单版shadow, 从零理解shadow插件化零反射插件化原理
android·前端·面试
Kapaseker9 小时前
一杯美式搞定 Kotlin 空安全
android·kotlin
三少爷的鞋10 小时前
Android 协程时代,Handler 应该退休了吗?
android
火柴就是我1 天前
让我们实现一个更好看的内部阴影按钮
android·flutter
FunnySaltyFish1 天前
什么?Compose 把 GapBuffer 换成了 LinkBuffer?
算法·kotlin·android jetpack
砖厂小工1 天前
用 GLM + OpenClaw 打造你的 AI PR Review Agent — 让龙虾帮你审代码
android·github
张拭心1 天前
春节后,有些公司明确要求 AI 经验了
android·前端·人工智能
张拭心1 天前
Android 17 来了!新特性介绍与适配建议
android·前端