Articles MVVM Starter Kit (Part 2 of 3) by Erik van Bilsen

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
MVVM Starter Kit (Part 2 of 3) by Erik van Bilsen
January 24, 2018 Erik van Bilsen
[SHOWTOGROUPS=4,20]
In this second part of our MVVM mini series we look at ViewModels and how they can be unit tested. We assume you already read the Для просмотра ссылки Войди или Зарегистрируйся, where we talked about the MVVM pattern and data binding.
We discuss ViewModels and Views separately, leaving the discussion of Views to the Для просмотра ссылки Войди или Зарегистрируйся. Usually, you will develop your ViewModels and Views in parallel, but it may be beneficial to think about the ViewModel before you start building the View. It helps avoid the temptation to put (too much) logic in the View.
ViewModels
A ViewModel is the middleman between the Model and the View:
Mvvm-VM

A ViewModel has access to the model and can request data from it, or ask it to run business logic. Recall that the Model does not have access to the ViewModel though. The model can only indirectly update the ViewModel (or View) through data binding, which is usually initiated by the View. Likewise, the ViewModel does not have access to the View. The ViewModel contains the logic to update the state of the View, but these state changes are usually propagated via data binding. This UI logic is triggered by the View, either by calling a method of the view or by using actions (aka commands).
From a technical perspective, there isn’t that much difference between a Model and a ViewModel. Both are models: one models the business logic and the other one models the UI logic. In our Для просмотра ссылки Войди или Зарегистрируйся repository, there isn’t even a separate base class for ViewModels. Like Models, ViewModels usually derive from TgoObservable so they can be a source for data binding. Of course, it may make sense to create your own base class for ViewModels in your applications.
ViewModels in MyTunes
We discuss a couple of different ways to model your ViewModels using the MyTunes sample application in the MvvmStarterKit repository. I suggest you open the MyTunes project group in Delphi as you follow along this article. This project group contains an FMX and VCL version of the application, as well as unit tests for the Models and ViewModels.
The MyTunes app has 3 Views and 3 corresponding ViewModels: The main view shows a list of albums and allows to user to add, delete or edit an album. A second view is used to edit the properties of an album. It also has an option to edit the tracks, which opens the third view.
View(Model) vs Form
In this example, there is a one-to-one correspondence between a View(Model) and a form. That does not have to be the case however. In more complex applications, a form can contain multiple logical views, each responsible for discrete parts of the application. For example, a form may host a tab control, where each page in the tab control provides distinct functionality.
There are multiple ways you can model this. You can use one single form (or View) with all controls for all pages in the tab control, but with multiple ViewModels: one for each page in the tab control. You can also put the contents of each page of the tab control into a separate frame (which is also a View), and create a ViewModel for each frame. You can even regard a single control (such as a list view) as a View, and have a separate ViewModel for just that control.
You will have to find some balance between flexibility and complexity when you model your application. Using more granular ViewModels may make it easier to port your application to different platforms, or to make OEM customized versions. But it increases complexity and can make it more difficult to keep track of how all parts are connected.
TViewModelTracks
We’ll start with the ViewModel for editing tracks in MyTunes. This TViewModelTracks is used by TViewTracks and provides the following bindable properties:
Mvvm-TViewModelTracks

The ViewModel maintains a list of tracks in its Tracks property. This property is of type TEnumerable<TAlbumTrack> so you can only read its contents. It is backed by an object of type TgoObservableCollection<TAlbumTrack>. An observable collection is a collection that implements the IgoNotifyCollectionChanged interface, so that it can be bound to list-like controls, such as a TListView in the example above. It is similar to the TgoObservable class (and corresponding IgoNotifyPropertyChanged interface) we discussed in the first part, but it applies to a collection instead of a single entity. We will talk a bit more about this in the third part of this series.
The View is responsible for binding the properties of the ViewModel to the controls, as will also be shown in the Для просмотра ссылки Войди или Зарегистрируйся. Suffice to note here that Genres, TrackNumber and Name are bound as sub-properties (eg. ‘SelectedTrack.Name’), whereas SelectedTrackDurationSeconds and SelectedTrackDurationMinutes are bound as “regular” properties. As said in the first part, these are “computed” properties that are calculated in their getter methods.
Adding and Deleting Tracks
The ViewModel provides the UI logic to add and delete tracks. This logic is provided as “bindable actions”. These are regular methods that the View can bind to using TAction components (as we will show in the next part). There are two types of bindable methods:
  • Procedures without parameters. These execute the action.
  • Functions without parameters, returning a Boolean. These are predicates that indicate if an action can be executed.
The sample ViewModel has two execute methods (AddTrack and DeleteTrack) and a predicate function (HasSelectedTrack) which returns True if the user has selected a track in the list view. The return value of this method is used by the View the enable or disable the Delete action (since you cannot delete a track if no track is selected).
The implementation of these methods is pretty trivial:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
procedure TViewModelTracks.AddTrack;
var
Track: TAlbumTrack;
begin
Track := TAlbumTrack.Create;
FTracks.Add(Track);
SetSelectedTrack(Track);
end;

procedure TViewModelTracks.DeleteTrack;
begin
Assert(Assigned(FSelectedTrack));
FTracks.Remove(FSelectedTrack);
SetSelectedTrack(nil);
end;

function TViewModelTracks.HasSelectedTrack: Boolean;
begin
Result := Assigned(FSelectedTrack);
end;
The AddTrack and DeleteTrack methods also call the property setter for the SelectedTrack property. This will fire a couple PropertyChanged notifications, which the View will respond to by updating the selected item in the list view and the controls on the right side of the form:
1
2
3
4
5
6
7
8
9
10
procedure TViewModelTracks.SetSelectedTrack(const Value: TAlbumTrack);
begin
if (Value <> FSelectedTrack) then
begin
FSelectedTrack := Value;
PropertyChanged('SelectedTrack');
PropertyChanged('SelectedTrackDurationMinutes');
PropertyChanged('SelectedTrackDurationSeconds');
end;
end;
As you can see, you must also send notifications for any computed properties that depend and the newly selected track. Sub-properties (such as ‘SelectedTrack.Name’) are handled automatically.


[/SHOWTOGROUPS]
 

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
MVVM Starter Kit (Part 2 of 3) by Erik van Bilsen
January 24, 2018 Erik van Bilsen
[SHOWTOGROUPS=4,20]
As you can see, you must also send notifications for any computed properties that depend and the newly selected track. Sub-properties (such as ‘SelectedTrack.Name’) are handled automatically.
Unit Testing TViewModelTracks
There is nothing in this ViewModel that warrants special attention when it comes to unit testing. It is a regular class like many others and can be unit tested as such.
It becomes more interesting when you need to emulate user actions, as we will see in the next ViewModel.
TViewModelAlbum
TViewModelAlbum has only a single bindable property called Album. The TViewAlbum View binds to the properties of this album:
Mvvm-BindingProperties

The more interesting part is the action method EditTracks, which is called when the user presses the list box item “Tracks” (or the “Edit Tracks” button in the VCL version). This will open up the Tracks View, as presented earlier.
Of course, a ViewModel can never create an FMX or VCL form directly. So it uses an interface IgoView that represents a View and the factory pattern to create a View that implements this interface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
procedure TViewModelAlbum.EditTracks;
var
Clone: TAlbumTracks;
ViewModel: TViewModelTracks;
View: IgoView;
begin
Clone := TAlbumTracks.Create;
try
Clone.Assign(Album.Tracks);
ViewModel := TViewModelTracks.Create(Clone);

{ The view becomes owner of the view model }
View := TgoViewFactory.CreateView('Tracks', nil, ViewModel);
View.ExecuteModal(
procedure (AModalResult: TModalResult)
begin
if (AModalResult = mrOk) then
Album.SetTracks(Clone);
Clone.DisposeOf;
end);
except
Clone.DisposeOf;
raise;
end;
end;
Although this code is not complicated, it shows a couple of important concepts:
  • You must handle the situation where the user cancels the “Edit Tracks” dialog box. In that case, any changes the user made must be undone. We solve this here by creating a (deep) clone of the list of tracks (using the Assign method) and have the user edit that clone instead. Only when the user closes the dialog box with “OK” is the edited clone assigned back.
  • We use some defensive programming to free the clone in case an exception occurs before the View is closed.
  • We create a ViewModel for the track list clone.
  • We use the TgoViewFactory class to create a View to edit the track list. The Views in your application register themselves with this factory using a user-defined identifier (‘Tracks’ in this example). In the next part we will show an example of this. The reason for this construct is that it decouples the creation of a FMX or VCL View from the ViewModel. The ViewModel doesn’t care what kind of View is created, as long as it is registered and implements the IgoView interface.
  • By default, the View becomes owner of the ViewModel and will destroy the ViewModel when the View is closed. You can change this behavior using an optional parameter to the CreateView method.
  • Note that we use the “ViewModel first” approach here: we first create a ViewModel and then the corresponding View. There is some debate about “ViewModel first” vs “View first” approaches. Personally, I prefer “ViewModel first” since it emphases the importance of the ViewModel. But this approach is hard to implement for the main form of the application, since Delphi creates this for you. So for the main form, we use the “View first” approach and have the View itself create its ViewModel.
  • Finally, the View is executed modally using the ExecuteModal method. You pass in an anonymous method (usually a closure) that is called when the user closes the dialog box. This closure receives a single parameter AModalResult that indicates how the user closed the dialog box. When the user cancelled the dialog box, it discards any changes. Otherwise, it assigns the changes to the clone back to the album. In both cases, it has to free the Clone.
The reason for using an anonymous method here is Android. Android does not support modal forms, so the FireMonkey framework uses an anonymous method as a notification that the form has closed. We use the same approach here so it works on all platforms. Keep in mind though that ExecuteModal is non-blocking on Android, while it blocks on other platforms.
Unit Testing TViewModelAlbum
This is were unit testing gets a bit more challenging (and interesting). TViewModelAlbum is still a regular class without any UI dependencies. So parts of it can be unit tested in the regular way. It gets interesting when you want to test the EditTracks method. This method creates a View and executes it modally.
However, the View it creates doesn’t have to by a physical (VCL or FMX) view. It can be any class that implements the IgoView interface. So you can implement this interface in a mock class and register that mock class with the view factory so it can be unit tested. We provide a sample TgoMockView class that emulates the user interaction using an anonymous method. But before we get there, lets take a quick look at the setup of the unit test:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
type
TTestViewModelAlbum = class
private
FAlbum: TAlbum;
FViewModel: TViewModelAlbum;
public
[Setup] procedure Setup;
[Teardown] procedure Teardown;
end;

procedure TTestViewModelAlbum.Setup;
var
I: Integer;
Track: TAlbumTrack;
begin
FAlbum := TAlbum.Create;

FAlbum.Title := 'Album Title';
FAlbum.Artist := 'Album Artist';

for I := 0 to 2 do
begin
Track := FAlbum.AddTrack;
Track.Name := 'Track ' + I.ToString;
Track.Duration := TTimeSpan.FromMinutes(I);
Track.TrackNumber := I + 1;
end;

FViewModel := TViewModelAlbum.Create(FAlbum);
end;

procedure TTestViewModelAlbum.Teardown;
begin
FViewModel.Free;
FAlbum.Free;
end;
We use the DUnitX framework here, but you can use DUnit as well. During setup, we create a sample album and a ViewModel. We add 3 tracks to the album with simple property values that can easily be tested.
Next, we are going to add 2 unit tests to test the TViewModelAlbum.EditTracks method. The first one emulates the user editing some tracks, and cancelling the dialog box. The second one does the same, but emulates that the user pressed OK to close the dialog box. We show the second test here. The first test is similar and can be found in the repository.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
procedure TTestViewModelAlbum.TestEditTracksOK;
var
I: Integer;
Track: TAlbumTrack;
begin
{ Register a mock view that simulates the Tracks view.
It simulates a user editing some values an pressing the OK button. }
TgoMockView<TViewModelTracks>.Register('Tracks',
function (AViewModel: TViewModelTracks): TModalResult
var
Track: TAlbumTrack;
begin
{ Modify existing tracks }
for Track in AViewModel.Tracks do
begin
Track.Name := 'New ' + Track.Name;
Track.Duration := TTimeSpan.FromMinutes(Track.Duration.Minutes + 10);
Track.TrackNumber := Track.TrackNumber * 2;
end;

Result := mrOk;
end);

FViewModel.EditTracks;

{ Tracks should be modified. }
I := 0;
for Track in FAlbum.Tracks do
begin
Assert.AreEqual('New Track ' + I.ToString, Track.Name);
Assert.AreEqual(I + 10, Track.Duration.Minutes);
Assert.AreEqual((I + 1) * 2, Track.TrackNumber);
Inc(I);
end;
Assert.AreEqual(3, I);
end;
The first half of the code sets up a mock view. It uses the (generic) TgoMockView class to register a View with an id (‘Tracks’) and an anonymous method that emulates the modal execution of the View. This anonymous method receives the ViewModel as parameter and must return a TModalResult to indicate how the (emulated) user closed the dialog box.
In the anonymous method, we just edit the track properties and return mrOk (the other unit test does the same but returns mrCancel).
Then, when FViewModel.EditTracks is executed, it will use the view factory to create a View for the id ‘Tracks’. Since we registered a mock view with this id, it will create a TgoMockView instance. The ExecuteModal method of the mock view then calls our anonymous method to emulate the user actions.
In the last half of the code, we check if the track properties have been updated as expected. In the other unit test – the one that returns mrCancel – it checks that the tracks remain unchanged.
TViewModelAlbums
Finally, we have TViewModelAlbums, which is used by TViewAlbums to show the main form:
Mvvm-BindingSubProperties

The structure of this ViewModel is very similar to that of the previous ViewModel, so I won’t get into too much detail here.
The only thing that is different is that both the “Add Album” and “Edit Album” actions open the Album View. But in the first case, it should add a new album to the list (in case the user didn’t cancel), and in the second case it should update the currently selected album.
Because these two actions share some code, they use a helper method ShowAlbumView that executes the View and calls another anonymous method to either add the album or assign it to the currently selected album. See the source code for details.

[/SHOWTOGROUPS]