英文:
How to model parent-child relationship in Android MVVM VMs?
问题
我正在开发一个Android钢琴“测验”应用程序 - 用户点击钢琴键,然后点击黄色的“检查”按钮以提交答案进行评估,并在钢琴上看到正确答案的绘制。主要的 QuizActivity
拥有以下布局:
屏幕的上半部分包含了一些控件(文本、提交按钮等)。
屏幕的下半部分被自定义的 PianoView
组件占据,用于绘制钢琴键盘。
根据 MVVM 原则,PianoView
应该有自己的 PianoViewModel
,该视图模型在 KeysStateRepository
中存储其状态(即当前按下的键、高亮的键等)。
封装的 QuizActivity
也应该有一个 QuizActivityViewModel
,该视图模型处理各种控件(提交答案、跳过问题等)。
QuizActivityViewModel
需要能够从 PianoView
(或者更准确地说是从其 KeysStateRepository
)中查询所选的键,将它们提交给 领域层 进行评估,然后将结果发送回 PianoView
进行可视化显示。
换句话说,QuizActivity
的 ViewModel
应该拥有/是 PianoView
的 ViewModel
的父级,以便促进通信和数据共享。
我该如何建立这种父子关系,以在视图模型之间进行通信?
据我所知,一个 ViewModel
不能依赖于另一个 ViewModel
(在另一个 Viewmodel
中,我应该传递什么作为 ViewModelStoreOwner
来获取一个 ViewModel
?)。至少我认为使用 Dagger-Hilt 也无法实现这一点。
以下是三种解决此问题的方法,但它们都不可用:
1 - 官方共享数据在视图之间的方式
Android 开发文档 建议 使用一个共享的 ViewModel
以促进在两个片段/视图之间共享数据。然而,这不适合我的用例。PianoView
(或其视图模型)应该是其状态的唯一所有者,具有范围限定在其视图模型的 Repository
。否则,PianoView
组件将无法重用。例如,在另一个 Activity
中,我想要显示两个独立的 PianoView
实例:
重新使用来自测验活动的共享视图模型显然是错误的,因为它包含不相关的方法和逻辑(即提交测验答案),并且不适合双键盘场景。
2 - 应用程序范围的存储库
Reddit 上提出了类似的问题,并提出了使用存储库的共享实例的解决方案。然而,使用 @Singleton
的 KeyStateRepository
又一次会阻止两个独立的键盘显示不同的数据。
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:
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:
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 ViewModel
s 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那里得到了一个不错的答案,我只是用不同的话来澄清他的意思。
-
KeyStateRepository
是钢琴键状态的存储。没有什么阻止你使其同时支持 N 个钢琴,这将解决在屏幕上有 NNN 个钢琴的情况,每个钢琴都有不同的按键按下。 -
PianoView
应该包含在一个Fragment
中,那应该是你的“单元”。为什么?因为你希望有一个 ViewModel 来处理视图传入/传出的状态和事件。而Fragment
是 Android 提供的用于此目的的构件。把它想象成一个烦人的包袱,你需要它。Android 开发者过去常把这些东西称为“策略代理”,因为你要将一些无法脱离“框架”(即 Android 框架)的东西委托给它们(一个 Fragment/Activity)。 -
在这种情况下,你有一个独立处理的 ViewModel/状态的
Activity
。这个 ViewModel 处理什么样的状态/事件?那些不在钢琴片段/视图中的东西。例如,如果你想处理返回导航或顶部的“录制”按钮,这是活动的领域。在“钢琴视图/片段”内部发生的事情不是这个活动的问题。 -
现在,将包含实际
PianoView
的片段设计成可以包含“多个”或仅一个。如果你选择多个,那么PianoContainerFragment
将被设计为一个 ViewModel,用于处理多个PianoView
(因此每个视图都将有一个“名称/键”),并且KeyStateRepo
将能够处理你提供的任何 Piano View 的“CRUD”操作。ViewModel 将位于中间,为不同的“订阅”视图分派事件。 -
如果你选择“一个片段包含一个钢琴视图”,那么这是类似的架构,但现在在一个“活动”中处理多个“片段”是活动(及其 ViewModel)的责任。但请记住,钢琴视图(通过一个共享或非共享的 Fragment)与可以在钢琴视图之间共享的 ViewModel 交流,后者与一个公共的 KeyState Repo 交流。活动协调视图和其他 Android 事务(导航等),但视图彼此独立运行,甚至相互独立。
-
我认为你实际上不需要一个共享的 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.
-
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. -
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). -
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. -
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.
-
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.
-
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 -> PianoFragment (PianoView)|
| <-> PianoViewModel <-> KeyRepo
PianoFragment (PianoView)| /
-> QuizActivityViewModel <----------------------/
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 -> PianoFragment (PianoView)|
(PianoView)| <-> PianoViewModel <-> KeyRepo
-> QuizActivityViewModel <---------------------------/
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
或其他一些文件/内存/数据库缓存机制。
因此,你的应用的最初问题不是 ActivityViewModel
对 PianoViewModel
的倒置依赖,而是应用的架构和其内部交互的缺陷。如果你想继续使用当前的架构 - 对于你的问题没有简单的答案,几乎每个选择的解决方案都不足以证明其使用的“干净”程度。
英文:
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
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论