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 模型的转换逻辑
  • 扩展函数提供批量转换的便利方法
相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui