WPF - 深入 Style

Style 用來在類型的不同實例之間共享屬性、資源和事件處理程序,您可以將 Style 看作是將一組屬性值應用到多個元素的捷徑。 

  這是MSDN上對Style的描述,翻譯的還算中規中矩。Style(樣式),簡單來說,就是一種對屬性值的批處理,類似于Html的CSS,可以快速的設置一系列屬性值到UI元素。

 


示例


  一個最簡單的Style的例子:

<Window>
<Grid>
<Grid.Resources>
<Style TargetType="{x:Type Button}" x:Key="ButtonStyle">
<Setter Property="Height" Value="22"/>
<Setter Property="Width" Value="60"/>
</Style>
</Grid.Resources>
<Button Content="Button" Style="{StaticResource ButtonStyle}"/>
<Button Content="Button" Style="{StaticResource ButtonStyle}" Margin="156,144,286,145" />
</Grid>
</Window>

  關于Resources的知識,請參見MSDN,這里創建了一個目標類型為Button的ButtonStyle,兩個Button使用靜態資源(StaticResource)的查找方式來找到這個Style。Style中定義了Button的高度(Height)和寬度(Width),當使用了這個Style后,兩個Button無需手動設置,即可自動設置它們的高度和寬度為ButtonStyle的預設值22和60。

  Style作為屬性,資源,事件的批處理,它提供了一種捷徑來對控件進行快速設置,使用Style的好處有二:


  1. 把一些控件的通用設置抽出來變成Style,使這些控件具有統一的風格,修改Style中的屬性值可以方便的作用在所有應用該Style的控件上。
  2. 可以對同一類型控件定義多個Style,通過替換Style來方便的更改控件的樣式。

Style的元素


  上面Style的例子中,Style內部使用了Setter來定義控件屬性的預設值,Style不僅支持對屬性的批處理,也可以共享資源和事件處理,如:

<Window>
<Window.Resources>
<Style TargetType="{x:Type Button}" x:Key="ButtonStyle">
<Style.Resources>
<SolidColorBrush x:Key="brush" Color="Yellow"/>
</Style.Resources>
<Setter Property="Height" Value="22"/>
<Setter Property="Width" Value="60"/>
<EventSetter Event="Loaded" Handler="Button_Loaded"/>
</Style>
</Window.Resources>
<x:Code>
<![CDATA[
void Button_Loaded(object sender, RoutedEventArgs e)
{
MessageBox.Show((sender as Button).Name + " Loaded");
}
]]>
</x:Code>
<Grid>
<Button x:Name="button1" Style="{StaticResource ButtonStyle}" Background="{DynamicResource brush}"/>
<Button x:Name="button2" Style="{StaticResource ButtonStyle}" Background="{DynamicResource brush}" Margin="156,144,286,145" />
</Grid>
</Window>

  Style中定義了資源SolidColorBrush,定義了屬性Height和Width,以及使用了EventSetter來定義了Loaded事件的處理。

 


Trigger


  Style使用了Setter和EventSetter來分別設置控件的屬性和事件處理,Setter這個單詞的含義是設置。Style在設計好了這兩種設置后,又引入了更先進的思路:條件設置。

  對于單純的Setter:<Setter Property=”Height” Value=”22”>來說,含義淺顯易懂:設置高度為22。條件設置的含義是,在某種條件下,去設置某個對象的某個值。

  WPF引入了Trigger(触發器)來触發這個條件,它的寫法是:

<Style TargetType="{x:Type Button}" x:Key="ButtonStyle">
<Setter Property="Width" Value="60"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Width" Value="80"/>
</Trigger>
</Style.Triggers>
</Style>

  這里Trigger的含義是,在Button的IsMouseOver屬性被設置為True的條件下,設置Button的寬度(Width)為80。

  在Style中,不需要指定Setter作用的對象(TargetName),默認作用的對象就是使用該Style的控件。Trigger,作為触發器,當触發時設置寬度為80,當IsMouseOver屬性為False,也就是触發條件失效時,寬度回到默認Setter的設置值60。

  WPF定義了五種Trigger來作為触發條件,分別是:Trigger,DataTrigger,MultiTrigger,MultiDataTrigger,EventTrigger,他們的触發條件分別是:


  1. Trigger:以控件的屬性作為触發條件,如前面的IsMouseOver為True的時候触發。
  2. DataTrigger:以控件DataContext的屬性作為触發條件。
  3. MultiTrigger:以控件的多個屬性作為触發條件。
  4. MultiDataTrigger:以控件DataContext的多個屬性作為触發條件。
  5. EventTrigger:以RoutedEvent作為触發條件,當指定的路由事件Raise時触發。

  關于這5種Trigger的具體使用,請參見MSDN,這里就不詳細介紹了。

 


Implicit Style


  上面的例子中,都是使用StaticResource來設置Style的,當然,你也可以使用DynamicResource來設置Style。這兩種方式都需要你在XAML或者后台代碼中手動注明,為了使用方便,WPF提出了隱式(Implicit) Style的方式允許自動設置Style到控件,如:

<Window>
<Grid>
<Grid.Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Height" Value="22"/>
<Setter Property="Width" Value="60"/>
</Style>
</Grid.Resources>
<Button x:Name="button1" Style="{x:Null}"/>
<Button x:Name="button2" Margin="156,144,286,145" />
<Button x:Name="button3" Margin="196,144,0,145" />
</Grid>
</Window>

  在Gird的Resource中定義Style時,沒有給Style起名字(Key),這個Style會自動應用在Grid的所有子Button中,如果像button1一樣在Button中顯式定義了Style(這里設置了一個空值Null),那麼這種隱式(Implicit)的Style會不起作用。

 


深入Style


  Style是一個不錯的概念,作為一個Presentation的框架,把UI對象的結构,樣式和行為分離這是一種很好的設計。Style也比較容易上手,像它的隱式(Implicit)Style的設計也是水到渠成的想法,但實際使用中也會出現一些問題。這些問題在WPF中也會經常遇見:概念不錯,描述簡單,前景美好,Bug稀奇古怪,要把這些問題說清楚,就要從根本來看,Style是個什麼東西?

  按照通常的想法,Style應該類似于一個Dictionary<string, object> setters,預存了屬性的名字和預設值,然后作用到UI對象上。WPF在Style處的想法很多,圍繞著几個關鍵技朮也加入了很多功能,詳細的介紹一下:

 


Style & Dependency Property


  Dependency Property(簡稱DP)是WPF的核心,Style就是基于Dependency Property的,關于DP的內幕,請參見深入WPF--依賴屬性。Style中的Setter就是作用在DP上的,如果你在控件中定義了一個CLR屬性,Style是不能設置的。Dependency Property設計的精髓在于把字段的存取和對象(Dependency Object)剝離開,一個屬性值內部用多個字段來存儲,根据取值條件的優先級來決定當前屬性應該取哪個字段。

  Dependency Property取值條件的優先級是(從上到下優先級從低到高):

public enum BaseValueSource
{
Unknown,
Default,
Inherited,
DefaultStyle,
DefaultStyleTrigger,
Style,
TemplateTrigger,
StyleTrigger
ImplicitStyleReference,
ParentTemplate,
ParentTemplateTrigger,
Local
}

  對于一個具體例子來說:

<Window>
<Window.Resources>
<Style TargetType="{x:Type Button}" x:Key="ButtonStyle">
<Setter Property="Width" Value="60"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Width" Value="80"/>
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Grid>
<Button x:Name="button1" Style="{StaticResource ButtonStyle}" Background="{DynamicResource brush}" Width="20"/>
</Grid>
</Window>

  第4行用Style的Setter設置Width=60,這個優先級是Style;第6行當IsMouseOver為True時設置Width=80,這個優先級是StyleTrigger;第13行使用Style的Button定義Width=20,這個優先級是Local。Local具有最高的優先級,所以即使鼠標移到Button上,第6行的Trigger也會因為優先級不夠高而不起作用。如果去掉了第13行中的Width=20,那麼鼠標移到Button上時Width會變為80,鼠標移開后會回到第4行的設置的60來。

 


Style & FrameworkElement


  Style作為一個屬性定義在FrameworkElement上,所有繼承自FrameworkElement的控件都可以使用Style。FrameworkElement定義了多個Style:Style,ThemeStyle,FocusVisualStyle:


  1. FocusVisualStyle:是當控件獲得鍵盤焦點時,顯示在外面的一個虛線框,這個Style並沒有直接作用在對應的FrameworkElement上,而是當控件獲得鍵盤焦點時使用AdornLayer創建了一個新的Control,然后再這個Control上使用FocusVisualStyle,再把它遮蓋在對應的FrameworkElement上形成一個虛線框的效果。
  2. Style:就是我們前面一直設置的Style。
  3. ThemeStyle:這里引入了一個Theme的概念,具體來談一下它。

  Windows定了很多Theme(主題),你可以在控制面板中切換Theme,如圖:

Theme

  最上面的兩排都屬于Aero主題,當從Aero主題切換到Windows Classic主題后,任務欄,窗口以及窗口內的控件外觀都會發生變化。為了更好的切換主題,WPF引入了ThemeStyle這個概念。當我們使用VS2010的模板生成一個自定義控件(Custom Control)后,會自動添加一個Themes的文件夾以及一個Generic.xaml的文件,如圖:

generic

  這里的Aero.NormalColor.xaml是手動添加的,先略去不談,來談談控件(Control)的默認樣式。

  WPF默認提供了很多控件,Button,ListBox,TabControl等等,我們使用這些控件時,是沒有指定它的樣式(Style)的,WPF為我們提供了默認Style,這個默認Style是與Windows主題相關的。比如我們切換Windows的主題從Aero到Classic,WPF窗口里的控件外觀也會發生變化。這些默認的Style是以ResourceDictionary的形式保存在PresentationFramework.Aero.dll,PresentationFramework.Classic.dll等dll中的,這里的命名規則是:程序集名稱+Theme名稱+.dll。

  那麼WPF又是如何根据Windows的Theme找到對應的ThemeStyle呢?WPF提出了ThemeInfo這個Attribute來指定Theme信息。ThemeInfo一般定義在Properties/AssemblyInfo.cs中,如:

[assembly: ThemeInfo(
ResourceDictionaryLocation.SourceAssembly,
ResourceDictionaryLocation.SourceAssembly)
)]

  ThemeInfo有兩個參數,第一個參數指的是ThemeResource,第二個參數指的是GenericResource,它們的類型是ResourceDictionaryLocation:

public enum ResourceDictionaryLocation
{
None = 0,
SourceAssembly = 1,
ExternalAssembly = 2
}

  ResourceDictionaryLocation的None指不存在對應的Resource,SourceAssembly指該程序集(Assembly)中存在對應的Resource,ExternalAssembly指對應的Resource保存在外部的程序集(Assembly)中,這個外部程序集的查找規則就是我們前面看到的:程序集名稱+Theme名稱+.dll。

  對于一個控件,無論是系統自帶的控件還是我們自定義的控件,WPF啟動時都會通過當前Windows系統的Theme查找它對應的ThemeStyle。這個查找規則是:


  1. 先通過控件的類型(Type)找到它對應的程序集(Assembly),然后獲取程序集中的ThemeInfo,看看它的ThemeResource和GenericResource在哪里。如果ThemeResource的值不是None,系統會讀取到ThemeResource對應的ResourceDictionary,在這個ResourceDictionary中查找是否定義了TargetType={x:Type 控件類型},如果有,把控件的ThemeStyle指定為這個Style。
  2. 如果第一步的查找失敗,那麼GenericResource派上用場,Generic這個詞表示一般。WPF會查看ThemeInfo的第二個參數GenericResource來查找它的ThemeStyle,查找規則同第一步,如果查找成功,把這個Style指定為控件的ThemeStyle。

  任意一個控件,如果不顯式指定它的Style,並且查不到默認的ThemeStyle,這個控件是沒有外觀的。為了編程方便,當我們使用VS添加自定義控件時,VS默認幫我們生成了Generic.xaml,如果我們希望自定義的控件也要支持系統的Theme變化,可以在Themes這個文件夾下加入對應的ResourceDictionary,比如上面的Aero.NormalColor.xaml,並且指定程序集ThemeInfo的第一個參數為SourceAssembly,表明該程序集支持系統Theme變化並且對應的資源文件在該程序集中。當然,ResourceDictionary一定要放在Themes文件夾下,因為WPF查找ResourceDictionary時使用的是類似:

string relativePackUriForResources = "/" +
themeAssemblyName.FullName +
";component/themes/" +
themeName + "." +
colorScheme + ".xaml";

這樣的方法。

 


Style & ResourceDictionary


  前面提到了很多次ResourceDictionary,關于WPF的Resource系統,以后再來細談。WPF的Resource系統使用ResourceDictionary來儲存Resource,ResourceDictionary,顧名思義,也是一個Dictionary,既然是Dictionary,就是按鍵/值對來存儲的。我們最前面在Window的Resource中創建Style時,指定了Style對應的鍵值(x:Key),后面又用StaticResource來引用這個鍵值。

  如果在ResourceDictionary中添加一個對象Button,不指定它的鍵值(x:Key),是不能通過編譯的。我們前面介紹的隱式(Implicit)Style,只指定了一個TargetType={x:Type  類型},並沒有指定鍵值,為什麼它可以通過編譯呢?

  對于在ResourceDictionary中添加Style,如果我們沒有指定鍵值(x:Key),WPF會默認幫我們生成鍵值,這個鍵值不是一個String,而是一個類型object(具體來說是Type實例),也就是說相當于:

<Style TargetType="{x:Type Button}" x:Key="{x:Type Button}">

后面的x:Key可以省略掉。

  Appliation以及FrameworkElement類都定義了Resources屬性,內部都持有一個ResourceDictionary,Resource查找遵循的最基本原則是就近原則,如:

<Window>
<Window.Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Background" Value="Yellow"/>
</Style>
<Style TargetType="{x:Type ToggleButton}" x:Key="toggleBtnStyle">
<Setter Property="Background" Value="Red"/>
</Style>
</Window.Resources>
<StackPanel>
<StackPanel.Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Background" Value="Blue"/>
</Style>
<Style TargetType="{x:Type ToggleButton}" x:Key="toggleBtnStyle">
<Setter Property="Background" Value="Green"/>
</Style>
</StackPanel.Resources>
<ToggleButton Width="80" Height="20" Style="{DynamicResource toggleBtnStyle}"/>
<Button Width="80" Height="20" Content="button2" Click="Button_Click"/>
</StackPanel>
</Window>

  Window和StackPanel的Resources中都分別定義了toggleBtnStyle以及隱式Style(Button),根据就近原則,StackPanel內部的ToggleButton和Button會應用StackPanel的Resource而不會使用Window的。

 


Style Merge


  這里要提到本篇的重點也是不被人注意卻經常出錯的地方,Style的合並(Merge)。

  前面提到了很多Style,ThemeStyle,Style,隱式Style。我們提過,Style相當于一個屬性值的批處理,那麼對于一個屬性,只能有一個預設值而不能多個,這些Style在運行時要進行合並,然后作用在FrameworkElement上。

  Style的合並,要分兩步進行:


  1. 找到所有Style。
  2. 确定Style的優先級,根据優先級來合並Style。

  以Button來說:


  1. 如果當前Windows的Theme是Aero,啟動后會從PresentationFramework.Aero.dll中找到對應的ThemeStyle。
  2. 如果在Button上使用StaticResource或者DynamicResource指定了Style,會通過鍵值在Resource系統中找到對應的Style。
  3. 如果沒有在Button上顯式指定Style,會通過Resource系統查找隱式Style(x:Type Button)。
  4. 第二步和第三步是排他的,這兩步只能确定一個Style,然后把這個Style和ThemeStyle進行合並(Merge)得到Button最終的效果。

  先從合並來說,顯式或者隱式Style的優先級是高于ThemeStyle的,如果Style和ThemeStyle的Setter中都對同一屬性進行了預設,那麼會取Style里面的Setter而忽略ThemeStyle。這里比較特殊的是EventSetter,EventSetter使用的是RoutedEvent,如果兩個Style的EventSetter對同一個RoutedEvent進行了設置,兩個都會注冊到RoutedEvent上。

  前面看到,顯式和隱式Style是排他的,兩者只能取一,在實際項目中,在全局定義好Button的基本樣式,然后具體使用上再根据基本樣式做一些特殊處理,這種需求是很常見的。為了解決這種需求,Style提出了BasedOn屬性,來表示繼承關系,如:

<Window>
<Window.Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Width" Value="80"/>
<Setter Property="Height" Value="20"/>
<EventSetter Event="Click" Handler="btnBase_Click"/>
</Style>
<Style TargetType="{x:Type ButtonBase}" x:Key="toggleBtnStyle">
<Setter Property="Width" Value="80"/>
<Setter Property="Height" Value="20"/>
<Setter Property="Background" Value="Red"/>
</Style>
</Window.Resources>
<StackPanel>
<StackPanel.Resources>
<Style TargetType="{x:Type Button}" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="Blue"/>
<EventSetter Event="Click" Handler="btn_Click"/>
</Style>
<Style TargetType="{x:Type ToggleButton}" x:Key="toggleBtnStyle" BasedOn="{StaticResource toggleBtnStyle}">
<Setter Property="Background" Value="Green"/>
</Style>
</StackPanel.Resources>
<ToggleButton Style="{DynamicResource toggleBtnStyle}"/>
<Button Content="button2"/>
</StackPanel>
</Window>

  為了更清晰的解釋,給出了一個不太常見的例子。第16行創建了一個隱式Style(Button),它的BasedOn屬性仍然是隱式Style(Button),Resource系統會向上查找找到Window的Resorces中的隱式Style(Button),然后把兩者合並。對于同一個ResourceDictionary,是不允許有重復鍵值的,StackPanel和Window各有各自的ResourceDictionary,他們的鍵值不受干擾,查找時會通過就近原則來找到優先級最高的Resource。第20行ToggleButton的例子和Button是一樣的,只是它查找到的第8行toggleBtnStyle的TargetStyle是ButtonBase,ButtonBase是ToggleButton的基類,BasedOn屬性也可以作用。

  WPF的Style机制是一個密封(Seal)机制,它的書寫方式很靈活,可以支持合並等,當最后合並后,Style就被密封(Seal),內部的Setter等不允許再被修改。這種密封的設計有它的道理,但在Style的動態性上就稍顯不足。

  以自定義控件為例,自定義一個Button,名字叫MyButton,它繼承自Button,在自定義控件中,經常可以看到這樣的代碼:

static MyButton()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(MyButton), new FrameworkPropertyMetadata(typeof(MyButton)));
}

  這里出現了DefaultStyle,這個是WPF對ThemeStyle的另一個說法,ThemeStyle就是用來确定默認的Style的,后來包括BaseValueSource中也使用了DefaultStyle來表示ThemeStyle。在MyButton的靜態函數中重載DefaultStyleKeyProperty內部Metadata的含義是告訴WPF系統,查找MyButton的ThemeStyle使用的鍵值從{x:Type Button}被改成了{x:Type MyButton}。

  如果像上述代碼一樣修改了DefaultStyleKeyProperty,那麼需要我們在Themes/Generic.xaml中定義好MyButton的默認(Theme)Style,否則MyButton是沒有外觀的,因為查找ThemeStyle的鍵值已經被修改,PresentationFramework.Aero.dll等dll中是沒有定義{x:Type MyButton}的。

  前面是關于ThemeStyle的用法,那麼回到隱式Style上來,如果我們在Application的Resources中定義了Button的隱式Style(TargetType={x:Type Button}),即使沒有顯式設置MyButton的Style,所有的MyButton控件也不會使用這個隱式Style的。需要你在Application的Resources中,在定義Button隱式Style的下面定義

<Style TargetType="{x:Type local:MyButton}" BasedOn="{StaticResource {x:Type Button}}"/>

  這里就回到Style的合並(Merge)上來了,Style的Merge是很基本(很傻)的合並(Merge),它不具備Auto性。具體來說,就是:


  1. 基類控件的隱式Style不會作用到派生類控件上。
  2. 像前面在Window和StackPanel中分別定義了隱式Style(Button),這兩個隱式Style不會智能合並后再作用到Button上,而是通過就近原則只選其一。
  3. Style的BasedOn屬性只支持StaticResource方式引用,因為Style繼承自DispatcherObject而不是DependencyObject,DynamicResource只支持DP。

  這些問題都需要通過Style的BasedOn來解決,因為BasedOn用的是靜態引用(StaticResource),當隱式Style發生變化時就有麻煩了。

 


換膚


  UI程序的換膚是很炫的玩意,換膚分兩種:1,更換整個控件的Style;2,更換Style中的顏色畫刷(Brush)。后者的實現很簡單,定義好顏色畫刷的資源文件(ResourceDictionary),使用畫刷的時候使用DynamicResource綁定,換膚的時候替換畫刷的資源文件就可以了。

  很多公司都有自己皮膚庫,這些皮膚庫一般都是隱式的Style,定義了所有控件的隱式Style,使用時把這個皮膚資源Merge到Application的Resources中。換膚時把舊的皮膚資源從Application的Resources中刪除,替換成新的皮膚資源ResourceDictionary。

  這種做法很好理解,但是碰到Style的BasedOn屬性就不起作用了,BasedOn屬性使用是StaticResource,是靜態的一次性的。新的皮膚庫被添加到Application資源文件后,如果在Application的資源文件中已經定義過<Style TargetType=“{x:Type Button}” BasedOn=“{StaticResource {x:Type Button}}”/>這樣隱式的Style,控件是不會更新皮膚的。如果有這方面的需求,需要手動合並(Merge)Style來解決問題,類似:

public static void Merge(this Style style, Style otherStyle)
{
foreach (SetterBase currentSetter in otherStyle.Setters)
{
style.Setters.Add(currentSetter);
}

foreach (TriggerBase currentTrigger in otherStyle.Triggers)
{
style.Triggers.Add(currentTrigger);
}

foreach (object key in otherStyle.Resources.Keys)
{
style.Resources[key] = otherStyle.Resources[key];
}
}

  這里還需要加上一些條件判斷,以及決定是否要遞歸合並otherStyle的BasedOn,回到前面,程序需要使用DynamicResource來監听Application資源中隱式Style的變化,用一個附加屬性來解決:

public static readonly DependencyProperty AutoMergeStyleProperty =
DependencyProperty.RegisterAttached("AutoMergeStyle", typeof(Type), typeof(Behavior),
new FrameworkPropertyMetadata((Type)null,
new PropertyChangedCallback(OnAutoMergeStyleChanged)));

private static void OnAutoMergeStyleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue == e.NewValue)
{
return;
}

FrameworkElement control = d as FrameworkElement;
if (control == null)
{
throw new NotSupportedException("AutoMergeStyle can only used in FrameworkElement");
}

Type type = e.NewValue as Type;
if (type != null)
{
control.SetResourceReference(Behavior.BaseOnStyleProperty, type);
}
else
{
control.ClearValue(Behavior.BaseOnStyleProperty);
}
}

  SetResourceReference是XAML中DynamicResource的代碼表示,相當于Behavior.BaseOnStyle={DynamicResource type}。對控件使用SetResourceReference,監听的鍵值是type,監听的屬性是一個我們自定義的附加屬性BaseOnStyleProperty。當換膚替換Application的資源文件時,BaseOnStyle屬性被更新,在BaseOnStyleProperty的Changed事件中可以讀取控件的Style屬性和新的ThemeStyle,調用Merge方法Merge兩者然后再設置到控件的Style屬性上。

 


總結


  WPF中Style的設計中規中矩,把UI對象樣式和結构分離是它的最初想法,其中也加入了Trigger等一些好的設計,但在使用中還是會出現一些問題,它本身也不是那麼智能完美。希望朋友們都能從內到外的看待Style,更好的玩轉它。

 


閑話


  這個深入WPF系列也寫了好几篇了,比起用嘴上白話一通,寫文章需要更多的耐心和細致。講解有很多境界:把簡單的東西講復雜;把復雜的東西講復雜;把復雜的東西講簡單;把復雜的東西講簡單,而且還有詩情哲理。我達不到那麼高的境界,希望能做到直接不回避的把技朮主線講清楚,也希望能更多的听到朋友們的反饋,我會繼續補充,爭取把這個系列寫好。

  謝謝支持,謝謝您頂一下。 ^_^

作者:周永恆
出處:http://www.cnblogs.com/Zhouyongh

留言

這個網誌中的熱門文章

C# – M$ Chart Control 自定 ToolTip 的顯示

Vue.js - 基礎介紹教學