`

WPF - Enhanced TabControl - TabControlEx aka Prerendering TabControl

阅读更多

As an opening word, let's check on the background  of the Prerendering tab controls. 

TabControlEx 写道
There is a known behavior of TabControl that it will "virtualize" its tabs when they are created via data binding. Only the visible tab actually exists and is bound to the selected data item. When selection changes, existing controls are reused and bound to new data context:
http://social.msdn.microsoft.com/Forums/en-US/wpf/thread/e1d95d22-ce08-4a9d-a244-31e69ac7c064

So in the case when the ContentTemplate of a TabControl is complicated and data binding takes time, its performance will be worse than the non-MVVM ways that programmatically creating TabItem.

To solve this while still keeping the MVVM pattern, we found a way to create a subclass named “TabControlEx” to change the behavior from “virtualize” to keeping the TabItem:
http://stackoverflow.com/questions/2193166/how-do-i-prerender-the-controls-on-a-tabitem-in-wpf

Hao has tried this solution with some modifications and it works fine. Also I have used the similar solution before of a application in some firm..

As the scenario of TabControl data-binding is very common during our work, so I think we can consider putting it in GuiToolKit or other shared lib.

 
The root case to this is that 


1. TabControl as the container has virutalization applied when the ControlTemplate is used. the optimization is basically tabItem will be reused and you might get delay when you switch tabs. and beside We have to take care of the styling and templating.

 

 

The solution

And the solution to this problem is TabControlEx, prerendering TabControl which will render all tabs and hide those which is not selected. (by setting the SeelctedItem and others)

 

As for the root cause of the issue, you can check on the reference page - How do I prerender the controls on a tabitem in wpfTabcontrol reuses contained controls;

 

And the page How do I prerender the controls on a tabitem in wpf tells you how to do the prerending of the tab items.


And the code is as below. 

    // check on : http://stackoverflow.com/questions/2193166/how-do-i-prerender-the-controls-on-a-tabitem-in-wpf

    /// <summary>
    /// The standard WPF TabControl is quite bad in the fact that it only
    /// even contains the current TabItem in the VisualTree, so if you
    /// have complex views it takes a while to re-create the view each tab
    /// selection change.Which makes the standard TabControl very sticky to
    /// work with. This class along with its associated ControlTemplate
    /// allow all TabItems to remain in the VisualTree without it being Sticky.
    /// It does this by keeping all TabItem content in the VisualTree but
    /// hides all inactive TabItem content, and only keeps the active TabItem
    /// content shown.
    /// </summary>

    [TemplatePart(Name = "PART_ItemsHolder", Type = typeof(Panel))]
    public class TabControlEx : TabControl
    {
        #region Data
        private Panel itemsHolder = null;
        #endregion 

        #region Ctor
        public TabControlEx() : base()
        {
            // this is necessary so that we get the initial databound selected item
            this.ItemContainerGenerator.StatusChanged += new EventHandler(ItemContainerGenerator_StatusChanged);
            this.Loaded += new RoutedEventHandler(TabControlEx_Loaded);
        }

        #endregion Ctor

        #region Public/Protected Methods
        /// <summary>
        /// get the ItemsHolder and generate any children
        /// </summary>
        public override void OnApplyTemplate()
        {
            base.OnApplyTemplate();
            itemsHolder = GetTemplateChild("PART_ItemsHolder") as Panel; // get Part as specified in the Control conract

            UpdateSelectedItem();
        }

        /// <summary>
        /// when the items change we remove any generated panel children and add any new ones as necessary
        /// </summary>
        /// <param name="e"></param>
        protected override void OnItemsChanged(System.Collections.Specialized.NotifyCollectionChangedEventArgs e) // NOTE where the NotifyCollectionChangedEventArgs belongs to (the namespace here)
        {
            base.OnItemsChanged(e);
            if (itemsHolder == null)
                return;
            switch (e.Action)
            {
                case System.Collections.Specialized.NotifyCollectionChangedAction.Reset:
                    itemsHolder.Children.Clear();
                    break;
                case System.Collections.Specialized.NotifyCollectionChangedAction.Add:
                case System.Collections.Specialized.NotifyCollectionChangedAction.Remove:
                    if (e.OldItems != null)
                    {
                        foreach (var item in e.OldItems)
                        {
                            ContentPresenter cp = FindChildContentPresenter(item);
                            if (cp != null)
                            {
                                itemsHolder.Children.Remove(cp); // remove the current switched out item and we wil take care of the items that come in
                            }
                        }
                    }
                    // don't do anything with new items because we don't want to
                    // create visuals that aren't being shown

                    UpdateSelectedItem();

                    break;
                case System.Collections.Specialized.NotifyCollectionChangedAction.Replace:
                    throw new NotImplementedException("Replace not implemented yet");
            }
        }

        /// <summary>
        /// update the visible child in the ItemsHolder
        /// </summary>
        /// <param name="e"></param>
        protected override void OnSelectionChanged(SelectionChangedEventArgs e)
        {
            base.OnSelectionChanged(e);
            UpdateSelectedItem();
        }

        /// <summary>
        /// copied from TabControl; wish it were protected in that class instead of private
        /// </summary>
        /// <returns></returns>
        protected TabItem GetSelectedTablItem()
        {
            object selectedItem = base.SelectedItem;
            if (selectedItem == null)
            {
                return null;
            }
            TabItem item = selectedItem as TabItem;
            if (item == null)
            {
                item = base.ItemContainerGenerator.ContainerFromIndex(base.SelectedIndex) as TabItem;
            }
            return item;
        }

       

        #endregion Public/Protected Methods

        #region Private Methods
        /// <summary>
        /// in some scenarios we need to update when loaded in case the 
        /// ApplyTemplate happens before the databind.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        void TabControlEx_Loaded(object sender, RoutedEventArgs e)
        {
            UpdateSelectedItem();
        }

        /// <summary>
        /// if containers are done, generate the selected item
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        void ItemContainerGenerator_StatusChanged(object sender, EventArgs e)
        {
            if (this.ItemContainerGenerator.Status == GeneratorStatus.ContainersGenerated) // The namespace of GeneratorStatus is from System.Windows.Control.Primitives
            {
                this.ItemContainerGenerator.StatusChanged -= ItemContainerGenerator_StatusChanged;
                UpdateSelectedItem();
            }
        }

        /// <summary>
        /// generate a ContentPresenter for the selected item
        /// </summary>
        private void UpdateSelectedItem()
        {
            if (itemsHolder == null)
            {
                return;
            }

            // Generate a ContentPresenter if necessary
            TabItem item = GetSelectedTablItem();
            if (item != null)
            {
                CreateChildContentPresenter(item);
            }
            // Show the right child
            foreach (ContentPresenter child in itemsHolder.Children)
            {
                child.Visibility = ((child.Tag as TabItem).IsSelected) ? Visibility.Visible : Visibility.Collapsed;
            }
        }

        /// <summary>
        /// create the child ContentPresenter for the given item (could be data or a TabItem)
        /// </summary>
        /// <param name="item"></param>
        /// <returns></returns>
        private ContentPresenter CreateChildContentPresenter(TabItem item)
        {

            if (item == null)
            {
                return null;
            }
            ContentPresenter cp = FindChildContentPresenter(item);

            if (cp != null)
            {
                return cp;
            }
            // the actual child to be added, cp.Tag is a reference to the TabItem.
            cp = new ContentPresenter();
            cp.Content = (item is TabItem) ? (item as TabItem).Content : item;
            cp.ContentTemplate = this.SelectedContentTemplate;
            cp.ContentTemplateSelector = this.SelectedContentTemplateSelector;
            cp.ContentStringFormat = this.SelectedContentStringFormat;
            cp.Visibility = System.Windows.Visibility.Collapsed;
            cp.Tag = (item is TabItem) ? item : (this.ItemContainerGenerator.ContainerFromItem(item));
            itemsHolder.Children.Add(cp);
            return cp;
        }

        /// <summary>
        /// Find the CP for the given object.  data could be a TabItem or a piece of data
        /// </summary>
        /// <param name="data"></param>
        /// <returns></returns>
        private ContentPresenter FindChildContentPresenter(object data)
        {
            if (data is TabItem)
            {
                data = (data as TabItem).Content;
            }
            if (data == null)
            {
                return null;
            }
            if (itemsHolder == null)
            {
                return null;
            }

            foreach (ContentPresenter cp in itemsHolder.Children)
            {
                if (cp.Content == data)
                {
                    return cp;
                }
            }
            return null;
        }

        #endregion Private Methods


    }

 

So basically the TabControlEx is done by subclassing the TabControl and Override/extends the methods that is pertaining to the ItemContainerGenerator methods/events. this is what you might have seen related to the 

 

this.ItemContainerGenerator.StatusChanged += new EventHandler(ItemContainerGenerator_StatusChanged);

 and 

            if (item == null)
            {
                item = base.ItemContainerGenerator.ContainerFromIndex(base.SelectedIndex) as TabItem;
            }

and the code depends on the successfully manipulation on the ChildContentPresenter. Which may include the following.

 

        private ContentPresenter CreateChildContentPresenter(TabItem item)
        {

            if (item == null)
            {
                return null;
            }
            ContentPresenter cp = FindChildContentPresenter(item);

            if (cp != null)
            {
                return cp;
            }
            // the actual child to be added, cp.Tag is a reference to the TabItem.
            cp = new ContentPresenter();
            cp.Content = (item is TabItem) ? (item as TabItem).Content : item;
            cp.ContentTemplate = this.SelectedContentTemplate;
            cp.ContentTemplateSelector = this.SelectedContentTemplateSelector;
            cp.ContentStringFormat = this.SelectedContentStringFormat;
            cp.Visibility = System.Windows.Visibility.Collapsed;
            cp.Tag = (item is TabItem) ? item : (this.ItemContainerGenerator.ContainerFromItem(item));
            itemsHolder.Children.Add(cp);
            return cp;
        }

 Well, for the rest of the code, you can reason out most of the logics. 

Well, getting the class extended is not yet done the job, we have to as well to define some template to use.  E.g basically you will need to set up the content holder and the rest. 

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:TabControlExLib"
    xmlns:ViewModels="clr-namespace:TabControlExLib.ViewModels"
    >

    
    
    <ControlTemplate x:Key="MainTabControlTemplateEx" TargetType="{x:Type local:TabControlEx}" >
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition x:Name="row0" Height="Auto" />
                <RowDefinition x:Name="row1" Height="4" />
                <RowDefinition x:Name="row2" Height="*" />
            </Grid.RowDefinitions>
            
            <!-- 
            
            Background definition is as follow
            
            Background="{StaticResource OutLookButtonHighlight}"
            -->
            <TabPanel x:Name="tabpanel"
                      Margin="0"
                      Grid.Row="0"
                      IsItemsHost="True" /> <!-- what does the isItemHost mean? does it mean ItemContainerGenerator -->
            <Grid x:Name="divider"
                  Grid.Row="1"
                  Background="Black"
                  HorizontalAlignment="Stretch"
                  />
            <Grid x:Name="PART_ItemsHolder"
                  Grid.Row="2" /> <!--  Grid layout control is a subclass of Panel? See the code for definition and Control contract-->
        </Grid>
        <!-- No Content Presenter -->        <!-- Content Presenter should be managed by the code, so we can create or delete Child from the ItemsHost explicitly-->
        <ControlTemplate.Triggers>
            <Trigger Property="TabStripPlacement" Value="Top"> <!-- Tabstrip is the strip where the tab's label is placed -->
                <Setter TargetName="tabpanel" Property="Grid.Row" Value="0" />
                <Setter TargetName="divider" Property="Grid.Row" Value="1" />
                <Setter TargetName="PART_ItemsHolder" Property="Grid.Row" Value="2" />
                <Setter TargetName="row0" Property="Height" Value="Auto" />
                <Setter TargetName="row1" Property="Height" Value="4" />
                <Setter TargetName="row2" Property="Height" Value="*" />
            </Trigger>
            <Trigger Property="TabStripPlacement" Value="Bottom">
                <Setter TargetName="tabpanel" Property="Grid.Row" Value="2" />
                <Setter TargetName="divider" Property="Grid.Row" Value="1" />
                <Setter TargetName="PART_ItemsHolder" Property="Grid.Row" Value="0" />
                <Setter TargetName="row0" Property="Height" Value="*" />
                <Setter TargetName="row1" Property="Height" Value="4" />
                <Setter TargetName="row2" Property="Height" Value="Auto" />
            </Trigger>
        </ControlTemplate.Triggers>
    </ControlTemplate>
</ResourceDictionary?

 

With this, you might as well define the Header template, so that each tab control can display some meaning information...

 

well, to do that, you first need to get the viewmodel right, suppose that each tab will be modelded in such an class called TabControlExViewModel.

 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.ComponentModel;
using System.Collections.ObjectModel;

namespace TabControlExLib.ViewModels
{
    public class TabControlExViewModel : INotifyPropertyChanged
    {

        public TabControlExViewModel()
        {

        }

        public string Name { get; set; }
        private ObservableCollection<string> _associatedNames = new ObservableCollection<string>();
        public ObservableCollection<string> AssociatedNames { get { return _associatedNames; } set { _associatedNames = value; } }


        #region INotifyPropertyChanged Implementation
        public event PropertyChangedEventHandler PropertyChanged;
        #endregion INotifyPropertyChanged Implementation
    }
}

 So, get back to our Header template, we can write as such .

    <!-- DataTemplate definition -->
    <!-- For details on the ItemsControl 
    Please check on this: http://msdn.microsoft.com/en-us/library/system.windows.controls.itemscontrol.aspx
    -->
    <DataTemplate DataType="{x:Type ViewModels:TabControlExViewModel}" x:Key="HeaderTemplate">
        <Grid>
            <TextBlock Text="{Binding Path=Name}" />
        </Grid>

    </DataTemplate>
    

 
And we might as well write a DataTemplate so that each Tab can have meaningful representation on the ViewModel.

    <!-- 
    we can also define implicit Data Template for the ViewModels:TabControlExViewModel 
    -->
    <DataTemplate DataType="{x:Type ViewModels:TabControlExViewModel}" >
        <Grid>
            <Grid.RowDefinitions>
                <!-- RowDefinition MaxHeight 
                MaxHeight="{DynamicResource {x:Static SystemParameters.WindowCaptionHeight}}"
                MaxHeight="{DynamicResource {x:Static SystemParameters.VerticalScrollBarButtonHeightKey}}"
                Height="0.00001*" 
                -->
                <RowDefinition MaxHeight="{DynamicResource {x:Static SystemParameters.ThickHorizontalBorderHeightKey}}"/>
                <RowDefinition Height="0.0001*"/>
            </Grid.RowDefinitions>
            <!-- this will be handled at the tabHeader -->
            <!--<TextBlock Text="{Binding Path=Name}" />-->
            <Line Grid.Row="0" />
            <!--<ItemsControl ItemsSource="{Binding Path=AssociatedNames}"
                          Grid.Row="2"
            />-->
            <ListView
            ItemsSource="{Binding Path=AssociatedNames}"
                Grid.Row="1"/>
        </Grid>
    </DataTemplate>

 

All those templates are defined in a resource file called ResourceDictionary.xaml file.

 

 To make for a demo, I have created the demo viewmodel, which composite a Collection of TabControlExViewModel. The code of which is as such.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Collections.ObjectModel;
// use Microsoft.Practises.Prism.ViewModel for the class "NotificationObject"
using Microsoft.Practices.Prism.ViewModel;

namespace TabControlExLib.ViewModels
{
    public class ExampleTabControlExViewModel : NotificationObject
    {
        #region Ctor
        public ExampleTabControlExViewModel()
        {
            Initialize();
        }
        #endregion Ctor


        #region Properties
        private ObservableCollection<TabControlExViewModel> _availableViewModels = new ObservableCollection<TabControlExViewModel>();
        public ObservableCollection<TabControlExViewModel> AvailableViewModels
        {
            get
            {
                return _availableViewModels;
            }
            set
            {
                if (Equals(value, _availableViewModels))
                {
                    return;
                }
                _availableViewModels = value;
            }

        }
        #endregion Properties


        #region Private Instance Methods
        // Mock the creation of the TablControlExViewModel collections
        private void Initialize()
        {
            List<TabControlExViewModel> viewmodels = new List<TabControlExViewModel>();

            viewmodels.Add(new TabControlExViewModel
            {
                Name = "Name1",
                AssociatedNames =
                {
                    "Associated Name1",
                    "Associated Name2",
                    "Associated Name3",
                }
            });

            viewmodels.Add(new TabControlExViewModel
            {
                Name = "Name2",
                AssociatedNames =
                {
                    "Associated Name3",
                    "Associated Name4",
                    "Associated Name5",
                }
            });

            AvailableViewModels = new ObservableCollection<TabControlExViewModel>(viewmodels);
            RaisePropertyChanged(() => AvailableViewModels); // Remembered that we have several ways to do NotifyPropertyChanged things.

        }
        #endregion Instance Methods
    }
}

 

and in the MainWindow.xaml file, we can have this:

<Window x:Class="TabControlExLib.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:TabControlExLib"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <!-- 
        
        Define and use MainTabControlTemplateEx to specify how does the control template looks like
        Deine the ItemTemplate to tell how to render the ViewModel, in this case the TabControlExViewModel
        
        Define the ItemsSource so that correct data binding is setup
        -->
        <local:TabControlEx
            IsSynchronizedWithCurrentItem="True"
            ItemsSource="{Binding Path=AvailableViewModels}"
            Template="{StaticResource MainTabControlTemplateEx}"
            ItemTemplate="{DynamicResource HeaderTemplate}"
            >
            
        </local:TabControlEx>
    </Grid>
</Window>

 and then in the constructor of the MainWindow, we can do proper initialization.

    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InstallThemes();
            InitializeComponent();
            Initialize();

        }

        private void Initialize()
        {
            
            this.DataContext = new ExampleTabControlExViewModel();
        }


        private void InstallThemes()
        {
            Resources.MergedDictionaries.Add(new ResourceDictionary() { Source = new Uri("/Themes/Generic.xaml", UriKind.RelativeOrAbsolute) } );
        }
    }

 Thus, if you run the code, you might see the following result.


 

You can find the zipped file - Download zip file.

References:

How do I prerender the controls on a tabitem in wpf

Tabcontrol reuses contained controls

  • 大小: 7.7 KB
分享到:
评论

相关推荐

    WPF - TabControl - Style用法

    学习在WPF当中如何使用Style定制TabControl的样式,包含TabControl的页面选项切换的简单动画效果。原始代码网上找的,我改了一些代码。

    WPF漂亮的TabControl样式

    在Windows Presentation Foundation(WPF)框架中,TabControl是一种常见的控件,用于展示多个视图或内容,用户可以通过标签页切换不同的视图。本资源聚焦于为TabControl创建美观、吸引人的样式,以提升用户体验和...

    gong-wpf-dragdrop, GongSolutions.WPF.DragDrop 库是WPF的拖动'n'拖放框架.zip

    gong-wpf-dragdrop, GongSolutions.WPF.DragDrop 库是WPF的拖动'n'拖放框架 简介GongSolutions.WPF.DragDrop 库是一个易于使用的拖拉'n'拖放框架。特性使用 MVVM: 拖放逻辑可以放在ViewModel中。 代码不需要放在in中...

    WPF漂亮的TabControl

    在Windows Presentation Foundation(WPF)中,TabControl是一个非常重要的控件,它允许用户通过标签页的形式展示多个相关的视图或内容。"WPF漂亮的TabControl"指的是通过自定义样式和模板,使得默认的TabControl...

    通用WPF主题控件包rubyer-wpf-master

    通用WPF主题控件包rubyer-wpf-master是一款专为Windows Presentation Foundation (WPF) 应用程序设计的开源UI框架。它提供了丰富的主题和控件,旨在帮助开发者快速构建美观且用户友好的应用程序界面。在2.0.0版本中...

    wpf-mvvm-DeskTop-Sample-master_C#_WPF_wpf客户端zfs_

    标题中的“wpf-mvvm-DeskTop-Sample-master”表明这是一个关于WPF(Windows Presentation Foundation)桌面应用程序的示例项目,使用了MVVM(Model-View-ViewModel)设计模式。这个项目是用C#编程语言编写的,面向的...

    WPF-Blockly-master.zip

    **WPF-Blockly** 是一个基于Windows Presentation Foundation (WPF) 的图形化编程工具,它为用户提供了构建和设计程序的直观界面。WPF作为Microsoft .NET Framework的一部分,主要用于构建桌面应用程序,它提供了...

    WPF模拟迅雷TabControl界面

    在本文中,我们将深入探讨如何使用Windows Presentation Foundation (WPF) 模拟迅雷软件中的TabControl界面,并且特别关注带有箭头的界面设计。迅雷作为一款流行的下载工具,其用户界面设计简洁、高效,尤其是...

    C#开发WPF-Silverlight动画及游戏系列教程-深蓝色右手 4

    C#开发WPF-Silverlight动画及游戏系列教程-深蓝色右手 C#开发WPF-Silverlight动画及游戏系列教程-深蓝色右手 C#开发WPF-Silverlight动画及游戏系列教程-深蓝色右手

    Wpf中用TabControl做的分页选项卡界面

    在Windows Presentation Foundation (WPF) 中,`TabControl` 是一个常用的控件,它允许开发者创建类似于浏览器或Office软件中的选项卡式用户界面。这个界面设计能够有效地组织和展示多个相关的视图或数据,提高用户...

    WPF-ControlBase-master.zip

    在这个名为"WPF-ControlBase-master.zip"的压缩包中,我们可以推测它包含了一个基于WPF的控制库项目,可能是一个开源或者个人开发的项目,用于提供自定义的WPF控件。这些控件可能是对标准WPF控件的扩展或增强,也...

    WPF 实现TabControl 中动态添加选项卡、动态添加Frame

    在Windows Presentation Foundation (WPF) 中,`TabControl`是一个常用控件,用于展示多个相互独立的内容区域,每个区域称为一个选项卡。动态添加选项卡和`Frame`是提高用户界面灵活性的重要方法,允许应用程序根据...

    WPF-Samples-master_WPF基本sample_

    WPF的基本空间历程,使用.net core3.0.1版本

    基于WPF的图形化编程控件和环境WPF-Blockly-master

    【标题】"基于WPF的图形化编程控件和环境WPF-Blockly-master" 提供了一个创新的编程体验,它将传统的代码编写转变为图形化的流程图形式,使得编程变得更加直观和易于理解。WPF(Windows Presentation Foundation)是...

    WPF的TabControl左侧的TabItem的Header纵线显示

    在Windows Presentation Foundation (WPF) 中,TabControl是一个常用的控件,用于展示多个可切换的视图。当TabItem的数量较多或者TabItem的标题较长时,为了更好的布局和用户体验,我们可能会考虑让TabItem的Header...

    WPF TabControl完美样式(在下方显示)+完美选择与切换

    **WPF TabControl详解** 在Windows Presentation Foundation (WPF) 中,TabControl是一个强大的控件,它允许用户在多个页面或视图之间进行导航。在设计用户界面时,TabControl经常用于组织和展示信息的不同部分,...

    bootstrap-wpf-style-master 样式

    Bootstrap-WPF 样式是一种将流行的前端框架 Bootstrap 的设计风格应用于 WPF(Windows Presentation Foundation)应用程序的方法。Bootstrap 是一个广泛使用的开源工具包,主要用于构建响应式、移动设备优先的网页...

    Prism-Samples-Wpf-master06-10.zip

    **Prism-Samples-Wpf-master06-10.zip** 是一个包含多个示例项目的压缩包,主要用于展示如何在Visual Studio 2017中使用 **WPF (Windows Presentation Foundation)** 和 **Prism** 框架进行开发。Prism是微软支持的...

    WPF TabControl 带关闭按钮

    在Windows Presentation Foundation (WPF) 中,`TabControl` 是一个常用的控件,它允许用户通过标签页的形式展示多个视图或数据。在某些应用程序设计中,为了提供更丰富的交互体验,我们可能希望在每个标签页上添加...

Global site tag (gtag.js) - Google Analytics