如何有效的对 UGUI Button 成组排队

[toc]

一般来说,对按钮的事件监听通常采用 AddListener 的方式,有些项目可能会对此处的处理函数额外做一层包装,做一些统一处理,最后再执行具体的逻辑

无论上面哪种方式,想要将按钮的点击事件完美成组排队,都要在业务逻辑牺牲很多便利性

多指触控带来的问题

假设当前游戏支持多点触控,此时弹出一个窗口,需要玩家确认,窗口有两个按钮,“确定”、“取消”,这两个按钮的点击同时对应两件完全相反的逻辑,如果都运行就会导致错误

注意,即使对应的函数都是同步的,也仍然会触发两段逻辑

因为开启了多点触控,玩家可以同时使用两个手指点击两个按钮,如果此时框架层没有对按钮做统一的管理,则会在极短的时间内触发这两个按钮对应的逻辑,而这个并不是我们希望看到的

连续点击重入的问题

大部分解决这种场景的方案,都是给按钮增加一个内置 CD,在计时没有结束以前的点击,都会被吞掉,但是,这个内置 CD 却无法反映出当前 Invoke 出去的函数到底什么时候才真正结束

注意,这里如果是同步的函数,并不会有重入的问题

假设当前按钮对应的点击逻辑是发起一段网络请求,因为网络波动失败了,准备重试,最终响应时间可能用了 2 秒钟(虽然可以用转菊花的方式来掩盖这个问题),也可能是加载一个资源,耗时超过了这个按钮的内置 CD

之前有几次在原神副本结算时,刚好网络波动,导致多次点击了结算按钮,原神的处理方式大概是存下了当前要执行的内容,然后挨个播放… 最后播放了十几遍结算动画…

解决思路

下面的思路是我们当前项目中在使用的方式,希望可以从框架层彻底解决这个问题,不给业务逻辑带来额外的开发负担

这里需要解决一个个 async 后带来的衍生问题,前期要在框架层做大量的工作,并不适合所有项目,如果动手能力不强也不适合,仅供参考

或许等 Unity 把上面的坑填上了,处理起来会更简单一些

按钮成组

这里要引入 UniTask 库,后续所有异步也是基于 UniTask 进行的

public async UniTask<bool> Popup(xxxx, CancellationToken ct)
{
    (bool canceled, int index) = await UniTask.WhenAny(
        ok_btn.OnClickAsync(),
        no_btn.OnClickAsync()
    ).AttachExternalCancellation(ct).SuppressCancellationThrow();
    
    if(canceled) 
    {
        return false;
    }
    
    return index == 0;
}

解决成组的思路非常简单,一个 WhenAny 即可解决,因为这里是 return 外部无论如何都只能拿到一个结果,而且外部调用因为使用 async 可以使得逻辑非常清晰,不用一个一个的 callback 传来传去

if(await xxx.Popup(xxx, ct) 
{
    // 成功
} 
else 
{
    // 失败
}

框架统一分发

有了上面的思路后,剩下的就是如何解决统一分发的问题,这里我们需要所有按钮的 List,以及每个按钮所对应的响应函数,并有序排列,这个过程可以交给 UI 绑定时,生成代码的部分来完成,无需开发人员手动维护

代码参考如下:

// 按钮的函数注册
public delegate UniTask ButtonClickBinder();
public class UI
{
    public List<Button> all_btns;
    public List<ButtonClickBinder> all_binders;
    public CancellationToken ct;
    public int group_wait_ms;
    
    // 代码生成的绑定逻辑
    public void Bind()
    {
        all_btns.Add(xxx);
        all_binders.Add(xxx_OnClick);
        // ...
    }
}

public static async UniTask Dispatch(UI ui) 
{
    while(true)
    {
        if(ui.ct.IsCancellationRequested) 
        {
            break;
        }
        
        (bool canceled, int index) = await UniTask.WhenAny(ui.all_btns).
                                    AttachExternalCancellation(ct).
                                    SuppressCancellationThrow();
        if(canceled) 
        {
            break;
        }
        
        await ui.all_binders[index].Invoke();
        await UniTask.Delay(ui.group_wait_ms, ui.ct).SuppressCancellationThrow();
    }
}

按照这个思路设计出来的按钮响应事件,全部都是异步的,我们再来结合上面的 Popup 示例看一下

public class UIHome
{
    public async UniTask PopupBtn_OnClick() 
    {
        if(await xxx.Popup(xxx, ct) 
        {
            // 成功
        } 
        else 
        {
            // 失败
        }
    }
    
    public async UniTask OtherBtn_OnClick()
    {
        // do something
    }
}

UIHome 下有两个按钮,分别对应两段响应函数,当点击 Popup 按钮,还没有做出选择之前,即使意外的点击到了 OtherBtn,也不会执行 OtherBtn_OnClick 函数,因为 UI 按钮 Dispatch 逻辑被阻塞在等待点击 Popup 按钮上

但是这样会对 Popup 这种特殊的 UI 带来一个新的问题,上面示例中的代码是手动写的 WhenAny,而我们希望框架层统一处理这个 WhenAny,所以需要对 Popup 进行额外的改造

public class UIPopup
{
    public UniTaskCompletionSource<bool> ucs;
    
    public async UniTask<bool> Popup(xxx)
    {
        ucs?.TrySetCanceled();
        ucs = new UniTaskCompletionSource<bool>();
        bool result = await ucs.Task;
        return result;
    }
    
    public UniTask Ok_OnClick()
    {
        ucs.TrySetResult(false);
        return UniTask.CompletedTask;
    }
    
    public UniTask No_OnClick()
    {
        ucs.TrySetResult(true);
        return UniTask.CompletedTask;
    }
}

后续可能会碰到的问题

按照这种方式对 UI 进行改造后,这样可以让整体框架全面 async 的前提,但是会带来新的问题

注意这里的 UI 并没有继承 Monobeahviour,因为希望对 UI 的生命周期有绝对的控制

单个 UI 粒度的防重入

在服务器下发通知时,UI 需要刷新,对应的刷新逻辑是 Refresh, 但是用户也点了可以触发 Refresh 的按钮,此时就需要在框架层进行单个 UI 粒度的异步排队(如:ET 中的协程锁,或者C# 的 Channel)

红点、新手引导异步化

基于现有设计有一个好处,想要找到一个按钮,不再需要按照 Transform 层级根据 name 进行搜索,因为所有的按钮都被保存在每个 UI 实例中的 List 了

但是新问题就是需要一个支持异步的行为树框架,这个市面上几乎所有的都是同步的,需要自己写一套

循环列表异步化

同样的,也是因为 async 带来的问题,UI 的加载、刷新等逻辑,全部都是异步逻辑,无法与同步逻辑完美兼容,这个部分也要自己改造

点击通用化

比如业务中需要对自定义且不继承 Button 的组件异步化,此时就需要修改 UGUI 源码了,我们的做法是增加一个统一的 IClickable 接口,给每个需要的组件继承上,并对 UniTask 中的 OnClickAsync 增加对应的扩展