Access 开发框架(翻译+改编)系列之八——从属对象

框架的最终目的,是提供一个明确定义的地方,将对象的属性和行为可以放置在这里,用于将来的项目开发。控件是一个主要 … 继续阅读“Access 开发框架(翻译+改编)系列之八——从属对象”

框架的最终目的,是提供一个明确定义的地方,将对象的属性和行为可以放置在这里,用于将来的项目开发。控件是一个主要的例子,因为它们没有内建类(built-in class),而我们想扩展它们的功能。于是,我们为每个控件类型创建了类,这样我们就能扩展这个控件的功能。

为了这个目的,在本系列中,我们将为已创建的组合框控件类添加一些新的功能。这个新功能就是从属对象重新查询(dependent object re-query)

从属对象重新查询的意思就是,我们某个类对象创建一个方法,当该类对象的值有变化时,任何从属于该类对象的对象的值,都会执行重新查询,获得更新。在本系列中,这个类对象就是组合框控件类。当用户在组合框控件中选择了一个新的值,任何从属于这个组合框控件的对象会自动的重新查询,基于组合框控件的值,更新自己的筛选数据。

这样的行为通常是非常有用的,同时也能向我们展示,如果我们想让框架做更多的事情的话,我们应该如何在其中添加新的事物,从而扩展我们的框架。

从属对象

从属对象在Access应用程序中是很普遍的。举个例子,有一个窗体,上面有2个组合框,名字分别为cboCompany和cboEmployees。当用户选择cboCompany的值后,cboEmployees需要重新查询自己的可选数据,只显示公司为组合框控件cboCompany中的值的员工数据。

从属对象的功能在通常情况下是怎样实现的呢?一般是为从属对象创建一个查询,这个查询为该从属对象提供数据,在该查询中,有一个字段,该字段引用了被从属的对象的值。换句话说,cboEmployees的“row source”属性(译者注:我用的英文版本Access,该属性对应的中文名称可能是“行来源”)中的查询会设置类似于“Like form!frmMyForm!cboCompany”这样的筛选条件。控件cboEmployees是从属于(依赖于)控件cboCompany的。仅仅这样做好设置以后,当你修改cboCompany的值以后,你会发现cboEmployee的值并未立即发生更新,你必须手动刷新(或通过代码)窗体,从属的组合框控件才会更新。

为了在我们的框架中实现这种功能。我们可以简单的直接在控件类中添加一个从属对象集,然后为每一个控件类添加方法,以便可被添加到这个集合中,当控件类对象销毁之前,将其从集合中移除。当然我们也会添加一个重新查询的方法,遍历集合中的所有对象,调用每个对象的重新查询方法。注意,这隐含着一个意思,每个可从属于其他控件的控件必须有一个公用的重新查询方法。

本章相关代码示例文件8.1 从属对象

从属对象代码

从属对象的代码大概是这个样子的:

在每个控件类的开始部分(为了方便阅读,剥离了所有的错误处理代码)

Private mcolDepObj As Collection

在每个控件类初始化事件中,将集合对象实例化。

Private Sub Class_Initialize()
    Set mcolDepObj = New Collection
End Sub

在每个控件类的Term()方法中写入清除代码

Public Sub Term()
    Set mcolDepObj = Nothing
End Sub

最后,公用函数或方法来处理从属对象的相关事情:

'将从属对象集合中的所有对象清除
Private Function ColEmpty()
    While mcolDepObj.Count > 0
        mcolDepObj.remove 1
    Wend
End Function

'遍历从属对象集合中的每个对象,调用各自的Requery方法
Public Function Requery()
    Dim obj As Object
    For Each obj In mcolDepObj
        obj.Requery
    Next obj
End Function

'一次添加一批对象到从属对象集合
Public Function AddDepObjs(ParamArray lDepObjsArr() As Variant)
    Dim obj As Variant
    For Each obj In lDepObjsArr
        mcolDepObj.Add obj, obj.Name
    Next obj
End Function

'一次添加一个对象到从属对象集合
Public Function Add(Item As Variant, Key As Variant)
    mcolDepObj.Add Item, Key
    Requery
End Function

'删除一个对象
Public Function Remove(Index As Variant)
    mcolDepObj.Remove Index
End Function

'返回一个计数
Public Function Count()
    Count = mcolDepObj.Count
End Function

'返回一个特定的元素
Public Function Item(Index As Variant)
    Item = mcolDepObj.Item(Index)
End Function

用这种方法,当然,不会有任何问题。但是,如果我们从类的角度,来思考这个问题的话,我们完全有更好的方案。我们可以创建一个从属对象类,将相关的代码都放置其中,然后我们只需要在每个控件类中添加一个从属类对象。

使用一个专用的类来处理这个问题的最大的好处,就是我们可以把所有类似的代码都放在一个地方——类中,然后在我们需要的地方,定义一个clsDepObj对象,然后调用类的方法。我们不再需要在每个控件中都重复去维护那一堆相似的代码。

每个控件类代码

创建了从属对象类clsDepObj,在控件类中,我们不再需要上述代码了,我们只需要这些:

在每个控件类的开始部分(为了方便阅读,剥离了所有的错误处理代码)

'定义从属对象
Private mclsDepObj As clsDepObj

在每个控件类初始化事件中,将从属类对象实例化。

Private Sub Class_Initialize()
    Set mclsDepObj = New clsDepObj
End Sub

在每个控件类的Term()方法中写入清除代码

Public Sub Term()
    mclsDepObj.Term
    Set mclsDepObj = Nothing
End Sub

然后创建一个属性来获取从属类对象的指针(句柄/引用)

Public Property Get clsDepObj() As clsDepObj
    Set clsDepObj = mclsDepObj
End Property

你不得不承认,相比将整个从属类的代码都添加在到每一个控件类中来说,在每个控件类中只用添加这些行,要来的更加清晰和容易。更不必说,如果我们想添加一些其他的从属对象功能到框架中来的话,我们只需要在一个地方添加即可,而不必在每个控件类中都做一遍。

有一件事情我们必须要记住,在每个包含从属对象的控件类中,要添加一个重新查询的方法。显而易见,如果这个控件类有一个数据源,那么我们需要重新查询这个控件,也需要调用从属对象类的重新查询方法。

'重新查询控件和从属集合
Public Function Requery()
    mcbo.Requery
    mclsDepObj.Requery
End Function

最后,当组合框的AfterUpdate(更新后)事件被触发后,我们想重新查询新类的从属对象集合中所有的对象。我们通过调用mclsDepObj的重新查询方法就可以了。WithEvents!你将会爱上它。

Private Sub mcbo_AfterUpdate()
    mclsDepObj.Requery
End Sub

现在,我们有了这个新的类和它的功能,我们怎样告诉框架来使用它呢?答案就在窗体类的初始化的地方——窗体的Open事件。

窗体的内建类

我修改了frmPeople11窗体,添加了2个组合框控件在窗体的头部,cboCompany和cboEmployee,分别包含公司和和这些公司的员工。窗体的代码大概是这样子的:

Option Compare Database
Option Explicit

我们的自定义窗体类dclsFrm

Private fdclsFrm As dclsFrm

以及窗体的打开事件

Private Sub Form_Open(Cancel As Integer)
    Set fdclsFrm = New dclsFrm
    fdclsFrm.Init Me, Me
    With fdclsFrm.Children
        .Item("cboCompany").clsDepObj.Add .Item("cboEmployee"), cboEmployee.Name
        .Item("cboCompany").clsDepObj.Add fdclsFrm, Me.Name
        .Item("cboCompany").Requery
    End With
End Sub

我知道这看起来非常复杂,但是我们可以一步一步来看,我会告诉你每一步是在干什么。Set语句和Init语句跟以前是一样的。我们将指向窗体内建类的指针(Me)和指向窗体本身的指针(Me)传递给fdclsFrm对象。非常凑巧的是,对窗体来讲,Me既代表了窗体内建类,也代表了物理窗体,所以出现2个Me,看起来有点奇怪。

    Set fdclsFrm = New dclsFrm
    fdclsFrm.Init Me, Me

接下来,我们用一个With语句来加快我们获取dclsFrm对象内部内容的速度

    With fdclsFrm.Children

dclsFrm有一个Children属性,可以返回类的集合指针。有了这个,我们现在可以引用集合的属性和方法。我们使用.Item()方法从集合中返回一些东西。

        .Item("cboCompany").clsDepObj.Add .Item("cboEmployee"), cboEmployee.Name

还记得吗?在dclsFrm类中,我们有一个控件扫描方法,可以发现窗体上的所有控件,然后为每个控件创建一个对象,然后将该对象的指针保存在集合colChildren中。还记得我们使用什么作为主键吗?是的,我们使用了控件的名字。这样一来,.Item(“cboCompany”)告诉类对象在集合colChildren中返回名叫“cboCompany”的对象,换句话说,就是控件cboCompany的控件类对象。

这样的话,.Item(“CboCompany”)就是指向组合框类对象的指针,与下述语句是一样的:

dim ldclsCbo as dclsCtlComboBox
set dclsCbo=.Item("cboCompany")

既然我们有了指向组合框控件类对象的指针,我们就可以调用这个类对象的任何方法或属性了,这就有了下面的语句:

        .Item("cboCompany").clsDepObj.Add

代码的意思就是,对组合框控件cboCompany的控件类对象来讲,调用它的从属类方法clsDepObj,返回指向它的从属类对象的指针,然后调用从属类对象的Add方法,准备添加一个从属类对象。

记住,句点符号指示那个类的属性或方法,这样一层一层的引用,有点像魔术师的盒子,大盒子放着中盒子,中盒子中放着小盒子,一直到我们最终要引用的对象。

fdclsFrm.Children.Item(“class name”).clsDepObj.Add

顺便说一下,我们可以在这行上面设置一个断点,然后单步调试这段代码,观察各种类的方法的调用情况,返回的指针指向下一个对象,然后调用下一个对象的方法,返回下下一个对象等,一直到最后,我们钻入到最后的Add方法。

这非常有趣!

好了,让我们回到主题。

    With fdclsFrm.Children
        .Item("cboCompany").clsDepObj.Add .Item("cboEmployee"), cboEmployee.Name
        .Item("cboCompany").clsDepObj.Add fdclsFrm, Me.Name
        .Item("cboCompany").Requery
    End With

我们已经告诉dclsFrm要添加一些东西到clsDepObj。“一些东西”就是组合框控件cboEmployee的类对象。

        .Item("cboCompany").clsDepObj.Add .Item("cboEmployee"), cboEmployee.Name

类似的,我让窗体本身也从属与组合框控件cboCompany,所以我们也需要告诉cboCompany,dclsFrm也是一个从属对象。

        .Item("cboCompany").clsDepObj.Add fdclsFrm, Me.Name

最后,我们还需要重新查询cboCompany,以便它能重新查询它的从属对象。

        .Item("cboCompany").Requery

引用窗体的Children集合对象,找到为cboCompany创建的对象,适用其clsDepObj的方法获取从属对象的引用,使用从属对象的Add方法,添加一个从属对象到从属对象集中。被添加的对象是cboEmployee的类对象和窗体本身,fdclsFrm。一旦我们将这两个项目添加到从属对象集中,重新查询整个结构。

我非常清楚,当你第一看到这些东西的时候,你肯定是眼花缭乱的。但是,请相信我,一旦你使用了类,方法,属性和集合一段时间后,这会变成你的第二天性。

总结

通过添加一个新的,只有一个集合变量,和几个公用方法的小的类,我们创建了一个操控从属对象的方法。一旦类创建好,每个需要从属对象功能的控件类,只需要添加几行代码就可以了。很有可能,最大的难点,是在窗体打开事件中,需要找到正确的控件,引用到它的从属对象,然后添加该控件的从属对象。这两个从属对象分别是cboEmployee和窗体本身。

最后的结果就是,当窗体被打开以后,没有任何记录显示出来,因为公司还没有选择,而窗体的绑定数据源中,公司作为了筛选条件。一旦我们选择了一个公司,公司组合框控件就有了值,这个值就可以用来筛选窗体和组合框控件cboEmployee。因为cboCompany会调用它的从属对象clsDepObj的重新查询方法Requery,窗体的类会重新查询窗体本身,cboEmployee的控件类会重新查询cboEmployee。顺便说一下,如果cboEmployee也被编码了,拥有自己的从属对象,cboEmployee的类对象也会重新查询它所有的从属对象,整个过程,就想波纹一样,自动的一环套一环的执行下去。

类是非常强大的工具,允许我们将功能封装在一个地方,将所有的实现必要的行为相关的变量和代码封装在一个位置,如果有必要的话,我们只需要到这个地方修改或添加新的功能。你可以将这个独立的类放到你自己的框架中,只需要相对少的功夫,就能为你自己的组合框和其他控件添加从属对象功能。从属对象处理是非常小而琐碎的功能,但是想象一下,如果类拥有十几个属性和方法会有会怎样?封装性和可移植行成为了学习使用类的首要原因。

译者注:本章介绍的从属对象非常不好理解,要搞清楚三点,一是,谁是从属对象(cboEmployee,fdclsFrm),谁是被从属对象(cboComanpy),被从属对象的变化会导致从属对象的变化。第二点是要知道如何设置窗体的绑定数据,以及组合框控件的绑定数据源,当然Access比较基础的知识。最后一点是,要了解,如果没有本章介绍的从属对象,那些在绑定的数据源中引用了被从属控件值的窗体,在被从属控件值更新后,会自动获得更新吗?答案当然是否定的。

最后,我做了这个示例文件8.1 从属对象,在该示例文件中,有3个窗体:frmPeople10,frmPeople11,frmPeople12,分别演示了未应用任何从属对象类的情况,为cboCompany添加了从属对象,为cboCompany和cboEmployee都添加了从属对象的情况。读者可以分别测试一下,看看效果差别在哪里。

后记:翻译到此章的时候,一直萦绕在我脑中的一个疑问有了一个答案。还是那个比较恼人的对象卸载问题。按照Colby的类模板的要求,对象在卸载自己的时候,首先卸载自己colChildren中的子对象,卸载子对象前,先执行子对象的Term()方法。问题来了:很显然,本章介绍的clsDepObj中的集合mcolDepObj中的对象,肯定都不是由其父对象dclsCtlComboBox所实例化,当卸载clsDepObj的时候,也需要执行它的集合中的子对象的Term()方法吗?答案当然是否定的。那么什么时候执行子对象的Term()方法?什么时候不用执行呢?

大家可以注意到,我在类模板的最后,添加了下面一个私有方法。什么时候执行Term(),由子对象的父对象是否是当前对象来决定。因为每个对象的父对象只有一个,就是实例化自己的对象。

'该方法将类对象中的子集对象清除掉,若子对象的父对象是自己的时候,执行Term
Private Sub RemoveChildren()
    Dim obj As Object
    
    If mcolChildren Is Nothing Then Exit Sub
    With mcolChildren
        While .Count > 0
            If .Item(.Count).Parent Is Me Then .Item(.Count).Term
            .remove .Count
        Wend
    End With
    Set mcolChildren = Nothing
    
End Sub

相应的,重写后的每个类中,都有类似的代码。这样就解决了,什么时候该释放类,什么时候只是清除其指针的问题。

Colby自始至终都未曾论述过这个问题,相反,他创建了一个叫做实例堆的类clsInstanceStack(后续系列中会谈到),这个类用通俗的话来讲,就是对象的生死簿。主要用来做故障查询和除错用的。个人觉得,如果不把问题的根源找到,而只是在出现问题的时候,再来调试查错,还是于事无补的。

对象由谁实例化的,谁就是这个对象的父对象,这个对象的销毁也必须由其父对象来完成,其他的类对象,如果在其子集中引用了该类对象,那么只能在其子集中清除引用指针就好了。总结成一句话,谁创造的,谁才能毁灭

《Access 开发框架(翻译+改编)系列之八——从属对象》有9个想法

  1. 你好,
    一直以来的困惑是:如何开发高度复杂的报表?
    先生能否介绍一些报表资料.谢谢!

    1. 高度复杂的报表?描述得不够具体,好难回答啊。不过以我最大的想象力来看,如果Access自带的报表模块不能满足你的复杂性的话,你还可以将数据写入到Excel和Word中,运用Excel和Word的特有功能,满足你高度复杂的报表需求。
      关于Access中控制Excel,以前做市场数据分析预测的时候,开发过很多类似应用,比如数据源是某个大型数据库,Oracle/Teradata等,使用ODBC将Access链接到数据仓库后,写一些SQL语句抓取其中的数据保存到本地Access中,然后运行一些计算逻辑,商业规则等,最后将结果输出到Excel模板中的报表数据区,再利用Excel自带的公式图表功能,形成最终的报表。财务数据分析中也能找到很多类似的应用场景。
      关于Access中控制Word,现在在人事部门有很多此类应用。比如合同自动化,Offer letter自动化等。很多合同条款针对不同的城市,不同的岗位,不同的部门,都有不同的表述。既可以在Access中判断这些逻辑,然后写入到Word模板,也可以利用Word自带的域(英文叫Field,有点类似Excel中的公式)来进行自动化。

      1. 谢谢指导!
        如您所说,access自带报表 或者一般参考书籍\网络的示例报表,都太过简单:
        1:不能借此获知报表到底有哪些对象,属性,方法和事件,(窗体则有充分的资料和示例);
        2:甚至不知道access报表能否接受:非本地的数据源(服务器端的recordset);
        3:access报表如\能接受非本地的,复杂的,数据源,能否至少将其输出呈现为excel透视表的格式,且在任意一个方框(同一长字符串)内部,可以任意设定字体大小-颜色-下划线-背景色,进而有右键弹窗,回复保存等功能;

        —-以上功能,几百万定制erp报表未必会有,但也不超过excel的vba功能. 既然是access能在”开其擅长领域能做到最好”.我想用access直接实现以上报表功能和需求(而非excel或word).

  2. 群主你好,
    如你所说,access报表的自带示例 或 书籍\网络的应用示例,其功能和界面都太过简单.
    1:无从顺藤摸瓜,了解掌握报表有什么属性,方法,事件及其组合应用(窗体则有充分的资料和示例,窗体本身也更直观);
    2:甚至不能知道,access报表能否接受非本地/本文件的数据源(服务器端的recordset,群主能否介绍一些资料?);
    3:access报表即使能接受非本地的,复杂的数据源,又如何输出呈现为:
    a:类似于excel透视表的样式,数个单元格/方框可以合并居中;
    b:可以对任意一个方框内的全部/部分字符串,任意设定其:字体/大小/样色/背景色;
    c:更进一步,可以插入总结文字\图片\附件链接等,外加右键弹窗;

    上述第3点的功能需求,几百万的erp未必能有,但也不超过excel的vba功能.access是”在其擅长的领域能做到最好”的,上述功能需求,就应该可以用access报表直接实现,而不是借力与excel 或 word.

    1. 你对access报表功能的想象有点局限于Excel的经验了,既然你希望实现像Excel那样的报表功能,为什么不直接使用Excel呢?Excel现在有一组Power BI免费插件:Power Query(解决数据源问题) / Power Pivot(解决数据之间关联问题,或者称之为‘数据模型’,其实就是Access中的‘表之间关系’) / Power View(解决数据呈现问题) / Power Map(解决数据地图问题)应该更符合你的需求。
      Access适合解决一些小规模的多用户事务处理问题,类似于’ERP系统’,当然因为它有数据库引擎的原因,也适合用来做中等规模的数据加工整理分析工作。纯粹的基于某个数据源的报表,可以使用Power BI或者Tableau。如果一定要使用Access,多半是因为看重了它的自动化功能。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注