MVPパターンを業務アプリに適用する − 画面遷移

MVPパターンにおける画面遷移のやり方を調べていたが、具体的な情報があまりなく、これだというのが無かったので自分なりに考えてみた。
業務アプリでやるからには極力シンプルで、誰にでもわかるやり方にする必要がある*1

とりあえず考えたのは、下図のようにプレゼンター側で遷移するビューを取り出し、必要な値を設定し表示するというやり方。

概要図

これくらい単純なのが一番いい。

ビューに値を設定する方法としては、

  1. コントロールを参照して直接値を設定する。
  2. プロパティを定義して、間接的に値を設定する。

が考えられるが、プロジェクト的には1.を採用したい。簡単だし、受け入れられ易い。プロパティってなに?って言われちゃうレベルだからね。

しかし、いくつか問題点がある。

  1. コントロールを直接参照するので、コントロール名を変更されたり、削除されたりするとコンパイルエラー
  2. 型の不一致(例えばTextBoxのTextプロパティはString型だけど、欲しいのはInteger型)

1.は、画面がころころ変わるのは事前に設計がちゃんとできていないからなので、それ以前の問題
事前の設計がきちんと行われていれば、コントロール名が変更されたり、削除される事は(ほとんど)無い。
2.は、利用する側でその都度変換をかけていては、あちこちに同じ様なコードが記述されてしまうので、
バグの温床になりかねない。

結果として、一番安全なのは、

  1. コントロールprivateにして、外部からアクセスできないようにする*2
  2. コントロールの値に外部からアクセスする為のプロパティを用意する。
  3. その際、型変換が必要な場合は行う。

3.は、プロパティを定義してもらう為の説得材料にもなりそう。こうしないと危険でしょ?みたいな。

実装

ということで、プレゼンターからビューにアクセスする為の変更を稚拙のMVPフレームワークに施していく。

まずは、PresenterBaseにコンテナからビューを取得する為のメソッドを追加する。

PresenterBase.vb(一部省略)
Public MustInherit Class PresenterBase(Of TView As Form)
    Private _container As IUnityContainer

    Public Sub SetContainer(ByVal container As IUnityContainer)
        _container = container
    End Sub

    Protected Function GetView(Of T As Form)() As T
        Return _container.Resolve(Of T)()
    End Function
End Class

コンテナの設定は後でやるとして、GetViewというメソッドを定義した。

このメソッドは単に型パラメータで渡された型のインスタンスResolveメソッドで取り出して返すだけ。

プレゼンターへのコンテナの設定はPresenterExtensionが行う。

PresenterExtension.vb(一部省略)

Public NotInheritable Class PresenterExtension
    Inherits UnityContainerExtension

    Protected Overrides Sub Initialize()
        Me.Context.Strategies.Add( _
            New PresenterInitializeStrategy(Me.Container), UnityBuildStage.Setup _
        )
    End Sub

    Public NotInheritable Class PresenterInitializeStrategy
        Inherits BuilderStrategy

        Private _container As IUnityContainer

        Public Sub New(ByVal container As IUnityContainer)
            _container = container

            Initialize()
        End Sub

        Public Overrides Sub PostBuildUp(ByVal context As IBuilderContext)
            Dim view = context.Existing
            Dim viewType = view.GetType()

            If _presenterDefinitions.ContainsKey(viewType) Then
                Dim presenter = _container.Resolve(_presenterDefinitions(viewType))

                presenter.SetView(view)
                presenter.SetContainer(_container)
            End If

            MyBase.PostBuildUp(context)
        End Sub
    End Class
End Class

見ての通り。

サンプルプログラムには画面遷移が無いので、画面遷移するように下図のメニュー画面を作る。

画面イメージ

コードはこんな感じ

MenuView.vb
Public Class MenuView
    Public Event MenuClick(ByVal sender As Object, ByVal e As MenuClickEventArgs)

    Private Sub menu_Click(ByVal sender As Object, ByVal e As EventArgs) Handles btnCalc.Click
        RaiseEvent MenuClick(sender, New MenuClickEventArgs(DirectCast(sender, Button).Tag))
    End Sub
End Class

Public Class MenuClickEventArgs
    Inherits EventArgs

    Private _menuKind As MenuKind

    Public ReadOnly Property MenuKind() As MenuKind
        Get
            Return _menuKind
        End Get
    End Property

    Public Sub New(ByVal menuKind As MenuKind)
        _menuKind = menuKind
    End Sub
End Class

メニューをいくつか定義するつもりなので、プレゼンター側でボタンのイベントハンドラを登録させるのはやめて、メニューがクリックされた事を通知する為の専用のイベントを定義した。

クリックされたメニューはMenuClickEventArgsクラスのMenuKindプロパティで判断できるようになっている。

MenuKind列挙型の定義は以下

Enums.vb
Public Enum MenuKind
    計算機 = 1
End Enum

で、このメニュー画面のプレゼンター

MenuViewPresenter.vb
Public Class MenuViewPresenter
    Inherits PresenterBase(Of MenuView)

    Protected Overrides Sub OnViewSet(ByVal view As MenuView)
        If view IsNot Nothing Then
            AddHandler view.MenuClick, AddressOf view_MenuClick
        End If

        MyBase.OnViewSet(view)
    End Sub

    Private Sub view_MenuClick(ByVal sender As Object, ByVal e As MenuClickEventArgs)
        Select Case e.MenuKind
            Case MenuKind.計算機
                Dim calcView = GetView(Of CalcView)()
                calcView.StartPosition = FormStartPosition.CenterParent
                calcView.ShowDialog(Me.View)

            Case Else
        End Select
    End Sub
End Class

やっている事はMenuClickイベントにイベントハンドラを登録し、そのイベントハンドラGetViewメソッドを使ってCalcViewというビュー*3を取り出し表示しているだけ。

実行して「計算機」ボタンをクリックすると、こんな感じ

スクリーンキャプチャ

これで適当な値を入力して計算したりなんかして、計算画面を閉じて、もう一度「計算機」ボタンをクリックすると前の値は保持されるずに初期状態の計算画面が表示される。

GetViewメソッドはDIコンテナから新しいオブジェクトを取り出すだけなので、毎回違うオブジェクトだからこの動きは当然の結果。

でも、使い捨ての画面ならこれでもいいけど、画面の値をずっと保持していたい場合なんかがあった場合、別途画面のインスタンスをどこかに保持しておくか、毎回画面の値をセットし直すなどのなんらかの対処をする必要がある。

こういう事はあまりしたくないので、DIコンテナから毎回同じインスタンスを取得できる、つまりSingletonなビューを取り出せるメソッドを用意しておく。

PresenterBase.vb(一部省略)

Protected Function GetViewOfSingleton(Of T As Form)(ByVal viewName As String) As T
    Dim view = _container.Resolve(Of T)(viewName)
    Dim singletonView = TryCast(view, SingletonViewBase)
    ' オブジェクトのLifetimeをSingletonで管理するオブジェクト
    Dim lifetimeMgr = New ContainerControlledLifetimeManager()

    If singletonView IsNot Nothing Then
        singletonView.SetLifetime(lifetimeMgr)
    End If
    ' Singletonで登録する。
    ' 次回以降、コンテナからSingletonで取り出せるようになる。
    _container.RegisterInstance(viewName, view, lifetimeMgr)

    Return view
End Function

Protected Function GetViewOfSingleton(Of T As Form)(ByVal viewName As String) As T
    Dim view = _container.Resolve(Of T)(viewName)
    Dim lifetimeMgr = _context.GetLifetimeManager(view)

    If lifetimeMgr Is Nothing Then
        lifetimeMgr = New ContainerControlledLifetimeManager()
        ' Singletonで登録する。
        ' 次回以降、コンテナからSingletonで取り出せるようになる。
        _container.RegisterInstance(viewName, view, lifetimeMgr)

        Dim singletonView = TryCast(view, SingletonViewBase)
        ' SingletonViewBaseから継承していれば、Lifetimeの管理を任せる。
        If singletonView IsNot Nothing Then
            singletonView.SetLifetime(lifetimeMgr)
        End If
    End If
    Return view
End Function

GetViewOfSingletonメソッドで取得したビューはSingletonインスタンスになる。もちろんviewName引数で指定した名前単位でだけど。

ついでにこういうのも作っておいた。

SingletonViewBase.vb
Public Class SingletonViewBase
    Private _lifetimeMgr As ILifetimePolicy

    Friend Sub SetLifetime(ByVal lifetimeMgr As ILifetimePolicy)
        _lifetimeMgr = lifetimeMgr
    End Sub

    <System.Diagnostics.DebuggerNonUserCode()> _
    Protected Overrides Sub Dispose(ByVal disposing As Boolean)
        Try
            If disposing AndAlso components IsNot Nothing Then
                components.Dispose()
            End If
            ' singletonオブジェクトを破棄
            ' これやるとDisposeが呼ばれてStackOverflowを起こす
            'If _lifetimeMgr IsNot Nothing Then
                '_lifetimeMgr.RemoveValue()
                '_lifetimeMgr = Nothing
            'End If
        Finally
            MyBase.Dispose(disposing)
        End Try
    End Sub
End Class

GetViewOfSingletonメソッドで生成したビューがSingletonViewBaseを継承していた場合はILifetimePolicyインスタンスを設定しておき、SingletonViewBaseがDisposeされたタイミングでILifetimePolicyRemoveValueメソッドを呼び出す事で、DIコンテナ上からもインスタンスが削除されるようになっている。

これで次からは同じviewNameでも新しいインスタンスが生成されるようになる。

ビューがSingletonになる可能性ができたことでPresenterExtension側にも対応が必要になったけど、その部分は割愛しておく。

とまぁ、こんな感じでビューを生成して画面遷移していけばいいと思う。

*1:画面遷移するにはこの○○インターフェースを実装して、この○○メソッドを呼び出す必要があります〜なんてのは論外

*2:デフォルトではFriendアクセス

*3:以前のMainView