思路
其实基本上可视化应用都有个单独的UI线程处理UI更新事件,JavaFX也不例外。而单线程模型的特点利用事件循环机制,不断从消息队列里取消息,然后执行消息。所以,我们只要在取消息前和取消息后加入hook操作,就可以得出当前主线程的一个任务的执行时间。
当需要更新UI元素值时,如果不在UI线程,会抛出Not on FX application thread
的异常。所以我们在子线程获取完数据后,需要用如下方式更新数据:Platform.runLater { /*update ui*/ }
步骤
-
找到UI线程的消息队列
-
找到消息执行点
-
在消息执行前后插入hook函数
-
统计消息执行时长
方案
其实思路和步骤很简单,主要是消息队列的获取,以及如何插入hook函数。
因为UI线程消息都是经过Platform.runLater { /*update ui*/ }
抛进消息队列的(其他方式也一样,最终将排队进入消息队列),那么就从这个方法入手。执行如下:
Platform.runLater(runnable)->Toolkit.getToolkit().defer(runnable)->Application.invokeLater(runnable)->Application._invokeLater(runnable)->invokeLaterDispatcher.invokeLater(runnable)->deque.addLast(runnable)
那么,这个deque就是UI线程要获取执行的消息队列了。
接下来就是找到执行点,并查找到可以hook的地方。
查找hook函数
有些开发框架有自带的hook点(Android),开发者可以方便得在消息执行前后插入hook回调,进行统计计算。但很遗憾,在JavaFX中暂时没找到
字节码插装
编译时,在目标代码前后插入自定义逻辑。实现难度大。且Java1.8中,JavaFX是在JDK的源码中,编译时是不会编译进项目源码里的。
替换JDK源码
查找InvokeLaterDispatcher.class(分发器类)所在的jar包,并替换此class文件。理论感觉可行,但尚未尝试!!!
反射替换
查找查找InvokeLaterDispatcher实例。并替换实例中的deque成员变量,重写addLast方法,插入自定义的Runnable包装器,在自定义包装器中保存原执行的Runnable,并且在run方法中,执行原Runnable的run方法,使其能正常执行,那么此时就可以在run方法前后就可以插入hook函数,进行统计并计算了。
-
在方案中我们发现,Application.invokeLater(runnable)是个静态方法,静态方法只能调用静态方法和使用静态实例,那么可以从这入手。
-
进一步,在Application中有一个静态单例对象application,而invokeLaterDispatcher实例就是Application的成员对象。
-
找到invokeLaterDispatcher后,就可以找到deque成员变量,之后进行替换就可以了。
kotlin
fun inject() {
//各个平台Application抽象类
//其中Mac平台实现类为MacApplication;win平台实现类为WinApplication。此外还有iOS平台,SWT平台等。
val appClass = Application::class.java
//Application抽象类中有个application的静态实例(),代表当前的全局Application
val macAppField = appClass.getDeclaredField("application")
macAppField.isAccessible = true
//获取静态application实例
val application = macAppField.get(null)
//InvokeLaterDispatcher是用于将延迟的可运行对象逐个提交到本机系统调度程序
//在Application中有个invokeLaterDispatcher实例对象
val invokeLaterDispatcherField = application.javaClass.getDeclaredField("invokeLaterDispatcher")
invokeLaterDispatcherField.isAccessible = true
val invokeLaterDispatcher = invokeLaterDispatcherField.get(application)
//InvokeLaterDispatcher调度器中执行的消息队列
//即所有将要在主线程(JavaFxThread)中执行的任务队列
val dequeField = invokeLaterDispatcher.javaClass.getDeclaredField("deque")
dequeField.isAccessible = true
// val deque = dequeField.get(invokeLaterDispatcher) as LinkedBlockingDeque<Runnable>
//自定义队列,用于hook操作。并替换系统中的消息队列实例。
val myDeque = MyDeque()
dequeField.set(invokeLaterDispatcher, myDeque)
fxUserThread = appClass.getDeclaredField("eventThread").run {
isAccessible = true
get(null) as Thread
}
}
//自定义队列,重写addLast方法,插入自定义的Runnable包装器,用于hook操作
class MyDeque : LinkedBlockingDeque<Runnable>() {
override fun addLast(e: Runnable) {
super.addLast(MyRunnableWrap(e))
}
}
class MyRunnableWrap(private val origin: Runnable) : Runnable {
private val TAG = "MyRunnableWrap"
private companion object {
var arg1: Field? = null
}
override fun run() {
val start = System.currentTimeMillis()
origin.run()
println("after run,cost=${System.currentTimeMillis() - start}ms")
}
private fun getOriginName() = try {
(arg1 ?: origin.javaClass.getDeclaredField("arg$1").also {
it.isAccessible = true
arg1 = it
}).get(origin).toString()
} catch (_: Exception) {
null
}
}
这个方法,可以在我们自定义的Application(和上文的Application不是同一个)中的start方法,或者直接在构造函数中调用都可以(亲测可用)。
点击事件hook
经测试,鼠标的点击事件到来时,runnable里的执行不会抛给UI线程的消息队列,而是由native直接抛给UI线程执行。那么针对点击事件我们也需要进行hook。
-
从点击事件开始debug,查找点击事件来源堆栈的根节点
-
Scene.TKScene(1.8中是impl_peer,17中是peer)字段,类型是ViewScene
-
所有事件来源于ViewScene.platformView.eventHandler
-
替换eventHandler为自己的eventHandler,并在事件前后加上hook函数,即统计到当前点击事件执行用时
关键代码如下
kotlin
fun injectViewEventHandler(scene: Scene) {
//java1.8中字段名是impl_peer,JavaFX17中(单独的库了),字段名是peer
val impl_peerField = scene.javaClass.getDeclaredField("peer").also { it.isAccessible = true }
val impl_peer = impl_peerField.get(scene)
val platformViewField = impl_peer.javaClass.getDeclaredField("platformView").also { it.isAccessible = true }
val platformView = platformViewField.get(impl_peer) as View
val originHandler = platformView.eventHandler
platformView.eventHandler = MyEventHandlerProxy(originHandler)
}
class MyEventHandlerProxy(val origin: View.EventHandler) : View.EventHandler() {}
监控
既然可以得到消息执行前后的回调,那么我们可以启动一个监控线程:
-
当主线程消息开始执行时,往监控线程里发送一个延迟消息,延迟时间由自己定义,比如设置延迟5秒,表示5秒后执行此消息。
-
当主线程消息结束执行时,移除这个延迟消息。
-
如果这条延迟消息没有被移除,说明主线程的这个任务5秒了还没执行完,那么可以判断当前发生了ANR,提取主线程
JavaFxThread
此时的调用堆栈即可得到当前执行线程,并且也可以知道此时的主线程消息,进而做进一步的判断和排查。