Thursday, August 11, 2005

View Observer

Separation of presentation and domain can often be complex, especially concerning testing. Martin Fowler is developing a new book that provides patterns giving guidance on this topic. At my previous project we did something similar but not yet documented.

How It Works
Conceptually the view observer is quite easy. The view contains setters for any state that is dynamic and raises events in response to user actions. The views implement interfaces allowing for easy stubbing when testing observers. The observers observe the view and respond to events by changing any appropriate state and reloading the entire view.

When To Use It
View Observer is similar to both Model View Presenter (MVP) and Presentation Model (PM). Similar to MVP, the observer takes a view via constructor injection. When using MVP the most common approach to handling user interaction is to have the view call methods on the presenter. Unfortunately, the view then becomes tightly coupled to the presenter. Another option would be to add methods to the view that allow you to add delegates to the events of the view. The downside is that this introduces more logic in the view. View Observer handles user interaction issues by raising events in the view and handling them in the observers. This decreases coupling and reduces the logic in the view.

Unlike MVP but similar to PM, the observer maintains the state of the view. By maintaining the state in the observer the view can be fully refreshed after each state change. Obviously, this can be optomized if necessary, but coarse-grained refreshes reduce complexity and are recommended. Additionally, View Observer is easily testable via a xUnit tool instead of using view based testing.

Example (C#)
I'm going to use the common example Martin is using in the new book to demonstrate View Observer. The view is a simple Win Form with setters for the dynamic state and event handlers that raise new events. The View implements IAlbumView to allow easy testing using stubs or mocks.

public class AlbumForm : Form, IAlbumView
{
private ListBox albumListBox;
private Label artistLabel;
private Label titleLabel;
private TextBox artistTextBox;
private TextBox titleTextBox;
private CheckBox classicalCheckBox;
private Label composerLabel;
private TextBox composerTextBox;
private Button applyButton;
private Button cancelButton;
public event UserAction ApplyButtonClick;
public event UserAction CancelButtonClick;
public event UserAction ClassicalCheckBoxCheck;
public event TextChangedUserAction ArtistTextChanged;
public event TextChangedUserAction TitleTextChanged;
public event TextChangedUserAction ComposerTextChanged;
public event IndexChangedUserAction AlbumListBoxSelectedIndexChanged;

[Windows Form Designer generated code, dispose, & default constructor removed]

private void applyButton_Click(object sender, EventArgs e)
{
if (ApplyButtonClick!=null) { ApplyButtonClick(); }
}

private void cancelButton_Click(object sender, EventArgs e)
{
if (CancelButtonClick!=null) { CancelButtonClick(); }
}

private void albumListBox_SelectedIndexChanged(object sender, EventArgs e)
{
if (AlbumListBoxSelectedIndexChanged!=null) { AlbumListBoxSelectedIndexChanged(albumListBox.SelectedIndex); }
}

private void classicalCheckBox_CheckedChanged(object sender, EventArgs e)
{
if (ClassicalCheckBoxCheck!=null) { ClassicalCheckBoxCheck(); }
}

private void artistTextBox_TextChanged(object sender, EventArgs e)
{
if (ArtistTextChanged!=null) { ArtistTextChanged(artistTextBox.Text); }
}

private void titleTextBox_TextChanged(object sender, EventArgs e)
{
if (TitleTextChanged!=null) { TitleTextChanged(titleTextBox.Text); }
}

private void composerTextBox_TextChanged(object sender, EventArgs e)
{
if (ComposerTextChanged!=null) { ComposerTextChanged(composerTextBox.Text); }
}

public string[] AlbumListBoxItems
{
set { albumListBox.DataSource = value; }
}

public string ArtistTextBoxText
{
set { artistTextBox.Text = value; }
}

public string TitleTextBoxText
{
set { titleTextBox.Text = value; }
}

public bool ClassicalCheckBoxChecked
{
set { classicalCheckBox.Checked = value; }
}

public string ComposerTextBoxText
{
set { composerTextBox.Text = value; }
}

public int AlbumListBoxSelectedIndex
{
set { albumListBox.SelectedIndex = value; }
}

public bool ComposerTextBoxEnabled
{
set { composerTextBox.Enabled = value; }
}
}

The Observer is responsible for syncronizing the Model with the observer. The model for this example is an album.

public class Album : IAlbum
{
private string title;
private bool isClassical;
private string artist;
private string composer;

public string Title
{
get { return title; }
set { title = value; }
}

public bool IsClassical
{
get { return isClassical; }
set { isClassical = value; }
}

public string Artist
{
get { return artist; }
set { artist = value; }
}

public string Composer
{
get { return composer; }
set { composer = value; }
}
}

The Observer contains fields that maintain the state of the View's controls. It accepts the View and the Model via constructor injection. In the constructor the Observer stores the reference to both the View and the Model and subscribes to the events published by the View.

public class AlbumObserver
{
private readonly IAlbumView view;
private readonly IAlbum[] albums;
private bool isListening = true;
private string artistTextBoxText;
private string titleTextBoxText;
private bool classicalCheckBoxChecked;
private string composerTextBoxText;
private int albumListBoxSelectedIndex;

public AlbumObserver(IAlbumView view, IAlbum[] albums)
{
this.view = view;
this.albums = albums;
view.AlbumListBoxSelectedIndexChanged+=new IndexChangedUserAction(SelectedIndexChanged);
view.CancelButtonClick+=new UserAction(ReloadFromModel);
view.ClassicalCheckBoxCheck+=new UserAction(ClassicCheckChanged);
view.ApplyButtonClick+=new UserAction(SaveToModel);
view.ArtistTextChanged+=new TextChangedUserAction(ArtistTextChanged);
view.ComposerTextChanged+=new TextChangedUserAction(ComposerTextChanged);
view.TitleTextChanged+=new TextChangedUserAction(TitleTextChanged);
ReloadFromModel();
}

...
}

The Observer is responsible for reloading the View after every state change and when the Observer is constructed. Changes to the model also cause the View to be reloaded. The isListening guard clause is needed to ensure that an infinite loop does not occur when reloading the View.

public class AlbumObserver
{
private void reloadView()
{
if (!isListening)
{
return;
}
isListening = false;
view.ArtistTextBoxText = artistTextBoxText;
view.ClassicalCheckBoxChecked = classicalCheckBoxChecked;
view.ComposerTextBoxText = classicalCheckBoxChecked?composerTextBoxText:string.Empty;
view.TitleTextBoxText = titleTextBoxText;
view.AlbumListBoxItems = createAlbumStringArray();
view.AlbumListBoxSelectedIndex = albumListBoxSelectedIndex;
view.ComposerTextBoxEnabled = classicalCheckBoxChecked;
isListening = true;
}

public void ReloadFromModel()
{
IAlbum selectedAlbum = albums[albumListBoxSelectedIndex];
artistTextBoxText = selectedAlbum.Artist;
classicalCheckBoxChecked = selectedAlbum.IsClassical;
composerTextBoxText = selectedAlbum.Composer;
titleTextBoxText = selectedAlbum.Title;
reloadView();
}

private string[] createAlbumStringArray()
{
string[] result = new string[albums.Length];
for (int i=0;i<result.Length;i++)
{
result[i] = albums[i].Title;
}
return result;
}

...
}

Saving to the model is quite straight forward.

public class AlbumObserver
{
public void SaveToModel()
{
IAlbum selectedAlbum = albums[albumListBoxSelectedIndex];
selectedAlbum.Artist = artistTextBoxText;
selectedAlbum.IsClassical = classicalCheckBoxChecked;
selectedAlbum.Composer = classicalCheckBoxChecked?composerTextBoxText:string.Empty;
selectedAlbum.Title = titleTextBoxText;
ReloadFromModel();
}

...
}

Lastly, the events raised from the View change the appropriate state and cause the View to be refreshed.

public class AlbumObserver
{
public void SelectedIndexChanged(int newIndex)
{
if (isListening)
{
albumListBoxSelectedIndex = newIndex;
ReloadFromModel();
}
}

public void ClassicCheckChanged()
{
if (isListening)
{
classicalCheckBoxChecked=!classicalCheckBoxChecked;
reloadView();
}
}

public void ArtistTextChanged(string text)
{
if (isListening)
{
artistTextBoxText = text;
reloadView();
}
}

public void ComposerTextChanged(string text)
{
if (isListening)
{
composerTextBoxText = text;
reloadView();
}
}

public void TitleTextChanged(string text)
{
if (isListening)
{
titleTextBoxText = text;
reloadView();
}
}

...
}
Post a Comment