MVVM in TornadoFX

August 12, 2018

Being architecture agnostic is a design goal for TornadoFX. This is important to organizations moving to the framework. When starting with TornadoFX, a legacy app might exist in a hybrid state. In porting to TornadoFX, the app might retain a traditional Java FXML MVC architecture as an anchoring point for the project. This MVC architecture can immediately reap the benefits of TornadoFX and Kotlin; however the architecture should eventually be upgraded to reduce the excessive coupling inherent in MVC.

MVVM or Model-View-View Model is similar to MVC in that there is a middle component brokering communication between the View and the Model. In both MVC and MVVM, the View is the frontmost component consisting of UI controls and the Model is the component closest to the backend. The difference between the two is in the manner in which the middle component -- Controller for MVC, View Model for MVVM -- interacts with the other layers.

This article will compare and contrast MVC and MVVM and show why MVVM is the better architecture. A demo app has been coded in both MVC and MVVM. This screenshot shows the MVVM version, but the only visible difference between the two is the window title.

Form with Status Bar

Encapsulation

Entering text into the Data TextField and pressing the Save Button will result in a println() stubbing-out a save operation. The println() is followed by a call that will update the status Label at the base of the screen.

In the MVC case, the Controller bears the complexity of the save operation. The Controller pulls the text contents from MVCFormView and executes the save operation with the contents as an argument. The Controller then updates MVCStatusView with a message indicating the save operation was successful.

This UML diagram shows the arrangement of the classes involved in MVC version of the app. The app consists of three main components: MVCApp, MVCView, and MVCController. MVCApp is the standard App subclass that launches an application. MVCView is a TornadoFX View component that consists of two other Views: MVCFormView (top) and MVCStatusView (bottom). MVCController is a Controller component.

Class Diagram of MVC App

As one might expect, MVCView knows about the Views of which it is composed (MVCFormView and MVCStatusView). Because all classes are singleton -- View and Controller -- dependency injection can be used. The root of MVCView adds the other View objects to the Scene Graph.

The problem with MVC is apparent when looking at the relationships with the Controller. Highlighted in red, there is a bi-directional relationship between MVCFormView and MVCController. MVCFormView will call MVCController's save() function. MVCController will retrieve save operation arguments from MVCForm's data field. Additionally, MVCController needs to know about MVCStatusView.

The opening up of the MVCFormView and MVCStatusView is a broken encapsulation which means that modifications to these classes can come from several places, leading to side effects. Worse, the bi-directional dependency can lead to memory leaks if the GC isn't able to free MVCController with an active MVCFormView reference nor free MVCFormView with an active MVCController reference.

The following classes are the code behind MVCView and its component Views. In the action behind the Save Button, there is a find() required to get to the MVCController object. Although everything is a singleton, the dependency injection is broken because of a cycle which will be shown later in the MVCController code.

If MVCView opted for a declaration like val c : MVCController by inject() , there would be a StackOverflowError.

class MVCView : View("MVC App") { val form : MVCFormView by inject() val status : MVCStatusView by inject() override val root = vbox { add(form) separator() add(status) prefWidth = 480.0 prefHeight = 320.0 } } class MVCFormView : View() { val data = SimpleStringProperty() override val root = form { fieldset { field("Data") { textfield(data) } } button("Save") { action { find<MVCController>().save() // breaks by inject cycle } } padding = Insets(10.0) vgrow = Priority.ALWAYS alignment = Pos.CENTER_LEFT } } class MVCStatusView : View() { val lastMessage = SimpleStringProperty() override val root = hbox { label(lastMessage) padding = Insets(4.0) vgrow = Priority.NEVER } }

There isn't any business logic in any of the Views which is a benefit of MVC over an ad hoc arrangement. That code has been successfully factored out in MVCController. However, in doing so, MVCController needs access to the View layer components. A Model class has been omitted from the article but would be used in a real-world app.

class MVCController : Controller() { val form : MVCFormView by inject() val status : MVCStatusView by inject() fun save() { val dataToSave = form.data.value // execute save here println("Saving ${dataToSave}") status.lastMessage.value = "Data saved" } }

The form (MVCFormView) and status (MVCStatusView) declarations use TornadoFX dependency injection but this forces the find() in the MVCView class. MVCController now affects MVCView indirectly (because of the form and status declarations). In this small example, there is a lot of coupling both direct and indirect and this can lead to side effects. These side effects becomes more pronounced on a team over a maintenance effort where large Controllers start to do too much. A Business Delegate or Helper Class layer begins to form. The result is less and less understanding of program behavior and more and more bugs.

One Way Relationships

The way that MVVM helps with application architecture (as compared to MVC) is in maintaining a one-way object relationships. This has a few consequences one of which is the EventBus. The first benefit of MVVM is the complete removal of cycles throughout the app which encourages separation and fends off tough-to-find memory leaks. Another benefit is that the View-changing events, like a post-save notitification, are opened up to the app at large. While the status Label is the only interested party in this demo, other components could capture the same notification.

The following class diagram shows the same View composition: MVView contains MVCFormView and MVCStatusView. However, in this MVVM version, each class is paired with a ViewModel. The critical difference between MVC and MVVM isn't shown by the UML boxes (classes) but by the arrows. The MVVM diagram flows from left to right without any cycles. That's an important condition for the GC that when it frees MVVMView, it can free for example MVVMFormView and MVVMFormViewModel as there won't be reverse references placed on these objects.

Class Diagram of MVVM App

MVVMFormView is paired with MVVMFormViewModel. The ViewModel contains the save() function. (This would be delegated to a Model component not used for this demonstration.) The ViewModel also contains the data bound to the MVVMFormView TextField. save() has enough information to do its job. When it is finished, save() posts a DataSavedEvent. This event is posted without regard to any listeners (there may not be any) which means that MVVMFormViewModel is not coupled to other classes.

MVVMStatusView is paired with MVVMStatusViewModel. MVVMStatusViewModel listens for the DataSavedEvent. When MVVMStatusViewModel receives the event, it updates its field "lastMessage". This will be reflected in MVVMStatusView.

Data Binding is important in MVVM as it is the mechanism to keep the View and ViewModel in sync with trivial, declarative code. The following is the MVCView code for the MVVM version of the app. It's the same as the code for the MVC version.

class MVVMView : View("MVVM App") { val form : MVVMFormView by inject() val status : MVVMStatusView by inject() override val root = vbox { add(form) separator() add(status) prefWidth = 480.0 prefHeight = 320.0 } }

The MVVMFormView / MVVMFormViewModel pair is presented below along with DataSavedEvent. The View contains a reference to the ViewModel which is used in the action of the Save Button. The save() function is implemented in MVVMFormViewModel, although this would involve a Model component in a real implementation. Unlike in MVCController, the MVVMFormViewModel does not interact with any View classes. Instead, it uses its own property "data" which gathers the text arguments though Data Binding and fires an event. It's up other classes to decide what happens to the event.

class DataSavedEvent(val message : String) : FXEvent() class MVVMFormView : View() { val vm : MVVMFormViewModel by inject() override val root = form { fieldset { field("Data") { textfield(vm.data) } } button("Save") { action { vm.save() } } padding = Insets(10.0) vgrow = Priority.ALWAYS alignment = Pos.CENTER_LEFT } } class MVVMFormViewModel : ViewModel() { val data = SimpleStringProperty() fun save() { val dataToSave = data.value // execute save here println("Saving ${dataToSave}") fire(DataSavedEvent("Data saved")) } }

This listing shows the MVVMStatusView / MVVMStatusViewModel pair. The View is updated by the ViewModel via Data Binding. The ViewModel will receive a notification carrying a message String from the subscribe() function.

class MVVMStatusView : View() { val vm : MVVMStatusViewModel by inject() override val root = hbox { label(vm.lastMessage) padding = Insets(4.0) vgrow = Priority.NEVER } } class MVVMStatusViewModel : ViewModel() { val lastMessage = SimpleStringProperty() init { subscribe<DataSavedEvent> { lastMessage.value = it.message } } }

While both MVC and MVVM rely on "middle components" to manage the complexity of a TornadoFX application, MVVM is better for maintenance because of the tighter encapsulation and the one-directional dependency graph. TornadoFX does not dictate a software architecture, recognizing that applications are often architected in a hybrid manner when converting from a legacy app. There is a learning curve to using MVVM. MVC is direct, easier to trace, and uses fewer classes. However, those benefits become drawbacks as the application grows since the complexity of the coupling -- and resulting side effects -- become the bigger maintenance challenges.

There are trivial App classes and main functions that are included in the source zip.

Resources

The source code presented in this video series is a Gradle project that can be imported into your IDE.