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();
}
}
...
}
I've been studying the View Observer sample you've posted here for a while now and must say that it really is a very cool way to separate the presentation and domain. The best part about the sample is the ability to test the UI code thoroughly.
ReplyDeleteThe only concern I have is that you might have chatty interfaces, but that can be solved by changing the way the model data gets passed to the form. Keep up the cool postings...
I like what you've done here, very clean. I used something similar myself, but without the pattern name! Good ideas are almost always the simplest!
ReplyDeleteNice separation, and I don't see any real downsides either. Lends itself nicely to TDD too, which is another great benefit...
Do have the code in a zipped format?
ReplyDeleteTIA
Yaz
I don't, sorry. This post should provide all of the code necessary to implement the pattern. If it doesn't, let me know and I'll fill in the blanks.
ReplyDeleteThe observer class's constructor expects an IVIEW and IAlbums[]. Does the view create a collection of albums and each album implement s IAlbum and then you pass it to the Observer?
ReplyDeleteTIA
Yaz
The view does not create a collection. The collection is a collection of your domain model objects in this example. If this were an actual application the objects would likely be created from database data either on demand or on application start.
ReplyDeleteYou previously asked for sample code, this is similar enough it should provide you with some value.
http://jayfields.com/src/samplesmartclient.zip
That sample comes from this write up: http://jayfields.blogspot.com/2005/11/smart-client-development-part-iv.html
We used several different patterns for testing View Observer. In the end we preferred stubs. However, you can also use NMock if you add events
ReplyDelete