在StateFlow中处理表单状态的常见方式。

huangapple go评论60阅读模式
英文:

Common way to handle form state in StateFlow in Android

问题

I am trying to implement a form screen with many EditTexts, Switches, etc. I have a fragment with viewBinding and viewModel that holds a StateFlow "formData". Every field in UI has its own field in that formData.

I created listeners for the fields that are updating the StateFlow, and a collector that updates those fields when data changes.

Minimal example (pseudo Kotlin):

// ViewModel
data class FormData(
 val editTextA: String = "default 1",
 val editTextB: String = "default 2"
)

private val _formData = MutableStateFlow(FormData())
val formData = _formData.asStateFlow()

// Fragment
setCollectors(){
 createMedicineViewModel.formData.launchAndCollectIn(viewLifecycleOwner) {
   binding.edtA.text = it.editTextA
   binding.edtB.text = it.editTextB
 }
}

setListeners(){
  binding.edtA.addTextChangedListener(AfterTextChangedWatcher { s ->
    viewModel.updateFormData(editTextA = s)
  })
  binding.edtA.addTextChangedListener(AfterTextChangedWatcher { s ->
    viewModel.updateFormData(editTextB = s)
  })
}

My problem is that I am getting cyclic renders because if I enter some text into, for example, editText, it gets updated in stateflow, and the collector tries to update the EditText again and so on.

Is there some general (common) way to approach this problem (without using databinding)?

I need to keep the formData in StateFlow because I want to be able to load initial data into StateFlow at init. I also want all logic in the viewModel rather than in the fragment. And last but not least, I do not want to handle state saving of the fields while changing orientation, adding the fragment to the backstack, etc. I want to get the latest data from the viewModel when the fragment is loaded again.

英文:

I am trying to implement an form screen with many EditTexts, Switches, etc. I have a fragment with viewBinding and viewModel that holds a StateFlow "formData". Every field in UI have its own field in those formData.

I created listeners for the fields, that are updating the StateFlow and collector that updates those fields when data changed.

Minimal example (pseudo Kotlin):

// ViewModel
data class FormData(
 val editTextA: String = "default 1"
 val editTextB: String = "default 2"
)

private val _formData = MutableStateFlow(FormData())
val formData = _formData.asStateFlow()


// Fragment
setCollectors(){
 createMedicineViewModel.formData.launchAndCollectIn(viewLifecycleOwner) {
   binding.edtA.text = it.editTextA
   binding.edtB.text = it.editTextB
 }
}

setListeners(){
  binding.edtA.addTextChangedListener(AfterTextChangedWatcher { s ->
    viewModel.updateFormData(editTextA = s)
  })
  binding.edtA.addTextChangedListener(AfterTextChangedWatcher { s ->
    viewModel.updateFormData(editTextB = s)
  })
}

My problem is that I am getting cyclic renders, because if I enter some text into e.g. editText, it gets updated in stateflow and collector tries to update the EditText again and so on.

Is there some general (common) way to approach this problem (without using databinding)?

I need to keep the formData in StateFlow because I want to be able to load initial data into StateFlow at init. I also want all logic in viewModel rather than in fragment. And last but not least, I do not want to handle state saving of the fields while changing orientation, adding fragment to backstack, etc. I want to get the latest data from viewModel when fragment is loaded again.

答案1

得分: 1

你提到不想处理字段的状态保存和恢复,但Android已经自动处理了UI表单字段(如EditText和CheckBox)的状态保存和恢复,只要视图使用与之前相同的ID。

我猜想您想要将逻辑移到ViewModel的部分是筛选输入文本吗?大多数UI组件如果设置为与它们已经显示的相同值,它们将不会重新渲染,EditText是例外。

我认为EditText不避免重新渲染的原因是它太复杂,需要太多的假设来确定无操作更改,因为它支持不同类型的CharSequences。

如果您不使用Spannables来在EditText文本中使用各种格式,您可以创建一个扩展函数或属性来更新TextViews(EditText的父级),如果CharSequence的内容匹配,就不会重新渲染。

英文:

You mention not wanting to handle state saving and restoring of fields, but Android already does this automatically for UI form fields like EditText and CheckBox, provided the views use the same IDs as before.

I suppose the logic you want to move to the ViewModel is filtering of text typed? Most of the UI components will not rerender if you set them to the same value they are already showing. EditText is the exception.

I think the reason EditText doesn't avoid the re-render is that it is simply too complex and would require too many assumptions to be able to determine no-op changes, since it supports different types of CharSequences.

If you aren't using Spannables to use various formatting within the text of the EditText, you could create an extension function or property for updating TextViews (the parent of EditText) without rerendering if the content of the CharSequence matches.

/** When set, only sets new text if the Chars in the sequence have changed. */
var TextView.chars: CharSequence
    get() = text
    set(value) {
        val toSet = (value as? String) ?: value.toString()
        if (text.toString() != toSet) { // toString() is fast, no string copy, if you check source
            setText(value)
        }
    }

答案2

得分: 0

抱歉,我会翻译代码之外的部分。以下是翻译好的文本:

"Sorry my English."

"If you want MVI style with View You can use this example"

Solution
My usage

val renderer = diff {
diff(
get = State::textValue,
compare = { old, new ->
old == new && new == binding.editText.text.toString()
}
) { newValue ->
binding.editText.text = newValue
}
}

英文:

Sorry my English.

If you want MVI style with View You can use this example

Solution
My usage

   val renderer = diff<State> {
        diff(
            get = State::textValue,
            compare = { old, new ->
                old == new && new == binding.editText.text.toString()
            }
        ) { newValue ->
            binding.editText.text = newValue
        }
    }

答案3

得分: 0

以下是翻译好的内容:

问题在于你设置了这样一个循环,其中将状态推送到用户界面(例如观察LiveData中的更改)会导致事件被推送到数据层(TextWatcherEditText更改时触发)。实际上,这些事件应该是对用户所做的事情的响应 - 显示更新的用户界面状态不应该触发任何非用户界面的操作,它只应该是对新状态的响应并显示它,然后完成任务。

因此,从根本上说,你的TextWatcher是一个问题,因为它无法区分是由用户交互引起的更改(你希望将其推送到数据层)还是仅仅是一个简单的显示更新(不应该触发任何响应行为)。并且由于你设置的方式,即使用户交互也会最终导致这种情况发生:

用户编辑 -> 观察者 -> VM更新 -> 观察者更新EditText -> 观察者 -> VM更新...

这使得实现避免循环TextWatcher行为的通常方法变得棘手,其中afterTextChanged在进行第二次更改之前设置一个标志或其他内容 - 这样,当它再次以响应自己的更改而被调用时,它可以通过检查标志来避免再次执行它(然后为下一次重置它)。但在这种情况下并不是真正可行的,因为它不是导致额外更新的原因 - 外部更改正在发生,而它不知道是什么原因。

所以,你可能需要采取一种不同的方法来打破这种循环。一种方法是如果EditText的内容没有更改,就不要更新它:

createMedicineViewModel.formData.launchAndCollectIn(viewLifecycleOwner) {
    if (binding.edtA.text != it.editTextA) {
        binding.edtA.text = it.editTextA
    }
}

你可以为此编写一个函数,使其变得不那么笨重:

fun TextView.setTextIfChanged(newText: String) {
    if (text != newText) text = newText
}

binding.edtA.setTextIfChanged(it.editTextA)

现在,如果内容发生了更改,它将推送一个更新到VM,然后VM将推送一个包含相同内容的更新给观察者。因为现在没有实际更改,所以你跳过了它,循环就被打破了。

可能会觉得有点笨拙,不得不将事物连接起来以处理特定的行为。一个更通用的方法是在StateFlow上调用distinctUntilChanged()

private val _formData = MutableStateFlow(FormData()).distinctUntilChanged()

现在,一旦在LiveData上设置了一个等于当前值的FormData,它将跳过向观察者发出更新,从而打破了循环。因为在你的示例中,你使用了一个使用String数据类,你可以免费进行相等性检查,最终事物会稳定下来,以至于你的TextWatcher推送了一个不会改变任何内容的“更新”。

但我说过,“最终事物会稳定下来”,这是因为,根据你的设置方式,你可能会推送一个更新多个EditText的状态,每个都将触发它们自己的更新循环。因此,你必须注意这一点,并决定是否允许单个更新在循环中多次循环,或者是否要避免这种情况。

另一种方法是采取(这可能是你的情况最简单的方法)某种通用的“我正在更新”的标志。任何更新用户界面的东西(例如观察者、初始设置代码)都可以在进行更改之前设置某个updating标志,并在完成后取消设置。

你的TextWatcher可以检查该标志,并忽略在标志设置时发生的任何更改。这样,“显示状态更改” 不会向VM推送任何内容,而用户更改只会在标志未设置时发生,从而触发更新事件的推送。

困难的部分在于确保你在所有需要的情况下设置了该标志,并在最后取消设置它 - 可能值得创建一个displayState(formData: FormData)函数,所有东西(包括设置代码)都使用它来在用户界面中显示特定状态,然后所有这些都在一个地方处理。

英文:

The problem is you've set up that cycle, where pushing state to the UI (e.g. observing changes in the LiveData) causes an event to be pushed to the data layer (the TextWatcher triggering when the EditText is changed).

Really those events should be in response to something the user does - displaying an updated UI state shouldn't trigger any non-UI actions, it should just be about reacting to new state and displaying it, job done.

So fundamentally, your TextWatcher is a problem, because it doesn't know the difference between a change caused by user interaction (which you want to push to the data layer) and a simple display update (which shouldn't trigger any reactive behaviour). And because of the way you have it set up, even a user interaction will cause this eventually:

user edit -> watcher -> VM update -> observer updates EditText -> watcher -> VM update...

This makes it tricky to implement the usual approaches to avoiding cyclic TextWatcher behaviour, where you make afterTextChanged set a flag or something before it makes a second change - that way, when it gets called again in response to its own change, it can avoid doing it again by checking the flag (and then resetting it for next time). But that's not really possible here, because it's not causing the extra updates itself - an external change is happening, and it doesn't know what's causing it.


So you'll probably want a different way of breaking that cycle. One way is to just not update the EditText if its contents haven't changed:

createMedicineViewModel.formData.launchAndCollectIn(viewLifecycleOwner) {
    if (binding.edtA.text != it.editTextA) {
        binding.edtA.text = it.editTextA
    }
}

You could always write a function for that, to make it a little less unwieldy.

fun TextView.setTextIfChanged(newText: String) {
    if (text != newText) text = newText
}

binding.edtA.setTextIfChanged(it.editTextA)

And now, if the contents are changed, it'll push an update to the VM, which pushes an update to observers containing those same contents. Because there's no actual change to make now, you skip it and the loop is broken.


That might seem a little clumsy though, having to wire things up to take care of that specific behaviour. A more general approach is calling distinctUntilChanged() on the StateFlow:

private val _formData = MutableStateFlow(FormData()).distinctUntilChanged()

Now, as soon as you set a FormData on that LiveData which is equal to the current value, it'll skip emitting an update to observers, breaking the cycle. Because in your example you have a data class using Strings, you get that equality check for free, and eventually things will settle to the point where your TextWatcher pushes an "update" that doesn't change anything.

But I said "eventually things will settle" because, depending on how you have things set up, it's possible you push a state that updates multiple EditTexts, and each one of those will trigger their own cycle of pushing updates. So you have to be aware of that, and decide whether a single update going round and round the loop a few times is ok, or if you want to avoid that.


The other approach you can take (and this is probably easiest for your situation) is some kind of general "I'm updating" flag. Anything that's updating the UI (e.g. observers, initial setup code) can set some updating flag before making the changes, and unset it when finished.

Your TextWatchers can check that flag, and ignore any changes that happen while it's set. That way, your "displaying state" changes don't push anything to the VM, and user changes will only happen while the flag is unset, causing update events to be pushed.

The tricky part there is ensuring you set that flag in all the situations you need to, and unset it at the end - might be worth creating a displayState(formData: FormData) function that everything (including setup code) uses to display a particular state in the UI, then it's all handled in one place.

huangapple
  • 本文由 发表于 2023年5月10日 16:31:45
  • 转载请务必保留本文链接:https://go.coder-hub.com/76216391.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定