一个简单的工具开发:从学生端更新程序部署工具说起,浅谈qt中自定义控件制作和调用、TCP协议下文件的收发 、以及可执行文件的打包

2022/10/09 笔记 共 7701 字,约 23 分钟

一个简单的工具开发:从学生端更新程序部署工具说起,浅谈qt中ui的使用和TCP协议下文件的收发、以及可执行文件的打包

写在前面,Qt Designer是一个非常操蛋的页面编辑器,它非常的…怎么说呢,生硬,也可能是我现在用的这个Qt Designer的版本比较老的原因。有很多点,如果要我吐槽我都不知道从哪里开始吐槽起,不过今天写到这里了,就先来吐槽一下这个布局的使用。

先上文件:

文件部署工具_教师端

文件部署工具_学生端

首先我们知道布局,在Qt里面这个布局是非常好用的一个工具,它可以自适应地给你布置好一些位置的控件,这样就不用你在resizeEvent里面去单独每一次修改窗体的大小或者对应位置,但是代价是什么呢?代价就是你真的不会想用这个做来做Qt的可视化预览界面的,用起来真的非常的一言难尽。

首先我们谈谈Qt的布局我们怎么用。常规的布局是垂直、水平布局,如果你玩的花一点,那么你可能会用到一个叫做栅格布局的,但是不管是一个什么样的布局,只要你不是一步步用布局来布置的,而是直接简单粗暴的对一个充满了空间的widget直接来一波布局,那么这个控件就会直接变形,整个widget可能直接就化为了废墟。布局确实是一个非常好用的东西,但是这个东西在具体使用上真的非常容易失控

那就从一次实战来讲解一下布局工具究竟应该怎么使用,顺便记一下笔记,聊聊这个学生端更新程序部署工具怎么使用

首先来看看工具

image

没错只有两个裸的exe文件,因为比较轻量化,所以依赖dll被我封装进了exe内部,随取随用就行

打开的话操作逻辑也比较简单,如下:

教师端:

Y`1 D_KBXYSJI3}{A7}(BBM

学生端:

~8S{W195{EHVYTCB4G$UE%F

如果是从零开始,这个小工具的设计主要有两个:

1.如何做到文件收发,这个是最基本的问题,不管界面上怎么做,至少这个文件收发要能够做到

2.教师端右边的学生是如何动态的插入的,而且可以让用户自定义上面的按钮和功能。

1.浅谈文件收发

关于传输文件的问题,我在上一篇文章中有提到,详情可以看Qt网络编程-书接上文,浅谈TCP文件收发,以及心跳包,这篇文章简单聊了一下如何进行tcp消息的消息传输,也提供了一个tcp传输的类,在这里就进行一个简单的实战,通过小工具来传输文件:

发送端:

首先做好发送端的准备,在教师端选中文件,然后点击发送之后,会做如下动作:

void StudentFileTransfer::on_btn_sendfile_clicked()
{
    if (!this->ui.line_file->text().isEmpty()) {
        QFile temp_file(this->ui.line_file->text())
        if (temp_file.exists()) {
            //此时需要缓存文件了,将文件转为二进制码流,同时开始进行传输工作
            QByteArray file_array;
            QFileInfo file_info(temp_file);
      temp_file.open(QIODevice::ReadOnly);
            file_array = file_title + "|" + file_info.fileName().toLocal8Bit() + "|" + temp_file.readAll();
            //准备发送文件,发送给全体成员,等待消息回复,并重置整个列表
            this->s_tcp->SendMsg(file_array, "", 0);           

            this->addMessage("Try SendFile to Client:" + file_info.fileName());

        }
    }
} 我们可以把文件发送端的行为简单的分为三步:找到文件->拆分文件->发送流

首先读取到指定文件

QFile temp_file(this->ui.line_file->text())
然后读取文件的字节,通过.readAll()方法 (注意,想要readAll需要先open该文件,如果文件并未open,则会提示QIODevice not open)然后将发送的字符串以:NewFile文件名文件二进制流的形式发送出去。

这样我们就把发送端做好了,然后来看看接收端

接收端:

接收端的话,稍微复杂一点,但是也不会有多复杂,我们可以来看

void FramelessWidget::RecvTCP(const QByteArray& bytes)
{
    //接到发送消息,进行解析
    if (bytes.contains(file_title)) {
        //如果当前发送的字符串是发送的文件,则此时开始保存文件
    //接到这段消息之后将字符串向右移动,将抬头移除
    QByteArray temp_qba = bytes;
    //将左侧的NewFile|移除,再输入
    temp_qba = temp_qba.remove(0, 8);
        this->FileReceiver(temp_qba);
    }
}

这里我们接到消息后,如果消息是带有我们的新文件接收事件的抬头的,则执行接收文件的方法,也就是FileReceiver,我这里图省事就直接把这个抬头去掉了,当然你不去掉也没什么关系,不影响的。注意这里是const类型的参数,所以不能通过replace和remove该,但是可以新申请一块内存来处理。

void FramelessWidget::FileReceiver(QByteArray strValue)
{

    QString file_title;
    QString SeatID;
    QString file_path;
    QDir folder;
    qint32 parse_index;
    QByteArray title_bytes; //名称信息
    QByteArray file_bytes; //文件二进制流

    parse_index = strValue.indexOf("|",1);

    title_bytes = strValue.left(parse_index);
    file_title = QString::fromLocal8Bit(title_bytes);

    file_path = this->file_path +"/"+ file_title;

  //不管怎么样,只要没占用就得重新写入
    QFile received_file(file_path);   
        file_bytes = strValue.mid(parse_index + 1, -1);
        file_bytes = file_bytes + "\0";

        received_file.open(QIODevice::Truncate | QIODevice::WriteOnly);

        if (received_file.isOpen()) {
            received_file.write(file_bytes, file_bytes.length());
        }
        else {
            folder.mkpath(this->file_path);
            received_file.open(QIODevice::Truncate | QIODevice::WriteOnly);
            received_file.write(file_bytes, file_bytes.length());
        } 
  //接收文件结束之后需要通知服务端当前文件已接收,同时需要改变置顶的文字提示
  ui.lab_title->setText(QString("文件%1已接受!").arg(file_path));
  c_tcp->SendMsg("FILERECIVED");
}

这里我们就把这个二进制流拆分为两部分,第一个|的前半部分是文件名称,后半部分是文件内容,将二者分别取出然后再写成文件,这样就完成了一个文件的接收

以上就是关于在tcp协议中传输文件的收发,除此之外还有几点需要注意的。

### 以下这部分是在我完成这篇博客之后才发现的。

1.tcp传输文件的效率根据网速而定,而且即使网速能处,实际上tcp的效率也可能非常感人,我的这个tcp协议本身是单线程而且是主线程的,也就是说这个工具在发送文件的时候是阻塞的,所以传输大文件可能会...你懂的。

2.文件传输的时候,如果当前文件被占用了,比如程序正在运行,那么这个时候的文件是没法写入的,当然了tcp可不会管你那么多,同样会显示写入成功了,但文件肯定是没有办法写入的。这个时候可以尝试对指定QFile temp_file(file_path),然后检查这个temp_file 的 isOpen,来试试看这个文件是否可以被写入。当然了,即使不可以写入也做不了什么,不过可以返回当前写入失败的情况给发送端。当然,最好的情况就是直接把该指定程序的占用全部解除...如果能做到这点...当然是可以的,但是非常麻烦,所以考虑到运行的场景,只让运行着的进程结束掉,且循环检测当前这个进程是否存在,如果不在了在进行更换。

3. 1 和 2两个问题结合起来,有的进程有守护,临时关闭一下,那可能还来不及接收到文件,被占用的进程就被调用起来了,这样文件又被占用了,导致文件再次无法写入。所以可以让文件在本地先缓存,文件接收完毕之后,再执行2中的步骤,本地之间的文件交换就快了,不用考虑网络的问题。

以上几点大概就是关于文件传输的一些观察,接下来讲一下自定义Ui控件如何编写和调用。

2.浅谈自定义ui的使用和编写

有很多地方可能我要动态地插入一些控件,比如我现在的这个工具的右边,有一个地方专门用来放一些用户的数据。当然了这个地方的控件肯定没有一个QtDesigner内标准的控件来表示,肯定只能让我自己来定义,但是该怎么做呢?

当前我们写的程序,都是直接在QtDesigner内编写的,但是实际上,QtDesigner的可视化编辑工具编辑出来的文件是什么呢?就是头文件ui_xxx.h,我们可以进去看一下里面是什么内容

image

看到了吗,也就是说这个可视化的工具编辑出来的结果其实还是一堆堆的文本,走进去看看就知道了,实际上还是代码来编辑的。我们可以看到里面的各种控件,其实还是一个个的QPushButton QLabel等指针和一大堆的setGeometry函数等等,那么我们实际上也可以直接通过代码的方式来编写界面,完全不依赖任何编辑器。

那么聪明的你肯定想到了,既然我们有QPushButton类,QLabel这些Qt自带的类,那么我们是不是可以自己定义一个类,来放我们的自定义控件,然后来操控呢?

答案当然是可以的,这也是接下来我要说的。

以服务端举例,可以看到界面大概是这样的

image

image

这个是我们的自定义控件,里面用来存放用户的信息和对应的操作,我们可以先把这个控件画出来。右键工程文件->Add Qt Class->Qt Widget Class,然后把这个界面先画出来。

image

内容就不展示了,详情可以直接看工程文件,只是说下思路。画完之后,这个类就可以当成一个普通控件来使用了。现在我们来写一个类,可以用以表示每一个连接上服务器的用户,这里我举一个例子:

struct Users {
    QString sIp = "";
    QString userName = "";
    qint32 sPort = 0;
    bool fileRecState = false;
    userInfo *info; //用户窗口控件指针

    QString current_file_name = "";
    //一个带有指定用户信息的窗口

    void static DeleteNode(QList<Users> user_list,QString sIp) {
        for (int i = 0; i < user_list.size(); i++) {
            if (user_list[i].sIp == sIp) {
                user_list[i].Delete();
                user_list.removeAt(i);
            }
        }
    }

    void Delete() {
        QString sIp = "";
        QString userName = "";
        qint32 sPort = 0;
        bool fileRecState = false;
        info = nullptr;
    }

    bool isEmpty() {
        if (sIp.isEmpty()) {
            return true;
        }
        else {
            return false;
        }
    }
};

构建一个结构体来表示一个用户,当然用类也是可以的,不过我是个C老嗨,对于比较轻量级的结构喜欢用结构体,这个看个人。

ok,现在我们就有了一个表示用户的结构体了,当我们新加入一个用户的时候,就可以申请一个Users的内存结点,然后给这个结点里面赋值,插入一个user_list,来代表我们总的用户的列表。

QList真的蛮好用的,一个很像数组的链表,用起来很方便,我一般用来表示全体用户的结构通常使用QList这种链表来表示,用的多了就知道有些结构怎么设计了。

然后现在就要向右下角的框体来插这些控件来,就和插入QPushButton 一样的,先声明,然后设置这个框体为控件的父控件,然后setGeometry,大概就这么简单,我这里只简单展示一下怎么把user_list内的所有用户的控件插入到右下角这个框体中。

框体我选用的是QScrollArea,因为用户可能有很多,可能需要一个滚轮上下滑动来找用户什么的,当然翻页什么的也可以的。

userInfo是我的自定义控件,user_list是存放所有用户的链表,其中的结点为Users

void StudentFileTransfer::UpdateUserList()
{
    try {
        //先清空,后添加
        QList<userInfo*> temp = ui.scrollAreaWidgetContents->findChildren<userInfo*>();

        for (int i = 0; i < temp.size(); i++) {
            userInfo* si = static_cast<userInfo*>(temp[i]);
            si->setParent(this);
            si->hide();
            //直接删除掉窗口,然后在添加
        }

        for (int i = 0; i < this->user_list.size(); i++) {
            this->ui.scrollAreaWidgetContents->setGeometry(0, 0, this->ui.scrollAreaWidgetContents->width(), 61 * (i+1));
            this->user_list[i].info->setParent(this->ui.scrollAreaWidgetContents);            
            this->user_list[i].info->move(0, 60 * i);
            this->user_list[i].info->show();
        }

        this->addMessage("## Update User List!");
        this->ui.lab_users->setText(QString("当前在线用户:%1人").arg(user_list.size()));
    }
    catch (exception& e) {
        qDebug() << "UpdateUserList Failed ! :" << e.what();
    }    
}

因为直接在QScrollArea中清除所有的用户窗口不是很方便,好像只能把这个结点的窗口指针直接清理掉,但是这样的话就会影响到user_list内用户的窗口指针,这里的话让这些窗口指针直接设置总窗口为父窗口,然后再让他们消失就可以了,反正之后还会加回来。

添加的话,就像我说的,先设置scrollArea为父窗口,然后再根据实际情况设置自定义窗口的位置,我这里就是一个自定义控件60p的高,一个个堆叠下来就行。

还有就是每个控件的信号怎么和外部的槽函数connect?其实你在创建的时候connect就行了,发送一个自定义控件内部的信号,触发外部单例的槽函数,执行一些指定的功能,比如新加入一个用户如下:

    Users user_node;
    user_node.sIp = clnAddr;
    user_node.userName = strValues[1];

    //反正新加入的用户不管说什么都不可能已经接到文件了不是吗
    user_node.fileRecState = false;

    userInfo *temp_info = new userInfo();
    temp_info->SetName(user_node.userName);
    temp_info->SetSip(user_node.sIp);
    temp_info->SetState(user_node.fileRecState);

    user_node.info = temp_info;
    user_list.append(user_node);

    connect(temp_info, SIGNAL(KickUserInfo(QString)), this, SLOT(RemoveUser(QString)));
    connect(temp_info, SIGNAL(Retry(QString)), this, SLOT(ReSendingFile(QString)));
    this->UpdateUserList();

创建的时候进行一下connect,那么这个控件的信号函数就会发出来给外部的槽,如果你要辨识是哪个自定义控件发出来的信号,那你就需要一个控件内的自定义表示,比如我这里的temp_info传递的参数是对应用户的ip地址,是唯一的而且是可以比对的。

先写这些吧,反正也是简单谈谈使用。

3.可执行文件打包

就是qt写的可执行文件一般都会有一大堆的依赖dll,但是这里有些dll没有找到并不会报错,而是某些功能变残疾,比如qgif.dll,这个是QLabel加载gif图片的关键组件,如果这个组件缺失了会导致所有QLabel上的Gif都无法加载,但是,但是,但是程序并不会报错。这就很操蛋了,因为你永远不可能知道自己所有的dll占用,即使你知道,我的天,那么多dll谁记得过来呢?

qt的开发者也预示到了这个问题,知道一般的开发者难以忍受这样的折磨可能会爆体而亡,所以提供了一个qt依赖补全工具:windeployqt (wind depoly qt,这样好背一点)

在菜单栏内找到工具:

Qt 5.10.1 for Desktop (MinGW 5.3.0 32 bit)

打开进入控制台,cd转到指定路径后,输入windployqt xxx.exe ,在对应文件夹内获取该程序的完整依赖

详情可以见教你使用windeployqt工具来进行Qt的打包发布,我要说的重点不是在如何获取这些依赖,而是如何将依赖打包成一个单独的可执行文件。

就比如我现在这个小工具,我当然不希望别人还要通过依赖来运行我的程序,这样会显得非常冗长,而且用起来也非常麻烦,能打包是最好的

详情见Qt程序打包(使用Enigma Virtual Box)

其他的想到什么说什么吧,class dismiss

文档信息

Search

    Table of Contents