Jetpack Compose表单生成器库的制作过程

294 阅读8分钟

构建Jetpack Compose表单生成器库的过程

表单生成器在我们的应用程序中为表单操作提供了一个抽象层。就像Room数据库一样,该库打算提供一种简单的方法来处理表单字段及其数据。

然而,我们面临着一些挑战,如如何绘制用户界面和如何从字段中检索数据。

在写这篇文章的时候,有一个已发布的库实现了同样的概念,但方法更灵活。

在本教程中,我们将介绍我们是如何制作表单生成器库的,我们是如何解决之前想法中的问题,以及如何使用该库。

前提条件

要跟上这篇文章,你将需要。

  • 对使用Jetpack compose的Android开发有基本了解。
  • 一些Kotlin的高级知识,特别是泛型和反射。
  • Android Studio IDE。

开始学习

第一步是解决我们所面临的最简单的问题,即UI问题。经过几次讨论和询问,我们得出的结论是,我们实际上不需要绘制用户界面。

我知道,这听起来有点好笑😂。

你现在会问,但是我们怎么给用户提供文本字段呢?,这很好。事情是这样的,为了允许灵活地绘制用户界面,我们不会触及可合成物中的任何东西。

这是因为,如果你仔细看看我们的库应该做什么,也就是说,为组件提供一个抽象,我们只需要管理字段的状态;数据。

就像Room不为你提供实际的数据库,而是提供一种更容易与SQLite数据库交互的方式。

所以我们只需要为用户提供访问数据和改变数据的方法。如果可能的话,以他们喜欢的方式进行转换。

考虑到这一点,我们的工作变得更容易了。

TextField的状态

新的实现将类似于之前的迭代,只是没有了可组合和相关的字段。

class TextFieldState(
    val name: String,
    initial: String = "",
    val validators: List<Validators> = listOf(),
) {

    var text: String by mutableStateOf(initial)
    var errorMessage: String by mutableStateOf("")
    var hasError: Boolean by mutableStateOf(false)

    fun change(value: String) {
        hideError()
        text = value
    }

    fun showError(error: String) {
        hasError = true
        errorMessage = error
    }

    fun hideError() {
        errorMessage = ""
        hasError = false
    }
}

至于验证器,我们只是把实现移到lambda函数之外。

    fun validate(): Boolean {
        val validations = validators.map {
            when (it) {
                is Validators.Email -> validateEmail(it.message)
                is Validators.Required -> validateRequired(it.message)
                is Validators.Custom -> validateCustom(it.function, it.message)
                is Validators.MinChars -> validateMinChars(it.limit, it.message)
                is Validators.MaxChars -> validateMaxChars(it.limit, it.message)
                is Validators.MaxValue -> validateMaxValue(it.limit, it.message)
                is Validators.MinValue -> validateMinValue(it.limit, it.message)
            }
        }
        return validations.all { it }
    }

    private fun validateCustom(function: (String) -> Boolean, message: String): Boolean {
        val valid = function(text)
        if (!valid) showError(message)
        return valid
    }

    private fun validateEmail(message: String): Boolean {
        val valid = Patterns.EMAIL_ADDRESS.matcher(text).matches()
        if (!valid) showError(message)
        return valid
    }

    private fun validateRequired(message: String): Boolean {
        val valid = text.isNotEmpty()
        if (!valid) showError(message)
        return valid
    }

    private fun validateMaxChars(limit: Int, message: String): Boolean {
        val valid = text.length <= limit
        if (!valid) showError(message)
        return valid
    }

    private fun validateMinChars(limit: Int, message: String): Boolean {
        val valid = text.length >= limit
        if (!valid) showError(message)
        return valid
    }

    private fun validateMinValue(limit: Int, message: String): Boolean {
        val valid = text.isNumeric() && text.toDouble() >= limit
        if (!valid) showError(message)
        return valid
    }

    private fun validateMaxValue(limit: Int, message: String): Boolean {
        val valid = text.isNumeric() && text.toDouble() <= limit
        if (!valid) showError(message)
        return valid
    }

消息参数将允许我们接受和使用来自开发者的自定义错误信息。

// Validators.kt

private const val EMAIL_MESSAGE = "invalid email address"
private const val REQUIRED_MESSAGE = "this field is required"

sealed interface Validators {
    class Email(var message: String = EMAIL_MESSAGE) : Validators
    class MinValue(var limit: Int, var message: String): Validators
    class MaxValue(var limit: Int, var message: String): Validators
    class MinChars(var limit: Int, var message: String) : Validators
    class MaxChars(var limit: Int, var message: String) : Validators
    class Required(var message: String = REQUIRED_MESSAGE) : Validators
    class Custom(var message: String, var function: (String) -> Boolean): Validators
}
  • MaxValue:检查所提供的值是否小于或等于所提供的限制。
  • MinValue:检查该值是否大于或等于所提供的限制。
  • MaxChars:这是一个字符串验证器,检查字段中的字符数是否小于或等于限制。
  • MinChars:适合于密码。这将检查字符数是否大于或等于指定的限制
  • 自定义:这是一个新的和强大的验证器。它允许人们传入其验证的自定义实现。它还提供文本字段的字符串值。因此,你可以用你自己的方式进行验证。由于它是一个lambda函数,你只需要确保最后一个语句的值是Boolean

有了状态的工作,我们想为这个库添加更多的功能。假设你有一个字段,需要用户输入他们的年龄。你很可能希望在验证结束时,该值是一个整数。

因此,我们想添加一个转换函数,允许人们将值改为他们想要的任何类型。这就是泛型的作用。

这个函数的性质将是(String) -> T ,你可以手动指定类型,或者直接从转化器的返回类型中推断出来。

因此,我们继续前进,并改变了我们的文本字段类,如下所示。

class TextFieldState<T>(
    ...
    val transform: ((String) -> T)? = null,
) {

有了这个,用户可以传入他们的转换函数,如String.toInt() ,甚至String.trim() 。它并不限制你只使用Kotlin标准类型。你也可以传入你自己的类。

我们会在表单状态下说到转换。说到这里,让我们看看我们是如何修改它的。

表单状态

我们使用setter方法来设置字段。这是第一个要去的地方。为了便于操作,我们把状态移到构造函数中。

class FormState(val fields: List<TextFieldState<*>>) {}

// Star projection to allow for all types to be used.

然后我们继续创建一个类似的getter方法。它使用状态的名称获得一个单一的文本字段。

fun getState(name: String): TextFieldState<*> = fields.first { it.name == name }

验证函数或多或少是一样的,即运行所有的字段并验证其中的每一个。

fun validate(): Boolean = fields.map { it.validate() }.all { it }

现在我们进入了有趣的部分,访问数据。还记得我们添加的新的转换功能吗?嗯,它在这里又出现了。

基本的想法是集体接收表单中的数据。我们之前使用的是Map<String, String> 。这其实并不灵活,如果我们应用转换功能,就会失败。另外,一个地图?真的吗?我们可以做得比这更好。

我说的更好是指,如果用户可以指定他们想要的类型,而我们提供给他们。在Kotlin中,我们使用了大量的数据类。所以,如果有人能提供一个数据类,并获得该类中的数据映射,那不是很好吗?

首先,我们需要将表单中的所有字段转换为格式Map<String, Any?> 。Any? 是所有类型的超类型,所以当我们应用转换时,不会有任何中断。而有了这个映射,我们就可以把它改成任何指定的类。

第二个需要克服的障碍是如何将地图转换为类。最初的解决方案是将map转换为JSON ,然后使用一个序列化库将json转换为我们的类。

但问题是,我个人更喜欢Kotlin的序列化库,但不是每个开发者都有同样的感觉。其他人使用Gson、Moshi等。所以这不是一个好办法。下一个解决方案是使用反射

由于各种原因,反射在开发社区有点争议。但在这种情况下,我们不会处理访问修改器等有争议的问题。我们只需要构造函数,所以我们可以创建类,因为这就是构造函数的作用。

fun <T : Any> getData(dataClass: KClass<T>) : T {
    val map: Map<String, Any?> = fields.associate {
        val value = if (it.transform == null) it.text else it.transform!!(it.text)
        it.name to value
    }

    val constructor = dataClass.constructors.first()
    val args = constructor.parameters.associateWith { map[it.name] }
    return constructor.callBy(args)
}

我们指定该函数接收一个类的类型T或开发者指定的任何类型。我们在每个字段上调用转换函数,如果适用的话。然后我们得到构造函数,将参数名称与我们的表单值相关联,并返回类。

这起初工作得很好,直到我决定用@Serializable 来注释我的数据类。在这一点上,我了解到注解支持改变类的构造函数。

经过几次记录和观察,我发现原来的构造函数是列表中的最后一个,所以,我们把代码改为。

val constructor = dataClass.constructors.last()

就这样,我们的表单构建器完成了。

一些额外的功能,更多的验证器,以及一个更好的提供用户数据的方法。

例子

为了使用这个库,你可以先在你的ViewModel中保存表单状态。

val formState = FormState(
    fields = listOf(
        TextFieldState(
            name = "email",
            transform = { it.trim().lowercase() },
            validators = listOf(Validators.Email()),
        ),
        TextFieldState(
            name = "password",
            validators = listOf(Validators.Required())
        ),
    )
)

在上面的例子中,我们通过删除任何尾部的空格并将其改为小写字母来转换我们的电子邮件地址。

在你的组件中,你可以访问和更新表单字段的状态,如下图所示。

val formState = remember { viewmodel.formState }

val emailState = formState.getState("email")
val passwordState = formState.getState("password")

OutlinedTextField(
    value = emailState.text,
    isError = emailState.hasError,
    label = { Text("Email address") },
    onValueChange = { emailState.change(it) }
)
if (emailState.hasError) Text(emailState.errorMessage, color = Color.Red)

Spacer(modifier = Modifier.height(20.dp))

OutlinedTextField(
    value = passwordState.text,
    isError = passwordState.hasError,
    label = { Text("Password") },
    onValueChange = { passwordState.change(it) }
)
if (passwordState.hasError) Text(passwordState.errorMessage, color = Color.Red)

我们可以访问错误和错误信息,所以我们可以相应地显示它们。你可以在ViewModel中验证整个表单,或者你可以验证个别字段。

data class Credentials(val email: String, val password: String)

if (formState.validate()) {
    val data = formState.getData(Credentials::class)
    Log.d("Data", "submit: data from the form $data")
}

这就是如何使用表单生成器库的一个简单例子。

总结

有了表单生成器库,你可以轻松地执行各种表单操作,同时保持代码简洁。

它也是可定制的,并且有新的功能,如转换getData函数。

库中还有更多的改进(这次没有剧透😆),所以请密切关注repo和更新日志。