Repository Wiki
isHarryh/Ark-Pets

模型浏览、搜索与筛选

模型浏览、搜索与筛选是 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 启动器主界面中,"模型操作台"是用户挑选桌宠模型的入口。其工作方式为:

  1. 数据集载入:启动时(或用户点击重载后)从本地数据文件 PathConfig.fileModelsDataPath 读取 JSON,反序列化为 ModelsDataset,其核心成员 data 是一个 ModelItemGroup(模型条目集合)。
  2. 列表浏览:ModelItem 通过 JavaFX ObservableList(targetList)绑定到 modelListView,用户滚动浏览即可查看全部可用模型。
  3. 关键字搜索:用户在 searchModelInput 中输入关键字并回车(或点击确认按钮),触发 modelSearch 对集合进行过滤。
  4. 标签筛选:筛选面板(filterPane)基于数据集 sortTags 动态生成标签按钮,用户选中的标签汇入 filterTagSet(ObservableSet),与关键字搜索叠加生效。
  5. 收藏过滤:topFavorite 按钮切换 filterFavorite 布尔状态,仅显示 app.config.character_favorites 中已收藏的模型。

关键字、标签、收藏三者叠加构成一条过滤器链,最终结果写回 targetList,JavaFX 属性绑定机制使 ListView 自动刷新——这是整个能力最关键的设计:UI 状态与数据过滤解耦,避免手动同步。

架构

Loading diagram...

图中要点(均可在源码中验证):

  • 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 → 可过滤集合"的全部预处理,包含三件关键事情:目录映射、字段补全、向后兼容。

java
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

这段预处理的设计意图:

  1. storageDirectory → assetDir 补全:JSON 中每个模型条目只记录 type 与 key,实际资源目录由 storageDirectory[type]/key 在运行时拼接得出。这样数据集不必为每条记录重复存储绝对路径,目录结构调整时也只需改一处映射。
  2. sortTags 保持原样透传:它是"标签原始值 → 显示名"的映射,供筛选面板做本地化显示(见后文"标签筛选"一节)。
  3. 低版本数据集兼容:当条目缺少 assetList(新版字段)但存在旧的 assetId + checksum 时,用 ModelItem.extensions 中的扩展名拼出默认文件名表,实现新旧数据集格式的平滑迁移。
  4. 最后统一 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 等)。其核心可变状态只有四个:

java
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) 中与浏览/搜索相关的一次性装配(裁剪节选):

java
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) 是浏览能力的前置条件,它读取本地数据文件并做两级兼容性判断:

java
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() 注册了搜索条的全部触发路径:

java
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() 建立标签清除、收藏切换两个入口,并对收藏配置做惰性初始化:

java
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。源码中的关键调用组合如下:

java
ModelItemGroup filtered = filterTagSet.isEmpty() ? favoured : favoured.filter(ModelItem.PropertyExtractor.ASSET_ITEM_SORT_TAGS, filterTagSet);

Source: ModelsModule.java

从 Grep 交叉引用可还原整条链路(各片段均出自 ModelsModule):

  1. 收藏过滤(可选):当 filterFavorite 开启时,先以 favoured = assetItemList.filter(ModelItem.PropertyExtractor.ASSET_ITEM_KEY, app.config.character_favorites.keySet(), ModelItemGroup.FilterMode.MATCH_ANY) 取出收藏键集合命中的条目(见 ModelsModule.java L498)。
  2. 标签过滤(可选):filterTagSet 非空时,再以 ASSET_ITEM_SORT_TAGS 提取器按标签集合过滤;为空则直接沿用上一步结果。
  3. 关键字过滤:modelSearch(text) 在上述结果之上按关键字匹配(ModelItem 的名称/代号等文本字段)。
  4. 写回 UI:结果写入 targetList,modelListView 因 ObservableList 绑定自动刷新,同时 searchModelStatus 更新结果计数提示。

ModelItem.PropertyExtractor 是统一的属性提取器枚举(如 ASSET_ITEM_KEY、ASSET_ITEM_SORT_TAGS),配合 ModelItemGroup.FilterMode(如 MATCH_ANY)构成一个小型、可组合的过滤 DSL。设计意图在于:过滤器链的每一环都是"集合 → 集合"的纯函数,UI 只消费最终产物,因此新增一种过滤维度(例如按稀有度、按阵营)只需增加一个提取器枚举值,无需改动控制器结构。

筛选面板的动态生成

标签按钮并非 FXML 静态写死,而是在数据集重载时基于"当前列表中实际出现过的标签"动态构建:

java
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

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 中对应按钮切换样式)。同一状态不会被两处代码分别维护,杜绝"按钮亮着但筛选未生效"这类不一致。

核心交互流程

Loading diagram...

重载流程(modelReload → initModelsDataset → 重建 filterTagSet 与标签按钮 → modelSearch)与上图共享同一个终点 targetList,因此任何数据源变化(下载新模型、导入模型)之后只需调用 modelReload(true) 即可让浏览/搜索/筛选三套 UI 全部回到一致状态——这也是 PostUnzipModelTask 成功回调末尾调用 app.modelsModule.modelReload(true) 的原因(见 ModelsModule.java L393-L403)。

配置项与数据依赖

依赖类型 / 来源默认行为说明
fileModelsDataPathPathConfig 常量必须存在本地数据集 JSON 路径;缺失时 FileNotFoundException,列表为空并弹"模型载入失败"
modelsDataset.sortTagsHashMap<String,String>可为 null标签显示名映射;null 时回退为直接显示原始标签键
modelsDataset.storageDirectoryHashMap<String,File>必须非空type → 资源目录映射,用于补全每个 ModelItem.assetDir
modelsDataset.arkPetsCompatibilityVersion(int[] 反序列化)—数据集兼容版本,参与双向兼容性检查
config.character_favoritesJSONObject惰性初始化为空对象并保存收藏键集合,收藏过滤的数据来源
config.character_assetJSONObject 键可为空上次选中模型的相对路径,用于切换过滤后恢复选中
appVersion / datasetLowestVersionConst 常量—软件版本与数据集最低可接受版本

失败模式与边界情况

  • 数据集缺失或损坏: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 上加 @JSONField setter,并在 ModelsDataset(ModelsDatasetBean) 中做补全/兼容处理(assetList 的兼容重建即范例)。

相关链接

  • ModelsModule.java — 浏览/搜索/筛选控制器入口
  • ModelsDataset.java — 数据集反序列化与预处理
  • ModelItem.java — 模型条目实体与 PropertyExtractor
  • ModelItemGroup.java — 集合过滤 API(filter / extract / searchByRelPath)
  • 模型下载、解压、校验等管理任务(guitasks 包)属于"模型管理"主题,请参阅对应章节。

Sources

(2 files)
core/src/cn/harryh/arkpets/assets
desktop/src/cn/harryh/arkpets/controllers