Sub function to show UserForm

后端 未结 2 2025
旧巷少年郎
旧巷少年郎 2021-02-06 14:40

I have an excel file with multiple UserForms. To open a UserForm I have code such as

Sub runAdjuster()
   Adjuster.Show
End Sub

There are about

相关标签:
2条回答
  • 2021-02-06 14:40

    Assuming Adjuster is the name of the form, you're using the default instance here, which isn't ideal.

    This would already be better:

    Dim view As Adjuster
    Set view = New Adjuster
    view.Show
    

    Yes, it's more code. But you're using a dedicated object (i.e. view) and, if that object's state gets modified, these changes aren't going to affect the default instance. Think of that default instance as a global object: it's global, which isn't very OOP.

    Now, you may argue, why not "new up" the object on the same line as the declaration then?

    Consider this:

    Sub DoSomething()
        Dim c As New Collection
        Set c = Nothing
        c.Add "test"
    End Sub
    

    Is this code accessing a null reference and blowing up with a run-time error 91? No! Confusing? Yes! Hence, avoid the As New shortcut, unless you like having VBA automagically doing implicit stuff behind your back.


    So, you're asking about best practice... I tend to consider VBA UserForms as an early pre-.NET version of winforms, and best practice design pattern for WinForms is the Model-View-Presenter pattern (aka "MVP").

    Following this pattern, you'll have UserForms strictly responsible for presentation, and you'll have your business logic either implemented in a presenter object, or in a dedicated object that the presenter uses. Something like this:

    Class Module: MyPresenter

    The presenter class receives events from the model, and executes application logic depending on the state of the model. It knows about a concept of a view, but it doesn't have to be tightly coupled with a concrete implementation (e.g. MyUserForm) - with proper tooling you could write unit tests to validate your logic programmatically, without having to actually run the code and display the form and click everywhere.

    Option Explicit
    
    Private Type TPresenter
        View As IView
    End type
    
    Public Enum PresenterError
        ERR_ModelNotSet = vbObjectError + 42
    End Enum
    
    Private WithEvents viewModel As MyModel
    Private this As TPresenter
    
    Public Sub Show()
        If viewModel Is Nothing Then
            Err.Raise ERR_ModelNotSet, "MyPresenter.Show", "Model is not set to an object reference."
        End If
        'todo: set up model properties
        view.Show
        If Not view.IsCancelled Then DoSomething
    End Sub
    
    Public Property Get View() As IView
        Set View = this.View
    End Property
    
    Public Property Set View(ByVal value As IView)
        Set this.View = value
        If Not this.View Is Nothing Then Set this.View.Model = viewModel
    End Property
    
    Public Property Get Model() As MyModel
        Set Model = viewModel
    End Property
    
    Public Property Set Model(ByVal value As MyModel)
        Set viewModel = value
        If Not this.View Is Nothing Then Set this.View.Model = viewModel        
    End Property
    
    Private Sub Class_Terminate()
        Set this.View.Model = Nothing
        Set this.View = Nothing
        Set viewModel = Nothing
    End Sub
    
    Private Sub viewModel_PropertyChanged(ByVal changedProperty As ModelProperties)
        'todo: execute logic that needs to run when something changes in the form
    End Sub
    
    Private Sub DoSomething()
        'todo: whatever needs to happen after the form closes
    End Sub
    

    Class Module: IView

    That's the abstraction that represents the concept of a View that exposes everything the Presenter needs to know about any UserForm - note that everything it needs to know, isn't much:

    Option Explicit
    
    Public Property Get Model() As Object
    End Property
    
    Public Property Set Model(ByVal value As Object)
    End Property
    
    Public Property Get IsCancelled() As Boolean
    End Property
    
    Public Sub Show()
    End Sub
    

    Class Module: MyModel

    The model class encapsulates the data that the form needs and manipulates. It doesn't know about the view, and it doesn't know about the presenter either: it's just a container for encapsulated data, with simple logic that enables both the view and the presenter to execute code when any of the properties are modified.

    Option Explicit
    
    Private Type TModel
        MyProperty As String
        SomeOtherProperty As String
        'todo: wrap members here
    End Type
    
    Public Enum ModelProperties
        MyProperty
        SomeOtherProperty
        'todo: add enum values here for each monitored property
    End Enum
    
    Public Event PropertyChanged(ByVal changedProperty As ModelProperties)
    Private this As TModel
    
    Public Property Get MyProperty() As String
        MyProperty = this.MyProperty
    End Property
    
    Public Property Let MyProperty(ByVal value As String)
        If this.MyProperty <> value Then
            this.MyProperty = value
            RaiseEvent PropertyChanged(MyProperty)
        End If
    End Property
    
    Public Property Get SomeOtherProperty() As String
        SomeProperty = this.SomeOtherProperty
    End Property
    
    Public Property Let SomeOtherProperty(ByVal value As String)
        If this.SomeOtherProperty <> value Then
            this.SomeOtherProperty = value
            RaiseEvent PropertyChanged(SomeOtherProperty)
        End If
    End Property
    
    'todo: expose other model properties
    

    UserForm: MyUserForm

    The UserForm is strictly responsible for visual presentation; all its event handlers to, is change the value of a property in the model - the model then tells the presenter "hey I've been modified!", and the presenter acts accordingly. The form also listens for modified properties on the model, so when the presenter changes the model, the view can execute code and update itself accordingly. Here's an example of a simple form "binding" the MyProperty model property to the text of some TextBox1; I added a listener for SomeOtherProperty just to illustrate that the view can also be updated indirectly when the model changes.

    Obviously the view wouldn't be reacting to the same properties changing as the presenter, otherwise you would enter an endless ping-pong of callbacks that would eventually blow up the stack... but you get the idea.

    Note that the form implements the IView interface, so that the presenter can talk to it without actually knowing about its inner workings. The interface implementation simply refers to concrete members, but the concrete members don't even need to actually exist, since they won't even be used!

    Option Explicit
    Implements IView
    
    Private Type TView
        IsCancelled As Boolean
    End Type
    
    Private WithEvents viewModel As MyModel
    Private this As TView
    
    Private Property Get IView_Model() As Object
        Set IView_Model = Model
    End Property
    
    Private Property Set IView_Model(ByVal value As Object)
        Set Model = value
    End Property
    
    Private Property Get IView_IsCancelled() As Boolean
        IView_IsCancelled = IsCancelled
    End Property
    
    Private Sub IView_Show()
        Show vbModal
    End Sub
    
    Public Property Get Model() As MyModel
        Set Model = viewModel
    End Property
    
    Public Property Set Model(ByVal value As MyModel)
        Set viewModel = value
    End Property
    
    Public Property Get IsCancelled() As Boolean
        IsCancelled = this.IsCancelled
    End Property
    
    Private Sub CancelButton_Click()
        this.IsCancelled = True
        Me.Hide
    End Sub
    
    Private Sub OkButton_Click()
        Me.Hide
    End Sub
    
    Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer)
        '"x-ing out" of the form is like clicking the Cancel button
        If CloseMode = VbQueryClose.vbFormControlMenu Then
            this.IsCancelled = True
        End If
    End Sub
    
    Private Sub UserForm_Activate()
        If viewModel Is Nothing Then
            MsgBox "Model property must be assigned before the view can be displayed.", vbCritical, "Error"
            Unload Me
        Else
            Me.TextBox1.Text = viewModel.MyProperty
            Me.TextBox1.SetFocus
        End If
    End Sub
    
    Private Sub TextBox1_Change()
        'UI elements update the model properties
        viewModel.MyProperty = Me.TextBox1.Text
    End Sub
    
    Private Sub viewModel_PropertyChanged(ByVal changedProperty As ModelProperties)
        If changedProperty = SomeOtherProperty Then
            Frame1.Caption = SomeOtherProperty
        End If
    End Sub
    

    Module: Macros

    Say your spreadsheet had a shape and you wanted to run that logic when it's clicked. You need to attach a macro to that shape - I like to regroup all macros in a standard module (.bas) called "Macros", that contains nothing but public procedures that all look like this:

    Option Explicit
    
    Public Sub DoSomething()
    
        Dim presenter As MyPresenter 
        Set presenter = New MyPresenter
    
        Dim theModel As MyModel
        Set theModel = New MyModel
    
        Dim theView As IView
        Set theView = New MyUserForm
    
        Set presenter.Model = theModel
        Set presenter.View = theView
        presenter.Show
    
    End Sub
    

    Now, if you want to test your presenter logic programmatically without showing a form, all you need to do is implement a "fake" view, and write a test method that will do what you need:

    Class: MyFakeView

    Option Explicit
    Implements IView
    
    Private Type TFakeView
        IsCancelled As Boolean
    End Type
    
    Private this As TFakeView
    
    Private Property Get IView_Model() As Object
        Set IView_Model = Model
    End Property
    
    Private Property Set IView_Model(ByVal value As Object)
        Set Model = value
    End Property
    
    Private Property Get IView_IsCancelled() As Boolean
        IView_IsCancelled = IsCancelled
    End Property
    
    Private Sub IView_Show()
        IsCancelled = False
    End Sub
    
    Public Property Get IsCancelled() As Boolean
        IsCancelled = this.IsCancelled
    End Property
    
    Public Property Let IsCancelled(ByVal value As Boolean)
        this.IsCancelled = value
    End Property
    

    Module: TestModule1

    There are probably other tools out there, but since I actually wrote this one and I like how it works without a crap ton of boilerplate setup code or comments that contain executable instructions I'm going to warmly recommend using Rubberduck unit tests. Here's what a [very simple] test module might look like:

    '@TestModule
    Option Explicit
    Option Private Module
    Private Assert As New Rubberduck.AssertClass
    
    '@TestMethod
    Public Sub Model_SomePropertyInitializesEmpty()
        On Error GoTo TestFail
    
        'Arrange
        Dim presenter As MyPresenter 
        Set presenter = New MyPresenter
    
        Dim theModel As MyModel
        Set theModel = New MyModel
    
        Set presenter.Model = theModel
        Set presenter.View = New MyFakeView
    
        'Act
        presenter.Show
    
        'Assert
        Assert.IsTrue theModel.SomeProperty = vbNullString
    
    TestExit:
        Exit Sub
    TestFail:
        Assert.Fail "Test raised an error: #" & Err.Number & " - " & Err.Description
    End Sub
    

    Rubberduck unit tests allow you to use this decoupled code to test everything you want to test about your application logic - as long as you keep that application logic decoupled and that you write testable code, you'll have unit tests that document how your VBA application is supposed to behave, tests that document what the specs are - just like you would have them in C# or Java, or any other OOP language one can write unit tests with.

    Point is, VBA can do it, too.


    Overkill? Depends. Specs changes all the time, code changes accordingly. Implementing all the application logic in spreadsheets' code-behind gets utterly annoying, because the Project Explorer doesn't drill down to module members, so finding what's implemented where can easily get annoying.

    And it's even worse when the logic is implemented in the forms' code-behind and then you have Button_Click handlers making database calls or spreadsheet manipulations.

    Code that's implemented in objects that have as few responsibilities as possible, makes code that's reusable, and that's easier to maintain.

    Your question isn't exactly precise about exactly what you mean with "an Excel file with multiple userforms", but if you need to, you could have a "main" presenter class that receives 4-5 "child" presenters, each being responsible for the specific logic tied to each "child" form.

    That said, if you have working code (that works exactly as intended) that you would like to refactor and make more efficient, or easier to read/maintain, you can post it on Code Review Stack Exchange, that's what that site is for.


    Disclaimer: I maintain the Rubberduck project.

    0 讨论(0)
  • 2021-02-06 14:50

    It depends on what launches these subs. If they are attached to a button or shape (which is what I tend to do for launching userforms) then it makes sense to put them in the module for the sheet that contains the shape. If buttons/shapes on several sheets refer to it -- put them in a general code module. I don't know if there really is a "best practice" here. The most important thing is to have consistency so that you don't have to go searching for things.

    0 讨论(0)
提交回复
热议问题