JavaFX 的聲明式綁定 (bind) 與觸發器 (on replace) 能極大簡化 UI 控制器邏輯,但不當使用會導致循環更新、性能抖動。本文以 BlueBill Mobile 物種搜索為例,實現輸入過濾、自動完成、雙屬性 (通用名/學名) 同時匹配,並深度剖析綁定循環陷阱與規避方案。
package it.tidalwave.bluebillmfx.taxon.model;
public class Taxon extends Comparable {
public-read protected var displayName: String; // 通用名 "Grey Heron"
public-read protected var scientificName: String; // 學名 "Ardea cinerea"
public-read protected var id: String;
override function compareTo(other: Object) {
displayName.compareTo((other as Taxon).displayName);
}
override function toString() {
"{displayName} ({scientificName}) ({id})";
}
}
// 靜態屬性獲取器序列,支援擴展新匹配字段 (如本地化名稱)
public function displayNameGetter(t: Taxon): String { t.displayName; }
public function scientificNameGetter(t: Taxon): String { t.scientificName; }
public def namePropertyGetters = [displayNameGetter, scientificNameGetter];
package it.tidalwave.bluebillmfx.taxon.controller;
import it.tidalwave.bluebillmfx.taxon.model.Taxon;
public class TaxonSearchController {
// 輸入屬性
public var taxons: Taxon[]; // 完整列表 (注入)
public var filter = "" on replace { // 觸發器:用戶輸入變化
filteredTaxons = taxons[taxon | matches(taxon, filter)];
update();
};
// 輸出屬性 (供 View 綁定)
public-read var filteredTaxons: Taxon[]; // 過濾後列表
public-read var autoCompleted = ""; // 自動完成建議
public var selectedTaxonIndex: Integer = -1; // -1 表示無唯一選中
public var selectedTaxon = bind // 綁定衍生屬性
if (selectedTaxonIndex < 0) null else filteredTaxons[selectedTaxonIndex];
// 核心匹配邏輯:任一屬性以 filter 開頭 (忽略大小寫)
protected function matches(taxon: Taxon, string: String): Boolean {
if (string == "") return true;
for (getter in Taxon.namePropertyGetters) {
if (getter(taxon).toLowerCase().startsWith(filter.toLowerCase())) return true;
}
return false;
}
// 更新自動完成與選中狀態
protected function update(): Void {
// 1. 計算所有匹配項在「命中屬性」上的公共前綴
def getter = findMatchingPropertyGetter();
def autoCompletedTry = commonLeadingSubstring(filteredTaxons, getter);
// 2. 關鍵防循環:建議必須比當前 filter 長,否則忽略
if (autoCompletedTry.length() > filter.length()) {
autoCompleted = autoCompletedTry;
}
// 3. 若過濾後僅剩 1 項,自動選中
selectedTaxonIndex = if (sizeof filteredTaxons == 1) 0 else -1;
println("selectedTaxonIndex: {selectedTaxonIndex}");
}
// 尋找當前過濾結果命中的屬性獲取器 (用於計算公共前綴)
protected function findMatchingPropertyGetter(): function(:Taxon): String {
for (taxon in filteredTaxons) {
for (getter in Taxon.namePropertyGetters) {
if (getter(taxon).toLowerCase().startsWith(filter.toLowerCase())) return getter;
}
}
return null;
}
// 遞歸計算字符串序列的公共前綴
protected function commonLeadingSubstring(taxons: Taxon[], getter: function(:Taxon): String): String {
if (sizeof taxons == 0) return "";
if (sizeof taxons == 1) return getter(taxons[0]);
var common = getter(taxons[0]);
for (other in taxons[1..]) {
common = commonLeadingSubstring(common, getter(other));
if (common == "") break;
}
return common;
}
// 兩字符串公共前綴 (遞歸)
function commonLeadingSubstring(s1: String, s2: String): String {
if (s1.length() > s2.length()) return commonLeadingSubstring(s2, s1);
if (s1 == "") return "";
if (s2.startsWith(s1)) return s1;
return commonLeadingSubstring(s1.substring(0, s1.length()-1), s2);
}
}
package it.tidalwave.bluebillmfx.taxon.view;
public class TaxonSearchScreen {
public var taxons: Taxon[]; // 注入完整列表
var filter = "";
// 控制器實例,雙向綁定 taxons 與 filter
public-read def controller = TaxonSearchController {
taxons: bind taxons
filter: bind filter
};
// 自動完成建議 → 回寫 filter (單向觸發)
def autoCompleted = bind controller.autoCompleted on replace {
if (autoCompleted != "") filter = autoCompleted;
};
// 列表綁定過濾結果
def list = ListBox { items: bind controller.filteredTaxons };
// 搜索框雙向綁定 filter
def searchBox = TextBox { text: bind filter with inverse };
}
場景:用戶輸入 "cali" → filter="cali" → 過濾結果含 "California Quail" (通用名) 與 "Calidris alpina" (學名) → 兩屬性公共前綴僅 "Cali" → autoCompleted="Cali" (長度 4) 不大於 filter.length() (4) → 不更新 autoCompleted → 避免循環。
若無長度檢查:
filter="cali"。autoCompleted="California Quail" (取首項完整值) → 更新 autoCompleted。on replace 觸發 filter = "California Quail"。核心防禦模式:
def autoCompletedTry = ...;
// 臨時變量持有候選值,驗證通過後才賦值給綁定屬性
if (autoCompletedTry.length() > filter.length()) {
autoCompleted = autoCompletedTry; // 只有「真正更長的建議」才回寫
}
原則:綁定屬性賦值前必須驗證「增量價值」,避免觸發無意義的逆向傳播。
過濾器 "cali" 同時匹配:
但兩者完整值不同,公共前綴僅 "Cali"。findMatchingPropertyGetter() 嘗試猜測用戶意圖 (通用名 vs 學名) 以計算更長建議,但無法 100% 準確。這是「多屬性同時搜索」固有的信息熵限制,UI 層可提示「按 Tab 選擇通用名/學名」進一步消歧。
matches() 全量掃描 < 10ms,可接受。Trie (前綴樹) 或 FilteredList (JavaFX 8+) 遞增過濾。namePropertyGetters 序列化設計支援運行時添加新匹配字段 (如中文名、日文名),無需修改控制器邏輯,體現開放封閉原則。JavaFX 綁定機制強大但雙刃劍:
bind with inverse、on replace易形成隱式循環。核心防禦三原則:(1) 臨時變量驗證後再賦值綁定屬性;(2) 只在「嚴格改進」時回寫 (如長度增長、值實質變化);(3) 關鍵路徑加println觀察觸發頻率。掌握這些模式,即可構建流暢、穩定的響應式 UI 控制器。