场景很常见:界面上好几个图表或者控件,它们的某个属性要保持联动——比如多个子图的 X 轴范围要一致、缩放倍数要同步,一个改了其他几个跟着变。

最朴素的写法是给每个属性挂监听器,监听器里去 set 别的属性。但很快就会踩坑:set 别的属性又会触发它们的监听器,绕回来形成回环;浮点的值 set 来 set 去,精度抖动让属性在 20 和 19.9999 之间来回跳;还有属性对象被替换的时候,旧的监听器没摘干净,内存泄漏。

后来干脆写了个通用的同步器,把这些坑一次性解决。核心不长:

class PropertySyncManager<T> {
var epsilon: Double = 1e-9

private val propertyMap = mutableMapOf<Property<T>, ChangeListener<T>>()
private var isSyncing = false

fun register(property: Property<T>) {
if (propertyMap.containsKey(property)) return // 幂等,重复注册不重复挂
val listener = ChangeListener<T> { _, _, newValue -> sync(property, newValue) }
propertyMap[property] = listener
property.addListener(listener)
}

fun unregister(property: Property<T>) {
val listener = propertyMap.remove(property)
listener?.let { property.removeListener(it) } // 干净移除,防泄漏
}

private fun sync(source: Property<T>, newValue: T?) {
if (isSyncing || newValue == null) return
isSyncing = true
try {
for ((prop, _) in propertyMap) {
if (prop !== source) updateIfChanged(prop, newValue)
}
} finally {
isSyncing = false
}
}

private fun updateIfChanged(target: Property<T>, newValue: T) {
val current = target.value
val shouldUpdate = when {
newValue is Number && current is Number ->
abs(newValue.toDouble() - current.toDouble()) > epsilon // 浮点走容差
else -> newValue != current // 其他走相等
}
if (shouldUpdate) target.value = newValue
}
}

几个点拆开说。

监听器要存引用。propertyMap 把每个属性和它的监听器绑在一起存着,不是为了查找,是为了 unregister 的时候能把那个匿名 ChangeListener 找出来 removeListener。JavaFX 的 addListener / removeListener 是按对象身份匹配的,不存引用根本摘不掉,属性对象换了一轮,旧监听器还挂着,就是泄漏。

重入锁打断回环。sync 一进来就置 isSyncing = true,接着去 set 其他属性;其他属性的监听器被触发,又调 sync,但这时候 isSyncing 还是 true,直接 return。这样就只有最初那个属性的变化往外扩散一轮,不会绕回来。finally 里复位,保证异常也不卡死。

浮点走容差。updateIfChanged 里对 Number 不用 ==,用 abs(差) > epsilon。因为属性联动经常是浮点(X 轴范围、缩放比例),来回 set 会把 20 抖成 19.9999996,再被判成”变了”又 set 回去,没完没了。epsilon 默认 1e-9,足够小,正常联动感知不到差异;遇到精度要求低的场景还能外部调大。

register 做了幂等,同一个属性注册两次不会挂两个监听器。用完调 clear() 能一次性把所有监听器摘掉。

用法很简单,把要联动的属性都 register 进同一个管理器就行:

val sync = PropertySyncManager<Number>()
charts.forEach { sync.register(it.xAxisUpperBoundProperty()) }

// 动态把某个图表踢出联动
sync.unregister(charts[2].xAxisUpperBoundProperty())

后来项目里凡是”一个属性变了其他要跟着变”的地方,X 轴范围、缩放、增益,全都收到这一套上,没再出过回环和泄漏。这种小工具抽出来挺值,比每处都手写一遍监听器加重入锁干净太多。