AddContentDialog 模块全面系统分析
目录
1 模块概述
1.1 基本信息
| 属性 | 值 |
|---|---|
| 模块名称 | AddContentDialog |
| 类型 | Editor(编辑器模块) |
| 描述 | 提供"向项目添加内容"对话框,用于浏览和安装 Feature Pack(功能包)内容到当前项目中 |
打开 UE5 编辑器,在内容浏览器或者项目菜单中点击"Add Content to the Project",弹出来的那个对话框就是由这个模块实现的。它本质上是一个内容分发系统的前端,负责扫描、展示、搜索、安装 Engine 和 Enterprise 目录下的 Feature Pack 资源包。

整个模块包含约 16 个源文件,涉及一个核心 Slate 窗口、一个 ViewModel 层、一个内容源提供者系统、以及负责处理 .upack 和 manifest.json 格式的 Feature Pack 解析器。代码量不算大,但层次结构清晰,非常适合作为学习 UE5 编辑器 UI 架构和插件化内容分发设计的案例。
2 模块整体架构解析
2.1 架构图
AddContentDialog 模块采用了分层架构设计,从底层的磁盘文件扫描,到中间层的数据模型抽象,再到上层的 Slate UI 展示,每一层都有清晰的职责边界。
┌─────────────────────────────────────────────────────────────────────┐
│ Slate UI 层 (表现层) │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ SAddContentDialog│ │ SAddContentWidget│ │SGenericThumbnail │ │
│ │ (SWindow 子类) │ │ (SCompoundWidget) │ │Tile (内容卡片) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └──────────────────┘ │
└───────────┼──────────────────────┼──────────────────────────────────┘
│ │
│ ▼
┌───────────┴──────────────────────────────────────────────────────────┐
│ ViewModel 层 (数据绑定层) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ FAddContentWidgetViewModel (核心 ViewModel) │ │
│ │ - 管理分类列表 (Categories) │ │
│ │ - 管理内容源列表 (ContentSourceViewModels) │ │
│ │ - 文本搜索过滤 (TTextFilter) │ │
│ │ - 分类×内容源的选中状态维护 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────┐ ┌────────────────────────────────────┐ │
│ │ FCategoryViewModel │ │ FContentSourceViewModel │ │
│ │ - 分类枚举→显示名称 │ │ - 名称/描述/图标/截图/分类 │ │
│ │ - 排序ID │ │ - 多语言文本选择 │ │
│ │ │ │ - PNG→SlateBrush 转换 │ │
│ └────────────────────────┘ └────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ 内容源抽象层 (接口层) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ IContentSource (接口) │ │
│ │ - 名称/描述/分类/资产类型/类类型 │ │
│ │ - 图标/截图数据 │ │
│ │ - 排序键/标识 │ │
│ │ - InstallToProject() 安装到项目 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ IContentSourceProvider (接口) │ │
│ │ - GetContentSources() 获取内容源列表 │ │
│ │ - SetContentSourcesChanged() 注册变更通知委托 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ FContentSourceProviderManager (管理器) │ │
│ │ - RegisterContentSourceProvider() 注册提供者 │ │
│ │ - GetContentSourceProviders() 获取全部提供者 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ 实际实现层 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ FFeaturePackContentSourceProvider (特征包内容源提供者) │ │
│ │ - 扫描 FeaturePackDir + EnterpriseFeaturePackDir 目录 │ │
│ │ - 注册 DirectoryWatcher 实现热更新 │ │
│ │ - 按 SortKey 排序内容源 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ FFeaturePackContentSource (特征包内容源) │ │
│ │ - 解析 .upack 文件 (Pak 挂载 + manifest.json 解析) │ │
│ │ - 解析松散 manifest.json 文件 (Templates/FeaturePack/ 目录) │ │
│ │ - 安装到项目 (ImportAssets + 保存 + ContentBrowser 导航) │ │
│ │ - 附加文件复制 / 附加资源包插入 / 启动时导入 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
2.2 模块间依赖关系
如果上述的架构图看起来还有点抽象,可以参考下面的依赖关系进行理解。
SAddContentDialog (顶层窗口)
↓ 持有
SAddContentWidget (主 Widget)
↓ 绑定
FAddContentWidgetViewModel (数据核心)
↓ 使用
┌───────────────────────┬──────────────────────────┐
│ FCategoryViewModel │ FContentSourceViewModel │
└───────────────────────┴──────────────────────────┘
│
FContentSourceProviderManager
│
┌───────────────┴────────────────┐
│ FFeaturePackContentSourceProvider│
└───────────────┬────────────────┘
│
┌───────────────┴────────────────┐
│ FFeaturePackContentSource │
└───────────────┬────────────────┘
│
┌───────────────┼────────────────┐
│ Json │ PakFile │ ImageWrapper │ AssetTools │
└──────┴─────────┴──────────────┴────────────┘
可能大家看到这里会跟我一样感到好奇,为什么编辑器的一个添加内容对话框,要分这么多层,搞这么多接口和抽象。
其实,UE5 引擎开发者的目的是为了支持内容源的扩展性。虽然目前引擎只内置了 Feature Pack 这一种内容源(通过 .upack 文件和 manifest.json 提供),但 IContentSource 和 IContentSourceProvider 的接口设计允许第三方(比如插件市场、企业版)通过 FContentSourceProviderManager::RegisterContentSourceProvider() 注册自己的内容源提供者。这样一来,未来如果要支持从 Marketplace 直接下载内容、从局域网共享服务器获取内容,只需实现这两个接口即可,整个 UI 层和 ViewModel 层完全不需要修改。
同时,ViewModel 层的引入将 UI 状态与 Slate 控件解耦。FAddContentWidgetViewModel 负责管理分类选择、搜索过滤、内容源选中状态这些纯数据逻辑,而 SAddContentWidget 只负责渲染和响应用户交互。这种设计让代码更易于测试和维护。
2.3 模块划分
对于每个模块的功能总结如下:
1. 模块入口层
职责:
- 实现
IAddContentDialogModule接口 - 管理模块生命周期(启动/关闭)
- 注册内容源提供者
- 提供对话框展示入口
包含类:
FAddContentDialogModule
2. Slate UI 层
职责:
- 提供"添加内容到项目"对话框窗口
- 展示分类标签页、内容卡片网格、详情面板
- 处理用户交互(搜索、选择、安装)
包含类:
SAddContentDialog(顶层窗口)SAddContentWidget(主布局 Widget)SGenericThumbnailTile(内容卡片内部类)
3. ViewModel 数据层
职责:
- 管理分类列表和内容源列表
- 提供文本搜索过滤功能
- 维护每个分类的选中状态
- 将原始数据(IContentSource)转换为 UI 友好的格式(SlateBrush、缓存文本)
包含类:
FAddContentWidgetViewModelFCategoryViewModelFContentSourceViewModel
4. 内容源抽象层
职责:
- 定义内容源和内容源提供者的通用接口
- 管理内容源提供者的注册和获取
- 提供拖拽操作支持
包含类:
IContentSource(接口)IContentSourceProvider(接口)FContentSourceProviderManager(管理器)FContentSourceDragDropOp(拖拽操作)
5. Feature Pack 实现层
职责:
- 扫描磁盘上的 .upack 文件和 manifest.json 文件
- 解析 Feature Pack 的 JSON 清单
- 挂载 Pak 文件并读取内容
- 安装内容到项目中(导入资产、复制文件、聚焦资源)
包含类:
FFeaturePackContentSourceProviderFFeaturePackContentSourceFPackData(辅助结构体)FFeaturePackLevelSet(辅助结构体)FFeatureAdditionalFiles(辅助结构体)
2.4 数据流走向
对话框打开流程:
1. 外部调用 IAddContentDialogModule::ShowDialog(ParentWindow)
↓
2. FAddContentDialogModule::ShowDialog() 创建 SAddContentDialog
↓
3. SAddContentDialog::Construct() 创建 SAddContentWidget 作为子 Widget
↓
4. SAddContentWidget::Construct() 创建 FAddContentWidgetViewModel
↓
5. FAddContentWidgetViewModel::Initialize()
├─ 创建 TTextFilter 用于文本搜索
├─ 从 FContentSourceProviderManager 获取所有 IContentSourceProvider
├─ 为每个 Provider 绑定 ContentSourcesChanged 委托
└─ 调用 BuildContentSourceViewModels()
↓
6. BuildContentSourceViewModels()
├─ 遍历所有 Provider 的 ContentSources
├─ 过滤掉 SharedPack 和 Unknown 分类
├─ 为每个内容源创建 FContentSourceViewModel
├─ 收集所有分类并创建 FCategoryViewModel
└─ 按分类排序,初始化每个分类的选中状态
↓
7. UI 绑定完成,对话框显示
用户选择内容并安装流程:
1. 用户点击分类标签页 → OnSelectedCategoryChanged()
↓
2. ViewModel->SetSelectedCategory() → UpdateFilteredContentSourcesAndSelection()
↓
3. 过滤后的内容源列表更新 → STileView 刷新
↓
4. 用户搜索 → SearchTextChanged() → ViewModel->SetSearchText()
↓
5. TTextFilter 过滤 → UpdateFilteredContentSourcesAndSelection()
↓
6. 用户点击内容卡片 → ContentSourceTileViewSelectionChanged()
↓
7. ViewModel->SetSelectedContentSource()
↓
8. 右侧详情面板更新(截图轮播、名称、描述、资产类型、类类型)
↓
9. 用户点击 "Add to Project" → AddButtonClicked()
↓
10. 调用 FFeaturePackContentSource::InstallToProject("/Game")
├─ 插入附加资源包(InsertAdditionalResources)
├─ 复制附加文件(CopyAdditionalFilesToFolder)
├─ 通过 AssetTools 导入 .upack 资产
├─ 保存导入的资产
└─ 聚焦到指定资产(如果配置了 FocusAsset)
Feature Pack 目录变更热更新流程:
1. FDirectoryWatcher 检测到 FeaturePackDir 文件变更
↓
2. FFeaturePackContentSourceProvider::OnFeaturePackDirectoryChanged()
↓
3. RefreshFeaturePacks()
├─ 清空 ContentSources 数组
├─ 重新扫描 .upack 文件
├─ 重新扫描 Templates/FeaturePack/ 下的 manifest.json
└─ 按 SortKey 排序
↓
4. OnContentSourcesChanged.ExecuteIfBound()
↓
5. FAddContentWidgetViewModel::ContentSourcesChanged()
↓
6. BuildContentSourceViewModels() 重建全部 ViewModel
↓
7. UI 自动刷新
2.5 核心技术栈
| 技术/类 | 用途 |
|---|---|
SWindow | 对话框顶层窗口 |
SCompoundWidget | 复合 Widget 基类 |
STileView | 内容卡片网格视图 |
SSegmentedControl | 分类标签页控件 |
SSearchBox | 搜索框 |
SWidgetCarouselWithNavigation | 截图轮播控件 |
FSlateDynamicImageBrush | 动态创建 Slate 画刷(从 PNG 数据) |
IImageWrapper | 解码 PNG 图像数据 |
FPakPlatformFile | 挂载和读取 .upack(Pak 格式)文件 |
FJsonSerializer | 解析 manifest.json |
FDirectoryWatcher | 监控 Feature Pack 目录变更 |
TTextFilter | 文本搜索过滤 |
FAssetToolsModule | 导入资产到项目中 |
FContentBrowserModule | 同步 ContentBrowser 到指定资产 |
2.6 架构设计优势
- 高扩展性:
IContentSource/IContentSourceProvider接口设计允许任意添加新的内容源类型,无需修改 UI 和 ViewModel 层 - MVVM 分离:ViewModel 负责数据逻辑,Slate Widget 负责渲染,职责清晰
- 热更新支持:通过
FDirectoryWatcher监控目录,新增/删除 Feature Pack 后 UI 自动刷新,无需重启编辑器 - 多语言支持:整个内容源系统从底层 JSON 清单到顶层 ViewModel 都支持多语言本地化
- 双格式兼容:同时支持 .upack(打包格式)和松散 manifest.json 两种分发方式
- 细节完善:截图轮播、搜索过滤、分类记忆、安装后自动聚焦资产等细节都处理到位
潜在改进点
- 内容源类型单一:目前只有 Feature Pack 一种实现,接口的扩展能力尚未被充分利用
- 异步安装:
InstallToProject()是同步操作,安装大型 Pack 时可能阻塞 UI 线程 - 进度反馈:安装过程缺少进度回调,用户无法得知安装进度
- 错误恢复:安装失败后缺少回滚机制,部分文件可能已复制但资产导入失败
- 缓存优化:每次打开对话框都会重新扫描目录并解析 JSON,可以增加缓存机制
3 类级代码注释详解
3.1 IAddContentDialogModule 接口
概述
IAddContentDialogModule 是 AddContentDialog 模块的对外接口,继承自 IModuleInterface。它定义了模块的两个核心能力:获取内容源提供者管理器、展示对话框。
核心方法:
GetContentSourceProviderManager()
功能分析:
- 返回
FContentSourceProviderManager的共享引用 - 外部模块可以通过这个接口注册自己的内容源提供者
- 这是模块扩展性的关键入口
ShowDialog(TSharedRef<SWindow> ParentWindow)
功能分析:
- 创建并显示"添加内容到项目"对话框
- 接收父窗口引用,作为模态子窗口显示
- 如果对话框已存在,不会重复创建(通过
TWeakPtr<SWindow>跟踪)
实现原理:
在 FAddContentDialogModule::ShowDialog() 中,使用了 FSlateApplication::Get().AddWindowAsNativeChild() 将对话框添加为原生子窗口,这比 AddModalWindow() 更轻量,且不会阻塞编辑器主循环。
3.2 FAddContentDialogModule 模块实现
功能分析(模块入口):
class FAddContentDialogModule : public IAddContentDialogModule
这是模块的核心实现类,通过 IMPLEMENT_MODULE 宏注册到引擎中。
StartupModule()
void StartupModule() override
{
FModuleManager::LoadModuleChecked<FWidgetCarouselModule>("WidgetCarousel");
FWidgetCarouselModuleStyle::Initialize();
ContentSourceProviderManager = TSharedPtr<FContentSourceProviderManager>(
new FContentSourceProviderManager());
ContentSourceProviderManager->RegisterContentSourceProvider(
MakeShareable(new FFeaturePackContentSourceProvider()));
}
功能分析:
- 加载 WidgetCarousel 模块:截图轮播功能依赖此模块,需要确保在模块启动时已加载
- 初始化样式:
FWidgetCarouselModuleStyle::Initialize()注册轮播控件所需的 Slate 样式 - 创建管理器:创建
FContentSourceProviderManager实例 - 注册提供者:将
FFeaturePackContentSourceProvider注册为内容源提供者。这一步是扩展性的体现——如果有更多内容源类型,只需在这里注册额外的 Provider
ShutdownModule()
void ShutdownModule() override
{
FWidgetCarouselModuleStyle::Shutdown();
}
模块关闭时清理样式资源。
3.3 FContentSourceProviderManager 类
概述
FContentSourceProviderManager 是一个简单的管理器类,负责维护已注册的 IContentSourceProvider 列表。
核心方法:
RegisterContentSourceProvider(TSharedRef<IContentSourceProvider> ContentSourceProvider)
功能分析:
- 将内容源提供者添加到内部数组
- 使用
TSharedRef确保提供者的生命周期被正确管理
GetContentSourceProviders()
功能分析:
- 返回内部数组的指针
- 调用者可以遍历所有提供者以获取全部内容源
设计意图:
这个管理器的存在是为了支持插件化的内容源注册。虽然目前只有一个内置的 FFeaturePackContentSourceProvider,但如果未来有插件想要提供自己的内容源(比如从远程服务器下载),只需获取 IAddContentDialogModule 然后调用 GetContentSourceProviderManager()->RegisterContentSourceProvider(...) 即可。
3.4 IContentSource 接口
概述
IContentSource 是内容源的抽象接口,定义了内容源必须提供的信息和行为。这个接口是所有内容源实现的基石。
class IContentSource
{
public:
virtual const TArray<FLocalizedText>& GetLocalizedNames() const = 0;
virtual const TArray<FLocalizedText>& GetLocalizedDescriptions() const = 0;
virtual const TArray<EContentSourceCategory>& GetCategories() const = 0;
virtual TSharedPtr<FImageData> GetIconData() const = 0;
virtual const TArray<TSharedPtr<FImageData>>& GetScreenshotData() const = 0;
virtual const TArray<FLocalizedText>& GetLocalizedAssetTypes() const = 0;
virtual const FString& GetClassTypesUsed() const = 0;
virtual const FString& GetSortKey() const = 0;
virtual bool InstallToProject(FString InstallPath) = 0;
virtual bool IsDataValid() const = 0;
virtual const FString& GetIdent() const = 0;
};
关键接口分析:
多语言文本 (LocalizedNames / LocalizedDescriptions / LocalizedAssetTypes)
这三个方法返回的都是 TArray<FLocalizedText>,而不是单个 FText。这是因为 Feature Pack 的 manifest.json 可以为同一条信息提供多种语言版本。FLocalizedText 包含两个字段:TwoLetterLanguage(ISO 639-1 语言代码)和 Text(该语言的文本)。UI 层会根据当前编辑器语言选择合适的版本。
分类 (GetCategories)
一个内容源可以属于多个分类,返回的是 TArray<EContentSourceCategory>。EContentSourceCategory 枚举定义如下:
| 枚举值 | 含义 |
|---|---|
BlueprintFeature | 蓝图功能模板 |
CodeFeature | C++ 代码功能模板 |
EnterpriseFeature | 企业版(Unreal Studio)功能 |
Content | 内容包 |
EnterpriseContent | 企业版内容包 |
SharedPack | 共享资源包(UI 中隐藏) |
Unknown | 未知类型(UI 中隐藏) |
安装到项目 (InstallToProject)
这是内容源最核心的行为——将内容安装到项目中。返回 bool 表示安装是否成功。
数据有效性 (IsDataValid)
允许内容源在解析失败后标记自己为无效,UI 层会据此过滤掉无效的内容源。
标识 (GetIdent)
返回内容源的唯一标识字符串,用于在 InsertAdditionalResources 中查找已注册的内容源。
3.5 IContentSourceProvider 接口
概述
IContentSourceProvider 是内容源提供者的抽象接口。它负责发现和提供内容源列表,并在内容源变更时通知监听者。
class IContentSourceProvider
{
public:
DECLARE_DELEGATE(FOnContentSourcesChanged);
virtual const TArray<TSharedRef<IContentSource>>& GetContentSources() const = 0;
virtual void SetContentSourcesChanged(FOnContentSourcesChanged OnContentSourcesChangedIn) = 0;
};
FOnContentSourcesChanged 委托机制是 ViewModel 层能够自动刷新 UI 的关键。当 FFeaturePackContentSourceProvider 检测到磁盘上的 Feature Pack 文件发生变化时,会触发这个委托,ViewModel 层收到通知后重建所有内容源的 ViewModel 并更新 UI。
3.6 FFeaturePackContentSourceProvider 类
概述
FFeaturePackContentSourceProvider 是 IContentSourceProvider 的唯一内置实现,负责扫描磁盘上的 Feature Pack 文件并提供相应的 FFeaturePackContentSource 对象。
核心实现分析:
构造函数
FFeaturePackContentSourceProvider::FFeaturePackContentSourceProvider()
{
FeaturePackPath = FPaths::FeaturePackDir();
EnterpriseFeaturePackPath = FPaths::EnterpriseFeaturePackDir();
TemplatePath = FPaths::RootDir() + TEXT("Templates/");
EnterpriseTemplatePath = FPaths::EnterpriseDir() + TEXT("Templates/");
StartUpDirectoryWatcher();
RefreshFeaturePacks();
}
功能分析:
- 设置四个扫描路径:Engine 的 FeaturePacks 目录、Enterprise 的 FeaturePacks 目录、Templates 目录、Enterprise Templates 目录
- 启动目录监控器(
DirectoryWatcher),以便在文件变更时自动刷新 - 立即执行一次全量扫描(
RefreshFeaturePacks())
RefreshFeaturePacks()
void FFeaturePackContentSourceProvider::RefreshFeaturePacks()
{
ContentSources.Empty();
// 1. 扫描 .upack 文件
IPlatformFile &PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
FFillArrayDirectoryVisitor DirectoryVisitor;
PlatformFile.IterateDirectory(*FeaturePackPath, DirectoryVisitor);
PlatformFile.IterateDirectory(*EnterpriseFeaturePackPath, DirectoryVisitor);
for (auto FeaturePackFile : DirectoryVisitor.Files)
{
if (FeaturePackFile.EndsWith(TEXT(".upack")) == true)
{
TUniquePtr<FFeaturePackContentSource> NewContentSource =
MakeUnique<FFeaturePackContentSource>(FeaturePackFile);
if (NewContentSource->IsDataValid())
{
ContentSources.Add(MakeShareable(NewContentSource.Release()));
}
}
}
// 2. 扫描松散 manifest.json 文件
// ...
// 3. 按 SortKey 排序
ContentSources.Sort(FFeaturePackCompareSortKey());
// 4. 通知 UI 刷新
OnContentSourcesChanged.ExecuteIfBound();
}
功能分析:
整个扫描分为两个阶段:
- .upack 文件扫描:使用
IPlatformFile::IterateDirectory()遍历 FeaturePacks 目录,找到所有 .upack 文件,为每个文件创建FFeaturePackContentSource实例 - 松散 manifest.json 扫描:递归遍历 Templates 目录,找到路径以 “FeaturePack” 结尾的目录下的 manifest.json 文件
两种格式的共存设计是为了兼容不同的分发场景——.upack 适合打包分发,而松散格式适合开发期快速迭代和模板系统中的内容。
目录监控
void StartUpDirectoryWatcher()
{
FDirectoryWatcherModule& DirectoryWatcherModule =
FModuleManager::LoadModuleChecked<FDirectoryWatcherModule>(TEXT("DirectoryWatcher"));
IDirectoryWatcher* DirectoryWatcher = DirectoryWatcherModule.Get();
// 注册 FeaturePackPath 和 EnterpriseFeaturePackPath 的变更回调
DirectoryWatcher->RegisterDirectoryChangedCallback_Handle(
FeaturePackPath, DirectoryChangedDelegate, DirectoryChangedDelegateHandle);
DirectoryWatcher->RegisterDirectoryChangedCallback_Handle(
EnterpriseFeaturePackPath, DirectoryChangedDelegate, DirectoryChangedDelegateHandle);
}
目录监控使得用户可以在编辑器运行时,将新的 .upack 文件放入 FeaturePacks 目录,对话框会自动检测到并刷新内容列表,无需重启编辑器。
3.7 FFeaturePackContentSource 类
概述
FFeaturePackContentSource 是 IContentSource 接口的核心实现,也是整个模块中代码量最大的类(约 920 行)。它负责解析 Feature Pack 的 JSON 清单、挂载 Pak 文件、读取图像数据、以及安装内容到项目中。
构造函数(两种模式)
FFeaturePackContentSource::FFeaturePackContentSource(FString InFeaturePackPath)
{
FeaturePackPath = InFeaturePackPath;
if (InFeaturePackPath.EndsWith(TEXT(".upack")) == true)
{
// Pak 模式:挂载 .upack 文件,从 Pak 中读取 manifest.json 和图片
bContentsInPakFile = true;
MountPoint = FPaths::GameFeatureRootPrefix();
// 创建/获取 PakPlatformFile,挂载文件
// 读取 manifest.json → ParseManifestString()
// 读取图片 → LoadFeaturePackImageDataFromPackFile()
// 卸载 Pak
}
else
{
// 松散模式:直接读取 manifest.json 和图片文件
bContentsInPakFile = false;
// 设置 MountPoint 为 manifest 文件所在目录
// 读取 manifest.json → ParseManifestString()
// 读取图片 → LoadFeaturePackImageData()
}
}
功能分析:
构造函数承担了所有解析工作,分为两种路径:
- .upack 路径:将 .upack 文件作为 Pak 文件挂载到
FPaths::GameFeatureRootPrefix()挂载点,然后通过FPakPlatformFile读取内部的 manifest.json 和图片资源。解析完成后立即卸载 Pak,释放文件句柄。 - 松散路径:直接通过
FFileHelper::LoadFileToString()读取 manifest.json,通过FFileHelper::LoadFileToArray()读取图片。
bContentsInPakFile 成员变量在后续的 InstallToProject() 中发挥了关键作用——它决定了安装时是使用 AssetTools 导入 .upack 文件,还是使用其他方式处理。
ParseManifestString()
这是整个类中最核心的解析函数,负责将 JSON 字符串解析为内容源的各项属性。
JSON 校验逻辑通过 TryValidateManifestObject() 辅助函数完成,它检查了以下必须字段:
Name(数组,每个元素包含 Language 和 Text)Description(数组,同上)AssetTypes(数组,同上)ClassTypes(字符串)Category(数组或字符串)Thumbnail(字符串,缩略图文件名)Screenshots(数组,截图文件名列表)
解析过程还包括可选字段:
Version:版本号Ident:唯一标识SortKey:排序键FocusAsset:安装后聚焦的资产路径SearchTags:搜索标签AdditionalFiles:附加文件配置AdditionalFeaturePacks:依赖的附加资源包
InstallToProject()
bool FFeaturePackContentSource::InstallToProject(FString InstallPath)
{
// 1. 插入附加资源包(依赖)
InsertAdditionalResources(AdditionalFeaturePacks, EFeaturePackDetailLevel::High,
FPaths::ProjectDir(), FilesCopied);
// 2. 复制附加文件
if (AdditionalFilesForPack.AdditionalFilesList.Num() != 0)
{
CopyAdditionalFilesToFolder(FPaths::ProjectDir(), FilesCopied, bHasSourceFiles);
}
// 3. 导入 .upack 资产
if (bContentsInPakFile == true)
{
FAssetToolsModule& AssetToolsModule = FModuleManager::Get()
.LoadModuleChecked<FAssetToolsModule>("AssetTools");
TArray<FString> AssetPaths;
AssetPaths.Add(FeaturePackPath);
TArray<UObject*> ImportedObjects = AssetToolsModule.Get()
.ImportAssets(AssetPaths, InstallPath);
// 保存导入的资产
FEditorFileUtils::PromptForCheckoutAndSave(ToSave, false, false);
}
// 4. 聚焦到指定资产
if (GetFocusAssetName().IsEmpty() == false)
{
// 通过 ContentBrowserModule 导航到 FocusAsset
}
return bResult;
}
功能分析:
安装流程分为四个步骤,步骤之间有依赖关系:
- 附加资源包(
InsertAdditionalResources):先安装依赖的共享资源包。这个方法会遍历AdditionalFeaturePacks列表,尝试从已注册的 ContentSourceProvider 中找到匹配的包(通过GetIdent()匹配),如果找不到则去磁盘上查找对应的 .upack 文件。 - 附加文件(
CopyAdditionalFilesToFolder):复制 manifest.json 中AdditionalFiles配置的文件到项目目录。支持通配符匹配,会自动检测是否包含源码文件。 - 资产导入(
ImportAssets):通过FAssetToolsModule::ImportAssets()将 .upack 文件中的所有资产导入到项目中。导入完成后,调用FAssetCompilingManager::Get().FinishAllCompilation()等待异步编译完成,然后通过FEditorFileUtils::PromptForCheckoutAndSave()保存。 - 聚焦资产(
FocusAsset):如果清单中配置了FocusAsset,安装完成后自动在 ContentBrowser 中定位并选中该资产,方便用户快速找到新添加的内容。
ImportPendingPacks() 静态方法
这是一个静态方法,用于在编辑器启动时"自动导入"之前通过命令行或其他方式标记的 Feature Pack。它从 GGameIni 的 [StartupActions] 段读取 bAddPacks 和 InsertPack 配置,执行导入后清除标记。这主要用于项目模板生成等自动化场景。
3.8 FAddContentWidgetViewModel 类
概述
FAddContentWidgetViewModel 是整个 UI 的数据核心,承担了"Model"和"ViewModel"的双重角色。它管理分类列表、内容源列表、搜索过滤、以及每个分类的选中状态。
核心成员变量:
TArray<TSharedPtr<IContentSourceProvider>> ContentSourceProviders; // 内容源提供者
TArray<FCategoryViewModel> Categories; // 分类列表
TArray<TSharedPtr<FContentSourceViewModel>> ContentSourceViewModels; // 全部内容源
TArray<TSharedPtr<FContentSourceViewModel>> FilteredContentSourceViewModels; // 过滤后
TMap<FCategoryViewModel, TSharedPtr<FContentSourceViewModel>> CategoryToSelectedContentSourceMap; // 每个分类的选中状态
FCategoryViewModel SelectedCategory; // 当前选中分类
TSharedPtr<ContentSourceTextFilter> ContentSourceFilter; // 文本过滤器
关键设计:分类×内容源选中状态
CategoryToSelectedContentSourceMap 是一个 TMap<FCategoryViewModel, TSharedPtr<FContentSourceViewModel>>,它为每个分类独立维护一个选中状态。这意味着用户切换到不同分类时,之前在该分类下的选中内容会被保留。这是一个非常贴心的 UX 细节。
BuildContentSourceViewModels()
void FAddContentWidgetViewModel::BuildContentSourceViewModels()
{
// 1. 清空所有数据
Categories.Empty();
ContentSourceViewModels.Empty();
// ...
// 2. 过滤掉 SharedPack 和 Unknown 分类
TArray<EContentSourceCategory> FilteredCategories;
FilteredCategories.Add(EContentSourceCategory::SharedPack);
FilteredCategories.Add(EContentSourceCategory::Unknown);
// 3. 遍历所有 Provider 和 ContentSource
for (const TSharedPtr<IContentSourceProvider>& ContentSourceProvider : ContentSourceProviders)
{
for (const TSharedRef<IContentSource>& ContentSource : ContentSourceProvider->GetContentSources())
{
// 检查是否所有分类都被过滤
bool bAnyVisible = false;
for (EContentSourceCategory ContentCategory : ContentSource->GetCategories())
{
if (!FilteredCategories.Contains(ContentCategory))
{
FoundCategories.Add(ContentCategory);
bAnyVisible = true;
}
}
if (bAnyVisible)
{
ContentSourceViewModels.Add(MakeShared<FContentSourceViewModel>(ContentSource));
}
}
}
// 4. 创建 CategoryViewModel 并排序
// 5. 反向遍历初始化每个分类的选中状态
for (int i = Categories.Num() - 1; i >= 0; i--)
{
SelectedCategory = Categories[i];
UpdateFilteredContentSourcesAndSelection(false);
}
}
功能分析:
SharedPack 和 Unknown 分类被硬编码过滤掉,因为它们是内部使用的分类,不应暴露给用户。
反向遍历初始化选中状态的逻辑值得注意:从最后一个分类开始向前遍历,这样第一个分类最终会成为选中状态。这种写法避免了记录"第一个分类"的额外逻辑。
UpdateFilteredContentSourcesAndSelection()
void FAddContentWidgetViewModel::UpdateFilteredContentSourcesAndSelection(bool bAllowEmptySelection)
{
FilteredContentSourceViewModels.Empty();
for (const TSharedPtr<FContentSourceViewModel>& ContentSource : ContentSourceViewModels)
{
if (ContentSource->GetCategories().Contains(SelectedCategory) &&
ContentSourceFilter->PassesFilter(ContentSource))
{
FilteredContentSourceViewModels.Add(ContentSource);
}
}
OnContentSourcesChanged.ExecuteIfBound();
// 如果当前选中项不在过滤结果中,自动选择第一个
if (FilteredContentSourceViewModels.Contains(GetSelectedContentSource()) == false)
{
TSharedPtr<FContentSourceViewModel> NewSelectedContentSource;
if (bAllowEmptySelection == false && FilteredContentSourceViewModels.Num() > 0)
{
NewSelectedContentSource = FilteredContentSourceViewModels[0];
}
SetSelectedContentSource(NewSelectedContentSource);
}
}
功能分析:
过滤逻辑通过两步完成:先按分类过滤(Contains(SelectedCategory)),再按搜索文本过滤(ContentSourceFilter->PassesFilter(ContentSource))。
bAllowEmptySelection 参数控制当过滤结果为空时,是否允许选中项也为空。在初始化阶段(BuildContentSourceViewModels),它被设为 false,确保每个分类至少有一个选中项。在用户交互阶段,它被设为 true,允许搜索无结果时清空选中。
3.9 FCategoryViewModel 类
概述
FCategoryViewModel 是一个轻量级的 ViewModel,将 EContentSourceCategory 枚举映射为 UI 友好的显示名称和排序 ID。
void FCategoryViewModel::Initialize()
{
switch (Category)
{
case EContentSourceCategory::BlueprintFeature:
Text = LOCTEXT("BlueprintFeature", "Blueprint");
SortID = 0;
break;
case EContentSourceCategory::CodeFeature:
Text = LOCTEXT("CodeFeature", "C++");
SortID = 1;
break;
// ...
}
}
排序 ID 决定了分类标签页在 UI 中的显示顺序:Blueprint → C++ → Unreal Studio Feature → Content → Unreal Studio Content → Miscellaneous。
3.10 FContentSourceViewModel 类
概述
FContentSourceViewModel 将 IContentSource 的原始数据转换为 UI 可直接使用的格式。它的核心职责包括:
- 多语言文本选择:根据当前编辑器语言,从
IContentSource的多语言文本数组中选择最合适的版本 - PNG 图像转换:将
IContentSource提供的原始 PNG 数据 (TArray<uint8>) 转换为FSlateDynamicImageBrush - 文本缓存:使用
FCachedContentText结构体缓存当前语言下的文本,避免每次 Get 时都执行语言匹配
ChooseLocalizedText()
FText FContentSourceViewModel::ChooseLocalizedText(
const TArray<FLocalizedText>& Choices, const FString& InCurrentLanguage) const
{
// 1. 尝试按优先级匹配本地化翻译
const TArray<FString> PrioritizedCultureNames =
FInternationalization::Get().GetPrioritizedCultureNames(InCurrentLanguage);
for (const FString& CultureName : PrioritizedCultureNames)
{
if (const FLocalizedText* Match = FindLocalizedTextForCulture(CultureName))
return Match->GetText();
}
// 2. 回退到英语
if (InCurrentLanguage != TEXT("en"))
{
if (const FLocalizedText* Match = FindLocalizedTextForCulture(TEXT("en")))
return Match->GetText();
}
// 3. 最后回退到第一个可用翻译
if (Choices.Num() > 0)
return Choices[0].GetText();
return FText();
}
功能分析:
这个多语言选择逻辑设计得相当完善,有三层回退机制:
- 优先使用当前语言的最佳匹配(通过
GetPrioritizedCultureNames()获取优先级列表,例如 “zh-Hans” 会优先匹配 “zh-Hans”,然后尝试 “zh”) - 如果当前语言没有匹配,回退到英语
- 如果连英语都没有,使用第一个可用的翻译
CreateBrushFromRawData()
TSharedPtr<FSlateDynamicImageBrush> FContentSourceViewModel::CreateBrushFromRawData(
const FString& ResourceNamePrefix, const TArray<uint8>& RawData) const
{
// 1. 使用 ImageWrapper 解码 PNG 数据
IImageWrapperModule& ImageWrapperModule =
FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
TSharedPtr<IImageWrapper> ImageWrapper =
ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG);
ImageWrapper->SetCompressed(RawData.GetData(), RawData.Num());
// 2. 获取原始 BGRA 像素数据
ImageWrapper->GetRaw(ERGBFormat::BGRA, 8, DecodedImage);
// 3. 创建 FSlateDynamicImageBrush
FString UniqueResourceName = ResourceNamePrefix + "_" + FString::FromInt(ImageID++);
Brush = FSlateDynamicImageBrush::CreateWithImageData(
FName(*UniqueResourceName),
FVector2D(Width, Height),
DecodedImage);
return Brush;
}
功能分析:
这里有一个值得注意的细节——ImageID 是一个静态自增计数器。每个 Brush 的名称都附加了唯一的 ID,这是为了避免两个不同内容源使用相同名称时,FSlateDynamicImageBrush 的纹理数据被意外释放的问题。因为 FSlateDynamicImageBrush 通过名称引用纹理资源,如果两个 Brush 同名,任意一个销毁时都会导致另一个的纹理数据被清空。
3.11 SAddContentDialog 类
概述
SAddContentDialog 继承自 SWindow,是整个对话框的顶层窗口。
void SAddContentDialog::Construct(const FArguments& InArgs)
{
SWindow::Construct(SWindow::FArguments()
.Title(LOCTEXT("AddContentDialogTitle", "Add Content to the Project"))
.SizingRule(ESizingRule::UserSized)
.ClientSize(FVector2D(900, 500))
.SupportsMinimize(false)
.SupportsMaximize(false)
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("Brushes.Panel"))
.Padding(FMargin(10,0))
[
SNew(SAddContentWidget)
]
]);
}
功能分析:
- 窗口大小为 900×500,用户可调整大小
- 禁用了最小化和最大化按钮
- 内部直接嵌入
SAddContentWidget作为唯一子 Widget - 使用
FAppStyle的面板画刷作为背景
3.12 SAddContentWidget 类
概述
SAddContentWidget 是整个 UI 的核心,负责布局和管理所有子控件。它继承自 SCompoundWidget,内部绑定了 FAddContentWidgetViewModel。
UI 布局结构:
SAddContentWidget
├── SVerticalBox
│ ├── SHorizontalBox (主内容区)
│ │ ├── SVerticalBox (左侧:内容选择区)
│ │ │ ├── SBox (分类标签页容器)
│ │ │ │ └── SSegmentedControl<FCategoryViewModel> (分类标签页)
│ │ │ ├── SSearchBox (搜索框)
│ │ │ └── STileView<FContentSourceViewModel> (内容卡片网格)
│ │ ├── SSeparator (垂直分隔线)
│ │ └── SBox (右侧:详情面板容器)
│ │ └── SScrollBox
│ │ ├── SWidgetCarouselWithNavigation (截图轮播)
│ │ ├── STextBlock (名称)
│ │ ├── STextBlock (描述)
│ │ ├── STextBlock (资产类型标签)
│ │ ├── STextBlock (资产类型列表)
│ │ ├── STextBlock (类类型标签)
│ │ └── STextBlock (类类型列表)
│ ├── SSeparator (水平分隔线)
│ └── SHorizontalBox (底部按钮区)
│ ├── SPrimaryButton ("Add to Project")
│ └── SButton ("Cancel")
委托绑定机制:
在 Construct() 中,SAddContentWidget 将自己的三个回调方法绑定到 ViewModel 的委托上:
ViewModel->SetOnCategoriesChanged(...CreateSP(this, &SAddContentWidget::CategoriesChanged));
ViewModel->SetOnContentSourcesChanged(...CreateSP(this, &SAddContentWidget::ContentSourcesChanged));
ViewModel->SetOnSelectedContentSourceChanged(...CreateSP(this, &SAddContentWidget::SelectedContentSourceChanged));
当 ViewModel 的数据发生变化时,这三个回调会自动更新 UI:
CategoriesChanged():重建分类标签页ContentSourcesChanged():刷新 STileView 列表SelectedContentSourceChanged():更新 STileView 选中状态 + 更新详情面板
SGenericThumbnailTile 内部类
这是一个定义在 SAddContentWidget.cpp 文件中的内部类,用于渲染内容卡片。它展示了一个带缩略图和名称的卡片,并支持选中和高亮状态:
class SGenericThumbnailTile : public SCompoundWidget
{
// 布局:缩略图区域 + 名称区域
// 交互:选中/悬停时显示不同边框
};
卡片使用 Lambda 动态绑定 IsSelected 状态,通过比较 ViewModel->GetSelectedContentSource() 与当前卡片的内容源来判断是否选中。这种写法避免了在 ViewModel 中维护额外的选中状态数组。
3.13 FContentSourceDragDropOp 类
概述
FContentSourceDragDropOp 继承自 FDecoratedDragDropOp,支持从 SAddContentDialog 中拖拽内容源到其他位置。虽然目前编辑器中没有明显的使用场景,但这个类为未来的拖拽导入功能(例如拖拽 Feature Pack 到 ContentBrowser 中直接安装)提供了基础。
TSharedPtr<SWidget> FContentSourceDragDropOp::GetDefaultDecorator() const
{
return SNew(SImage)
.Image(ContentSource->GetIconBrush().Get());
}
拖拽时的装饰器显示内容源的图标,光标变为 EMouseCursor::GrabHandClosed。
4 功能使用示例编写
示例1:通过代码打开"添加内容"对话框
// 在编辑器的任意模块中
#include "IAddContentDialogModule.h"
#include "Modules/ModuleManager.h"
void OpenAddContentDialog()
{
IAddContentDialogModule& AddContentDialog =
FModuleManager::LoadModuleChecked<IAddContentDialogModule>("AddContentDialog");
TSharedRef<SWindow> ParentWindow =
FSlateApplication::Get().GetActiveTopLevelWindow().ToSharedRef();
AddContentDialog.ShowDialog(ParentWindow);
}
示例2:注册自定义内容源提供者
如果想要添加自己的内容源(例如从远程服务器获取内容列表),可以通过实现 IContentSourceProvider 和 IContentSource 接口来扩展:
// MyRemoteContentSource.h
#pragma once
#include "IContentSource.h"
#include "IContentSourceProvider.h"
// 自定义内容源
class FMyRemoteContentSource : public IContentSource
{
public:
FMyRemoteContentSource(const FString& InName, const FString& InDescription)
{
LocalizedNames.Add(FLocalizedText(TEXT("en"), FText::FromString(InName)));
LocalizedDescriptions.Add(FLocalizedText(TEXT("en"), FText::FromString(InDescription)));
Categories = { EContentSourceCategory::Content };
}
virtual const TArray<FLocalizedText>& GetLocalizedNames() const override
{
return LocalizedNames;
}
virtual const TArray<FLocalizedText>& GetLocalizedDescriptions() const override
{
return LocalizedDescriptions;
}
virtual const TArray<EContentSourceCategory>& GetCategories() const override
{
return Categories;
}
virtual TSharedPtr<FImageData> GetIconData() const override
{
return nullptr;
}
virtual const TArray<TSharedPtr<FImageData>>& GetScreenshotData() const override
{
static TArray<TSharedPtr<FImageData>> Empty;
return Empty;
}
virtual const TArray<FLocalizedText>& GetLocalizedAssetTypes() const override
{
static TArray<FLocalizedText> Empty;
return Empty;
}
virtual const FString& GetClassTypesUsed() const override
{
static FString Empty;
return Empty;
}
virtual const FString& GetSortKey() const override
{
return LocalizedNames[0].GetText().ToString();
}
virtual const FString& GetIdent() const override
{
return LocalizedNames[0].GetText().ToString();
}
virtual bool InstallToProject(FString InstallPath) override
{
// 执行下载和安装逻辑
UE_LOG(LogTemp, Log, TEXT("Installing remote content to %s"), *InstallPath);
return true;
}
virtual bool IsDataValid() const override
{
return true;
}
private:
TArray<FLocalizedText> LocalizedNames;
TArray<FLocalizedText> LocalizedDescriptions;
TArray<EContentSourceCategory> Categories;
};
// 自定义内容源提供者
class FMyRemoteContentSourceProvider : public IContentSourceProvider
{
public:
virtual const TArray<TSharedRef<IContentSource>>& GetContentSources() const override
{
return ContentSources;
}
virtual void SetContentSourcesChanged(FOnContentSourcesChanged OnContentSourcesChangedIn) override
{
OnContentSourcesChanged = OnContentSourcesChangedIn;
}
void RefreshFromServer()
{
ContentSources.Empty();
// 从服务器获取内容列表...
ContentSources.Add(MakeShareable(
new FMyRemoteContentSource(TEXT("Remote Pack 1"), TEXT("A remote content pack"))));
OnContentSourcesChanged.ExecuteIfBound();
}
private:
TArray<TSharedRef<IContentSource>> ContentSources;
FOnContentSourcesChanged OnContentSourcesChanged;
};
在模块启动时注册:
// 在某个编辑器模块的 StartupModule() 中
void FMyEditorModule::StartupModule()
{
IAddContentDialogModule& AddContentDialog =
FModuleManager::LoadModuleChecked<IAddContentDialogModule>("AddContentDialog");
TSharedRef<FMyRemoteContentSourceProvider> RemoteProvider =
MakeShareable(new FMyRemoteContentSourceProvider());
RemoteProvider->RefreshFromServer();
AddContentDialog.GetContentSourceProviderManager()
->RegisterContentSourceProvider(RemoteProvider);
}
示例3:手动创建并安装 Feature Pack
#include "FeaturePackContentSource.h"
void InstallFeaturePack(const FString& PackFilePath)
{
TUniquePtr<FFeaturePackContentSource> ContentSource =
MakeUnique<FFeaturePackContentSource>(PackFilePath);
if (ContentSource->IsDataValid())
{
// 查看 Pack 的基本信息
for (const FLocalizedText& Name : ContentSource->GetLocalizedNames())
{
UE_LOG(LogTemp, Log, TEXT("Pack Name [%s]: %s"),
*Name.GetTwoLetterLanguage(), *Name.GetText().ToString());
}
// 安装到项目
bool bSuccess = ContentSource->InstallToProject(TEXT("/Game/MyContent"));
if (bSuccess)
{
UE_LOG(LogTemp, Log, TEXT("Feature Pack installed successfully!"));
}
}
else
{
UE_LOG(LogTemp, Error, TEXT("Invalid Feature Pack: %s"), *PackFilePath);
for (const FString& Error : ContentSource->ParseErrors)
{
UE_LOG(LogTemp, Error, TEXT(" Parse Error: %s"), *Error);
}
}
}
示例4:读取 Feature Pack 中的图片数据
#include "FeaturePackContentSource.h"
#include "IImageWrapper.h"
#include "IImageWrapperModule.h"
UTexture2D* ExtractIconFromFeaturePack(const FString& PackFilePath)
{
TUniquePtr<FFeaturePackContentSource> ContentSource =
MakeUnique<FFeaturePackContentSource>(PackFilePath);
if (!ContentSource->IsDataValid())
{
return nullptr;
}
TSharedPtr<FImageData> IconData = ContentSource->GetIconData();
if (!IconData.IsValid())
{
return nullptr;
}
// 解码 PNG 数据
IImageWrapperModule& ImageWrapperModule =
FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
TSharedPtr<IImageWrapper> ImageWrapper =
ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG);
if (!ImageWrapper->SetCompressed(IconData->GetData()->GetData(),
IconData->GetData()->Num()))
{
return nullptr;
}
TArray<uint8> RawData;
if (!ImageWrapper->GetRaw(ERGBFormat::BGRA, 8, RawData))
{
return nullptr;
}
// 创建 UTexture2D
UTexture2D* Texture = UTexture2D::CreateTransient(
ImageWrapper->GetWidth(), ImageWrapper->GetHeight(), PF_B8G8R8A8);
void* TextureData = Texture->GetPlatformData()->Mips[0].BulkData.Lock(LOCK_READ_WRITE);
FMemory::Memcpy(TextureData, RawData.GetData(), RawData.Num());
Texture->GetPlatformData()->Mips[0].BulkData.Unlock();
Texture->UpdateResource();
return Texture;
}
5 总结与最佳实践
核心要点
-
接口驱动的内容源扩展
IContentSource和IContentSourceProvider是模块扩展性的核心接口- 实现这两个接口即可添加新的内容源类型,无需修改 UI 和 ViewModel 代码
FContentSourceProviderManager是注册入口
-
MVVM 风格的 UI 架构
FAddContentWidgetViewModel作为数据核心,管理所有 UI 状态- Slate Widget 通过委托绑定自动响应数据变化
- 这种分离使得 UI 逻辑更容易测试和维护
-
双格式 Feature Pack 支持
- .upack 格式:打包分发,通过
FPakPlatformFile挂载读取 - 松散 manifest.json 格式:开发期友好,直接读取文件系统
- 两种格式共存的架构设计兼顾了不同使用场景
- .upack 格式:打包分发,通过
-
目录监控热更新
FDirectoryWatcher监控 FeaturePack 目录变更- 新增/删除 .upack 文件后 UI 自动刷新
- 无需重启编辑器即可看到新添加的内容
-
多语言本地化支持
- 从 JSON 清单到 ViewModel 层,全链路支持多语言
- 三层回退机制:当前语言 → 英语 → 第一个可用翻译
- 使用
GetPrioritizedCultureNames()实现智能语言匹配
最佳实践
-
扩展内容源时遵循接口设计
- 为新的内容源类型实现
IContentSource和IContentSourceProvider - 在模块启动时通过
FContentSourceProviderManager::RegisterContentSourceProvider()注册 - 内容源变更时调用
OnContentSourcesChanged.ExecuteIfBound()通知 UI 刷新
- 为新的内容源类型实现
-
Feature Pack 清单编写规范
- manifest.json 必须包含所有必需字段:Name、Description、AssetTypes、ClassTypes、Category、Thumbnail、Screenshots
SortKey用于控制内容源在列表中的排序,建议使用有意义的键值FocusAsset是可选的,但强烈建议设置,以便用户安装后快速找到新内容AdditionalFeaturePacks用于声明依赖的共享资源包
-
安装路径的选择
- 默认安装路径为
/Game,但可以根据需要指定子路径 - 如果 Pack 包含共享资源,确保
AdditionalFeaturePacks中的MountName与资源包的实际路径一致
- 默认安装路径为
-
错误处理
FFeaturePackContentSource的ParseErrors数组记录了所有解析错误- 在安装前检查
IsDataValid()的返回值 - 如果安装失败,检查
ParseErrors获取详细错误信息
-
调试技巧
- 使用
LogFeaturePack日志分类查看 Feature Pack 的解析和安装日志 - 在
DefaultGame.ini的[StartupActions]段中可以使用InsertPack进行自动导入测试 - .upack 文件本质上是 Pak 文件,可以使用 UnrealPak 工具查看其内容
- 使用
常见问题解决
问题1:Feature Pack 没有出现在对话框中
- 检查 .upack 文件是否放在
Engine/FeaturePacks/目录下 - 检查 manifest.json 格式是否正确,所有必需字段是否齐全
- 查看 Output Log 中
LogFeaturePack分类的错误日志 - 确认
IsDataValid()返回 true(内部检查了bPackValid标志)
问题2:安装后资产没有保存
InstallToProject()中调用了FEditorFileUtils::PromptForCheckoutAndSave(),如果资产未保存,检查是否有版本控制冲突- 确保
FAssetCompilingManager::Get().FinishAllCompilation()等待异步编译完成
问题3:图片不显示
- 检查 manifest.json 中
Thumbnail和Screenshots字段是否正确 - 图片必须是 PNG 格式
- 对于 .upack 文件,确保图片路径在 Pak 内部为
Media/目录 - 对于松散格式,确保图片相对于 manifest.json 的路径为
Media/目录
问题4:目录监控不生效
- 确认
FDirectoryWatcher模块已正确加载 - 检查 Feature Pack 目录路径是否存在(
FPaths::FeaturePackDir()) - 如果目录不存在,
StartUpDirectoryWatcher()会自动创建
附录
A. 文件结构参考
AddContentDialog/
├── AddContentDialog.Build.cs
├── Public/
│ ├── IAddContentDialogModule.h (模块接口)
│ └── FeaturePackContentSource.h (Feature Pack 内容源声明)
├── Private/
│ ├── AddContentDialogModule.cpp (模块实现)
│ ├── IContentSource.h (内容源接口 + 辅助类型)
│ ├── IContentSourceProvider.h (内容源提供者接口)
│ ├── ContentSourceProviderManager.h (内容源提供者管理器)
│ ├── ContentSourceProviderManager.cpp
│ ├── ContentSourceDragDropOp.h (拖拽操作)
│ ├── ContentSourceDragDropOp.cpp
│ ├── SAddContentDialog.h (对话框窗口)
│ ├── SAddContentDialog.cpp
│ ├── SAddContentWidget.h (主内容 Widget)
│ ├── SAddContentWidget.cpp
│ ├── ViewModels/
│ │ ├── AddContentWidgetViewModel.h (核心 ViewModel)
│ │ ├── AddContentWidgetViewModel.cpp
│ │ ├── CategoryViewModel.h (分类 ViewModel)
│ │ ├── CategoryViewModel.cpp
│ │ ├── ContentSourceViewModel.h (内容源 ViewModel)
│ │ └── ContentSourceViewModel.cpp
│ └── ContentSourceProviders/
│ └── FeaturePack/
│ ├── FeaturePackContentSource.cpp (Feature Pack 解析与安装)
│ ├── FeaturePackContentSourceProvider.h
│ └── FeaturePackContentSourceProvider.cpp (Feature Pack 扫描与监控)
B. 相关资源
- UE5 官方文档:Slate UI Framework
- UE5 官方文档:Pak File System
- UE5 官方文档:AssetTools Module
- WidgetCarousel 模块源码
- DirectoryWatcher 模块源码

213

被折叠的 条评论
为什么被折叠?



