尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unreal编辑器扩展:从UHT反射到Slate控件实战

Unreal编辑器扩展:从UHT反射到Slate控件实战 不知道你有没有经历过这一幕手上有个C项目做得好好的切到 Unreal 之后忽然发现平时引以为傲的模板技巧、虚函数玩法全都使不上劲。编辑器想加个按钮第一反应是开个 Win32 或者 Qt 窗口结果发现怎么做都嵌不进 UE 的界面。其实原因很朴素UE 编辑器整个界面是 Slate 控件一层层拼出来的而跨模块、可被编辑器识别的类型是由 UHTUnreal Header Tool在编译前扫描UCLASS/UPROPERTY/USTRUCT这些宏之后提前生成元数据来提供的。也就是说Unreal 并没有把 C 原封不动拿过来用而是用一整套元编程机制把 C “改造”成了适合引擎和编辑器协作的形态。这篇是《Unreal 对 C 做了什么》系列的第 20 章主题很聚焦编辑器扩展。我会从 UHT 反射体系讲起解释编辑器扩展为什么天然依赖元编程再拆解 Slate 的运作方式最后给一个能直接上手的编辑器工具案例。适合两类人一类是刚接触 UE 插件开发、想搞懂编辑器扩展底层逻辑的 C 开发者另一类是已经会用蓝图编辑器工具但想知道原生 C 这条路该怎么走的进阶者。1. Unreal 对 C 动过的刀被 UHT 改写后的类型系统1.1 标准 C 缺了什么UE 都补上了先退一步想一个问题如果完全不依赖引擎你能否写出一段通用的编辑器代码让它可以自动列举任意一个 C 类的所有成员变量并且针对每个成员渲染出对应的输入框标准 C 很难体面地回答这个问题。RTTI 只能告诉你typeid和dynamic_cast拿不到成员变量列表更拿不到每个成员的类型、默认值、显示名称。想序列化一个对象你得手写Save/Load想把某个属性暴露给外部脚本你得写一堆胶水函数想在编辑器的细节面板里自动生成 UI几乎是天方夜谭。所以 Unreal 选择了另外一条路改造编译器之前的“文本扫描”流程。项目编译时UHT 会先读取你的头文件找到UCLASS、USTRUCT、UENUM、UPROPERTY、UFUNCTION这些标记宏然后在目标文件旁边生成对应的.generated.h文件。这些生成文件里包含了该类型元数据的静态描述比如属性叫什么、类型是什么、偏移量在哪里、是否可以被蓝图访问。最后编译器才把“你的类代码”和“自动生成的元数据代码”一起编译。这一刀切下去效果是完全不同的。类本身还是 C 类但运行期你拥有了一个“关于类的描述表”。这个描述表就是反射信息它把原来需要人工编写的大量额外逻辑变成了可以自动推导的公共能力。用一句话概括就是Unreal 给 C 增加了反射但没有通过修改编译器实现而是在编译前用工具生成了额外的 C 代码。1.2 编辑器眼中的 UCLASS不只是宏而是协议看下面这段代码UCLASS(BlueprintType) class UMyToolSettings : public UObject { GENERATED_BODY() public: UPROPERTY(EditAnywhere, Category Tool) float Tolerance 0.1f; UPROPERTY(EditAnywhere, Category Tool) bool bAutoSave true; UFUNCTION(BlueprintCallable, Category Tool) void RunProcess(); };站在 C 语法的角度UCLASS、UPROPERTY也就是几个展开了等于没展开的空宏。但站在编辑器工具链的角度这已经是一份“界面协议”EditAnywhere让编辑器知道这个属性要显示在细节面板里、允许编辑Category Tool告诉编辑器要在 UI 上把它分到哪个分类BlueprintType和BlueprintCallable告诉蓝图系统这个类型可以作为变量、这个函数可以被外部调用。同样的逻辑在写插件扩展时会变得更直白。你不需要为每个设置项手动写一行 UI因为编辑器可以通过反射机制拿到这个类全部被UPROPERTY标记的属性然后自动生成输入控件。编辑器界面的存在感降低了类的元数据本身成了核心。1.3 反射带来的连锁能力GC、序列化、UI 联动反射不是孤立存在的。一旦类型系统有了元数据很多原本需要靠“自觉性”的工程问题就变成了“框架自动处理”的问题。首先是垃圾回收。UE 的 UObject 体系并不依赖 C 的shared_ptr而是使用引擎自有的 GC 机制。GC 怎么知道某个 UObject 被另一个对象持有了靠的正是UPROPERTY元数据。你写了UPROPERTY() UObject* Ref;GC 就会把这个指针当作强引用追踪你没写GC 就认为它是裸指针在极端情况下对象会被直接回收。当你在编辑器扩展里写界面时只要持有的是 UObject 引用随处都要注意有没有被反射正确标记。其次是序列化。编辑器里的“保存关卡”“保存资产”并不需要为每个类写一套自定义序列化因为UPROPERTY已经把需要序列化的字段全部标记好了。泛型代码读取这些元数据就能把对象状态存到磁盘再在加载时恢复回来。最后是 UI 联动。细节面板能自动显示类的属性是因为编辑器调用了反射接口去枚举字段。你的扩展工具如果也想根据选中对象动态生成属性面板做法和编辑器内置细节面板几乎一模一样拿到 UClass遍历它的属性再按属性类型分配控件。这是元编程和 UI 最直观的结合点。2. 编辑器扩展为什么绕不开元编程2.1 以数据驱动 UI而不是以控件驱动 UI很多人在写编辑器工具时第一时间想到的是拖几个控件、在按钮回调里写逻辑。这个思路不能说错但它撑不起复杂的工具场景。我举个例子。美术团队给工具提了个需求允许选中任意一批 Actor批量修改它们的CaptureThreshold和bCastShadow。如果老老实实为每种 Actor 各写一套属性面板你会发现两个问题第一Actor 类型非常多每写一种都是重复劳动第二需求永远会把你引向完全没见过的类你不可能提前给全项目所有类型都做好 UI。正确思路是把逻辑反过来让元数据驱动 UI。你不需要关心用户选中了什么具体类型只要能从被选中的对象拿到它的UClass就能通过反射拿到所有EditAnywhere属性。然后遍历属性为数值属性生成数值输入框为布尔属性生成勾选框为枚举属性生成下拉框。界面是属性表的投影而属性表来自类本身的元数据。2.2 反射接口长什么样UClass、FProperty 与 TFieldIterator真正动手前需要知道几个贯穿全文的反射接口。UClass是所有 UObject 类信息的中心它告诉你这个类继承自谁、拥有哪些属性、哪些函数被标记为可调用。拿到一个对象实例后调用GetClass()就能获得它对应的UClass。FProperty是属性描述的统一基类。它下面的派生类型名称非常好认FBoolProperty用于布尔FFloatProperty用于浮点FIntProperty用于整数FStrProperty用于字符串其余类型以此类推。TFieldIteratorFProperty是遍历某个类全部属性的迭代器。它可以顺着类的继承链一路往上找因为父类的属性同样会被子类继承。用起来大概是这样的if (const UClass* Class Object-GetClass()) { for (TFieldIteratorFProperty It(Class); It; It) { FProperty* Property *It; const FName PropertyName Property-GetFName(); if (Property-HasAnyPropertyFlags(CPF_Edit)) { // 这个属性允许在编辑器中修改 } } }这段代码的核心价值在于它和具体类完全解耦。同一个属性面板控件今天用来显示 A Actor明天用来显示 B Actor不需要改任何控件代码因为数据来源不依赖具体类型只依赖“这个类型有哪些被标记为可编辑的属性”这个事实。2.3 为什么不直接在运行时解析 C 对象而要依赖 UHT可能会有读者问C 不是有运行时类型信息吗为什么 UE 不走那套而要单独开发一套 UHT原因很复杂但可以梳理出三条最关键的考量。第一标准 RTTI 信息量不足。它能告诉你“类型名是什么”“基类是谁”但不会告诉你属性列表、属性类型、属性偏移量更不会告诉你哪些属性应该暴露给蓝图。而编辑器需要的是后者的完整信息。第二跨语言协作需求。Unreal 有蓝图体系蓝图需要理解 C 类结构。如果元数据完全在编译后的二进制里以隐晦形式存在跨语言层很难稳定访问。UHT 生成的文件是一种可读的中间产物C 和蓝图可以围绕它达成共识。第三运行时性能和包体控制。UHT 生成的是编译期静态信息运行期不需要动态分析二进制。相比之下一些语言采用运行期反射每次遍历属性都有额外开销。在编辑器工具里性能问题不明显但在游戏运行时一旦频繁遍历差距就会拉开。所以你可以理解为UE 并不是为了炫技才做 UHT它是为了同时满足“编辑器友好”和“性能可控”选择了一条看起来不那么 C 的元编程路径。3. Slate 在编辑器里的位置以及它本质上是套声明式 UI3.1 编辑器从里到外都是 Slate打开 Unreal 编辑器看到的主菜单、内容浏览器、细节面板、视口顶部的工具条全部是 Slate 控件。Slate 没有可视化设计器它是一套纯 C 的 UI 框架。这套框架的核心思想是用代码声明控件树事件通过委托回调处理。UMG 是面向游戏运行时 UI 的方案而 Slate 更多服务于编辑器、工具窗口和开发者控件。两者不是简单的上下级关系而更像是针对不同场景的两种解法。想在编辑器里做一个复杂工具你会直接使用 Slate因为它是编辑器原生支持的 UI 方案能够天然响应布局、停靠、标签页这些编辑器交互。Slate 最让新手不适应的点是它的“声明式”语法。你写的面板结构本质上是一棵控件树。比如一个简单按钮的长相是这样的SNew(SButton) .OnClicked(this, FToolPanel::OnButtonClicked) .Content() [ SNew(STextBlock) .Text(FText::FromString(TEXT(运行工具))) ]这段代码不描述“创建按钮后设置点击回调再设置内容文本”的步骤而是直接声明按钮的属性点击时会触发OnButtonClicked内容区域包含一个文本控件。你要是用传统 Win32 API 或 Qt 写过界面会明显感觉到思路的差异但如果你用过前端框架比如 React 这类声明式 UI会觉得很亲切。3.2 为什么不像 Dear ImGui 那样“即时模式”讨论 Slate 时经常有人提到 Dear ImGui。Dear ImGui 是一套即时模式 GUI它的特点是每一帧都从头绘制界面控件本身不保存过多状态所有数据都在调用栈上传入传出。这种方案特别适合工具开发、调试可视化因为代码直接、反射友好很多游戏引擎的内部调试面板都愿意用它。但编辑器的需求并不完全一致。编辑器需要支持复杂的拖拽布局、面板停靠、标签页复用、焦点管理和无障碍支持。这些交互需要控件树保留比较稳定的状态因此更符合保留模式Retained Mode的 UI 框架。Slate 正是典型的保留模式框架控件在创建后声明自己的布局和状态事件派发不依赖这一帧是否重新绘制。两种模式没有绝对“谁更好”的分野。可以用一张表看清它们的差别维度Slate保留模式Dear ImGui即时模式状态管理控件树主动保存状态跨帧稳定状态由调用方每帧传入几乎不保存布局能力支持停靠、拖拽、复杂嵌套布局以简单窗口和面板为主复杂布局偏弱嵌入引擎天然是 Unreal 编辑器 UI 体系的一部分通常以插件或第三方方案集成和编辑器内部交互较浅上手曲线需要理解控件树和委托门槛稍高每帧构建 UI代码直观容易快速原型适用场景编辑器插件、资产编辑器、可视化工具调试面板、开发者工具、独立编辑器Slate 的学习心态要调成这样它不是控制画笔而是描述静态界面的骨架。你把界面拆成树、把每个回调接到委托上剩下的布局和渲染交给框架。3.3 Slate 的控件树与生命周期观念写 Slate 最常见的一个错误方式是“用完后把它 delete 掉”。Slate 控件一般使用TSharedRef或TSharedPtr管理引用计数归零后自动销毁绝不能像标准 C new 出来的裸指针那样手动 delete。控件树里父控件持有子控件的引用所以只要树还在节点就不会被提前释放。创建控件通常用SNew和SAssignNewTSharedRefSVerticalBox Panel SNew(SVerticalBox); TSharedPtrSListViewTSharedPtrFItemData ListView; SAssignNew(ListView, SListViewTSharedPtrFItemData) .ItemHeight(28) .ListItemsSource(Items) .OnGenerateRow(this, FToolPanel::OnGenerateRow);SNew适合直接创建的临时控件SAssignNew会把创建结果赋值给一个共享指针方便后续在回调中刷新。控件的标题、可点击性、可见性等都可以用链式方法在创建时设置好这种方式不太容易遗漏状态。Slate 还有一层重要概念叫 Widget Style。控件的颜色、字体、留白并不是直接写在每个控件属性上而是从全局样式表里查找例如设置按钮的.Style(FAppStyle::Get().GetWidgetStyleFButtonStyle(Button))。编辑器主题一变所有控件都会自动跟随。扩展工具时尽量少做硬编码风格多套用编辑器自带 Style能省下大量调整 UI 视觉的琐碎时间。4. 实操做一个会读取 UClass 属性的编辑器列表与动态属性面板4.1 建立编辑器插件模块先创建一个插件骨架。假设插件名为ReflectionTool模块类型选择 EditorLoading Phase 设为 Default。模块路径下的类看起来是这个样子class FReflectionToolModule : public IModuleInterface { public: virtual void StartupModule() override; virtual void ShutdownModule() override; private: void RegisterMenu(); void ShowToolWindow(); };模块类型必须选 Editor 而不是 Runtime因为我们要引用编辑器专属 API比如UToolMenus、FGlobalTabmanager以及 Slate 的编辑器控件集合。如果模块被错误标记为 Runtime在打包游戏时会把这些编辑器代码也带进去轻则体积膨胀重则依赖缺失直接编译失败。4.2 注册菜单入口并打开一个 DockTab编辑器大量使用可停靠标签页作为承载工具 UI 的容器。注册一个工具窗口的标准流程是先在模块启动时向全局标签管理器注册一个 Tab 生成器然后定义菜单项点击菜单项时调用 Tab 管理器打开对应标签页。下面这个例子以 UE5.x 的 API 为基准版本升级后某些接口名可能有小改动但整体思路不变void FReflectionToolModule::StartupModule() { const FName TabId(TEXT(ReflectionToolTab)); FGlobalTabmanager::Get()-RegisterNomadTabSpawner( TabId, FOnSpawnTab::CreateLambda([](const FSpawnTabArgs) { return SNew(SDockTab) .TabRole(ETabRole::NomadTab) .Label(FText::FromString(TEXT(反射工具))); }) ) .SetDisplayName(FText::FromString(TEXT(反射工具))) .SetMenuType(ETabSpawnerMenuType::Hidden); // 菜单注册 UToolMenus* ToolMenus UToolMenus::Get(); FMenuBarBuilder MenuBuilder(FName(ReflectionTool)); // ... 注册菜单项回调中调用 ShowToolWindow } void FReflectionToolModule::ShowToolWindow() { const FName TabId(TEXT(ReflectionToolTab)); FGlobalTabmanager::Get()-TryInvokeTab(TabId); }这里的思路值得重点消化。我们没有直接创建一个窗口对象而是注册了一个“生成器”。当用户点击菜单、希望打开工具时框架才调用生成器创建标签页。它的好处是标签管理器可以统一处理布局状态即使标签页被切换、关闭或移动到其他停靠区域逻辑上仍然受控。实际操作中我建议菜单注册回调里的逻辑尽量薄把真正的界面初始化放到 Slate 控件内部。否则菜单、标签页生成、界面状态耦合在一起代码一旦膨胀就很难维护。4.3 构建反射驱动的列表扫描选中 Actor 并根据 UClass 自动生成属性行接下来是重头戏让工具扫描当前选中 Actor 的可编辑属性并为其生成对应的 Slate 控件行。先设计数据结构。为了在 ListView 中显示每一种扫描结果都用共享指针放进数组中class FReflectionRowData { public: TWeakObjectPtrAActor Actor; FString PropertyName; FString PropertyDisplayName; FString PropertyValue; FProperty* Property nullptr; };然后写一个刷新函数从编辑器拿到当前选中的 Actor再遍历它的属性并把信息填入数组void FReflectionToolPanel::RefreshSelection() { Items.Reset(); // 获取编辑器选中的 Actor TArrayUObject* SelectedObjects; GEditor-GetSelectedActors()-GetSelectedObjects(SelectedObjects); for (UObject* Obj : SelectedObjects) { if (!Obj) { continue; } const UClass* Class Obj-GetClass(); for (TFieldIteratorFProperty It(Class); It; It) { FProperty* Property *It; if (!Property-HasAnyPropertyFlags(CPF_Edit)) { continue; } // 把属性名转为可读文本顺序和分类信息也可以一并读取 TSharedRefFReflectionRowData Row MakeSharedFReflectionRowData(); Row-Actor CastAActor(Obj); Row-Property Property; Row-PropertyName Property-GetName(); Row-PropertyDisplayName Property-GetDisplayNameText().ToString(); Items.Add(Row); } } if (ListView.IsValid()) { ListView-RequestListRefresh(); } }注意一个容易忽略的细节FProperty*指针不是 UObject但它有独立的反射生命周期并且在编译器生成的代码里和类绑定所以使用过程中不需要进行 UObject 上的 GC 引用管理但要注意它所属的 UStruct 必须保持活跃。4.4 ListView 行控件生成与属性值读写ListView 不负责每行显示什么具体渲染由OnGenerateRow委托决定。每行的控件可以用SNew(SMultiLineEditableText)显示当前属性值再用OnTextCommitted回调写回对象。TSharedRefITableRow FReflectionToolPanel::OnGenerateRow( TSharedPtrFReflectionRowData Item, const TSharedRefSTableViewBase OwnerTable) { return SNew(STableRowTSharedPtrFReflectionRowData, OwnerTable) .Content() [ SNew(SHorizontalBox) SHorizontalBox::Slot() .AutoWidth() .Padding(4.0f) [ SNew(STextBlock) .Text(FText::FromString(Item-PropertyDisplayName)) .MinDesiredWidth(180.0f) ] SHorizontalBox::Slot() .FillWidth(1.0f) .Padding(4.0f) [ SNew(SEditableTextBox) .Text(FText::FromString(CurrentValue(Item))) .OnTextCommitted_Lambda([this, Item]( const FText NewText, ETextCommit::Type CommitType) { if (CommitType ETextCommit::OnEnter || CommitType ETextCommit::OnUserMovedFocus) { ApplyPropertyValue(Item, NewText.ToString()); } }) ] ]; }读取和写入属性值时不用手写每个具体情况。读取值可以借用Property-ExportText系列接口把属性内容导出成字符串写入则先判断是否“可写”再通过导入文本的方式填入目标对象。也就是说字符类型直接赋值都要根据属性类型来兜底尤其要注意枚举的字符串和显示名称的映射关系不能随便把 UI 文本直接强塞给整型属性。这里我做一个小补充如果你只想快速做一个内部工具不打算处理浮点、枚举、向量等复杂类型之间的转换可以先用ExportText/ImportText生成一个能跑的最小版本。实际使用中这种通用的文本互换足够覆盖八成标量属性但遇到结构体属性时仍需要先定位到具体成员再处理。4.5 把动态属性面板接到面板中央最后把整个内容放在一个SVerticalBox里顶部放刷新按钮中间放 ListView。SNew(SVerticalBox) SVerticalBox::Slot() .AutoHeight() .Padding(8.0f) [ SNew(SButton) .OnClicked(this, FReflectionToolPanel::OnRefreshClicked) .Content() [ SNew(STextBlock) .Text(FText::FromString(TEXT(刷新选中属性))) ] ] SVerticalBox::Slot() .FillHeight(1.0f) .Padding(8.0f) [ SNew(SListViewTSharedPtrFReflectionRowData) .ListItemsSource(Items) .OnGenerateRow(this, FReflectionToolPanel::OnGenerateRow) ]至此一个麻雀虽小但五脏俱全的反射驱动界面就成型了从选中对象到元数据从元数据到 UI 行再从 UI 行的编辑事件写回对象。这套工具后续随便换一个类界面都会跟着属性的变化自动适配这就是元编程在编辑器扩展中最直观的红利。5. 编辑器扩展踩坑实录与问题速查表5.1 Slate 控件生命周期与内存管理陷阱Slate 控件必须走TSharedRef/TSharedPtr体系最忌讳的思路是把某个控件指针转成裸指针保存然后在另一个回调里使用。UI 是异步事件密集的地方当某个按钮被点击时它所属的面板可能已经处于关闭销毁流程中。裸指针一旦指向已销毁的控件崩溃就在所难免。我的习惯是在成员变量中保存控件依赖的数据列表而不是保存控件引用。控件只负责显示数据由共享指针数组持有。这样既不干扰 GC也不会出现试图操作已释放 Slate 控件的问题。5.2 反射读取时属性丢失不是没加 UPROPERTY就是类型不对很多人写完遍历逻辑发现字段列表里少了一些属性第一反应是遍历接口出错。但实际上九成原因是目标属性根本没有加UPROPERTY。UHT 只会为明确标记的属性生成元数据没有被标记的普通 C 成员变量反射世界根本不知道它们的存在。想要暴露到编辑器就必须老老实实给每个目标字段加标记没有任何捷径。还有一个容易踩的点是析构顺序。编辑器模块在 Shutdown 时有可能先卸载 UI 模块再卸载反射信息所属的引擎模块。如果此时还有 Slate 控件试图读取 UClass 元数据就会访问已卸载模块的代码。所以模块 Shutdown 中要先把控件引用全部释放、注销 Tab 生成器再去卸载别的依赖。5.3 编辑器内修改运行时数据要注意对象是否允许设置面板上可以直接编辑编辑器内当前选中的 Actor 属性但如果是运行时 PIE 状态下的实例就需要额外小心。直接通过反射修改PIE世界中对象的属性可能会和游戏自身的逻辑冲突。更合理的方式是仅允许工具在非 PIE 状态下编辑生成时数据或者在修改后主动标记脏状态并调用相关接口让场景刷新。编辑器扩展看起来只是写 UI但它往往承担着“修改资产数据”的任务。一旦数据写坏了恢复成本比写坏代码高得多。我给自己定的规矩是工具需要写回任何对象前先打印一行日志说明对象名、属性名、旧值、新值。日志是排障时最容易忽视的朋友。5.4 属性面板常见问题速查现象可能原因建议解决思路遍历属性时列表为空字段没有UPROPERTY或遍历对象本身为nullptr检查头文件标记打印GetClass()-GetName()确认类型Slate 控件点击回调触发崩溃回调绑定了裸指针控件创建者已销毁改用TWeakPtr绑定并在回调开头检测合法性新加的菜单找不到菜单注册时机早于UToolMenus初始化确认模块 Startup 顺序必要时监听相关初始化事件再注册属性修改后详情面板没刷新编辑器 UI 缓存了对象状态调用刷新接口或让对象PostEditChangeProperty触发更新插件打包进游戏崩溃模块类型被错误设置为 Runtime引用了编辑器 API将模块类型改为 Editor或在打包脚本中移除该模块界面风格和编辑器不一致控件直接硬编码了颜色和字体尽量改用FAppStyle::Get()提供的编辑器样式5.5 工具代码结构调整建议写编辑器扩展超过三个面板后一个重要经验是不要把业务逻辑塞进 Slate 控件。Slate 控件最擅长的是描述界面布局和接收事件不适合承载“扫描哪些资产”“如何汇总数据”这类复杂逻辑。推荐把扩展拆成三层第一层是数据访问层负责从编辑器拿到当前选中对象、扫描资产、读取和写入属性。第二层是工具状态层维护面板需要的数据列表、选中项、缓存结果。第三层是 Slate 表现层只负责渲染数据和转发用户操作。这样做的好处很明显当你升级引擎、替换 UI 框架或把工具界面迁移到编辑器工具控件时只需要替换第三层前两层不被动。我见过很多编辑器工具一开始写得飞快后面需求一变改 UI 就得连带翻底层逻辑最后整个模块一团乱麻。从一开始就把数据与表现切开省下的时间不是一点点。最后聊几句实操体会坦白讲编辑器扩展的代码量通常不大真正难的是理解 UE 把类元数据当作一种基础设施来使用这件事。我刚接触 UE 时总想着用传统 C 的反射替代方案解决问题吃了几次亏后才彻底接受 UHT 这条路。如今回头看能深刻体会那个设计意图当类型系统能够自我描述编辑器就只是这套自我描述之上的另一层外壳而已。扩展工具时不要把主要精力花在“摆放控件”上而是花在“让数据和元数据如何被清晰表达”上。数据表达顺了UI 只是顺便长出来的表象。后面有机会我会继续拆 Slate 的高级布局和资产编辑器扩展那又是一个很深的坑但也很有意思。
返回列表