封面图:https://www.pixiv.net/artworks/92335014

本文首发于知乎:https://zhuanlan.zhihu.com/p/625789580


找了很久才找到解决方法,特此记录。

网上很多都是简单的一句“可以将异步委托更改成异步任务”的作用,但是很难理解。

这是让我“茅厕顿开”的一个例子:C# dotnet 使用 TaskCompletionSource 让事件转异步方法

下面结合我的实际用途讲一下这玩意有什么用:

假设你在做客户端登录,先检查缓存有没有 jwt token,有就直接用 jwt token 去连接服务器,没有就要弹出个窗口,让用户输入用户名和密码登录以获得 jwt token。

public class ClientService
{
    // 这个委托是外面传进来的,调用就会打开登录页面
    // ClientService 不关心登录怎么实现,它只想知道登录有没有成功
    // 因此这个委托异步返回登录结果
    public Func<string, Task<bool>>? LoginMethod { get; set; }

    public async Task<bool> ConnectAsync(string url)
    {
        // 获取 jwt token
        string jwtToken = 随便什么方法()
        // 我们暂时不关心 jwt token 的有效性
        if (jwtToken == null)
        {
            // 要登录
            // 我们希望打开登录页面,用户一通操作,
            // 完成登录后返回登录有没有成功
            bool loginResult = await LoginMethod(url);
            if (loginResult == true)
            {
                // 登录成功,可以再次获取jwt token
            }
            else
            {
                // 用户不登录了
            }
        }
        // 接下来是用jwt token连接服务器
        // 我们也不关心    
    }
}

现在要实现 LoginMethod 里面的具体操作了。这时候就会想到一个问题:不管是 xamarin、WPF 还是 Razor 网页,如果打开新的页面/窗口/网页之类的玩意用来给用户登录,窗口关闭后可没办法返回值去指示登录有没有成功啊!(虽然 WPF 的 ShowDialog() 可以返回值,xamarin 也可以安装第三方 popup ,但是为了统一多平台行为还是不这么做了)

好,想到了异步事件:虽然窗口没办法返回值,但是我可以用事件传值啊!登录窗口声明个事件,ClientService 监听这个事件,登录窗口搞完后就触发这个事件,ClientService 就可以收到登录结果了。于是一通操作猛如虎:

首先实现登录窗口:

// 这里用 WPF 做示范
public partial class LoginWindow : Window
{
    // 事件
    public event EventHandler<bool> LoginOver;

    public LoginWindow(string url)
    {
        // 初始化
    }

    public async Task LoginAsync(string url)
    {
        // 我们不关心具体怎么登录
        // 登录成功返回 true
        bool loginResult = 随便怎么处理(账号密码之类的);
        // 触发事件,把登录结果传回去
        LoginOver?.Invoke(loginResult)
        // 关闭窗口,不关心        
    }
}

然后把登录窗口绑上 ClientService 的委托:

// 给 ClientService 绑上实现好的登录方法
ClientService.LoginMethod = async (url) => 
{
   LoginWindow loginWindow = new(url);
   loginWindow.ShowDialog();
};

然后,你试图修改 ClientService :

public class ClientService
{
    // 这个委托是外面传进来的,调用就会打开登录页面
    // ClientService 不关心登录怎么实现,它只想知道登录有没有成功
    // 注意这玩意现在不返回结果了
    public Action<string>? LoginMethod { get; set; }

    public async Task<bool> ConnectAsync(string url)
    {
        // 获取 jwt token
        string jwtToken = 随便什么方法()
        // 我们暂时不关心 jwt token 的有效性
        if (jwtToken == null)
        {
            // 要登录
            // 我们希望打开登录页面,用户一通操作,
            // 完成登录后返回登录有没有成功

            // ?????????
            // 怎么才能在这里绑上 LoginOver 事件?

            LoginMethod(url);

            // ?????????
            // 怎么在这里获取 LoginOver 传递回来的登录结果?

            if (loginResult == true)
            {
                // 登录成功,可以再次获取jwt token
            }
            else
            {
                // 用户不登录了
            }
        }
        // 接下来是用jwt token连接服务器
        // 我们也不关心    
    }
}

这个时候就碰到非常棘手的问题了:

  1. 既然 LoginMethod() 内部怎么实现的与 ClientService 无关,那 ClientService 怎么监听 LoginMethod() 内部的 LoginWindow 的 LoginOver 事件???

当然,相信聪明的你一定能想到办法绑上去,即使可能违反各种乱七八糟的编程原则,不管怎样这并不是一个无法解决的问题。但是接下来的这个问题才是致命一击:

2. 怎么让 LoginOver 触发的值恰好传回到调用了 LoginMethod() 之后,以继续进行 ClientService.ConnectAsync() 接下来的流程???

也许大神可以用基于事件的异步或者回调之类的解决这个问题,但反正我是没想到怎么解决,而且我已经习惯了 async/await 语法糖。这时候,就该 TaskCompletionSource 英雄出场了!

首先修改登录窗口:

// 这里用 WPF 做示范
public partial class LoginWindow : Window
{
    TaskCompletionSource<bool> _taskCompletionSource;

    public LoginWindow(string url, TaskCompletionSource<bool> taskCompletionSource)
    {
        _taskCompletionSource = taskCompletionSource
        // 初始化
    }

    public async Task LoginAsync(string url)
    {
        // 我们不关心具体怎么登录
        // 登录成功返回 true
        bool loginResult = 随便怎么处理(账号密码之类的);
        // 把登录结果传回去        
        _taskCompletionSource.SetResult(_loginResult);
        // 关闭窗口,不关心        
    }
}

然后修改绑定:

// 给 ClientService 绑上实现好的登录方法
ClientService.LoginMethod = async (url, taskCompletionSource) => 
{
   LoginWindow loginWindow = new(url, taskCompletionSource);
   loginWindow.ShowDialog();
};

最后修改调用:

public class ClientService
{
    // 这个委托是外面传进来的,调用就会打开登录页面
    // ClientService 不关心登录怎么实现,它只想知道登录有没有成功
    // 注意这玩意现在不返回结果了
    public Action<string, TaskCompletionSource>? LoginMethod { get; set; }

    public async Task<bool> ConnectAsync(string url)
    {
        // 获取 jwt token
        string jwtToken = 随便什么方法()
        // 我们暂时不关心 jwt token 的有效性
        if (jwtToken == null)
        {
            // 要登录
            // 我们希望打开登录页面,用户一通操作,
            // 完成登录后返回登录有没有成功
            TaskCompletionSource<bool> source = new();
            // 把 TaskCompletionSource 一起传进去
            LoginMethod(url, source);
            // 这里就一直等待
            // 直到 LoginWindow 触发 SetResult(), 就会精准把值传回到这里
            bool loginResult = await source.Task;
            // 继续流程
            if (loginResult == true)
            {
                // 登录成功,可以再次获取jwt token
            }
            else
            {
                // 用户不登录了
            }
        }
        // 接下来是用jwt token连接服务器
        // 我们也不关心    
    }
}