【问题标题】:Kotlin onClick events and architecture best practicesKotlin onClick 事件和架构最佳实践
【发布时间】:2021-01-25 22:21:13
【问题描述】:

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

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

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

问题在于我的所有事件实际上都需要一个上下文,因为它们以某种对话框(如图像选择器)开始。我发现this article 建议使用事件包装器来解决这个问题。我阅读了关于它的实现Github page 的讨论并决定试一试(我也不确定我是否喜欢这种不必要的 ViewModel-Fragment 乒乓球)。我实现了 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 类中处理它们,我不希望我的 Fragments 直接与之交互。但这变得很有趣,因为我最终将这些函数存储为我的 Fragment 的成员——这感觉不对。必须有比这更简洁的解决方案。请帮我找到它:)

谢谢! 奥马尔

【问题讨论】:

    标签: android kotlin android-fragments mvvm onclick


    【解决方案1】:

    我也不确定我是否喜欢这种不必要的 ViewModel-Fragment 乒乓球

    这是每个人都有的正常反应。好处是 View 与 ViewModel 解耦,这使得 View 只是一个处理用户输入并向用户显示结果的哑类,而 ViewModel 是处理逻辑的智能类。这使得逻辑易于单元测试,因为您不需要运行整个应用程序(片段需要)。视图非常繁重,因此将逻辑保留在另一个地方非常好,因为它不会被大量的视图代码包围,因此更容易阅读。

    我的活动需要上下文 [...]

    事件只是给 View 执行特定操作的信号。您将不会在 ViewModel 中处理 Context,相反,一旦 View 收到信号,它就可以使用其 Context 做它需要做的事情。

    事件可以包含数据,但不能包含特定于 Android 的数据。例如,如果您想通过事件传递 Drawable(或任何其他资源),则只需传递其资源 ID,View 可以使用其 Context 将其解析为 Drawable。

    例如ViewModel 发出一个ChangePhotoEvent

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

    View 处理点击,并告诉 ViewModel:

    android:onClick="@{() -&gt; 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 只是用户事件的中继。

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


    你可以查看一个框架,比如RainbowCake,它为这类东西提供了基类和助手。我已经使用了一段时间了,你可以看到一个完整的项目,我正在使用它here

    【讨论】:

    • 感谢您的详细解答! Authentication::updatePhotoUrl 是一个函数,而不是一个字符串,在 onActivityResult 中我只是用挑选的图像作为参数来调用它(withProgressBar 只是我对 Task&lt;T&gt; 的扩展,用于在任务的持续时间)。如果我按照您的建议进行操作,我将为每个事件提供两个单行方法的 ViewModel:一个设置 event LiveData(我已经拥有),一个只调用 updatePhotoUrl。这使得整个班级只是一个挡路的空心管道 - 这是这里的最佳做法吗?
    • ViewModel 也会处理进度更新,所以它不会那么浅。它将处理Task&lt;T&gt;,从中读取进度,并将其作为LiveData 发布到视图。它还会处理 Task 抛出的任何异常,并使 View 向用户显示错误。
    • ViewModel 是 UI 和应用程序其他组件之间的中介。例如,它会调用存储库来检索数据。如果由于用户离线而无法访问 API,存储库可能会引发异常,因此 ViewModel 将负责捕获该异常并反过来让 View 知道发生了错误等。
    • @OmerLevy 如果你喜欢这个答案,也许你可以接受它:c
    • 该任务实际上是一个 Firabase 任务(从案例中的FirebaseUser.updateProfile 调用返回),所以我没有读取它的进度,只是将其传回,这样我就可以在完成时关闭我的不确定进度条.我看不到 ViewModel 可以具有的任何逻辑,因为我想保持与提供者的交互分离,并且所有其他过程都与 UI 相关。也许只是在我的特定情况下,ViewModel 变得很浅......所以你建议为每个事件使用这 2 个单行方法,以将发送到 Fragment 的信息量保持在最低限度(实际上它可以是 Void )?
    【解决方案2】:

    首先,使用私有支持字段可能非常烦人。我建议改用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 更改由于这些操作(或其他一些数据更改)。

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

    【讨论】:

    • context 无论如何都会将您耦合到一个通用片段,而不是特定片段(无论如何,新应用程序中的每个视图都应该是一个片段)。该片段可以替换为context,但我认为fragment 足够通用。
    • 你不能也不应该回避上下文。使用依赖注入传递它是一种更好的做法,但视图模型可能会使用上下文。如果您想启动服务作为对用户操作的响应,您会怎么做?顺便说一句,传递照片 URL 就像传递上下文一样“特定于 Android”。
    • 很抱歉打扰您,但 ViewModel Android 依赖项 (androidx.lifecycleViewModel),LiveData 也是。如果你想要独立于平台的实现,你应该重新实现两者。
    • 顺便说一句 @Omer Levy 如果您需要澄清,欢迎私下与我联系。
    • 感谢您的回答@SirCodesalot!我完全同意与提供者的解耦,这就是为什么我将对它的所有引用包含在一个单独的类中。关于ShowPickerEvent - 我认为为我所拥有的每项活动开设一堂课有点矫枉过正。此外,我将从哪里获得 Fragment 参考?最初的调用来自 xml,我认为它不应该知道 Fragment。我错过了什么?
    猜你喜欢
    • 1970-01-01
    • 2013-01-04
    • 2015-07-13
    • 2020-06-12
    • 2010-11-17
    • 1970-01-01
    • 2017-04-25
    • 1970-01-01
    • 2020-07-13
    相关资源
    最近更新 更多