使用ViewModel模式简化WPF TreeView

目录

介绍

TreeView的背景

ViewModel的背景

究竟是什么让TreeView如此困难?

ViewModel来救援

演示解决方案

演示 1 – 带有文本搜索的家谱

PersonViewModel

用户界面

FamilyTreeViewModel

演示 2 – 按需加载的地理细分

结论


 

介绍

本文探讨如何使用ViewModel模式来更轻松地使用WPF中的TreeView控件。在此过程中,我们研究了为什么人们在使用WPF TreeView时经常遇到困难、什么是ViewModel,以及展示如何将TreeViewViewModel结合的两个演示应用程序。其中一个演示展示了如何创建可搜索的TreeView,另一个演示如何实现延迟加载(也称为按需加载)。

TreeView的背景

WPF中的TreeView控件获得了不应有的坏名声。许多人尝试使用它,发现它非常困难。问题是人们经常尝试以与针对Windows窗体控件TreeView编写代码相同的方式使用它。为了利用WPF TreeView的广泛功能,您不能使用与Windows窗体中相同的编程技术。这是WPF如何要求您转换思维方式以适当地利用平台的另一个例子。

Window窗体中,使用TreeView控件非常容易,因为它非常简单。这种简单性源于以下事实:Windows窗体TreeView完全不灵活,不支持UI虚拟化,对可视化自定义提供零可能性,并且由于它不支持数据绑定,因此需要您将数据存储在其节点中。WinForms TreeView 对于政府工作来说已经足够好了

相比之下,WPF TreeView极其灵活,固有地支持UI虚拟化(即,TreeViewItem是按需创建的),允许完全可视化定制,并且完全支持数据绑定。这些出色的功能是有代价的。它们使TreeView控件比WinForms更复杂。一旦您学会了如何正确使用 WPF TreeView,这些复杂性就消失了,并且很容易利用控件的全部功能。

ViewModel的背景

早在2005年,John Gossman在博客中谈到了他在Microsoft的团队用来创建Expression Blend(当时称为“Sparkle”)的模型-视图-视图模型模式。它与Martin Fowler的Presentation Model模式非常相似,只是它利用WPF丰富的数据绑定填补了表示模型​​和视图之间的空白。在Dan Crevier撰写了他精彩的DataModel-View-ViewModel系列博文之后,(D)MVVM模式开始流行起来。

(Data)Model-View-ViewModel模式与经典的Model-View-Presenter相似,只是您有一个为View量身定制的模型,称为ViewModelViewModel包含所有必要的特定于UI的界面和属性,以便于开发用户界面。View绑定到ViewModel,并执行命令以向其请求操作。ViewModel反过来与Model通信,并告诉它更新以响应用户交互。

这使得为​​应用程序创建用户界面(UI)变得更加容易。在应用程序上添加UI越容易,对于技术有挑战的视觉设计师来说,在Blend中创建漂亮的UI就越容易。此外,UI与应用程序功能的耦合越松散,该功能就越可测试。谁不想要一个漂亮的UI和一套干净、有效的单元测试?

究竟是什么让TreeView如此困难?

只要您以正确的方式使用它,它实际上很容易使用TreeView。矛盾的是,以正确的方式使用它意味着根本不直接使用它!当然,你需要设置属性并直接在TreeView中调用临时方法。这是不可避免的,这样做并没有错。但是,如果您发现自己深入控件的核心,那么您可能没有采取最好的方法。如果你的TreeView是数据绑定的,并且您发现自己试图以编程方式上下移动项,那么您做事的方式不正确。如果您发现自己正在挂钩ItemContainerGeneratorStatusChanged事件,以便您可以访问最终被创建时的TreeViewItem子项,你就偏离了轨道!相信我它不必如此丑陋和困难。有一个更好的方法!

对待WPF TreeView和对待WinForms TreeView的基本问题是,正如我前面提到的,它们是非常不同的控件。WPF TreeView允许您通过数据绑定生成其项。这意味着它将为您创建TreeViewItem。由于TreeViewItem是由控件而不是由您生成的,因此不能保证数据对象的对应项TreeViewItem在您需要时存在。您必须询问TreeViewItemContainerGenerator是否已经为您生成了TreeViewItem。如果没有,您必须挂钩它的StatusChanged事件,以便在它创建其子元素时得到通知。

乐趣不止于此!如果您想获得嵌套在树中深处的一个TreeViewItem,您必须询问该项的父级/自己的TreeViewItem,而不是TreeView控件,如果ItemContainerGenerator创建了该项。但是,如果其父级TreeViewItem尚未创建它,您如何获得对其的引用?如果还没有生成父级的父级怎么办?依此类推,依此类推,依此类推。这可能是相当折磨人的。

如您所见,WPF TreeView是一个复杂的野兽。如果您尝试以错误的方式使用它,那将并不容易。幸运的是,如果你以正确的方式使用它,它就是小菜一碟。那么,让我们看看如何正确使用它……

ViewModel来救援

WPF很棒,因为它实际上要求您将应用程序的数据与UI分开。上一节中列出的所有问题都源于试图违背常规并将UI视为后备存储。一旦您不再将其TreeView视为放置数据的地方,而是开始将其视为展示数据的地方,一切都会开始顺利进行。这就是ViewModel的想法发挥作用的地方。

与其编写在TreeView中的项目上下移动的代码,不如创建一个TreeView绑定到的ViewModel,然后编写操作您的ViewModel的代码。这不仅可以让您忽略TreeView的复杂性,还可以让您编写可以轻松进行单元测试的代码。为与TreeView的运行时行为密切相关的类编写有意义的单元测试几乎是不可能的,但是为对这种废话一无所知的类编写单元测试很容易。

现在,是时候看看如何实现这些概念了。

演示解决方案

本文附带两个演示应用程序,可在本页顶部下载。该解决方案有两个项目。BusinessLib类库项目包含简单的领域类,仅用作数据传输对象。它还包含一个实例化并返回这些数据传输对象的Database类。另一个项目TreeViewWithViewModelDemo包含示例应用程序。这些应用程序使用BusinessLib程序集返回的对象,并将它们包装在ViewModel中,然后再将它们显示在TreeView中。

这是解决方案的解决方案资源管理器的屏幕截图:

演示 1 – 带有文本搜索的家谱

我们将检查的第一个演示应用程序使用家谱填充TreeView。它提供了一种搜索功能,在UI底部可供用户使用。该演示可以在下面的屏幕截图中看到:

 

当用户输入一些搜索文本并按Enter或单击查找按钮时,将显示第一个匹配项。继续搜索将循环通过每个匹配的项目。所有这些逻辑都在ViewModel中。在深入了解ViewModel的工作原理之前,让我们先检查一下周围的代码。这是TextSearchDemoControl的代码隐藏:

public partial class TextSearchDemoControl : UserControl
{
    readonly FamilyTreeViewModel _familyTree;  

    public TextSearchDemoControl()
    {
        InitializeComponent();

        // Get raw family tree data from a database.
        Person rootPerson = Database.GetFamilyTree();

        // Create UI-friendly wrappers around the 
        // raw data objects (i.e. the view-model).
        _familyTree = new FamilyTreeViewModel(rootPerson);

        // Let the UI bind to the view-model.
        base.DataContext = _familyTree;
    }

    void searchTextBox_KeyDown(object sender, KeyEventArgs e)
    {
        if (e.Key == Key.Enter)
            _familyTree.SearchCommand.Execute(null);
    }
}

构造函数展示了我们如何将原始数据对象转换为ViewModel,然后设置为UserControlDataContext。该类Person位于BusinessLib程序集中,非常简单:

/// <summary>
/// A simple data transfer object (DTO) that contains raw data about a person.
/// </summary>
public class Person
{
    readonly List<Person> _children = new List<Person>();
    public IList<Person> Children
    {
        get { return _children; }
    }

    public string Name { get; set; }
}

PersonViewModel

由于Person类是应用程序的数据访问层返回的内容,因此它绝对不适合UI使用。每个Person对象最终都会被PersonViewModel类的一个实例包装,使其具有扩展的语义,例如被扩展和选择。如上所示,该类FamilyTreeViewModel启动了将Person对象包装在PersonViewModel对象内部的过程,如该类的构造函数所示:

public FamilyTreeViewModel(Person rootPerson)
{
    _rootPerson = new PersonViewModel(rootPerson);

    _firstGeneration = new ReadOnlyCollection<PersonViewModel>(
        new PersonViewModel[] 
        { 
            _rootPerson 
        });

    _searchCommand = new SearchFamilyTreeCommand(this);
}

私有PersonViewModel构造函数递归地遍历族谱,将每个Person对象包装在一个PersonViewModel.中,这些构造函数如下所示:

public PersonViewModel(Person person)
    : this(person, null)
{
}

private PersonViewModel(Person person, PersonViewModel parent)
{
    _person = person;
    _parent = parent;

    _children = new ReadOnlyCollection<PersonViewModel>(
            (from child in _person.Children
             select new PersonViewModel(child, this))
             .ToList<PersonViewModel>());
}

PersonViewModel有两种成员:与表示相关的成员和与Person的状态相关的成员。表示属性是TreeViewItem将绑定的内容,状态属性由TreeViewItem的内容绑定。表示属性之一,IsSelected,如下所示:

/// <summary>
/// Gets/sets whether the TreeViewItem 
/// associated with this object is selected.
/// </summary>
public bool IsSelected
{
    get { return _isSelected; }
    set
    {
        if (value != _isSelected)
        {
            _isSelected = value;
            this.OnPropertyChanged("IsSelected");
        }
    }
}

此属性与person无关,而只是用于将ViewViewModel同步的状态。请注意,该属性的setter调用了一个OnPropertyChanged方法,该方法最终引发了对象的PropertyChanged事件。该事件是INotifyPropertyChanged接口的唯一成员。INotifyPropertyChanged是一个特定于UI的接口,这就是PersonViewModel类实现它的原因,而不是Person类。

PersonViewModel上的演示成员的一个更有趣的例子是IsExpanded属性。这个属性很容易解决确保数据对象的对应TreeViewItem在必要时被扩展的问题。请记住,当直接针对TreeView自身进行编程时,这些类型的问题可能非常棘手且难以处理。

/// <summary>
/// Gets/sets whether the TreeViewItem 
/// associated with this object is expanded.
/// </summary>
public bool IsExpanded
{
    get { return _isExpanded; }
    set
    {
        if (value != _isExpanded)
        {
            _isExpanded = value;
            this.OnPropertyChanged("IsExpanded");
        }

        // Expand all the way up to the root.
        if (_isExpanded && _parent != null)
            _parent.IsExpanded = true;
    }
}

正如我之前提到的,PersonViewModel还具有与其底层Person对象的状态相关的属性。这是一个例子:

public string Name
{
    get { return _person.Name; }
}

用户界面

绑定到PersonViewModel树的TreeViewXAML非常简单。请注意,TreeViewItemPersonViewModel对象之间的连接在于控件的ItemContainerStyle:

<TreeView ItemsSource="{Binding FirstGeneration}">
  <TreeView.ItemContainerStyle>
    <!-- 
    This Style binds a TreeViewItem to a PersonViewModel. 
    -->
    <Style TargetType="{x:Type TreeViewItem}">
      <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
      <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
      <Setter Property="FontWeight" Value="Normal" />
      <Style.Triggers>
        <Trigger Property="IsSelected" Value="True">
          <Setter Property="FontWeight" Value="Bold" />
        </Trigger>
      </Style.Triggers>
    </Style>
  </TreeView.ItemContainerStyle>

  <TreeView.ItemTemplate>
    <HierarchicalDataTemplate ItemsSource="{Binding Children}">
      <TextBlock Text="{Binding Name}" />
    </HierarchicalDataTemplate>
  </TreeView.ItemTemplate>
</TreeView>

该演示的UI的另一部分是搜索区域。该区域为用户提供了一个TextBox输入搜索字符串的区域,以及一个查找按钮以执行对家谱的搜索。这是搜索区域的XAML

<StackPanel 
  HorizontalAlignment="Center" 
  Margin="4" 
  Orientation="Horizontal"
  >
  <TextBlock Text="Search for:" />
  <TextBox 
    x:Name="searchTextBox"
    KeyDown="searchTextBox_KeyDown" 
    Margin="6,0"
    Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
    Width="150"
    />
  <Button 
    Command="{Binding SearchCommand}" 
    Content="_Find" 
    Padding="8,0" 
    />
</StackPanel>

现在,让我们看看支持这个用户界面的FamilyTreeViewModel中的代码。

FamilyTreeViewModel

搜索功能封装在FamilyTreeViewModel类中。TextBox包含的搜索文本绑定到SearchText属性,声明如下:

/// <summary>
/// Gets/sets a fragment of the name to search for.
/// </summary>
public string SearchText
{
    get { return _searchText; }
    set
    {
        if (value == _searchText)
            return;

        _searchText = value;

        _matchingPeopleEnumerator = null;
    }
}

当用户单击查找按钮时,将执行FamilyTreeViewModelSearchCommand命令。该命令类嵌套在FamilyTreeViewModel中,但向视图公开它的属性是public。该代码如下所示:

/// <summary>
/// Returns the command used to execute a search in the family tree.
/// </summary>
public ICommand SearchCommand
{
    get { return _searchCommand; }
}

private class SearchFamilyTreeCommand : ICommand
{
    readonly FamilyTreeViewModel _familyTree;

    public SearchFamilyTreeCommand(FamilyTreeViewModel familyTree)
    {
        _familyTree = familyTree;
    }

    public bool CanExecute(object parameter)
    {
        return true;
    }

    event EventHandler ICommand.CanExecuteChanged
    {
        // I intentionally left these empty because
        // this command never raises the event, and
        // not using the WeakEvent pattern here can
        // cause memory leaks. WeakEvent pattern is
        // not simple to implement, so why bother.
        add { }
        remove { }
    }

    public void Execute(object parameter)
    {
        _familyTree.PerformSearch();
    }
}

如果您熟悉我的WPF技术和理念,您可能会惊讶地发现我在这里没有使用路由命令。出于多种原因,我通常更喜欢路由命令,但在这种情况下,使用简单的ICommand实现会更干净、更简单。注意,一定要阅读CanExecuteChanged事件声明中的注释。

搜索逻辑完全不依赖于TreeViewTreeViewItem。它只是遍历ViewModel对象并设置ViewModel属性。尝试直接针对TreeView API编写此代码将更加困难且容易出错。这是我的搜索逻辑:

IEnumerator<PersonViewModel> _matchingPeopleEnumerator;
string _searchText = String.Empty;

void PerformSearch()
{
    if (_matchingPeopleEnumerator == null || !_matchingPeopleEnumerator.MoveNext())
        this.VerifyMatchingPeopleEnumerator();

    var person = _matchingPeopleEnumerator.Current;

    if (person == null)
        return;

    // Ensure that this person is in view.
    if (person.Parent != null)
        person.Parent.IsExpanded = true;

    person.IsSelected = true;
}

void VerifyMatchingPeopleEnumerator()
{
    var matches = this.FindMatches(_searchText, _rootPerson);
    _matchingPeopleEnumerator = matches.GetEnumerator();

    if (!_matchingPeopleEnumerator.MoveNext())
    {
        MessageBox.Show(
            "No matching names were found.",
            "Try Again",
            MessageBoxButton.OK,
            MessageBoxImage.Information
            );
    }
}

IEnumerable<PersonViewModel> FindMatches(string searchText, PersonViewModel person)
{
    if (person.NameContainsText(searchText))
        yield return person;

    foreach (PersonViewModel child in person.Children)
        foreach (PersonViewModel match in this.FindMatches(searchText, child))
            yield return match;
}

演示 2 – 按需加载的地理细分

下一个演示应用程序使用有关一个国家/地区的各个地方的信息填充TreeView。它处理三种不同类型的对象:RegionStateCity。这些类型中的每一个都有一个对应的表示类,TreeViewItem绑定到该类。

每个表示类都派生自TreeViewItemViewModel基类,该基类提供了在之前的演示PersonViewModel类中看到的所有特定于表示的功能。此外,此演示中的项目是延迟加载的,这意味着程序不会获取项目的子项并将它们添加到对象图中,直到用户尝试查看它们。您可以在下面的屏幕截图中看到此演示:

正如我上面提到的,这里有三个独立的数据类,每个数据类都有一个关联的表示类。所有这些表示类都派生自TreeViewItemViewModel,由该接口描述:

interface ITreeViewItemViewModel : INotifyPropertyChanged
{
    ObservableCollection<TreeViewItemViewModel> Children { get; }
    bool HasDummyChild { get; }
    bool IsExpanded { get; set; }
    bool IsSelected { get; set; }
    TreeViewItemViewModel Parent { get; }
}

LoadOnDemandDemoControl的代码隐藏如下所示:

public partial class LoadOnDemandDemoControl : UserControl
{
    public LoadOnDemandDemoControl()
    {
        InitializeComponent();

        Region[] regions = Database.GetRegions();
        CountryViewModel viewModel = new CountryViewModel(regions);
        base.DataContext = viewModel;
    }
}

该构造函数只是从BusinessLib程序集中加载一些数据对象,从中创建一些UI友好的包装器,然后让视图绑定到这些包装器。视图DataContext设置为此类的一个实例:

/// <summary>
/// The ViewModel for the LoadOnDemand demo. This simply
/// exposes a read-only collection of regions.
/// </summary>
public class CountryViewModel
{
    readonly ReadOnlyCollection<RegionViewModel> _regions;

    public CountryViewModel(Region[] regions)
    {
        _regions = new ReadOnlyCollection<RegionViewModel>(
            (from region in regions
             select new RegionViewModel(region))
            .ToList());
    }

    public ReadOnlyCollection<RegionViewModel> Regions
    {
        get { return _regions; }
    }
}

有趣的代码在TreeViewItemViewModel中。 它主要是之前演示的PersonViewModel中的演示逻辑的副本,但有一个有趣的转折。TreeViewItemViewModel具有对子项按需加载的内置支持。该逻辑存在于类的构造函数和IsExpanded属性的设置器中。按需加载TreeViewItemViewModel逻辑如下图所示:

protected TreeViewItemViewModel(TreeViewItemViewModel parent, bool lazyLoadChildren)
{
    _parent = parent;

    _children = new ObservableCollection<TreeViewItemViewModel>();

    if (lazyLoadChildren)
        _children.Add(DummyChild);
}

/// <summary>
/// Gets/sets whether the TreeViewItem 
/// associated with this object is expanded.
/// </summary>
public bool IsExpanded
{
    get { return _isExpanded; }
    set
    {
        if (value != _isExpanded)
        {
            _isExpanded = value;
            this.OnPropertyChanged("IsExpanded");
        }

        // Expand all the way up to the root.
        if (_isExpanded && _parent != null)
            _parent.IsExpanded = true;

        // Lazy load the child items, if necessary.
        if (this.HasDummyChild)
        {
            this.Children.Remove(DummyChild);
            this.LoadChildren();
        }
    }
}

/// <summary>
/// Returns true if this object's Children have not yet been populated.
/// </summary>
public bool HasDummyChild
{
    get { return this.Children.Count == 1 && this.Children[0] == DummyChild; }
}

/// <summary>
/// Invoked when the child items need to be loaded on demand.
/// Subclasses can override this to populate the Children collection.
/// </summary>
protected virtual void LoadChildren()
{
}

加载对象的子项的实际工作留给子类处理。它们重写该LoadChildren方法以提供加载子项的特定于类型的实现。RegionViewModel如下所示,该类覆盖此方法以加载State对象并创建StateViewModel包装对象。

public class RegionViewModel : TreeViewItemViewModel
{
    readonly Region _region;

    public RegionViewModel(Region region) 
        : base(null, true)
    {
        _region = region;
    }

    public string RegionName
    {
        get { return _region.RegionName; }
    }

    protected override void LoadChildren()
    {
        foreach (State state in Database.GetStates(_region))
            base.Children.Add(new StateViewModel(state, this));
    }
}

此演示的用户界面仅包含一个TreeView,它使用以下XAML进行配置:

<TreeView ItemsSource="{Binding Regions}">
  <TreeView.ItemContainerStyle>
    <!-- 
    This Style binds a TreeViewItem to a TreeViewItemViewModel. 
    -->
    <Style TargetType="{x:Type TreeViewItem}">
      <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
      <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
      <Setter Property="FontWeight" Value="Normal" />
      <Style.Triggers>
        <Trigger Property="IsSelected" Value="True">
          <Setter Property="FontWeight" Value="Bold" />
        </Trigger>
      </Style.Triggers>
    </Style>
  </TreeView.ItemContainerStyle>

  <TreeView.Resources>
    <HierarchicalDataTemplate 
      DataType="{x:Type local:RegionViewModel}" 
      ItemsSource="{Binding Children}"
      >
      <StackPanel Orientation="Horizontal">
        <Image Width="16" Height="16" 
           Margin="3,0" Source="Images\Region.png" />
        <TextBlock Text="{Binding RegionName}" />
      </StackPanel>
    </HierarchicalDataTemplate>

    <HierarchicalDataTemplate 
      DataType="{x:Type local:StateViewModel}" 
      ItemsSource="{Binding Children}"
      >
      <StackPanel Orientation="Horizontal">
        <Image Width="16" Height="16" 
          Margin="3,0" Source="Images\State.png" />
        <TextBlock Text="{Binding StateName}" />
      </StackPanel>
    </HierarchicalDataTemplate>

    <DataTemplate DataType="{x:Type local:CityViewModel}">
      <StackPanel Orientation="Horizontal">
        <Image Width="16" Height="16" 
           Margin="3,0" Source="Images\City.png" />
        <TextBlock Text="{Binding CityName}" />
      </StackPanel>
    </DataTemplate>
  </TreeView.Resources>
</TreeView>

结论

如果您曾经使用过WPF TreeView,也许这篇文章已经阐明了使用该控件的另一种方法。一旦你开始顺其自然,不再试图逆流而上,WPF会让你的生活变得非常轻松。困难的部分是放弃你来之不易的知识和技能,并采用完全不同的方式来解决相同的问题。

https://www.codeproject.com/Articles/26288/Simplifying-the-WPF-TreeView-by-Using-the-ViewMode

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
WPF TreeView 可以通过设置 SelectionMode 属性来启用多选模式。默认情况下,TreeView 的 SelectionMode 属性是 Single,只能选择一个节点。如果要启用多选模式,可以将 SelectionMode 属性设置为 Extended。 以下是一个简单的示例,演示如何在 WPF TreeView 中启用多选模式: ```xml <TreeView ItemsSource="{Binding Items}" SelectionMode="Extended"> <TreeView.ItemContainerStyle> <Style TargetType="{x:Type TreeViewItem}"> <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" /> </Style> </TreeView.ItemContainerStyle> <TreeView.ItemTemplate> <HierarchicalDataTemplate ItemsSource="{Binding Children}"> <TextBlock Text="{Binding Name}" /> </HierarchicalDataTemplate> </TreeView.ItemTemplate> </TreeView> ``` 在上面的示例中,我们将 TreeView 的 SelectionMode 属性设置为 Extended,使其启用多选模式。然后,我们使用 TreeViewItem 的样式将其 IsSelected 属性绑定到 ViewModel 中的 IsSelected 属性。最后,我们使用 HierarchicalDataTemplate 定义 TreeView 的节点模板。 ViewModel 代码如下: ```csharp public class ItemViewModel : INotifyPropertyChanged { public string Name { get; set; } public ObservableCollection<ItemViewModel> Children { get; set; } private bool _isSelected; public bool IsSelected { get { return _isSelected; } set { if (_isSelected != value) { _isSelected = value; OnPropertyChanged(nameof(IsSelected)); } } } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } ``` 在 ViewModel 中,我们定义了一个 IsSelected 属性,并在 TreeViewItem 的样式中将其绑定到 TreeView 中的 IsSelected 属性。当用户选择或取消选择节点时,ViewModel 中的 IsSelected 属性将更新。 希望这个示例可以帮助你实现 WPF TreeView 的多选模式

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值