做项目中深深体会到了传统QTreeWidget编辑数据后内存数据、硬盘数据、树形结构数据不同步带来的不便,遂决定学习一下视图模型架构,搜了一些博文,感觉都没有官方教程上讲的流畅,于是写下这篇博客,翻译官方教程并作适当删减。
1.介绍:
模型/视图架构用于将窗体中展示数据的视图和数据进行分离。标准窗体控件(widget)并没有将数据从视图中分离,所以QT4中存在两种看起来相同但与数据交互不同的组件:widget和view.
1.1标准窗体组件(widget)
标准组件通过提供的读写方法能够很方便的将数据整合到程序中,这种方法很直接而且在很多程序中非常有用。但是该方法使用了两份数据,一份在组件中(用于数据展示)一份在组件外(数据文件本身),当对数据进行编辑时,开发人员就必须保证同步两份数据,除此之外数据和数据展示之间的紧耦合关系使得编写单元测试非常困难。
1.2模型/视图
模型视图架构更灵活且解决了数据的一致性问题,通过将一个模型传入多个视图,可以轻松做到一份数据对应多个视图。模型/视图架构最大的区别在于,单元格背后并没有存储数据拷贝,他直接操作数据本身。因为view并不知道数据的结构,你需要将你的数据进行包装使它遵从QAbstractItemModel接口,view使用该接口进行数据读写。一个QAbstractItemModel实现类的实例被称作一个模型,一旦view接收到指向model的指针,该view就会读取数据进行展示并且为数据提供编辑器。
1.3在表格和模型之间使用适配器
我们可以通过表格直接操作表格中存储的数据,但是使用文本域进行操作会方便的多。模型/视图架构并不能直接将操作一个值(而不是一个数据集)的widget进行model和view的分离(原文:There is no direct model/view counterpart that separates data and views for widgets that operate on one value (QLineEdit,QCheckBox ...) instead of a dataset),所以我们需要一个适配器来连接数据源和表格。
QDataWidgetMapper是一个很好的解决方案,它能够将一个表单组件映射到数据表中的行,这使得为数据表创建窗体表格非常方便。
另一个适配器的例子是QCompleter,QCompleter为widget(例如:QComboBox和QLineEdit)提供了自动适配。QCompleter使用一个模型作为数据源。
2.一个简单的模型/视图应用程序
2.1展示数据
先上代码:
//窗体:
ModelViewTest::ModelViewTest(QWidget *parent, Qt::WFlags flags)
: QMainWindow(parent, flags)
{
ui.setupUi(this);
MyModel* myModel = new MyModel(0);
ui.tableView->setModel(myModel);
}
MyModel.h:
class MyModel : public QAbstractTableModel
{
Q_OBJECT
public:
MyModel(QObject *parent);
~MyModel();
int rowCount(const QModelIndex& parent = QModelIndex()) const;
int columnCount(const QModelIndex& parent = QModelIndex()) const;
QVariant data(const QModelIndex & index, int role = Qt::DisplayRole ) const;
}
MyModel.cpp:
int MyModel::rowCount(const QModelIndex& parent /* = QModelIndex */)const
{
return 2;
}
int MyModel::columnCount(const QModelIndex& parent /* = QModelIndex */)const
{
return 3;
}
QVariant MyModel::data(const QModelIndex & index, int role /* = Qt::DisplayRole */ )const
{
if(role==Qt::DisplayRole)
{
return QString("Row%1,Col%2").arg(index.row()).arg(index.column());
}
return QVariant();
}
窗体效果如下:
窗体就是普通的代码,通过setModel将自定义的MyModel对象指针传给QTableView,QTableView将会自动调用model中的方法来完成两件事:获取要显示的行数和列数;确定每个单元格中要显示的内容。rowCount、columnCount和data方法皆重写自QAbstractTableModel。值得注意的是,data方法中传入参数role确定了要访问的数据作用(例如:字体、显示、对齐方式等),每个单元格对于每个role都有着对应的数据,通过data方法来请求该数据。将data中代码修改如下,能够体会到role发挥的作用。
QVariant MyModel::data(const QModelIndex &index, int role) const
{
int row = index.row();
int col = index.column();
// generate a log message when this method gets called
qDebug() << QString("row %1, col%2, role %3")
.arg(row).arg(col).arg(role);
switch(role){
case Qt::DisplayRole:
if (row == 0 && col == 1) return QString("<--left");
if (row == 1 && col == 1) return QString("right-->");
return QString("Row%1, Column%2")
.arg(row + 1)
.arg(col +1);
break;
case Qt::FontRole:
if (row == 0 && col == 0) //change font only for cell(0,0)
{
QFont boldFont;
boldFont.setBold(true);
return boldFont;
}
break;
case Qt::BackgroundRole:
if (row == 1 && col == 2) //change background only for cell(1,2)
{
QBrush redBackground(Qt::red);
return redBackground;
}
break;
case Qt::TextAlignmentRole:
if (row == 1 && col == 1) //change text alignment only for cell(1,1)
{
return Qt::AlignRight + Qt::AlignVCenter;
}
break;
case Qt::CheckStateRole:
if (row == 1 && col == 0) //add a checkbox to cell(1,0)
{
return Qt::Checked;
}
}
return QVariant();
}
显示效果:
2.2改变表单中的数据
上面介绍了如何将model数据返回给表单中,那么model中数据改变如何通知表单更新显示呢?下面例子完成了实时显示时间:每隔一秒更新单元格中显示时间。
/在构造函数中设置计时器,每隔一秒发送信号timeout
MyModel::MyModel(QObject *parent)
: QAbstractTableModel(parent)
{
mp_timer = new QTimer(this);
mp_timer->setInterval(1000);
connect(mp_timer,SIGNAL(timeout()),this,SLOT(timerHit()));
mp_timer->start();
}
//在0,0单元格内显示时间
QVariant MyModel::data(const QModelIndex &index, int role) const
{
int row = index.row();
int col = index.column();
if(row==0 && col==0)
{
return QTime::currentTime().toString("hh::mm::ss");
}
return QVariant();
}
//响应timeout信号,发送dataChanged信号,更新表单显示
void MyModel::timerHit()
{
QModelIndex index = createIndex(0,0);
emit dataChanged(index,index);
}
关键点在于通过dataChanged信号使表单自动更新显示,该信号在表单数据发生改变时立即触发,两个参数分别是左上角的item和右下角的item,如果参数范围内的item拥有相同的父类,那么他们全部会受到影响,如果拥有不同父类将会导致意想不到的结果。
2.3如何设置表头数据
通过重写headerData方法对表头进行设置,与data方法类似,section为表头单元格,orientation为表头方向
QVariant MyModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (role == Qt::DisplayRole)
{
if (orientation == Qt::Horizontal) {
switch (section)
{
case 0:
return QString("first");
case 1:
return QString("second");
case 2:
return QString("third");
}
}
}
return QVariant();
}
2.4可编辑表格
在model中加入成员变量QString mp_data[][]来保存单元格中显示的数据,data方法中根据QModelIndex来对应返回mp_data中的数据。可编辑表格需要重写setData和flags方法。falgs方法返回QModelIndex对应单元格拥有的属性(来使单元格可编辑),setData方法会在单元格每次受到编辑时触发,方法内应该完成编辑数据写入到mp_data中。
MyModel.h:
class MyModel : public QAbstractTableModel
{
Q_OBJECT
public:
MyModel(QObject *parent);
~MyModel();
int rowCount(const QModelIndex& parent = QModelIndex()) const;
int columnCount(const QModelIndex& parent = QModelIndex()) const;
QVariant data(const QModelIndex & index, int role = Qt::DisplayRole ) const;
bool setData(const QModelIndex &index,const QVariant &val,int role=Qt::EditRole);
Qt::ItemFlags flags(const QModelIndex&index)const;
public slots:
void timerHit();
private:
QTimer* mp_timer;
QString mp_data[2][3];
signals:
void editCompleted(const QString& str);
};
MyModel.cpp:
MyModel::MyModel(QObject *parent)
: QAbstractTableModel(parent)
{
}
MyModel::~MyModel()
{
}
int MyModel::rowCount(const QModelIndex& parent /* = QModelIndex */)const
{
return 2;
}
int MyModel::columnCount(const QModelIndex& parent /* = QModelIndex */)const
{
return 3;
}
QVariant MyModel::data(const QModelIndex &index, int role) const
{
if (role == Qt::DisplayRole)
{
return mp_data[index.row()][index.column()];
}
return QVariant();
}
void MyModel::timerHit()
{
QModelIndex index = createIndex(0,0);
emit dataChanged(index,index);
}
bool MyModel::setData(const QModelIndex &index,const QVariant& val,int role/* =Qt::EditRole */)
{
if(role==Qt::EditRole)
{
mp_data[index.row()][index.column()] = val.toString();
QString result;
for(int row = 0;row<2;row++)
{
for(int col=0;col<3;col++)
{
result+=mp_data[row][col]+" ";
}
}
//emit editCompleted(result);
}
return true;
}
Qt::ItemFlags MyModel::flags(const QModelIndex& index) const
{
return Qt::ItemIsSelectable | Qt::ItemIsEditable | Qt::ItemIsEnabled ;
}
注意:函数的入参中QModelIndex一定要是引用,否则会设置无效。
2.5TreeView的使用
TreeView的使用和TableView基本一致,区别在于TreeVIew中的iem之间拥有层级(父子)关系。QStandardItemModel是专为层级数据设计的容器且继承自QAbstractItemModel,TreeView与QStandardItemModel搭配使合适。
在TreeView中插入item:
QTreeViewTest::QTreeViewTest(QWidget *parent)
: QMainWindow(parent)
{
ui.setupUi(this);
QStandardItemModel* standItemModel = new QStandardItemModel();
QList<QStandardItem*> prepareRow1;
prepareRow1<<new QStandardItem("first");
prepareRow1<<new QStandardItem("second");
prepareRow1<<new QStandardItem("third");
QList<QStandardItem*> prepareRow2;
prepareRow2<<new QStandardItem("111");
prepareRow2<<new QStandardItem("222");
prepareRow2<<new QStandardItem("333");
QStandardItem* rootItem = standItemModel->invisibleRootItem();
rootItem->appendRow(prepareRow1);
prepareRow1.first()->appendRow(prepareRow2);
ui.treeView->setModel(standItemModel);
ui.treeView->expandAll();
}
效果如下:
捕捉选中节点并获取节点数据:TreeView通过一个单独的selection model对象来管理选中对象,该对象通过selectionModel方法可以获取,获取到selection model对象后通过selectionChanged信号来绑定相应的槽函数。
例子:当选中treeView中的节点时将窗体标题设置为节点显示内容
QTreeViewTest::QTreeViewTest(QWidget *parent)
: QMainWindow(parent)
{
ui.setupUi(this);
QStandardItemModel *standardItemModel = new QStandardItemModel();
QStandardItem* rootNode = standardItemModel->invisibleRootItem();
QStandardItem *americaItem = new QStandardItem("America");
QStandardItem *mexicoItem = new QStandardItem("Canada");
QStandardItem *usaItem = new QStandardItem("USA");
QStandardItem *bostonItem = new QStandardItem("Boston");
QStandardItem *europeItem = new QStandardItem("Europe");
QStandardItem *italyItem = new QStandardItem("Italy");
QStandardItem *romeItem = new QStandardItem("Rome");
QStandardItem *veronaItem = new QStandardItem("Verona");
rootNode-> appendRow(americaItem);
rootNode-> appendRow(europeItem);
americaItem-> appendRow(mexicoItem);
americaItem-> appendRow(usaItem);
usaItem-> appendRow(bostonItem);
europeItem-> appendRow(italyItem);
italyItem-> appendRow(romeItem);
italyItem-> appendRow(veronaItem);
ui.treeView->setModel(standardItemModel);
ui.treeView->expandAll();
QItemSelectionModel *selectionModel = ui.treeView->selectionModel();
connect(selectionModel, SIGNAL(selectionChanged (const QItemSelection &, const QItemSelection &)),
this, SLOT(selectionChangedSlot(const QItemSelection &, const QItemSelection &)));
}
void QTreeViewTest::selectionChangedSlot(const QItemSelection & /*newSelection*/, const QItemSelection & /*oldSelection*/)
{
//get the text of the selected item
const QModelIndex index = ui.treeView->selectionModel()->currentIndex();
QString selectedText = index.data(Qt::DisplayRole).toString();
//find out the hierarchy level of the selected item
int hierarchyLevel=1;
QModelIndex seekRoot = index;
while(seekRoot.parent() != QModelIndex())
{
seekRoot = seekRoot.parent();
hierarchyLevel++;
}
QString showString = QString("%1, Level %2").arg(selectedText)
.arg(hierarchyLevel);
setWindowTitle(showString);
}
通过 treeView->selectionModel()->currrentIndex获取当前选中节点的Model index,通过model index获取节点中的内容。顶层节点的parent()方法会返回一个默认构造的QModelIndex对象,以此来判断当前节点是否是顶层节点。
通过QAbstractItemView::setSelectionModel方法来为view设置selection model,以此可以实现多个view使用同一个selection model,只需要获取其中一个的selection model然后再设置给其他view。
2.6 预定义的Model
典型的使用模型视图架构的方式是将数据进行封装使其适配view类,Qt也未一些通用的数据结构预定义了一些常用的model:
3.0 委托
到目前为止的所有例子我们都在显示和编辑文本数据,提供数据显示和编辑服务的组件叫做委托,view会有一个默认委托。但是如果我们想在单元格中编辑图片或者将数据显示为图形样式,我们就需要自己设置委托。