Kotlin onClick 事件和架构最佳实践

Kotlin onClick events and architecture best practices

我刚刚开始我的第一个 Kotlin 应用程序,我的目标是一路学习该语言的最佳实践(当然最终会得到一个可用的应用程序 :))。我遇到了一个困扰我一段时间的问题:onClick 事件的流程应该是什么?

起初,我使用了在我的片段中设置 onClick 的直截了当的方式,如下所示:binding.image_profile_picture.setOnClickListener { onChangePhoto() }

我查看了一些 kotlin 培训代码实验室并相应地更改了我的代码。我理解的一件事是建议在 ViewModel 而不是片段中处理事件 (codelab#3),所以现在我的 onClicks 在布局 xml 中设置,如下所示:android:onClick="@{() -> profileViewModel.onChangePhoto()}" .

问题是我的所有事件实际上都需要一个上下文,因为它们以某种对话(如图像选择器)开始。我发现 this article recommending to solve this problem using an event wrapper. I read the discussion on it's implementation's Github page and decided to give it a shot (also I'm not sure I like this unnecessary ViewModel-Fragment ping-pong). I implemented aminography's OneTimeEvent 现在我的 ViewModel 看起来像这样:

// One time event for the fragment to listen to
    private val _event = MutableLiveData<OneTimeEvent<EventType<Nothing>>>()
    val event: LiveData<OneTimeEvent<EventType<Nothing>>> = _event

    // Types of supported events
    sealed class EventType<in T>(val func: (T) -> Task<Void>?) {
        class ShowMenuEvent(func: (Context) -> Task<Void>?, val view: View) : EventType<Context>(func)
        class ChangePhotoEvent(func: (Uri) -> Task<Void>?) : EventType<Uri>(func)
        class EditNameEvent(func: (String) -> Task<Void>?) : EventType<String>(func)
        ...
    }


    fun onShowMenu(view: View) {
        _event.value = OneTimeEvent(EventType.ShowMenuEvent(Authentication::signOut, view))
    }

    fun onChangePhoto() {
        _event.value = OneTimeEvent(EventType.ChangePhotoEvent(Authentication::updatePhotoUrl))
    }

    fun onEditName() {
        _event.value = OneTimeEvent(EventType.EditNameEvent(Authentication::updateDisplayName))
    }

    ...

我的 Fragment 的 onCreateView 看起来像这样:

        ...

        // Observe if an event was thrown
        viewModel.event.observe(
            viewLifecycleOwner, {
                it.consume { event ->
                    when (event) {
                        is ProfileViewModel.EventType.ShowMenuEvent ->
                            showMenu(event.func, event.view)
                        is ProfileViewModel.EventType.EditEmailEvent ->
                            showEditEmailDialog(event.func)
                        is ProfileViewModel.EventType.ChangePhotoEvent ->
                            showImagePicker(event.func)
                        ...
                    }
                }
            }
        )

        return binding.root

如果我们坚持以 showImagePicker 为例,它看起来像这样:

    private fun showImagePicker(func: (Uri) -> Task<Void>?) {
        onPickedFunc = func
        val intent =
            Intent(
                Intent.ACTION_PICK,
                android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI
            )
        startActivityForResult(intent, RC_PICK_IMAGE)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == RC_PICK_IMAGE && resultCode == Activity.RESULT_OK) {
            data?.data?.let { onPickedFunc(it)?.withProgressBar(progress_bar) }
        }
    }

我像这样传递 Authentication::updatePhotoUrl 函数而不是仅仅从 Fragment 调用它的原因是我想坚持使用 MVVM guidelines。对 FirebaseAuth API 的所有调用对我来说都像是“存储库级别”,因此我在 Authentication class 中处理它们,我不希望我的片段直接与之交互。但这变得很有趣,因为我最终将这些函数存储为我的 Fragment 的成员——这感觉不对。必须有比这更简洁的解决方案。请帮我找到它:)

谢谢! 奥马尔

首先,使用私有支持字段可能会很烦人。我建议改用 interface

interface MyViewModel {
    val event: LiveData<OneTimeEvent<EventType<Nothing>>>
}

class MyViewModelImpl: MyViewModel {
    override val event = MutableLiveData<OneTimeEvent<EventType<Nothing>>>()
}

其次,如果您只需要一个上下文,那么您可以将所有逻辑移至 ViewModel 并使用如下内容:

interface FragmentEvent {
    fun invoke(fragment: Fragment)
}

class ShowPickerEvent: FragmentEvent {
    override fun invoke(fragment: Fragment) {
        val intent =
            Intent(
                Intent.ACTION_PICK,
                MediaStore.Images.Media.INTERNAL_CONTENT_URI
            )
        fragment.startActivityForResult(intent, RC_PICK_IMAGE)
    }
}

总的来说,我认为您的 MVVM 方法是正确的,所有逻辑都应该由 ViewModel 处理,而 View 应该将用户的请求传递给 ViewModel 并显示 UI 由于这些操作(或数据中的其他一些更改)而可能发生的更改。

确实应该在存储库级别处理身份验证,以便可以从多个位置执行身份验证,更重要的是不要将身份验证提供程序与应用程序逻辑的其余部分结合起来。如果您将来决定更改身份验证提供程序,它应该对您的应用程序产生尽可能小的影响。

also I'm not sure I like this unnecessary ViewModel-Fragment ping-pong

这是每个人对它的正常反应。好处是 View 与 ViewModel 解耦,这使得 View 只是一个处理用户输入并向用户显示结果的哑 class,而 ViewModel 是处理逻辑的智能 class。这使得逻辑很容易 unit-test,因为您不需要整个应用程序 运行(Fragment 需要)。视图相当 boiler-place 繁重,因此最好将逻辑放在另一个地方,这样更容易阅读,因为它没有被大量视图代码包围。

My events need a context [...]

事件只是发送给视图以执行特定操作的信号。您将不会在 ViewModel 中处理 Context,相反,View 可以在收到信号后使用其 Context 执行所需的操作。

事件可以包含数据,但不能包含 Android-specific 数据。例如,如果您想通过事件传递 Drawable(或任何其他资源),您只需传递其资源 ID,View 可以使用其上下文将其解析为 Drawable。

例如ViewModel 发出 ChangePhotoEvent,

用户点击并导致显示对话框的整个流程如下所示。

View 处理点击,并将其告知 ViewModel:

android:onClick="@{() -> profileViewModel.onChangePhoto()}"

ViewModel 现在确定应该发生什么,它可以向 Repositories 询问数据、检查一些条件等。在这种情况下,它只想向用户显示一个照片选择器,因此它向 View 发送一个事件, 只需要足够的信息就可以知道对它的要求:

OneTimeEvent 的实施方式比实际情况更糟。让我为您简化一下:

public interface OneShotEvent

abstract class BaseViewModel : ViewModel() {
    private val _events = MutableLiveData<OneShotEvent>()
    val events: LiveData<OneShotEvent> = _events

    fun postEvent(event: OneShotEvent) {
        _events.postValue(event)
    }
}
class MyViewModel: BaseViewModel() {
    
    data class ChangePhotoEvent(val updatePhotoUrl: String) : OneShotEvent

    // for events without parameters, define them as objects
    // object ParameterlessEvent : OneShotEvent

    fun onChangePhoto() {
        postEvent(ChangePhotoEvent(Authentication::updatePhotoUrl))
    }
}
class MyFragment : Fragment() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel.events.observe(this) { event ->
            when (event) {
                is ChangePhotoEvent -> {
                    // display the dialog, here you can use Context to do so
                    showImagePicker(event.updatePhotoUrl)
                }
                // omit 'is' if event is an object
                // ParameterlessEvent -> {}
            }
        }
    }
}

我不明白你用 Authentication::updatePhotoUrl 做什么。在 onActivityResult 中,您应该再次调用 ViewModel,收到的结果是:viewModel.onPhotoChanged(data?.data),并且 ViewModel 应该调用 Authentication.updatePhotoUrl()。所有的逻辑都发生在ViewModel中,View只是user-events.

的一个中继

如果您需要从 API 检索任何数据,ViewModel 必须在后台线程上执行此操作,最好使用协程。然后你可以将数据作为事件的参数传递。


你可以检查一个框架,比如 RainbowCake, which provides base classes and helpers for this kind of stuff. I've been using it for a while, you can see a full project where I'm using it here