模型浏览、搜索与筛选
模型浏览、搜索与筛选是 Ark-Pets 桌面启动器"模型操作台"的核心交互能力:它将本地数据集 ModelsDataset 解析为可浏览的 ModelItem 列表,并基于关键字搜索、标签筛选与收藏过滤实时刷新 JavaFX 界面中的模型列表。
目的与范围
本页覆盖以下内容:
- 数据集
ModelsDataset的反序列化结构与标签映射(sortTags、storageDirectory、兼容性版本)。 ModelsModule控制器(models.fxml的后端)中搜索、筛选、收藏、随机选择与重载的完整交互初始化逻辑。ModelItemGroup在浏览/筛选链路中被调用的过滤 API(filter/extract/searchByRelPath)。- 数据集载入失败、版本兼容性告警等失败模式与 UI 反馈。
以下内容属于兄弟页面,本页仅作指引:
- 模型的下载、校验、解压、导入导出等 GUI 任务(
DownloadModelsTask、UnzipModelsTask、VerifyModelsTask等),详见"模型管理任务"相关页面。 - 收藏数据(
character_favorites)的持久化配置与整体启动器配置体系。 - 模型选中后的桌宠实例化流程。
概述
在 Ark-Pets 启动器主界面中,"模型操作台"是用户挑选桌宠模型的入口。其工作方式为:
- 数据集载入:启动时(或用户点击重载后)从本地数据文件
PathConfig.fileModelsDataPath读取 JSON,反序列化为ModelsDataset,其核心成员data是一个ModelItemGroup(模型条目集合)。 - 列表浏览:
ModelItem通过 JavaFXObservableList(targetList)绑定到modelListView,用户滚动浏览即可查看全部可用模型。 - 关键字搜索:用户在
searchModelInput中输入关键字并回车(或点击确认按钮),触发modelSearch对集合进行过滤。 - 标签筛选:筛选面板(
filterPane)基于数据集sortTags动态生成标签按钮,用户选中的标签汇入filterTagSet(ObservableSet),与关键字搜索叠加生效。 - 收藏过滤:
topFavorite按钮切换filterFavorite布尔状态,仅显示app.config.character_favorites中已收藏的模型。
关键字、标签、收藏三者叠加构成一条过滤器链,最终结果写回 targetList,JavaFX 属性绑定机制使 ListView 自动刷新——这是整个能力最关键的设计:UI 状态与数据过滤解耦,避免手动同步。
架构
图中要点(均可在源码中验证):
ModelsModule是Controller<ArkHomeFX>实现,持有ArkHomeFX app引用,通过app.modelsDataset访问共享数据集(见 ModelsModule.java)。targetList在initializeWith中通过modelListView.setItems(targetList)完成一次性绑定,此后过滤结果只需重写targetList内容即可驱动 UI。filterTagSet是双向的:既被标签按钮点击事件写入,也被其SetChangeListener反向用于刷新按钮的高亮状态。- 数据集文件位于 core 模块的资产解析路径之外,由 desktop 模块通过
IOUtils.FileUtil.readString读取后交给 fastjson2 解析。
核心数据模型:ModelsDataset
ModelsDataset 是浏览与筛选能力的数据源,位于 core 模块。它的构造函数承担了"原始 JSON → 可过滤集合"的全部预处理,包含三件关键事情:目录映射、字段补全、向后兼容。
1protected ModelsDataset(ModelsDatasetBean bean) {
2 Objects.requireNonNull(bean);
3
4 storageDirectory = new HashMap<>();
5 if (bean.storageDirectory == null || bean.storageDirectory.isEmpty())
6 throw new DatasetKeyException("storageDirectory");
7 for (String key : bean.storageDirectory.keySet())
8 storageDirectory.put(key, Path.of(bean.storageDirectory.get(key)).toFile());
9
10 sortTags = bean.sortTags;
11 gameDataVersionDescription = bean.gameDataVersionDescription;
12 gameDataServerRegion = bean.gameDataServerRegion;
13
14 if (bean.data == null || bean.data.isEmpty())
15 throw new DatasetKeyException("data");
16 data = new ModelItemGroup();
17 for (String key : bean.data.keySet()) {
18 // Pre deserialization
19 ModelItem modelItem = bean.data.get(key).toJavaObject(ModelItem.class);
20 // Make up for `assetDir` field
21 if (modelItem == null || !storageDirectory.containsKey(modelItem.type))
22 throw new DatasetKeyException("type");
23 modelItem.key = key;
24 modelItem.assetDir = Path.of(storageDirectory.get(modelItem.type).toString(), key).toFile();
25 // Compatible to lower version dataset
26 if (modelItem.assetList == null && modelItem.assetId != null && modelItem.checksum != null) {
27 HashMap<String, Object> defaultFileMap = new HashMap<>();
28 for (String fileType : ModelItem.extensions)
29 defaultFileMap.put(fileType, modelItem.assetId + fileType);
30 modelItem.assetList = new JSONObject(defaultFileMap);
31 }
32 data.add(modelItem);
33 }
34 data.sort();
35
36 arkPetsCompatibility = new Version(bean.arkPetsCompatibility);
37}Source: ModelsDataset.java
这段预处理的设计意图:
storageDirectory→assetDir补全:JSON 中每个模型条目只记录type与key,实际资源目录由storageDirectory[type]/key在运行时拼接得出。这样数据集不必为每条记录重复存储绝对路径,目录结构调整时也只需改一处映射。sortTags保持原样透传:它是"标签原始值 → 显示名"的映射,供筛选面板做本地化显示(见后文"标签筛选"一节)。- 低版本数据集兼容:当条目缺少
assetList(新版字段)但存在旧的assetId+checksum时,用ModelItem.extensions中的扩展名拼出默认文件名表,实现新旧数据集格式的平滑迁移。 - 最后统一
data.sort():保证进入 UI 的初始列表即为有序状态。
除构造函数外,该类还定义了非法数据异常 DatasetKeyException(继承 IllegalArgumentException,报错形如 The key "xxx" not found or invalid.)以及用于 fastjson2 反序列化的内部 Bean 类 ModelsDatasetBean(通过 @JSONField 注解的 setter 接收 HashMap<String, JSONObject> data 等字段,见 ModelsDataset.java)。
控制器初始化与状态字段
ModelsModule 实现了 Controller<ArkHomeFX>,所有 @FXML 注入的控件分为四组:搜索条(searchModelInput、searchModelConfirm、searchModelReset、searchModelRandom、searchModelReload、searchModelStatus)、列表与信息(modelListView、selectedModelName 等详情标签)、筛选面板(filterPane、filterPaneTagFlow、filterPaneTagClear、topFavorite)与管理面板(modelFetch、modelUpdate 等)。其核心可变状态只有四个:
1private ModelItemGroup assetItemList;
2private ModelItemWrapper selectedModel;
3private final ObservableList<ModelItem> targetList = FXCollections.observableArrayList();
4private ObservableSet<String> filterTagSet = FXCollections.observableSet();
5private boolean filterFavorite;Source: ModelsModule.java
assetItemList:当前被展示的ModelItemGroup快照(app.modelsDataset.data的引用)。targetList:modelListView的数据源,过滤链的最终输出。filterTagSet:当前生效的标签筛选集合。filterFavorite:收藏过滤开关。
initializeWith(ArkHomeFX app) 中与浏览/搜索相关的一次性装配(裁剪节选):
1@Override
2public void initializeWith(ArkHomeFX app) {
3 this.app = app;
4 this.selectedModel = new ModelItemWrapper();
5 this.modelListView.setItems(targetList);
6 ScrollUtils.addSmoothScrolling(modelListView);
7 // ... infoPaneComposer / mngBtnComposer 面板切换装配 ...
8 initInfoPane();
9 initModelSearch();
10 initModelFilter();
11 initModelManage();
12 modelReload(false);
13 // ...
14}Source: ModelsModule.java
设计意图:初始化顺序固定为"详情面板 → 搜索 → 筛选 → 管理 → 重载",保证事件处理器注册完成后立即通过 modelReload(false)(不弹提示的静默模式)填充列表,用户进入页面即可浏览。
数据集载入与兼容性检查
initModelsDataset(boolean doPopNotice) 是浏览能力的前置条件,它读取本地数据文件并做两级兼容性判断:
1app.modelsDataset = new ModelsDataset(
2 JSONObject.parseObject(
3 IOUtils.FileUtil.readString(new File(PathConfig.fileModelsDataPath), charsetDefault)
4 )
5);
6app.modelsDataset.data.removeIf(Predicate.not(ModelItem::isValid));
7try {
8 // Check the dataset compatibility
9 Version compatibleVersion = app.modelsDataset.arkPetsCompatibility;
10 if (appVersion.lessThan(compatibleVersion)) {
11 datasetTooHighVerNotice.activate();
12 Logger.warn("ModelManager", "The model dataset version may be too high which requiring program version " + compatibleVersion);
13 } else {
14 datasetTooHighVerNotice.suppress();
15 }
16 if (datasetLowestVersion.greaterThan(compatibleVersion)) {
17 datasetTooLowVerNotice.activate();
18 Logger.warn("ModelManager", "The model dataset version may be too low");
19 } else {
20 datasetTooLowVerNotice.suppress();
21 }
22} catch (Exception ex) {
23 Logger.warn("ModelManager", "Failed to get the compatibility of the model database.");
24}Source: ModelsModule.java
要点:
- 无效条目即时剔除:
data.removeIf(Predicate.not(ModelItem::isValid))在数据集层面过滤掉损坏/不完整的条目,保证列表只出现可用模型——这是搜索/筛选链路最上游的一道"过滤器"。 - 双向版本兼容:
appVersion.lessThan(compatibleVersion)检测"软件比数据集旧"(激活datasetTooHighVerNotice,提示更新软件并链接官方下载页);datasetLowestVersion.greaterThan(compatibleVersion)检测"数据集比软件可接受的最低版本还旧"(激活datasetTooLowVerNotice,提示重新下载模型)。 - 异常策略:内部任何异常都会把
app.modelsDataset显式置为null再向外抛出;FileNotFoundException分支记录警告并按需弹窗"模型载入失败:未找到数据集"。也就是说,浏览能力的所有后续代码都必须容忍app.modelsDataset == null的状态(筛选面板重建处即有app.modelsDataset != null判空,见后文)。
关键字搜索
initModelSearch() 注册了搜索条的全部触发路径:
1private void initModelSearch() {
2 searchModelInput.setPromptText("输入关键字");
3 searchModelInput.setOnKeyPressed(e -> {
4 if (e.getCode().getName().equals(KeyCode.ENTER.getName()))
5 modelSearch(searchModelInput.getText());
6 });
7
8 searchModelConfirm.setOnAction(e -> modelSearch(searchModelInput.getText()));
9
10 searchModelReset.setOnAction(e -> app.popLoading(ev -> {
11 Logger.debug("ModelManager", "Reset search and filter conditions");
12 searchModelInput.clear();
13 searchModelInput.requestFocus();
14 filterTagSet.clear();
15 modelSearch("");
16 infoPaneComposer.activate(0);
17 }));
18
19 loadEmptyAction.setOnMouseClicked(e -> {
20 Logger.debug("ModelManager", "Reset requested from placeholder");
21 if (filterFavorite)
22 topFavorite.getOnAction().handle(new ActionEvent(loadEmptyAction, topFavorite));
23 searchModelReset.getOnAction().handle(new ActionEvent(loadEmptyAction, searchModelReset));
24 });
25
26 searchModelRandom.setOnAction(e -> modelRandom());
27
28 searchModelReload.setOnAction(e -> modelReload(true));
29}Source: ModelsModule.java
交互细节与设计意图:
- 两种触发方式:文本框回车(
ENTER)与确认按钮都收敛到同一个modelSearch(text)入口,避免逻辑分叉。 - 重置是"复合操作":
searchModelReset同时清空输入框、清空filterTagSet并以空关键字重新搜索(即恢复全量列表),再用infoPaneComposer.activate(0)切回默认详情面板。整个动作包在app.popLoading中以显示加载遮罩,因为全量重过滤在模型量大时并非瞬时操作。 - 空态自愈:
loadEmptyAction是列表为空时的占位提示。有趣的是它不直接调用方法,而是合成ActionEvent去复用已有按钮的处理器——若收藏过滤开启则先模拟点击topFavorite关闭收藏,再模拟点击searchModelReset清空条件。这保证空态入口与真实按钮的行为永远一致,是典型的 UI 复用手法。 - 随机与重载:
searchModelRandom→modelRandom()随机挑选模型;searchModelReload→modelReload(true)(弹提示)重新载入数据集。
标签筛选与收藏过滤
initModelFilter() 建立标签清除、收藏切换两个入口,并对收藏配置做惰性初始化:
1private void initModelFilter() {
2 ScrollUtils.addSmoothScrolling(filterPaneTagScroll);
3 filterPaneTagClear.setOnMouseClicked(e -> app.popLoading(ev -> {
4 filterTagSet.clear();
5 modelSearch(searchModelInput.getText());
6 infoPaneComposer.activate(0);
7 }));
8
9 if (app.config.character_favorites == null) {
10 app.config.character_favorites = new JSONObject();
11 app.config.save();
12 }
13
14 topFavorite.setOnAction(e -> {
15 Logger.debug("ModelManager", "Toggle favorite display");
16 modelListView.scrollTo(0);
17 if (filterFavorite) {
18 GuiPrefabs.replaceStyleClass(topFavorite, "btn-primary", "btn-secondary");
19 } else {
20 GuiPrefabs.replaceStyleClass(topFavorite, "btn-secondary", "btn-primary");
21 }
22 filterFavorite = !filterFavorite;
23 modelSearch(searchModelInput.getText());
24 ModelItem recentSelected = assetItemList.searchByRelPath(app.config.character_asset);
25 if (recentSelected != null)
26 for (ModelItem cell : modelListView.getItems())
27 if (recentSelected.equals(cell)) {
28 modelListView.scrollTo(cell);
29 modelListView.getSelectionModel().select(cell);
30 }
31 });
32}Source: ModelsModule.java
- 标签清除:只清
filterTagSet,保留当前关键字(modelSearch(searchModelInput.getText())),与"重置"语义刻意区分。 - 收藏切换:先翻按钮样式(primary/secondary 互换),翻转
filterFavorite,再以当前关键字重搜。随后通过assetItemList.searchByRelPath(app.config.character_asset)定位上次选中的模型,若它仍在过滤结果中则滚动并选中——保证切换过滤模式后用户不"丢失"自己的选择上下文。
过滤链:从 ModelItemGroup 到 targetList
浏览、搜索、筛选最终都汇入 modelSearch,其过滤逻辑的核心是 ModelItemGroup 提供的声明式过滤 API。源码中的关键调用组合如下:
ModelItemGroup filtered = filterTagSet.isEmpty() ? favoured :
favoured.filter(ModelItem.PropertyExtractor.ASSET_ITEM_SORT_TAGS, filterTagSet);Source: ModelsModule.java
从 Grep 交叉引用可还原整条链路(各片段均出自 ModelsModule):
- 收藏过滤(可选):当
filterFavorite开启时,先以favoured = assetItemList.filter(ModelItem.PropertyExtractor.ASSET_ITEM_KEY, app.config.character_favorites.keySet(), ModelItemGroup.FilterMode.MATCH_ANY)取出收藏键集合命中的条目(见 ModelsModule.java L498)。 - 标签过滤(可选):
filterTagSet非空时,再以ASSET_ITEM_SORT_TAGS提取器按标签集合过滤;为空则直接沿用上一步结果。 - 关键字过滤:
modelSearch(text)在上述结果之上按关键字匹配(ModelItem 的名称/代号等文本字段)。 - 写回 UI:结果写入
targetList,modelListView因ObservableList绑定自动刷新,同时searchModelStatus更新结果计数提示。
ModelItem.PropertyExtractor 是统一的属性提取器枚举(如 ASSET_ITEM_KEY、ASSET_ITEM_SORT_TAGS),配合 ModelItemGroup.FilterMode(如 MATCH_ANY)构成一个小型、可组合的过滤 DSL。设计意图在于:过滤器链的每一环都是"集合 → 集合"的纯函数,UI 只消费最终产物,因此新增一种过滤维度(例如按稀有度、按阵营)只需增加一个提取器枚举值,无需改动控制器结构。
筛选面板的动态生成
标签按钮并非 FXML 静态写死,而是在数据集重载时基于"当前列表中实际出现过的标签"动态构建:
1filterTagSet = FXCollections.observableSet();
2filterTagSet.addListener((SetChangeListener<String>) change -> {
3 String s = change.getElementAdded() == null ? change.getElementRemoved() : change.getElementAdded();
4 String t = app.modelsDataset.sortTags == null ? s : app.modelsDataset.sortTags.getOrDefault(s, s);
5 for (Node node : filterPaneTagFlow.getChildren())
6 // ... 依据 s 是否仍在集合中切换对应按钮的高亮样式 ...
7});Source: ModelsModule.java
1if (assetItemList != null && app.modelsDataset != null) {
2 ArrayList<String> sortTags = new ArrayList<>(assetItemList.extract(ModelItem.PropertyExtractor.ASSET_ITEM_SORT_TAGS));
3 sortTags.sort(Comparator.naturalOrder());
4 sortTags.forEach(s -> {
5 String t = app.modelsDataset.sortTags == null ? s : app.modelsDataset.sortTags.getOrDefault(s, s);
6 // ... 为每个 s 创建标签按钮 ...
7 // .setOnAction(ev -> {
8 // if (filterTagSet.contains(s))
9 // filterTagSet.remove(s);
10 // else
11 // filterTagSet.add(s);
12 // });
13 });
14}Source: ModelsModule.java
这段实现包含三个值得注意的设计决策:
- 标签全集来自数据而非元数据:
assetItemList.extract(ASSET_ITEM_SORT_TAGS)从当前模型条目中抽取实际出现的标签,再按Comparator.naturalOrder()排序。这样数据集新增标签即自动出现在面板上,无需改 UI 代码。 - 显示名与原始值分离:按钮显示
sortTags.getOrDefault(s, s)的本地化名称,而filterTagSet中保存的是原始键s——显示层与数据层解耦,避免本地化文案混入过滤逻辑。 - ObservableSet 作为唯一事实来源:按钮点击只做
add/remove,高亮状态完全由SetChangeListener反推(遍历filterPaneTagFlow中对应按钮切换样式)。同一状态不会被两处代码分别维护,杜绝"按钮亮着但筛选未生效"这类不一致。
核心交互流程
重载流程(modelReload → initModelsDataset → 重建 filterTagSet 与标签按钮 → modelSearch)与上图共享同一个终点 targetList,因此任何数据源变化(下载新模型、导入模型)之后只需调用 modelReload(true) 即可让浏览/搜索/筛选三套 UI 全部回到一致状态——这也是 PostUnzipModelTask 成功回调末尾调用 app.modelsModule.modelReload(true) 的原因(见 ModelsModule.java L393-L403)。
配置项与数据依赖
| 依赖 | 类型 / 来源 | 默认行为 | 说明 |
|---|---|---|---|
fileModelsDataPath | PathConfig 常量 | 必须存在 | 本地数据集 JSON 路径;缺失时 FileNotFoundException,列表为空并弹"模型载入失败" |
modelsDataset.sortTags | HashMap<String,String> | 可为 null | 标签显示名映射;null 时回退为直接显示原始标签键 |
modelsDataset.storageDirectory | HashMap<String,File> | 必须非空 | type → 资源目录映射,用于补全每个 ModelItem.assetDir |
modelsDataset.arkPetsCompatibility | Version(int[] 反序列化) | — | 数据集兼容版本,参与双向兼容性检查 |
config.character_favorites | JSONObject | 惰性初始化为空对象并保存 | 收藏键集合,收藏过滤的数据来源 |
config.character_asset | JSONObject 键 | 可为空 | 上次选中模型的相对路径,用于切换过滤后恢复选中 |
appVersion / datasetLowestVersion | Const 常量 | — | 软件版本与数据集最低可接受版本 |
失败模式与边界情况
- 数据集缺失或损坏:
initModelsDataset捕获FileNotFoundException等异常,显式置app.modelsDataset = null后重抛;doPopNotice决定是否弹出警告对话框(文案"模型未成功载入:未找到数据集。")。浏览界面进入空态,loadEmptyAction占位提示可一键重置。 - 关键字段缺失:
ModelsDataset构造对storageDirectory、data为空,以及条目type无对应目录的情况,统一抛DatasetKeyException(IllegalArgumentException子类),在数据进入 UI 前快速失败。 - 无效条目:
data.removeIf(Predicate.not(ModelItem::isValid))在载入阶段剔除,不会进入搜索结果。 - 低版本数据集:缺少
assetList的条目由assetId + extensions规则重建,兼容旧格式。 - 兼容性告警为非阻断:版本不匹配只激活
NoticeBar(datasetTooLowVerNotice/datasetTooHighVerNotice),不中断浏览;高版本告警点击可跳转urlOfficialDownloadPage。版本检查本身也包裹在 try/catch 中,失败仅记录Logger.warn。 - 并发与线程:过滤与列表写回均在 JavaFX Application Thread 上执行(按钮/键盘事件回调);耗时的全量重置操作(
searchModelReset、filterPaneTagClear)通过app.popLoading以加载遮罩包裹,避免用户在过滤期间重复触发。数据集 JSON 的磁盘读取与 fastjson2 解析发生在载入路径上,异常被统一收敛到initModelsDataset的外层 catch。 - 状态一致性:
filterTagSet在每次重载时整体重建为新实例并重新挂监听器,旧按钮节点被filterPaneTagFlow重建替换,避免旧监听器泄漏或指向已失效的标签集合。
扩展点
- 新增过滤维度:在
ModelItem.PropertyExtractor中新增提取器枚举值,即可在过滤链中叠加新的ModelItemGroup.filter(...)环节(现有维度:ASSET_ITEM_KEY用于收藏、ASSET_ITEM_SORT_TAGS用于标签)。 - 新增筛选面板控件:仿照标签面板的"ObservableSet + SetChangeListener 反推样式"模式,为新的筛选集合挂监听器即可获得同样的状态一致性保证。
- 空态行为定制:
loadEmptyAction的占位提示通过合成ActionEvent复用既有按钮处理器,新增空态动作时可直接追加同类合成调用。 - 数据集结构演进:
ModelsDatasetBean是唯一的 JSON 契约层;如需新字段,在 Bean 上加@JSONFieldsetter,并在ModelsDataset(ModelsDatasetBean)中做补全/兼容处理(assetList的兼容重建即范例)。
相关链接
- ModelsModule.java — 浏览/搜索/筛选控制器入口
- ModelsDataset.java — 数据集反序列化与预处理
- ModelItem.java — 模型条目实体与
PropertyExtractor - ModelItemGroup.java — 集合过滤 API(
filter/extract/searchByRelPath) - 模型下载、解压、校验等管理任务(
guitasks包)属于"模型管理"主题,请参阅对应章节。