文章

【RxMvvmLight】0x08 Interaction

实现一个简易的 Interaction

【RxMvvmLight】0x08 Interaction

背景

在 VM 里弹窗一直是一个很麻烦的事情,这个麻烦其实不是代码上的,而是抽象上的:ViewModel 到底要不要做弹窗这种抽象?

一般而言,这个问题有几个解决方案:

  1. 使用消息系统,ViewModel 发一个消息出去,等待消息回应。
  2. 使用 IDialogService,将声明 ViewModel 需要弹窗。
  3. 就是本文所说的 Interaction。

我受 RxUI 影响很深,RxUI 的 Interaction 我是真的觉得好用,好吧,也不是特别好用,但对比起来应该还算好的:

  • Message 本质上就是一个大号 goto,如果可以的话,我是真的不想用它。
  • IDialogService 是直接把 Dialog 的抽象漏给 ViewModel,嗯,它最大的优点是解耦。
  • Interaction 则是 ViewModel 声明一个具体的交互请求,这个请求实现成什么样子ViewModel就不管了。

虽然用 Interaction 代码会长一些,但他总体而言,属于是比较合适的概念,ViewModel 没有假装不需要弹窗,也没有依赖一个非常 View 的 DialogService,它只是声明一件事情:我需要更多信息。这种克制的声明,我是比较喜欢的。

实现

Interaction 本质上就是一个 Func<TInput, Task<TOutput>>, 只是它允许多个 Handler,但仅允许最后注册的Handler返回结果。这是为了适配View的结构:允许一个生命周期更短的View结构暂时接管响应。

但无论如何,都应该有且仅有一个响应。为什么?因为 ViewModel 仅期待一个结果,如果每个 Handler 都返回结果,ViewModel 采纳哪个?随便选一个吗?这才是 Interaction 是 Interaction 而不是 Func 或者 event 的原因:多播委托并不适配这种单一响应的场景。

不过 RxUI 的 Interaction 我觉得有些过度设计了。这个过度设计是说:处理程序的参数是 IInteractionContext<TInput, TOutput> 而不是 TInput。 这让他用起来有些不舒服,因为返回值不能直接 return,而是必须调用 context.SetOutput(output), 我是没少忘记调用 SetOutput 导致报错。

所以我决定做一个简单的实现,不要 Context:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public sealed class Interaction<TIn, TOut> : IDisposable
{
    private readonly List<Func<TIn, Task<TOut>>> handlers = [];
    private readonly Lock gate = new();
    private bool disposed;

    public IDisposable RegisterHandler(Func<TIn, TOut> handler) =>
        RegisterHandler(input => Task.FromResult(handler(input)));

    public IDisposable RegisterHandler(Func<TIn, Task<TOut>> handler)
    {
        lock (gate)
        {
            ObjectDisposedException.ThrowIf(disposed, this);
            handlers.Add(handler);
        }

        return Disposable.Create(() =>
        {
            lock (gate)
            {
                handlers.Remove(handler);
            }
        });
    }

    public async Task<TOut> Handle(TIn input)
    {
        Func<TIn, Task<TOut>> handler;
        lock (gate)
        {
            if (disposed)
                throw new ObjectDisposedException(nameof(Interaction<TIn, TOut>));
            if (handlers.Count == 0)
                throw new InvalidOperationException("Interaction 未注册任何 handler");
            handler = handlers[^1];
        }

        return await handler(input);
    }

    public void Dispose()
    {
        lock (gate)
        {
            if (disposed)
                return;
            disposed = true;
            handlers.Clear();
        }
    }
}

约 50 行代码,应该还凑合吧,支持多次注册,最后注册的 Handler 负责返回结果,这个实现足以应对 80% 的情况。

改进

虽然已经可以应对 80% 的情况了,但我稍微有些不甘心,因为在 RxUI 里,那个被我舍弃的 IInteractionContext<TInput,TOutput> 是用来实现一种特殊需求的:后注册的 Handler 可以选择不处理,交由先注册的 Handler 处理,这允许设置默认 Handler。

老实说我是没怎么用过这个特性,所以它给我带来的仪式负担远高于它带来的灵活福利,但他的灵活性我也不想丢,咋办呢?

只好换一个思路:既然我只是要让前一个 Handler 来处理,那我让 Handler 能够调用前一个 Handler 不就行了吗?

思路有了,开始实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public sealed class Interaction<TIn, TOut> : IDisposable
{
    // 省略重复代码

    public delegate Task<TOut> HandlerWithPrevious(TIn input, Func<TIn, Task<TOut>> previousHandler);
 
    public IDisposable RegisterHandler(HandlerWithPrevious handler)
    {
        Func<TIn, Task<TOut>> innerHandler = null!;

        innerHandler = new Func<TIn, Task<TOut>>(async (input) =>
        {
            async Task<TOut> previous(TIn input)
            {
                Func<TIn, Task<TOut>> innerFallback = null!;
                lock (gate)
                {
                    ObjectDisposedException.ThrowIf(disposed, this);

                    var index = handlers.IndexOf(innerHandler);
                    innerFallback = index > 0 ? handlers[index - 1] : throw new InvalidOperationException("没有回退 handler");
                }
                return await innerFallback(input);
            }

            return await handler(input, previous);
        });

        return RegisterHandler(innerHandler);
    }
}

搞定。通过注册一个接受前一个 HandlerHandler 可以实现不想处理的交给前一个 Handler 处理,这样就行了。

赠品

这个新的 Handler 本质上是一种中间件/拦截器/装饰器,所以它可以很轻松的包装前一个 Handler, 比如,你可以实现一个 RegisterLogger 来为 Handler 增加日志:

1
2
interaction.RegisterHandler(x=>{...}); //正常的 Handler
interaction.RegisterLogger(Logger); // 给正常的 Handler 增加日志

也可以在 Handler 异常时返回默认值:

1
2
3
4
5
6
7
8
9
10
11
12
interaction.RegisterHandler(x => {...}); //正常的 Handler
interaction.RegisterHandler(async (x, previous) =>
{
    try
    {
        return await previous(x);
    }
    catch
    {
        return default;
    }
});

这就比较灵活,而且跟 Validation 组件一样,扩展他的行为只需要一个扩展方法,可以说是十分方便了。(真的有人去扩展它吗?)

本文由作者按照 CC BY 4.0 进行授权