Sukma Wardana
Sukma Wardana

Reputation: 550

JavaFX Image Performance Optimization

I'm making a picture viewer application with JavaFX 17. To summarize, the application is like Windows Photo / Windows Picture Viewer. The user could open a picture or a folder. The application will display the given picture or the first picture from the given folder. My application will display one picture at a time, User could navigate the picture using available control (next, prev, last, & beginning).

I've checked the below threads to make sure it's optimised enough:

But, I found that my code has an issue with processing 200 pictures with each size around 1~2 MB.

Without background loading, the application does not display anything. Even though the navigation control state is changed because of knowing that there are available pictures. So, click next & prev only shows a blank screen. When using the background loading, only a few of the very first image is loaded. After several next control, suddenly it's become blank again.

Here is my minimal, reproducible example:

package com.swardana.mcve.image;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import javafx.application.Application;
import javafx.concurrent.Task;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

/**
 * JavaFX App
 */
public class App extends Application {

    @Override
    public void start(Stage stage) {
        var view = new View();
        var path = Paths.get("Path/to/many/images");
        var storage = new Storage(new PictureSource(path));
        storage.setOnSucceeded(eh -> view.exhibit(storage.getValue()));
        Executors.newSingleThreadExecutor().submit(storage);
        var scene = new Scene(view, 640, 480);
        scene.addEventFilter(KeyEvent.KEY_PRESSED, eh -> {
            switch (eh.getCode()) {
                case RIGHT:
                    view.next();
                    break;
                case DOWN:
                    view.last();
                    break;
                case LEFT:
                    view.prev();
                    break;
                case UP:
                    view.beginning();
                    break;    
                default:
                    throw new AssertionError();
            }
        });
        stage.setScene(scene);
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }

    public class Picture {

        private final String name;
        private final Image image;

        public Picture(final String name, final Path src) throws IOException {
            this(name, new Image(src.toUri().toURL().toExternalForm(), true));
        }

        public Picture(final String name, final Image img) {
            this.name = name;
            this.image = img;
        }

        public final String name() {
            return this.name;
        }

        public final Image image() {
            return this.image;
        }

    }

    public class PictureSource {

        private final Path source;

        public PictureSource(final Path src) {
            this.source = src;
        }

        public final List<Picture> pictures() {
            var dir = this.source.toString();
            final List<Picture> pictures = new ArrayList<>();
            try (var stream = Files.newDirectoryStream(this.source, "*.{png,PNG,JPG,jpg,JPEG,jpeg,GIF,gif,BMP,bmp}")) {
                for (final var path : stream) {
                    var picName = path.getFileName().toString();
                    pictures.add(
                        new Picture(picName, path)
                    );
                }
                return pictures;
            } catch (final IOException ex) {
                throw new RuntimeException(ex);
            }
        }
    }
    
    public class Storage extends Task<List<Picture>> {
        private final PictureSource source;

        public Storage(final PictureSource src) {
            this.source = src;
        }

        @Override
        protected final List<Picture> call() throws Exception {
            return this.source.pictures();
        }
    }
    
    public class View extends VBox {
        private final ImageView image;
        private List<Picture> pictures;
        private int lastIdx;
        private int index;
        
        public View() {
            this.image = new ImageView();
            this.initGraphics();
        }
        
        // This method to accept value from the `Storage`.
        public void exhibit(final List<Picture> pics) {
           this.pictures = pics;
           this.index = 0;
           this.lastIdx = pics.size();
           this.onChange();
        }
        
        public void next() {
            if (this.index != this.lastIdx - 1) {
                this.index++;
                this.onChange();
            }
        }
        
        public void prev() {
            if (this.index != 0) {
                this.index--;
                this.onChange();
            }
        }
        
        public void last() {
            this.index = this.lastIdx - 1;
            this.onChange();
        }
        
        public void beginning() {
            this.index = 0;
            this.onChange();
        }

        // Whenever the action change, update the image from pictures.
        public void onChange() {
            this.image.setImage(this.pictures.get(this.index).image());
        }
        
        private void initGraphics() {
            this.getChildren().add(this.image);
        }
        
    }

}

Really appreciate any help and advise.

Upvotes: 2

Views: 1036

Answers (1)

user17022635
user17022635

Reputation:

The problem is that you load all images in full size (it requires a lot of memory) at one time and they saved in List<Pictures> so they stay in memory. I tried to load 100 big pictures with your code and to 10th picture i got OutOfMemoryError: Java heap space (used heap was about 2.5GB in size).

I found two possible solutions:

  1. Resize images.
  2. Load image on demand (lazy).

Resizing images to 800px width reduce used heap to 600MB. For that i changed the Picture's class constructor.

public Picture(final String name, final Path src) throws IOException {
    this(name, new Image(src.toUri().toURL().toExternalForm(), 800, 0, true, true, true));
}

In case where an image is loaded only when it is necessary, the most time used heap size was about 250MB, with several jumps to 500MB. Again, I changed constructors of the class Picture and introduced a new field imageUrl, so a Path just converted to a URL string and an Image object is not created.

private final String imageUrl;

public Picture(final String name, final Path src) throws IOException {
    this(name, src.toUri().toURL().toExternalForm());
}

public Picture(final String name, final String imageUrl) {
    this.name = name;
    this.imageUrl = imageUrl;
}

The image() method now doesn't return a preload image but load an image on demand and does it synchronously.

public final Image image() {
    return new Image(imageUrl);
}

For a folder with 850 images I got this:

Loading image while Picture is created and save them all in a List results to consuming a lot of memory and GC activity (that can't do anything to free memory).

graphs for cpu and heap usage without lazy loading

And with lazy loading I got these graphs.

graphs for cpu and heap usage with lazy loading

Upvotes: 2

Related Questions