65.9K
CodeProject 正在变化。 阅读更多。
Home

Calcium:利用 PRISM 的模块化应用程序工具集 - 第二部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (45投票s)

2009年7月5日

BSD

12分钟阅读

viewsIcon

126219

downloadIcon

5

Calcium 提供了构建多方面且复杂的模块化应用程序所需的大部分功能。它包含一套模块和服务,以及一个可用于您下一个应用程序的基础架构。

Calcium Logo

目录

引言

Calcium 是一个利用 Composite Application Library 的 WPF 复合应用程序工具集。它提供了快速构建多功能、复杂的模块化应用程序所需的大部分功能。

在本系列文章的 第一部分 中,我们探讨了 Calcium 的一些核心基础架构,包括模块管理、区域适配和 Bootstrapper。现在,我们将研究消息系统,并介绍另外两个模块,即 WebBrowser 模块和 Output 模块。

Calcium screenshot

图:显示 Web 浏览器和 Output 模块的 Calcium。

在第一篇文章中,我们了解到 Calcium 由一个客户端应用程序和基于服务器的 WCF 服务组成,它们允许客户端之间的交互和通信。Calcium 开箱即用地提供了大量模块和服务,以及一个可用于您下一个应用程序的基础架构。

我们还有很多内容要讲。因此,我决定将本文分为三到四篇文章。

  1. Calcium 简介、模块管理器
  2. 消息服务、WebBrowser 模块、Output 模块(本文)
  3. 文件服务、视图服务、Calcium 品牌重塑
  4. 待定

在本文中,您将学习如何

  • 构建一个位置无关的消息系统;
  • 使用 WCF 自定义标头来标识特定的客户端应用程序实例;
  • 为 Calcium 创建 Web 浏览器和 Output 窗口模块;
  • RoutedCommand 处理程序注入 Shell(主窗口)。

本系列文章中的一些内容并非高级级别,适合初学者。而另一些内容,例如消息系统,则适合更高级的读者。希望这里每个人都能学到东西。

这些系列文章在某些方面是对 Prism 某些领域的介绍。但是,如果您是 Prism 的新手,可能会觉得有时会不知所措,我建议您在学习 Calcium 之前,先看看一些初学者 Prism 文章,例如 Jammer 的引言MSDN 上的技术概念

位置无关的消息服务

在开发应用程序时,很明显,在执行某些任务时保持一致性是明智的。例如,显示通用对话框。但是,如果您认为这一节仅仅是关于抽象对话框系统的,那就错了。那样会很无聊。Calcium 虽然提供了通用的对话框系统,但它还允许我们在任何 WCF 调用期间从服务器向用户显示对话框!此外,它允许我们在客户端和服务器端使用相同的 API。我们不必担心在客户端解释 WCF 调用结果。这意味着我们可以直接与用户从任何地方进行交互,而无需知道我们的业务逻辑在哪里执行,即客户端还是服务器。

Calcium 开箱即用地提供了许多 IMessageService 的实现。有一个适用于 WPF 的客户端实现,还有一个适用于命令行驱动应用程序的客户端实现,以及一个通过回调将消息发送回客户端并利用客户端 IMessageService 实现的服务器端实现。

首先,我想为您提供客户端消息服务的概述,然后我们将研究它是如何从服务器端利用的,以提供位置无关性。

客户端消息服务实现

显然,让开发团队的每个成员都为诸如询问用户一个封闭式问题(是/否问题框)之类的简单任务创建自己的对话框是不明智的。我们最终会产生大量重复,这会降低可维护性。如果我们决定更改整个对话框的标题,如果对话框散布在整个项目中,那将更加困难。同样,如果我们希望将应用程序从 WPF 移植到 Silverlight,甚至移植到命令行界面(考虑 Powershell 或移动应用程序),能够为任何给定场景替换实现将非常棒。显然,需要一个抽象层。

Message Service 提供了各种重载,允许指定错误、标题和消息。

让我们看一下 IMessageService 接口和客户端实现。

IMessageService Class Diagram

图:IMessageService 允许我们以 UI 无关的方式与用户交互。

Message Service 允许指定消息重要性级别。这允许用户指定一个阈值噪声级别。如果 MessageImportance 低于用户的偏好设置,则用户将不会收到该消息的打扰。在 Calcium 的后续版本中,我们将看到一个 Preferences Service 用于指定首选级别。

MessageServiceBase Class Diagram

图:MessageServiceCommandLineMessageService 都重写了 MessageServiceBaseShowCustomDialog 方法,以适应它们各自的环境。

通过仅重写 ShowCustomDialog 方法来实现变体,这使得为 MessageServiceBase 类进行测试模拟变得非常容易。

我们的客户端 WPF 实现通过 ShowCustomDialog 方法将所有消息请求传递,如下面的摘录所示。

/// <summary>
/// WPF implementation of the <see cref="IMessageService"/>.
/// </summary>
public class MessageService : MessageServiceBase
{
    public override MessageResult ShowCustomDialog(string message, string caption,
        MessageButton messageButton, MessageImage messageImage, 
        MessageImportance? importanceThreshold, string details)
    {
        /* If the importance threshold has been specified 
         * and it's less than the minimum level required (the filter level) 
         * then we don't show the message. */
        if (importanceThreshold.HasValue && 
		importanceThreshold.Value < MinumumImportance)
        {
            return MessageResult.OK;
        }

        if (MainWindow.Dispatcher.CheckAccess())
        {/* We are on the UI thread, and hence no need to invoke the call.*/
            var messageBoxResult = MessageBox.Show(MainWindow, message, caption, 
            messageButton.TranslateToMessageBoxButton(), 
            messageImage.TranslateToMessageBoxButton());
            return messageBoxResult.TranslateToMessageBoxResult();
        }

        MessageResult result = MessageResult.OK; /* Satisfy compiler 
						with default value. */
        MainWindow.Dispatcher.Invoke((ThreadStart)delegate
            {
                var messageBoxResult = MessageBox.Show(MainWindow, message, caption, 
                messageButton.TranslateToMessageBoxButton(), 
                messageImage.TranslateToMessageBoxButton());
                result = messageBoxResult.TranslateToMessageBoxResult();
            });

        return result;
    }

    static Window MainWindow
    {
        get
        {
            return UnitySingleton.Container.Resolve<IMainWindow>() as Window;
        }
    }
}

我创建了各种扩展方法,用于在 WPF 的本地 enums MessageBoxButtonMessageBoxImageMessageBoxResult 之间进行翻译。为什么要费这么大力气?乍一看,这看起来像是一种反模式。原因实际上是,WPF 和 Silverlight 中这些 enums 存在差异,这允许我们在不重复的情况下同时支持两者。

我正在考虑扩展该服务以支持消息详细信息,并可能通过引用类型 Message 参数减少重载的数量。另一个改进是实现一个“不再显示”复选框系统。我将其留给未来的版本。我希望使用 Karl Shifflet 出色的 Common TaskDialog 项目 WPFTaskDialogVistaAndXP.aspx(经 Karl 许可)。

服务器端消息服务

我们已经了解了 IMessageService 的基本客户端实现,但现在事情将变得更有趣。通过 Calcium,我们可以在客户端和服务器端以相同的方式使用 IMessageService。为此,我们使用 WCF 自定义标头和双工服务回调。

演示应用程序包含一个名为 MessageServiceDemoModule 的模块。该模块的视图包含一个按钮,当 CommunicationService 与服务器连接时,该按钮将变为可用。

下面的摘录显示了 WCF 服务中的代码,该代码是异步执行的。用户将在客户端看到对话框。请记住,此代码在服务器端执行!

public class DemoService : IDemoService
{
    public void DemonstrateMessageService()
    {
        var messageService = UnitySingleton.Container.Resolve<IMessageService>();
        ThreadPool.QueueUserWorkItem(delegate { 
            messageService.ShowMessage(
            "This is a synchronous message invoked from the server. " 
                + "The service method is still executing and waiting for your response.", 
            "DemoService", MessageImportance.High);
            bool response1 = messageService.AskYesNoQuestion(
              "This is a question invoked from the server. Select either Yes or No.", 
              "DemoService");
            string responseString = response1 ? "Yes" : "No";
            messageService.ShowMessage(string.Format(
                "You selected {0} as a response to the last question.", responseString), 
                MessageImportance.High);
         });
    }
}

在上面的摘录中,我们从 Unity 容器中检索 IMessageService 实例。这与我们在客户端执行的方式相同!位置无关性允许我们更轻松地移动业务逻辑。我将消息服务调用放在 QueueUserWorkItem 委托中,只是为了演示消息服务的独立性。WCF 调用几乎立即返回,但子线程将继续在后台工作,并且仍然能够与用户进行通信。如果不使用子线程与用户通信,如果用户响应速度不够快,我们可能会遇到超时。请注意,我们必须在服务调用完成之前从 Unity 容器中检索 Message Service。否则将引发异常,因为服务调用的 OperationContext 将不再存在。

Screenshot of Demo Message Module, displaying message.

图:服务器导致在客户端显示对话框。

图:从服务器提出的问题。

图:收到响应并回显给用户。

工作原理

当我们 i 需要服务通道时,我们使用 IChannelManager 实例来检索一个实例。我在其他文章中讨论过 IChannelManager,特别是在 此处此处

在 WCF 中,每个 WCF 服务都有独立的会话状态。换句话说,WCF 服务不共享全局会话。因此,为了从任何 WCF 服务调用中标识客户端应用程序实例,我们将一个自定义标头放入我们创建的每个服务通道中,如 ServiceManagerSingletonInstanceIdHeader 类中的以下摘录所示。

public static class InstanceIdHeader
{
    public static readonly String HeaderName = "InstanceId";
    public static readonly String HeaderNamespace 
            =  OrganizationalConstants.ServiceContractNamespace;
}

我们使用这个 static 类来放置标头,并在服务器端检索标头。

void AddCustomHeaders(IClientChannel channel)
{
    MessageHeader shareableInstanceContextHeader = MessageHeader.CreateHeader(
    InstanceIdHeader.HeaderName,
    InstanceIdHeader.HeaderNamespace,
    instanceId.ToString());

    var scope = new OperationContextScope(channel);
    OperationContext.Current.OutgoingMessageHeaders.Add(shareableInstanceContextHeader);
}

因此,当我们创建服务通道时,我们添加自定义标头,如下面的摘录所示。

public TChannel GetChannel<TChannel>()
{
    Type serviceType = typeof(TChannel);
    object service;

    channelsLock.EnterUpgradeableReadLock();
    try
    {
        if (!channels.TryGetValue(serviceType, out service))
        {/* Value not in cache, therefore we create it. */
            channelsLock.EnterWriteLock();
            try
            {
                /* We don't cache the factory as it contains a list of channels 
                 * that aren't removed if a fault occurs. */
                var channelFactory = new ChannelFactory<TChannel>("*");

                service = channelFactory.CreateChannel();
                AddCustomHeaders((IClientChannel)service);

                var communicationObject = (ICommunicationObject)service;
                communicationObject.Faulted += OnChannelFaulted;
                channels.Add(serviceType, service);
                communicationObject.Open(); 
                ConnectIfClientService(service, serviceType);

                UnitySingleton.Container.RegisterInstance<TChannel>((TChannel)service);
            }
            finally
            {
                channelsLock.ExitWriteLock();
            }
        }
    }
    finally
    {
        channelsLock.ExitUpgradeableReadLock();
    }

    return (TChannel)service;
}

我们缓存通道直到它被关闭或出现故障。但只要我们创建一个新通道,我们就添加该标头以供服务器使用。我们还支持双工通道,其工作方式基本相同。

在我们尝试在服务器端使用 Message Service 之前,必须与 ICommunicationService 通信以创建回调。这由 CommunicationModule 自动完成。事实上,CommunicationModule 会定期轮询服务器,以告知服务器它仍然在线。它还将检测网络连接或缺乏连接,并相应地启用或禁用轮询。

/// <summary>
/// Notifies the server communication service that the client is still alive.
/// Occurs on a ThreadPool thread. <see cref="NotifyAlive"/>
/// </summary>
/// <param name="state">The unused state.</param>
void NotifyAliveAux(object state)
{
    try
    {
        if (!NetworkInterface.GetIsNetworkAvailable())
        {
            return;
        }
        var channelManager = UnitySingleton.Container.Resolve<IChannelManager>();
        var communicationService = 
            channelManager.GetDuplexChannel<ICommunicationService>(callback);
        communicationService.NotifyAlive();
        if (!connected)
        {
            connected = true;
            connectionEvent.Publish(ConnectionState.Connected);
        }
        lastExceptionType = null;
    }
    catch (Exception ex)
    {
        Type exceptionType = ex.GetType();
        if (exceptionType != lastExceptionType)
        {
            Log.Warn("Unable to connect to communication service.", ex);
        }
        lastExceptionType = exceptionType;
        if (connected)
        {
            connected = false;
            connectionEvent.Publish(ConnectionState.Disconnected);
        }
    }
    finally
    {
        connecting = false;
    }
}

顺便说一句,CommunicationModule 本身没有 UI。它的目的是与 CommunicationService 交互,并在各种服务器端事件发生时向客户端提供通知。其中一个 CompositeEventConnectionEvent,可以在前面的摘录中看到。

我计划扩展 Message Service 系统以支持文本输入、下拉列表选择,甚至可能是自定义对话框。

WebBrowser 模块

WebBrowser 模块包括模块本身、视图和视图模型。视图类 WebBrowserView 包含一个 WPF WebBrowser 控件,其 Url 属性绑定到 ViewModel,如下面的摘录所示。

<WebBrowser Name="WebBrowser" wb:WebBrowserUtility.BindableSource="{Binding Url}"
   HorizontalAlignment="Stretch" VerticalAlignment="Stretch" />

图:Calcium 显示 WebBrowser 的屏幕截图。

WebBrowser 工具栏有一个按钮,其 Command 是 WebBrowserViewModel.NavigateCommand,还有一个 TextBoxNavigateCommand 实际上被注入到 Shell 的 CommandBinding 集合中,以便在当前视图实现 IWebBrowserView 内容接口时调用它。

这在 WebBrowserModule 中得到了实际应用,我们将 WebBrowserViewModel.NavigateCommandWebBrowserViewModel 的内容类型关联起来,如下面的摘录所示。

/* When a WebBrowserViewModel is the ViewModel of the active item in the shell,
 * the NavigateCommand becomes active. */
commandService.AddCommandBindingForContentType<WebBrowserViewModel>(
WebBrowserViewModel.NavigateCommand,
(arg, commandParameter) => arg.Navigate(commandParameter),
(arg, commandParameter) => arg.CanNavigate(commandParameter));

在这里,Shell 接受一个 ICommand,并且当 WebBrowserViewModel 激活时,它将根据 CanNavigate 处理程序启用该命令。

以下摘录来自 ICommandService 接口,其中我们看到了关联命令的各种签名。

/// <summary>
/// Adds a <seealso cref="CommandBinding"/> for the shell, 
/// that is associated with the active content.
/// When the command binding's canExecute handler is called, 
/// we get the active content <strong>ß</strong> in the shell.
/// If the active content is of the specified <code>TContent</code> 
/// type then the specified <code>canExecuteHandler(ß)</code> is called.
/// When the command binding's execute handler is called, 
/// we get the active content <strong>ß</strong> in the shell.
/// If the active content is of the specified <code>TContent</code> 
/// type then the specified <code>executeHandler(ß)</code> is called.
/// </summary>
/// <typeparam name="TContent">The type of the content 
/// that must be active in the workspace.</typeparam>
/// <param name="command">The command to register.</param>
/// <param name="executeHandler">The execute handler. 
/// Must return <code>true</code> if the command 
/// is to be marked as handled.</param>
/// <param name="canExecuteHandler">The can execute handler. 
/// If the handler returns <code>true</code> the command is executable 
/// (e.CanExecute is set to <code>true</code>), otherwise the command 
/// will be not executable (e.CanExecute is set to <code>false</code>.</param>
void AddCommandBindingForContentType<TContent>
				(ICommand command,
                           		Func<TContent, object, bool> executeHandler,
                          		Func<TContent, object, bool> canExecuteHandler)
    where TContent : class;

//… overloads omitted for brevity.

void AddCommandBinding(ICommand command,
Func<bool> executeHandler, Func<bool> canExecuteHandler);

void AddCommandBinding(ICommand command,
Func<bool> executeHandler, Func<bool> canExecuteHandler, KeyGesture keyGesture);

void RegisterKeyGester(KeyGesture keyGesture, ICommand command);

void RemoveCommandBinding(ICommand command);

在命令目标位于可视化树中的场景中,WPF 路由命令基础结构很有用。然而,对于结合了工具视图和文档视图的接口,触发命令处理程序可能会很困难,并且某些控件的行为可能不如预期。例如,当当前工作区视图未获得焦点时,工具栏按钮未启用,这可以作为示例。

为了解决这个挑战,我们使用 ICommandService 方法将已知内容类型与命令关联起来。下面的摘录显示了 AddCommandBindingForContentType 接口定义。

AddCommandBindingForContentType 已在 DesktopShell.Commanding.cs 中实现,如下面的摘录所示。

public void AddCommandBindingForContentType<TContent>(
    ICommand command,
    Func<TContent, object, bool> executeHandler, 
    Func<TContent, object, bool> canExecuteHandler)
    where TContent : class
{
    /* When the workspace view changes, 
     * if the view is viewType then the specified command 
     * will be enabled depending on the result of the command.CanExecute. 
     * When the command is executed the current view's specified member 
     * will be called. */
    CommandBindings.Add(new CommandBinding(command,
        (sender, e) =>
        {
            var content = GetSelectedContent<TContent>();
            if (content == null)
            {
                /* Shouldn't get here because the CanExecute handler 
                 * should prevent it. */
                return;
            }
            e.Handled = executeHandler(content, e.Parameter);
        },
        (sender, e) =>
        {
            var content = GetSelectedContent<TContent>();
            if (content == null)
            {
                e.CanExecute = false;
                return;
            }
            e.CanExecute = canExecuteHandler(content, e.Parameter);
        }));
}

TabItemDictionary.xaml:

<DataTemplate x:Key="TabHeaderDataTemplate">
<StackPanel Orientation="Horizontal" VerticalAlignment="Stretch">
<TextBlock x:Name="textBlock" 
   Text="{Binding TabHeader}" 
   HorizontalAlignment="Left" TextTrimming="CharacterEllipsis" 
   TextWrapping="NoWrap" Foreground="#FFFFFFFF" Margin="0,2,0,0"  
   FontFamily="Arial" FontSize="11" />
<TextBlock Text="*" 
   Visibility="{Binding Path=Content.Dirty, FallbackValue=Collapsed, 
   Converter={StaticResource BooleanToVisibilityConverter}}"
   HorizontalAlignment="Left" 
   Foreground="#FFFFFFFF" Margin="0,2,0,0" />
<Button x:Name="button"  
    Command="ApplicationCommands.Close" CommandParameter="{Binding Path=View}" 
    Template="{DynamicResource CloseTabButtonControlTemplate}" 
    Background="{x:Null}" BorderBrush="{x:Null}" Foreground="{x:Null}" 
    Width="9" Height="9" Opacity="1" ToolTip="Close" 
    Margin="8,3,0,0" VerticalAlignment="Stretch" BorderThickness="0"
    HorizontalAlignment="Right" />
</StackPanel>
</DataTemplate>

这一切都集中在 WebBrowserModule 类中,该类从 RegionManager 获取 Workspace 区域并用 WebBrowserView 填充它。然后,我们创建一个新的 WebBrowserToolBar 实例并将其放入 Shell 的 StandardToolBarTray 区域。

[Module(ModuleName = ModuleNames.WebBrowser)]
public class WebBrowserModule : IModule
{
    public void Initialize()
    {
        var regionManager = UnitySingleton.Container.Resolve<IRegionManager>();
        var view = new WebBrowserView();
        regionManager.Regions[RegionNames.Workspace].Add(view);
        string startUrl = "http://wpfdisciples.wordpress.com/";
        var viewModel = (WebBrowserViewModel)view.ViewModel;
        viewModel.Url = startUrl;

        /* Add web browser toolbar. */
        var viewService = UnitySingleton.Container.Resolve<IViewService>();
        var toolBarProvider = new WebBrowserToolBar { Url = startUrl };
        var toolBar = toolBarProvider.ToolBar;
        regionManager.Regions[RegionNames.StandardToolBarTray].Add(toolBar);
        viewService.AssociateVisibility(typeof(IWebBrowserView),
            new UIElementAdapter(toolBar), Visibility.Collapsed);

        var shell = UnitySingleton.Container.Resolve<IShell>();

        /* When a WebBrowserViewModel is the ViewModel of the active item in the shell,
         * the NavigateCommand becomes active. */
        shell.AddCommandBindingForContentType<WebBrowserViewModel>(
            WebBrowserViewModel.NavigateCommand,
            (arg, commandParameter) => arg.Navigate(commandParameter),
            (arg, commandParameter) => arg.CanNavigate(commandParameter));
    }
}

为了根据界面中的内容隐藏和显示工具栏,我们使用 IViewService,它会在当前内容不实现 IWebBrowser 视图时隐藏工具栏,并在实现时显示工具栏。我们将在本系列的下一篇文章中介绍 View Service。

我们在 WebBrowserModule 中做的最后一件事是将命令绑定添加到 Shell。

读者可能会注意到,在我编写的代码中,我通常避免使用构造函数注入。什么是构造函数注入?构造函数注入是指依赖注入(DI)容器(在此例中为 Unity)用于自动调用非默认构造函数并为其提供已知类型的实例。我选择避免它,因为我发现它很麻烦。当发生解析失败,并且您有大量在构造函数注入期间解析的级联类型时,您可能会发现自己要花费大量时间在堆栈跟踪中寻找失败的根本原因。因此,我通常通过单例检索 DI 容器。我也看不出注入容器有什么价值。对我来说,这只是膨胀。

Output 模块

Output 模块用于显示通过 OutputPostedEvent 收到的消息,这是一个 Prism CompositePresentationEventCompositePresentationEvents 与 IEventAggregator 一起使用,以允许模块以解耦的方式订阅和发布事件。如果您不熟悉 CompositePresentationEvents(以前称为 CompositeEvent),可以在 此处[^] 找到更多信息。

Output module screenshot.

图:路由事件导致消息显示在 OuputView 中。

此事件可以从客户端的任何地方发布。OutputViewModel 订阅此事件,当收到消息时,它会将其添加到 ObservableCollection 中,如下面的摘录所示。

class OutputViewModel : ViewModelBase
{
    readonly ObservableCollection<OutputMessage> outputMessages 
                = new ObservableCollection<OutputMessage>();
        
    public ObservableCollection<OutputMessage> OutputMessages
    {
        get
        {
            return outputMessages;
        }
    }

    public OutputViewModel(IOutputView outputView) : base(outputView)
    {
        var eventAggregator = UnitySingleton.Container.Resolve<IEventAggregator>();
        var outputPostedEvent = eventAggregator.GetEvent<OutputPostedEvent>();
        outputPostedEvent.Subscribe(OnOutputPosted);
        TabHeader = "Output";
    }

    void OnOutputPosted(OutputMessage message)
    {
        OutputMessages.Add(message);
    }
}

视图仅仅负责显示消息。我们可以看到如何将消息发送到 Output 视图,在 TextEditorModule 中。

/* Send an output message. */
var eventAggregator = UnitySingleton.Container.Resolve<IEventAggregator>();
var outputPostedEvent = eventAggregator.GetEvent<OutputPostedEvent>();
outputPostedEvent.Publish(new OutputMessage { 
    Category = outputCategory, Message = fileNameUsed + " opened." });

我们首先从 DI 容器中检索 IEventAggregator 实例。然后使用 GetEvent 方法检索事件同步。然后我们发布或引发事件,这会导致 OutputViewModel 中的处理程序 OnOutputPosted 被调用。

结论

在本文中,我们了解了 Calcium 如何用于提供一个位置无关的消息系统,该系统允许从任何地方(无论是服务器端还是客户端)向用户显示一组通用对话框。我们可以在客户端和服务器端使用相同的 API,从而允许我们直接与用户从任何地方进行交互,而无需知道我们的业务逻辑在哪里执行。我们还探讨了 Web 浏览器模块、Output 模块的实现,并研究了如何将 RoutedEvent 处理程序注入 Calcium 的 Shell。

在下一篇文章中,我们将介绍 File Service,它自动处理常见的 IO 错误,并且具有一个我非常喜欢的相当灵活的 API。我们还将介绍 User Affinity Module,用于提供有关应用程序其他用户的反馈,最后是 Text Editor Module,它将整合我们一直在探索的许多其他功能。

我们还有很多内容要讲,希望您能继续关注我们的下一篇文章。

我希望您觉得这个项目有用。如果有用,我将不胜感激您能对其进行评分和/或在下方留下反馈。这将帮助我写出更好的下一篇文章。

历史

  • 2009 年 7 月
    • 首次发布
© . All rights reserved.