我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)

效果演示

先放几张效果图:

哈哈哈,还可以吧?


诞生背景

去年在新电脑上看视频的时候,在触摸板上做了一个缩放的手势把程序列表call出来了:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第1张图片

我那时候是纯黑色的壁纸,视频也刚好播放到白色衣服人物在黑夜中的画面,加上若隐若现的应用程序图标,这虚实结合的效果使得画面中的人物变得立体起来了!甚至有一种身临其境的感觉!

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第2张图片

我当时就觉得,哇这种效果好棒啊,就像在播放透明背景的视频一样。记得那时候还在鸿神的群里讨论了一下关于播放透明视频的话题,后面有群友提到Android Studio就有个自带的设置透明背景图的功能。

第二天,好奇的我想知道Android Studio它是怎么做到把窗口下所有组件都设置成半透明并且把图片放进去的。。。


原理探索(同样适用研究其他功能)

这一节需要用到IntelliJ IDEA来进行debug,以Android Studio作为这次debug的目标,没有Android Studio的同学,用JetBrains系列的其他IDE也可以,都有这个功能的。

先把IDE对应版本的源码下载下来,看下AS的Build Version:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第3张图片

可以看到是202.7660开头的,然后在 JetBrains/intellij-community 这里找到对应的idea版本:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第4张图片

下载、解压(我一般会把它重命名为source并放到IDEA的根目录下)。

接着打开IDEA,创建一个Plugin项目(这一步网上教程多如牛毛,这里就不赘述了),在Project Structure -> SDKs里把刚刚解压的源码关联上;

关联源码之后,到Run/Debug Configuration里新建一个Remote JVM Debug的configuration:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第5张图片

先不要关闭页面,打开Android Studio程序目录下的bin文件夹,找到studio.sh(Windows系统的是studio.bat),复制一份,可以命名为debug.sh(Windows系统保留bat后缀),然后编辑:
在文件的末尾,Run the IDE注释的下面会有"$JAVA_BIN"(Windows系统是"%JAVA_EXE%")字眼的:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第6张图片

在它后面加上刚刚新建的configuration里面的一串命令行参数,比如我的是:-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
像这样:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第7张图片

编辑之后保存,顺便保存刚刚新建的configuration。


好了,可以正式开始了,现在从终端里运行刚刚修改过的debug.sh,然后打开Settings -> Appearance & Behavior -> Appearance

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第8张图片

看到那个Background Image按钮没有?我们现在就要debug它的鼠标事件,以拿到这个按钮的对象,然后把它的listener扒出来。
现在可以回到IDEA这边,点一下那个绿色的小虫子,attach到AS进程了,成功之后会弹出debug控制台窗口并有以下字眼:

preview

没有就是没成功,请重试上面的步骤。

成功attach之后,开始打断点。
随便新建一个类,在里面输入java.awt.Component,然后点开它的源码并找到processMouseEvent(MouseEvent e)这个方法:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第9张图片

在它调用listener.mousePressed方法那一行打个断点(监听鼠标按下)。接着回到AS窗口,鼠标点一下那个Background Image按钮:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第10张图片

会看到已经断点成功了,当前点击的Component是个JButton,熟悉Java Swing的同学会知道,这个JButton继承自JComponent,而JComponent里有个listenerList,里面的数组是专门存放EventListener的,看一下这个JButton设置了哪些listener:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第11张图片

看第4个listener,它里面持有一个AnAction的引用,这个AnAction的实例是SetBackgroundImageAction,还有toString的内容也是"Set Background Image"。
基本可以断定它就是这个按钮所对应的Action了,SHIFT + F4看看这个类的代码:

public class SetBackgroundImageAction extends DumbAwareAction {
    
  ...  

  @Override
  public void actionPerformed(@NotNull AnActionEvent e) {
    Project project = e.getProject();
    if (project == null) return;
    VirtualFile file = e.getData(CommonDataKeys.VIRTUAL_FILE);
    boolean image = file != null && ImageFileTypeManager.getInstance().isImage(file);
    BackgroundImageDialog dialog = new BackgroundImageDialog(project, image ? file.getPath() : null);
    dialog.showAndGet();
  }
}

它在重写的actionPerformed方法里创建了BackgroundImageDialog对象并调用了showAndGet方法。
好,按F9让代码继续运行,果然弹出了一个这样的dialog:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第12张图片

随便设置一张图片,然后点OK:

emmmm,看来就是最后点击的OK按钮生效的,来看看这个BackgroundImageDialog的代码:

public class BackgroundImageDialog extends DialogWrapper {
    
    @Override
    protected void doOKAction() {
        super.doOKAction();

        storeRecentImages();
        String value = calcNewValue();
        String prop = getSystemProp();
        myResults.put(prop, value);

        if (value.startsWith(",")) value = null;

        boolean perProject = myThisProjectOnlyCb.isSelected();
        PropertiesComponent.getInstance(myProject).setValue(prop, perProject ? value : null);
        if (!perProject) {
            PropertiesComponent.getInstance().setValue(prop, value);
        }

        repaintAllWindows();
    }
    
    public static void repaintAllWindows() {
        UISettings.getInstance().fireUISettingsChanged();
        for (Window window : Window.getWindows()) {
            window.repaint();
        }
    }
}    

doOKAction方法,逻辑比较清晰,无非做了4件事:

  1. 保存最近使用过的图片路径;
  2. 获取当前图片路径和一些设置相关的信息;
  3. 应用当前设置的图片;
  4. 重绘所有窗口;

我们重点看第3,也就是PropertiesComponent那几句,稍微把它改造一下让它看起来更直观些:

boolean currentProjectOnly = myThisProjectOnlyCb.isSelected();
if (currentProjectOnly) {
    // 只对当前项目生效
    PropertiesComponent.getInstance(myProject).setValue(prop, value);
} else {
    // 清除当前项目设置的背景
    PropertiesComponent.getInstance(myProject).setValue(prop, null);
    // 换成全局的
    PropertiesComponent.getInstance().setValue(prop, value);
}

熟悉IDEA插件开发的同学会知道,这个PropertiesComponent其实只是持久化键值对的工具类,并不会直接给窗口设置背景图。也就是说,上面PropertiesComponent.getInstance().setValue(prop, value)这句代码,只是把prop=value这个键值对保存到本地而已。
先来弄清楚它这个键值对是怎么样的吧,在doOKAction方法中打个断点:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第13张图片

注意到这个value的格式没有?
它完整的格式其实是这样的:
图片绝对路径,透明度,绘制方式,基准点,翻转(可选项)

透明度的取值范围是0~100
图片的绘制方式有:plain(原始尺寸)、scale(缩放到合适的大小)、tile(平铺);
基准点top-lefttop-centertop-rightmiddle-leftcentermiddle-rightbottom-leftbottom-centerbottom-right
翻转flipH(水平翻转)、flipV(垂直翻转)、flipHV(水平垂直翻转);

prop(key)是固定的:"idea.background.editor"

好了,现在我们已经知道了背景图的设置方式,接下来就试着简单实现一下这个功能。


动手实践

像其他插件一样,先创建一个Action:

class SetBackgroundAction : AnAction() {
    override fun actionPerformed(event: AnActionEvent) {
        // 固定的key
        val key = "idea.background.editor"
        
        // 图片路径
        val path = "/home/wuyr/Downloads/Images/DSC00892.JPG"
        // 15%透明度
        val transparency = 15
        // 自动缩放图片以适应屏幕
        val type = "scale"
        // 以图片中心区域为基点进行缩放变换
        val pivot = "center"
        // 拼接格式
        val value = "$path,$transparency,$type,$pivot"

        // 更新到本地
        PropertiesComponent.getInstance().setValue(key, value)
        // 重绘所有窗口
        IdeBackgroundUtil.repaintAllWindows()
    }
}

这个value的格式是完全按照上面的BackgroundImageDialog来定义的。
接着在plugin.xml里声明这个Action:

<actions>
    <action id="SetBackgroundAction" class="SetBackgroundAction" text="Set Background">
        <add-to-group group-id="ViewMenu" anchor="last"/>
    action>
actions>

id可以随便设置,保证跟其他Action不冲突就行;
class就是Action的完整类名(这个Action我没有放在任何一个package下所以这里看上去只有一个类名);
text:显示的文本;
add-to-group这一行表示我们把这个按钮放在菜单栏View的底部。

好,编译运行看一下效果:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第14张图片

点击这个选项,会发现背景图已经成功替换成Action里面指向的图片了。

现在,想让背景动起来非常简单,只需要周期性地更换图片的路径就行。不过当你真的这样做的时候,你会发现调用repaintAllWindows重绘界面会非常非常的慢,就跟播放PPT一样!
也许我们不应该直接使用这种方法来做动态背景效果,还是再研究一下它具体是怎么实现绘制背景图的吧,看看能不能找到粒度更小的实现方式。


再探原理

现在已经知道背景图相关信息是通过PropertiesComponent.setValue方法来保存到本地。有set肯定还有get,我们干脆就在getValue方法打个断点,分析下调用的源头:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第15张图片

直接往下面看,它是在JComponent进行绘制的时候调用的,JComponent在绘制之前,会先通过getComponentGraphics方法来获取Graphics对象(这个Graphics可以理解为Android中的Canvas,是用来绘制各种图形的),可以看到当前断点的对象(IdeStatusBarImpl)重写了这个getComponentGraphics方法,来看看它是怎么实现的:

@Override
protected Graphics getComponentGraphics(Graphics g) {
  return JBSwingUtilities.runGlobalCGTransform(this, super.getComponentGraphics(g));
}

很简单,就调用了JBSwingUtilities的静态方法runGlobalCGTransform,这个方法会返回一个Graphics,看下代码:

public final class JBSwingUtilities {

  private static final List<BiFunction> ourGlobalTransform = new CopyOnWriteArrayList<>(Collections.emptyList());

  public static Disposable addGlobalCGTransform(BiFunction fun) {
    ourGlobalTransform.add(fun);
  }

  public static Graphics2D runGlobalCGTransform(JComponent c, Graphics g) {
    Graphics2D gg = (Graphics2D)g;
    for (BiFunction transform : ourGlobalTransform) {
      gg = transform.apply(c, gg);
    }
    return gg;
  }
}

噢,原来它是遍历一个叫ourGlobalTransform的list,并调用这个list中所有BiFunction的apply方法。
注意看,它每次调用apply方法都会把本地变量gg这个Graphics2D对象传进去,同时apply方法又会返回一个Graphics2D对象,又赋值给了gg,看样子这就是在给Graphics2D打包装(装饰器模式 Decorator Pattern)。
现在关键点就到了这个ourGlobalTransform里的BiFunction实例是怎么来的,全局搜一下看看有哪些地方调用了addGlobalCGTransform方法:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第16张图片

太好了,就只有三个地方调用,而且都是在同一个类里面,能省不少力气。
看第一个地方,它居然在类加载的时候就调用addGlobalCGTransform方法了,还把一个新的MyTransform实例传了进去,这个MyTransform是IdeBackgroundUtil的静态内部类:

public final class IdeBackgroundUtil {

  static {
    JBSwingUtilities.addGlobalCGTransform(new MyTransform());
  }

  private static final class MyTransform implements BiFunction<JComponent, Graphics2D, Graphics2D> {

    @Override
    public Graphics2D apply(JComponent c, Graphics2D g) {

      .......

      String type = getComponentType(c);
      if (type == null) return g;

      .......

      Graphics2D gg = withEditorBackground(g, c);

      .......                     

      return gg;
    }
  }
}

它实现的apply方法也没有想象中的复杂,先是调用了getComponentType,判断返回值是否为null,如果是null的话就直接返回传进来的Graphics2D对象,不做任何包装。
不为null就调用IdeBackgroundUtil的静态方法withEditorBackground,并返回这个方法的返回值。

好,先看看getComponentType方法吧,看看什么情况下会返回null:

private static @Nullable String getComponentType(JComponent component) {
  return component instanceof JTree ? "tree" :
         component instanceof JList ? "list" :
         component instanceof JTable ? "table" :
         component instanceof JViewport ? "viewport" :
         component instanceof JTabbedPane ? "tabs" :
         component instanceof JButton ? "button" :
         component instanceof ActionToolbar ? "toolbar" :
         component instanceof StatusBar ? "statusbar" :
         component instanceof JMenuBar || component instanceof JMenu? "menubar" :
         component instanceof Stripe ? "stripe" :
         component instanceof EditorsSplitters ? "frame" :
         component instanceof EditorComponentImpl ? "editor" :
         component instanceof EditorGutterComponentEx ? "editor" :
         component instanceof JBLoadingPanel ? "loading" :
         component instanceof JBTabs ? "tabs" :
         component instanceof ToolWindowHeader ? "title" :
         component instanceof JBPanelWithEmptyText ? "panel" :
         component instanceof JPanel && isKnownName(component.getName()) ? component.getName() :
         null;
}

只要当前的JComponent属于上面列出的这些类的实例,就不为null,就能进一步调用withEditorBackground方法。而现在列出来的component已经涵盖绝大部分的基础组件了。
再来看看withEditorBackground方法:

public final class IdeBackgroundUtil {

  public static @NotNull Graphics2D withEditorBackground(@NotNull Graphics g, @NotNull JComponent component) {
    return withNamedPainters(g, EDITOR_PROP, component);
  }

  private static Graphics2D withNamedPainters(Graphics g, String paintersName, JComponent component) {
    JRootPane rootPane = component.getRootPane();
    Component glassPane = rootPane == null ? null : rootPane.getGlassPane();
    PaintersHelper helper = glassPane instanceof IdeGlassPaneImpl? ((IdeGlassPaneImpl)glassPane).getNamedPainters(paintersName) : null;
    if (helper == null || !helper.needsRepaint()) return (Graphics2D)g;
    return MyGraphics.wrap(g, helper, component);
  }
}

它是直接调用了withNamedPainters方法,注意看,withNamedPainters里有好几层判断,先整理一下,变成以下代码:

private static Graphics2D withNamedPainters(Graphics g, String paintersName, JComponent component) {
    JRootPane rootPane = component.getRootPane();
    if (rootPane != null) {
        Component glassPane = rootPane.getGlassPane();
        if (glassPane instanceof IdeGlassPaneImpl) {
            PaintersHelper helper = glassPane.getNamedPainters(paintersName);
            if (helper != null && helper.needsRepaint()) {
                return MyGraphics.wrap(g, helper, component);
            }
        }
    }
    return (Graphics2D) g;
}

它一定要:

  • Component的RootPane不为null;
  • RootPane里面的GlassPane不为null;
  • GlassPane是IdeGlassPaneImpl的实例;
  • GlassPane里面有对应名字的PaintersHelper对象;
  • 对应名字的PaintersHelper对象的needsRepaint方法返回true

符合以上所有条件,才会对传进来的Graphics进行包装。
看它第一个return,它调用的是MyGraphics.wrap方法,这个方法会返回一个做过手脚的Graphics对象,继承自Graphics2DDelegate:

public class Graphics2DDelegate extends Graphics2D{
  protected final Graphics2D myDelegate;

  public Graphics2DDelegate(Graphics2D g2d){
    myDelegate=g2d;
  }

  @Override
  public void fillRect(int x, int y, int width, int height) {
    myDelegate.fillRect(x, y, width, height);
  }

  @Override
  public void fillArc(int x, int y, int width, int height, int startAngle, int arcAngle) {
    myDelegate.fillArc(x, y, width, height, startAngle, arcAngle);
  }

  @Override
  public void drawImage(BufferedImage img, BufferedImageOp op, int x, int y) {
    myDelegate.drawImage(img, op, x, y);
  }

  ......
  ......

}

看到没有,这就是很典型的静态代理写法。MyGraphics也重写了Graphics2DDelegate所有重写的方法:

private static final class MyGraphics extends Graphics2DDelegate {

    static Graphics2D wrap(Graphics g, PaintersHelper helper, JComponent component) {
      MyGraphics gg = g instanceof MyGraphics ? (MyGraphics)g : null;
      return new MyGraphics(gg != null ? gg.myDelegate : g, helper, helper.computeOffsets(g, component), gg != null ? gg.preserved : null);
    }

    @Override
    public void fillRect(int x, int y, int width, int height) {
      super.fillRect(x, y, width, height);
      runAllPainters(x, y, width, height, null, getColor());
    }

    @Override
    public void fillArc(int x, int y, int width, int height, int startAngle, int arcAngle) {
      super.fillArc(x, y, width, height, startAngle, arcAngle);
      runAllPainters(x, y, width, height, new Arc2D.Double(x, y, width, height, startAngle, arcAngle, Arc2D.PIE), getColor());
    }

    @Override
    public void drawImage(BufferedImage img, BufferedImageOp op, int x, int y) {
      super.drawImage(img, op, x, y);
      runAllPainters(x, y, img.getWidth(), img.getHeight(), null, img);
    }

    ......
    ......

    final PaintersHelper helper;

    void runAllPainters(int x, int y, int width, int height, @Nullable Shape sourceShape, @Nullable Object reason) {
      setClip(tmpClip);
      helper.runAllPainters(myDelegate, offsets);
      setClip(prevClip);
    }

}

相比Graphics2DDelegate,MyGraphics还多了一个runAllPainters方法,而且在每个重写的方法里都会调用它。
这个runAllPainters里面又会调用PaintersHelperrunAllPainters方法,并将原始的Graphics对象传了进去,看下代码:

final class PaintersHelper implements Painter.Listener {

  private final Set<Painter> painters = new LinkedHashSet<>();

  void addPainter(@NotNull Painter painter, @Nullable Component component) {
    painters.add(painter);
    ......
  }

  void runAllPainters(Graphics gg, @Nullable Offsets offsets) {
    ......
    Graphics2D g = (Graphics2D)gg;
    for (Painter painter : painters) {
        ......
       if (painter.needsRepaint()) {
         painter.paint(cur, g);
      }
    }
  }
}

PaintersHelper中有个成员变量painters,它是一个LinkedHashSet。
注意看,在runAllPainters方法中,会遍历这个painters,如果painters中的元素的needsRepaint方法返回true的话,就会进一步调用它的paint方法!提醒一下! 当外面的Component调用这个被做过手脚的Graphics(MyGraphics)对象的任何一个绘制方法时,都会走到这里!

PaintersHelper里还有一个addPainter方法,用来添加实现了Painter接口的对象实例。也就是说,如果我们能获取到对应的PaintersHelper实例,就能直接通过它的addPainter方法把自己实现的Painter添加进去,就能监听到对应Component的每一次重绘!!!

至于这个PaintersHelper实例在哪里,要怎么获取,其实答案就藏在前面的代码里,现在往上翻,找到分析withNamedPainters方法那一段代码:

private static Graphics2D withNamedPainters(Graphics g, String paintersName, JComponent component) {
    JRootPane rootPane = component.getRootPane();
    if (rootPane != null) {
        Component glassPane = rootPane.getGlassPane();
        if (glassPane instanceof IdeGlassPaneImpl) {
            PaintersHelper helper = glassPane.getNamedPainters(paintersName);
            ......
        }
    }
    ......
}

看到了没有,PaintersHelper就放在Window(component.getRootPane()其实获取到的是所在JFrame(JFrame就是Window)的RootPane实例)的RootPane的GlassPane里面!!!而Window的实例我们可以轻易拿到。

好了,监听到Component重绘之后,具体要怎么做才能将带有透明度的背景图绘制出来呢?
上面说到了Painter接口的两个重要的方法:needsRepaintpaint,看看在哪里实现了Painter接口:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第17张图片

总共有四个地方,但仔细一看,后面三个都是在同一个文件里,而且看它的名字是跟分割线有关,应该不是我们要找的。
排第一的AbstractPainter,是一个抽象类:

public abstract class AbstractPainter implements Painter {

  ......
  ......

  @Override
  public final void paint(final Component component, final Graphics2D g) {
    myNeedsRepaint = false;
    executePaint(component, g);
  }

  public abstract void executePaint(final Component component, final Graphics2D g);
}

它实现了paint方法并标记为final,在里面调用了抽象方法executePaint,所以如果我们要实现它的话,只重写它的needsRepaintexecutePaint方法即可。
来看下现在有哪些类继承了它:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第18张图片

看第八条结果,是PaintersHelper的抽象静态内部类。点进去会发现,有一个叫MyImagePainter的类继承了它并重写了needsRepaintexecutePaint方法:

private static final class MyImagePainter extends ImagePainter {

    ......
    
    @Override
    public boolean needsRepaint() {
      return ensureImageLoaded();
    }

    boolean ensureImageLoaded() {
      IdeFrame frame = ComponentUtil.getParentOfType(IdeFrame.class, rootComponent);
      Project project = frame == null ? null : frame.getProject();
      String value = IdeBackgroundUtil.getBackgroundSpec(project, propertyName);
      if (!Objects.equals(value, current)) {
        current = value;
        loadImageAsync(value);
        // keep the current image for a while
      }
      return image != null;
    }
}

先看needsRepaint,前面说过,如果它返回true,PaintersHelper就会进一步调用paint方法。
MyImagePainter的实现是直接返回ensureImageLoaded方法的结果,这个方法会先判断是否有设置图片并且已经加载完成,如果没有设置图片那肯定返回false,有设置图片但还没加载的话,会【异步加载图片】并返回false,只有在图片加载成功之后才是true
loadImageAsync的代码有点多,就不贴不出来了,主要是对那个value的格式:图片绝对路径,透明度,绘制方式,基准点,翻转 进行解析,加载图片并对图片进行对应的变换操作。

好,最后就到executePaint了,也是最重要的一个方法:

private static final class MyImagePainter extends ImagePainter {

    private Image image;

    ......

    @Override
    public void executePaint(Component component, Graphics2D g) {
      if (image == null) {
        // covered by needsRepaint()
        return;
      }
      executePaint(g, component, image, fillType, anchor, alpha, insets);
    }
}

它这里的做法是,如果image不为null则调用另一个executePaint,这方法有点长,但逻辑并不复杂:

private static final class MyImagePainter extends ImagePainter {

    final Map<GraphicsConfiguration, Cached> cachedMap = new HashMap<>();

    void executePaint(Graphics2D g, Component component, Image image, IdeBackgroundUtil.Fill fillType, IdeBackgroundUtil.Anchor anchor, float alpha, Insets insets) {
      ......
      Cached cached = cachedMap.get(cfg);
      // 从缓存中取出(如果有的话)
      VolatileImage scaled = cached == null ? null : cached.image;
      ......
      int sw0 = scaled == null ? -1 : scaled.getWidth(null);
      int sh0 = scaled == null ? -1 : scaled.getHeight(null);
      // 如果图片未缓存过,或者窗口尺寸有变更,则需要加载
      boolean repaint = cached == null || !cached.src.equals(src0) || !cached.dst.equals(dst0);
      while ((scaled = validateImage(cfg, scaled)) == null || repaint) {
        int sw = Math.min(cw, dst0.width);
        int sh = Math.min(ch, dst0.height);
        if (scaled == null || sw0 < sw || sh0 < sh) {
          // 当前图片已变更,则重新加载图片并缩放到指定的尺寸
          scaled = createImage(cfg, sw, sh);
          // 缓存起来
          cachedMap.put(cfg, cached = new Cached(scaled, src0, dst0));
        } else {
          // 图片没变,只是窗口尺寸有变化的话,则直接刷新缓存的尺寸
          cached.src.setBounds(src0);
          cached.dst.setBounds(dst0);
        }
        Graphics2D gg = scaled.createGraphics();
        // 混合模式为SRC
        gg.setComposite(AlphaComposite.Src);
        // 图片绘制方式为 缩放模式
        if (fillType == IdeBackgroundUtil.Fill.SCALE) {
          // 使用双线性插值法来处理图片缩放
          gg.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
          // draw到硬件加速的VolatileImage上
          StartupUiUtil.drawImage(gg, image, dst0, src0, null);
        } 
        // 图片绘制方式为 平铺模式
        else if (fillType == IdeBackgroundUtil.Fill.TILE) {
          Rectangle r = new Rectangle(0, 0, 0, 0);
          for (int x = 0; x < dst0.width; x += w) {
            for (int y = 0; y < dst0.height; y += h) {
              r.setBounds(dst0.x + x, dst0.y + y, src0.width, src0.height);
              // 反复绘制,直到记录的尺寸大于目标尺寸为止
              StartupUiUtil.drawImage(gg, image, r, src0, null);
            }
          }
        } 
        // 原尺寸绘制
        else {
          // 直接draw到硬件加速的VolatileImage上,不需任何多余的操作
          StartupUiUtil.drawImage(gg, image, dst0, src0, null);
        }
        gg.dispose();
        repaint = false;
      }

      // 设置透明度
      GraphicsConfig gc = new GraphicsConfig(g).setAlpha(adjustedAlpha);
      // 把图片draw出来
      StartupUiUtil.drawImage(g, scaled, dst, src, null, null);
      gc.restore();
    }

}

它大概只做了三件事:

  1. 获取上一次缓存过的image
  2. 如果缓存为空,或者组件的当前尺寸有变更,则重新根据绘制模式(平铺、缩放)将图片绘制到支持硬件加速的VolatileImage上;
  3. 把VolatileImage的内容draw到Graphics上面;

有同学可能会问:为什么不把加载好的图片直接绘制到Graphics上,而非要在中间加一个VolatileImage呢?,这不是多此一举嘛?
其实不是的,这样做虽然看起来是绘制了两次,影响效率,但实际上要比直接绘制一次快得多,因为上面也强调了是支持硬件加速的VolatileImage。不信的话,等下我们可以来测试一下。


好,现在具体的绘制方法我们也已经知道了,最后来看下这个MyImagePainter是怎么生效的吧:

final class PaintersHelper implements Painter.Listener {

  private final Set<Painter> painters = new LinkedHashSet<>();

  void addPainter(@NotNull Painter painter, @Nullable Component component) {
    painters.add(painter);
    painterToComponent.put(painter, component == null ? rootComponent : component);
    painter.addListener(this);
  }

  static void initWallpaperPainter(@NotNull String propertyName, @NotNull PaintersHelper painters) {
    painters.addPainter(new MyImagePainter(painters.rootComponent, propertyName), null);
  }
}

在PaintersHelper的静态方法initWallpaperPainter里会看到,它是通过前面说到的addPainter方法来添加一个新的MyImagePainter实例。
这个initWallpaperPainter是在IdeBackgroundUtil的initEditorPainters里面调用:

public final class IdeBackgroundUtil {

  ......

  static void initEditorPainters(@NotNull IdeGlassPaneImpl glassPane) {
    PaintersHelper.initWallpaperPainter(EDITOR_PROP, glassPane.getNamedPainters(EDITOR_PROP));
  }
}

再上一层:

public class IdeGlassPaneImpl extends JPanel implements IdeGlassPaneEx, IdeEventQueue.EventDispatcher {
 
  ......
 
  public IdeGlassPaneImpl(JRootPane rootPane, boolean installPainters) {
    ......
    if (installPainters) {
      IdeBackgroundUtil.initEditorPainters(this);
    }
    ......
  }
}

看到没有,GlassPane!!!
当这个IdeGlassPaneImpl初始化时,如果参数installPainters为true的话,就会调用IdeBackgroundUtil.initEditorPainters最终把MyImagePainter的实例添加进PaintersHelper中监听Components的绘制!!!
这就刚好跟我们前面分析的【Window、RootPane、GlassPane、PaintersHelper】的关系对应上了!!!

好了,终于分析完了。。。
喘口气,来把整个流程捋一下:

  • Component在进行绘制时,会通过getComponentGraphics方法来获取Graphics对象(切入点就在这里);

  • IDEA在各个相关容器里都重写了getComponentGraphics方法,并返回了【通过JBSwingUtilities.runGlobalCGTransform获取到的一个做过手脚的Graphics对象】;

  • 这个做过手脚的Graphics对象的实例是IdeBackgroundUtil的静态内部类MyGraphics,它重写了Graphics所有绘制相关的方法,并在每一个绘制方法里面都调用PaintersHelper.runAllPainters

  • PaintersHelper里面有一个集合专门存放Painter(Painter可以理解为用来监听绘制的Callback),runAllPainters方法会遍历这个集合,回调每一个Painter的paint方法(如果它的needsRepaint返回true的话);

  • IDEA自带的设置透明背景图功能,就是在IdeGlassPaneImpl初始化的时候,将自定义的Painter添加进PaintersHelper来监听Component的绘制,当Component进行绘制时,就把设置的背景图画出来;

大概就是这样。
那接下来可以正式动手写代码了。


动态背景

我们大致的思路是:
借助现成的(毕竟这个还自己搞的话,不现实)javacv来把视频每一帧图片解析出来,然后根据视频帧率将这些图片绘制到背景板上。

先到 https://github.com/bytedeco/javacv/releases 把最新的 javacv-platform-[version]-bin.zip 下载下来。
接下来需要用到的jar包有:

  • ffmpeg.jar
    ffmpeg-linux-x86_64.jar
    ffmpeg-macosx-x86_64.jar
    ffmpeg-windows-x86_64.jar
  • javacpp.jar
    javacpp-linux-x86_64.jar
    javacpp-macosx-x86_64.jar
    javacpp-windows-x86_64.jar
  • javacv.jar

后面带平台字眼的都是动态链接库,但因为现在只是在本机上测试,暂时不需要考虑跨平台,动态链接库的jar可以只取适合自己平台的,比如我的是64位的linux系统,所以只复制带linux-x86_64字眼的即可:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第19张图片

复制进项目之后,到Project Structure -> Libraries里加上依赖,这个不必多说:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第20张图片

OK,现在可以正常使用这些jar包了。


先把解析视频帧这一块弄好吧。

其实刚刚下载下来的那个javacv压缩包里就有示例代码,在samples/JavaFxPlayVideoAndAudio.java里面。

  • 我们用到javacv的类有:FFmpegFrameGrabber(提取视频帧)、Java2DFrameConverter(转换视频帧);
  • 提取和转换视频帧不可能在主线程中完成的,这种情况当然是把它放在Runnable里,然后扔进线程池最好了;
  • 我们还需要一个阻塞队列,用来缓存转换之后的图片;

好,来看下代码怎么写:

object MediaPlayer {

    @Volatile
    private var playing = false
    private val threadPool = Executors.newCachedThreadPool()

    private var frameGrabber: FFmpegFrameGrabber? = null

    fun init(url: String) {
        frameGrabber = FFmpegFrameGrabber.createDefault(url)
    }

    fun start() {
        frameGrabber?.let { grabber ->
            grabber.imageWidth = rootPane.width
            grabber.imageHeight = rootPane.height
            grabber.start()
            playing = true
            threadPool.execute(frameGrabTask)
        }
    }
}

frameGrabber就是用来提取视频帧的,可以看到它在init方法里面初始化,在start方法中,先是指定了视频帧尺寸为当前窗口大小,然后调用了frameGrabberstart方法,最后把frameGrabTask扔进了线程池里,看下这个frameGrabTask的代码:

object MediaPlayer {

    ......
    private val imageQueue = LinkedBlockingDeque<BufferedImage>(32)

    private val frameGrabTask = Runnable {
        val imageConverter = Java2DFrameConverter()
        while (playing) {
            frameGrabber?.grab()?.also { frame ->
                frame.image?.let {
                    imageConverter.getBufferedImage(frame)?.let {
                        imageQueue.put(it)
                    }
                }
            }
        }
    }
}

大致跟javacv samples/JavaFxPlayVideoAndAudio.java里面的流程一样,都是先通过frameGrabbergrab方法获取到视频帧数据,再通过Java2DFrameConverter来转换成图片,然后put到阻塞队列imageQueue里面。
阻塞队列的容量我们指定为32个(数值太大会消耗很多内存),也就是最多只缓存32帧,如果队列满了,就会自动让提取视频帧的线程阻塞。

好,现在视频的每一帧图片都已经拿到了,来想想怎么把它们draw到背景板上:
要知道,Component的paint方法,在同一次重绘流程中,可能不止被调用一次的,如果在重写的AbstractPainter.executePaint里直接从缓存队列中取出图片然后绘制,那缓存队列里的图片就会很快被清空,一旦队列空了,线程就会阻塞,而且这还是在主线程里阻塞,后果非常严重啊!!!
所以还需要有一个独立的BufferedImage,用来表示当前帧,这样的话,就算executePaint多次被调用,也不会提前把下一帧都消耗掉,也不会因为缓存队列为空而造成主线程阻塞。

可问题又来了:这个独立的BufferedImage,什么时候刷新呢(何时开始获取下一帧)?
不是有线程池嘛?多开一个线程,专门负责刷新视频帧就行了,刷新周期可以根据视频帧率算出。
当然了,还需要有一个专门负责通知重绘的线程,以保证重绘周期的稳定性。
看下代码怎么写:

object MediaPlayer {

    ......
    private lateinit var rootPane: JRootPane
    private var frameImage: BufferedImage? = null
    private var repaintInterval = 0L

    private val imageProcessTask = Runnable {
        while (playing) {
            imageQueue.take().let {
                frameImage = it
                Thread.sleep(repaintInterval)
            }
        }
    }

    private val repaintTask = Runnable {
        while (playing) {
            EventQueue.invokeLater {
                rootPane.repaint()
            }
            Thread.sleep(repaintInterval)
        }
    }

可以看到,除了刚刚说的表示当前帧的frameImage,还多了个rootPanerepaintInterval
rootPane就是当前Window的RootPane,主要用来发起重绘。还记得不?前面分析【设置背景图功能】的时候,IDEA是通过IdeBackgroundUtil.repaintAllWindows()来重绘所有Window的,但是这个方法太重量级了,通常我们播放视频的话,只需要显示在其中一个Window上就行了,所以就用rootPane.repaint()来代替。
repaintInterval就是 1000 / 视频帧率 得出每一帧的间隔时间(ms)。

Runnable创建了之后,还要在start方法里面启动,改一下start方法的代码:

    fun start() {
        frameGrabber?.let { grabber ->
            grabber.imageWidth = rootPane.width
            grabber.imageHeight = rootPane.height
            grabber.start()
            repaintInterval = (1000 / grabber.frameRate).toLong()
            playing = true
            threadPool.execute(frameGrabTask)
            threadPool.execute(imageProcessTask)
            threadPool.execute(repaintTask)
        }
   }

好,那现在就剩下负责绘制的Painter了。
不过我们首先要把addPainter的调用封装好,因为这个addPainter不能直接访问到,只能通过反射来调用。

就按照前面分析过的流程,获取Window(JFrame)的GlassPane里面的PaintersHelper实例。
改一下init方法,加上JFrame参数:

    fun init(window: JFrame, url: String) {
        // 清除当前已设置的背景
        PropertiesComponent.getInstance().setValue("idea.background.editor", null)
        // 注入自定义Painter
        injectPainter(window)

        frameGrabber = FFmpegFrameGrabber.createDefault(url)
    }

嗯,主要代码都在injectPainter方法里面:

object MediaPlayer {

    ......

    private fun injectPainter(window: JFrame) {
        rootPane = window.rootPane.apply {
            val glassPane = glassPane
            if (glassPane is IdeGlassPaneImpl) {
                // 先获取IdeGlassPaneImpl的getNamedPainters方法
                val getNamedPaintersMethod = glassPane::class.java.getDeclaredMethod(
                        "getNamedPainters", String::class.java).apply { isAccessible = true }
                // 通过反射调用,拿到对应的PaintersHelper对象
                val paintersHelper = getNamedPaintersMethod.invoke(glassPane, "idea.background.editor")

                // 获取PaintersHelper的addPainter方法
                val addPainterMethod = paintersHelper::class.java.getDeclaredMethod(
                        "addPainter", com.intellij.openapi.ui.Painter::class.java, java.awt.Component::class.java)
                        .apply { isAccessible = true }
                // 反射调用,把自己实现的Painter添加进去,最后一个参数是executePaint方法的glassPane
                addPainterMethod.invoke(paintersHelper, painter, null)
            }
        }
    }
}

跟前面分析的流程一样,不必多说。
最后到painter

object MediaPlayer {

    ......
    private var alphaComposite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, .15F /* 85%透明度 */)
    private val painter = object : AbstractPainter() {

        // 只要未停止播放,都需要绘制
        override fun needsRepaint() = playing

        override fun executePaint(glassPane: Component, graphics: Graphics2D) {
            frameImage?.let {
                // 先记下原来的composite
                val oldComposite = graphics.composite
                // 把当前composite换成带透明度的
                graphics.composite = alphaComposite
                
                // 视频帧的尺寸
                val imageBounds = Rectangle(0, 0, it.getWidth(null), it.getHeight(null))
                // 把视频帧draw到graphics上,绘制范围指定为glassPane的尺寸大小,也就是充满整个窗口
                StartupUiUtil.drawImage(graphics, it, glassPane.bounds, imageBounds, null)
                
                // 恢复原来的composite
                graphics.composite = oldComposite
            }
        }
    } 
}

OK,现在播放器这边是完成了。
接着创建一个Action,用来启动播放器:

class PlayAction : AnAction() {
    override fun actionPerformed(event: AnActionEvent) {
        // 视频路径(温馨提醒:FFMPEG是支持网络路径的哦)
        val videoUrl = "/home/wuyr/Desktop/万恶淫为首.mp4"
        // 获取当前焦点所在窗口对象
        val frame = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as JFrame
        // 初始化播放器
        MediaPlayer.init(frame, videoUrl)
        // 开始播放
        MediaPlayer.start()
    }
}

记得在plugin.xml里声明一下:

<actions>
    ......
    <action id="PlayAction" class="PlayAction" text="Play Video">
        <add-to-group group-id="ViewMenu" anchor="last"/>
    action>
actions>

好!运行,看看效果(首次运行可能会提示内存不足(默认是512m),把内存限制调大点就行):

救命!这也太卡了叭!!!
我们播放的是1080p 60帧的视频,但卡成这个样子是完全不能接受的,还是不要偷懒,老老实实优化一下性能吧。


性能优化

首先把常规的BufferedImage换成VolatileImage,使其支持硬件加速:
在代码里将frameImage的类型:

    private var frameImage: BufferedImage? = null

改成VolatileImage:

    private var frameImage: VolatileImage? = null
    private var frameImageGraphics: Graphics2D? = null

还加了一个frameImageGraphics,这个Graphics主要负责往VolatileImage里绘制内容。
好,最后到负责更新frameImageimageProcessTask,改成以下这样:

    private val imageProcessTask = Runnable {
        while (playing) {
            imageQueue.take().let { image ->
                frameImage ?: initFrameImage()
                frameImageGraphics?.let { graphics ->
                    val imageBounds = Rectangle(0, 0, image.getWidth(null), image.getHeight(null))
                    StartupUiUtil.drawImage(graphics, image, rootPane.bounds, imageBounds, null)
                }
                image.flush()
                Thread.sleep(repaintInterval)
            }
        }
    }

大致逻辑就是:先从缓存队列中取出视频帧,如果frameImage没有初始化就先初始化,然后通过frameImageGraphics将视频帧的内容绘制到支持硬件加速的frameImage上,最后释放视频帧的资源,周而复始。
重点来了,看下支持硬件加速的frameImage怎么初始化:

    private fun initFrameImage() {
        val config = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration
        frameImage = try {
            config.createCompatibleVolatileImage(rootPane.width, rootPane.height, ImageCapabilities(true), 3)
        } catch (e: Exception) {
            config.createCompatibleVolatileImage(rootPane.width, rootPane.height, 3)
        }.apply {
            validate(null)
            // 最高优先级别
            accelerationPriority = 1F
            frameImageGraphics = createGraphics().apply {
                composite = AlphaComposite.Src
                // 关闭防抖动
                setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_DISABLE)
                // 性能优先
                setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED)
                setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_SPEED)
                setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED)
            }
        }
    }

这一段代码都是在前面分析过的源码里翻出来改造的,不多作解释。
好,再次运行:

看到没有,现在已经比优化前好多了。
不过还不够,虽然现在画面看上去流畅了很多,但依然能感觉到小姐姐舞扇的动作没有原视频快,而且,当播放时间一长,画面刷新的频率还会时高时低。
也就是说:

  1. 绘制视频帧的速度还是赶不上视频原来的帧率;
  2. 画面刷新的周期不稳定;

第一个问题产生原因可能有多个,包括解析(解析慢了会间接导致消费线程阻塞)、(从缓存队列中)取出(队列空了会阻塞)、绘制等等;
第二个问题,因为现在的repaintTask是通过 EventQueue.invokeLater 来切换到DispatchThread(忘记了的同学赶紧翻一下代码),并使用rootPanerepaint方法来发起重绘,invokeLater本身是异步回调的,即我们传进去的Runnable会经过排队之后才执行(对,就跟Android中的Handler.post一样),这样就很有可能会出现好几个重绘任务挤在一起执行的现象,也就是我们看到的画面时快时慢的效果。
要解决这个问题很简单,我们把invokeLater改成invokeAndWait就行了,后者会在绘制任务被执行之前一直阻塞,强行把异步变成同步。
还没完,来看下repaint方法的代码:

public abstract class JComponent extends Container implements Serializable, HasGetTransferHandler {

    ......

    public void repaint() {
        this.repaint(0L, 0, 0, this.width, this.height);
    }

    public void repaint(long tm, int x, int y, int width, int height) {
        RepaintManager.currentManager(SunToolkit.targetToAppContext(this)).addDirtyRegion(this, x, y, width, height);
    }
}

Component的repaint(long, int, int, int, int)方法被JComponent重写了,在里面调用了RepaintManager的addDirtyRegion

public class RepaintManager {
    public void addDirtyRegion(JComponent c, int x, int y, int w, int h) {
        RepaintManager delegate = this.getDelegate(c);
        if (delegate != null) {
            delegate.addDirtyRegion(c, x, y, w, h);
        } else {
            this.addDirtyRegion0(c, x, y, w, h);
        }
    }

    private void addDirtyRegion0(Container c, int x, int y, int w, int h) {
        ......
        ......
        synchronized(this) {
            ......
            this.dirtyComponents.put(c, new Rectangle(x, y, w, h));
        }
        this.scheduleProcessingRunnable(SunToolkit.targetToAppContext(c));
        ......
    }

    private void scheduleProcessingRunnable(AppContext context) {
        Toolkit tk = Toolkit.getDefaultToolkit();
        if (tk instanceof SunToolkit) {
            SunToolkit.getSystemEventQueueImplPP(context).postEvent(new InvocationEvent(Toolkit.getDefaultToolkit(), this.processingRunnable));
        } else {
            Toolkit.getDefaultToolkit().getSystemEventQueue().postEvent(new InvocationEvent(Toolkit.getDefaultToolkit(), this.processingRunnable));
        }
    }
}

addDirtyRegion方法中,如果RepaintManager没有设置代理对象的话(我们这里假设它没有),就会调用addDirtyRegion0addDirtyRegion0方法会先把目标Component的边界信息记录到dirtyComponents里面,然后调用了scheduleProcessingRunnable方法。
看最后的scheduleProcessingRunnable方法,那个if无论走哪个分支,都会调用EventQueue的postEvent方法,再次提交一个任务:processingRunnable,它是内部类ProcessingRunnable的实例,来看下里面做了什么:

    private final class ProcessingRunnable implements Runnable {

        ......

        public void run() {
            ......
            RepaintManager.this.scheduleHeavyWeightPaints();
            RepaintManager.this.validateInvalidComponents();
            RepaintManager.this.prePaintDirtyRegions();
        }
    }

嗯,它分别调用了RepaintManager的scheduleHeavyWeightPaintsvalidateInvalidComponentsprePaintDirtyRegions,直觉告诉我们prePaintDirtyRegions是处理重绘的关键方法,看一下:

    private void prePaintDirtyRegions() {
        ......
        this.paintDirtyRegions();
        ......
    }

    public void paintDirtyRegions() {
        ......
        this.paintDirtyRegions(this.tmpDirtyComponents);
    }

它里面直接调用了paintDirtyRegionspaintDirtyRegions又调用了paintDirtyRegions(Map)

    private void paintDirtyRegions(final Map<Component, Rectangle> tmpDirtyComponents) {
        if (!tmpDirtyComponents.isEmpty()) {
            ......
            for (final int j = 0; j < count.get(); ++j) {
                // 获取到需要重绘的Component
                final Component dirtyComponent = (Component) roots.get(j);
                if (dirtyComponent instanceof JComponent) {
                    // 如果是JComponent则直接调用他的paintImmediately方法来进行重绘
                    ((JComponent) dirtyComponent).paintImmediately(rect.x, rect.y, rect.width, rect.height);
                } else if (dirtyComponent.isShowing()) {
                    // 如果Component可见,则调用其paint方法来进行重绘
                    Graphics g = JComponent.safelyGetGraphics(dirtyComponent, dirtyComponent);
                    if (g != null) {
                        g.setClip(rect.x, rect.y, rect.width, rect.height);
                        try {
                            dirtyComponent.paint(g);
                        } finally {
                            g.dispose();
                        }
                    }
                }
                ......
            }
            ......
            tmpDirtyComponents.clear();
        }
    }

看到没有,Component的重绘,就是在这个方法里发起的。这也证实了我们刚刚的猜测是对的:ProcessingRunnable.run里面调用的prePaintDirtyRegions就是处理Component重绘的关键方法。

来思考一下:
既然我们调用的repaint方法最终都会走到ProcessingRunnable.run,那为什么不直接用反射拿到RepaintManager的这个ProcessingRunnable对象,然后在每次重绘的时候直接调用呢?这岂不是节省了postEvent这个环节了?
还有一点,不只是我们调用的repaint方法,是所有发起重绘的操作最终都会来到ProcessingRunnable.run这暗示着什么? 这就说明,在视频播放过程中,完全可以忽略掉其他地方发起的重绘请求!!!因为我们的重绘任务是不间断地进行的!这样做可以给画面刷新减轻不少压力。

好,现在来看一下刚刚repaint方法获取RepaintManager对象时调用的RepaintManager.currentManager

public class RepaintManager {

    private static final Object repaintManagerKey = RepaintManager.class;

    static RepaintManager currentManager(AppContext appContext) {
        RepaintManager rm = (RepaintManager)appContext.get(repaintManagerKey);
        ......
        return rm;
    }
}

他是调用AppContext的get(Object key)方法来获取到RepaintManager对象的,参数传的就是RepaintManager.class
AppContext其实还有个对应的put(Object key, Object value)方法,我们可以事先把做过手脚的RepaintManager对象通过put方法放进去!!这样其他地方在发起重绘时获取到的RepaintManager就是我们做过手脚的对象了!
我们要怎么做手脚呢?
很简单,重写刚刚分析过的paintDirtyRegions()方法(prePaintDirtyRegions()paintDirtyRegions(Map)都是private的,所以最合适是paintDirtyRegions()了),并在里面根据一个标识来控制调不调用super.paintDirtyRegions()(只要不调用父类方法,本次重绘就不会生效),达到过滤多余重绘请求的效果。

好,捋一下思路:

  1. 先把公共的RepaintManager对象替换成自己做过手脚的RepaintManager;
  2. 通过反射获取RepaintManager的processingRunnable,用来直接处理重绘操作,绕过消息队列;
  3. 在视频播放过程中,忽略掉【除刷新视频帧任务外】的所有重绘请求;

来看看代码怎么写:

    private lateinit var repaintRunnable: Runnable
    private lateinit var repaintManager: RepaintManager
    private var repaintBarrier = false

    private fun replaceRepaintManager() {
        val appContextClass = Class.forName("sun.awt.AppContext")
        val appContext = appContextClass.getMethod("getAppContext").invoke(null)

        val putMethod = appContextClass.getMethod("put", Any::class.java, Any::class.java)
        putMethod.invoke(appContext, RepaintManager::class.java, object : RepaintManager() {
            override fun paintDirtyRegions() {
                if (!repaintBarrier || !playing) {
                    super.paintDirtyRegions()
                }
            }
        }.also { repaintManager = it })
        repaintRunnable = RepaintManager::class.java.getDeclaredField("processingRunnable").run {
            isAccessible = true
            get(repaintManager) as Runnable
        }
    }          

先是通过反射获取到AppContext对象,然后再反射调用AppContext的put方法,把自定义的RepaintManager对象传了进去。
跟着刚刚的思路,在自定义的RepaintManager中重写了paintDirtyRegions方法,并加上条件判断:只有关闭repaintBarrier(重绘屏障)或停止播放的情况下,才会处理重绘请求。
最后同样是通过反射,获取到RepaintManager的processingRunnable,用于绕过消息队列,直接处理重绘请求。

新增一个updateFrameImmediately方法:

    private fun updateFrameImmediately() {
        // 添加脏区
        repaintManager.addDirtyRegion(rootPane, 0, 0, rootPane.width, rootPane.height)
        // 关闭重绘屏障
        repaintBarrier = false
        // 同步处理重绘请求
        repaintRunnable.run()
        // 重新开启屏障
        repaintBarrier = true
    }

第一行调用RepaintManager.addDirtyRegion添加脏区是必须的,因为后面都会根据这些脏区来确定哪些Component需要重绘。
然后是关闭屏障,直接调用repaintRunnable.run()同步处理重绘请求,重绘完成后再开启屏障,这样其他地方发起的重绘请求就没用了。

好了,现在可以把原来repaintTask中的EventQueue.invokeLater { rootPane.repaint() },替换成EventQueue.invokeAndWait { updateFrameImmediately() }了:

    private val repaintTask = Runnable {
        while (playing) {
            (repaintInterval - measureTimeMillis {
                EventQueue.invokeAndWait { updateFrameImmediately() }
            }).let { if (it > 0) Thread.sleep(it) }
        }
    }

细心的同学会发现原来的Thread.sleep(repaintInterval)变成Thread.sleep(it)了,前面还多了个repaintInterval - measureTimeMillis
是的,我们现在把每次固定的睡眠时间改成动态计算了,如果本次重绘比较耗时,线程sleep的时长也会相应地减少,提高流畅度。
当然了,还有imageProcessTask里面的Thread.sleep(repaintInterval)也可以替换成这种方式(代码就不贴了)。

噢!差点忘了刚刚的replaceRepaintManager方法要在init方法里调用,赶紧加上:

    fun init(window: JFrame, url: String) {
        ......
        // 替换做过手脚的RepaintManager
        replaceRepaintManager()
        ......
    }

OK,我们的优化环节已经完成了,运行看下效果如何:

非常流畅!太棒了!!!


收尾工作

最后来处理一下视频播放完毕的善后工作,比如关闭FFmpegFrameGrabber、清空/释放缓存图片资源什么的。
先加一个stop方法:

    fun stop() {
        if (playing) {
            playing = false
            Thread.sleep(repaintInterval * 2)
            removePainter()
            frameGrabber?.close()
            frameGrabber = null
            imageQueue?.clear()
            frameImage?.flush()
            frameImage = null
            frameImageGraphics = null
            rootPane.repaint()
        }
    }

sleep(repaintInterval * 2)是为了保证线程池里面那几个线程能运行结束。
rootPane.repaint()是停止后刷新一下界面,不残留最后一帧。
removePainter方法是移除开始播放时设置的Painter:

    private fun removePainter() {
        val glassPane = rootPane.glassPane
        if (glassPane is IdeGlassPaneImpl) {
            // 先获取IdeGlassPaneImpl的getNamedPainters方法
            val getNamedPaintersMethod = glassPane::class.java.getDeclaredMethod(
                    "getNamedPainters", String::class.java).apply { isAccessible = true }
            // 通过反射调用,拿到对应的PaintersHelper对象
            val paintersHelper = getNamedPaintersMethod.invoke(glassPane, "idea.background.editor")

            // 获取PaintersHelper的removePainter方法
            val removePainterMethod = paintersHelper::class.java.getDeclaredMethod(
                    "removePainter", com.intellij.openapi.ui.Painter::class.java)
                    .apply { isAccessible = true }
            // 反射调用
            removePainterMethod.invoke(paintersHelper, painter)
        }
    }

还没完噢,刚刚的stop是主动停止,还有播放结束自动停止的呢。
我们的思路是:frameGrabber把视频帧都解析完了(grab方法返回null)之后,就把imageQueue置空并结束线程。然后在imageProcessTask那边,判断到imageQueue为空则主动释放资源并结束线程。
好,先把imageQueue的类型改成可空:

    private var imageQueue: LinkedBlockingDeque<BufferedImage>? = null

把初始化放在start的时候:

    fun start() {
        frameGrabber?.let { grabber ->
            ......
            playing = true
            imageQueue = LinkedBlockingDeque(32)
            ......
        }
    }

跟着刚刚的思路,修改一下frameGrabTask,如果grab为空则把imageQueue置空并结束线程(当然了,put那里也要加上非空判断):

    private val frameGrabTask = Runnable {
        ......
        while (playing) {
            frameGrabber?.grab()?.also { frame ->
                ......
                        imageQueue?.put(it)
                ......
            } ?: run {
                imageQueue = null
                return@Runnable
            }
        }
    }

再修改一下imageProcessTask

    private val imageProcessTask = Runnable {
        while (playing) {
            imageQueue?.take()?.also { image ->
                ......
                ......
            } ?: run {
                EventQueue.invokeLater { stop() }
                return@Runnable
            }
        }
    }

如果imageQueue为空则主动调用stop方法并马上return(结束线程)。

好,最后加上一个【Stop Play】的功能按钮,就算完美结束了:

class StopAction : AnAction() {
    override fun actionPerformed(event: AnActionEvent) {
        MediaPlayer.stop()
    }
}

记得在plugin.xml里声明一下:

    <actions>
        ......
        ......
        <action id="StopAction" class="StopAction" text="Stop Play">
            <add-to-group group-id="ViewMenu" anchor="last"/>
        </action>
    </actions>

运行:

我被老板炒鱿鱼了!因为我在IDE里看漂亮小姐姐跳舞!(IntelliJ IDEA插件开发之打造炫酷动态背景墙)_第21张图片

按钮已经出来了,现在无论是手动结束还是播放完毕后自动结束,都能正常释放资源了。

祝摸鱼快乐!!!(低调点,不要真的被炒鱿鱼了噢!)


本篇文章到此结束,有错误的地方请指出,谢谢大家!

Github地址:https://github.com/wuyr/intellij-media-player 欢迎star

你可能感兴趣的:(java,intellij-idea,spring,后端,android)