Psy4J - 类库架构设计问题概要

Psy4J 是我设计的一个基于 Java 虚拟机(JVM)平台的,支持 Java、Scala、Kotlin 等 JVM 语言的,搭建在 JavaFx 2 GUI 框架上的心理学程序刺激呈现类库。

其网站如下:psy4j.mazhangjing.com,其 Github 仓库为:corkine.github.com/psy4j。代码采用 GPL v2 协议开源(所有基于本程序编写的 Java、Scala、Kotlin 程序必须强制公开源代码 —— 因为本程序依赖的底层 GUI 库和虚拟机:OpenJDK、OpenJFx 都是开源产品,秉承着取之于开源,用之于开源的原则开放源代码)。此外,Psy4J 采用 Scala 混合 Java 8 实现,推荐使用 Scala 实现的 API。

类库的架构如下:

这个类库并不大,核心类有 Screen、Trial、Experiment,一共才小上万行代码,其中还包括了 LabUtils、ScreenBuilder、TrialBuilder 等工具类。当然,麻雀虽小,五脏俱全,Psy4J 间接或者直接实现了几种设计模式:迭代器模式(实现了一个带有守卫的 Queue)、状态模式(为 Queue 的读写提供了一个抽象的方法 exp.currentScreen)、观察者模式(exp.terminal, scr.eventHandler)、抽象方法模式(initScreen、initTrial)、策略模式(ifKeyPressed...)。

此外,在设计过程中着重解决了定时器更新 GUI 并发问题、全局计时器处理问题、全局事件传递问题,JavaFx 线程的 GUI 安全更新问题(terminal 实现了一个观察者,由 JavaFx 自行更新下一个 Screen)。

Java 定时器调用并发更新 GUI 问题

这个问题纯属多线程的一个坑,虽然 JavaFx 规定了,从其余线程调用 UI 控件更新的时候,必须要使用 Platform.runlater 插入 GUI 绘制队列,因此想要在定时器线程犯错还是不太容易的。但是,由于 Psy4J 实现了基于 terminal 更新 GUI 的信号 - 观察者模式,当定时器需要更新 GUI 的时候,它会调用 terminal 发送信号,然后由 terminal 来在 UI 线程更新,因此,问题就发生了。

这个问题的解决很简单,当定时器需要执行的时候,在执行 runlater 之前进行判断,如果状态仍然满足可供更新时,更新 GUI,如果当前 Screen 对象已经被 Queen 弹出,那么不执行语句。

Java 全局定时器的精度和取消问题

全局定时器不能使用 Timer 类,原因之一是,Timer 的定时精度不够,其次,Timer 的定时,时间收到很多因素的影响,在极端环境下,其执行时间可能不等于规定时间。最后,Timer 定时,且启动/规划的任务无法真正的取消。

对于 PP 而言,直接使用一个 while true 循环卡死 GUI 线程,之后等待时间结束即可,但是这种设计极其不优雅,且丧失了程序的灵活性,属于下下策。

Psy4J 采用的全局定时器是 Java 的单线程池,线程池中的任务延迟调度,这样精度得以保证,且,如果想要阻断任务,只需要清空 queue 即可,ScheduleFuture 的任务不会继续执行。但是,精度仅仅是调用的精度,创建 GUI 组件不应该在需要显示 Screen 时才调用,而应该提前创建好,当指针指向 Screen 时直接传递引用到 JavaFx 的 Scene 即可,这些设计合起来保证了程序呈现的精确性。由于多线程调用的延迟非常短,在 1 ms 级别,因此,这种设计相比较 PP,在不丧失精确性的前提下,保证了程序的极大的灵活性 —— 用户可以并不静态的呈现刺激,然后卡死线程,而可以继续在呈现刺激的时候,和 UI 线程交互。

全局事件传递问题

比如语音检测,需要语音程序跑在一个单独线程上,然后,不时的调用 Screen 传递给 eventHandler 数据,完成数据交互。全局事件在 Psy4J 中,注册在 EventMaker 接口上,然后写入 ExpRunner 即可,Main 会通过反射来创建对应的 EventMaker 接口的全局事件发生源,发生事件。

这个问题,曾经也让我苦恼很久,我试过在 Screen 中传入一个由 Experiment 中构造的全局事件源,但是,因为 JavaFx 调用 Experiment 以构建和 JavaFx 真正执行后的 UI 线程并非一回事,因此,全局事件源往往不能正确的运行。而将全局事件源放入 JavaFx Application 类中,则能保证调用全局事件源的线程安全性,且后者能够提供 Experiment#currentScreen 接口,来传递事件,因此,这个也就成了 Psy4J 最终的设计。

JavaFx Main 参数传递的问题和 Main 调用的问题【待解决】

这两个问题我都没有解决,JavaFx 的 Application 不能通过 Class#forName 反射创建,因为 Application 内就执行的是反射,如果反射,那么会提醒 toolkit 没有初始化。而在 JavaFx 1 API 中提供的专用 JavaFx 反射接口在 JavaFx 2 中去掉了。因此,在 1.2.4 版本中,我的解决方案是,每次运行一个 Application 实例,而在其中通过反射调用规定文件中提供的类 - ExpRunner,而后者保存了 Experiment 的实例、EventMaker 的实例,进一步通过反射注入 Application 中,完成程序的初始化。

对于 Main arg 传递参数的问题,因为 Experiment 是在 Application 中通过反射在构造器中调用的,而 arg 参数的传递则在反射创建 Experiment 之后。Experiment 在自己创建的时候已经完成了 Screen 和 Trial 的全部静态构造和数据注入,因此,arg 的参数不能正常传入。我目前的解决方案是通过一个静态 SET 枚举保存参数,未来考虑通过 yaml 提供一个数据类 Bean,然后注入 ExpRunner,从 ExpRunner 获取这些配置信息,然后再反射创建 Experiment。