PyQt5桌面应用系列

下一个玩具

已经写了13篇PyQt5的文章,把基础的需求分析、信号与事件、界面布局、并行运行、对话框、文件IO、QGraphicsView框架都浅尝辄止地写了一点点,项目有什么需要还是要再仔细地阅读文档。

剩下就三个比较感兴趣的话题:

  • 数据库
  • Model/View框架
  • Charts

这三个话题中数据库本身就是比较大的,类似于QGraphicsView框架这样,背后需要的知识很专业化。Model/View框架和Charts都是比较常大路货。也不没什么难度。数据库呢,如果只是很表面很表面的涉及,其实接口也大差不差。

要一次性写三个比较大的话题实际上也很难权衡。不过这一系列的帖子本身也就是提供一个引子,大概了解一下有这么些东西,要应用的时候知道怎么找就可以。反而是趣味性和一定意义上的实用性我更加关注。

所以这里的开发也来一个实际的需求。目前所有关于PyQt5的写作,都放在一个目录下,并且维护了一个开源的GitCode仓库
,我们的需求是把所有的MarkDown文档都放在一个数据库里面,然后可以通过一个界面来显示这些文档,并且把文档的长度做一个图形。

这个需求很清楚:

  1. 报表:Markdown文档的列表显示;
  2. 报表:Markdown文档的长度图形;
  3. 数据:Markdown文档的名称、长度、创建时间,存储与Sqlite数据文件。

PyQt5桌面应用开发(14):数据库+ModelView+QCharts-LMLPHP

报表一:Markdown文档的列表显示

Widget的树、表、列报表

PyQt5桌面应用开发(4):界面设计
中,我们蜻蜓点水的介绍了表、树和列三种很重要报表形式,在后面的PyQt5桌面应用开发(9):经典布局QMainWindow
中,我们又做了一个非常拙劣的树性视图,用的是QTreeWidget。

这三个报表形式,是三个最基础的概念。

  • 树:树形结构,每个节点都可以有子节点,子节点可以有子节点,以此类推,最终形成一个树形结构;
  • 表:二维结构的数据,每一行都是一个记录,每一列都是一个字段;
  • 列:一维结构的数据,每一行都是一个记录,每一列都是一个字段。

其实在Qt中,这三个概念都是按照所谓Model/View的框架来设计和实现的。

Qt中的MVC框架

Qt包含了一系列条目-视图的类,采用Model/View框架来管理数据和数据的展示方式。将数据管理和展示功能分离,使得开发者又更大的自由度来设计独特的显示方式,并且提供一个同意的模型界面来使用各种不同的数据来源。这是一种典型的系统体系架构,功能分离。

在《设计模式》一书中,作者写到:

MV框架

  • 模型:与数据源通信,为体系中其它构件提供接口。数据源的不同是模型所需要处理的变化;
  • 视图:从模型获得模型索引,每个索引指向一份数据。条目索引是模型与视图的主要接口,视图通过索引来获取实际的数据。视图需要管理显示的细节;
  • 代理: 代理是模型与视图之间的中间层,用于过滤或修改模型中的数据。代理可以用于排序、过滤、修改数据等。

一般来说,模型/视图类可以分为上述三组:模型、视图和代理。每个组件都由抽象类定义,这些抽象类提供了公共接口,并且在某些情况下提供了功能的默认实现。抽象类的目的是为了提供完整的功能集,这些功能集是由其他组件期望的;当然也允许编写专门的组件。

模型、视图和代理之间通过信号与槽来通信。

  • 模型的信号告知视图数据的变化;
  • 视图的信号告知模型用户再用户界面上的操作;
  • 代理的信号用于在编辑过程中告知模型和视图编辑器的状态。

模型

所有的条目(item)模型都是基于QAbstractItemModel类的。这个类定义了一些纯虚函数,用于访问数据。这个类足够灵活,能够处理树、列和表类的数据。值得注意的时,这个类提供给视图和代理来访问数据,但是树并不必然存储在模型中,也可以存储在其他地方,比如数据库、文件、其他类、网络等等。

其子类有:

  • QAbstractListModel
  • QAbstractTableModel
  • QStringListModel
  • QStandardItemModel
  • QFileSystemModel
  • QSqlQueryModel
  • QSqlTableModel
  • QSqlRelationalTableModel

应用中,可以直接使用上述模型,也能够自由选择适当的类来实现自己的模型。

视图

QListView、QTableView和QTreeView则是完全不同的实现。虽然所有这三个类都是QAbstractItemView的子类。继承View来实现自己的特性也是很常规的做法。

代理

QAbstractItemDelegate是代理的基类,它定义了一些纯虚函数,用于编辑数据。实现这个类的由QStyledItemDelegate和QItemDelegate。推荐使用前者。

报表二:Markdown文档的长度图形

说起绘图,Python有太多优秀的选择。但是PyQt5的QtCharts也还算能用。

大概的过程:

  • 建立一个QChart;
  • 建立一个QChartView;
  • 建立一个QXXXSeries;
  • 将QXXXXSeries添加到QChart中;
  • 将QChart添加到QChartView中;
  • 在QXXXXSeries中增加数据集合;
  • 建立零个到两个QXXXAxis;
  • 将QXXXAxis添加到QChart中;

大概有点点啰嗦,但是也没有过于啰嗦;最终的基础效果也能接受,但也没那么好;经过QSS的美化,效果还是不错的。

def make_summary_chart(tv: QTableView):
    tv_model = tv.model()
    dock_chart_widget = QDockWidget("Summary")
    cv = QChartView()
    dock_chart_widget.setWidget(cv)

    chart = QChart()
    chart.setTitle("<b style='color:darkgray;'>My Writings: File Length Summary Chart</b>")
    chart.setAnimationOptions(QChart.SeriesAnimations)

    cv.setChart(chart)

    barset = QBarSet("File Length")
    count = tv_model.rowCount()

    barset.append([tv_model.data(tv_model.index(i, 2)) for i in range(0, count)])

    files = [tv_model.data(tv_model.index(i, 1)) for i in range(0, count)]
    files = [f.strip(".md") for f in files]

    series = QHorizontalBarSeries()
    series.append(barset)

    chart.addSeries(series)
    chart.legend().hide()

    axis_y = QBarCategoryAxis()
    axis_y.append(files)
    chart.addAxis(axis_y, Qt.AlignLeft)
    series.attachAxis(axis_y)

    axis_x = QValueAxis()
    chart.addAxis(axis_x, Qt.AlignBottom)
    series.attachAxis(axis_x)

    return dock_chart_widget

数据:Markdown文档和Sqlite数据库

这个数据什么的就太简单了。

QSqlDatebase是Qt中的数据库类,它可以连接各种数据库,比如Sqlite、MySQL、PostgreSQL等等。这里我们使用Sqlite数据库。链接过程也超级直观。

最终就是自己写起SQL,用QSqlQuery来exec_(这个名字因为重名才改成加_)执行,得到的结果用first,next来移动cursor,value(column)
来获取值。

def make_sqlite_table():
    """
    Please run
        > python -c "import tbl_sqlite as ts;ts.make_sqlite_table();"
    to create the sqlite database
    """
    db = QSqlDatabase.addDatabase("qsqlite".upper())
    db.setDatabaseName("writting_summary.db")

    if not db.open():
        raise IOError(f"{db.databaseName} open error")
    query = QSqlQuery(db)
    if not query.exec_("""
        create table if not exists writing_items (
            id integer primary key autoincrement, 
            file_name text not null unique, 
            length integer, 
            created_time timestamp
            )""".strip()):
        raise IOError(f"create table error: {query.lastError().text()}")

    for fn, file_size, lmt in list_all_markdown():
        # check if the file already exist
        query.exec_(f"""
            select * from writing_items where file_name='{fn}'
        """.strip())
        # already exist... stop
        if query.first():
            if not query.exec_(f"""
                    update writing_items set length={file_size}, created_time='{lmt}' where file_name='{fn}'
                    """.strip()):
                raise IOError(f"update error: {query.lastError().text()}")
        else:
            # insert a row
            if not query.exec_(f"""
                    insert into writing_items(file_name, length, created_time) 
                    values('{fn}', {file_size}, '{lmt}')
                    """.strip()):
                raise IOError(f"insert error: {query.lastError().text()}")

    db.close()


def list_all_markdown():
    for item in glob.glob("*.md"):
        file = pathlib.Path(item)
        state = file.stat()

        yield file.name, state.st_size, datetime.fromtimestamp(state.st_ctime)

代码

完整代码:

import glob
import pathlib
import sys
from datetime import datetime

from PyQt5.QtChart import QChartView, QChart, QBarSet, QValueAxis, QBarCategoryAxis, QHorizontalBarSeries
from PyQt5.QtCore import Qt
from PyQt5.QtSql import QSqlTableModel, QSqlDatabase, QSqlQuery
from PyQt5.QtWidgets import QApplication, QMainWindow, QTableView, QDockWidget


def make_sqlite_table():
    """
    Please run
        > python -c "import tbl_sqlite as ts;ts.make_sqlite_table();"
    to create the sqlite database
    """
    db = QSqlDatabase.addDatabase("qsqlite".upper())
    db.setDatabaseName("writting_summary.db")

    if not db.open():
        raise IOError(f"{db.databaseName} open error")
    query = QSqlQuery(db)
    if not query.exec_("""
        create table if not exists writing_items (
            id integer primary key autoincrement, 
            file_name text not null unique, 
            length integer, 
            created_time timestamp
            )""".strip()):
        raise IOError(f"create table error: {query.lastError().text()}")

    for fn, file_size, lmt in list_all_markdown():
        # check if the file already exist
        query.exec_(f"""
            select * from writing_items where file_name='{fn}'
        """.strip())
        # already exist... stop
        if query.first():
            if not query.exec_(f"""
                    update writing_items set length={file_size}, created_time='{lmt}' where file_name='{fn}'
                    """.strip()):
                raise IOError(f"update error: {query.lastError().text()}")
        else:
            # insert a row
            if not query.exec_(f"""
                    insert into writing_items(file_name, length, created_time) 
                    values('{fn}', {file_size}, '{lmt}')
                    """.strip()):
                raise IOError(f"insert error: {query.lastError().text()}")

    db.close()


def list_all_markdown():
    for item in glob.glob("*.md"):
        file = pathlib.Path(item)
        state = file.stat()

        yield file.name, state.st_size, datetime.fromtimestamp(state.st_ctime)


def make_table_view_on_database():
    make_sqlite_table()
    tv = QTableView()

    db = QSqlDatabase.addDatabase("qsqlite".upper())
    db.setDatabaseName("writting_summary.db")

    if not db.open():
        raise IOError(f"{db.databaseName} open error")

    tv.setModel(QSqlTableModel(tv, db))

    tv.model().setTable("writing_items")
    tv.model().select()
    tv.model().setHeaderData(1, Qt.Horizontal, "File Name")
    tv.model().setHeaderData(2, Qt.Horizontal, "Length")
    tv.model().setHeaderData(3, Qt.Horizontal, "Created Time")

    [tv.resizeColumnToContents(i) for i in range(0, tv.model().columnCount())]
    tv.setSortingEnabled(True)
    tv.showGrid()
    tv.setAlternatingRowColors(True)

    return tv


def make_summary_chart(tv: QTableView):
    tv_model = tv.model()
    dock_chart_widget = QDockWidget("Summary")
    cv = QChartView()
    dock_chart_widget.setWidget(cv)

    chart = QChart()
    chart.setTitle("<b style='color:darkgray;'>My Writings: File Length Summary Chart</b>")
    chart.setAnimationOptions(QChart.SeriesAnimations)

    cv.setChart(chart)

    barset = QBarSet("File Length")
    count = tv_model.rowCount()

    barset.append([tv_model.data(tv_model.index(i, 2)) for i in range(0, count)])

    files = [tv_model.data(tv_model.index(i, 1)) for i in range(0, count)]
    files = [f.strip(".md") for f in files]

    series = QHorizontalBarSeries()
    series.append(barset)

    chart.addSeries(series)
    chart.legend().hide()

    axis_y = QBarCategoryAxis()
    axis_y.append(files)
    chart.addAxis(axis_y, Qt.AlignLeft)
    series.attachAxis(axis_y)

    axis_x = QValueAxis()
    chart.addAxis(axis_x, Qt.AlignBottom)
    series.attachAxis(axis_x)

    return dock_chart_widget


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = QMainWindow()

    window.setCentralWidget(make_table_view_on_database())
    window.addDockWidget(Qt.RightDockWidgetArea, make_summary_chart(window.centralWidget()))

    window.resize(1440, 1000)
    window.show()
    sys.exit(app.exec_())

总结

  1. QSqlTableModel可以很好的管理数据库表格;
  2. MVC模型通过功能分离,提供了更好的灵活性和重用;
  3. QChartView来显示QChart,具体的图标是根据Series来决定的。
05-14 06:51