I've hit the performance limits of JTextPane, while trying to implement a console style component. For the most part, my console behaves quite well, but attempts to spam it with large amounts of non- space separated text end up freezing the GUI entirely. I'd like to avoid this or at least provide an opportunity to hit the stop button in a normal fashion.
Some quick profiling revealed that EDT is stuck laying out text in JTextPane most of the time (laying out LabelViews as part of its EditorKit implementation) - and since Swing stuff should be done on EDT, I thought I was screwed. But then a glimmer of hope. After a bit of research I have stumbled upon some lost Swing arts. Namely, this article by Timothy Prinzing.
The (now completely broken) article describes how the issue that is pestering me (layout) may be shoved off EDT, defining a class called AsyncBoxView
, which, to my surprise, is now a part of Swing. But...
After modifying my editor kit to create AsyncBoxView instead of the usual BoxView
, I immediately hit a snag - it throws an NPE during initialization. Here's some code:
package com.stackoverflow
import java.awt.*;
import java.awt.event.*;
import java.util.concurrent.*;
import javax.swing.*;
import javax.swing.event.*;
import javax.swing.text.*;
public class ConsoleTest extends JFrame {
public static final boolean USE_ASYNC_BOX_VIEW = true;
public static final int MAX_CHARS = 1000000;
public static final int MAX_LINES = 100;
private static final String LONG_TEXT;
static {
StringBuilder sb = new StringBuilder();
String tmp = ""
+ "<?xml version = \"1.0\" encoding = \"utf-8\"?><!-- planes.xml"
+ " - A document that lists ads for used airplanes --><!DOCTYPE "
+ "planes_for_sale SYSTEM \"planes.dtd\"><planes_for_sale><ad>"
+ "<year> 1977 </year><make> &c; </make><model> Skyhawk </model>"
+ "<color> Light blue and white </color><description> New paint,"
+ " nearly new interior, 685 hours SMOH, full IFR King avionics"
+ " </description><price> 23,495 </price><seller phone = \"555-"
+ "222-3333\"> Skyway Aircraft </seller><location><city> Rapid "
+ "City, </city><state> South Dakota </state></location></ad>"
+ "<ad><year>1965</year><make>&p;</make><model>Cherokee</model>"
+ "<color>Gold</color><description>240 hours SMOH, dual NAVCOMs"
+ ", DME, new Cleveland brakes, great shape</description><sell"
+ "er phone=\"555-333-2222\" email=\"[email protected]\">John"
+ " Seller</seller><location><city>St. Joseph,</city><state>Mi"
+ "ssouri</state></location></ad></planes_for_sale>";
// XML obtained from:
// https://www.cs.utexas.edu/~mitra/csFall2015/cs329/lectures/xml.html
for (int i = 0; i < 1000 * 10 * 2; i++) { // ~15 MB of data?
sb.append(tmp);
}
LONG_TEXT = sb.toString();
}
public ConsoleTest() {
setDefaultCloseOperation(EXIT_ON_CLOSE);
setLayout(new BorderLayout());
setTitle("Console Spammer");
// the console
final JTextPane console = new JTextPane();
console.setFont(new Font("Monospaced", Font.PLAIN, 12));
console.setEditorKit(new ConsoleEditorKit());
console.setEditable(false);
JScrollPane scroll = new JScrollPane(console);
scroll.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
add(scroll, BorderLayout.CENTER);
// make a style rainbow
final Style[] styles = new Style[]{
console.addStyle("0", null),
console.addStyle("1", null),
console.addStyle("2", null),
console.addStyle("3", null),
console.addStyle("4", null),
console.addStyle("5", null)
};
StyleConstants.setForeground(styles[0], Color.red);
StyleConstants.setForeground(styles[1], Color.blue);
StyleConstants.setForeground(styles[2], Color.green);
StyleConstants.setForeground(styles[3], Color.orange);
StyleConstants.setForeground(styles[4], Color.black);
StyleConstants.setForeground(styles[5], Color.yellow);
// simulate spam comming from non-EDT thread
final DefaultStyledDocument document = (DefaultStyledDocument) console.getDocument();
final Timer spamTimer = new Timer(100, new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
@Override
protected Void doInBackground() throws Exception {
final int chunkSize = 16384;
int remaining = LONG_TEXT.length();
int position = 0;
while (remaining > 0) {
final String chunk;
if (remaining - chunkSize > 0) {
remaining -= chunkSize;
position += chunkSize;
chunk = LONG_TEXT.substring(position - chunkSize, position);
} else {
chunk = LONG_TEXT.substring(position, position + remaining);
remaining = 0;
}
// perform all writes on the same thread (EDT)
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
try {
performSpam(document, styles, chunk);
} catch (BadLocationException ex) {
ex.printStackTrace();
}
}
});
}
return null;
}
@Override
protected void done() {
try {
get();
} catch (InterruptedException | ExecutionException ex) {
ex.printStackTrace();
}
}
};
worker.execute();
}
});
spamTimer.setRepeats(true);
// the toggle
JToggleButton spam = new JToggleButton("Spam");
spam.addItemListener(new ItemListener() {
@Override
public void itemStateChanged(ItemEvent e) {
if (e.getStateChange() == ItemEvent.SELECTED) {
spamTimer.restart();
} else {
spamTimer.stop();
}
}
});
add(spam, BorderLayout.PAGE_END);
// limit number of lines (not that it matters)
DocumentListener limitLinesDocListener = new DocumentListener() {
@Override
public void insertUpdate(final DocumentEvent e) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
Element root = document.getDefaultRootElement();
while (root.getElementCount() > MAX_LINES) {
Element line = root.getElement(0);
int end = line.getEndOffset();
try {
document.remove(0, end);
} catch (BadLocationException ex) {
break;
} finally {
}
}
}
});
}
@Override
public void removeUpdate(DocumentEvent e) {
}
@Override
public void changedUpdate(DocumentEvent e) {
}
};
document.addDocumentListener(limitLinesDocListener);
setSize(640, 480);
setLocationRelativeTo(null);
}
private void performSpam(
DefaultStyledDocument document, Style[] styles, String chunk) throws BadLocationException {
System.out.println(
String.format("chunk-len:%d\t\tdoc-len:%d",
chunk.length(), document.getLength(),
document.getDefaultRootElement().getElementCount()));
document.insertString(
document.getLength(), chunk,
styles[ThreadLocalRandom.current().nextInt(0, 5 + 1)]);
while (document.getLength() > MAX_CHARS) { // limit number of chars or we'll have a bad time
document.remove(0, document.getLength() - MAX_CHARS);
}
}
public static class ConsoleEditorKit extends StyledEditorKit {
public ViewFactory getViewFactory() {
return new MyViewFactory();
}
static class MyViewFactory implements ViewFactory {
public View create(Element elem) {
String kind = elem.getName();
if (kind != null) {
if (kind.equals(AbstractDocument.ContentElementName)) {
return new WrapLabelView(elem);
} else if (kind.equals(AbstractDocument.ParagraphElementName)) {
return new CustomParagraphView(elem);
} else if (kind.equals(AbstractDocument.SectionElementName)) {
return USE_ASYNC_BOX_VIEW ? new AsyncBoxView(elem, View.Y_AXIS) : new BoxView(elem, View.Y_AXIS);
} else if (kind.equals(StyleConstants.ComponentElementName)) {
return new ComponentView(elem);
} else if (kind.equals(StyleConstants.IconElementName)) {
return new IconView(elem);
}
}
return new LabelView(elem);
}
}
static class WrapLabelView extends LabelView {
public WrapLabelView(Element elem) {
super(elem);
}
public float getMinimumSpan(int axis) {
switch (axis) {
case View.X_AXIS:
return 0;
case View.Y_AXIS:
return super.getMinimumSpan(axis);
default:
throw new IllegalArgumentException("Invalid axis: " + axis);
}
}
}
static class CustomParagraphView extends ParagraphView {
public static int MAX_VIEW_SIZE = 100;
public CustomParagraphView(Element elem) {
super(elem);
strategy = new MyFlowStrategy();
}
public int getResizeWeight(int axis) {
return 0;
}
public static class MyFlowStrategy extends FlowView.FlowStrategy {
protected View createView(FlowView fv, int startOffset, int spanLeft, int rowIndex) {
View res = super.createView(fv, startOffset, spanLeft, rowIndex);
if (res.getEndOffset() - res.getStartOffset() > MAX_VIEW_SIZE) {
res = res.createFragment(startOffset, startOffset + MAX_VIEW_SIZE);
}
return res;
}
}
}
}
public static void main(String[] args)
throws ClassNotFoundException, InstantiationException,
IllegalAccessException, UnsupportedLookAndFeelException {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
new ConsoleTest().setVisible(true);
}
});
}
}
This code throws:
Exception in thread "AWT-EventQueue-0" java.lang.NullPointerException
at javax.swing.text.AsyncBoxView.preferenceChanged(AsyncBoxView.java:511)
at javax.swing.text.View.preferenceChanged(View.java:288)
at javax.swing.text.BoxView.preferenceChanged(BoxView.java:286)
at javax.swing.text.FlowView$FlowStrategy.insertUpdate(FlowView.java:380)
at javax.swing.text.FlowView.loadChildren(FlowView.java:143)
at javax.swing.text.CompositeView.setParent(CompositeView.java:139)
at javax.swing.text.FlowView.setParent(FlowView.java:289)
at javax.swing.text.AsyncBoxView$ChildState.<init>(AsyncBoxView.java:1211)
at javax.swing.text.AsyncBoxView.createChildState(AsyncBoxView.java:220)
at javax.swing.text.AsyncBoxView.replace(AsyncBoxView.java:374)
at javax.swing.text.AsyncBoxView.loadChildren(AsyncBoxView.java:411)
at javax.swing.text.AsyncBoxView.setParent(AsyncBoxView.java:479)
at javax.swing.plaf.basic.BasicTextUI$RootView.setView(BasicTextUI.java:1328)
at javax.swing.plaf.basic.BasicTextUI.setView(BasicTextUI.java:693)
at javax.swing.plaf.basic.BasicTextUI.modelChanged(BasicTextUI.java:682)
at javax.swing.plaf.basic.BasicTextUI$UpdateHandler.propertyChange(BasicTextUI.java:1794)
at java.beans.PropertyChangeSupport.fire(PropertyChangeSupport.java:335)
at java.beans.PropertyChangeSupport.firePropertyChange(PropertyChangeSupport.java:327)
at java.beans.PropertyChangeSupport.firePropertyChange(PropertyChangeSupport.java:263)
at java.awt.Component.firePropertyChange(Component.java:8434)
at javax.swing.text.JTextComponent.setDocument(JTextComponent.java:443)
at javax.swing.JTextPane.setDocument(JTextPane.java:136)
at javax.swing.JEditorPane.setEditorKit(JEditorPane.java:1055)
at javax.swing.JTextPane.setEditorKit(JTextPane.java:473)
at com.stackoverflow.ConsoleTest.<init>(ConsoleTest.java:53)
...
Trying to find resources describing how to do this properly has proven to be quite difficult. I would appreciate it if someone could describe how to use AsyncBoxView to improve EDT responsiveness.
Note: if you set USE_ASYNC_BOX_VIEW to false, you can see what I mean by performance limits, though my actual use case performs much worse compared to this simple example.
Edit:
Which is line 511 in file AsyncBoxView.java?
The exception is thrown at cs.preferenceChanged(width, height);
below (JDK 1.8).
public synchronized void preferenceChanged(View child, boolean width, boolean height) {
if (child == null) {
getParent().preferenceChanged(this, width, height);
} else {
if (changing != null) {
View cv = changing.getChildView();
if (cv == child) {
// size was being changed on the child, no need to
// queue work for it.
changing.preferenceChanged(width, height);
return;
}
}
int index = getViewIndex(child.getStartOffset(),
Position.Bias.Forward);
ChildState cs = getChildState(index);
cs.preferenceChanged(width, height);
LayoutQueue q = getLayoutQueue();
q.addTask(cs);
q.addTask(flushTask);
}
}
Edit:
I managed to make my example work by changing the order of calls during init and ensuring that setting the editor kit does not change the original document from JTextPane constructor call (I did an override of StyledEditorKit.createDefaultDocument()
and made it return the same original instance of DefaultStyledDocument). It still threw an NPE after JTextPane.setEditable(false)
so I set that before setting editor kit.
final JTextPane console = new JTextPane();
console.setFont(new Font("Monospaced", Font.PLAIN, 12));
console.setEditable(false);
final DefaultStyledDocument document = (DefaultStyledDocument) console.getDocument();
console.setEditorKit(new ConsoleEditorKit(document));
public static class ConsoleEditorKit extends StyledEditorKit {
final DefaultStyledDocument document;
public ConsoleEditorKit(DefaultStyledDocument document) {
this.document = document;
}
@Override
public Document createDefaultDocument() {
return document;
}
// ...
}
Unfortunately, this is not an option for my real use case, since toggling editable property is a must there. Besides it seems to throw the NPE on other JTextPane property changes, such as JTextPane.setFont(Font), as well, unless done prior to setting editor kit instance. So my question still stands. How do you use AsyncBoxView?
Edit:
I've now experienced the same NPE even after simply inserting text into JTextPane, so working around the issue as described in the Edit above is pointless.
java.lang.NullPointerException
at javax.swing.text.AsyncBoxView.preferenceChanged(AsyncBoxView.java:511)
at javax.swing.text.View.preferenceChanged(View.java:288)
at javax.swing.text.BoxView.preferenceChanged(BoxView.java:286)
at javax.swing.text.FlowView$FlowStrategy.insertUpdate(FlowView.java:380)
at javax.swing.text.FlowView.loadChildren(FlowView.java:143)
at javax.swing.text.CompositeView.setParent(CompositeView.java:139)
at javax.swing.text.FlowView.setParent(FlowView.java:289)
at javax.swing.text.AsyncBoxView$ChildState.<init>(AsyncBoxView.java:1211)
at javax.swing.text.AsyncBoxView.createChildState(AsyncBoxView.java:220)
at javax.swing.text.AsyncBoxView.replace(AsyncBoxView.java:374)
at javax.swing.text.AsyncBoxView.loadChildren(AsyncBoxView.java:411)
at javax.swing.text.AsyncBoxView.setParent(AsyncBoxView.java:479)
at javax.swing.plaf.basic.BasicTextUI$RootView.setView(BasicTextUI.java:1328)
at javax.swing.plaf.basic.BasicTextUI.setView(BasicTextUI.java:693)
at javax.swing.plaf.basic.BasicTextUI.modelChanged(BasicTextUI.java:682)
at javax.swing.plaf.basic.BasicTextUI$UpdateHandler.insertUpdate(BasicTextUI.java:1862)
at javax.swing.text.AbstractDocument.fireInsertUpdate(AbstractDocument.java:201)
at javax.swing.text.AbstractDocument.handleInsertString(AbstractDocument.java:748)
at javax.swing.text.AbstractDocument.access$200(AbstractDocument.java:99)
at javax.swing.text.AbstractDocument$DefaultFilterBypass.insertString(AbstractDocument.java:3107)
...
AsyncBoxView.java
? I am on JDK 14 and line 511 is the javadoc comments for methodpreferenceChanged()
. However line 515 is:getParent().preferenceChanged(this, width, height)
I'm guessing that the "parent" is null when methodpreferenceChanged()
is called. – Skewbackcs
is null which means that methodgetChildState(index)
returns null. Looking at the code for methodgetChildState()
, the method returns null ifindex
is invalid or the class memberstats
(whose type isList<ChildState>
) contains a null element atindex
. You could add someprintln()
statements in the code, compile it and then replace the class in the relevant JAR file with your compiled class. – Skewback