• 解读WPF中的Binding


    1.Overview

    基于MVVM实现一段绑定大伙都不陌生,Binding是wpf整个体系中最核心的对象之一这里就来解读一下我花了纯两周时间有哪些秘密。这里我先提出几个问题应该是大家感兴趣的,如下:

    (1)INotifyPropertyChanged是如何被加载、触发的(Binding如何完成数据更新的)?

    (2)为什么需要开发者手动实现INotifyPropertyChanged接口来为每个成员实现数据通知,为什么不集成在wpf框架里?

    (3)藏在WPF体系里的观察者模式在哪里?

     

    2.Detail

    想了解以上问题,我们先补充以下前置知识点。

    我们带着以上几个问题来看本文的后续内容,首先我们通过下面这张图来了解绑定的过程。

     

     

    根据以上过程我们可以基于MVVM模式下,在Xaml中写出这样的语句来表示绑定。

    <TextBoxName="mytextbox"Height="25"Width="150"Text="{BindingPath=Name,Mode=**TwoWay**,UpdateSourceTrigger=**PropertyChanged**}"></TextBox>

    那么如果把他转换成c#代码,将会是如下表示。

    public string BeachName{ get; set; }
    
    private void Test()
    {
      BeachName="BikiniBeach";
    
      TextBoxtextBox = newTextBox();
      textBox.Name = "myTextBox";
       
      Binding binding = new Binding();
      binding.Source = BeachName;
      binding.Path = new PropertyPath("BeachName");
    
      textBox.SetBinding(TextBox.TextProperty, binding);
    }
    

    (1-1)

    上面这段代码,包含了两个关键对象Textbox和Binding它们里面大有文章首先我们逐个拆解这两个对象里都有什么。

    Textbox

    在(1-1)的代码中初始化一个Textbox对象,它会创建一个依赖属性TextProperty用于绑定要素之一。

    public static readonly DependencyProperty TextProperty = DependencyProperty.Register(nameof (Text), typeof (string), typeof (TextBox), (PropertyMetadata) new FrameworkPropertyMetadata((object) string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.Journal, new PropertyChangedCallback(TextBox.OnTextPropertyChanged), new CoerceValueCallback(TextBox.CoerceText), true, UpdateSourceTrigger.LostFocus));
    

    Binding

     

     

     

    当我们在日常开发实现绑定过程当中,WPF的体系会默默帮你创建Binding对象,这里我们来看看Binding包含了哪些定义(为了观看体验删除了大部分不相关代码)。

    namespace System.Windows.Data
    {
     public class Binding : BindingBase
    {
       //....其它代码省略
       public static void AddSourceUpdatedHandler(
         DependencyObject element,
         EventHandler<DataTransferEventArgs> handler)
      {
         UIElement.AddHandler(element, Binding.SourceUpdatedEvent, (Delegate) handler);
      }
    
       public static void RemoveSourceUpdatedHandler(
         DependencyObject element,
         EventHandler<DataTransferEventArgs> handler)
      {
         UIElement.RemoveHandler(element, Binding.SourceUpdatedEvent, (Delegate) handler);
      }
    
       public static void AddTargetUpdatedHandler(
         DependencyObject element,
         EventHandler<DataTransferEventArgs> handler)
      {
         UIElement.AddHandler(element, Binding.TargetUpdatedEvent, (Delegate) handler);
      }
    
       public static void RemoveTargetUpdatedHandler(
         DependencyObject element,
         EventHandler<DataTransferEventArgs> handler)
      {
         UIElement.RemoveHandler(element, Binding.TargetUpdatedEvent, (Delegate) handler);
      }
    
       public Binding(string path)
      {
         if (path == null)
           return;
         if (Dispatcher.CurrentDispatcher == null)
           throw new InvalidOperationException();
         this.Path = new PropertyPath(path, (object[]) null);
      }
    
       public PropertyPath Path
      {
         get => this._ppath;
         set
        {
           this.CheckSealed();
           this._ppath = value;
           this._attachedPropertiesInPath = -1;
           this.ClearFlag(BindingBase.BindingFlags.PathGeneratedInternally);
           if (this._ppath == null || !this._ppath.StartsWithStaticProperty)
             return;
           if (this._sourceInUse == Binding.SourceProperties.None || this._sourceInUse == Binding.SourceProperties.StaticSource || FrameworkCompatibilityPreferences.TargetsDesktop_V4_0)
             this.SourceReference = Binding.StaticSourceRef;
           else
             throw new InvalidOperationException(SR.Get("BindingConflict", (object) Binding.SourceProperties.StaticSource, (object) this._sourceInUse));
        }
      }
    
      [DefaultValue(BindingMode.Default)]
       public BindingMode Mode
      {
         get
        {
           switch (this.GetFlagsWithinMask(BindingBase.BindingFlags.PropagationMask))
          {
             case BindingBase.BindingFlags.OneTime:
               return BindingMode.OneTime;
             case BindingBase.BindingFlags.OneWay:
               return BindingMode.OneWay;
             case BindingBase.BindingFlags.OneWayToSource:
               return BindingMode.OneWayToSource;
             case BindingBase.BindingFlags.TwoWay:
               return BindingMode.TwoWay;
             case BindingBase.BindingFlags.PropDefault:
               return BindingMode.Default;
             default:
               Invariant.Assert(false, "Unexpected BindingMode value");
               return BindingMode.TwoWay;
          }
        }
         set
        {
           this.CheckSealed();
           BindingBase.BindingFlags flags = BindingBase.FlagsFrom(value);
           if (flags == BindingBase.BindingFlags.IllegalInput)
             throw new InvalidEnumArgumentException(nameof (value), (int) value, typeof (BindingMode));
           this.ChangeFlagsWithinMask(BindingBase.BindingFlags.PropagationMask, flags);
        }
      }
    
      [DefaultValue(UpdateSourceTrigger.Default)]
       public UpdateSourceTrigger UpdateSourceTrigger
      {
         get
        {
           switch (this.GetFlagsWithinMask(BindingBase.BindingFlags.UpdateDefault))
          {
             case BindingBase.BindingFlags.OneTime:
               return UpdateSourceTrigger.PropertyChanged;
             case BindingBase.BindingFlags.UpdateOnLostFocus:
               return UpdateSourceTrigger.LostFocus;
             case BindingBase.BindingFlags.UpdateExplicitly:
               return UpdateSourceTrigger.Explicit;
             case BindingBase.BindingFlags.UpdateDefault:
               return UpdateSourceTrigger.Default;
             default:
               Invariant.Assert(false, "Unexpected UpdateSourceTrigger value");
               return UpdateSourceTrigger.Default;
          }
        }
         set
        {
           this.CheckSealed();
           BindingBase.BindingFlags flags = BindingBase.FlagsFrom(value);
           if (flags == BindingBase.BindingFlags.IllegalInput)
             throw new InvalidEnumArgumentException(nameof (value), (int) value, typeof (UpdateSourceTrigger));
           this.ChangeFlagsWithinMask(BindingBase.BindingFlags.UpdateDefault, flags);
        }
      }
      [DefaultValue(false)]
       public bool NotifyOnSourceUpdated
      {
         get => this.TestFlag(BindingBase.BindingFlags.NotifyOnSourceUpdated);
         set
        {
           if (this.TestFlag(BindingBase.BindingFlags.NotifyOnSourceUpdated) == value)
             return;
           this.CheckSealed();
           this.ChangeFlag(BindingBase.BindingFlags.NotifyOnSourceUpdated, value);
        }
      }
      [DefaultValue(false)]
       public bool NotifyOnTargetUpdated
      {
         get => this.TestFlag(BindingBase.BindingFlags.NotifyOnTargetUpdated);
         set
        {
           if (this.TestFlag(BindingBase.BindingFlags.NotifyOnTargetUpdated) == value)
             return;
           this.CheckSealed();
           this.ChangeFlag(BindingBase.BindingFlags.NotifyOnTargetUpdated, value);
        }
      }
      [DefaultValue(null)]
       public IValueConverter Converter
      {
         get => (IValueConverter) this.GetValue(BindingBase.Feature.Converter, (object) null);
         set
        {
           this.CheckSealed();
           this.SetValue(BindingBase.Feature.Converter, (object) value, (object) null);
        }
      }
    
       public object Source
      {
         get
        {
           WeakReference<object> weakReference = (WeakReference<object>) this.GetValue(BindingBase.Feature.ObjectSource, (object) null);
           if (weakReference == null)
             return (object) null;
           object target;
           return !weakReference.TryGetTarget(out target) ? (object) null : target;
        }
         set
        {
           this.CheckSealed();
           if (this._sourceInUse == Binding.SourceProperties.None || this._sourceInUse == Binding.SourceProperties.Source)
          {
             if (value != DependencyProperty.UnsetValue)
            {
               this.SetValue(BindingBase.Feature.ObjectSource, (object) new WeakReference<object>(value));
               this.SourceReference = (ObjectRef) new ExplicitObjectRef(value);
            }
             else
            {
               this.ClearValue(BindingBase.Feature.ObjectSource);
               this.SourceReference = (ObjectRef) null;
            }
          }
           else
             throw new InvalidOperationException(SR.Get("BindingConflict", (object) Binding.SourceProperties.Source, (object) this._sourceInUse));
        }
      }
    
       internal override BindingExpressionBase CreateBindingExpressionOverride(
         DependencyObject target,
         DependencyProperty dp,
         BindingExpressionBase owner)
      {
         return (BindingExpressionBase) BindingExpression.CreateBindingExpression(target, dp, this, owner);
      }
    }
    }
    

    Binding对象继承自BindingBase,在Binding类中我们可以看到CreateBindingExpressionOverride这个方法,这个方法来自父类BindingBase。代码中的BindingExpression是“绑定表达式”的意思,在CreateBindingExpression中入参完美的阐述了绑定关系;

    internal override BindingExpressionBase CreateBindingExpressionOverride(
         DependencyObject target,
         DependencyProperty dp,
         BindingExpressionBase owner)
    {
         return (BindingExpressionBase) BindingExpression.CreateBindingExpression(target, dp, this, owner);
    }
    
    internal static BindingExpression CreateBindingExpression(
         DependencyObject d,
         DependencyProperty dp,
         Binding binding,
         BindingExpressionBase parent)
    {
         if (dp.GetMetadata(d.DependencyObjectType) is FrameworkPropertyMetadata metadata && !metadata.IsDataBindingAllowed || dp.ReadOnly)
           throw new ArgumentException(System.Windows.SR.Get("PropertyNotBindable", (object) dp.Name), nameof (dp));
         BindingExpression bindingExpression = new BindingExpression(binding, parent);
         bindingExpression.ResolvePropertyDefaultSettings(binding.Mode, binding.UpdateSourceTrigger, metadata);
         if (bindingExpression.IsReflective && binding.XPath == null && (binding.Path == null || string.IsNullOrEmpty(binding.Path.Path)))
           throw new InvalidOperationException(System.Windows.SR.Get("TwoWayBindingNeedsPath"));
         return bindingExpression;
    }
    

    (1)DependencyObject,是所有控件的基类这里我们在当前环境中可以理解为Textbox。

    (2)DependencyProperty,是我们要绑定的控件中的TextProperty依赖属性。

    (3)Binding,表达了数据源、绑定目标、绑定模式、更新通知触发类型等信息。

     

     

    创建binding对象,建立绑定表达式CreateBindingExpression将依赖属性和控件、绑定对象关联起来->BindingExpression该方法将Path传给 TraceData.Trace追踪对象。在Binding继承的BindingBase.cs中实现了CreateBindingExpression(创建绑定表达式,它的作用就是用来“描述”绑定的整个过程)

    [BindingExpression作用-1]

    该对象提供了绑定更新的机制,UpdateSourceTrigger.Explicit 模式用来控制源对象的更新时机。

    (1)调用 BindingExpression.UpdateSource()和 UpdateTarget( )方法,触发立即刷新行为。

    (2)获取 BindingExpression对象,需要使用 GetBindingExpression( )方法。

    BindingExpiression binding =
    txtFontSize.GetBindingExpression(TextBox ,TextProperty);
    
    binding.UpdateSource()

    要完全控制源对象的更新时机,可选择 UpdateSourceTrigger.ExpUdt 模式。如果在文本框示 例中使用这种方法,当文本框失去焦点后不会发生任何事情 反而,由您编写代码手动触发更 新。例如,可添加 Apply 按钮,调用 BindingExpression.UpdateSource()方法,触发立即刷新行为并更新字体尺寸。 当然,在调用 BindingExpressiorLUpdateSource( )之前 ,需要 一 种方法 来获取 BindingExpression 对象。BindingExpressicm 对象仅是将两项内容封装到一起的较小组装包,这 两项内容是:己经学习过的 Binding 对象(通过 BindingExpression.ParentBinding 属性提供)和由 源绑定的对象(BindingExpression.Dataltem)a 此外,BindingExpression 对象为触发立即更新绑定 的-部分提供了两个方法:UpdateSource( )和 UpdateTarget( )方法, 为联取 BindingExpressiori 对象,需要使用 GetBindingExpression( )方法,并传入具有绑定的 目标属性,每个元素都从 FrameworkEkment 类继承了该方法。

    可为每个属性引发事件。对于这种情况,事件必须以 的形式迸行命 名(如 UnitCostChanged)当属性变化时,由您负责引发事件。 可实现 System.ComponentModel.INotifyPropertyChanged 接口,该接口需要名为 PropertyChanged 的事件。无论何时属性发生变化,都必须引发 PropertyChanged 事件,并 且通过将属性名称作为字符串提供来指示哪个属性发生了变化。当属性发生变化时,仍 由您负责引发事件,但不必为每个属性定义单独的事件& 第一种方法依赖于 WPF 的依赖项属性基础架构,而第二种和第三种方法依赖于事件,通 常,当创建数据对象时,会使用第三种方法。对于非元素类而言,这是最简单的选择。实际上,还可使用另一种方法如果怀疑绑定对象已经发生变化,并且绑定对象不支持任 何恰当方 式的更改通知,这时可检索 BindingExpression 对象(使用 FrameworkElement. GetBmdingExpression()方法),并调用 BindingExpresskm.UpdateTarget()方法来触发更新, 这是最憨的解决方案。

    [BindingExpression作用-2]

    BindingExpression继承自BindingExpressionBase除了表述绑定关系以外,还创建了BindingWorker对象(下面为关键代码)这里所要讲的就是INotifyPropertyChanged是如何被加载、触发的。

    private BindingWorker _worker;
    
    private void CreateWorker()
    {
      Invariant.Assert(this.Worker == null, "duplicate call to CreateWorker");
      this._worker = (BindingWorker) new ClrBindingWorker(this, this.Engine);
    }
    

    上面代码将_worker初始化为ClrBindingWorker,它里面又包含PropertyPathWorker对象,PropertyPathWorker这个对象中有一个方法UpdateSourceValueState,它会从上层引用中拿到ViewModel的引用(引用会逐层从Binding类的层面逐层传递进来)然后会判断这个ViewModel是否继承了INotifyPropertyChanged如果继承了则找到public event PropertyChangedEventHandler PropertyChanged;的引用并进行管理。

    else if (newO is INotifyPropertyChanged source15)
                 PropertyChangedEventManager.AddHandler(source15, new EventHandler<PropertyChangedEventArgs>(this.OnPropertyChanged), this.SVI[k].propertyName);
    

    ViewModel.PropertyChangedEventHandler的我们开发者定义好的通知事件,添加进入到PropertyChangedEventManager中进行管理,这个时候我们在给ViewModel里的变量Set值能通知界面更改就这么来的;下面为PropertyChangedEventManager.cs部分源码(这里的Manager类似于观察者模式)。

    //将ViewModel里的PropertyChangedEventHandler PropertyChanged;添加监听
    private void AddListener(
         INotifyPropertyChanged source,
         string propertyName,
         IWeakEventListener listener,
         EventHandler<PropertyChangedEventArgs> handler)
      {
         using (this.WriteLock)
        {
           HybridDictionary hybridDictionary = (HybridDictionary) this[(object) source];
           if (hybridDictionary == null)
          {
             hybridDictionary = new HybridDictionary(true);
             this[(object) source] = (object) hybridDictionary;
             this.StartListening((object) source);
          }
           WeakEventManager.ListenerList list = (WeakEventManager.ListenerList) hybridDictionary[(object) propertyName];
           if (list == null)
          {
             list = (WeakEventManager.ListenerList) new WeakEventManager.ListenerList<PropertyChangedEventArgs>();
             hybridDictionary[(object) propertyName] = (object) list;
          }
           if (WeakEventManager.ListenerList.PrepareForWriting(ref list))
             hybridDictionary[(object) propertyName] = (object) list;
           if (handler != null)
             list.AddHandler((Delegate) handler);
           else
             list.Add(listener);
           hybridDictionary.Remove((object) PropertyChangedEventManager.AllListenersKey);
           this._proposedAllListenersList = (WeakEventManager.ListenerList) null;
           this.ScheduleCleanup();
        }
      }
    
    //这里每次在ViewModel里给变量Set值的之后就是通过OnPropertyChanged通知界面更改值,sender是ViewModel对象
    private void OnPropertyChanged(object sender, PropertyChangedEventArgs args)
      {
         string propertyName = args.PropertyName;
         WeakEventManager.ListenerList list;
         using (this.ReadLock)
        {
           HybridDictionary hybridDictionary = (HybridDictionary) this[sender];
           if (hybridDictionary == null)
             list = WeakEventManager.ListenerList.Empty;
           else if (!string.IsNullOrEmpty(propertyName))
          {
             WeakEventManager.ListenerList<PropertyChangedEventArgs> listenerList1 = (WeakEventManager.ListenerList<PropertyChangedEventArgs>) hybridDictionary[(object) propertyName];
             WeakEventManager.ListenerList<PropertyChangedEventArgs> listenerList2 = (WeakEventManager.ListenerList<PropertyChangedEventArgs>) hybridDictionary[(object) string.Empty];
             if (listenerList2 == null)
               list = listenerList1 == null ? WeakEventManager.ListenerList.Empty : (WeakEventManager.ListenerList) listenerList1;
             else if (listenerList1 != null)
            {
               list = (WeakEventManager.ListenerList) new WeakEventManager.ListenerList<PropertyChangedEventArgs>(listenerList1.Count + listenerList2.Count);
               int index1 = 0;
               for (int count = listenerList1.Count; index1 < count; ++index1)
                 list.Add(listenerList1.GetListener(index1));
               int index2 = 0;
               for (int count = listenerList2.Count; index2 < count; ++index2)
                 list.Add(listenerList2.GetListener(index2));
            }
             else
               list = (WeakEventManager.ListenerList) listenerList2;
          }
           else
          {
             list = (WeakEventManager.ListenerList) hybridDictionary[(object) PropertyChangedEventManager.AllListenersKey];
             if (list == null)
            {
               int capacity = 0;
               foreach (DictionaryEntry dictionaryEntry in hybridDictionary)
                 capacity += ((WeakEventManager.ListenerList) dictionaryEntry.Value).Count;
               list = (WeakEventManager.ListenerList) new WeakEventManager.ListenerList<PropertyChangedEventArgs>(capacity);
               foreach (DictionaryEntry dictionaryEntry in hybridDictionary)
              {
                 WeakEventManager.ListenerList listenerList = (WeakEventManager.ListenerList) dictionaryEntry.Value;
                 int index = 0;
                 for (int count = listenerList.Count; index < count; ++index)
                   list.Add(listenerList.GetListener(index));
              }
               this._proposedAllListenersList = list;
            }
          }
           list.BeginUse();
        }
         try
        {
           this.DeliverEventToList(sender, (EventArgs) args, list);
        }
         finally
        {
           list.EndUse();
        }
         if (this._proposedAllListenersList != list)
           return;
         using (this.WriteLock)
        {
           if (this._proposedAllListenersList != list)
             return;
           HybridDictionary hybridDictionary = (HybridDictionary) this[sender];
           if (hybridDictionary != null)
             hybridDictionary[(object) PropertyChangedEventManager.AllListenersKey] = (object) list;
           this._proposedAllListenersList = (WeakEventManager.ListenerList) null;
        }
      }
    

    [BindingExpression作用-3]

     

     

    这里主要讲述,如果直接在文本框内直接修改数据Binding是如何更新通知的(View->ViewModel)。

    1.创建Binding对象,建立绑定表达式CreateBindingExpression将依赖属性和控件、绑定对象关联起来->BindingExpression该方法将Path传给 TraceData.Trace追踪Path。

    2.手动在Textbox中输入内容则会被控件中的OnPreviewTextInput事件捕捉到,最后由BindingExpressionBase.OnPreviewTextInput触发Drity方法。

    [特别分享:这里的Dirty命名我觉得很有造诣,这里分享一下我的理解Dirty直接翻译为‘脏’这个字如何去理解,举例:下雨天雨点落在了车窗玻璃上,这时候雨刷器把落在玻璃上的雨点视为‘脏’东西然后雨刷器刷一下把所有雨点清理干净了。借喻到代码中就是当有数据需要更新调用Dirty方法解决所有的更新需求。]

    internal void Dirty()
    {
      if (ShouldReactToDirty())
      {
          NeedsUpdate = true;
          if (!HasValue(Feature.Timer))
          {
              ProcessDirty();
          }
          else
          {
              // restart the timer
              DispatcherTimer timer = (DispatcherTimer)GetValue(Feature.Timer, null);
                      timer.Stop();
                      timer.Start();
          }
                NotifyCommitManager();
          }
    }

    Drity方法会检测是否有数据改动没有改动则退出更新机制。如果在绑定表达式中用了Delay属性,则会触发BindingExpressionBase中的DispatcherTimer来达到数据延迟更新的效果。可见每创建一个绑定表达式里都会包含一个定时器只是大部分时间不会启动而已。内部会有bool的标记来判断更新过程是否开始或结束。

    private void DetermineEffectiveUpdateBehavior()
    {
     if (!this.IsReflective)
       return;
     for (BindingExpressionBase bindingExpressionBase = this.ParentBindingExpressionBase; bindingExpressionBase != null; bindingExpressionBase = bindingExpressionBase.ParentBindingExpressionBase)
    {
       if (bindingExpressionBase is MultiBindingExpression)
         return;
    }
     int delay = this.ParentBindingBase.Delay;
     if (delay <= 0 || !this.IsUpdateOnPropertyChanged)
       return;
     DispatcherTimer dispatcherTimer = new DispatcherTimer();
     this.SetValue(BindingExpressionBase.Feature.Timer, (object) dispatcherTimer);
     //这里的Interval就是根据我们在设置Binding对象Delay属性来设置的。如果写Delay=1000;那么就是1秒后触发更新
     dispatcherTimer.Interval = TimeSpan.FromMilliseconds((double) delay);
     dispatcherTimer.Tick += new EventHandler(this.OnTimerTick);
    }
    

    3.这时候访问依赖属性Text的内容去修改绑定在ViewModel的属性BindingExpression.UpdateSource(object value)。

    4.BindingExpressionBase.UpdateValue()里的object rawProposedValue = this.GetRawProposedValue();会去拿到依赖属性的值这时候取到的内容是没有被验证是否合法的内容,然后会做两件事情

    (1)会判断值是否合法能否通过验证规则。

    (2)如果在绑定表达式里写了Convert转换器,则进行值转换。

    完成以上两步的值将会object obj = this.UpdateSource(convertedValue)来触发更新;最终由依赖属性中PropertyMetadata注册的PropertyChangedCallback来落实值的修改。

    internal bool UpdateValue()
      {
           ValidationError oldValidationError = BaseValidationError;
    
           if (StatusInternal == BindingStatusInternal.UpdateSourceError)
               SetStatus(BindingStatusInternal.Active);
           
           object value = GetRawProposedValue();
           if (!Validate(value, ValidationStep.RawProposedValue))
               return false;
    
           value = ConvertProposedValue(value);
           if (!Validate(value, ValidationStep.ConvertedProposedValue))
               return false;
    
           value = UpdateSource(value);
           if (!Validate(value, ValidationStep.UpdatedValue))
               return false;
    
           value = CommitSource(value);
           if (!Validate(value, ValidationStep.CommittedValue))
               return false;
    
           if (BaseValidationError == oldValidationError)
          {
               // the binding is now valid - remove the old error
               UpdateValidationError(null);
          }
           EndSourceUpdate();
           NotifyCommitManager();
           return !HasValue(Feature.ValidationError);
      }
    

    看到这里大家应该会明白设计者为什么不把ViewModel的每个字段默认集数据通知机制,我个人的理解是数据通知会带来一定的性能损耗所以开放给开发者“按需”添加通知的成员。

    3.Reference

  • 相关阅读:
    全国城市经纬度
    CentOS下SSH无密码登录的配置
    Nginx 1.9+PHP5.6 环境搭建
    Sphinx 2.2.11-release reference manual
    JVM 内存管理机制
    solr 3.5.0 与 tomcat 7.0.5 整合配置
    lucene 分词实现
    lucene 索引 demo
    lucene 搜索demo
    Lucene 简单API使用
  • 原文地址:https://www.cnblogs.com/justzhuzhu/p/15866477.html
Copyright © 2020-2023  润新知