使用Qt实现一个必应壁纸客户端

作者: 博客园精华区  更新时间:2021-05-20 09:05:00  原文链接


概要

必应的每日壁纸很好看,但是看不到一周以前的壁纸图片,日前使用python开发了必应壁纸收集站,可惜这样的收集站只能在线浏览,我在想要是有一款软件能够下载每日必应壁纸,并应用到windows的桌面不是更好,必应出过一款叫“必应缤纷桌面”的软件,这款软件功能很简单并且不好用,我的win7系统下载下来安装还要安装net 4.0的支持,不是很方便。市面上还有其他几款关于设置必应桌面壁纸的软件,也都一一看过,软件不是功能很简陋,就是python做的脚本,要安装python环境,安装一些支持库,脚本才能跑起来。这对不懂程序的人来说无疑是很困难的。既然没有心仪的软件,那干脆自己花时间做一个好了。于是有了这篇博文。软件使用Qt开发,理论上应该可以运行在所有windows系统,不需要安装其他的依赖库。下面总结一下开发过程,给有需要要使用Qt开发软件的同学一些参考。软件使用QtCreator工具开发,可以点击这里下载体验: 必应壁纸PC客户端工具 ,其界面效果如下:

去掉传统标题栏,自定义最小化关闭按钮,拖动窗体

首先软件界面要漂亮的话,不能使用传统的标题栏,窗口按钮了,现在主流的软件,QQ音乐,微信,360,金山基本上都是这种模式。这块在Qt中是很容易做到的。去掉传统标题栏用如下代码即可:

//去掉软件标题栏,自己来实现  Qt::FramelessWindowHint
//设置窗体透明,但里面的控件不透明,这个可以用来做不规则的窗体效果
//如果是规则的矩形窗体这个可以不用
//setAttribute(Qt::WA_TranslucentBackground,true);
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowSystemMenuHint | Qt::WindowMinimizeButtonHint);
setWindowIcon(QIcon(":/images/title/icon_32.ico"));//可执行程序图标

这里面 Qt::FramelessWindowHint 就是创建无边框窗口,使用 setWindowIcon() 去设置软件图标(该图标就是在任务栏上看到的图标),这里面还有一个比较常用的属性设置,setAttribute(Qt::WA_TranslucentBackground,true) 这个用于设置整个窗体透明,一般用来做不规则的窗口,比如圆形或者其他。我们知道windows的控件,包括窗口肯定都是矩形,当设置窗体透明之后,给窗体设置一个非矩形的背景图片,比如一个圆形的png作为背景,那么由于png的透明部分的效果,使得窗体看起来是一个圆形。通过这种方式,只要设计好背景png图片,理论上可以做出任何效果的窗体。当然本例中,我们不需要做非矩形的窗体也就不设置这个属性。

当去掉传统的标题栏之后,相应的窗口拖动功能就没有了,因为窗口拖动都是基于传统的标题栏进行拖动,那么这部分功能就需要自己去实现。做起来也很简单。在这之前,先说说我们的整个布局,我们的整个窗口是一个QMainWindow窗体,然后在这个窗体中拖入了一个QWidget,为QMainWindow设置一个布局(水平和垂直布局都可以)使得这个QWidget完全填充满整个QMainWindow。然后我们再给这个QWidget设置一个垂直布局,之后的所有控件都是基于这个QWidget的。为什么要在最外面的QMainWindow中加一个QWidget作为整个布局容器,而不直接使用QMainWindow作为直接的布局容器呢,因为在使用过程中发现,在设置QSS时,无论是直接为QMainWindow设置QSS,还是给他定义一个objeceName用这个objectName设置QSS都没有效果。而通过在他里面添加一个QWidget,给这个QWidget设置一个objectName之后,在QSS文件中通过 #objectName 的方式设置样式就可以。目前这个原因不得而知。我们整个软件的布局如下:

在使用控件布局的时候,用QFrame做容器,用的最多的就是垂直布局和水平布局,再加上垂直和水平的Spacer控件,基本上可以搞定所有布局效果,这里要注意的是,在QtCreator中为控件设置布局的Layout属性是有默认值的,包括Margin和Spacing,导致布局控件的子控件之间有空隙,所以这里最好是手工都设置为0,按照上面的布局,我们最外层是一个QMainWindow,在其头文件和源文件中重写鼠标的按下、移动、释放按钮如下:

//mainwindow.h 头文件
protected:
    virtual void mousePressEvent(QMouseEvent *event);
    virtual void mouseMoveEvent(QMouseEvent *event);
    virtual void mouseReleaseEvent(QMouseEvent *event);

重新实现鼠标的这3个事件代码如下:

//重写鼠标按下事件
void MainWindow::mousePressEvent(QMouseEvent *event)
{
    mMoveing = true;
    //记录下鼠标相对于窗口的位置
    //event->globalPos()鼠标按下时,鼠标相对于整个屏幕位置
    //pos() this->pos()鼠标按下时,窗口相对于整个屏幕位置
    mMovePosition = event->globalPos() - pos();
    QWidget::mousePressEvent(event);
}

//重写鼠标移动事件
void MainWindow::mouseMoveEvent(QMouseEvent *event)
{
    //(event->buttons() & Qt::LeftButton)按下是左键
    //鼠标移动事件需要移动窗口,窗口移动到哪里呢?就是要获取鼠标移动中,窗口在整个屏幕的坐标,然后move到这个坐标,怎么获取坐标?
    //通过事件event->globalPos()知道鼠标坐标,鼠标坐标减去鼠标相对于窗口位置,就是窗口在整个屏幕的坐标
    if (mMoveing && (event->buttons() & Qt::LeftButton)
        && (event->globalPos()-mMovePosition).manhattanLength() > QApplication::startDragDistance())
    {
        move(event->globalPos()-mMovePosition);
        mMovePosition = event->globalPos() - pos();
    }
    QWidget::mouseMoveEvent(event);
}

//鼠标释放
void MainWindow::mouseReleaseEvent(QMouseEvent *event)
{
    mMoveing = false;
    QWidget::mouseReleaseEvent(event);
}

这样就实现了无边框窗体的拖动效果,除此之外我们要重写关闭按钮的功能,在关闭窗体的时候不去close,这个代码就不展示了,百度一下就有,下面我们看看如何自定义QListWidget

自定义QListWidget项目

默认的QListWidget项目很简单,每一个QListWidget中的项目是一个QListWidgetItem对象,该对象提供 setText() 为该项目设置文字,setIcon() 用于为项目设置图标。如果仅仅是这样的话,这是完全满足不了需求的,我们需要设置图片,并且需要在图片上显示图片的描述,日期,地址等信息。我们可以在QtCreator中创建一个ui文件(包括头文件和对应的cpp文件),在这个ui文件中,我们以QWidget作为最顶层的容器(不像窗体用的QMainWindow),他就像我们任何自定义的ui控件一样,我们可以利用QtCreator的布局功能做任何复杂的布局,最后这个ui文件对应的头文件与cpp文件实际上就是一个C++类,我们在需要的地方使用这个类就可以了。我们为这个QListWidget的每一项定义的ui界面布局如下:

可以看到我们这个ui布局中黑色实线的矩形框一共有4个都是用QFrame,最外面的QFrame0是整个ui的容器他是被一个QWidget包裹,每一个项目的图片被设置为QFrame0的背景图片,该项目的区域被分为上下两部分,上面是QFrame1,下面是QFrame3,其中QFrame1里面嵌套了一个小的QFrame2,这个QFrame2中有2个水平布局的按钮,一个是预览(软件中的放大镜),一个是设置当前图片为桌面壁纸(软件中的显示器图标)。默认情况下这两个按钮不显示,当鼠标移动到QFrame0上的时候,整个QFrame1将以动画的形式从上切入,并且有一个透明度的变化。如何将我们自定义的ui类,设置为该QListWidget的项呢,可以用下面的代码:

void MainWindow::initListWigdet(){
    QFile file2(":/qss/listwidget.scrollbar3.qss");
    file2.open(QFile::ReadOnly);
    ui->listWidget->verticalScrollBar()->setStyleSheet(file2.readAll());
    file2.close();
    //初始化QListWidget
    //ui->listWidget->setIconSize(QSize(300,225));
    ui->listWidget->setResizeMode(QListView::Adjust);
    ui->listWidget->setViewMode(QListView::IconMode);
    ui->listWidget->setMovement(QListView::Static);
    ui->listWidget->setSpacing(10);
    ui->listWidget->horizontalScrollBar()->setDisabled(true);
    ui->listWidget->verticalScrollBar()->setDisabled(true);

    // 创建单元项
    // 这里创建的Item使用的背景图是样式表中的默认背景图
    for (int i = 0; i<6; ++i)
    {
        QListWidgetItem *item = new QListWidgetItem;
        ImageInfoItem2 *widget = new ImageInfoItem2;
        item->setSizeHint(QSize(288,180));
        ui->listWidget->addItem(item);
        ui->listWidget->setSizeIncrement(150,190);
        ui->listWidget->setItemWidget(item,widget);//最重要的是这句将Item设置为一个Widget,而这个Widget就是自定义的ui
    }

    //给item绑定真实数据
    updateListWidget(1); // page 从1开始
}

我们首先为这个QListWidget加载了一个样式表,然后设置了一些参数,最后为其设置了6个item项目,每个item项目是一个Widget对象,也就是我们自定义的ui类(ImageInfoItem2),其实际上是继承自QWidget类的。最重要的是使用QListWidget::setItemWidget()成员函数设置item为一个QWidget,设置好item对象之后,最后有一个函数去设置每个item的数据,例如背景图片、图片描述、日期、地址信息等。当我们去遍历该QListWidget的每一个item并将item转换为当初设置的QWidget对象,调用该ui对象的成员方法来更新其上的数据即可:

//将图片列表更新为第page页的图片数据
void MainWindow::updateListWidget(int page){
    //获取第page页面的数据当在imageInfoList中
    QList<BingImageDataInfo> imageInfoList = DataManager::GetImageInfoList(page);
    //先初始化listWidget列表的每一项为空数据
    for (int i = 0; i<ui->listWidget->count(); ++i)
    {
        QListWidgetItem *item = ui->listWidget->item(i);
        QWidget * widget =  ui->listWidget->itemWidget(item);
        ImageInfoItem2* imageInfoItem = dynamic_cast<ImageInfoItem2*>(widget);
        if(imageInfoItem!=NULL){
            BingImageDataInfo info;//空数据
            imageInfoItem->updateImageInfo(info);
        }
    }
    //根据实际上得到的imageInfoList初始化listWidget
    for (int i = 0; i<imageInfoList.size(); ++i)
    {
        //qDebug()<<imageInfoList[i].Url<<endl;
        QListWidgetItem *item = ui->listWidget->item(i);
        QWidget * widget =  ui->listWidget->itemWidget(item);
        ImageInfoItem2* imageInfoItem = dynamic_cast<ImageInfoItem2*>(widget);
        if(imageInfoItem!=NULL){
            imageInfoItem->updateImageInfo(imageInfoList[i]);
        }
    }
}

上面的代码中,先用空数据初始化了一遍item,然后用真实数据填充,因为这里按分页获取的数据也许个数并不满足一页的数据项6个(比如最后一页也许只有5个),这里先通过QListWidget::itemWidget()得到一个绑定在该item上的QWidget对象,然后将其转换成我们真实的 ImageInfoItem2 对象(就是那个ui类对象),并通过该对象的函数去更新item的数据。

QListWidget滚动条样式表设置

本例中我们实际上并没有允许滚动条出现,不过在最开始的时候确实考虑过使用滚动条,也就是让每页显示不止6个图片,但是发现对于这个软件的这种布局使用滚动条比较奇怪,实际上滚动条通过QSS设置出来的效果还是很好的,在这里将QSS放出来给需要的人。

/* QSS中不能用双斜杠的注释这会导致qss无效 */
QScrollBar:vertical {
   border: 0px;
   background: #202020;
   width: 8px; /*设置一个非0的宽度让滚动条显示*/
   margin: 0;
   padding:0;
}
/*滑块的样式*/
QScrollBar::handle:vertical {
   border: 0; /*设置border-radius属性并不需要border属性有值*/
   background: rgba(70,70,70,85%);
   min-height: 20px;
   border-radius:4px; /*坑:这个圆角要注意,不能超过宽度的一半,否则没有圆角效果*/
}
/*鼠标放上滑块的样式,颜色透明度变一下*/
QScrollBar::handle:vertical:hover {
   border: 0;
   background: rgba(70,70,70,100%);
   min-height: 20px;
   border-radius:4px;
}
QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical {
   border: 0;
   background: #202020;
   height: 20px;
   subcontrol-position: bottom;
   subcontrol-origin: margin;
}
/*这两个属性必须都要设置否则背景不会继承基础设置中的背景*/
QScrollBar::add-page:vertical,QScrollBar::sub-page:vertical
{
    background:#202020;
}

其显示的滚动条效果如下:

这里在对QScrollBar的滚动条的QSS属性加以说明,以便使用:

为方便,我这里是自己在草稿纸上画的,没有用作图工具,就将就着看一下吧。

分页效果

分页做起来很简单,也是在QtCreator中自定义了一个ui界面类,将这个ui整体当作一个分页控件来用,在类里面提供成员函数来设置各个按钮的样式,这个ui界面里面就是一些分页按钮,上一页,下一页,一个输入文本框,以及一个GO按钮,根据当前分页是第几页高亮表示当前分页的按钮,根据页数的多少隐藏或显示某些多余的按钮。每次点击上一页,下一页或者在文本框中输入数字转到某一页之后,分页ui中的一系列的数字按钮要重新初始化,并设置样式。弄一个成员函数去统一更新就可以了。其余的都是在这个ui类的构造函数中加载QSS样式文件去设置其界面,这些都是最基本的样式没有什么复杂的。当分页控件中的当前页改变之后,我们可以发射一个信号出去,通知主窗体上进行QListWidget的更新。这部分的逻辑没什么好说的,就不放代码了。在QtCreator看起来布局是这样:

应用QSS之后的效果是这样的:

Qt中读取数据库

在Qt中读写数据库是很方便的,这里以Sqlite为例子,主要是通过 QSqlDatabase 去连接数据库,通过 QSqlQuery 去做查询并获取结果。这里在查询数据的时候,我们可以做一下简单的封装,我们首先需要有一个 QSqlDatabase 的对象实例,调用该实例的 open() 函数去打开数据库连接,代码如下:

//得到一个QSqlDatabase的实例
SqliteDBAOperator::SqliteDBAOperator(QString dbName)
{
    this->dbName = dbName;
    if (QSqlDatabase::contains(dbName))
    {
        //这个类里面应该有一个静态的数据结构存储 连接名 -> QSqlDatabase 对象的映射
        db = QSqlDatabase::database(dbName);
    }
    else
    {
        //建立和sqlite数据的连接
        db = QSqlDatabase::addDatabase("QSQLITE",this->dbName);
        //设置数据库文件的名字
        QString dbFilePath = QCoreApplication::applicationDirPath() +QString("/")+ this->dbName;
        db.setDatabaseName(dbFilePath);
    }
}
//打开数据库连接,之后就可以用这个 db 对象了
bool SqliteDBAOperator::openDb(void)
{
    //打开数据库
    if(db.open() == false){
        qDebug() << "connect db fail";
        return false;
    }
    qDebug() << "connect db success";
    return true;
}

这样我们就得到了一个 QSqlDatabase 的对象变量 db ,之后就可以用这个QSqlQuery 去查询:

QList<QMap<QString, QVariant> > SqliteDBAOperator::queryData(QString& str)
{
    QSqlQuery query(this->db);
    bool res = query.exec(str);
    QList<QMap<QString, QVariant> > list;
    if(res==true){
        while(query.next()){
            QSqlRecord record = query.record();
            QMap<QString, QVariant> info;
            for(int i=0;i<record.count();i++){//遍历该record的所有列
                QString field = record.fieldName(i);//得到字段名,作为该map的key
                QVariant var = record.value(field);
                info[field] = var;
            }
            list<<info;
        }
    }
    return list;
}

我们将查询出来的数据放到了一个QList中,其每一个元素是一个QMap,实际上就是表中的一行数据,用QMap可以非常方便的通过表的字段来引用该行数据的某一列的数据值,这里的值是 QVariant 类型,那么在使用的时候可以像下面这样用:

QString sql = QString("select aa,bb from table ");
QList<QMap<QString, QVariant> > listMap = sqliteAdapter->queryData(sql);
QList<BingImageDataInfo> list;    
if(listMap.size()>0){
    for(int i=0;i<listMap.size();i++){
        QMap<QString, QVariant> &row = listMap[0];
        BingImageDataInfo info;
        info.Url = row["url"].toString();
        info.Time = row["time"].toInt();
        list<<info;
    }
}

使用表的字段名来引用该字段的值看起来是不是很直观方便,另外要使用Sql功能,我们还需要在项目的pro文件中加上 QT += sql

自定义菜单项

我们这里所说的自定义菜单,不是给菜单项添加一个图标,在Qt中,可以使用QSS给每个菜单项设置样式,比如边框,背景颜色,菜单项文字前面的图标等。但是我们这里不仅限于此,我们的软件中有一个菜单按钮,点开之后是如下的菜单:

首先这个菜单是圆角的,其次这个菜单看起来不是常规的每个菜单项就是一行文字(最多前面加个图标修饰一下),这里面有复选框,有文字,还有按钮,其实这个框框中的3个复选框,以及3个QLabel,还有一个按钮,他们仅仅包含在一个菜单项中,也就是说这个菜单只有一个菜单项,这个菜单项是我们自定义的,同样的这个菜单项,是我们自定义的一个ui文件类(包含了对应的头文件以及cpp文件),当我们以自定义的ui文件类的方式来定义菜单项的时候,我们可以想做多复杂就做多复杂,像很多音乐播放器的任务托盘上的菜单有播放,调整音量,有的甚至有专辑封面,这些都是小菜一碟。首先截图中的菜单按钮(信封按钮的右边)是一个QPushButton,在Qt中可以为某一个按钮关联一个弹出菜单的,只需要调用这个按钮的 setMenu() 函数传递一个 QMenu 就可以将这个 QMenu 与这个按钮关联起来,默认情况下这个 QMenu 弹出的位置是与这个QPushButton沿左边线对齐的,可以看到,上面的截图显然不是,上面的截图中,弹出的菜单看起来与按钮是居中对齐的(这里左对齐不好看),为了改变这种左对齐的默认行为,我们自己定义了一个继承自QMenu的类,叫做 PopMenu ,在这个类中重写了QMenu的 showEvent 函数,在此函数中,重新调整菜单显示的位置,代码如下:

PopMenu::PopMenu(QPushButton* button, QWidget* parent) : QMenu(parent), b(button)
{
    this->b = button; //保存其关联的按钮
}

void PopMenu::showEvent(QShowEvent* event)
{
    //根据按钮的位置,调整菜单的位置
    QPoint p = this->pos();
    int diff = (this->width() - b->width())/2;
    int newx = p.x()-diff;
    int newy = p.y()+5;
    this->move(newx,newy);
}

与菜单按钮关联的菜单实际上是 PopMenu 其也是一个 QMenu 对象,那么我们自定义的表示菜单项的ui类(下面代码中的 MenuSetting 类)如何设置到这个 PopMenu 菜单里面的呢,

void MainWindow::initMenuSetting()
{
    //构造 PopMenu 对象
    this->menuSetting = new PopMenu(ui->btnMenu,this);
    //构造自定义的菜单项的ui类对象,实际上是一个继承自QWidget的子类
    MenuSetting *menuSettingWidget = new MenuSetting(this);
    //下面两句是关键,这里定义的菜单项不是QAction 而是 QWidgetAction 就是用来将菜单项设置为一个Wdiget的,这个QWidgetAction实际上是QAction的子类
    QWidgetAction *action = new QWidgetAction(this->menuSetting);
    //将这个Action设置为一个QWdiget,就是我们自定义的ui类对象
    action->setDefaultWidget(menuSettingWidget);
    //为这个菜单添加Action
    this->menuSetting->addAction(action); //这个一定要有
    //这个写在主样式表里面没效果,要写在代码中才行
    this->ui->btnMenu->setStyleSheet("QPushButton#btnMenu::menu-indicator{image:none;background-color:transparent;}");
    //给按钮绑定此弹出菜单
    ui->btnMenu->setMenu(this->menuSetting);   
    //这里为什么需要,因为菜单作为一个独立的存在,其地位跟窗口是一样的,菜单并不是被包含在主窗口的任何控件中
    //没有任何控件是菜单的容器,他其实就是一个独立的窗口,只不过菜单是一种比较特殊的窗口,所以我们需要单独为其设置窗体标志,设置背景透明
    menuSetting->setWindowFlags(Qt::Popup|Qt::FramelessWindowHint);
    menuSetting->setAttribute(Qt::WA_TranslucentBackground);
}

至于菜单的圆角效果,那就更简单了,其实是我们自定义菜单项中的ui类对象中的容器QFrame,我们设置QSS的时候设置成圆角就可以。这里有一点要注意的是,一旦一个QPushButton 设置了一个关联的弹出菜单之后,该QPushButton的行为就有了变化,单击这个按钮的时候他的行为就是弹出其关联的弹出菜单,他不会再响应其 click 槽函数,这点是需要注意的,此时这个按钮的唯一功能就是单击之后,弹出菜单。

Qt中托盘程序的实现

在Qt中实现托盘程序也比较容易,Qt中的托盘程序主要是通过 QSystemTrayIcon 以及其关联的上下文菜单对象来实现的,具体代码如下:

void MainWindow::initTraySystem(){
        //初始化托盘图标对象
        this->trayIcon = new QSystemTrayIcon( this );
        //设定托盘图标
        this->trayIcon->setIcon( QIcon( QPixmap( ":/images/title/icon_16.ico" ) ) );
        //设置提示文字
        this->trayIcon->setToolTip(QString(WINDOW_TITLE));
        //让托盘图标显示在系统托盘上
        this->trayIcon->show();
        //连接信号与槽,实现单击图标恢复窗口的功能,槽是自定义的槽函数TrayIconAction
        connect( this->trayIcon, SIGNAL( activated( QSystemTrayIcon::ActivationReason ) ), this, SLOT( TrayIconAction( QSystemTrayIcon::ActivationReason ) ) );
        //初始化托盘菜单及功能,这里定义一个菜单,并定义各个菜单项
        this->trayMenu = new QMenu(this);//初始化菜单
        this->trayMenu->setObjectName("trayMenu");
        this->trayActionOpen = new QAction(this);//打开主窗口菜单
        this->trayActionOpen->setText( "主窗口" );
        connect(this->trayActionOpen, SIGNAL(triggered()), this, SLOT(showNormal()));//菜单中的显示窗口,单击显示窗口
        this->trayActionquit = new QAction(this);//退出菜单
        this->trayActionquit->setText( "退出" );
        connect(this->trayActionquit, SIGNAL( triggered()), qApp, SLOT(quit()));// 菜单中的退出程序,单击退出
        this->trayActionAbout = new QAction(this);//关于
        this->trayActionAbout->setText("关于");
        connect(this->trayActionAbout, SIGNAL( triggered()), this, SLOT(TriggerAbout()));// 弹出关于窗口

        //将定义好的菜单与托盘图标关联起来
        this->trayIcon->setContextMenu(this->trayMenu);
        this->trayMenu->addAction(this->trayActionOpen);
        this->trayMenu->addAction(this->trayActionAbout);
        this->trayMenu->addAction(this->trayActionquit);
        //为菜单设置样式
        QFile file(":/qss/traymenu.qss");
        file.open(QFile::ReadOnly);
        QTextStream filetext(&file);
        QString stylesheet = filetext.readAll();
        this->trayMenu->setStyleSheet(stylesheet);
        file.close();
}

这里还要注意一点,由于Qt程序中,当所有的窗口都关闭之后,程序就退出了,但是我们希望窗口都关闭之后,程序仍然还在运行,进程是不能退出的,因为托盘图标还在,这个时候就需要在Qt程序的main函数中,设置一下如下:

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);
    app.setQuitOnLastWindowClosed(false);
    .... 其他代码省略 ....
}

Qt单进程的实现

我们希望在已经有一个进程打开的情况下,用户再双击打开程序直接触发之前打开的程序窗口,而不是同时打开多个程序,就像目前这个程序,如果用户已经有一个进程最小化到了任务托盘中,当用户再次打开程序的时候,直接将当前运行在任务托盘的程序触发其显示主窗口即可。要实现单进程,就必须有一种方案,能在进程启动的时候标识一个全局数据,在进程退出之后自动取消标识。基于共享内存、文件锁等方式的实现不是很完美,总会有一些问题。我们这里使用 QLocalServer 的方式来实现,实现代码如下:

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);

    //MainWindow w;
    /*
     * MainWindow的定义不能放在这里,如果已经有一个实例在运行的情况下,这里势必会进入if分支
     * 然后调用 return -1 我们预期的情况下当前这个进程应该会退出,但是实际实验中发现,windows 的任务管理器中
     * 会多出一个进程出来,在已经运行一个客户端的情况下,后面的每双击一下程序,任务管理器都会多出一个进程
     * 很奇怪,通过日志打印这里也确实是进入了if分支,并且确实是return -1 也就是这个main函数已经退出了
     * 但是进程就在任务管理器里面,并且没有任何界面(都没有走到下面的w.show()不会有窗口显示)
     */
    //初始化一个LocalServer
    LocalServer server;
    if(!server.init(LOCAL_SERVER_NAME)){
        // 初使化Server失败, 说明已经有一个在运行了,通过客户端连接这个已经运行的Server
        // 并给他发个消息让已经运行的那个进程显示主窗口,自己则退出
        LocalClient::ConnectSendMessage(LOCAL_SERVER_NAME,ACTIVE_MESSAGE);
        qDebug()<<"localserver init false exit -1"<<endl;
        a.quit();
        return -1;
    }

    //初始化数据
    if(!DbDataManager::Init()){
        qDebug()<<"DbDataManager::Init() false exit -1"<<endl;
        return -1;
    }

    /*
     * 这个定义放在这里就不会有上面描述的后打开的进程不退出的问题,不知道为什么在上面那种情况下
     * main函数都return了进程却不退出。以为是MainWindow中的构造函数中有初始化任务托盘可能跟任务托盘有关系
     * 但是注释掉初始化任务托盘的相关函数之后还是一样的情况,具体原因还不清楚
     */
    MainWindow w;

    //LocalServer 初始化成功,当server收到消息之后用一个槽函数处理
    //收到激活消息之后显示主窗口
    QObject::connect(&server, &LocalServer::newMessage, [&](const QString &message){
        if(message == ACTIVE_MESSAGE){
            qDebug()<<"recv active message show normal window"<<endl;
            w.showNormal();
        }
    });

    //所有窗口都关闭之后不退出程序,不设置的话如果关闭主窗口之后,在任务托盘上打开"关于"窗口之后,关闭"关于"窗口
    //会导致进程退出(可能是所有可见窗口都关闭了所以进程自动退出了)
    a.setQuitOnLastWindowClosed(false);
    //根据命令行参数来决定程序启动的时候是否显示主窗口,还是直接最小化到任务托盘
    //一般如果程序是随系统自启动的情况下,让他直接最小化到任务托盘
    //如果是用户自己双击启动的情况,就显示主窗口
    //判断程序是随系统自启动还是用户双击启动的区别,就是有没有命令行参数
    //用户双击启动是不会有命令行参数的
    bool showMainForm = true;
    if(argc >= 2){
        QString twoParam = argv[1];
        if(twoParam==CMD_PARAM_AUTO_RUN){
            showMainForm = false;
        }
    }
    if(showMainForm){
        w.show();
    }
    return a.exec();
}

上面的代码中 LocalServer,LocalClient 是我们基于 QLocalServer,QLocalSocket 包装的本地服务和客户端。客户端的主要代码如下:

//localclient.cpp
void LocalClient::ConnectSendMessage(QString serverName,QString message){
    QLocalSocket ls;
    ls.connectToServer(serverName);
    if (ls.waitForConnected()){
        QTextStream ts(&ls);
        ts << message;
        ts.flush();
        ls.waitForBytesWritten();
    }
}

服务端的主要代码如下:

//localserver.cpp
LocalServer::LocalServer(QObject *parent) : QObject(parent)
{
    m_server = 0;
}

LocalServer::~LocalServer()
{
    if (m_server)
    {
        delete m_server;
    }
}

bool LocalServer::init(const QString & servername)
{
    // 如果已经有一个实例在运行了就返回0
    if (isServerRun(servername)) {
        return false;
    }
    m_server = new QLocalServer;
    // 先移除原来存在的,如果不移除那么如果
    // servername已经存在就会listen失败
    QLocalServer::removeServer(servername);
    // 进行监听
    m_server->listen(servername);
    connect(m_server, SIGNAL(newConnection()), this, SLOT(newConnection()));
    return true;
}

// 有新的连接来了
void LocalServer::newConnection()
{
    QLocalSocket *newsocket = m_server->nextPendingConnection();
    connect(newsocket, SIGNAL(readyRead()), this, SLOT(readyRead()));
}

// 可以读数据了
void LocalServer::readyRead()
{
    // 取得是哪个localsocket可以读数据了
    QLocalSocket *local = static_cast<QLocalSocket *>(sender());
    if (!local)
        return;
    QTextStream in(local);
    QString     readMsg;
    // 读出数据
    readMsg = in.readAll();
    // 发送收到数据信号
    emit newMessage(readMsg);
}

// 判断是否有一个同名的服务器在运行
bool LocalServer::isServerRun(const QString & servername)
{
    // 用一个localsocket去连一下,如果能连上就说明
    // 有一个在运行了
    QLocalSocket ls;
    ls.connectToServer(servername);
    if (ls.waitForConnected(1000)){
        // 说明已经在运行了
        ls.disconnectFromServer();
        ls.close();
        return true;
    }
    return false;
}

这样就通过 QLocalServer 和 QLocalSocket 实现了单进程,实际上这里的服务端并没有监听一个端口,在windows的实现中是通过命名管道的,这个管道在我们程序启动之后被打开,在进程退出之后就自动没有了。当程序启动的时候启动服务端,如果另一个程序也启动,他会发现启动通过一个名字的服务端失败了,说明之前已经有一个服务端已经启动了,这个时候他只需要以客户端的方式连接之前启动的服务端,给他发一个消息,那个服务端收到消息之后显示其主窗口即可。

Qt写注册表实现自启动

我们通过在注册表项中添加项目来实现程序的自启动,在Qt中写注册表是很简单的,主要代码如下:

//开机自启动注册表键
#define REG_RUN "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"

//设置程序开机自启动
void Tools::setAutoRunning(bool is_auto_start)
{
    QString application_name = QApplication::applicationName();
    QSettings *settings = new QSettings(REG_RUN, QSettings::NativeFormat);
    if(is_auto_start)
    {
        //程序执行文件的路径 F:\\xxx\\xxx\zz.exe
        QString application_path = QApplication::applicationFilePath();
        application_path += " " ;
        //加上命令行参数  F:\\xxx\\xxx\zz.exe autorun
        application_path += CMD_PARAM_AUTO_RUN;
        settings->setValue(application_name, application_path.replace("/", "\\"));
    }
    else
    {
        settings->remove(application_name);
    }
    delete settings;
}

效果是这样:

在线程中请求http或者https资源

我们需要通过网络去请求json数据,以及下载壁纸图片,在Qt中是通过 QNetworkAccessManager 来实现的,线程的功能代码如下:

//定时任务读取json数据,并从解析出的json数据中获取要下载的图片url
//进一步下载图片,这里面删除了一些无关的代码,保留了关键的代码
void DownloadThread::run(){
    QNetworkAccessManager* manager = new QNetworkAccessManager();
    while(true){
        QThread::sleep(TIME_INTERVAL_MINS*60);//secs
        //输出当前Qt支持的ssl版本情况
        //qDebug()<<"QSslSocket="<<QSslSocket::sslLibraryBuildVersionString();
        //输出当前Qt是否支持ssl,如果支持这里返回true,否则返回false
        //qDebug() << "OpenSSL支持情况:" << QSslSocket::supportsSsl();
        QEventLoop loop;
        QObject::connect(manager, &QNetworkAccessManager::finished, &loop, &QEventLoop::quit);
        //request对象
        QNetworkRequest request;
        //设置UserAgent头
        request.setHeader(QNetworkRequest::UserAgentHeader, USER_AGENT_FIREFOX);
        //设置需要请求的资源url,例如 http://www.abc.com/xx.jpg
        request.setUrl(QUrl(BING_JSON_URL));
        //调用 QNetworkAccessManager 的get函数发送http请求
        QNetworkReply *reply = manager->get(request);
        loop.exec();
        //当请求完成之后通过 QNetworkReply 的readAll()读取响应结果
        //readAll()函数返回的是一个 QByteArray 如果响应是一个文本,我们可以赋值给一个QString,比如这里是一个json文本
        QString json = reply->readAll();
        //删除该reply对象
        reply->deleteLater();
        BingImageDataInfo info;
        //解析json数据,这里面通过 QJsonDocument 相关的类来解析json数据,很简单,就不展示 parseJson() 的实现了
        bool parseRes = parseJson(json,info);
        qDebug()<<"parse"<<endl;
        if(parseRes){ // added to db and mem
            //待下载的图片的url路径
            QString downloadUrl = BING_DOMAIN+info.Url;
            //重新设置request的url
            request.setUrl(QUrl(downloadUrl));
            //发送请求获取图片内容
            QNetworkReply *replyDownload = manager->get(request);
            loop.exec();
            QFile fDownload(imageFilePath);
            if(fDownload.open(QIODevice::WriteOnly)){
                fDownload.write(replyDownload->readAll());//读取响应结果并写入磁盘中
                fDownload.close();
            }
            replyDownload->deleteLater();
            }
        }
    } // end while true
}

当在线程中使用 QNetworkAccessManager 来请求http资源的时候,有几个问题需要注意,第一个我们的QNetworkAccessManager变量最好是定义在DownloadThread线程的run函数中,就像上面的代码中一样,不能定义他的构造函数中,否则当我们在主线程中实例化这个线程类DownloadThread并启动他的时候,QNetworkAccessManager 对象由于是在DownloadThread的构造函数中初始化,而DownloadThread的构造函数实际上是在主线程中运行的,所以实际上最终 QNetworkAccessManager 对象可以认为是属于主线程。然而我们却在 DownloadThread::run() 中通过 connect() 函数为这个manager绑定信号槽,也就是说我们绑定信号槽的语句是在 DownloadThread 这个线程中做的,那么这里就出问题了。我们在一个线程中定义一个对象,却在另一个线程中为这个对象绑定信号槽。所以这里我们需要将 QNetworkAccessManager 对象也定义在 DownloadThread::run() 中,这样这个manager对象就属于 DownloadThread线程,而不是主线程了。

另外一个问题,从上面的代码中可以看到,我们在连接manager对象的信号槽之前,通过语句 QEventLoop loop; 定义了一个局部的事件循环,在我们的Qt程序中,主线程(也就是ui线程)会启动一个事件循环,而我们的信号的触发,对应的槽函数被调用,与这个事件循环是有关系的,可以认为当一个信号被触发之后,其被放到当前事件循环的队列中,当事件循环检测到信号的时候,去查找该信号绑定的槽函数并执行之。如果没有事件循环,那就不会有信号槽的运行。所以我们需要在线程 DownloadThread 中启动一个局部的事件循环,否则manager对象的槽函数不会被触发。这里会报错:QNetworkAccessManager without finished signal

使用事件循环的另一个好处是可以将这里的异步转成同步,因为在线程中我们需要同步去下载,如果是异步的话,这里不是很好跟线程结合在一起使用。当调用 loop.exec() 之后程序就阻塞在调用处,直到 manager 的 finished 信号被触发的时候,调用之前绑定的 QEventLoop::quit 槽函数,从而退出事件循环,loop.exec() 从调用处返回,后面的语句继续执行。这样就达到了类似同步请求http资源的效果了。

在Qt中想要请求https:// 的资源,还要做一些别的设置,不做任何设置的情况下,请求https资源是没有任何效果的,我们首先可以在程序中打印出  QSslSocket::sslLibraryBuildVersionString() 以及 QSslSocket::supportsSsl() 来查看当前Qt版本支持ssl的情况。如果不支持OpenSSL 那么我们 去下载并安装Windows的OpenSSL ,下载界面如下:

根据自己使用的QT编译器是32位还是64位,下载相应的安装包。将下载的安装包进行安装,安装之后,找到安装目录下的bin目录中的两个文件(libcrypto-1_1.dll 和libssl-1_1.dll),拷贝到QT编译器目录下即可。例如我的OpenSSL安装在 C:\Program Files (x86)\OpenSSL-Win32\bin 将里面的两个dll 拷贝到我的Qt的32位编译器的目录 D:\Qt\Qt5.14.2\5.14.2\mingw73_32\bin 下面,这两个dll文件在最后部署程序的时候也要放到部署包中。

Qt程序的部署与发布

Qt程序在写好之后,如何部署呢,在QtCreator中我们一般是在Debug模式下编译调试程序,当程序写完之后,我们切换到Release模式下,重新编译项目,最后会在相应的Release目录下生成一个可执行文件,但是仅仅只有一个exe文件直接双击肯定是运行不了的,好在Qt为我们提供了工具,获得这个exe文件运行所依赖的其他文件。按照下面的步骤操作就可以了:

1.首先把我们编译出来的Release最终程序拷贝到任意一个文件夹,例如下面这样:

我这里是拷贝到 F:\bingimg\bingimg.exe  然后我们需要打开Qt提供的命令行环境,比如我的电脑上是这么打开: 开始->所有程序->Qt 5.14.2->5.14.2->MinGW 7.3.0(32-bit)->Qt 5.14.2 (MinGW 7.3.0 32-bit)  如下图所示,不同的电脑,不同的版本安装之后可能略有差别:

因为我的程序编译的是32位的,所以我这里打开的是32位的命令行环境,打开Qt命令行环境之后,我们切换目录到刚刚拷贝编译程序的那个目录 F:\bingimg 下面,之后运行命令 windeployqt.exe   bingimg.exe  这样这个部署命令会自动检测程序 bingimg.exe 所依赖的库以及其他文件,并将其依赖的文件都拷贝到其所在的目录  F:\bingimg 下面。然后双击我们的程序就可以运行了:

可以看到运行 windeployqt.exe 命令之后,后面的工作都是自动的,到命令全部执行完成,我们在查看我们的目录  F:\bingimg 可以看到如下:

所有需要的库文件都已经拷贝到目标程序所在的目录,不过这里我们还需要将openssl的那两个dll拷贝到这个目录下,至此必应壁纸程序的编写以及发布全部完成。