【RxMvvmLight】0x08 Interaction
实现一个简易的 Interaction
背景
在 VM 里弹窗一直是一个很麻烦的事情,这个麻烦其实不是代码上的,而是抽象上的:ViewModel 到底要不要做弹窗这种抽象?
一般而言,这个问题有几个解决方案:
- 使用消息系统,ViewModel 发一个消息出去,等待消息回应。
- 使用 IDialogService,将声明 ViewModel 需要弹窗。
- 就是本文所说的 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);
}
}
搞定。通过注册一个接受前一个 Handler 的 Handler 可以实现不想处理的交给前一个 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 组件一样,扩展他的行为只需要一个扩展方法,可以说是十分方便了。(真的有人去扩展它吗?)