September 2, 2018
This article shows a technique I use for keeping a UI performant when working with controls that change a lot of data. While an intuitive design usually calls for the screen to closely mirror the data as it's represented elsewhere, the overhead to keep everything in sync can make for a less-usable UI. This is especially the case where the screen state of the UI is a work-in-progress that offers a button to the user when their action is finished and the data is suitable to commit.
I call this "Journaling" because it is similar to a Journal Entry being transferred to a General Ledger in the field of Accounting.
The technique saves the actions off, deferring them for a later backend sequence of operations. Optimistically, new data and changed data is added to the screen. A deliberate user action -- a button press -- will initiate the backend calls. New data lacks a backend identifier, so the case where new data is updated as a work-in-progress is handled.
The following video shows the application executing add, update, and delete operations. The UI operates smoothly. When Save is pressed, a series of console messages appear that are placeholders for backend calls. Even if the backend is fast, say 70ms for a call, initiating such a call on each and every update makes the TableView difficult to use. Moreover, if the call is costly, say upwards of 1s, the TableView becomes unusable with frequent pauses.
A class SalesRecord is the data structure contained in the TableView. Notice the var id : Int
member. That's a var rather than a val because I'm going to assign it a temporary value of -1 if the item is new. For existing records or ones that have been successfully saved, this will be a value coming from the backend.
I'm also keeping an objectId which is a transient object identifier. This is for a future requirement that simplifies the payload by grouping related update actions. Prior to iterating over the actions, I can consolidate sequences of updates on the same objectId, saving only the last one which would contain the current record with all the changes.
class SalesRecord(var id : Int, office : String, year : String, quarter : String, totalSales : String) {
val officeProperty = SimpleStringProperty(office)
val yearProperty = SimpleStringProperty(year)
val quarterProperty = SimpleStringProperty(quarter)
val totalSalesProperty = SimpleStringProperty(totalSales)
val objectId = nextObjectId() // always increasing
companion object {
private var idgen = 1 // faux static class member
fun nextObjectId() = idgen++
}
override fun toString(): String {
return "id=${id} objectId=${objectId} - ${officeProperty.value} ${yearProperty.value}/${quarterProperty
.value} " +
"sales=${totalSalesProperty.value}"
}
}
The Journal is based on a Action data structure which contains the data (a changed/added/deleted record) and an actionType that flags the type of operation to perform. There is also an event which the Controller will provide to the View to notify the user that the update has finished.
enum class ActionType { ADD, UPDATE, DELETE }
class Action(val actionType : ActionType, val data : SalesRecord)
class SaveCompletedEvent : FXEvent()
A Controller manages the data, seeded with a few records, and a consoleLog which verifies that the stubbed-out operations are being called. The Controller also keeps track of the changes in a list of pendingActions. This list will grow with the work that is done by the user in between each save. A save will clear this list.
class JournalController : Controller() {
val data = mutableListOf(
SalesRecord(990, "Home", "2018", "1", "100000"),
SalesRecord(991, "NY", "2018", "1", "200000"),
SalesRecord(992, "Home", "2018", "2", "125000"),
SalesRecord(993, "NY", "2018", "2", "190000")
).observable()
val pendingActions = mutableListOf<Action>()
val consoleLog = SimpleStringProperty("")
fun save() {
pendingActions.forEach {
when(it.actionType) {
ActionType.ADD -> {
consoleLog += "Calling create service on ${it.data}\n"
if( it.data.id == -1 ) {
it.data.id = nextId() // as though returned by a service call
}
}
ActionType.UPDATE -> consoleLog += "Calling update service on ${it.data}\n"
ActionType.DELETE -> consoleLog += "Calling delete service on ${it.data}\n"
}
}
pendingActions.clear()
fire(SaveCompletedEvent())
}
fun newItem() {
val newRecord = SalesRecord(-1, "Home", "2018", "3", "0.0")
add(newRecord)
}
fun add(record : SalesRecord) {
pendingActions.add( Action(ActionType.ADD, record) )
data.add( record )
}
fun delete(record : SalesRecord) {
pendingActions.add(Action(ActionType.DELETE, record))
data.remove(record)
}
fun update(record : SalesRecord) {
pendingActions.add(Action(ActionType.UPDATE, record))
}
// used to generate fake ids from create call
companion object {
private var idgen = 8000
fun nextId() = idgen++
}
}
The ADD case in the Controller's save() when clause will substitute the temporary id for the returned id from the backend service. This screenshot shows the sequence. Notice the ADD returns the id which is applied to the SalesRecord. That SalesRecord -- with the update id -- now results in a valid UPDATE case. It would also result in a valid DELETE case.
The DELETE case is handled by a ContextMenu. As mentioned previously, the backend call will use the id returned from the database as part of the initial retrieval or applied from an earlier ADD.
The View is a TableView and a TextArea. The TableView shows the SalesRecord and the TextArea shows a String built up of stubbed-out backend service calls. The TableView is editable and each EditCommit event will generate a pendingAction in the TableView. This lets the TableView know that updates are needed. The + button will add a new SalesRecord. (This SalesRecord contains id=-1.) Delete is called from a ContextMenu if a TableRow has been selected.
class JournalView : View("Journal App") {
val c : JournalController by inject()
override val root = vbox {
button("+") { action { c.newItem() } }
tableview(c.data) {
isEditable = true
column("Office", SalesRecord::officeProperty).makeEditable().setOnEditCommit { c.update(it.rowValue) }
column("Year", SalesRecord::yearProperty).makeEditable().setOnEditCommit { c.update(it.rowValue) }
column("Quarter", SalesRecord::quarterProperty).makeEditable().setOnEditCommit { c.update(it.rowValue) }
column("TotalSalesProperty", SalesRecord::totalSalesProperty).makeEditable().setOnEditCommit { c.update(it.rowValue) }
contextmenu {
item("Delete") {
action {
if (selectedItem != null) {
c.delete(selectedItem!!)
}
}
}
}
vgrow = Priority.ALWAYS
}
titledpane("Console") {
textarea(c.consoleLog)
}
separator()
hbox {
button("Save") { action{ c.save() } }
alignment = Pos.TOP_RIGHT
padding = Insets(4.0)
}
padding = Insets(4.0)
spacing = 4.0
prefWidth = 800.0
prefHeight = 600.0
}
init {
subscribe<SaveCompletedEvent> {
alert(Alert.AlertType.INFORMATION, "Save Saved Successfully")
}
}
}
To keep the application lively, a JavaFX Task would be used to manage the save operation. The journal technique also supports progress tracking since the sequence of operations can be estimated into increments for a percent complete presentation. I've left off two important parts of the program that would need to be considered in a real environment: transactions and error handling.
For transactions, you might want to support a partial update, keeping all changes if there is some mid-operation failure. Alternatively, if it's important that all transactions either succeed or fail, you may keep a second list of offsetting changes alongside pendingActions. As a pendingAction is realized by the backend call, it is removed, and a rollbackActions list item is added. If there is any failure, unwind the backend calls by applying each rollbackAction in reverse order.
The most intuitive UI will have the persistent store reflect what the user sees on the screen. However, in today's connected world, data may have changed while the user is viewing it on the screen. Also, the performance penalty of keeping the data in sync while the user if forming their work may be prohibitive. This example showed how you can defer your backend operations potentially supporting a completely offline mode.
There is a trivial App and main provided in the source link
The complete JournalingApp.kt file can be downloaded here.
By Carl Walker
President and Principal Consultant of Bekwam, Inc