Prism初研究之使用Prism實現WPF的MVVM的高級應用


Prism初研究之使用Prism實現WPF的MVVM的高級應用


本章描述MVVM如何支持一些復雜的使用場景,以及如何組織命令和子視圖來滿足用戶需求。本章還描述了如何處理異步數據請求以及之后的UI交互。

Commands

復合命令(Composite Commands)

通常,一個定義在View Model中的命令能夠通過綁定到控件來實現直接命令調用。但是,有些情況下,可能使用一個父視圖的控件調用一個或多個View Model的多個命令。
比如,如果應用程序允許用戶同時編輯多個數據項,就需要允許用戶通過點擊一個按鈕(命令)來保存所有的數據項。這種情況下Save All命令將會調用每一個數據項的Save命令。
實現Save All復合命令
Prism提供了CompositeCommand類來實現復合命令。
這個命令類由對個子命令組成。當復合命令被調用時,所有子命令將會依次調用。這個命令類可以應用於使用一個邏輯命令調用多個命令和使用一個命令來表示一組命令兩種使用場景。
Stock Trader RI例子中的SubmitAllOrder命令就是一個復合命令。
CompositeCommand命令維護一個子命令(DelegateCommand實例)的列表,它的Execute方法只是遍歷調用了子命令的Execute。CanExecute方法類似,不過如果有一個子命令不可運行,就返回false。

注冊和注銷子命令

通過RegisterCommand和UnregisterCommand方法來注冊和注銷子命令。Stock Trader RI中每一個訂單的Submit和Cancel命令都注冊到SubmitAllOrders和CancelAllOrders組件命令:

 
 
 
         
  1. // OrdersController.cs
  2. commandProxy.SubmitAllOrdersCommand.RegisterCommand( orderCompositeViewModel.SubmitCommand );
  3. commandProxy.CancelAllOrdersCommand.RegisterCommand( orderCompositeViewModel.CancelCommand );

在活動的子視圖上運行命令

Prism的CompositeCommand和DelegateCommand類可以與Prism的regions一起工作。
下圖展示了一個子視圖如何添加到EditRegion的。UI設計者可以選擇使用Tab控件在Region中布局視圖:
用TabControl定義EditRegion
有時可能需要運行當前活動子視圖的命令:實現一個Zoom命令來引起活動視圖的縮放。

Prism提供了IActiveAware接口來支持這種使用情況。該接口定義了一個IsActive屬性(在實現者活動是返回true)和一個IsActiveChanged事件(active狀態改變時發生)。
可以在子視圖或者視圖模型類上實現IActiveAware接口。視圖的活動狀態由特定區域控件中的區域適配器(region Adapter)決定。上圖中,有一個region Adapter將選中標簽中的視圖設置為活動的。
DelegateCommand類也實現了IActiveAware接口。CompositeCommand可以通過擁有參數monitorCommandActivity的構造函數來配置是否評估子命令的活動狀態)。
如果monitorCommandActivity參數是true,CompositeCommand類會有以下行為:

  • CanExecute。所有Active的命令都可以被執行時返回true。不活動的子命令不會被考慮。
  • Execute。運行所有的活動命令。不活動的子命令不會被考慮。

集合中綁定命令

另一個場景的使用情景是, 在視圖中顯示一組數據的集合,同時需要為每一個數據項綁定一個命令,但是這個命令是在父視圖(視圖模型)中實現的(不是數據項類中實現的)。
比如,下圖的視圖使用ListBox顯示了一組數據,使用數據模板為每一個數據項顯示了一個Delete按鈕:
在集合中綁定命令
困難在於將視圖模型實現的Delete命令綁定到每一個項。由於每一項的數據上下文(ListBox中)引用的是集合中的項,而Delete命令在父View Model中實現。
一種解決方案是在數據模板中綁定命令。

 
 
 
         
  1. <Grid x:Name="root">
  2. <ListBox ItemsSource="{Binding Path=Items}">
  3. <ListBox.ItemTemplate>
  4. <DataTemplate>
  5. <Button Content="{Binding Path=Name}" Command="{Binding ElementName=root, Path=DataContext.DeleteCommand}" />
  6. </DataTemplate>
  7. </ListBox.ItemTemplate>
  8. </ListBox>
  9. </Grid>

觸發器和命令的交互

使用Blend來設計觸發器交互:

 
 
 
         
  1. <Button Content="Submit" IsEnable="{Binding CanSubmit}">
  2. <i:Interaction.Triggers>
  3. <i:EventTrigger EventName="Click">
  4. <i:InvokeCommandAction Command="{Binding SubmitCommand}"/>
  5. </i:EventTrigger>
  6. </i:Interaction.Triggers>
  7. </Button>

這種方法可以用於任何可以附加交互觸發器的控件。如果想要將命令綁定到沒有實現ICommandSource接口的控件,或者想要調用自定義的事件來觸發命令時,這種方式尤其有用。
下面代碼顯示了配置ListBox來監聽SelectionChanged事件。事件發生時會地調用綁定的命令:

 
 
 
         
  1. <ListBox ItemsSource="{Binding Items}" SelectionModel="Single">
  2. <i:Interaction.Triggers>
  3. <i:EventTrigger EventName="SelectionChanged">
  4. <i:InvokeCommandAction Command="{Binding SelectedCommand}"/>
  5. </i:EventTrigger>
  6. </i:Interaction.Triggers>
  7. </ListBox>

命令vs行為

為命令傳入EventArgs參數

當想要調用命令來響應控件觸發的事件時,可以使用Prism類庫中的InvokeCommandAction。Prism類庫的InvokeCommandAction與Blend SDK中的同名方法的區別如下:首先Prism類庫的InvokeCommandAction方法根據命令CanExecute方法的返回值更新控件的enable狀態。第二,如果沒有設置CommandParameter,Prism類庫的InvokeCommandAction方法可以從父觸發器傳遞EventArgs參數(依賴項屬性TriggerParameterPath)。
有些情況下,需要從父觸發器傳遞參數給命令,比如EventTrigger的EventArgs。這種情況下不能使用Blend SDK中的InvokeCommandAction方法。代碼如下:

 
 
 
         
  1. <ListBox Grid.Row="1" Margin="5" ItemsSource="{Binding Items}" SelectionModel="Single">
  2. <i:Interaction.Triggers>
  3. <i:EventTrigger EventName="SelectionChanged">
  4. <!-- 調用選擇命令,並且傳遞參數 -->
  5. <prism:InvokeCommandAction Command="{Binding SelectedCommand}" TriggerParameterPath="AddedItems"/>
  6. </i:EventTrigger>
  7. </i:Interaction.Triggers>
  8. </ListBox>

處理異步交互

view Model經常面臨異步的交互,比如請求網絡服務和網絡資源,或者后台的的計算或IO任務。使用異步可以提供好的用戶體驗。
當用戶啟動了一個異步請求或后台任務時,預測任務何時完成非常困難。但是UI只能在UI線程中更新,所以需要頻繁的調度UI線程。

通過網絡服務獲取數據和進行交互

在異步編程模式中,需要調用一對方法而不是一個。為了啟動異步調用,首先調用BeginXXX方法,當調用結束時調用EndXXX方法。
為了決定調用EndXXX方法的時機,可以選擇輪詢是否完成或者在調用BeginXXX方法時指定回調函數。回調方法如下:

 
 
 
         
  1. IAsyncResult asyncResult = this.service.BeginGetQuestionnaire(GetQuestionnaireCompleted, null);
  2. private void GetQuestionnaireCompleted(IAsyncResult result)
  3. {
  4. try
  5. {
  6. questionnaire = this.service.EndGetQuestionnaire(ar);
  7. }
  8. catch(Exception ex)
  9. {
  10. //報錯
  11. }
  12. }

注意,在End方法中,可能會遇到一些異常。需要處理這些異常,並且以線程安全的方式報告給UI。
由於遠程響應一般都不再UI線程,所以如果想要改變UI的狀態,必須將響應調度到UI線程(使用Dispatcher或者SynchronizationContext對象)。WPF中一般使用Dispatcher。
下面示例中,Questionnaire對象通過異步請求獲得,然后將它設置為QuestionnaireView的數據上下文。使用CheckAccess方法來判斷目前是否處於UI線程。

 
 
 
         
  1. var dispatcher = System.Windows.Deployment.Current.Dispatcher;
  2. if(dispatcher.CheckAccess())
  3. {
  4. QuestionnaireView.DataContext = questionnaire;
  5. }
  6. else
  7. {
  8. dispatcher.BeginInvoke(()=>{ Questionnaire.DataContext = questionnaire; });
  9. }

用戶交互模式

有許多交互的方式,比如顯示對話框或MessageBox,但是在基於MVVM的應用中實現概念分離的交互是一個很大的挑戰。舉例來說,在非MVVM應用中常用的MessageBox,在MVVM應用中不能被使用,因為它會破壞view和view model概念之間的分離。
在MVVM模式中有兩種通用的方法來實現用戶交互。一種是實現一種View model使用的用戶交互服務,並且保持它和view的獨立性。另一種方法是在view Model中通過觸發事件來向UI傳達意圖,這需要view中的組件綁定到這些事件。

使用交互服務

這種方法,view model通常依賴於交互服務接口。它會通過依賴注入容器或service Locator頻繁的請求交互服務。
一旦view Model獲得了交互服務的引用,它就能在必要時請求交互服務。交互服務事項了交互的視覺效果,如下圖所示:
使用交互服務
模態交互,比如顯示一個MessageBox或彈出一個模態窗口,在運行繼續前需要一個指定的響應,所以可以以同步的方式進行實現:

 
 
 
         
  1. var result = interactionService.ShowMessageBox("Are you sure you want to cancel this operation?"), "Confirm", MessageBoxButton.OK);
  2. if(result == MessageBoxResult.Yes)
  3. {
  4. CancelRequest();
  5. }

這種方法的劣勢是強制了同步的編程模型。一個可選的異步實現是如下:

 
 
 
         
  1. var result = interactionService.ShowMessageBox("Are you sure you want to cancel this operation?"), "Confirm", MessageBoxButton.OK,
  2. result =>
  3. {
  4. if(result == MessageBoxResult.Yes)
  5. });

交互服務的異步實現更靈活一些。

使用交互請求對象

另一個在MVVM模式中實現UI的方式是允許view model通過交互請求對象(與view中的行為耦合)直接向view請求交互。交互請求對象封裝交互請求的細節和響應,並且通過事件來和view通信。view一般將交互封裝在一個行為中。
使用交互請求對象
Prism采用這種方式。Prism框架通過IInteractionRequest接口和InteractionRequest 類支持交互請求對象方式。IInteractionRequest接口定義額一個事件(Raise)來啟動交互。view中的行為綁定到這個接口,並訂閱這個事件。InteractionRequest 類實現了IInteractionRequest接口,並且定義了兩個Raise方法來允許view Model啟動交互同時指定請求上下文,還可以選擇傳遞一個回調函數。

從view Model初始化交互請求

上下文對象允許View Model傳遞數據和狀態給view。如果指定了回調,上下文對象還可以回傳給View Model。

 
 
 
         
  1. public interface IInteractionRequest
  2. {
  3. event EventHandler<InteractionRequestionRequestedEventArgs> Raised;
  4. }
  5. public class InteractionRequest<T> : IInteractionRequest where T : INotification
  6. {
  7. public event EventHandler<InteractionRequestedEventArgs> Raised;
  8. public void Raise(T context)
  9. {
  10. this.Raise(context, c => { });
  11. }
  12. public void Raise(T context, Action<T> callback)
  13. {
  14. var handler = this.Raised;
  15. if(handler != null)
  16. {
  17. handler(
  18. this,
  19. new InteractionRequestedEventArgs(
  20. context,
  21. () => { if(callback != null) callback(context);} )
  22. );
  23. }
  24. }
  25. }

Prism提供了一些預定義的上下文類來支持通用的交互請求。INotification接口用來通知用戶發生了一個重要的事件。它提供了兩個屬性——Title和Content。通常通知都是單向的,所以不需要用戶在交互過程中更改這些值。Notification類是該接口的默認實現。
IConfirmation接口擴展了INotification接口並且提供了第三個屬性——Confirmed,這個屬性用來標識用戶是確認還是取消了操作。Confirmation類是該接口的默認實現,它實現了MessageBox風格的交互。可以自定義上下文類來實現INotification接口封裝任何需要的數據和狀態。
View Model類會創建一個InteractionRequest 的實例,並且定義一個只讀的屬性(用來view綁定)。當View Model啟動交互請求時,會調用Raised方法,傳遞上下文對象,和可選的回調委托。

 
 
 
         
  1. public InteractionRequestViewModel()
  2. {
  3. this.ConfirmationRequest = new InteractionRequest<IConfirmation>();
  4. ...
  5. //為每個按鈕定義一個命令,每一個按鈕都引起不同的交互請求。
  6. this.RaiseConfirmationCommand = new DelegateCommand(this.RaiseConfirmation);
  7. ...
  8. }
  9. public InteractionRequest<IConfirmation> ConfirmationRequest {get; private set;}
  10. private void RaiseConfirmation()
  11. {
  12. this.ConfirmationRequest.Raise(
  13. new Confirmation{ Content = "Confirmation Message", Title = "Confirmation"},
  14. c => { InteractionResultMessage = c.Confirmed ? "The user accepted." : "The user cancelled.";});
  15. }
  16. }

Interactivity QuickStart示例展示了如何使用這些接口和類來完成交互。

使用行為實現UI體驗

交互請求對象表示了邏輯的交互,實際的UI體驗被定義在view中。行為經常被用來封裝UI體驗。UI設計師可以將view Model中的交互請求對象綁定到行為上。
View必須探測到一個交互請求事件,然后呈現請求的視覺效果。事件觸發器用來在探測到交互請求事件時進行初始化動作。
通過綁定到交互請求對象,Blend提供的標准EventTrigger可以監視一個交互請求事件。然而,Prism定義了一個EventTrigger——InteractionRequestTrigger,可以自動和IInteractionRequest接口的Raised事件進行連接。這減少了XAML的代碼量,同時減少了錯誤輸入事件名稱的可能。
事件觸發以后,InteractionRequestTrigger會調用指定的動作。Prism為WPF提供了PopupWindowAction類,這個類可以顯示一個彈出窗口。當窗口顯示時,它的數據上下文設置為交互請求的上下文參數。使用PopupWindowAction的WindowContent屬性可以指定彈出窗口的視圖。彈出窗口的標題被綁定到上下文對象的Title屬性。

注意:默認情況下PopupWindowAction類顯示的窗口與上下文對象相關。如果是一個Notification上下文對象,DefaultNotificationWindow將會顯示,如果是Confirmation上下文對象,一個DefaultConfirmationWindow將會顯示。可以通過WindowContent屬性來指定彈出窗口的視圖。

如何使用InteractionRequestTrigger和 PopupWindowAction:

 
 
 
         
  1. <i:Interaction.Triggers>
  2. <prismInteractionRequestTrigger SourceObject="{Binding ConfirmationRequest, Mode=OneWay}">
  3. <prism:PopupWindowAction IsModal="True" CenterOverAssociatedObject="True"/>
  4. </prismInteractionRequestTrigger>
  5. </i:Interaction.Triggers>

Prism的InteractionRequestTrigger和PopupWindowAction類可以用作自定義觸發器和動作的基礎。

高級創建和裝配

使用MEF創建View和ViewModel

使用MEF,可以通過使用Import特性來指定view的依賴,使用Export特性指定具體的View Model類型。
屬性設置View的數據上下文。可以選擇使用屬性或者有參構造函數來為View傳入View model。
比如,StockTrader RI的Shell view中聲明了一個只寫的屬性(ViewModel,Import特性標注)。視圖被實例化是,MEF穿件了指定View Model的實例,並且設置這個屬性值。代碼如下:

 
 
 
         
  1. [Import]
  2. ShellViewModel ViewModel
  3. {
  4. set { this.DataContext = value; }
  5. }

View Model定義如下:

 
 
 
         
  1. [Export]
  2. public class ShellViewModel : BindableBase
  3. {
  4. ...
  5. }

一個可選的方法是,在視圖中定義一個Importing Constructor。

 
 
 
         
  1. public Shell()
  2. {
  3. InitializeComponent();
  4. }
  5. [ImportingConstructor]
  6. public Shell(ShellViewModel viewModel) : this()
  7. {
  8. this.DataContext = viewModel;
  9. }

MEF將會實例化一個ShellViewModel,並傳遞給Shell的構造器。

使用Unity創建View和ViewModel

使用Unity同樣有兩種依賴注入的方式。區別在於,使用Unity無法在運行時被隱式地發現,它們必須被注冊到DI容器中。
通常,你需要為view Model指定一個接口,這樣ViewModel的具體類型才能從view中解耦。

 
 
 
         
  1. public Shell()
  2. {
  3. InitializeComponent();
  4. }
  5. public Shell(ShellViewModel ViewModel):this()
  6. {
  7. this.DataContext = viewModel;
  8. }

當然,也可以定義一個只寫的屬性,Unity會實例化請求的View Model,並且調用屬性設置器來指定數據上下文。

 
 
 
         
  1. public Shell()
  2. {
  3. InitializeComponent();
  4. }
  5. [Dependency]
  6. public ShellViewModel ViewModel
  7. {
  8. set { this.DataContext = value; }
  9. }

注冊到Unity容器的代碼如下:

 
 
 
         
  1. IUnityContainer container;
  2. container.RegisterType<ShellViewModel>();

view 可以通過容器來進行實例化:

 
 
 
         
  1. IUnityContainer container;
  2. var view = container.Resolve<Shell>();

通過外部類來創建View和View Model

有些情況下,可能需要定義一個controller或服務類來實例化這些view和View Model類。比如實現導航功能:

 
 
 
         
  1. private void NavigateToQuestionnaireList()
  2. {
  3. this.uiService.ShowView(ViewNames.QuestionnaireTemplatesList);
  4. }

ShowView通過容器創建一個視圖實例,然后顯示它:

 
 
 
         
  1. public void ShowView(string viewName)
  2. {
  3. var view = this.ViewFactory.GetView(viewName);
  4. }

注意:Prism為region的導航提供了支持。詳情見View-Based Navigation。

測試MVVM應用

測試Model和View Model與測試其它類使用相同的工具和技術——比如單元測試和模擬框架。但是,對於Model和View Model有一些典型的測試模式。

測試INotifyPropertyChanged接口的實現類

該接口的實現類允許視圖對模型和視圖模型的改變做出反應。這些改變並不限於控件中的領域數據,還有控制視圖的數據,比如視圖模型的狀態(控制動畫的開始或控件的enable狀態)。

簡單情況

能夠被測試代碼直接更新的屬性,可以通過為PropertyChanged事件設置一個事件處理函數來進行測試,在為屬性設置一個新值,然后測試事件是否被觸發。有一些幫助類可以用來附加事件處理函數並且收集測試結果,比如PropertyChangeTracker類。

 
 
 
         
  1. var changeTracker = new PropertyChangeTracker(viewModel);
  2. viewModel.CurrentState = "newState";
  3. CollectionAssert.Contains(changeTracker.ChangedProperties, "CurrentState");

計算的和不可設定的屬性

如果屬性不能被測試代碼設置——比如屬性沒有公開的設置器或者是只讀的,計算的屬性——測試代碼需要模擬對象來引起屬性的改變。

 
 
 
         
  1. var changeTracker = new PropertyChangeTracker(viewModel);
  2. var question = viewModel.Questions.First() as OpenQuestionViewModel;
  3. question.Question.Response = "some text";
  4. CollectionAssert.Contains(changeTracker.ChangedProperties, "UnansweredQuestions");

整個對象

測試INotifyDataErrorInfo接口的實現類

實現綁定數據的輸入驗證有三種方式:在設置器中拋出異常、實現IDataErrorInfo接口、實現INotifyDataErrorInfo接口。第三種方式為每個屬性提供了多個錯誤報告的支持,同時意味着需要更多的測試。
INotifyDataErrorInfo接口有兩個方面需要測試:測試驗證規則的正確性和測試實現的接口,比如觸發ErrorsChanged事件。

測試驗證規則

驗證邏輯通常很容易進行測試,因為它是自包含的過程(輸出依賴於輸入)。對於每一個有驗證規則的屬性,需要測試合法值,非法值,邊界值等等。

 
 
 
         
  1. // 非法用例
  2. var notifyErrorInfo = (INotifyDataErrorInfo)question;
  3. question.Response = -15;
  4. Assert.IsTrue(notifyErrorInfo.GetErrors("Response").Cast<ValidationResult>().Any());
  5. // 合法用例
  6. var notifyErrorInfo = (INotifyDataErrorInfo)question;
  7. question.Response = 15;
  8. Assert.IsFalse(notifyErrorInfo.GetErrors("Response").Cast<ValidationResult>().Any());

跨屬性的驗證規則遵循相同的測試模式,一般需要更多的測試來組合不同的屬性值。

測試INotifyDataErrorInof接口的實現

實現INotifyDataErrorInfo接口必須確保ErrorsChanged事件在適當的時候被觸發、HasErrors屬性必須反應對象的整個錯誤狀態。
並不是所有被驗證的屬性都必須被測試。
測試接口的需要至少包括:

  • HasErrors屬性反映對象的全局錯誤狀態。為原先非法的屬性設置一個合法值,其它的屬性值保持非法,判斷該屬性的結果是否改變。
  • ErrorsChanged事件被觸發(當一個屬性的錯誤狀態改變時,通過GetErrors方法的結果反映)。錯誤狀態從一個合法狀態到非法狀態,還有反過來,還可以從一個非法狀態到另一個非法狀態,如果GetErrors的結果發生改變,說明ErrorsChanged事件被觸發了。

 
 
 
         
  1. var helper = new NotifyDataErrorInfoTestHelper<NumericQuestion, int?>(question, q => q.Response);
  2. helper.ValidatePropertyChange(
  3. 6,
  4. NotifyDataErrorInfoBehavior.FiresErrorsChanged
  5. | NotifyDataErrorInfoBehavior.HasErrors
  6. | NotifyDataErrorInfoBehavior.HasErrorsForProperty);
  7. helper.ValidatePropertyChange(
  8. null,
  9. NotifyDataErrorInfoBehavior.FiresErrorsChanged
  10. | NotifyDataErrorInfoBehavior.HasErrors
  11. | NotifyDataErrorInfoBehavior.HasErrorsForProperty);
  12. helper.ValidatePropertyChange(
  13. 2,
  14. NotifyDataErrorInfoBehavior.FiresErrorsChanged);

測試異步服務調用

雖然基於事件的異步設計模式(Event-based Asynchronous design pattern)能確保事件在合適的線程上調用,但是IAsyncResult設計模式不能提供任何線程安全的保證。
處理線程關注點很復雜,因此通常也很難編寫測試代碼。通常要求測試代碼本身也是異步的。當通知確實在UI線程發生時,不是因為使用了標准的基於事件的異步設計模式,還是因為視圖模型依賴於一個服務訪問層(分發通知到合適的線程),測試本質上都扮演了UI線程調度(Dispatch for the UI thread)的角色。
模擬服務的方式依賴於實現服務操作的異步事件模式。如果使用的是基於方法的模式(method-based based pattern),用標准的mock框架來模擬一個服務接口就足夠了;但是如果使用基於事件的模式(event-Based pattern),首選的方案是模擬一個定制的類(實現增加,移除服務處理事件的方法)。
下面的例子顯示了通過模擬服務,在完成異步操作后通知UI線程進行適當行為的一個測試。這個示例中,測試代碼獲取View Model為異步服務調用提供的回調,然后通過調用這個回調來模擬異步服務調用完成。這種方法使測試一個組件的異步服務而無需編寫復雜的異步測試。

 
 
 
         
  1. questionnaireRepositoryMock
  2. .Setup(
  3. r =>
  4. r.SubmitQuestionnaireAsync(
  5. It.IsAny<Questionnaire>(),
  6. It.IsAny<Action<IOperationResult>>()))
  7. .Callback<Questionnaire, Action<IOperationResult>>(
  8. (q, a) => callback = a);
  9. uiServiceMock
  10. .Setup(svc => svc.ShowView(ViewNames.QuestionnaireTemplatesList))
  11. .Callback<string>(viewName => requestedViewName = viewName);
  12. submitResultMock
  13. .Setup(sr => sr.Error)
  14. .Returns<Exception>(null);
  15. ComplateQuestionnaire(viewModel);
  16. viewModel.Submit();
  17. //模擬
  18. callback(submitResultMock.Object);
  19. //測試行為——請求導航到list 視圖
  20. Assert.AreEqual(ViewNames.QuestionnaireTemplatesList, requestedViewName);

注意:使用這種測試方法僅僅能保證覆蓋功能測試,並不能測試代碼是否是線程安全的。

擴展閱讀






免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM