如何在Android MVVM的ViewModel中建模父子关系?

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

How to model parent-child relationship in Android MVVM VMs?

问题

我正在开发一个Android钢琴“测验”应用程序 - 用户点击钢琴键,然后点击黄色的“检查”按钮以提交答案进行评估,并在钢琴上看到正确答案的绘制。主要的 QuizActivity 拥有以下布局:

屏幕的上半部分包含了一些控件(文本、提交按钮等)。
屏幕的下半部分被自定义的 PianoView 组件占据,用于绘制钢琴键盘。

根据 MVVM 原则,PianoView 应该有自己的 PianoViewModel,该视图模型在 KeysStateRepository 中存储其状态(即当前按下的键、高亮的键等)。
封装的 QuizActivity 也应该有一个 QuizActivityViewModel,该视图模型处理各种控件(提交答案、跳过问题等)。
QuizActivityViewModel 需要能够从 PianoView(或者更准确地说是从其 KeysStateRepository)中查询所选的键,将它们提交给 领域层 进行评估,然后将结果发送回 PianoView 进行可视化显示。

换句话说,QuizActivityViewModel 应该拥有/是 PianoViewViewModel 的父级,以便促进通信和数据共享。

我该如何建立这种父子关系,以在视图模型之间进行通信?

据我所知,一个 ViewModel 不能依赖于另一个 ViewModel(在另一个 Viewmodel 中,我应该传递什么作为 ViewModelStoreOwner 来获取一个 ViewModel?)。至少我认为使用 Dagger-Hilt 也无法实现这一点。

以下是三种解决此问题的方法,但它们都不可用:

1 - 官方共享数据在视图之间的方式

Android 开发文档 建议 使用一个共享ViewModel 以促进在两个片段/视图之间共享数据。然而,这不适合我的用例。PianoView(或其视图模型)应该是其状态的唯一所有者,具有范围限定在其视图模型的 Repository。否则,PianoView 组件将无法重用。例如,在另一个 Activity 中,我想要显示两个独立的 PianoView 实例:

重新使用来自测验活动的共享视图模型显然是错误的,因为它包含不相关的方法和逻辑(即提交测验答案),并且不适合双键盘场景。

2 - 应用程序范围的存储库

Reddit 上提出了类似的问题,并提出了使用存储库的共享实例的解决方案。然而,使用 @SingletonKeyStateRepository 又一次会阻止两个独立的键盘显示不同的数据。

3(编辑)- 通过事件总线复制的两个重复存储库

理论上,我可以创建两个独立的 ViewModel 和 两个 KeyStateRepository 实例。这些 ViewModel 将订阅事件总线。每当一个 ViewModel 在其存储库上调用一个可变操作时,它还会触发一个事件,操作将通过订阅了相同事件总线的另一个 ViewModel 复制。

然而,这感觉像是一个脆弱且复杂的黑客。我希望有一个简单的与 MVVM 兼容的解决方案。我不能相信在 MVVM 中,两个 UI 组件之间的简单父子关系是无法实现的。

英文:

I'm working on an Android piano "quiz" app - users tap on the piano keys and then click the yellow "check" button to submit the answer for evaluation and see the correct answer drawn on the piano. The main QuizActivity has this layout:
如何在Android MVVM的ViewModel中建模父子关系?

The upper part of the screen hosts a couple of controls (Text, submit buttons, etc.).
The lower part of the screen is occupied by a custom PianoView component, that handles drawing of the piano keyboard.

According to the MVVM principles, the PianoView should have its own PianoViewModel, that stores its state (i.e. currently pressed keys, highlighted keys, etc...) in a KeysStateRepository.
The enclosing QuizActivity should also have a QuizActivityViewModel, that handles the various controls (submitting an answer, skipping a question...).
The QuizActivityViewModel needs to be able to query the selected keys from the PianoView (or rather from its KeysStateRepository), submit them to the Domain layer for evaluation and then send the results back to the PianoView for visualization.

In other words, the QuizActivity's ViewModel should own/be a parent of the PianoView's ViewModel to facilitate communication and data sharing.

How can I model this parent-child relationship to communicate between the ViewModels?

AFAIK a ViewModel cannot depend on another ViewModel (What would I pass as the ViewModelStoreOwner to obtain a ViewModel in another Viewmodel?). I don't think it's possible to achieve with Dagger-Hilt at least.

Three solutions to work around this problem came to mind, all of them unusable:

1 - The official way of sharing data between Views

The Android dev docs recommend using a shared ViewModel to facilitate sharing of data between two Fragments / Views. However, this does not fit my use-case. The PianoView (or its ViewModel) should be the sole owner of its state with a Repository scoped to its ViewModel. Otherwise, the PianoView component would not be reusable. Consider for example another Activity, where I'd like to have two independent PianoView instances visible:

如何在Android MVVM的ViewModel中建模父子关系?

Reusing a Shared ViewModel from the quiz activity would be obviously wrong, because it contains irrelevant methods and logic (i.e. submitting quiz answers) and would not fit the two-keyboard scenario.

2 - Application-scoped repository

A similar problem was tackled on Reddit with a proposed solution of using a shared instance of the repository. However, using a @Singleton KeyStateRepository would once again prevent the two independent keyboards to display different data.

3(EDIT) - 2 duplicate repositories replicated by an Event Bus

I could in theory create 2 independent ViewModels and 2 KeyStateRepository instances. The ViewModels would subscribe to an event bus. Each time a ViewModel invokes a mutable operation on its repository, it would also fire an event and the operation would get replicated via the other ViewModel subscribed to the same event bus.

However, this feels like a fragile & complicated hack. I'd like to have a simple MVVM-compatible solution. I can't believe a simple parent-child relationship for two UI components is something unattainable in MVVM.

答案1

得分: 2

我认为你从Pavlo那里得到了一个不错的答案,我只是用不同的话来澄清他的意思。

  1. KeyStateRepository 是钢琴键状态的存储。没有什么阻止你使其同时支持 N 个钢琴,这将解决在屏幕上有 NNN 个钢琴的情况,每个钢琴都有不同的按键按下。

  2. PianoView 应该包含在一个 Fragment 中,那应该是你的“单元”。为什么?因为你希望有一个 ViewModel 来处理视图传入/传出的状态和事件。而 Fragment 是 Android 提供的用于此目的的构件。把它想象成一个烦人的包袱,你需要它。Android 开发者过去常把这些东西称为“策略代理”,因为你要将一些无法脱离“框架”(即 Android 框架)的东西委托给它们(一个 Fragment/Activity)。

  3. 在这种情况下,你有一个独立处理的 ViewModel/状态的 Activity。这个 ViewModel 处理什么样的状态/事件?那些不在钢琴片段/视图中的东西。例如,如果你想处理返回导航或顶部的“录制”按钮,这是活动的领域。在“钢琴视图/片段”内部发生的事情不是这个活动的问题。

  4. 现在,将包含实际 PianoView 的片段设计成可以包含“多个”或仅一个。如果你选择多个,那么 PianoContainerFragment 将被设计为一个 ViewModel,用于处理多个 PianoView(因此每个视图都将有一个“名称/键”),并且 KeyStateRepo 将能够处理你提供的任何 Piano View 的“CRUD”操作。ViewModel 将位于中间,为不同的“订阅”视图分派事件。

  5. 如果你选择“一个片段包含一个钢琴视图”,那么这是类似的架构,但现在在一个“活动”中处理多个“片段”是活动(及其 ViewModel)的责任。但请记住,钢琴视图(通过一个共享或非共享的 Fragment)与可以在钢琴视图之间共享的 ViewModel 交流,后者与一个公共的 KeyState Repo 交流。活动协调视图和其他 Android 事务(导航等),但视图彼此独立运行,甚至相互独立。

  6. 我认为你实际上不需要一个共享的 ViewModel,事实上,除非确实需要,否则我不会这样做,你将事物分开得越多,就越不容易“违反”其中一个花哨的模式……但如果你选择将 PianoViewModel 用作所有视图共享的,那是完全可以接受的,你需要在其中包含钢琴的“名称”以区分事件属于谁。

换句话说(为了简化,以下用一个 PianoViewModel 进行演示):

// 一个 QuizActivityViewModel,多个片段:

Activity -> PianoFragment (PianoView) |
                                     | <-> PianoViewModel <-> KeyRepo
            PianoFragment (PianoView) |                       /
            -> QuizActivityViewModel  <----------------------/

这里的 QuizActivity 创建了 N 个片段(可能在列表中)。这些片段在内部初始化其钢琴视图并连接到一个 PianoViewModel(可以像上面的图表中共享),或者每个片段都可以有自己的。它们都与同一个 Repo 交流。这个 Repo 是关于每个“钢琴”的“单一真相源”。哪些键被按下,以及你能想到的其他任何事情(包括一个使其唯一的名称/键)。
当 QuizActivity 需要评估这些的状态时,它会询问(通过自己的 ViewModel) NN 个钢琴的状态。

或者

// 1 个活动,1 个片段,N 个视图。
Activity -> PianoFragment (PianoView) |
                          (PianoView) | <-> PianoViewModel <-> KeyRepo
         -> QuizActivityViewModel      <---------------------------/

通过这些,创建钢琴的 QuizActivity(同时也创建了钢琴),还知道将要/正在显示的钢琴的键。它可以与它的 ViewModel 交流,后者与相同的 KeysRepo 交流(你只有一个这样的 Repo,这很好)。因此,它仍然可以处理“导航”按钮,并且可以询问(通过其 QuizActVM)键的当前状态(对于所有涉及的钢琴)。当在钢琴视图中触发钢琴键事件时,PianoViewModel 将接收事件(哪个键被触摸,在哪个钢琴上);KeyStateRepo 将记录此事件,并可能使用来自钢琴的事件更新 flow {}...

Flow 将在一个 sealed class 中表示,它将包含足够的信息,供 QuizActivity + VM 使用(可能进行实时验证),以及供 PianoViewModel 更新状态并将新状态推送到 PianoFragment(这将更新其视图的状态)。

这对于任何方法都是常见的。希望这澄清了顺序。

这对你有意义吗?

英文:

I think you got a decent answer from Pavlo up there, I'll just clarify what he meant with other words.

  1. KeyStateRepository is a storage for the state of piano keys. There's nothing stopping you from making it to support N number of Pianos at the same time, this would solve the scenario where you have NNN Pianos on Screen, each with different keys pressed.

  2. The PianoView should be contained in a Fragment, that should be your "unit". Why? Because you want a ViewModel to handle the state and events coming to/from the view. And a Fragment is the Android artifact provided for that regard. Think of it as an annoying piece of baggage you need. Android Devs used to call these things "Policy Delegates" because you delegate to these (a Fragment/Activity) some things you cannot do without "the framework" (the Android Framework, that is).

  3. With this in mind, you have an Activity whose viewModel/State is handled independently. What State/Events do this viewModel handle? Things that are not in the PianoFragment/View(s). E.g. if you wanted to handle the back navigation, or a "record" button at the top, this is the activity's domain. What happens inside the "PianoView/Fragment" is not this activity's problem.

  4. Now the Fragment that will contain the actual PianoView can be designed to contain "more than one" or just one. If you go for more than one, then the PianoContainerFragment will be designed with a ViewModel designed to handle more than one PianoView (so each view will have a "name/key") and the KeyStateRepo will be able to handle the "CRUD" operations of any Piano View you throw at. The ViewModel will sit in between, dispatching events for different "subscribed" views.

  5. If you elect to go for "one fragment contains one piano view", then it's a similar architecture, but now handling the multiple "fragments" in one "activity" is now responsibility of the Activity (and its view model). But remember, the PianoViews (via a Fragment either shared or not) talk to a ViewModel that can be shared among piano views, that talks to a common KeyState Repo. The activity coordinates the views and other Android things (navigation, etc.) but the views operate independently, even of each other.

  6. You don't really need a shared viewModel I think, in fact, I wouldn't do it until really needed, the more you separate things, the less the chances of "violating" one of the fancy patterns... but if you elect to use the PianoViewModel as a shared among all views, that's perfectly acceptable, you're going to have to include the Piano "Name" to differentiate whose events are for whom.

In other words (showing with ONE PianoViewModel for ASCII Simplicity),

// One QuizActivityViewModel, Multiple Fragments:

Activity -&gt; PianoFragment (PianoView)| 
                                     | &lt;-&gt; PianoViewModel &lt;-&gt; KeyRepo
            PianoFragment (PianoView)|                       /
            -&gt; QuizActivityViewModel &lt;----------------------/

Here the QuizActivity creates N fragments (in a list maybe?). These fragments internally initialize their pianoView and connect to a PianoViewModel (can be shared like in the graph above) or each can have its own. They all talk to the same Repo. The repo is your "single source of truth about what each "piano". What keys are pressed, and anything else you can think of (including a name/key to make it unique).
When QuizActivity needs to evaluate the state of these, it will ask (via its own viewModel) for the state of NN pianos.

Or

// 1 Act. 1 Frag. N Views.
Activity -&gt; PianoFragment (PianoView)| 
                          (PianoView)| &lt;-&gt; PianoViewModel &lt;-&gt; KeyRepo
         -&gt; QuizActivityViewModel  &lt;---------------------------/

With these, the QuizActivity (which created the pianos to begin with as well), also knows the keys of the pianos that will/are displayed. It can talk to its viewModel that talks to the same KeysRepo (you only have one of these and that's fine). So it can still handle the "nav" buttons and it can ask (via its QuizActVM) what the current state of the Keys are (for all involved pianos). When a Piano key event is fired in a PianoView, the PianoViewModel will receive the event (what key was touched, in what piano); the KeyStateRepo will record this, and perhaps update a flow {} with the events coming from the pianos...

The Flow will be expressed in a sealed class which will contain enough information for both QuizActivity + VM (to perhaps perform a real-time validation), and to the PianoViewModel to update the state and push a new state to the PianoFragment (Which will update the state of its view(s)).

This is all common to either method. I hope this clarifies the sequence.

Does this make sense to you?

答案2

得分: 1

Edit

在多架构活动中,如果你不想让 PianoViews 拥有 ViewModels,也不希望你的 ActivityViewModel 知道它们 - 不要在它们上面使用 Dagger 注入,而是在 ActivityViewModel 内部创建 PianoViewModels,并在创建阶段为它们分配一些回调 - 这样你就能够访问它们,并且能够监听它们的事件、影响它们的行为,以及保存它们的状态,都可以在 ActivityViewModel 内部进行。这并不是一种罕见的方法,在某些情况下甚至是正确的方法。Dagger - 只是一个工具,不是用来随处使用的,而是在需要的地方使用。没有必要创建 PianoViewModels - 你可以将所有所需的内容都注入到 ActivityViewModel 中,并将所有必要的元素传递给 PianoViewModels 的构造函数。

另外,如果不想的话,你也不需要将你的 Views 包装成 Fragments。

Edit end

你正在基于有缺陷的架构方法做出错误的假设。

我很好奇为什么你需要 ActivityViewModel。View Model 只应该为具有某些视图的元素存在。当前的 Android 开发建议 Activity 不应具有视图表示,而只应作为其他视图的纯容器(单一活动原则)。根据你的架构,Activity 可能会处理显示加载状态(进度条)和一些错误,但再次强调,它不应包含其他视图处理的任何内容。因此,PianoView 应该是一个带有自己的 ViewModel 的 PianoFragment,通过领域层的交互器在数据层上处理对其存储库的访问。

共享的 View Model 在你需要一个,并且你正在使用单一活动原则配合多个片段的情况下会起作用。因为 Jetpack Navigation 原生支持共享的 View Model。在共享 View Model 的情况下,每个片段都会有自己的 View Model,以及一个用于通信的共享 View Model。每个 导航图 可能都会有一个单独的共享 View Model,仅用于其包含的片段。

此外,关于 KeyStateRepository - 你只需要其中一个(或使用 Dagger 的 @Scoped 创建多个副本 - 但我不推荐这样做)。唯一的改变应该是 - 为每个单独的 PianoView 添加一个额外的键 - 以在 KeyStateRepository 内部区分它们。为了轻松实现这一点,你可以使用 Room 或其他一些文件/内存/数据库缓存机制。

因此,你的应用的最初问题不是 ActivityViewModelPianoViewModel 的倒置依赖,而是应用的架构和其内部交互的缺陷。如果你想继续使用当前的架构 - 对于你的问题没有简单的答案,几乎每个选择的解决方案都不足以证明其使用的“干净”程度。

英文:

Edit

In a multiple architecture activity if you wan't PianoViews to have ViewModels and your ActivityViewModel to know about them - don't use Dagger injection with them but create PianoViewModels inside an ActivityViewModel and assign some callback to them on the stage of creation - thus you will have an access to them and will be able to listen to their events and influence their behaviour as well as save their state, from inside the ActivityViewModel. It is not an uncommon and in some cases even a correct approach. Dagger - is a mere instrument that is not intended to be used everywhere, but only there were it is needed. It is not needed to create PianoViewModels - you can inject all the needed stuff into the ActivityViewModel and pass all the needed elements to PianoViewModels constructors.

Also you don't need to wrap your Views into Fragments if you don't want to.

Edit end

You are making wrong assumptions based on a flawed architectural approach.

I am curious why do you need ActivityViewModel at all. View model should exist only for the elements that have some View. Current android development suggests that the Activity should not have a view representation and serve as a mere container of other views(Single activity principle). Depending on your architecture Activity may handle showing the loading state(progress bar) and some errors, but once again it should not contain anything that is being handled by other views. Thus PianoView should be a PianoFragment with its own ViewModel that handles access to its repository on the data layer via interactor on the domain layer.

The shared view model would work in case you would need one, and you would be using the Single activity principle with multiple fragments. Because Jetpack Navigation has the support of the shared view model out of the box. In the case of a shared view model - each fragment would have its own view model along with a shared one for communication. Each navigation graph could have a separate shared view model only for the fragments it contains.

Also regarding KeyStateRepository - you need only one of those(or a Dagger @Scoped multiple copies - but I do not recommend it). The only change should be - the addition of an extra key for each separate PianoView - to distinguish them inside a KeyStateRepository. To easily achieve that you may be using Room or some other file/memory/database cache mechanism.

Hence the initial problem of your app is not an inverted dependency of ActivityViewModel on a PianoViewModel, but a flawed architecture of the app and its inner interactions. If you want to continue work with your current architecture - there is no easy answer to your question, and almost every chosen solution would not be 'clean' enough to justify its usage.

答案3

得分: 0

我会按照以下方式操作,如果您不想将 PianoViewModel 与您的 ActivityViewModel 相关联,我只会创建一个 interface,由 ActivityViewModel 实现,然后 PianoVM 可以具有对该接口的可空引用。这样,对于 PianoViewModel 的工作,既不需要实现该接口,也不需要该组件的存在。

如何获取 ActivityViewModel 是另一个问题。请查看用于片段的 by activityViewModels() 实现,您可能可以使用 by viewModels(),将活动的 viewModelStore 传递给它。

英文:

I would do the following, if you don't want to tie the PianoViewModel to your ActivityViewModel, I'd just create an interface, which the ActivityViewModel implements, and the PianoVM could have a nullable reference to that interface. This way neither the implementation, nor the existence of the component would be required for the PianoViewModel to work.

How you get the ActivityViewModel is another question. Check out by activityViewModels() implementation for fragments, you probably can do the same with by viewModels() passing in the viewModelStore of the activity instead

huangapple
  • 本文由 发表于 2020年10月14日 00:10:24
  • 转载请务必保留本文链接:https://go.coder-hub.com/64339055.html
匿名

发表评论

匿名网友

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

确定