在不崩溃的前提下预览你的 KMP 驱动的 Compose UI

37 阅读6分钟

因为AI,所以deepseek

原文连接

以下是翻译内容:

在不崩溃的前提下预览你的 KMP 驱动的 Compose UI

大多数 Android 开发者与 Jetpack Compose 预览的关系都很复杂。它们慢,它们坏,它们在复杂应用中不起作用。这些是你常听到的抱怨,然后开发者就放弃了,又回到每次花三十秒构建和运行来检查一个按钮是否居中的日子。

坦率地说,这通常是他们(我也是)自己的错。

预览不起作用,是因为他们试图预览的 UI 是一个业务逻辑、状态计算和隐式依赖纠缠在一起的混乱体。当你的 @Composable 函数还要负责决定获取什么数据以及如何格式化时,你写的就不是一个 UI 组件。你写的是一个微型的、不可测试的应用程序。不,预览工具无法运行你的整个应用程序。

在 Kotlin 多平台架构中,Android 应用是一个专门的 UI 层,这些借口就烟消云散了。

如果你的预览仍然崩溃,问题不在于工具,而在于你的自律性。

基础:为何这应该很简单 让我们明确它是如何工作的。一个 @Composable 函数,本质上是一个将状态转换为 UI 的函数。Compose 运行时在这方面非常高效,但它依赖于一个简单的契约:UI = f(状态)。当你直接在组合函数中嵌入副作用和业务逻辑,你就破坏了这个模型。

我们的 KMP 设置给了我们一个良好的开端。所有困难的部分——状态管理、业务逻辑、数据获取——都在共享模块中处理。KMP ViewModel 暴露状态,Android UI 层只是观察它。UI 的唯一工作是向 ViewModel 发送事件。实际上,它就是一个哑终端。

这是预览的完美环境。一个哑 UI 很容易预览,因为你可以提供你想要的任何状态,它会忠实地渲染它。

方法:一个简单、可重复的工作流程 为了让这个模式可靠地工作,我们为每个功能屏幕遵循一个严格的三部分模式。

  • <功能>Screen:这是“智能”组合函数。它是该功能中唯一允许与 KMP ViewModel 对话的组件。它的职责是从 ViewModel 收集状态(如果需要的话,将其映射到一个简单的 UI 特定状态模型),并将用户事件(点击、文本更改)连接到 ViewModel 的事件处理器。
  • <功能>Content:这是“哑”组合函数。它包含该功能的所有 UI——Scaffold、Column、TextFields 和 Buttons。它对 ViewModel 或 KMP 一无所知。它接受两个参数:一个状态对象和一组用于事件的 lambda。这正是我们要预览的组件。
  • <功能>UiStateProvider:这是一个简单的工厂类,实现 PreviewParameterProvider。它的唯一目的是为每个我们想要可视化的可能场景生成 UI 状态的实例:加载中、成功、错误以及其他任何特定的业务用例。

让我们将此应用于一个虚构的 WCDonalds 应用,重点关注登录和注册流程。

首先,我们需要自定义预览注解以保持一致性。

import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Preview

@Preview(
  name = "浅色模式",
  group = "浅色",
  showBackground = true,
  backgroundColor = 0xffEEEEEE,
  uiMode = UI_MODE_NIGHT_NO,
  fontScale = 1f,
)
@Preview(
  name = "深色模式",
  group = "深色",
  showBackground = true,
  backgroundColor = 0xff444444,
  uiMode = UI_MODE_NIGHT_YES,
  fontScale = 1f,
)
@Preview(
  name = "浅色模式 - 大字体",
  group = "浅色",
  showBackground = true,
  backgroundColor = 0xffEEEEEE,
  uiMode = UI_MODE_NIGHT_NO,
  fontScale = 1.5f,
)
@Preview(
  name = "深色模式 - 大字体",
  group = "深色",
  showBackground = true,
  backgroundColor = 0xff444444,
  uiMode = UI_MODE_NIGHT_YES,
  fontScale = 1.5f,
)
annotation class WcdPreview

现在,让我们构建登录屏幕。 KMP 模块提供了一个表示状态的密封类。

// 由 KMP 共享模块提供
sealed class LoginState {
    object Idle : LoginState()
    object Loading : LoginState()
    data class Error(val message: String) : LoginState()
    object TwoFactorRequired : LoginState()
    object Success : LoginState()
}

我们的 LoginUiStateProvider 将为预览创建这个状态的实例。

import androidx.compose.ui.tooling.preview.PreviewParameterProvider

class LoginUiStateProvider : PreviewParameterProvider<LoginState> {
    override val values: Sequence<LoginState> = sequenceOf(
        LoginState.Idle,
        LoginState.Loading,
        LoginState.Error("用户名或密码无效。"),
        LoginState.TwoFactorRequired
    )
}

接下来,是 LoginContent 组合函数。它是无状态且哑的。它接收 LoginState 和用于每个可能用户交互的 lambda。

@Composable
fun LoginContent(
    state: LoginState,
    onEmailChange: (String) -> Unit,
    onPasswordChange: (String) -> Unit,
    onLoginClick: () -> Unit,
    onTwoFactorCodeChange: (String) -> Unit,
    onTwoFactorSubmit: () -> Unit
) {
    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        // ... 邮箱和密码的 TextFields ...
        when (state) {
            is LoginState.Idle -> {
                Button(onClick = onLoginClick) { Text("登录") }
            }
            is LoginState.Loading -> {
                CircularProgressIndicator()
            }
            is LoginState.Error -> {
                Button(onClick = onLoginClick) { Text("登录") }
                Text(text = state.message, color = MaterialTheme.colors.error)
            }
            is LoginState.TwoFactorRequired -> {
                // ... 2FA 验证码的 TextField ...
                Button(onClick = onTwoFactorSubmit) { Text("提交验证码") }
            }
            is LoginState.Success -> {
                // 通常在此导航离开,但对于预览,我们可以显示成功信息。
                Text("登录成功!")
            }
        }
    }
}

哑的 LoginContent 准备就绪后,我们可以创建预览。

@WcdPreview
@Composable
fun LoginPreview(
    @PreviewParameter(LoginUiStateProvider::class) state: LoginState
) {
    WcdonaldsTheme { // 你的应用主题
        LoginContent(
            state = state,
            onEmailChange = {},
            onPasswordChange = {},
            onLoginClick = {},
            onTwoFactorCodeChange = {},
            onTwoFactorSubmit = {}
        )
    }
}

最后,LoginScreen 在实际应用中连接一切。

@Composable
fun LoginScreen(
    viewModel: LoginViewModel // 来自 KMP
) {
    val state by viewModel.state.collectAsState()
    LoginContent(
        state = state,
        onEmailChange = viewModel::onEmailChanged,
        onPasswordChange = viewModel::onPasswordChanged,
        onLoginClick = viewModel::onLoginClicked,
        onTwoFactorCodeChange = viewModel::onTwoFactorCodeChanged,
        onTwoFactorSubmit = viewModel::onTwoFactorSubmit
    )
}

这个模式对任何组件都是可重复的。 需要预览一个处于禁用状态的按钮?创建一个 ButtonPreview,用 enabled = false 调用你的 WcdButton 组合函数。

原理相同:提供状态,渲染 UI。

好处,直截了当地说 遵循这个工作流程不仅仅是为了让预览工作。它强制推行了更清晰的架构。

  • 更快的迭代:你可以构建整个 UI 流程,包括所有错误和边界情况,而无需运行应用程序。你可以开发 2FA 验证屏幕,而无需先实际登录。
  • 架构纯粹性:UI 层保持简单。它不与逻辑纠缠。这符合 SOLID 原则,并保持代码 DRY(不重复)。
  • 更容易上手:新开发者可以打开预览文件,立即了解屏幕可能处于的所有视觉状态,而无需了解底层业务逻辑的任何知识。
  • 视觉测试:你可以立即发现设计系统在不同状态(浅色/深色模式、字体缩放)下的不一致之处。这鼓励创建可重用、原子化的组件。

不容商榷的注意事项 这个工作流程很有效,但也很严格。只有你遵守规则时才有效。

  • 无真实数据:你的预览不能获取真实数据。它不应该访问网络或数据库。UiStateProvider 提供的状态必须是静态和模拟的。
  • 组合函数中无逻辑:如果你发现自己写 if (LocalInspectionMode.current) 来“修复”预览错误,那么你就失败了。错误是逻辑不属于你的 UI 的症状。修复架构,而不是预览。
  • UDF 是强制的:整个系统建立在单向数据流和状态提升的基础上。状态向下流,事件向上流。这不是可选的。

总结 这不是万能药。这是一个有纪律的工作流程,可以减少摩擦,并消除不写预览的最常见借口。承诺在 KMP 驱动的逻辑和 Compose 驱动的 UI 之间保持清晰的关注点分离是前提条件。

当这种分离得到尊重时,预览就不再是一件苦差事,而成为快速开发的强大工具。

如果这里的任何概念不熟悉,Jetpack Compose 官方文档是存在的。你或许应该去读一读。