- End to End GUI Development with Qt5
- Nicholas Sherriff Guillaume Lazar Robin Penea Marco Piccolino
- 3293字
- 2021-06-10 19:27:14
Implementing the model
The data is ready to be exposed to potential clients (the applications that will display and edit its content). However, a direct connection between the client and the database will make a very strong coupling. If we decide to switch to another storage type, the view would have to be rewritten, partially at least.
This is where the model comes to our rescue. It is an abstract layer that communicates with the data (our database) and exposes this data to the client in a data-specific, implementation-agnostic form. This approach is a direct offspring of the MVC (Model View Controller) concept. Let's recapitulate how MVC works:
- The Model manages the data. It is responsible for requesting for the data and updating it.
- The View displays the data to the user.
- The Controller interacts with both the Model and the View. It is responsible for feeding the View with the correct data and sending commands to the Model based on the user interaction received from the View.
This paradigm enables swapping various parts without disturbing the others. Multiple views can display the same data, the data layer can be changed, and the upper parts will not be aware of it.
Qt combines the View and the Controller to form the Model/View architecture. The separation of the storage and the presentation is retained while being simpler to implement than a full MVC approach. To allow editing and view customization, Qt introduces the concept of Delegate, which is connected to both the Model and the View:
The Qt documentation about Model/View is truly plethoric. It is nevertheless easy to get lost in the details; it feels sometimes a bit overwhelming. We will try to clear things up by implementing the AlbumModel class and seeing how it works.
Qt offers various Model sub-classes that all extend from QAbstractItemModel. Before starting the implementation, we have to carefully choose which base class will be extended. Keep in mind that our data are variations on lists: we will have a list of albums, and each album will have a list of pictures. Let's see what Qt offers us:
- QAbstractItemModel: This class is the most abstract, and therefore, the most complex, to implement. We will have to redefine a lot of functions to properly use it.
- QStringListModel: This class is a model that supplies strings to views. It is too simple. Our model is more complex (we have custom objects).
- QSqlTableModel (or QSqLQueryModel): This class is a very interesting contender. It automatically handles multiple SQL queries. On the other hand, it works only for very simple table schemas. In the pictures table, for example, the album_id foreign key makes it very hard to fit this model. You might save some lines of code, but if feels like trying to shoehorn a round peg into a square hole.
- QAbstractListModel: This class provides a model that offers one-dimensional lists. This fits nicely with our requirements, saves a lot of key strokes, and is still flexible enough.
We will go with the QabstractListModel class and create a new C++ class named AlbumModel. Update the AlbumModel.h file to look like this:
#include <QAbstractListModel> #include <QHash> #include <vector> #include <memory> #include "gallery-core_global.h" #include "Album.h" #include "DatabaseManager.h" class GALLERYCORESHARED_EXPORT AlbumModel : public QAbstractListModel { Q_OBJECT public: enum Roles { IdRole = Qt::UserRole + 1, NameRole, }; AlbumModel(QObject* parent = 0); QModelIndex addAlbum(const Album& album); int rowCount(const QModelIndex& parent = QModelIndex()) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; bool setData(const QModelIndex& index, const QVariant& value, int role) override; bool removeRows(int row, int count, const QModelIndex& parent) override; QHash<int, QByteArray> roleNames() const override; private: bool isIndexValid(const QModelIndex& index) const; private: DatabaseManager& mDb; std::unique_ptr<std::vector<std::unique_ptr<Album>>> mAlbums; };
The AlbumModel class extends the QAbstractListModel class and has only two members:
- mDb: This is the link to the database. In the Model/View schema, the model will communicate with the data layer through mDb.
- mAlbums: This acts as a buffer that will avoid hitting the database too much. The type should remind you of what we wrote for AlbumDao::albums() with the smart pointers.
The only specific functions the AlbumModel class has are addAlbum() and isIndexValid(). The rest are overrides of QAbstractListModel functions. We will go through each of these functions to understand how a model works.
First, let's see how the AlbumModel class is constructed in the AlbumModel.cpp file:
AlbumModel::AlbumModel(QObject* parent) : QAbstractListModel(parent), mDb(DatabaseManager::instance()), mAlbums(mDb.albumDao.albums()) { }
The mDb file is initialized with the DatabaseManager singleton address, and, after that, we see the now famous AlbumDao::albums() in action.
The vector type is returned and initializes mAlbums. This syntax make the ownership transfer automatic without any need for an explicit call to the std::move() function. If there are any stored albums in the database, mAlbums is immediately filled with those.
Each time the model interacts with the view (to notify us about changes or to serve data), mAlbums will be used. Because it is in memory only, reading will be very fast. Of course, we have to be careful about maintaining mAlbum coherently with the database state, but everything will stay inside the AlbumModel inner mechanics.
As we said earlier, the model aims to be the central point to interact with the data. Each time the data changes, the model will emit a signal to notify the view; each time the view wants to display data, it will request the model for it. The AlbumModel class overrides everything needed for read and write access. The read functions are:
- rowCount(): This function is used to get the list size
- data(): This function is used to get a specific piece of information about the data to display
- roleNames(): This function is used to indicate to the framework the name for each "role". We will explain in a few paragraphs what a role is
The editing functions are:
- setData(): This function is used to update data
- removeRows(): This function is used to remove data
We will start with the read part, where the view asks the model for the data.
Because we will display a list of albums, the first thing the view should know is how many items are available. This is done in the rowCount() function:
int AlbumModel::rowCount(const QModelIndex& parent) const { return mAlbums->size(); }
Being our buffer object, using mAlbums->size() is perfect. There is no need to query the database, as mAlbums is already filled with all the albums of the database. The rowCount() function has an unknown parameter: a const QModelIndex& parent. Here, it is not used, but we have to explain what lies beneath this type before continuing our journey in the AlbumModel class.
The QModelIndex class is a central notion of the Model/View framework in Qt. It is a lightweight object used to locate data within a model. We use a simple QAbstractListModel class, but Qt is able to handle three representation types:
Let's now see the models in detail:
- List Model: In this model, the data is stored in a one-dimensional array (rows)
- Table Model: In this model, the data is stored in a two-dimensional array (rows and columns)
- Tree Model: In this model, the data is stored in a hierarchical relationship (parent/children)
To handle all these model types, Qt came up with the QModelIndex class, which is an abstract way of dealing with them. The QModelIndex class has the functions for each of the use cases: row(), column(), and parent()/child(). Each instance of a QModelIndex is meant to be short-lived: the model might be updated and thus the index will become invalid.
The model will produce indexes according to its data type and will provide these indexes to the view. The view will then use them to query back new data to the model without needing to know if an index.row() function corresponds to a database row or a vector index.
We can see the index parameter in action with the implementation of data():
QVariant AlbumModel::data(const QModelIndex& index, int role) const { if (!isIndexValid(index)) { return QVariant(); } const Album& album = *mAlbums->at(index.row()); switch (role) { case Roles::IdRole: return album.id(); case Roles::NameRole: case Qt::DisplayRole: return album.name(); default: return QVariant(); } }
The view will ask for data with two parameters: an index and a role. As we have already covered the index, we can focus on the role responsibility.
When the data is displayed, it will probably be an aggregation of multiple data. For example, displaying the picture will consist of a thumbnail and the picture name. Each one of these data elements needs to be retrieved by the view. The role parameter fills this need, it associates each data element to a tag for the view to know what category of data is shown.
Qt provides various default roles (DisplayRole, DecorationRole, EditRole, and so on) and you can define your own if needed. This is what we did in the AlbumModel.h file with the enum Roles: we added an IdRole and a NameRole.
The body of the data() function is now within our reach! We first test the validity of the index with a helper function, isIndexValid(). Take a look at the source code of the chapter to see what it does in detail. The view asked for data at a specific index: we retrieve the album row at the given index with *mAlbums->at(index.row()).
This returns a unique_ptr<Album> value at the index.row() index and we dereference it to have an Album&. The const modifier is interesting here because we are in a read function, and it makes no sense to modify the album row. The const modifier adds this check at compile time.
The switch on the role parameter tells us what data category should be returned. The data() function returns a QVariant value, which is the Awiss Army Knife of types in Qt. We can safely return the album.id(), album.name(), or a default QVariant() if we do not handle the specified role.
The last read function to cover is roleNames():
QHash<int, QByteArray> AlbumModel::roleNames() const { QHash<int, QByteArray> roles; roles[Roles::IdRole] = "id"; roles[Roles::NameRole] = "name"; return roles; }
At this level of abstraction, we do not know what type of view will be used to display our data. If the views are written in QML, they will need some meta-information about the data structure. The roleNames() function provides this information so the role names can be accessed via QML. If you are writing for a desktop widget view only, you can safely ignore this function. The library we are currently building will be used for QML; this is why we override this function.
The reading part of the model is now over. The client view has everything it needs to properly query and display the data. We shall now investigate the editing part of AlbumModel.
We will start with the creation of a new album. The view will build a new Album object and pass it to Album::addAlbum() to be properly persisted:
QModelIndex AlbumModel::addAlbum(const Album& album) { int rowIndex = rowCount(); beginInsertRows(QModelIndex(), rowIndex, rowIndex); unique_ptr<Album> newAlbum(new Album(album)); mDb.albumDao.addAlbum(*newAlbum); mAlbums->push_back(move(newAlbum)); endInsertRows(); return index(rowIndex, 0); }
Indexes are a way to navigate within the model data. This first thing we do is to determinate what will be the index of this new album by getting the mAlbums size with rowCount().
From here, we start to use specific model functions: beginInsertRows() and endInsertRows(). These functions wrap real data modifications. Their purpose is to automatically trigger signals for whoever might be interested:
- beginInsertRows(): This function informs that rows are about to change for the given indexes
- endInsertRows(): This function informs that rows have been changed
The first parameter of the beginInsertRows() function is the parent for this new element. The root for a model is always an empty QModelIndex() constructor. Because we do not handle any hierarchical relationship in AlbumModel, it is safe to always add the new element to the root. The following parameters are the first and last modified indexes. We insert a single element per call, so we provide rowIndex twice. To illustrate the usage of this signal, a view might, for example, display a loading message telling the user "Saving 5 new albums".
For endInsertRows(), the interested view might hide the saving message and display "Save finished".
This may look strange at first, but it enables Qt to handle automatically a lot of signaling for us and in a generic way. You will see very soon how well this works when designing the UI of the application in Chapter 12, Conquering the Desktop UI.
The real insertion begins after the beginInsertRows() instruction. We start by creating a copy of the album row with unique_ptr<Album> newAlbum. This object is then inserted in the database with mDb.albumDao.addAlbum(*newAlbum). Do not forget that the AlbumDao::addAlbum() function also modifies the passed album by setting its mId to the last SQLITE3-inserted ID.
Finally, newAlbum is added to mAlbums and its ownership is transferred as well with std::move(). The return gives the index object of this new album, which is simply the row wrapped in a QModelIndex object.
Let's continue the editing functions with setData():
bool AlbumModel::setData(const QModelIndex& index, const QVariant& value, int role) { if (!isIndexValid(index) || role != Roles::NameRole) { return false; } Album& album = *mAlbums->at(index.row()); album.setName(value.toString()); mDb.albumDao.updateAlbum(album); emit dataChanged(index, index); return true; }
This function is called when the view wants to update the data. The signature is very similar to data(), with the additional parameter value.
The body also follows the same logic. Here, the album row is an Album&, without the const keyword. The only possible value to edit is the name, which is done on the object and then persisted to the database.
We have to emit ourselves the dataChanged() signal to notify whoever is interested that a row changed for the given indexes (the start index and end index). This powerful mechanism centralizes all the states of the data, enabling possible views (album list and current album detail for example) to be automatically refreshed.
The return of the function simply indicates if the data update was successful. In a production application, you should test the database processing success and return the relevant value.
Finally, the last editing function we will cover is removeRows():
bool AlbumModel::removeRows(int row, int count, const QModelIndex& parent) { if (row < 0 || row >= rowCount() || count < 0 || (row + count) > rowCount()) { return false; } beginRemoveRows(parent, row, row + count - 1); int countLeft = count; while (countLeft--) { const Album& album = *mAlbums->at(row + countLeft); mDb.albumDao.removeAlbum(album.id()); } mAlbums->erase(mAlbums->begin() + row, mAlbums->begin() + row + count); endRemoveRows(); return true; }
The function signature should start to look familiar by now. When a view wants to remove rows, it has to provide the starting row, the number of rows to delete, and the parent of the row.
After that, just as we did for addAlbum(), we wrap the effective removal with two functions:
- The beginRemoveRows() function, which expects the parent, the starting index, and the last index
- The endRemoveRows() function, which simply triggers automatic signals in the model framework
The rest of the function is not very hard to follow. We loop on the rows left to delete and, for each one, we delete it from the database and remove it from mAlbums. We simply retrieve the album from our in-memory mAlbums vector and process the real database deletion with mDb.albumDao.removeAlbum(album.id()).
The AlbumModel class is now completely covered. You can now create a new C++ class and name it PictureModel.
We will not cover the PictureModel class in so much detail. The major parts are the same (you simply swap the data class Album for Picture). There is however one main difference: PictureModel always handles pictures for a given album. This design choice illustrates how two models can be linked with only some simple signals.
Here is the updated version of PictureModel.h:
#include <memory> #include <vector> #include <QAbstractListModel> #include "gallery-core_global.h" #include "Picture.h" class Album; class DatabaseManager; class AlbumModel; class GALLERYCORESHARED_EXPORT PictureModel : public QAbstractListModel { Q_OBJECT public: enum PictureRole { FilePathRole = Qt::UserRole + 1 }; PictureModel(const AlbumModel& albumModel, QObject* parent = 0); QModelIndex addPicture(const Picture& picture); int rowCount(const QModelIndex& parent = QModelIndex()) const override; QVariant data(const QModelIndex& index, int role) const override; bool removeRows(int row, int count, const QModelIndex& parent) override; void setAlbumId(int albumId); void clearAlbum(); public slots: void deletePicturesForAlbum(); private: void loadPictures(int albumId); bool isIndexValid(const QModelIndex& index) const; private: DatabaseManager& mDb; int mAlbumId; std::unique_ptr<std::vector<std::unique_ptr<Picture>>> mPictures; };
The interesting parts are those concerning the album. As you can see, the constructor expects an AlbumModel. This class also stores the current mAlbumId to be able to request the pictures for a given album only. Let's see what the constructor really does:
PictureModel::PictureModel(const AlbumModel& albumModel, QObject* parent) : QAbstractListModel(parent), mDb(DatabaseManager::instance()), mAlbumId(-1), mPictures(new vector<unique_ptr<Picture>>()) { connect(&albumModel, &AlbumModel::rowsRemoved, this, &PictureModel::deletePicturesForAlbum); }
As you can see, the albumModel class is used only to connect a signal to our slot deletePicturesForAlbum() which is self-explanatory. This makes sure that the database is always valid: a picture should be deleted if the owning album is deleted. This will be done automatically when AlbumModel emits the rowsRemoved signal.
Now, mPictures is not initialized with all the pictures of the database. Because we chose to restrict PictureModel to work on the pictures for a given album, we do not know at the construction of PictureModel which album to choose. The loading can only be done when the album is selected, in setAlbumId():
void PictureModel::setAlbumId(int albumId) { beginResetModel(); mAlbumId = albumId; loadPictures(mAlbumId); endResetModel(); }
When the album changes, we completely reload PictureModel. The reloading phase is wrapped with the beginResetModel() and endResetModel() functions. They notify any attached views that their state should be reset as well. Any previous data (for example, QModelIndex) reported from the model becomes invalid.
The loadPictures() function is quite straightforward:
void PictureModel::loadPictures(int albumId) { if (albumId <= 0) { mPictures.reset(new vector<unique_ptr<Picture>>()); return; } mPictures = mDb.pictureDao.picturesForAlbum(albumId); }
By convention, we decided that, if a negative album id is provided, we clear the pictures. To do it, we reinitialize mPictures with the call mPictures.reset(new vector<unique_ptr<Picture>>()). This will call the destructor on the owned vector, which in turn will do the same for the Picture elements. We force mPictures to always have a valid vector object to avoid any possible null reference (in PictureModel::rowCount() for example).
After that, we simply assign the database pictures for the given albumId to mPictures. Because we work with smart pointers at every level, we do not even see any specific semantics here. Still, mPicture is a unique_ptr<vector<unique_ptr<Picture>>>. When the = operator is called, the unique_ptr pointer overloads it and two things happen:
- The ownership of the right-hand side (the pictures retrieved from the database) is transferred to mPictures
- The old content of mPictures is automatically deleted
It is effectively the same as calling mPictures.reset() and then mPictures = move(mDb.pictureDao.picturesForAlbum(albumId)). With the = overload, everything is streamlined and much more pleasant to read.
The PictureModel shows you how flexible the model paradigm can be. You can easily adapt it to your own use case without making any strong coupling. After all, the albumModel is only used to connect to a single signal; there are no retained references. The remainder of the class is available in the source code of the chapter.