一个简单的工具开发:从学生端更新程序部署工具说起,浅谈qt中ui的使用和TCP协议下文件的收发、以及可执行文件的打包
写在前面,Qt Designer是一个非常操蛋的页面编辑器,它非常的…怎么说呢,生硬,也可能是我现在用的这个Qt Designer的版本比较老的原因。有很多点,如果要我吐槽我都不知道从哪里开始吐槽起,不过今天写到这里了,就先来吐槽一下这个布局的使用。
先上文件:
首先我们知道布局,在Qt里面这个布局是非常好用的一个工具,它可以自适应地给你布置好一些位置的控件,这样就不用你在resizeEvent里面去单独每一次修改窗体的大小或者对应位置,但是代价是什么呢?代价就是你真的不会想用这个做来做Qt的可视化预览界面的,用起来真的非常的一言难尽。
首先我们谈谈Qt的布局我们怎么用。常规的布局是垂直、水平布局,如果你玩的花一点,那么你可能会用到一个叫做栅格布局的,但是不管是一个什么样的布局,只要你不是一步步用布局来布置的,而是直接简单粗暴的对一个充满了空间的widget直接来一波布局,那么这个控件就会直接变形,整个widget可能直接就化为了废墟。布局确实是一个非常好用的东西,但是这个东西在具体使用上真的非常容易失控
那就从一次实战来讲解一下布局工具究竟应该怎么使用,顺便记一下笔记,聊聊这个学生端更新程序部署工具怎么使用
首先来看看工具
没错只有两个裸的exe文件,因为比较轻量化,所以依赖dll被我封装进了exe内部,随取随用就行
打开的话操作逻辑也比较简单,如下:
教师端:
学生端:
如果是从零开始,这个小工具的设计主要有两个:
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,我们可以进去看一下里面是什么内容
看到了吗,也就是说这个可视化的工具编辑出来的结果其实还是一堆堆的文本,走进去看看就知道了,实际上还是代码来编辑的。我们可以看到里面的各种控件,其实还是一个个的QPushButton QLabel等指针和一大堆的setGeometry函数等等,那么我们实际上也可以直接通过代码的方式来编写界面,完全不依赖任何编辑器。
那么聪明的你肯定想到了,既然我们有QPushButton类,QLabel这些Qt自带的类,那么我们是不是可以自己定义一个类,来放我们的自定义控件,然后来操控呢?
答案当然是可以的,这也是接下来我要说的。
以服务端举例,可以看到界面大概是这样的
这个是我们的自定义控件,里面用来存放用户的信息和对应的操作,我们可以先把这个控件画出来。右键工程文件->Add Qt Class->Qt Widget Class,然后把这个界面先画出来。
内容就不展示了,详情可以直接看工程文件,只是说下思路。画完之后,这个类就可以当成一个普通控件来使用了。现在我们来写一个类,可以用以表示每一个连接上服务器的用户,这里我举一个例子:
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