问题描述
最近,我继承了一个用C#和WPF开发的相当大的项目.它使用绑定以及 INotifyPropertyChanged
接口将更改传播到视图,或从视图传播更改.
一些前言:在不同的类中,我具有依赖于同一类中的其他属性的属性( (例如,取决于属性 TaxCode
的属性,该属性依赖于诸如 Name
和姓氏
).借助于一些我在SO上找到的代码(尽管找不到答案),我创建了抽象类 ObservableObject
和属性 DependsOn
.来源如下:
使用系统;使用System.Collections.Generic;使用System.ComponentModel;使用System.Linq;使用System.Reflection;使用System.Runtime.CompilerServices;命名空间TestNameSpace{[AttributeUsage(AttributeTargets.Property,Inherited = false)]公共密封类DependsOn:属性{公共DependsOn(参数字符串[]属性){this.Properties =属性;}public string []属性{私人套装;}}[可序列化]公共抽象类ObservableObject:INotifyPropertyChanged{私有静态Dictionary< Type,Dictionary< string,string []>>dependentPropertiesOfTypes =新的Dictionary< Type,Dictionary< string,string []>>();[领域:非序列化]公共事件PropertyChangedEventHandler PropertyChanged;私有只读布尔hasDependentProperties;公共ObservableObject(){取决于属性;类型type = this.GetType();如果(!dependentPropertiesOfTypes.ContainsKey(type)){foreach(type.GetProperties()中的PropertyInfo pInfo){attr = pInfo.GetCustomAttribute< DependsOn>(false);如果(attr!= null){如果(!dependentPropertiesOfTypes.ContainsKey(type)){relatedPropertiesOfTypes [type] = new字典< string,string []>();}dependentPropertiesOfTypes [type] [pInfo.Name] = attr.Properties;}}}如果(dependentPropertiesOfTypes.ContainsKey(type)){hasDependentProperties = true;}}公共虚拟无效OnPropertyChanged(string propertyName){this.PropertyChanged?.Invoke(this,new PropertyChangedEventArgs(propertyName));如果(this.hasDependentProperties){//检查是否有任何依赖此属性的计算属性IEnumerable< string>其中,(kvp => kvp.Value.Contains(propertyName)).Select(kvp => kvp.Key);if(computedPropNames!= null&&!computedPropNames.Any()){返回;}//为依赖于我们刚刚设置的属性的每个计算属性更改raise属性foreach(computedPropNames中的字符串computedPropName){//如果属性依赖于自身,则可以避免由于无限递归导致的stackoverflow!如果(computedPropName == propertyName){抛出新的InvalidOperationException(属性不能依赖于自身");}this.OnPropertyChanged(computedPropName);}}}受保护的布尔值SetField< T>(ref T字段,T值,[CallerMemberName]字符串propertyName = null){返回this.SetField< T>(ref字段,值,false,propertyName);}受保护的bool SetField T(ref T字段,T值,bool forceUpdate,[CallerMemberName]字符串propertyName = null){bool valueChanged =!EqualityComparer< T> .Default.Equals(field,value);如果(valueChanged || forceUpdate){字段=值;this.OnPropertyChanged(propertyName);}返回valueChanged;}}}
这些课程使我能够:
- 在我的属性设置器中仅使用
this.SetValue(ref this.name,value)
. - 在属性TaxCode上使用属性
DependsOn(nameof(Name),nameof(LastName))
这样, TaxCode
仅具有一个getter属性,该属性将 FirstName
, LastName
(以及其他属性)组合在一起并返回相应的代码.即使有了绑定,由于有了这个依赖系统,该属性仍是最新的.
因此,只要 TaxCode
依赖于同一类中的属性,一切都将正常运行.但是,我需要具有在其子对象上具有一个或多个依赖项的属性.例如(我将仅使用json使层次结构更简单):
{名称,姓,税法,健康屋:{价值},车:{价值}}
因此,可以通过以下方式实现人的财产健康:
[DependsOn(nameof(House.Value),nameof(Car.Value))]公共双重健康{get =>(this.House.Value + this.Car.Value);}
第一个问题是"House.Value"和"Car.Value"在该上下文中不是 nameof
的有效参数.第二个问题是,通过我的实际代码,我可以引发仅在同一对象中的属性,因此不能引发子级的属性,也不能引发应用范围内的属性(例如,我有一个属性,用于表示单位是否度量单位以公制/英制表示,其变化会影响数值的显示方式.
现在,我可以使用的解决方案是在我的 ObservableObject
中插入事件字典,其键为属性名称,并使父级注册一个回调.这样,当子代的属性发生更改时,将使用代码触发事件,以通知父代的属性已更改.但是,这种方法迫使我每次实例化一个新孩子时都注册回调.肯定不多,但是我喜欢仅指定依赖项并让我的基类为我完成工作的想法.
因此,长话短说,我想要实现的目标是拥有一个能够通知相关属性更改的系统,即使所涉及的属性是其子级或与该特定对象无关.由于代码库很大,所以我不想抛弃现有的 ObservableObject
+ DependsOn方法,并且我正在寻找一种更优雅的方法,而不仅仅是在我的代码中放置回调.>
当然,如果我的方法不正确/我所拥有的代码无法实现我想要的,请随时提出更好的方法.
带有 DependsOnAttribute
的原始解决方案是一个不错的主意,但是该实现存在一些性能和多线程问题.无论如何,它不会给您的类带来任何令人惊讶的依赖关系.
class MyItem:ObservableObject{public int Value {get;}[DependsOn(nameof(Value))]public int DependentValue {get;}}
有了这个,您可以在任何地方使用您的 MyItem
-在您的应用程序,单元测试中,您以后可能愿意创建的类库中.
现在,考虑一个这样的课程:
class MyDependentItem:ObservableObject{公共IMySubItem SubItem {get;}//IMySubItem提供一些NestedItem属性[DependsOn(/*对this.SubItem.NestedItem.Value */的某些引用)]public int DependentValue {get;}[DependsOn(/*对GlobalSingleton.Instance.Value */的某些引用)]public int OtherValue {get;}}
该类现在有两个令人惊讶"的依赖项:
-
MyDependentItem
现在需要知道IMySubItem
类型的特定属性(而最初,它仅公开该类型的实例,而不知道其详细信息).当您以某种方式更改IMySubItem
属性时,也不得不更改MyDependentItem
类. -
此外,
MyDependentItem
需要引用全局对象(此处以单例形式表示).
所有这些都破坏了 SOLID 原则(这是为了最大程度地减少代码更改),并使该类不可测试.它引入了与其他班级的紧密联系,并降低了班级的凝聚力.迟早要调试与此相关的问题,您会遇到麻烦.
我认为,微软在设计WPF数据绑定引擎时面临同样的问题.您正在以某种方式尝试重新发明它-您正在寻找 PropertyPath
,因为它目前正在XAML绑定中使用.为此,Microsoft创建了整个依赖项属性概念,并创建了一个全面的数据绑定引擎,该引擎可解析属性路径,传输数据值并观察数据更改.我不认为您真的想要这种复杂的东西.
相反,我的建议是:
-
对于同一类中的属性依赖项,请按当前操作使用
DependsOnAttribute
.我会稍微重构实现以提高性能并确保线程安全. -
要获取对外部对象的依赖关系,请使用 SOLID 的依赖关系反转原理;在构造函数中将其实现为依赖项注入.对于您的度量单位示例,我什至将数据和表示方面分开,例如通过使用依赖于某些
ICultureSpecificDisplay
(您的度量单位)的视图模型.class MyItem{公共双重健康}}类MyItemViewModel:INotifyPropertyChanged{公共MyItemViewModel(MyItem项目,ICultureSpecificDisplay显示){this.item = item;this.display = display;}//TODO:实现INotifyPropertyChanged支持公共字符串Wellness =>display.GetStringWithMeasurementUnits(item.Wellness);}
- 对于对象的合成结构中的依赖项,只需手动进行即可.您有多少个这样的从属属性?一对班上的一对?发明一个全面的框架而不是额外的2-3行代码是否有意义?
如果我仍然不能说服您-很好,您当然可以扩展您的 DependsOnAttribute
来不仅存储属性名称,还存储声明这些属性的类型.您的 ObservableObject
也需要更新.
让我们看看.这是扩展属性,也可以保存类型引用.请注意,它现在可以多次应用.
[AttributeUsage(AttributeTargets.Property,AllowMultiple = true)]class DependsOnAttribute:属性{公共DependsOnAttribute(参数字符串[]属性){属性=属性;}公共DependsOnAttribute(类型,类型,字符串[]属性):此(属性){类型=类型;}public string []属性{}//现在我们还可以存储PropertyChanged事件源的类型公共类型类型{get;}}
ObservableObject
需要订阅子事件:
抽象类ObservableObject:INotifyPropertyChanged{//我们正在使用ConcurrentDictionary< K,V>以确保线程安全.//C#7元组轻巧,快速.私有静态只读ConcurrentDictionary<(Type,string),string>依赖关系=新的ConcurrentDictionary<(类型,字符串),字符串>();//在这里我们存储已经处理的类型以及一个标志//一个类型是否至少具有一个依赖项私有静态只读ConcurrentDictionary< Type,bool>RegisteredTypes =新的ConcurrentDictionary< Type,bool>();受保护的ObservableObject(){输入thisType = GetType();如果(registeredTypes.ContainsKey(thisType)){返回;}var属性= thisType.GetProperties().SelectMany(propInfo => propInfo.GetCustomAttributes< DependsOn>().SelectMany(attribute =>属性.选择(propName =>(SourceType:attribute.Type,SourceProperty:propName,TargetProperty:propInfo.Name)))));bool atLeastOneDependency = false;foreach(属性中的var属性){//如果未设置属性的类型,//我们假设该属性来自此类型.类型sourceType = property.SourceType?这个类型;//字典键是事件源类型//*和*属性名称,组合成一个元组依赖性[(sourceType,property.SourceProperty)] =property.TargetProperty;atLeastOneDependency = true;}//这里有一个竞争条件:一个不同的线程//可能会超出构造函数开头的检查//然后再处理一次相同的数据.//但这并没有真正的伤害:它是相同的类型,//并发字典将处理多线程访问,//最后,您必须实例化同一对象//同时输入不同的线程//-它多久发生一次?RegisteredTypes [thisType] = atLeastOneDependency;}公共事件PropertyChangedEventHandler PropertyChanged;受保护的void OnPropertyChanged(string propertyName){var e = new PropertyChangedEventArgs(propertyName);PropertyChanged?.Invoke(this,e);如果(registeredTypes [GetType()]){//仅检查至少有一个依赖项的依赖项属性.//需要为我们自己的属性调用此函数,//因为在类内部可能存在依赖关系.RaisePropertyChangedForDependentProperties(this,e);}}受保护的布尔SetField< T>(ref T栏位,T值[CallerMemberName]字符串propertyName = null){如果(EqualityComparer< T.Default.Equals(field,value)){返回false;}如果(registeredTypes [GetType()]){如果(字段为INotifyPropertyChanged oldValue){//我们需要删除旧的订阅以避免内存泄漏.oldValue.PropertyChanged-= RaisePropertyChangedForDependentProperties;}//如果类型具有某些属性相关性,//我们连接事件以了解子对象中的更改.如果(值是INotifyPropertyChanged newValue){newValue.PropertyChanged + = RaisePropertyChangedForDependentProperties;}}字段=值;OnPropertyChanged(propertyName);返回true;}私人无效RaisePropertyChangedForDependentProperties(对象发送者,PropertyChangedEventArgs e){//我们查看该对是否存在依赖关系//"Type.PropertyName"并引发相关属性的事件.如果(dependencies.TryGetValue((sender.GetType(),e.PropertyName),out vardependentProperty)){PropertyChanged?.Invoke(this,new PropertyChangedEventArgs(dependentProperty));}}}
您可以使用如下代码:
class MyClass:ObservableObject{私人国际价公共诠释值{得到=>val;设置=>SetField(ref val,value);}//MyChildClass必须实现INotifyPropertyChanged私人MyChildClass子级;公共MyChildClass子级{得到=>孩子;设置=>SetField(ref child,value);}[DependsOn(typeof(MyChildClass),nameof(MyChildClass.MyProperty))][DependsOn(nameof(Val))]public int Sum =>Child.MyProperty + Val;}
Sum
属性取决于同一类的 Val
属性以及 MyChildClass
MyProperty 属性>课堂.
如您所见,这看起来并不那么好.此外,整个概念取决于属性设置程序执行的事件处理程序注册.如果您碰巧直接设置了字段值(例如 child = new MyChildClass()
),则所有操作均将无效.我建议您不要使用这种方法.
Recently I inherited a pretty big project developed in C# and WPF.It uses bindings along with the INotifyPropertyChanged
interface to propagate changes to/from the View.
A little preface:In different classes I have properties that depend on other properties in the same class (think for example the property TaxCode
that depends on properties like Name
and Lastname
).With the help of some code I found here on SO (can't find again the answer though) I created the abstract class ObservableObject
and the attribute DependsOn
.The source is the following:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace TestNameSpace
{
[AttributeUsage(AttributeTargets.Property, Inherited = false)]
public sealed class DependsOn : Attribute
{
public DependsOn(params string[] properties)
{
this.Properties = properties;
}
public string[] Properties { get; private set; }
}
[Serializable]
public abstract class ObservableObject : INotifyPropertyChanged
{
private static Dictionary<Type, Dictionary<string, string[]>> dependentPropertiesOfTypes = new Dictionary<Type, Dictionary<string, string[]>>();
[field: NonSerialized]
public event PropertyChangedEventHandler PropertyChanged;
private readonly bool hasDependentProperties;
public ObservableObject()
{
DependsOn attr;
Type type = this.GetType();
if (!dependentPropertiesOfTypes.ContainsKey(type))
{
foreach (PropertyInfo pInfo in type.GetProperties())
{
attr = pInfo.GetCustomAttribute<DependsOn>(false);
if (attr != null)
{
if (!dependentPropertiesOfTypes.ContainsKey(type))
{
dependentPropertiesOfTypes[type] = new Dictionary<string, string[]>();
}
dependentPropertiesOfTypes[type][pInfo.Name] = attr.Properties;
}
}
}
if (dependentPropertiesOfTypes.ContainsKey(type))
{
hasDependentProperties = true;
}
}
public virtual void OnPropertyChanged(string propertyName)
{
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
if (this.hasDependentProperties)
{
//check for any computed properties that depend on this property
IEnumerable<string> computedPropNames = dependentPropertiesOfTypes[this.GetType()].Where(kvp => kvp.Value.Contains(propertyName)).Select(kvp => kvp.Key);
if (computedPropNames != null && !computedPropNames.Any())
{
return;
}
//raise property changed for every computed property that is dependant on the property we did just set
foreach (string computedPropName in computedPropNames)
{
//to avoid stackoverflow as a result of infinite recursion if a property depends on itself!
if (computedPropName == propertyName)
{
throw new InvalidOperationException("A property can't depend on itself");
}
this.OnPropertyChanged(computedPropName);
}
}
}
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
return this.SetField<T>(ref field, value, false, propertyName);
}
protected bool SetField<T>(ref T field, T value, bool forceUpdate, [CallerMemberName] string propertyName = null)
{
bool valueChanged = !EqualityComparer<T>.Default.Equals(field, value);
if (valueChanged || forceUpdate)
{
field = value;
this.OnPropertyChanged(propertyName);
}
return valueChanged;
}
}
}
These classes allow me to:
- Use just
this.SetValue(ref this.name, value)
inside the setter of my properties. - Use the attribute
DependsOn(nameof(Name), nameof(LastName))
on the property TaxCode
This way TaxCode
only has a getter property that combines FirstName
, LastName
(and other properties) and returns the corresponding code. Even with binding this property is up to date thanks to this dependency system.
So, as long as TaxCode
has dependencies on properties that are in the same class, everything works correctly. However I'm in the need to have properties that have one or more dependencies on their child object. For example (I'll just use json to make the hierarchy more simple):
{
Name,
LastName,
TaxCode,
Wellness,
House:
{
Value
},
Car:
{
Value
}
}
So, the Property Wellness of person sould be implemented like this:
[DependsOn(nameof(House.Value), nameof(Car.Value))]
public double Wellness { get =>(this.House.Value + this.Car.Value);}
The first problem is that "House.Value" and "Car.Value" are not valid parameters for nameof
in that context.The second is that with my actual code I can raise properties that are only in the same object so no properties of childs, nor properties that are application wide (I have for example a property that represents if the units of measurement are expressed in metric/imperial and the change of it affects how values are shown).
Now a solution I could use could be to insert a dictionary of events in my ObservableObject
with the key being the name of the property and make the parent register a callback. This way when the property of a child changes the event is fired with the code to notify that a property in the parent has changed. This approach however forces me to register the callbacks everytime a new child is instantiated. It is certainly not much, but I liked the idea of just specifying dependencies and let my base class do the work for me.
So, long story short, what I'm trying to achieve is to have a system that can notify dependent property changes even if the properties involved are its childs or are unrelated to that specific object. Since the codebase is quite big I'd like not to just throw away the existing ObservableObject
+ DependsOn approach, and I'm looking for a more elegant way than just place callbacks all over my code.
Of course If my approach is wrong / what I want cannot be achieved with the code I have, please DO feel free to suggest better ways.
The original solution with a DependsOnAttribute
is a nice idea, but the implementation has a couple of performance and multithreading issues. Anyway, it doesn't introduce any surprising dependencies to your class.
class MyItem : ObservableObject
{
public int Value { get; }
[DependsOn(nameof(Value))]
public int DependentValue { get; }
}
Having this, you can use your MyItem
anywhere - in your app, in unit tests, in a class library you might be willing to create later.
Now, consider such a class:
class MyDependentItem : ObservableObject
{
public IMySubItem SubItem { get; } // where IMySubItem offers some NestedItem property
[DependsOn(/* some reference to this.SubItem.NestedItem.Value*/)]
public int DependentValue { get; }
[DependsOn(/* some reference to GlobalSingleton.Instance.Value*/)]
public int OtherValue { get; }
}
This class has two "surprising" dependencies now:
MyDependentItem
now needs to know a particular property of theIMySubItem
type (whereas originally, it only exposes an instance of that type, without knowing its details). When you change theIMySubItem
properties somehow, you are forced to change theMyDependentItem
class too.Additionally,
MyDependentItem
needs a reference to a global object (represented as a singleton here).
All this breaks the SOLID principles (it's all about to minimize changes in code) and makes the class not testable. It introduces a tight coupling to other classes and lowers the class' cohesion. You will have troubles debugging the issues with that, sooner or later.
I think, Microsoft faced same issues when they designed the WPF Data Binding Engine. You're somehow trying to reinvent it - you're looking for a PropertyPath
as it is currently being used in XAML bindings. To support this, Microsoft created the whole dependency property concept and a comprehensive Data Binding Engine that resolves the property paths, transfers the data values and observes the data changes. I don't think you really want something of that complexity.
Instead, my suggestions would be:
For the property dependencies in the same class, use the
DependsOnAttribute
as you're currently doing. I would slightly refactor the implementation to boost the performance and to ensure the thread safety.For a dependency to an external object, use the Dependency Inversion Principle of SOLID; implement it as dependency injection in constructors. For your measurement units example, I would even separate the data and the presentation aspects, e.g. by using a view-model that has a dependency to some
ICultureSpecificDisplay
(your measurement units).class MyItem { public double Wellness { get; } } class MyItemViewModel : INotifyPropertyChanged { public MyItemViewModel(MyItem item, ICultureSpecificDisplay display) { this.item = item; this.display = display; } // TODO: implement INotifyPropertyChanged support public string Wellness => display.GetStringWithMeasurementUnits(item.Wellness); }
- For a dependency in the composition structure of your object, just do it manually. How many such dependent properties do you have? A couple in a class? Does it make sense to invent a comprehensive framework instead of additional 2-3 lines of code?
If I still didn't convince you - well, you can of course extend your DependsOnAttribute
to store not only property names but also the types where those properties are declared. Your ObservableObject
needs to be updated too.
Let's take a look.This is an extended attribute that also can hold the type reference. Note that it can be applied multiple times now.
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
class DependsOnAttribute : Attribute
{
public DependsOnAttribute(params string[] properties)
{
Properties = properties;
}
public DependsOnAttribute(Type type, params string[] properties)
: this(properties)
{
Type = type;
}
public string[] Properties { get; }
// We now also can store the type of the PropertyChanged event source
public Type Type { get; }
}
The ObservableObject
needs to subscribe to the children events:
abstract class ObservableObject : INotifyPropertyChanged
{
// We're using a ConcurrentDictionary<K,V> to ensure the thread safety.
// The C# 7 tuples are lightweight and fast.
private static readonly ConcurrentDictionary<(Type, string), string> dependencies =
new ConcurrentDictionary<(Type, string), string>();
// Here we store already processed types and also a flag
// whether a type has at least one dependency
private static readonly ConcurrentDictionary<Type, bool> registeredTypes =
new ConcurrentDictionary<Type, bool>();
protected ObservableObject()
{
Type thisType = GetType();
if (registeredTypes.ContainsKey(thisType))
{
return;
}
var properties = thisType.GetProperties()
.SelectMany(propInfo => propInfo.GetCustomAttributes<DependsOn>()
.SelectMany(attribute => attribute.Properties
.Select(propName =>
(SourceType: attribute.Type,
SourceProperty: propName,
TargetProperty: propInfo.Name))));
bool atLeastOneDependency = false;
foreach (var property in properties)
{
// If the type in the attribute was not set,
// we assume that the property comes from this type.
Type sourceType = property.SourceType ?? thisType;
// The dictionary keys are the event source type
// *and* the property name, combined into a tuple
dependencies[(sourceType, property.SourceProperty)] =
property.TargetProperty;
atLeastOneDependency = true;
}
// There's a race condition here: a different thread
// could surpass the check at the beginning of the constructor
// and process the same data one more time.
// But this doesn't really hurt: it's the same type,
// the concurrent dictionary will handle the multithreaded access,
// and, finally, you have to instantiate two objects of the same
// type on different threads at the same time
// - how often does it happen?
registeredTypes[thisType] = atLeastOneDependency;
}
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string propertyName)
{
var e = new PropertyChangedEventArgs(propertyName);
PropertyChanged?.Invoke(this, e);
if (registeredTypes[GetType()])
{
// Only check dependent properties if there is at least one dependency.
// Need to call this for our own properties,
// because there can be dependencies inside the class.
RaisePropertyChangedForDependentProperties(this, e);
}
}
protected bool SetField<T>(
ref T field,
T value,
[CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
if (registeredTypes[GetType()])
{
if (field is INotifyPropertyChanged oldValue)
{
// We need to remove the old subscription to avoid memory leaks.
oldValue.PropertyChanged -= RaisePropertyChangedForDependentProperties;
}
// If a type has some property dependencies,
// we hook-up events to get informed about the changes in the child objects.
if (value is INotifyPropertyChanged newValue)
{
newValue.PropertyChanged += RaisePropertyChangedForDependentProperties;
}
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
private void RaisePropertyChangedForDependentProperties(
object sender,
PropertyChangedEventArgs e)
{
// We look whether there is a dependency for the pair
// "Type.PropertyName" and raise the event for the dependent property.
if (dependencies.TryGetValue(
(sender.GetType(), e.PropertyName),
out var dependentProperty))
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(dependentProperty));
}
}
}
You can use that code like this:
class MyClass : ObservableObject
{
private int val;
public int Val
{
get => val;
set => SetField(ref val, value);
}
// MyChildClass must implement INotifyPropertyChanged
private MyChildClass child;
public MyChildClass Child
{
get => child;
set => SetField(ref child, value);
}
[DependsOn(typeof(MyChildClass), nameof(MyChildClass.MyProperty))]
[DependsOn(nameof(Val))]
public int Sum => Child.MyProperty + Val;
}
The Sum
property depends on the Val
property of the same class and on the MyProperty
property of the MyChildClass
class.
As you see, this doesn't look that great. Furthermore, the whole concept depends on the event handler registration performed by the property setters. If you happen to set the field value directly (e.g. child = new MyChildClass()
), then it all won't work. I would suggest you not to use this approach.
这篇关于INotifyPropertyChanged和不同对象上的派生属性的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持!