65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Self-Tracking Entity Generator 构建 WPF 应用程序 - IClientChangeTracking 接口

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2011年12月23日

CPOL

10分钟阅读

viewsIcon

24942

downloadIcon

2

本文介绍了自跟踪实体生成器为 WPF/Silverlight 生成的 IClientChangeTracking 接口。

  • 这里 下载源代码
  • 请访问此 项目站点 以获取最新版本和源代码。

目录

引言

在本文中,我们将介绍自动生成的 IClientChangeTracking 接口,然后我们将研究该接口的方法和属性如何在我们的演示应用程序中用于客户端更改跟踪。

IClientChangeTracking 接口

IClientChangeTracking 接口包含以下成员

  • 方法 AcceptChanges() 接受实体对象的更改。
  • 方法 AcceptObjectGraphChanges() 接受实体对象及其对象图中所有对象的更改。
  • 方法 RejectChanges() 拒绝对实体对象所做的更改。
  • 方法 RejectObjectGraphChanges() 拒绝对实体对象及其对象图中所有对象所做的更改。
  • 属性 HasChanges 是只读的,用于跟踪实体对象是否具有任何更改。
  • 方法 ObjectGraphHasChanges() 返回实体对象及其对象图是否具有任何更改。
  • 方法 EstimateObjectGraphSize() 返回实体对象及其对象图的估计大小。
  • 方法 EstimateObjectGraphChangeSize() 返回仅包含已更改对象的优化实体对象图的估计大小。
  • 方法 GetObjectGraphChanges() 返回一个优化后的实体对象图,其中仅包含已更改的对象。

前四个方法可用于接受或回滚对实体对象所做的任何更改。AcceptChanges() 仅接受对对象所做的更改,而 AcceptObjectGraphChanges() 接受对对象及其对象图中所有对象所做的更改。方法 RejectChanges()RejectObjectGraphChanges() 的工作方式类似。接下来的两个是属性 HasChanges 和方法 ObjectGraphHasChanges(),它们返回实体对象是否具有任何更改。区别在于前者仅检查实体对象本身,而后者检查整个对象图。最后,最后三个方法是相关的,并且经常一起使用。方法 GetObjectGraphChanges() 返回调用对象图的一个副本,其中仅包含已更改的对象,而其他两个是辅助方法,用于返回估计大小并帮助确定调用 GetObjectGraphChanges() 是否有意义。

在我们探讨 IClientChangeTracking 接口如何在客户端使用之前,让我们先看一下服务器端逻辑。

SchoolService 类(服务器端)

大部分服务器端业务逻辑驻留在 SchoolService 类中,该类中的方法大致可分为数据检索方法和更新方法。数据检索方法要么返回实体列表,要么返回单个实体对象,而更新方法则用于添加/删除/更新单个实体。接下来我们将讨论数据检索方法。

数据检索方法

在实现数据检索方法时,我们需要特别注意那些跨越多个导航属性级别的。以 GetCourses() 方法为例,该方法返回一个 Course 对象列表,并扩展了两个导航属性级别“Enrollments.Student”。因此,如果我们这样实现该方法:

public List<Course> GetCourses()
{
    using (var context = new SchoolEntities())
    {
        return context.Courses
            .Include("Enrollments.Student")
            .ToList();
    }
}

我们将检索到以下图所示的 Course 对象列表

Course 对象列表的问题在于,每个 Course 对象不属于自己的对象图,并且实体“CS111”、“CS112”通过 Student 对象连接。这使得任何处理对象图的方法都无法使用。例如,如果我们更改实体“CS111”,则对实体“CS112”调用 ObjectGraphHasChanges() 也会返回 true,因为“CS111”和“CS112”属于同一个对象图。

为了克服这个问题,必须修改 GetCourses() 方法如下:

public List<Course> GetCourses()
{
    using (var context = new SchoolEntities())
    {
        var courseList = new List<Course>();
        foreach (var course in context.Courses)
        {
            var currentCourse = course;
            using (var innerContext = new SchoolEntities())
            {
                courseList.Add(
                    innerContext.Courses
                    .Include("Enrollments.Student")
                    .Single(n => n.CourseId == currentCourse.CourseId));
            }
        }
        return courseList;
    }
}

修改后的 GetCourses() 方法将返回以下图所示的 Course 对象列表,我们可以看到实体“CS111”和“CS112”属于两个不相关的对象图。这次,如果我们更改实体“CS111”,则对“CS111”调用 ObjectGraphHasChanges() 将返回 true,而对“CS112”的相同调用仍返回 false。由于每个 Course 对象都属于不同的对象图,因此我们可以检测并保存对一个 Course 对象所做的更改,而不会影响列表中的其他对象。

更新方法

更新方法通常在每个实体类型的单个方法内处理添加/删除/更新操作。以下是 UpdateCourse() 方法,该方法保存单个 Course 对象所做的更改,无论操作是添加、删除还是更新。

 public List<object> UpdateCourse(Course item)
{
    var returnList = new List<object>();
    try
    {
        using (var context = new SchoolEntities())
        {
            switch (item.ChangeTracker.State)
            {
                case ObjectState.Added:
                    // server side validation
                    item.ValidateObjectGraph();
                    // save changes
                    context.Courses.ApplyChanges(item);
                    context.SaveChanges();
                    break;
                case ObjectState.Deleted:
                    // save changes
                    context.Courses.ApplyChanges(item);
                    context.SaveChanges();
                    break;
                default:
                    // server side validation
                    item.ValidateObjectGraph();
                    // save changes
                    context.Courses.ApplyChanges(item);
                    context.SaveChanges();
                    break;
            }
        }
        returnList.Add(string.Empty);
        returnList.Add(item.CourseId);
    }
    catch (OptimisticConcurrencyException)
    {
        var errorMessage = "Course " + item.CourseId + 
		" was modified by another user. " +
            	"Refresh that item before reapply your changes.";
        returnList.Add(errorMessage);
        returnList.Add(item.CourseId);
    }
    catch (Exception ex)
    {
        Exception exception = ex;
        while (exception.InnerException != null)
        {
            exception = exception.InnerException;
        }
        var errorMessage = "Course " + item.CourseId + 
		" has error: " + exception.Message;
        returnList.Add(errorMessage);
        returnList.Add(item.CourseId);
    }
    return returnList;
}

Course 实体本身会跟踪所有所做的更改,并将其对象状态存储在 ChangeTracker.State 属性中。如果 State 为 Added,我们将添加一个新课程。如果 State 为 Deleted,我们将从数据库中删除该课程。如果 State 为 Unchanged 或 Modified,我们将执行更新操作。此外,对于所有三种情况,我们只需调用 context.Courses.ApplyChanges(item) 然后调用 context.SaveChanges() 来保存更改。

至此,我们完成了关于服务器端逻辑的讨论。现在我们可以开始研究 IClientChangeTracking 接口如何在客户端使用。

SchoolModel 类(客户端)

让我们以“Student”屏幕为例,并检查实现此屏幕的基本要求。首先,应该有一个列表来存储从服务器端检索到的所有 Student 实体。其次,应该有一个变量指向当前正在编辑的 Student 对象。然后,应该有布尔属性来跟踪是否进行了任何更改。最后,应该有一组方法来检索、更新和回滚学生信息。所有这些都已在 SchoolModel 类中实现,并可总结如下:

  • 属性 StudentsList 存储从服务器端检索到的所有 Student 实体。
  • 属性 CurrentStudent 跟踪当前正在编辑的对象。
  • 只读属性 StudentsListHasChanges 跟踪 StudentsList 是否有更改。
  • 只读属性 CurrentStudentHasChanges 跟踪 CurrentStudent 是否有更改。
  • 方法 GetStudentsAsync() 从服务器端检索 Student 实体列表。
  • 方法 SaveStudentChangesAsync(bool allItems = true)allItemstrue 时保存 StudentsList 中所有已更改的实体,在 allItems 设置为 false 时保存 CurrentStudent 的更改。
  • 方法 RejectStudentChanges(bool allItems = true)allItemstrue 时回滚 StudentsList 的所有更改,在 allItems 设置为 false 时回滚 CurrentStudent 的更改。

StudentsListHasChanges 和 CurrentStudentHasChanges

布尔属性 StudentsListHasChangesCurrentStudentHasChanges 分别存储 StudentsListCurrentStudent 是否有更改。要更新这两个属性,我们需要调用下面显示的 private 方法 ReCalculateStudentsListHasChanges()ReCalculateCurrentStudentHasChanges(),这两个方法都依赖于 IClientChangeTracking 接口中的 ObjectGraphHasChanges(),该方法返回实体对象及其对象图是否具有任何更改。

public bool StudentsListHasChanges
{
    get { return _studentsListHasChanges; }
    private set
    {
        if (_studentsListHasChanges != value)
        {
            _studentsListHasChanges = value;
            OnPropertyChanged("StudentsListHasChanges");
        }
    }
}

private bool _studentsListHasChanges;

public bool CurrentStudentHasChanges
{
    get { return _currentStudentHasChanges; }
    private set
    {
        if (_currentStudentHasChanges != value)
        {
            _currentStudentHasChanges = value;
            OnPropertyChanged("CurrentStudentHasChanges");
        }
    }
}

private bool _currentStudentHasChanges;

private void ReCalculateStudentsListHasChanges()
{
    // re-calculate StudentsListHasChanges
    StudentsListHasChanges = StudentsList != null
        && StudentsList.Any(n => n.ObjectGraphHasChanges());
}

private void ReCalculateCurrentStudentHasChanges()
{
    // re-calculate CurrentStudentHasChanges
    CurrentStudentHasChanges = CurrentStudent != null
        && CurrentStudent.ObjectGraphHasChanges();
}

ReCalculateStudentsListHasChanges()ReCalculateCurrentStudentHasChanges() 都需要从可能发生对 StudentsListCurrentStudent 更改的任何地方调用,这将在后面介绍。

StudentsList 和 CurrentStudent

StudentsList 订阅 CollectionChanged 事件,列表中的每个 Student 对象也订阅 PropertyChanged 事件。每当 StudentsListCollectionChanged 事件触发时,ReCalculateStudentsListHasChanges() 将重新计算 StudentsList 是否有更改。其次,每当 StudentsList 中的任何 Student 对象的 PropertyChanged 事件触发并且更改的属性等于 HasChanges 时,也会调用 ReCalculateStudentsListHasChanges() 来重新计算 StudentsList 是否有更改。最后,如果 StudentsList 本身被设置为指向另一个列表,则再次使用 ReCalculateStudentsListHasChanges() 方法来重置 StudentsListHasChanges 属性。

public ObservableCollection<Student> StudentsList
{
    get { return _studentsList; }
    set
    {
        if (!ReferenceEquals(_studentsList, value))
        {
            if (_studentsList != null)
            {
                _studentsList.CollectionChanged -= _studentsList_CollectionChanged;
                foreach (var student in _studentsList)
                {
                    ((INotifyPropertyChanged)student).PropertyChanged -= 
						EntityModel_PropertyChanged;
                }
            }
            _studentsList = value;
            if (_studentsList != null)
            {
                _studentsList.CollectionChanged += _studentsList_CollectionChanged;
                foreach (var student in _studentsList)
                {
                    ((INotifyPropertyChanged)student).PropertyChanged += 
						EntityModel_PropertyChanged;
                }
            }
            ReCalculateStudentsListHasChanges();
        }
    }

private ObservableCollection<Student> _studentsList;

private void _studentsList_CollectionChanged
	(object sender, NotifyCollectionChangedEventArgs e)
{
    if (e.NewItems != null)
    {
        foreach (Student newItem in e.NewItems)
            ((INotifyPropertyChanged)newItem).PropertyChanged += 
					EntityModel_PropertyChanged;
    }
    if (e.OldItems != null)
    {
        foreach (Student oldItem in e.OldItems)
            ((INotifyPropertyChanged)oldItem).PropertyChanged -= 
					EntityModel_PropertyChanged;
    }
    ReCalculateStudentsListHasChanges();
}

private void EntityModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName.Equals("HasChanges"))
    {
        if (sender is Student)
        {
            ReCalculateStudentsListHasChanges();
            ReCalculateCurrentStudentHasChanges();
        }
        else if (sender is Instructor)
        {
            ReCalculateInstructorsListHasChanges();
            ReCalculateCurrentInstructorHasChanges();
        }
        else if (sender is Course || sender is Enrollment)
        {
            ReCalculateCoursesListHasChanges();
            ReCalculateCurrentCourseHasChanges();
        }
        else
        {
            throw new NotImplementedException();
        }
    }
}

CurrentStudent follows a similar pattern. The difference is that it only subscribes to the PropertyChanged event. Method ReCalculateCurrentStudentHasChanges() is called whenever PropertyChanged event fires and the changed property is HasChanges. Likewise, when CurrentStudent is assigned to a different Student object, ReCalculateCurrentStudentHasChanges() will also update CurrentStudentHasChanges.

public Student CurrentStudent
{
    get { return _currentStudent; }
    set
    {
        if (!ReferenceEquals(_currentStudent, value))
        {
            if (_currentStudent != null)
            {
                ((INotifyPropertyChanged)_currentStudent).PropertyChanged -= 
						EntityModel_PropertyChanged;
            }
            _currentStudent = value;
            if (_currentStudent != null)
            {
                ((INotifyPropertyChanged)_currentStudent).PropertyChanged += 
						EntityModel_PropertyChanged;
            }
            ReCalculateCurrentStudentHasChanges();
        }
    }
}

private Student _currentStudent;

到目前为止,我们已经展示了如何定义属性 StudentsListCurrentStudent 以及两个伴随属性 StudentsListHasChangesCurrentStudentHasChanges。这四个属性使得“Student”屏幕能够显示从数据库中获取的学生信息。此外,根据 StudentsListHasChangesCurrentStudentHasChanges 的值,我们可以轻松确定“保存”、“全部保存”、“取消”和“全部取消”按钮应该启用还是禁用。但是,这种设计有一个小缺点:如果 Student 实体类型有许多导航属性,并且每个导航属性都扩展了多个级别,那么 StudentsList 的属性 setter 可能会变得有些复杂。因为我们需要跟踪多个导航属性上的更改,所以所有这些属性都必须订阅 PropertyChanged 事件。

接下来,让我们继续讨论客户端数据检索方法,用于填充 StudentsListCurrentStudent 属性。

数据检索方法

SchoolModel 类的 "Data retrieval methods" 是异步方法,它们使用 IAsyncResult 设计模式。下面所示的 GetStudentsAsync() 方法就是其中之一。它通过 WCF 服务调用 BeginGetStudents() 开始检索学生信息,其第二个参数是一个指向 BeginGetStudentsCompleteAsyncCallback 委托。当此 WCF 服务调用完成后,AsyncCallback 委托将在单独的线程中处理检索操作的结果。由于我们需要在 UI 线程上触发 GetStudentsCompleted 事件,因此必须将其包含在 ThreadHelper.BeginInvokeOnUIThread() 中,如下所示:

public void GetStudentsAsync(string includeOption, string screenName)
{
    _proxy.BeginGetStudents(includeOption, BeginGetStudentsComplete, screenName);
    _proxy.IncrementCallCount();
}

/// <summary>
/// AsyncCallback for BeginGetStudents
/// </summary>
/// <param name="result"></param>
private void BeginGetStudentsComplete(IAsyncResult result)
{
    ThreadHelper.BeginInvokeOnUIThread(
        delegate
        {
            _proxy.DecrementCallCount();
            try
            {
                // get the return values
                var students = _proxy.EndGetStudents(result);
                if (GetStudentsCompleted != null)
                {
                    GetStudentsCompleted(this, new ResultsArgs<Student>
				(students, null, false, result.AsyncState));
                }
            }
            catch (Exception ex)
            {

                if (GetStudentsCompleted != null && 
			(_lastError == null || AllowMultipleErrors))
                {
                    GetStudentsCompleted(this, new ResultsArgs<Student>
				(null, ex, true, result.AsyncState));
                }
                _lastError = ex;
            }
        });
}

更新方法

同样,更新方法也是异步方法。我们的示例是 SaveStudentChangesAsync() 方法。此调用接受一个布尔参数 allItems。如果 allItemstrue,它会遍历 StudentsList 中所有已更改的项并调用 BeginUpdateStudent()。否则,它只检查 CurrentStudent 是否有更改,如果为 true,则仅为 CurrentStudent 调用 BeginUpdateStudent()

SaveStudentChangesAsync() 使用 IClientChangeTracking 接口的几个方法。首先,我们使用 ObjectGraphHasChanges() 来找出 Student 对象是否需要保存更改。接下来,我们使用两个辅助方法 EstimateObjectGraphSize()EstimateObjectGraphChangeSize() 来确定对象图更改的大小是否小于总大小的 70%。如果为 true,我们调用 GetObjectGraphChanges() 来获取一个优化后的实体对象图,其中仅包含已更改的对象。

GetObjectGraphChanges() 方法在减少从客户端发送到服务器端的数据总量方面非常有用。例如,如果我们有一个订单屏幕,该屏幕检索一个订单以及数百个订单详细信息行作为其导航集合,而我们只更改订单的实际发货日期而不更改任何订单详细信息行。在保存此订单之前调用 GetObjectGraphChanges() 将确保我们只发送订单对象而不发送任何订单详细信息行。因此,克服了使用自跟踪实体的一个主要缺点。

/// <summary>
/// If allItems is true, all items from the StudentsList have
/// their changes saved; otherwise, only CurrentStudent from
/// the StudentsList has its changes saved.
/// </summary>
/// <param name="allItems"></param>
public void SaveStudentChangesAsync(bool allItems = true)
{
    if (allItems)
    {
        if (StudentsList != null && StudentsListHasChanges)
        {
            // save changes for all items from the StudentsList
            foreach (var student in StudentsList.Where(n => n.ObjectGraphHasChanges()))
            {
                var totalSize = student.EstimateObjectGraphSize();
                var changeSize = student.EstimateObjectGraphChangeSize();
                // if the optimized entity object graph is less than 70%
                // of the original, call GetObjectGraphChanges()
                var currentStudent = changeSize < (totalSize*0.7)
                                        ? (Student) student.GetObjectGraphChanges()
                                        : student;

                _actionQueue.Add(
                    n => _proxy.BeginUpdateStudent(
                        currentStudent,
                        BeginUpdateStudentComplete,
                        currentStudent.PersonId));
            }
            // start save changes for the first student
            if (_actionQueue.BeginOneAction()) _proxy.IncrementCallCount();
        }
    }
    else
    {
        if (CurrentStudent != null && StudentsList != null && CurrentStudentHasChanges)
        {
            // save changes for only CurrentStudent from the StudentsList
            var currentStudent = StudentsList
                .FirstOrDefault(n => n.PersonId == CurrentStudent.PersonId);
            if (currentStudent != null)
            {
                var totalSize = currentStudent.EstimateObjectGraphSize();
                var changeSize = currentStudent.EstimateObjectGraphChangeSize();
                // if the optimized entity object graph is less than 70%
                // of the original, call GetObjectGraphChanges()
                currentStudent = changeSize < (totalSize*0.7)
                                    ? (Student) currentStudent.GetObjectGraphChanges()
                                    : currentStudent;

                _actionQueue.Add(
                    n => _proxy.BeginUpdateStudent(
                        currentStudent,
                        BeginUpdateStudentComplete,
                        currentStudent.PersonId));
                // start save changes for the current student
                if (_actionQueue.BeginOneAction()) _proxy.IncrementCallCount();
            }
        }
    }
}

方法 BeginUpdateStudentComplete() 是上面描述的 BeginUpdateStudent()AsyncCallback,它处理异步更新操作的结果。如果更新成功且服务器端没有警告消息,我们调用 AcceptObjectGraphChanges(),这是 IClientChangeTracking 接口中定义的另一个方法,它接受 Student 对象及其对象图中所有对象的更改。之后,Student 对象的 HasChanges 属性将设置为 false

/// <summary>
/// AsyncCallback for BeginUpdateStudent
/// </summary>
/// <param name="result"></param>
private void BeginUpdateStudentComplete(IAsyncResult result)
{
    ThreadHelper.BeginInvokeOnUIThread(
        delegate
        {
            try
            {
                // get the return values
                var returnList = _proxy.EndUpdateStudent(result);
                // returnList[0] could be a warning message
                var warningMessage = returnList[0] as string;
                // returnList[1] is the updated student Id
                var updatedStudentId = Convert.ToInt32(returnList[1]);
                // get the studentId for the student that finished saving changes
                var studentId = Convert.ToInt32(result.AsyncState);
                var student = StudentsList.Single(n => n.PersonId == studentId);
                // update the student Id if the student State is Added
                if (student.ChangeTracker.State == ObjectState.Added)
                    student.PersonId = updatedStudentId;

                if (string.IsNullOrEmpty(warningMessage))
                {
                    var isDeleted = student.ChangeTracker.State == ObjectState.Deleted;
                    // if there is no error or warning message, 
		  // call AcceptObjectGraphChanges() first
                    student.AcceptObjectGraphChanges();
                    // if State is Deleted, remove the student from the StudentsList
                    if (isDeleted) StudentsList.Remove(student);
                    // then, continue to save changes for the next student in queue
                    if (_actionQueue.BeginOneAction() == false)
                    {
                        // all changes are saved, we need to send notification
                        _proxy.DecrementCallCount();
                        if (SaveStudentChangesCompleted != null &&
                            _lastError == null && string.IsNullOrEmpty(warningMessage))
                        {
                            SaveStudentChangesCompleted(this,
                                new ResultArgs<string>(string.Empty, null, false, null));
                        }
                    }
                }
                else
                {
                    // if there is a warning message, 
		  // we need to stop and send notification
                    // on first occurrence, in other words, if _lastError is still null
                    _actionQueue.Clear();
                    _proxy.DecrementCallCount();
                    if (SaveStudentChangesCompleted != null &&
                        (_lastError == null || AllowMultipleErrors))
                    {
                        SaveStudentChangesCompleted(this,
                            new ResultArgs<string>(warningMessage, null, true, null));
                    }
                }
            }
            catch (Exception ex)
            {
                // if there is an error, we need to stop and send notification
                // on first occurrence, in other words, if _lastError is still null
                _actionQueue.Clear();
                _proxy.DecrementCallCount();
                if (SaveStudentChangesCompleted != null &&
                    (_lastError == null || AllowMultipleErrors))
                {
                    SaveStudentChangesCompleted(this,
                        new ResultArgs<string>(string.Empty, ex, true, null));
                }
                _lastError = ex;
            }
        });
}

回滚方法

最后一个方法是 RejectStudentChanges()。与 SaveStudentChangesAsync() 方法一样,RejectStudentChanges() 接受一个布尔参数 allItems。如果 allItemstrue,则该方法遍历 StudentsList 中所有已更改的项并调用 RejectObjectGraphChanges()IClientChangeTracking 接口的另一个方法)。否则,该方法仅检查 CurrentStudent 是否有更改,如果有,则仅为 CurrentStudent 调用 RejectObjectGraphChanges()

/// <summary>
/// If allItems is true, all items from the StudentsList have
/// their changes rejected; otherwise, only CurrentStudent from
/// the StudentsList has its changes rejected.
/// </summary>
/// <param name="allItems"></param>
public void RejectStudentChanges(bool allItems = true)
{
    if (allItems)
    {
        if (StudentsList != null && StudentsListHasChanges)
        {
            // reject changes for all items from the StudentsList
            foreach (var student in StudentsList.Where
			(n => n.ObjectGraphHasChanges()).ToList())
            {
                var isAdded = student.ChangeTracker.State == ObjectState.Added;
                student.RejectObjectGraphChanges();
                // if the State is Added, simply remove it from the StudentsList
                if (isAdded) StudentsList.Remove(student);
            }
        }
    }
    else
    {
        if (CurrentStudent != null && StudentsList != null && CurrentStudentHasChanges)
        {
            // reject changes for only CurrentStudent from the StudentsList
            var currentStudent = StudentsList
                .FirstOrDefault(n => n.PersonId == CurrentStudent.PersonId);
            if (currentStudent != null)
            {
                var isAdded = currentStudent.ChangeTracker.State == ObjectState.Added;
                currentStudent.RejectObjectGraphChanges();
                // if the State is Added, simply remove it from the StudentsList
                if (isAdded) StudentsList.Remove(currentStudent);
            }
        }
    }
}

总结

我们已经完成了关于如何使用 IClientChangeTracking 接口的方法和属性的讨论。总而言之,方法 ObjectGraphHasChanges() 在多个地方用于检查实体是否具有任何更改。其次,方法 AcceptObjectGraphChanges() 仅在更新操作成功完成时使用,而方法 RejectObjectGraphChanges() 在回滚操作中调用以撤销所做的任何更改。最后,方法 GetObjectGraphChanges() 在节省通过网络传输的总数据量方面非常有用。

希望您觉得本文有用,请在下方评分和/或留下反馈。谢谢!

历史

  • 2011 年 12 月 23 日 - 首次发布
  • 2011 年 12 月 26 日 - 对文章进行了小幅更新
© . All rights reserved.