将内容放在一起

现在,我们已经概述了 CommunityToolkit.Mvvm 包中可用的所有不同组件,我们可以看一个将所有组件结合到一起以生成一个更大示例的实际示例。 在这种情况下,需要为选定数量的 Reddit 子版块生成非常简单和极简的 Reddit 浏览器。

我们要生成什么

首先,概述我们要生成的内容:

  • 由两个“小组件”组成的最小 Reddit 浏览器:一个显示 Reddit 子版块的帖子,另一个显示当前所选帖子。 这两个小组件需要相互独立,并且不能彼此强引用。
  • 我们希望用户能够从可用选项列表中选择 Reddit 子版块,并将所选 Reddit 子版块另存为设置,并在下次加载示例时将其加载。
  • 我们希望 Reddit 子版块小组件还提供刷新按钮来重新加载当前 Reddit 子板块。
  • 就此示例而言,我们不需要能够处理所有可能的帖子类型。 我们将只向所有加载的帖子分配一个示例文本并直接显示该文本,以便简化操作。

设置 viewmodel

我们从将为 Reddit 子版块小部件提供支持的 viewmodel 开始,先回顾一下需要的工具:

  • 命令:我们需要该视图才能请求 viewmodel 从所选子板块重新加载当前帖子列表。 可以使用 AsyncRelayCommand 类型包装将从 Reddit 中提取帖子的私有方法。 在这里,我们将通过 IAsyncRelayCommand 接口公开命令,以避免对所使用的确切命令类型进行强引用。 这还使我们将来可能更改命令类型,而无需担心依赖所使用的该特定类型的任何 UI 组件。
  • 属性:我们需要向 UI 公开一些值,如果这些值是我们打算完全替换的值,则可以使用可观察的属性,或者使用自己本身可观察的属性(例如 ObservableCollection<T>)来执行这一操作。 在此示例中,我们有:
    • ObservableCollection<object> Posts,这是已加载帖子的可观察列表。 在这里,我们只将 object 用作占位符,因为尚未创建用于表示帖子的模型。 我们稍后可以替换它。
    • IReadOnlyList<string> Subreddits,这是一个只读列表,其中包含允许用户从中选择的 Reddit 子板块的名称。 此属性永远不会更新,因此也不需要观察此属性。
    • string SelectedSubreddit,这是当前选定的 Reddit 子版块。 此属性需要绑定到 UI,因为它不仅用于指示加载示例时最后选择的 Reddit 子板块,还用于在用户更改所选内容时直接从 UI 操作。 在这里,我们使用 ObservableObject 类中的 SetProperty 方法。
    • object SelectedPost,这是当前选定的帖子。 在本例中,我们使用 ObservableRecipient 类中的 SetProperty 方法来指示在此属性发生更改时,我们也希望广播通知。 这样做是为了能够通知帖子小组件当前帖子选择已更改。
  • 方法:我们只需要一个由异步命令包装的私有 LoadPostsAsync 方法,其中包含从所选 Reddit 子板块加载帖子的逻辑。

以下是到目前为止的 viewmodel:

public sealed class SubredditWidgetViewModel : ObservableRecipient
{
    /// <summary>
    /// Creates a new <see cref="SubredditWidgetViewModel"/> instance.
    /// </summary>
    public SubredditWidgetViewModel()
    {
        LoadPostsCommand = new AsyncRelayCommand(LoadPostsAsync);
    }

    /// <summary>
    /// Gets the <see cref="IAsyncRelayCommand"/> instance responsible for loading posts.
    /// </summary>
    public IAsyncRelayCommand LoadPostsCommand { get; }

    /// <summary>
    /// Gets the collection of loaded posts.
    /// </summary>
    public ObservableCollection<object> Posts { get; } = new ObservableCollection<object>();

    /// <summary>
    /// Gets the collection of available subreddits to pick from.
    /// </summary>
    public IReadOnlyList<string> Subreddits { get; } = new[]
    {
        "microsoft",
        "windows",
        "surface",
        "windowsphone",
        "dotnet",
        "csharp"
    };

    private string selectedSubreddit;

    /// <summary>
    /// Gets or sets the currently selected subreddit.
    /// </summary>
    public string SelectedSubreddit
    {
        get => selectedSubreddit;
        set => SetProperty(ref selectedSubreddit, value);
    }

    private object selectedPost;

    /// <summary>
    /// Gets or sets the currently selected subreddit.
    /// </summary>
    public object SelectedPost
    {
        get => selectedPost;
        set => SetProperty(ref selectedPost, value, true);
    }

    /// <summary>
    /// Loads the posts from a specified subreddit.
    /// </summary>
    private async Task LoadPostsAsync()
    {
        // TODO...
    }
}

现在,我们来看看帖子小部件的 viewmodel 需要什么。 这将是一个更简单的 viewmodel,因为它实际上只需要使用当前选定的帖子公开 Post 属性,并从 Reddit 子版块小部件接收广播消息以更新 Post 属性。 它看起来可能像如下所示:

public sealed class PostWidgetViewModel : ObservableRecipient, IRecipient<PropertyChangedMessage<object>>
{
    private object post;

    /// <summary>
    /// Gets the currently selected post, if any.
    /// </summary>
    public object Post
    {
        get => post;
        private set => SetProperty(ref post, value);
    }

    /// <inheritdoc/>
    public void Receive(PropertyChangedMessage<object> message)
    {
        if (message.Sender.GetType() == typeof(SubredditWidgetViewModel) &&
            message.PropertyName == nameof(SubredditWidgetViewModel.SelectedPost))
        {
            Post = message.NewValue;
        }
    }
}

在本例中,我们使用 IRecipient<TMessage> 接口来声明我们希望 viewmodel 接收的消息。 当 IsActive 属性设置为 true 时,ObservableRecipient 类将自动添加已声明消息的处理程序。 请注意,使用此方法并非强制性的,还可以手动注册每个消息处理程序,如下所示:

public sealed class PostWidgetViewModel : ObservableRecipient
{
    protected override void OnActivated()
    {
        // We use a method group here, but a lambda expression is also valid
        Messenger.Register<PostWidgetViewModel, PropertyChangedMessage<object>>(this, (r, m) => r.Receive(m));
    }

    /// <inheritdoc/>
    public void Receive(PropertyChangedMessage<object> message)
    {
        if (message.Sender.GetType() == typeof(SubredditWidgetViewModel) &&
            message.PropertyName == nameof(SubredditWidgetViewModel.SelectedPost))
        {
            Post = message.NewValue;
        }
    }
}

现在,我们已经草拟好 viewmodel,接下来可以开始研究我们需要的服务。

生成设置服务

注意

此示例是使用依赖项注入模式生成的,这是处理 viewmodel 中服务的建议方法。 也可以使用其他模式(例如服务定位符模式),但 MVVM 工具包不提供内置 API 来启用该模式。

由于我们希望保存和保留某些属性,因此需要一种方法让 viewmodel 能够与应用程序设置进行交互。 不过,我们不应直接在 viewmodel 中使用特定于平台的 API,因为这会阻止我们将所有 viewmodel 都放在可移植的 .NET Standard 项目中。 我们可以通过使用服务以及 Microsoft.Extensions.DependencyInjection 库中的 API 为应用程序设置 IServiceProvider 实例来解决此问题。 其思路是写入表示我们需要的所有 API 图面的接口,然后在所有应用程序目标上实施实现此接口的特定于平台的类型。 viewmodel 将仅与接口交互,因此根本不会具有对任何平台特定类型的强引用。

下面是某个设置服务的简单界面:

public interface ISettingsService
{
    /// <summary>
    /// Assigns a value to a settings key.
    /// </summary>
    /// <typeparam name="T">The type of the object bound to the key.</typeparam>
    /// <param name="key">The key to check.</param>
    /// <param name="value">The value to assign to the setting key.</param>
    void SetValue<T>(string key, T value);

    /// <summary>
    /// Reads a value from the current <see cref="IServiceProvider"/> instance and returns its casting in the right type.
    /// </summary>
    /// <typeparam name="T">The type of the object to retrieve.</typeparam>
    /// <param name="key">The key associated to the requested object.</param>
    [Pure]
    T GetValue<T>(string key);
}

我们可以假定实现此接口的平台特定类型将负责处理所有必要逻辑,以实际序列化设置,将这些设置存储到磁盘,然后重新读取这些设置。 现在,我们可以在 SubredditWidgetViewModel 中使用此服务,从而使 SelectedSubreddit 属性持久化:

/// <summary>
/// Gets the <see cref="ISettingsService"/> instance to use.
/// </summary>
private readonly ISettingsService SettingsService;

/// <summary>
/// Creates a new <see cref="SubredditWidgetViewModel"/> instance.
/// </summary>
public SubredditWidgetViewModel(ISettingsService settingsService)
{
    SettingsService = settingsService;

    selectedSubreddit = settingsService.GetValue<string>(nameof(SelectedSubreddit)) ?? Subreddits[0];
}

private string selectedSubreddit;

/// <summary>
/// Gets or sets the currently selected subreddit.
/// </summary>
public string SelectedSubreddit
{
    get => selectedSubreddit;
    set
    {
        SetProperty(ref selectedSubreddit, value);

        SettingsService.SetValue(nameof(SelectedSubreddit), value);
    }
}

此处,我们使用依赖项注入和构造函数注入,如上所述。 我们声明了一个 ISettingsService SettingsService 字段,该字段只存储设置服务(在 viewmodel 构造函数中以参数的形式接收),然后我们初始化构造函数中的 SelectedSubreddit 属性,方法是使用上一个值或仅使用第一个可用的 Reddit 子版块。 然后,我们还修改了 SelectedSubreddit 资源库,以便它还可以使用设置服务将新值保存到磁盘。

很好! 现在,我们只需编写此服务的平台特定版本,这次直接在我们的某个应用项目中进行。 该服务在 UWP 上的外观可能如下所示:

public sealed class SettingsService : ISettingsService
{
    /// <summary>
    /// The <see cref="IPropertySet"/> with the settings targeted by the current instance.
    /// </summary>
    private readonly IPropertySet SettingsStorage = ApplicationData.Current.LocalSettings.Values;

    /// <inheritdoc/>
    public void SetValue<T>(string key, T value)
    {
        if (!SettingsStorage.ContainsKey(key)) SettingsStorage.Add(key, value);
        else SettingsStorage[key] = value;
    }

    /// <inheritdoc/>
    public T GetValue<T>(string key)
    {
        if (SettingsStorage.TryGetValue(key, out object value))
        {
            return (T)value;
        }

        return default;
    }
}

最后,我们需要将此平台特定服务注入到服务提供程序实例中。 我们可以在启动时执行此操作,如下所示:

/// <summary>
/// Gets the <see cref="IServiceProvider"/> instance to resolve application services.
/// </summary>
public IServiceProvider Services { get; }

/// <summary>
/// Configures the services for the application.
/// </summary>
private static IServiceProvider ConfigureServices()
{
    var services = new ServiceCollection();

    services.AddSingleton<ISettingsService, SettingsService>();
    services.AddTransient<PostWidgetViewModel>();

    return services.BuildServiceProvider();
}

这会注册 SettingsService 的一个单一实例,作为实现 ISettingsService 的一个类型。 我们还将 PostWidgetViewModel 注册为暂时性服务,这意味着每次检索实例时,结果都会是新实例(可以想见,如果想要拥有多个独立的帖子小组件,这会很有用)。 这意味着,每次解析 ISettingsService 实例并且所用的应用为 UWP 应用时,它都会收到一个 SettingsService 实例,该实例将在后台使用 UWP API 来操控设置。 太完美了!

生成 Reddit 服务

后端缺失的最后一个组件是一项服务,该服务能够使用 Reddit REST API 从我们感兴趣的 Reddit 子版块中提取帖子。 为了生成此服务,我们将使用 refit,这是一个库,用于轻松生成类型安全的服务,以便与 REST API 交互。 与以前一样,我们需要使用该服务将实现的所有 API 定义接口,如下所示:

public interface IRedditService
{
    /// <summary>
    /// Get a list of posts from a given subreddit
    /// </summary>
    /// <param name="subreddit">The subreddit name.</param>
    [Get("/r/{subreddit}/.json")]
    Task<PostsQueryResponse> GetSubredditPostsAsync(string subreddit);
}

PostsQueryResponse 是一个我们曾经编写的模型,用于映射该 API 的 JSON 响应。 该类的确切结构并不重要,只需知道它包含一系列 Post 项,这些项是表示我们的帖子的简单模型,如下所示:

public class Post
{
    /// <summary>
    /// Gets or sets the title of the post.
    /// </summary>
    public string Title { get; set; }

    /// <summary>
    /// Gets or sets the URL to the post thumbnail, if present.
    /// </summary>
    public string Thumbnail { get; set; }

    /// <summary>
    /// Gets the text of the post.
    /// </summary>
    public string SelfText { get; }
}

有了服务和模型后,就可以将它们插入 viewmodel,以完成后端。 在此期间,还可以将这些 object 占位符替换为我们定义的 Post 类型:

public sealed class SubredditWidgetViewModel : ObservableRecipient
{
    /// <summary>
    /// Gets the <see cref="IRedditService"/> instance to use.
    /// </summary>
    private readonly IRedditService RedditService = Ioc.Default.GetRequiredService<IRedditService>();

    /// <summary>
    /// Loads the posts from a specified subreddit.
    /// </summary>
    private async Task LoadPostsAsync()
    {
        var response = await RedditService.GetSubredditPostsAsync(SelectedSubreddit);

        Posts.Clear();

        foreach (var item in response.Data.Items)
        {
            Posts.Add(item.Data);
        }
    }
}

我们添加了一个新的 IRedditService 字段来存储服务,这与我们之前处理设置服务时的做法很类似,我们还实现了 LoadPostsAsync 方法,该方法先前为空。

最后,只需将实际服务注入到服务提供程序即可大功告成。 在本例中,最大的区别在于,得益于 refit,我们实际上根本不需要实现该服务! 该库将在后台自动创建实现这一服务的类型。 因此,我们只需要获取一个 IRedditService 实例并直接将其注入,如下所示:

/// <summary>
/// Configures the services for the application.
/// </summary>
private static IServiceProvider ConfigureServices()
{
    var services = new ServiceCollection();

    services.AddSingleton<ISettingsService, SettingsService>();
    services.AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"));
    services.AddTransient<PostWidgetViewModel>();

    return services.BuildServiceProvider();
}

至此,我们已完成所有必要工作。 我们现在已经准备好整个后端,可以投入使用,包括为此应用专门创建的两个自定义服务! 🎉

生成 UI

完成整个后端后,现在我们可以为小组件编写 UI。 请注意,之前得益于 MVVM 模式,我们首先只需专注于业务逻辑,而无需编写任何与 UI 相关的代码,而现在我们需要编写相关代码。 为简单起见,这里我们将删除不与 viewmodel 交互的所有 UI 代码,然后,我们将逐个处理每个不同的控件。 示例应用中提供了完整源代码。

在处理各种控件之前,先介绍如何解析应用程序中所有不同视图的 viewmodel(例如 PostWidgetView),方法如下:

public PostWidgetView()
{
    this.InitializeComponent();
    this.DataContext = App.Current.Services.GetService<PostWidgetViewModel>();
}

public PostWidgetViewModel ViewModel => (PostWidgetViewModel)DataContext;

我们使用 IServiceProvider 实例解析所需的 PostWidgetViewModel 对象,然后将该对象分配给数据上下文属性。 我们还创建了一个强类型 ViewModel 属性,该属性将数据上下文转换为正确的 viewmodel 类型,这是在 XAML 代码中启用 x:Bind 所必需的。

我们从 Reddit 子版块小组件开始,该小组件具有一个用来选择 Reddit 子版块的 ComboBox、一个用来刷新源的 Button、一个用来显示帖子的 ListView 和一个指示何时加载源的 ProgressBar。 我们假设 ViewModel 属性表示之前所述的 viewmodel 实例,可以在 XAML 中或直接在代码隐藏中对此加以声明。

Reddit 子版块选择器:

<ComboBox
    ItemsSource="{x:Bind ViewModel.Subreddits}"
    SelectedItem="{x:Bind ViewModel.SelectedSubreddit, Mode=TwoWay}">
    <interactivity:Interaction.Behaviors>
        <core:EventTriggerBehavior EventName="SelectionChanged">
            <core:InvokeCommandAction Command="{x:Bind ViewModel.LoadPostsCommand}"/>
        </core:EventTriggerBehavior>
    </interactivity:Interaction.Behaviors>
</ComboBox>

在这里,我们将源绑定到 Subreddits 属性,并将所选项绑定到 SelectedSubreddit 属性。 请注意,Subreddits 属性只绑定了一次,因为集合本身会发送更改通知,而 SelectedSubreddit 属性则与 TwoWay 模式进行绑定,因为我们需要同时使用二者才能加载从设置中检索的值,以及在用户更改选择时更新 viewmodel 中的属性。 此外,我们还使用了一个 XAML 行为,以便在选择发生更改时调用命令。

刷新按钮:

<Button Command="{x:Bind ViewModel.LoadPostsCommand}"/>

此组件非常简单,我们只是将自定义命令绑定到按钮的 Command 属性,以便在用户单击按钮时调用该命令。

帖子列表:

<ListView
    ItemsSource="{x:Bind ViewModel.Posts}"
    SelectedItem="{x:Bind ViewModel.SelectedPost, Mode=TwoWay}">
    <ListView.ItemTemplate>
        <DataTemplate x:DataType="models:Post">
            <Grid>
                <TextBlock Text="{x:Bind Title}"/>
                <controls:ImageEx Source="{x:Bind Thumbnail}"/>
            </Grid>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

在这里,我们使用 ListView 将源和所选内容绑定到 viewmodel 属性,并使用模板来显示每个可用的帖子。 我们在模板中使用 x:DataType 启用 x:Bind,并使用两个控件直接绑定到帖子的 TitleThumbnail 属性。

加载栏:

<ProgressBar Visibility="{x:Bind ViewModel.LoadPostsCommand.IsRunning, Mode=OneWay}"/>

在这里,我们绑定到 IsRunning 属性,它是 IAsyncRelayCommand 接口的一部分。 每当该命令的异步操作启动或完成时,AsyncRelayCommand 类型都会针对该属性引发通知。


最后需要处理的是帖子小组件的 UI。 与之前一样,为了简单起见,我们删除了与 viewmodel 交互时非必需的所有与 UI 相关的代码。 示例应用中提供了完整源代码。

<Grid>

    <!--Header-->
    <Grid>
        <TextBlock Text="{x:Bind ViewModel.Post.Title, Mode=OneWay}"/>
        <controls:ImageEx  Source="{x:Bind ViewModel.Post.Thumbnail, Mode=OneWay}"/>
    </Grid>

    <!--Content-->
    <ScrollViewer>
        <TextBlock Text="{x:Bind ViewModel.Post.SelfText, Mode=OneWay}"/>
    </ScrollViewer>
</Grid>

以上代码中只有一个标头,其中 TextBlockImageEx 控件将各自的 TextSource 属性绑定到 Post 模型中的相应属性,ScrollViewer 中的 TextBlock 则用于显示所选帖子的(示例)内容。

示例应用程序

此处提供了示例应用程序。

一切就绪! 🚀

现在,我们生成了所有 viewmodel、必要服务和小组件 UI - 一个简单的 Reddit 浏览器已打造完成! 这只是一个示例,说明如何基于 MVVM 模式和使用 MVVM Toolkit 中的 API 生成应用。

如上所述,这只是一个参考,你可以自由修改此结构以满足自己的需求,也可仅从库中选取一部分组件。 无论采用哪种方法,MVVM 工具包都应该为打造新应用程序的工作提供了坚实的基础,让你能够立即开始工作和专注于业务逻辑,而无需担心要去手动完成所有必需的管道设置来适当支持 MVVM 模式。