Skip to content

hjohn/hs.jfx.eventstream

Repository files navigation

Streams for JavaFX

Maven Central Build Status Coverage License: MIT

Based on the work by Tomas Mikula's excellent ReactFX project (https://github.com/TomasMikula/ReactFX) and used with permission.

Overview

This library allows streaming values generated by properties or changes triggered by JavaFX events with an expressive fluent API which also enables easier management of listeners to prevent memory leaks.

Basics

Streams can be used to listen to property changes and take an action each time the property changes. A simple example below shows how to print the value of a button's text property to the console each time it changes:

Changes.of(button.textProperty())
    .subscribe(System.out::println);

A more sophisticated example may want to convert the text to upper case and replace a null value with an empty string:

Changes.of(button.textProperty())
    .map(String::toUpperCase)
    .orElse("")
    .subscribe(System.out::println);

To also print the initial value of the text property another print statement could be included, but alternatively a value stream can be used. A property of a value stream is that it will send the current value immediately to new subscribers:

Values.of(new SimpleStringProperty("Hello World"))
    .subscribe(System.out::println);  // prints the value immediately

Null Handling

In the earlier examples null was not explicitly handled in the map function. This is because these functions are null-safe. The functions are not called when the value emitted is null. In order to handle nulls, streams offer similar functions to Java's Optional to deal with the case when null is emitted.

These functions make dealing with null a lot simpler as not every map, flatMap or filter step specifically needs to deal with it. For example, when creating a stream with several chained properties, the null checks can be omitted:

Values.of(button.sceneProperty())
    .flatMap(scene -> Values.of(scene.windowProperty()))     // scene won't be null :)
    .flatMap(window -> Values.of(window.showingProperty()))
    .orElse(false)  // deals with the case when either scene or window is null
    .subscribe(showing -> System.out.println("showing is: " + showing));

Stream Types

This library offers three types of streams, each with distinct characteristics. Their differences lie in how they treat null and what happens when they are subscribed. See the summary in the table below:

Type Null Values Upon Subscription
Event Stream Discarded Sends nothing
Change Stream Allowed Sends nothing
Value Stream Allowed Sends current value

Event Streams

Event streams are the most basic type of stream. When an event occurs, this stream emits a value, which can be mapped and filtered as needed. Event streams never emit null, and mapping an event to null will discard the event. Event streams only deliver events when they occur, thus if after subscribing no events occur nothing will be delivered.

Change Streams

Change streams are event streams which allow null values and mapping to null values. Its methods are null safe, and a separate set of methods is available to deal with null cases, similar to Java's Optional. This means that methods like filter, map and flatMap never have to deal with their input being null, instead use methods like orElse and orElseGet. Subscribers can still receive null, unlike event streams.

Value Streams

Value streams expand further upon change streams with a notion of a current value which new subscribers will immediately receive as their first value. This makes value streams very suitable as a binding source for a property as they immediately emit their current state.

Conversions between Stream Types

The different types of streams can be converted into one or the other with a few specific methods. Usually these methods could violate an invariant of the current stream type but allow this by returning a different stream type for which this is allowed.

For example, filtering a value stream may result in the stream not emitting a value upon subscription making it unsuitable as a binding. The filter method therefore returns a change stream instead. Another example is changing a change stream into a value stream with withDefault. This method assigns a default value to the stream which can be emitted immediately upon subscription.

The table below shows which of the most commonly used functions are available for each stream type and whether the type of stream changes as a result:

Function Event Change Value
map, flatMap X X X
filter X X X(C)
peek X X X
withDefault, withDefaultGet X(V) X(V) -
orElse, orElseGet - X X
or - X X
conditionOn X X X
flatMapToChange - - X(C)

The following table shows which terminal operations are available for each stream type:

Function Event Change Value
subscribe X X X
toBinding - - X

Lazy Subscriptions

Streams only observe their source when a consumer is currently subscribed.

ValueStream<String> vs = Values.of(button.textProperty())
    .map(String::toUpperCase);

In the above example, the button's text property is not observed until an actual subscriber is added to the stream:

vs.subscribe(System.out::println);

Streams of this type are called lazy streams. All streams provided by this package are lazy and will only observe their source when needed.

As lazy streams will stop observing their source when they have no subscribers, the source stream will not prevent garbage collection when there are no more subscribers. There is therefore no need to use weak listeners for observing the source stream.

An advantage of this approach is that an example like below will function as one would expect, and will keep printing changes in button.textProperty():

Values.of(button.textProperty())
    .map(t -> t + "World")
    .subscribe(System.out::println);

Contrast this with JavaFX's standard binding mechanism which may garbage collect the binding at any time because of its use of weak listeners:

button.textProperty()
    .concat("World")  // weak binding used here
    .addListener((obs, old, current) -> System.out.println(current));

This can be very surprising, especially when adding the concat function at a later stage, because that simple change will result in a completely different runtime behavior.

Motivation

This project was created in the hope to add additional functionality directly to JavaFX to address a few of its rough edges. Mainly:

  • Type-safe Bindings#select functionality, which allows to create bindings to nested properties. The current implementation is not type safe and does not offer much flexibility to customize the binding.

The project purposely contains only a small well defined subset of code adapted from ReactFX with the goal of making the project easier to evaluate for potential inclusion into JavaFX. Direct inclusion would offer major advantages by adding default methods to the ObservableValue and Binding interfaces making classes that implement these interfaces act more like Optional's would:

Binding<String> quotedTitleText = model.titleProperty()
    .map(text -> "'" + text "'");  // new `map` method on `Binding`

Type-safe binding to nested properties

In standard JavaFX, creating a binding to a nested property is a cumbersome affair. One has to keep track of the listeners to unregister them when a parent property changes, and reregister the listener on the new value. With multiple levels of nesting this can quickly become complicated and error prone.

An example from JavaFX itself is the implementation of the treeShowing property. It tracks whether or not a Node is currently showing on the screen. In order to do this, it must check if the Node has a Scene, whether the Scene has a Window, and whether that Window is currently shown:

    ChangeListener<Boolean> windowShowingChangedListener = (win, oldVal, newVal) -> updateTreeShowing();

    ChangeListener<Window> sceneWindowChangedListener = (scene, oldWindow, newWindow) -> {
        if (oldWindow != null) {
            oldWindow.showingProperty().removeListener(windowShowingChangedListener);
        }
        if (newWindow != null) {
            newWindow.showingProperty().addListener(windowShowingChangedListener);
        }
        updateTreeShowing();
    };

    ChangeListener<Scene> sceneChangedListener = (node, oldScene, newScene) -> {
        if (oldScene != null) {
            oldScene.windowProperty().removeListener(sceneWindowChangedListener);

            Window window = oldScene.windowProperty().get();
            if (window != null) {
                window.showingProperty().removeListener(windowShowingChangedListener);
            }
        }
        if (newScene != null) {
            newScene.windowProperty().addListener(sceneWindowChangedListener);

            Window window = newScene.windowProperty().get();
            if (window != null) {
                window.showingProperty().addListener(windowShowingChangedListener);
            }
        }

        updateTreeShowing();
    };

This can already be expressed much more succintly by using the Bindings#select function:

    BooleanProperty treeShowing = Bindings.selectBoolean(node.sceneProperty(), "window", "showing");

The method however is not type safe. A mistake in one of the string parameters or the choice of select method will lead to errors at runtime. It will also complain about null values and map them to some standard value.

Alternative solution using Streams

With streams we can create the same binding in a type-safe manner:

    Binding<Boolean> treeShowing = Values.of(node.sceneProperty())
        .flatMap(s -> Values.of(s.windowProperty()))
        .flatMap(w -> Values.of(w.showingProperty()))
        .orElse(false)
        .toBinding();

This is far less cumbersome and still 100% type safe.

Preventing memory leaks

When you bind a property in JavaFX you have to carefully consider the lifecycle of the two properties involved. Calling bind on a property will keep a target property synced with a source property.

target.bind(source);  // keep target in sync with source

This is equivalent to adding a (weak) listener (weak listener code omitted here) and keeping track of the property target was bound to:

source.addListener((obs, old, current) -> target.set(current));
target.getProperties().put("boundTo", source);

In both these cases:

  • source refers to target through the listener added because it needs to update the target when it changes
  • target refers to source as the property it is "bound to" in order for unbind to do its magic

In JavaFX, bind will use a weak listener, which means that the target can be garbage collected independently from the source property. However, the reference from target to source with its "bound to" property is a hard reference (if it were weak then a binding could stop working without notice because the source could be garbage collected and stop sending its updates). This means that the lifecycle of the source property is now closely tied to the target property.

If the target property is a long-lived object (like a data model) and the source property is a shorter lived object like a UI element, you could have inadvertently created a big memory leak; all UI components have a parent and a scene property, so effectively keeping a reference to any UI element can keep an entire scene or window from being garbage collected.

Something as simple as keeping the selection of a ListView in sync with a model can lead to this:

model.selectedItemProperty()
    .addListener((obs, old, current) -> listView.getSelectionModel().select(current));

Assuming model here is a long-lived object that perhaps is re-used next time the UI is shown to remember the last selected item, the listener as shown above will prevent the ListView and all other UI components that it refers to from being garbage collected.

To prevent this one must remember to wrap the above listener in a WeakChangeListener and be careful not to keep a reference around to the unwrapped change listener. Using a weak listener is not a perfect solution however. The listener only stops working when a garbage collection cycle runs and in the mean time the UI code may interfere with normal operations if the selected item is changed in the model, as it could trigger code in the (soon to be garbage collected) UI, which may still trigger other changes.

It might be better to disable the listener as soon as the UI is hidden. Doing this manually means keeping track of the listeners involved that may need unregistering (and potentially reregistering if the UI becomes visible again). Instead we could listen to a property that tracks the showing status of our UI. Unfortunately, this is somewhat involved as there is no easy property one can listen to; you have to listen for Scene changes, check which Window it is associated with and then bind to Window::showingProperty -- and update these listeners if the scene or window changes.

With Streams one could safely bind a UI property to a model only when the UI is visible:

model.selectedItemProperty()
    .conditionOn(isShowing)
    .subscribe(selectedItem -> listView.getSelectionModel().select(selectedItem));

Where the isShowing variable can be created like this:

Binding<Boolean> isShowing = Values.of(listView.sceneProperty())
    .flatMap(s -> Values.of(s.windowProperty()))
    .flatMap(w -> Values.of(w.showingProperty()))
    .orElse(false)
    .toBinding();

Or with a small helper class, which only needs the Node involved as a parameter:

model.selectedItemProperty()
    .conditionOn(Helper.isShowing(listView))
    .subscribe(selectedItem -> listView.getSelectionModel().select(selectedItem));

The above binding to model.selectedItemProperty() will only be present while listView is showing. If the list view is hidden, the listener is unregistered, and if it is shown again the listener is re-added. If the UI is hidden, it will instantly stop reacting to any changes in the model and (if also no longer referenced) will eventually be garbage collected.

About

Light-weight Streams for JavaFX

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages