M-V-VM视图交互简述

Interactions between the View and ViewModel

Posted by eagleboost on November 2, 2021

1. The problem

Displaying dialogs/windows is a common scenario in desktop applications. Modal dialogs are used most often, like MessageBox, or more complex ones like Login Dialog that requires user inputs, while modeless dialogs is less common that most of the small size applications don’t use it at all.

The concept seems simple, but when it comes to implementation “how to display a window” is indeed not trivial, at least it’s not as simple as it looks. Below are some design problems to consider other than just “create a window and display it”:

  • Separation of View and Data——This is an important implication of M-V-VM because the views need be upgraded/optimized independently.
  • Easy to test——Codes that directly create and display windows are not easy to test, however, the business logic would be easy to test if good separation of the view and data is implemented.
  • Asynchronous interaction——Complex interaction processes sometimes involve asynchronous operations, for example, displaying a dialog for user to input during a login process.
  • Unified programming model——Modal dialogs are naturally interactive because the parent window is disabled before a modal dialog is closed. Modeless dialogs althought seems different but we should be able to handle its interaction in similar way. For example, click a button on the main window to show a modeless dialog (or pass data to an already opened modeless dialog), and click some button on the modeless dialog to notify completion of job, then we can either close/hide the modeless dialog or clear out its content so it can wait for the next invocation.

Common approaches of displaying dialogs usually only tackle the first two points, and with noticeable flaws and limitations.

2. Dialog Service

A Dialog Service is the most intuitive way almost all developers can think of, after all abstraction of interfaces is so text book that all modern application developers should know. Take MessageBox as an example, anyone can come up with below interface:

1
2
3
4
public interface IMessageBox
{
  bool Show(string header, string content, MessageBoxType type, MessageBoxIcon icon);
}

The implementation of the IMessageBox interface can then create a custom window or simply call methods of the MessageBox class (a Win32 API wrapper) shipped with WPF. Using interface ensures view-data-separation and makes it easy to test, but its problems are also as obvious as its idea:

  • We might want to add some visual effects for certain types of important messages.
  • User might want the message box to have different background in some cases.
  • Complex scenarios like supporting “Yes”, “No” and ”Cancel” button along with a “Do not show this dialog next time” checkbox.
  • In a single ViewModel that interacts with different type of dialog, it’s hard to differentiate the calls and test them separately.

To satisfy/fix those requirements/problems, the Dialog Service approach would most likely end up giving use more and more method parameters (can be replaced by an object), more and more methods, and more and more interfaces. To summaries, it’s not flexible enough, it’s incompatible with asynchronous programming fashion and not capable of handling interactions with modeless dialogs.

3. InteractionRequest

I’ve been using the well known Prism library in my projects as the foundation of M-V-VM practices. Prism‘s answer to this problem is something called InteractionRequest, which is based on System.Windows.Interactivity. The ViewModel creates IInteractionRequest objects, and the View hook up to the Raised event of the objects via InteractionRequestTriggers. When the Raised event is triggered in the ViewModel, the Trigger would create a dialog specified in the XAML and make it visible. When the dialog is closed, the callback passed along with the parameters is invoked and result is passed back to the ViewModel.

1
2
3
4
public interface IInteractionRequest
{
  event EventHandler<InteractionRequestedEventArgs> Raised;
}

It looks great when I saw it for the first time, and I have used it extensively before in my projects. InteractionRequest creatively uses event as the bridge and achieves separation of data and view, but also because of event, cross threading interaction becomes hard to implement. It also requires view to subscribe to the event. So if we want to display a dialog from a background thread (although not recommended) and wait for user inputs, it’s certainly not a good idea to create a view to subscribe to the event of some background thread codes.

Event if we put asynchronous interaction aside, the InteractionRequest idea still has one drawback often overlooked by people——easy to cause memory leaks. Imaging in a XMAL that composed of several reusable controls of the same type, if the control wrapped InteractionRequest, then the Raised event can be hooked up several times without any warnings (usually we don’t limit number of subscribers of a event), we might end up seeing the same dialog being displayed, closed and displayed again because the event is listened by more than one subscribers.

To fix the problem usually a boolean property needs to be added to the reusable control to enable/disable the InteractionRequestTrigger so that only one is enabled in the case of multiple occupance of the same type of control.

The Prism team also realized the problems of InteractionRequest, its current key contributor Brian Lagunas even frankly says that he does not like it, and he proposed a new Dialog Service on 2019, it’s now part of the Prism official document. However, in my opinion this so called new approach is nothing but the Dialog Service discussed earlier in this blog, the only improvement is that the method parameters are replaced by a IDialogParameters interface, it still does’t support asynchronous interaction and modeless dialogs.

3. AsyncInteraction

Let’s take a step back and review the requirement again, it’s indeed display a dialog, wait for user inputs and get the result, sounds familiar? Yes, it’s exactly same as start a task, wait for its completion and get the result. So now since we have clarified the concept, the solution is already right there.

First let’s define a generic interface IAsyncInteraction<T>, it has only one method StartAsync that accepts one parameter and returns a TaskAsyncInteractionArgs can be used to pass any number of parameter for the sake of flexibility。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface IAsyncInteraction<T> where T : class
{
  Task<AsyncInteractionResult<T>> StartAsync(AsyncInteractionArgs args);
}

public sealed class AsyncInteractionArgs : ReadOnlyCollection<object>
{
  public AsyncInteractionArgs(params object[] args) : base(args)
  {
  }
}

public class AsyncInteractionResult<T> where T : class
{
  public readonly bool IsConfirmed;
  
  public readonly T Result;

  public AsyncInteractionResult(bool isConfirmed, T result)
  {
    IsConfirmed = isConfirmed;
    Result = result ?? throw new ArgumentNullException(nameof(result));
  }
}

Obviously the use of generic type <T> makes dependency injection possible. For example, in a ViewModel that needs to interact with a MessageBox and a LoginDialog, we can have two properties injected like below, the properties would be easy to mock in unit tests, it also clearly state the dependencies of the ViewModel (compare to IDialogService, which is more general).

1
2
3
4
5
[Depencency]
public IAsyncInteraction<MessageBoxData> MessageBox { get; set; }
    
[Depencency]
public IAsyncInteraction<ILoginViewModel> LoginBox { get; set; }

To use this interface, we just start a Task: pass the parameter via the AsyncInteractionArgs, the implementation of the interface then switch to its specified GUI thread (main GUI or secondary thread), create and render the window based on the parameter, set owner window and display the dialog. When the modal dialog is closed (or when the modeless dialog completes operation), set result to the Task so the caller can continue.

Below is an example of displaying a MessageBox in the async/await fashion:

1
2
3
4
5
6
private async Task<AsyncInteractionResult<MessageBoxData>> ShowMessageAsync()
{
  var data = CreateMessageBoxData();
  var args = new AsyncInteractionArgs(data);
  return MessageBox.StartAsync(args);
}

Now we can say with confidence that the problems listed in the beginning of this blog are solved:

  • Separation of View and Data——The ViewModel only cares about the interface, how the view is created/rendered is completely hidden in the implementation. Mean while, developers have maximum freedom to define the contract between the view and data.
  • Easy to test——Mocking interfaces and testing Tasks are easy.
  • Asynchronous interaction——Task is invented for asynchronous operations, so it’s easy to handle even if we want do display some UI in a background thread and wait for user inputs.
  • Unified programming model——Since the caller only cares about state of the Task, so the difference between modal or modeless dialog does not matter anymore and can be handled in the same way.
  • Compare to Dialog Service——Generic interface is type safe and more flexible, we also don’t have to force the parameters implement things like IDialogParameters, dependencies are also clearer.
  • Compare to InteractionRequest——Nor more event subscribers and not more memory leaks because of multiple event subscribers.

4. Conclusion

Please visit github for full details and sample codes。To simplify discussions, all codes above is based on.net 5, but the principle of the IAsyncInteraction<T> interface does not limit to .net 5. Similar implementation can be done as long some Task like thing is supported, no matter it’s WPF or WinForm, .net Core or .net Framework, even any other programing languages.

1. 问题

显示窗口是桌面应用程序中永恒的主题之一。最常见的是模态窗口,比如MessageBox,或者更复杂一些如用户登录这类需要输入信息的窗口。非模态窗口也有,但不多见,小型应用程序通常就没有使用非模态窗口的场景。

上面的描述是从使用的角度出发,而对于开发人员来说“如何显示窗口”则是一个看似简单但不容易实现好的问题,因为需要考虑的不仅仅是“创建一个窗口并显示出来”这么简单:

  • 视图与数据分离——这在M-V-VM中尤为重要,因为视图(窗口)的具体实现要能够独立地更新和优化。
  • 易于测试——直接创建并打开窗口的代码测试起来相当麻烦,而实现了视图与数据分离的代码则天然易于测试。
  • 异步交互——复杂的交互逻辑常常伴随着异步操作,比如在异步登录过程中弹出对话框等待用户输入信息后再继续。
  • 统一的处理方式——模态窗口需要等待子窗口关闭后焦点才会回到父窗口,从直觉上说有一个“交互”的过程。但实际上非模态窗口也应该能用同样的方式处理。举例来说,点击主窗口上的按钮显示一个非模态窗口(或者把数据传递给一个已经存在的非模态窗口),点击非模态窗口上的某个按钮表示操作完成,之后可以关闭该窗口或者清空所有数据等待下一次操作。

常见的实现方式大都只考虑了前两种情况,而且有不小的缺陷。

2. Dialog Service

这是最容易想到的方法,毕竟通过接口抽象把实现隐藏起来是现代程序开发者的基本素养之一。以MessageBox为例,人们会定义这样的接口:

1
2
3
4
public interface IMessageBox
{
  bool Show(string header, string content, MessageBoxType type, MessageBoxIcon icon);
}

IMessageBox接口的实现可以创建自定义的窗口或调用WPF包装自Win32 APIMessageBox类的方法,简单直接,实现了视图与数据分离和易于测试,但问题也显而易见,比如:

  • 某些需要引起用户注意的重要消息我们希望在消息框加上闪烁的效果。
  • 某些情况下用户希望整个消息框的背景使用不同颜色。
  • 再复杂一点,比如对话框需要支持“”,“”,“取消”按钮,甚至是显示一个“下次不再提示”的勾选框。
  • 需要其它类型的窗口,比如一个InputBox
  • 在同一个ViewModel中需要显示多个不同类型对话框的时候难以区分这些调用和测试相应代码。

满足这些需求会导致要么接口参数增多(可以用对象代替参数列表的方式解决),要么接口方法增多,要么接口增多,但总体说来灵活性太差,与异步交互也先天不兼容,更无法处理非模态窗口。

3. InteractionRequest

M-V-VM领域的老牌劲旅Prism——也是我一直使用的框架——对于这个问题给出的答案是基于System.Windows.InteractivityInteractionRequest。原理很简单,在ViewModel中创建一个IInteractionRequest对象,在View中则通过一个InteractionRequestTrigger订阅该对象的Raised事件,当Raised事件被触发时会根据XAML里配置好的窗口类型创建一个窗口并显示出来,最后窗口关闭的时候调用随着事件参数传递过来的callback把结果传回给ViewModel

1
2
3
4
public interface IInteractionRequest
{
  event EventHandler<InteractionRequestedEventArgs> Raised;
}

乍一看很美,我在项目中就曾经大量使用了这项技术。InteractionRequest创造性地使用事件解决了数据传递的问题,但也因为事件的存在,使得跨线程的异步交互难于实现,而且事件必须有订阅者整个流程才能工作,反而增加了耦合度。比如在后台线程显示一个对话框的情形(尽管并不推荐这么做),需要附加一个View来订阅事件是不可想象的。

把异步交互撇开不谈,这项技术还有一个常常被忽视的问题——容易导致内存泄露。由于InteractionRequestTrigger通过数据绑定发现IInteractionRequest对象来订阅Raised事件,所以在一个组合式的XAMLRaised事件很容易在不知不觉中被订阅多次。其症状则是同样的窗口关闭了又显示出来,因为事件有多个订阅者。

这种问题一方面排查麻烦,另一方面修补起来容易导致代码变得臃肿。比如一个封装这项技术的可重用自定义控件就需要添加一些额外的属性用来控制是否允许InteractionRequestTrigger工作,这样才能在多个同样的控件出现在同一个View的场景下只让其中一个工作来避免泄露。

Prism开发组也意识到了InteractionRequest的一些问题,其目前的主要维护者Brian Lagunas则直接表示不喜欢该方法,并在2019年提出了一个新的方法并纳入官方文档,但这个“新方法”乏善可陈,实际上回到了Dialog Service的老路,唯一改进是把参数列表换成了一个IDialogParameters接口,同样不支持异步交互和非模态窗口支持。

3. AsyncInteraction

回归到问题本身,我们会发现“显示对话框”要做的事总结下来就是“显示一个窗口,等待用户输入后返回并获取结果”,跟“启动一个任务,等待任务结束后返回并获取结果”一模一样。概念澄清之后解决方案也就顺理成章了。

首先我们把“显示对话框”这件事抽象成一个IAsyncInteraction<T>接口,StartAsync方法接受一个参数并返回一个TaskAsyncInteractionArgs可用来传递任意数目的参数以增加灵活性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface IAsyncInteraction<T> where T : class
{
  Task<AsyncInteractionResult<T>> StartAsync(AsyncInteractionArgs args);
}

public sealed class AsyncInteractionArgs : ReadOnlyCollection<object>
{
  public AsyncInteractionArgs(params object[] args) : base(args)
  {
  }
}

public class AsyncInteractionResult<T> where T : class
{
  public readonly bool IsConfirmed;
  
  public readonly T Result;

  public AsyncInteractionResult(bool isConfirmed, T result)
  {
    IsConfirmed = isConfirmed;
    Result = result ?? throw new ArgumentNullException(nameof(result));
  }
}

容易看出,类型参数<T>的驱动使得基于接口的依赖注入成为可能,比如一个需要与消息框和登录框交互的ViewModel可以包含如下依赖注入属性(以UnityContainer为例),这样一来测试更加容易,也更具有针对性。

1
2
3
4
5
[Depencency]
public IAsyncInteraction<MessageBoxData> MessageBox { get; set; }
    
[Depencency]
public IAsyncInteraction<ILoginViewModel> LoginBox { get; set; }

使用则是与Task一脉相承的异步操作:把输入参数(通常也是返回值)通过AsyncInteractionArgs传递过去,由接口的具体实现并切换到指定的GUI线程(主线程或其它GUI线程),根据输入创建(不同类型的窗口)和渲染窗口(根据数据类型选择数据模版等),设置父窗口并显示,当窗口关闭后(或者非模态窗口完成处理后)把结果从Task传回给调用者。

下面的代码以显示MessageBox为例:

1
2
3
4
5
6
private async Task<AsyncInteractionResult<MessageBoxData>> ShowMessageAsync()
{
  var data = CreateMessageBoxData();
  var args = new AsyncInteractionArgs(data);
  return MessageBox.StartAsync(args);
}

如此一来本文开头说到的几个问题就得到了完美解决:

  • 视图与数据分离——ViewModel只关心接口,视图的创建完全隐藏在具体实现中,视图的样式则由数据驱动,开发者有最大的自由度。
  • 易于测试——接口容易模拟,基于Task的代码测试非常方便,相比之下基于事件的测试则麻烦一些。
  • 异步交互——Task与异步交互浑然天成,即便是在某个Service的后台线程显示一个对话框,等待用户输入后继续的场景也能轻松处理。
  • 统一的处理方式——由于调用方只关心Task的状态,模态还是非模态窗口也就没有了本质区别,能用统一的接口来完成。
  • 对比Dialog Service——一方面泛型参数更为灵活,无需实现类似IDialogParameters的接口,另一方面根据泛型参数能够注入不同实现,依赖更为清晰。
  • 对比InteractionRequest——一方面避免了事件必须有订阅者才能工作的限制,另一方面杜绝了因为事件而导致的多次订阅(内存泄露)的问题产生。

4. 结语

实现细节以及实例请异步github。为方便讨论,文中的代码及实例全部基于.net 5,但IAsyncInteraction<T>接口所表达的原理本身是没有局限的。不论是WPF还是WinForm.net Core还是.net Framework,甚至是其它编程语言,只要有类似于Task的支持都可以完成相应的实现。