UE5.8 Common UI 与跨平台输入

📑 目录

UE5.8 Common UI 与跨平台输入

从一次移植危机说起

小王所在的三人小组做了两年的一款 Roguelike,年初终于确定要上三个平台:PC 键鼠、Switch 手柄、手机触屏。之前 demo 只在 PC 上跑,所有菜单都用普通 UMG Button 拼出来,点击事件直接连到 PlayerController 的 RPC。组长把移植排期给他时,他觉得不过是多绑几个手柄键位、把按钮做大一点的事。

第一周 QA 就拿来了三段录像。第一段:Switch 测试机进主菜单,右摇杆能移动焦点,但 A 键确认没反应,反而按下手柄的 Plus 键直接打开了设置。第二段:手机端点开背包,触屏能点,但手指划过屏幕时角色在后台疯狂转圈。第三段:PC 玩家插上手柄,屏幕上的提示还是空格键图标,拔掉手柄重新插拔才恢复。小王盯着三段视频,发现问题的根子不在输入能不能读到,而在 UI 不知道现在该用哪套语言跟玩家对话。

UE5.8 的 Common UI 就是为解决这类问题而生的。它不是 UMG 的替代品,而是建立在 UMG 之上的一层规范和基础设施,专门处理跨平台菜单、输入切换、焦点导航、UI 堆栈这些重复劳动。这篇文章把 Common UI 里最常用的几个组件和机制拆开讲,尽量贴着实际开发时会踩的坑写。

Common UI 的设计目标

Common UI 的前身是 Epic 在《堡垒之夜》里沉淀下来的一套 UI 框架,后来抽出来做成插件。它的核心想法很简单:把菜单系统里反复出现的能力标准化,让程序员少写重复代码,让策划和美术在一致的结构里工作。

具体落到 UE5.8 里,Common UI 主要解决四类问题。

第一类是输入抽象。普通 UMG 控件绑定的是具体按键,例如空格键或鼠标左键。Common UI 把输入抽象成 Action,再根据不同平台把 Action 翻译成不同的图标和键位。玩家按下手柄 A 键、键盘 E 键或触屏按钮,最终都触发同一个 IA_Confirm

第二类是焦点与导航。手柄玩家没有鼠标指针,需要用方向键或摇杆在按钮之间移动焦点。Common UI 提供了一套导航规则,让焦点在按钮、滑块、列表之间自动流转,程序员不需要为每个界面写方向键监听。

第三类是 UI 生命周期管理。菜单打开、关闭、子菜单嵌套、返回上一层,这些状态如果散落在各个 Widget 里,很快就会变成面条代码。Common UI 用 CommonActivatableWidget 和 Activatable Widget Stack 把打开和关闭抽象成激活与失活,每层菜单都知道自己该什么时候接管输入、什么时候把焦点还给上一层。

第四类是平台适配。PC、主机、掌机、手机对 UI 的要求差异很大。Common UI 通过 Input Icon Database、Platform Traits、DPI Scaling 等机制,让同一套 UI 资产在不同平台上呈现出合理的布局和提示。

下面这张图展示了 Common UI 在 UE5.8 里的整体位置。

flowchart TD
    subgraph logic ["游戏逻辑层"]
    end
    subgraph common ["Common UI 中间层"]
    end
    subgraph render ["渲染表现层"]
        pc["PlayerController"]
        gas["Gameplay Ability"]
        input["CommonInputSubsystem"]
        stack["Activatable Widget Stack"]
        button["CommonButtonBase"]
        activatable["CommonActivatableWidget"]
        router["UI Action Router"]
        slate["Slate / UMG"]
        viewport["Viewport"]
    end
    pc --> input
    gas --> input
    input --> router
    router --> stack
    stack --> activatable
    activatable --> button
    button --> slate
    slate --> viewport

图中 CommonInputSubsystem 是整个输入感知的入口。它维护当前输入设备类型,并通知 UI 框架刷新图标和焦点。UI Action Router 负责把输入事件路由到当前激活的 Activatable Widget。Activatable Widget Stack 管理界面层级。最底层的 Slate 和 Viewport 负责真正画到屏幕上。

CommonButtonBase 与 CommonActivatableWidget

Common UI 里用得最多的两个控件是 CommonButtonBaseCommonActivatableWidget。前者是按钮的跨平台版本,后者是界面的激活单元。理解它们的区别和配合方式,是搭好 Common UI 的第一步。

CommonButtonBase 继承自 UCommonUserWidget,内部封装了一个普通 UMG Button,但额外提供了平台相关的状态管理。它知道当前输入设备是键鼠、手柄还是触屏,会根据状态自动切换按钮的选中外观、提示图标和点击音效。普通 UMG Button 的 OnClicked 只处理鼠标点击和触屏点击,而 CommonButtonBase 还会处理手柄确认键和键盘空格确认。

CommonActivatableWidget 则是一个更大的容器。任何需要接管输入焦点的界面都可以继承它,例如主菜单、暂停菜单、背包、设置、弹窗。它有两个核心状态:激活和失活。调用 Activate() 时,它会把自己压入 Activatable Widget Stack,请求焦点,并触发打开动画;调用 Deactivate() 时,它会从栈中弹出,归还焦点,并触发关闭动画。

一个按钮被点击后,通常会触发一个 Activatable Widget 的打开。下面这张状态图展示了 CommonButtonBase 在不同输入设备下的响应流程。

flowchart TD
    A["CommonButtonBase 获得焦点"] --> B{当前输入类型}
    B -->|MouseAndKeyboard| C["显示 hover 高亮"]
    B -->|Gamepad| D["显示 focus 边框"]
    B -->|Touch| E["放大触控热区"]
    C --> F{玩家按下确认键}
    D --> F
    E --> F
    F -->|"键盘空格 / 鼠标左键"| G["触发 OnClicked"]
    F -->|"手柄 A 键"| G
    F -->|"触屏点击"| G
    G --> H["播放按下动画"]
    H --> I["通知上层打开 Activatable Widget"]

从图中能看出,按钮的外观和响应会随输入类型变化,但最终都收敛到同一个 OnClicked 事件。这意味着策划在蓝图里只需要连一条线,不需要为每种设备写分支。

在 C++ 里定义一个继承自 CommonButtonBase 的按钮很简单:

UCLASS()
class MYGAME_API UMyCommonButton : public UCommonButtonBase
{
    GENERATED_BODY()

protected:
    virtual void NativeOnClicked() override;
    virtual void NativeOnCurrentTextStyleChanged() override;
};

void UMyCommonButton::NativeOnClicked()
{
    Super::NativeOnClicked();
    // 打开对应菜单或触发游戏逻辑
}

CommonActivatableWidget 的写法也类似。下面这个例子展示了如何让它在激活时请求焦点:

UCLASS()
class MYGAME_API UMyMenuWidget : public UCommonActivatableWidget
{
    GENERATED_BODY()

public:
    virtual void NativeOnActivated() override;
    virtual void NativeOnDeactivated() override;

protected:
    UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
    TObjectPtr<UCommonButtonBase> ConfirmButton;
};

void UMyMenuWidget::NativeOnActivated()
{
    Super::NativeOnActivated();
    if (ConfirmButton)
    {
        ConfirmButton->SetFocus();
    }
}

SetFocus() 把初始焦点放到确认按钮上。手柄玩家进入菜单后,第一眼就能看到高亮框停在哪个按钮上,不会不知所措。

输入设备检测与图标切换

Common UI 的输入感知入口是 UCommonInputSubsystem。每个本地玩家都有一个实例,通过它读取当前输入类型:键盘鼠标、手柄或触屏。这个类型不是静态的,而是根据玩家最近一次有效输入动态变化。玩家插着手柄不动,用鼠标点了一下屏幕,输入类型立刻变成键鼠;再拿起手柄按任意键,又切回手柄。

这个机制带来一个好处:UI 不需要知道设备是否插着,只需要知道玩家最近用了什么。坏处是如果设备信号有噪声,图标会频繁闪烁。比如手柄摇杆漂移会让引擎误判为持续手柄输入,导致键鼠提示切不回来。处理办法是给摇杆死区设得足够大,同时在 CommonGameViewportClient 里过滤掉小幅度的摇杆事件。

图标切换的核心资产是 CommonInputActionDataBase。它是一个 Data Asset,里面每个条目对应一个 Input Action,并为每种输入类型指定图标和提示文本。例如 IA_Confirm 在键鼠下显示空格键图标,在 Xbox 下显示 A 键图标,在 PS 下显示 Cross 图标,在触屏下显示一个 Tap 提示。

下面这张序列图展示了玩家从键鼠切换到手柄时,图标如何刷新。

sequenceDiagram
    participant Player as "玩家"
    participant SubSys as CommonInputSubsystem
    participant Router as "UI Action Router"
    participant Widget as CommonInputActionWidget
    participant DB as CommonInputActionDataBase

    Player->>SubSys: 按下手柄 A 键
    SubSys->>SubSys: 更新 LastInputType = Gamepad
    SubSys->>Router: 广播 OnInputMethodChanged
    Router->>Widget: 通知刷新所有输入提示
    Widget->>DB: 查询 IA_Confirm / Gamepad
    DB-->>Widget: 返回 A 图标
    Widget->>Widget: 更新 Brush

    Player->>SubSys: 按键盘空格
    SubSys->>SubSys: 更新 LastInputType = MouseAndKeyboard
    SubSys->>Router: 广播 OnInputMethodChanged
    Router->>Widget: 通知刷新所有输入提示
    Widget->>DB: 查询 IA_Confirm / MouseAndKeyboard
    DB-->>Widget: 返回空格图标
    Widget->>Widget: 更新 Brush

这个流程里最关键的一点是:CommonInputActionWidget 不直接监听输入事件,而是监听 OnInputMethodChanged。输入类型变化时,它自动重新查询 Data Base 并刷新外观。这意味着只要数据配对了,新增平台或新增 Action 都不需要改控件蓝图。

切换的响应时间可以近似表示为:

Tresponse=Tpoll+Tdispatch+Tquery+TdrawT_{response} = T_{poll} + T_{dispatch} + T_{query} + T_{draw}

其中 TpollT_{poll} 是引擎输入轮询间隔,TdispatchT_{dispatch} 是事件派发到 UI 框架的时间,TqueryT_{query} 是从 Data Base 查询图标资产的时间,TdrawT_{draw} 是 Slate 重绘一帧的时间。Common UI 把 TdispatchT_{dispatch}TqueryT_{query} 压得很低,实际体验中几乎无感。如果项目里在 OnInputMethodChanged 回调里塞了大量同步逻辑,TresponseT_{response} 就会被明显拉长。

Input Action 绑定

Common UI 里的按钮确认、取消、导航不是绑定具体按键,而是绑定 Input Action。例如 IA_UI_Confirm 在键鼠下映射到空格和鼠标左键,在手柄下映射到 A 键,在触屏下映射到 Tap。按钮本身不 care 玩家按了什么键,只 care IA_UI_Confirm 是否触发。

这种绑定方式需要在两个地方配置。第一是在 Enhanced Input 的 Input Mapping Context 里,把 UI Action 映射到不同设备的键位。第二是在 Common UI 的 CommonInputActionDataBase 里,为每个 Action 配置不同输入类型下的图标和显示文本。两套数据各司其职:IMC 决定功能能不能响,Data Base 决定提示长什么样。

一个常见的配置结构是:

IMC_UI_Navigation
├── IA_UI_Up      → 键盘 W / 手柄 DPad Up / 左摇杆上
├── IA_UI_Down    → 键盘 S / 手柄 DPad Down / 左摇杆下
├── IA_UI_Left    → 键盘 A / 手柄 DPad Left / 左摇杆左
├── IA_UI_Right   → 键盘 D / 手柄 DPad Right / 左摇杆右
├── IA_UI_Confirm → 键盘空格 / 手柄 Face Button Bottom / Tap
├── IA_UI_Cancel  → 键盘 Esc / 手柄 Face Button Right / 返回手势
└── IA_UI_TabNext → 键盘 Tab / 手柄 Right Shoulder

CommonButtonBase 里,可以通过 SetTriggeringInputAction 指定这个按钮由哪个 Action 触发。策划在蓝图里拖一个 CommonInputActionDataBase 资产进去即可,不需要写代码。

void UMyConfirmButton::NativeOnInitialized()
{
    Super::NativeOnInitialized();
    if (ConfirmActionData)
    {
        SetTriggeringInputAction(ConfirmActionData);
    }
}

这段代码在按钮初始化时把触发 Action 和数据资产绑定。ConfirmActionData 是一个 UCommonInputActionDataBase 指针,在蓝图子类里配置。运行时按钮会根据当前输入类型自动显示对应图标,并在对应键被按下时触发点击。

导航与焦点管理

手柄和键盘玩家没有鼠标指针,UI 必须提供清晰的焦点指示。Common UI 的导航系统基于 Slate 的 FNavigationReply,但做了更高层的封装。每个 CommonButtonBase 默认会响应方向输入,自动把焦点传给上下左右的兄弟控件。如果默认导航顺序不对,可以在 Details 面板里手动设置 Navigation 规则的 Explicit 目标。

焦点管理的难点在于状态切换。游戏进行时,焦点应该在游戏视口,摇杆和方向键控制角色;菜单打开时,焦点应该切到 UI,摇杆和方向键控制导航。这个切换不能靠猜,必须显式请求和释放。

CommonActivatableWidget::Activate() 会自动把焦点拿到自己身上,Deactivate() 会自动归还。但如果界面里同时存在多个 Activatable Widget,就要靠 Activatable Widget Stack 来管理。栈顶的 Widget 拥有焦点,下面的 Widget 处于暂停状态。关闭顶层 Widget 时,焦点自动回到下一层。

下面这张图展示了焦点在Activatable Widget Stack 里的流转。

graph LR
    A["游戏视口持有焦点"] --> B["打开主菜单 Activate"]
    B --> C["主菜单压入栈顶"]
    C --> D["焦点切到主菜单"]
    D --> E["打开设置 Activate"]
    E --> F["设置压入栈顶"]
    F --> G["焦点切到设置"]
    G --> H["关闭设置 Deactivate"]
    H --> I["设置弹出栈"]
    I --> J["焦点回到主菜单"]
    J --> K["关闭主菜单 Deactivate"]
    K --> L["主菜单弹出栈"]
    L --> A

这个机制有一个常见陷阱:如果在普通 UMG UserWidget 里打开菜单,没有走 CommonActivatableWidget 的流程,焦点就不会被正确管理。结果是菜单打开后,背景里的角色仍在接收方向输入。小王的团队在第一版 demo 里就遇到了这个问题,后来把所有菜单都改成继承 CommonActivatableWidget,并在打开时显式调用 Activate(),问题才消失。

导航的另一个细节是循环。手柄玩家按方向键到列表底部时,再按一次下键,焦点应该回到顶部还是停在底部?Common UI 允许在 ScrollWidgetListView 上配置循环策略。对于主菜单按钮,通常建议停在边界,避免玩家误操作;对于背包里的物品格子,循环滚动会更顺手。这些策略不需要写代码,在蓝图里勾选即可。

UI 堆栈与路由

Common UI 把菜单系统抽象成一个栈。每个 CommonActivatableWidget 是一个栈元素,打开就是压栈,关闭就是弹栈。这个抽象看起来简单,但实际开发中有不少细节需要考虑。

第一个细节是栈的深度控制。Roguelike 的菜单层级通常很深:主菜单 → 角色选择 → 装备 → 武器详情 → 强化 → 确认弹窗。如果每层都压栈,退出时需要连续按多次取消键。Common UI 允许把某些界面设置为 Replace 模式,打开时替换栈顶元素而不是压栈。例如从主菜单进入设置,可以替换而不是叠加,返回时直接回到游戏。

第二个细节是返回键的统一处理。Common UI 提供了 BindUILayerAction,可以把 IA_UI_Cancel 绑定到当前栈顶 Widget 的 Deactivate()。这样玩家在任何菜单里按取消键,都会关闭当前最上层界面,而不需要每个界面单独监听 Esc 或手柄 B 键。

第三个细节是模态弹窗。确认框、提示框需要屏蔽下层输入。Common UI 的 Activatable Widget 可以配置 bIsBackHandlerbIsBackActionDisplayedInActionBar。模态弹窗通常既是返回处理器,又显示在 Action Bar 上,让玩家知道按哪个键取消。

下面这张流程图展示了从游戏内打开背包并关闭的完整栈操作。

flowchart TD
    A["游戏运行"] --> B["玩家按背包键 IA_Inventory"]
    B --> C["创建背包 CommonActivatableWidget"]
    C --> D["Activate 压入栈"]
    D --> E["焦点交给背包"]
    E --> F{玩家操作}
    F -->|"选择物品"| G["打开物品详情"]
    G --> H["Activate 压入子栈"]
    H --> I["焦点交给详情"]
    F -->|"按取消键"| J["Deactivate 弹出详情"]
    J --> K["焦点回到背包"]
    F -->|"再按取消键"| L["Deactivate 弹出背包"]
    L --> M["焦点回到游戏视口"]
    M --> A

Activatable Widget Stack 不仅管理焦点,还管理可见性和动画。一个 Widget 被压到栈底时,可以选择保持可见但失活,也可以选择隐藏。主菜单打开设置时,通常让主菜单保持在背后;打开背包后再打开地图,则可以让背包隐藏,避免两层 UI 重叠造成视觉混乱。这些行为通过 ActivationModebAutoActivate 等属性配置。

平台适配

Common UI 的平台适配分为三块:输入图标、布局尺寸、平台特性。

输入图标前面已经讲过,核心是 CommonInputActionDataBase 按平台分数据。实际项目中,图标资产数量会随平台增加而线性增长。假设项目有 AA 个 Input Action,支持 PP 个平台,每个 Action 平均需要 kk 种状态图,则总图标数约为:

Nicons=A×P×kN_{icons} = A \times P \times k

对于一款有 30 个 Action、上 PC/Xbox/PS/Switch/手机五个平台、每个 Action 三种状态的项目,图标数就是 30×5×3=45030 \times 5 \times 3 = 450 张。这个数字不是小数目,必须建立规范的目录结构和命名约定。建议按平台分目录,按 Action 命名文件,让美术和策划能一眼找到对应资源。

布局尺寸方面,Common UI 会读取项目设置里的 DPI Scaling 规则。PC 和主机通常接大屏,Scale 接近 1.0;手机和小屏掌机 Scale 可能到 1.5 甚至 2.0。Common UI 的控件默认使用 Size Box 和 Scale Box 做自适应,策划在 Widget Designer 里切换 DPI 预览就能看到不同屏幕上的效果。

平台特性主要指不同主机厂商的认证要求。索尼要求菜单必须能用方向键和确认键完整操作,不能强制玩家使用键鼠。微软要求键鼠和手柄同时插入时,两者都能继续操作。任天堂要求特定按键映射不能冲突。这些要求会直接影响 CommonInputActionDataBase 的配置和 IMC 的优先级设计。最好在项目早期就建立一份平台 check list,每次打包前对照检查。

Switch 的 A/B 键布局与 Xbox 相反,这是移植时最容易出错的地方。Xbox 的 A 是确认、B 是取消;Switch 的 A 也是确认,但物理位置在右侧,B 在下方。如果图标只显示字母而不显示具体位置图,玩家很容易按反。Common UI 可以通过为 Switch 单独配置一套图标资产来解决,显示 Switch 风格的 A/B/X/Y 图标而不是 Xbox 风格。

与 Enhanced Input 的结合

UE5.8 里 Common UI 和 Enhanced Input 的整合比早期版本更紧密。Common UI 不再维护一套独立的输入映射,而是直接消费 Enhanced Input 的 Input Action 和 IMC。项目里只需要维护一套 Action 资产, gameplay 和 UI 都能用。

具体配合方式是这样的:Enhanced Input 负责把硬件输入翻译成 Action Value,Common UI 负责根据当前输入类型选择图标和导航行为。二者通过 UCommonInputSubsystemUEnhancedInputLocalPlayerSubsystem 协作。前者感知设备类型,后者管理 IMC 栈。

一个典型的场景是:玩家在游戏中按下手柄的 Menu 键,打开暂停菜单。此时需要把游戏内的 IMC_Player_Gamepad 弹出或降低优先级,同时压入 IMC_UI_Navigation。这个切换通常写在 PlayerController 里,由 CommonActivatableWidget 的激活事件触发。

void AMyPlayerController::HandleMenuOpened()
{
    if (UEnhancedInputLocalPlayerSubsystem* EIS =
        ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetLocalPlayer()))
    {
        EIS->RemoveMappingContext(GamepadIMC);
        EIS->AddMappingContext(UINavigationIMC, 10);
    }
}

void AMyPlayerController::HandleMenuClosed()
{
    if (UEnhancedInputLocalPlayerSubsystem* EIS =
        ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetLocalPlayer()))
    {
        EIS->RemoveMappingContext(UINavigationIMC);
        EIS->AddMappingContext(GamepadIMC, 0);
    }
}

这段代码在菜单打开和关闭时切换 IMC。优先级 10 的 IMC_UI_Navigation 会覆盖优先级 0 的游戏内 IMC,确保方向键和确认键被 UI 吃掉。菜单关闭后,游戏内 IMC 恢复,角色重新获得控制权。

切换 IMC 时要注意清理当前活跃的 Action。如果玩家正按着手柄 RT 开火时打开了菜单,RemoveMappingContext 会自动清理该 Context 下的活跃 Action,避免角色在菜单背后继续射击。保险起见,也可以先调用 ClearAllMappingContext 再重新添加需要的 Context。

触屏场景需要额外处理。手机和平板通常没有物理手柄,所有输入都来自触屏。Common UI 的按钮对触屏点击原生支持,但虚拟摇杆和虚拟按钮需要单独配置。UE5 的 VirtualJoystick 在移动项目里默认开启,PC 项目如果要支持 Steam Deck 的触屏摇杆,需要手动在项目设置里打开。

Steam Deck 是一个有趣的中间态。它既有物理手柄,又有触屏。玩家可能左手推摇杆控制角色,右手点屏幕上的按钮。这种情况下,输入类型不能简单按最后一次输入切换,否则图标会频繁在 A 键和 Tap 提示之间跳动。一个折中做法是:游戏内操作保持当前输入类型不变,UI 点击允许触屏直接命中,只有当玩家使用虚拟摇杆或虚拟按钮时才把 LastInputType 切到 Touch。这个策略需要在 CommonGameViewportClient 里做少量定制。

调试 Common UI 的几个入口

Common UI 出了问题,排查思路可以按层级展开。

先看输入类型有没有变。在编辑器里订阅 OnInputMethodChanged,把当前类型打印到屏幕。如果切换设备但类型没变,检查 CommonGameViewportClient 是否被自定义过,是否吞掉了输入事件。

再看图标换没换。如果类型变了但图标不变,先查 CommonInputActionDataBase 里有没有对应平台和 Action 的条目,再查 CommonInputActionWidget 是否正确绑定了 Action。很多蓝图里控件直接绑的是 Key 而不是 Input Action,这样设备切换时自然不会刷新。

然后看焦点对不对。Widget Reflector 可以显示当前焦点落在哪个控件上。如果菜单打开后焦点还在游戏视口,说明 Activate() 没有正确调用,或者 Activatable Widget Stack 里没有这个 Widget。

最后看 IMC 栈。用控制台命令 showdebug enhancedinput 查看当前激活的 Mapping Context。如果菜单打开时角色还能移动,说明游戏内 IMC 优先级高于 UI IMC,或者菜单没有正确拿到 Focus。

收尾

小王后来把项目里的所有菜单重做了一遍。主菜单、暂停菜单、背包、设置、确认弹窗全部继承 CommonActivatableWidget,按钮全部换成 CommonButtonBase,输入提示全部用 CommonInputActionWidgetCommonInputActionDataBase。PC 上插拔手柄,图标跟着切换;Switch 上方向键和 A 键能完整操作所有菜单;手机上触屏点击和虚拟摇杆互不干扰。

这套改造没有引入复杂算法,只是把 Common UI 提供的规范真正用起来。它的价值在于让玩家忘记输入设备的存在。玩家插上手柄就看到手柄图标,按键盘就看到键鼠提示,点屏幕就有触控反馈。UI 像一位翻译,把玩家的操作翻译成游戏能听懂的语言,同时把游戏的反馈翻译成玩家熟悉的符号。

如果你正在用 UE5.8 做多平台项目,建议从第一天就把 Common UI 纳入架构。按钮用 CommonButtonBase,菜单用 CommonActivatableWidget,输入提示用 CommonInputActionWidget,图标和键位走 CommonInputActionDataBase,导航和堆栈交给 Activatable Widget Stack。先把骨架搭对,后期加平台、加设备、加菜单时,改动会小很多。等移植阶段再补,往往要在每个界面里打补丁,成本会高出不少。