文章目录
前言
对即时通讯系统项目客户端开发部分涉及到的界面布局进行总结。
核心数据类
在项目的Model目录中存放着data.h和dataCenter.h两个文件,其中data.h存放着核心数据类和工具函数。
用户信息类
包含有用户ID/昵称/个性签名/手机号/和用户头像。提供一个load方法进行pb对象转换。
class UserInfo
{
public:
QString userId = ""; //用户编号
QString nickname = ""; //用户昵称
QString description = ""; //用户签名
QString phone = ""; //手机号码
QIcon avatar; //用户头像
//从 protobuf 的 UserInfo 对象,装换成当前代码的 UserInfo 对象
void load(const lkm_im::UserInfo& userInfo)
{
this->userId = userInfo.userId();
this->nickname = userInfo.nickname();
this->description = userInfo.description();
this->phone = userInfo.phone();
if(userInfo.avatar().isEmpty()){
//使用默认头像即可
this->avatar = QIcon(":/resource/image/defaultAvatar.png");
}else{
this->avatar = makeIcon(userInfo.avatar());
}
}
};
消息信息类
其中包含有消息ID/会话ID/消息发送时间/消息类型/消息发送者/消息内容/文件ID/文件名称。
这里没有包含文件大小。
enum MessageType
{
TEXT_TYPE, //文本消息
IMAGE_TYPE, //图片消息
FILE_TYPE, //文件消息
SPEECH_TYPE //语音消息
};
class Message
{
public:
QString messageId = ""; //消息编号
QString chatSessionId = ""; //一个消息只能属于一个会话
QString time = ""; //格式化时间
MessageType messageType = TEXT_TYPE; //消息类型
UserInfo sender; //消息发送者的详细信息
QByteArray content; //消息的主体
QString fileId = ""; // 如果是图⽚, ⽂件, 语⾳类型, 表⽰对应的⽂件 id
QString fileName = ""; //只有当消息类型是文件时,表明文件的名称
}
使用了工厂模式来进行消息的构造。当我们发送消息时会调用这个方法构造一个消息对象并渲染到界面。
//extrainfo 是消息类型为文件消息时,作为文件名使用的
static Message makeMessage(MessageType messageType,const QString& chatSessionId,\
const UserInfo& sender,const QByteArray& content,const QString extraInfo){
if (messageType == TEXT_TYPE) {
return makeTextMessage(chatSessionId, sender, content);
} else if (messageType == SPEECH_TYPE) {
return makeSpeechMessage(chatSessionId, sender, content);
} else if (messageType == IMAGE_TYPE) {
return makeImageMessage(chatSessionId, sender, content);
} else if (messageType == FILE_TYPE) {
return makeFileMessage(chatSessionId, sender, content, extraInfo);
} else {
return Message();
}
}
// ⽣成消息 ID
static QString makeId() {
return "M" + QUuid::createUuid().toString().sliced(25, 12);
}
static Message makeTextMessage(const QString& chatSessionId,const UserInfo& sender,const QByteArray& content){
Message message;
//通过生成UUID来充当消息编号
message.messageId = makeId();
message.chatSessionId = chatSessionId;
//获取当前的秒级时间戳,格式化后来充当消息时间
message.time = formatTime(getTime());
message.messageType = TEXT_TYPE;
message.sender = sender;
message.content = content;
return message;
}
static Message makeSpeechMessage(const QString& chatSessionId,const UserInfo& sender,const QByteArray& content){
Message message;
//通过生成UUID来充当消息编号
message.messageId = makeId();
message.chatSessionId = chatSessionId;
//获取当前的秒级时间戳,格式化后来充当消息时间
message.time = formatTime(getTime());
message.messageType = SPEECH_TYPE;
message.sender = sender;
message.content = content;
return message;
}
static Message makeImageMessage(const QString& chatSessionId,const UserInfo& sender,const QByteArray& content){
Message message;
//通过生成UUID来充当消息编号
message.messageId = makeId();
message.chatSessionId = chatSessionId;
//获取当前的秒级时间戳,格式化后来充当消息时间
message.time = formatTime(getTime());
message.messageType = IMAGE_TYPE;
message.sender = sender;
message.content = content;
return message;
}
static Message makeFileMessage(const QString& chatSessionId,const UserInfo& sender,const QByteArray& content,const QString& FileName){
Message message;
//通过生成UUID来充当消息编号
message.messageId = makeId();
message.chatSessionId = chatSessionId;
//获取当前的秒级时间戳,格式化后来充当消息时间
message.time = formatTime(getTime());
message.messageType = FILE_TYPE;
message.sender = sender;
message.content = content;
message.fileName = FileName;
return message;
}
会话信息类
包含有会话ID/会话名称/最新一条消息/会话头像/用户ID
这里的用户ID只有当是单聊会话时才有值,为对方的用户ID。
添加这个字段是为了建立会话和好友的映射关系,我们可以通过点击好友列表项跳转到会话列表中。
class ChatSessionInfo
{
public:
QString chatSessionId = ""; //会话编号
QString chatSessionName = ""; //会话名称 如果是单聊,会话名称为对方的昵称。如果是群聊,会话名称为群聊名称
Message prevMessage; //显示最新的一条消息
QIcon avatar; //会话的头像,如果会话是单聊, 头像就是对方的头像; 如果是群聊, 头像群聊的头像
QString userId = ""; //如果会话是单聊,则为对方的用户ID。如果是群聊,后续有其他方式来组织群聊中用户的Id
}
工具函数
工具函数主要是文件的读写,日志宏,二进制数据和图片的转换以及时间戳转换日期。
其中日志宏中 qDebug() 会自动为输出的字符串添加引号。 使用` .noquote() 可以禁用这个特性,输出的字符串将不再包含引号。
将二进制数据转换为QICon需要借助QPixmap。
时间戳是为了显示发送消息时间,需要将时间戳转换为日期格式显示。
static inline QString fileName(const QString &path){
QFileInfo file(path);
return file.fileName();
}
#define TAG QString("[%1:%2]").arg(model::fileName(__FILE__),QString::number(__LINE__))
#define LOG() qDebug().noquote() << TAG
// 根据 QByteArray 转成 QIcon
static inline QIcon makeIcon(const QByteArray& byteArray) {
QPixmap pixmap;
pixmap.loadFromData(byteArray);
QIcon icon(pixmap);
return icon;
}
//获取到文件的二进制内容
static inline QByteArray loadFileToByteArray(const QString& filename) {
QFile file(filename);
bool ok = file.open(QFile::ReadOnly);
if (!ok) {
LOG() << "⽂件读取失败!";
return QByteArray();
}
QByteArray content = file.readAll();
file.close();
return content;
}
// 把⼆进制数据写⼊⽂件
static inline void writeByteArrayToFile(const QString& filename, const
QByteArray& content) {
QFile file(filename);
bool ok = file.open(QFile::WriteOnly);
if (!ok) {
LOG() << "⽂件写⼊失败!";
return;
}
file.write(content);
file.flush();
file.close();
return;
}
// 把时间戳转成 01-01 18:00:00 这样的格式化时间
static inline QString formatTime(int64_t timestamp) {
QDateTime dateTime = QDateTime::fromSecsSinceEpoch(static_cast<time_t>(timestamp));
return dateTime.toString("MM-dd HH:mm:ss");
}
// 获取当前秒级时间戳
static inline int64_t getTime() {
return QDateTime::currentSecsSinceEpoch();
}
主界面布局(MainWidget)
MainWiget这个类表示主窗口,主窗口是界面的核心,其只有一份,因此实现了单例。
另外主窗口分为左中右三个部分。这三个部分使用三个QWidget表示。
//左侧导航窗体
QWidget *windowLeft;
//中间会话窗体
QWidget *windowMid;
//右侧聊天窗体
QWidget *windowRight;
在initMainWIdget中完成了主窗口的初始化,主要是使用QHBoxLayout水平布局管理器来对三个QWIdget进行组织。
关于布局管理器有几个重要的接口需要重点记忆下:
setSpacing:是指内部元素之间的距离。
setContentsMargins:是内边距,元素距离布局管理器的距离,参数顺序为左上右下。
另外这里给三个QWidget设置了固定宽度。
QHBoxLayout* layout = new QHBoxLayout();
// Spacing 就是 layout 内部元素之间的间隔距离. 设为 0 就是 "紧挨着"
layout->setSpacing(0);
// layout 里面的元素距离四个边界的距离.
layout->setContentsMargins(0, 0, 0, 0);
this->setLayout(layout);
windowLeft->setFixedWidth(70);
windowMid->setFixedWidth(310);
windowRight->setMinimumWidth(800);
左侧布局
其中左侧部分有四个按钮,用户头像/会话列表/好友列表/好友申请列表。
这里的四个成员变量都是在mainWidget主窗口类中的。
//用户头像按钮
QPushButton *userAvatar;
//会话标签页按钮
QPushButton *sessionTabBtn;
//好友标签页按钮
QPushButton *friendTabBtn;
//添加好友标签页按钮
QPushButton *applyTabBtn;
- 左侧的布局使用了垂直布局管理器。
- 设置元素之间距离为20,下边距50.
- 在这里设置了按钮的固定大小和图标的固定大小。
- 通过qss设置了按钮背景颜色为transparent使其贴合于背景。
- 在添加进布局管理器是设置了占比为1,且设置对齐方式为Qt::AlignTop | Qt::AlignHCenter。
- 在布局管理器最后添加一个stretch占比为20,其目的是为了把四个按钮往上顶。
//创建垂直布局管理器
QVBoxLayout *layout = new QVBoxLayout();
layout->setSpacing(20);
layout->setContentsMargins(0,50,0,0);
windowLeft->setLayout(layout);
// 添加用户头像
userAvatar = new QPushButton();
userAvatar->setFixedSize(45, 45);
userAvatar->setIconSize(QSize(45, 45));
layout->addWidget(userAvatar, 1, Qt::AlignTop | Qt::AlignHCenter);
//在布局管理器最后添加一个“弹簧”,站比20
layout->addStretch(20);
这里的布局主要是通过设置占比和向上的对齐方式,且最后添加一个"弹簧",把元素往上顶。设置了元素间间距实现的。
中间布局
中间的布局使用的是网格布局,当然使用垂直布局和水平布局嵌套也可以。
//会话标题标签
QLabel *sessionTitleLabel;
//会话详情按钮
QPushButton *extraBtn;
//消息展示区
MessageShowArea *messageShowArea;
//消息编辑区
MessageEditArea *messageEditArea;
这里设置元素间垂直距离为10,上边距为20.
//创建网课布局管理器
QGridLayout *layout = new QGridLayout();
layout->setVerticalSpacing(10);
layout->setHorizontalSpacing(0);
layout->setContentsMargins(0,20,0,0);
windowMid->setLayout(layout);
- 搜索栏和添加按钮在第0行第1列,会话列表在第0行第3列。
- 这里要实现搜索栏和添加按钮的左右边距,为了不影响会话列表,我们添加了三个QWIdget。
- 设置固定宽度为10,添加到布局管理器中。
- 将会话好友区域添加进布局管理器中,在第一行,第0列,占1行5列。
// 为了更灵活的控制边距, 只影响搜索框按钮这一行, 不影响下方列表这一行
// 创建空白的 widget 填充到布局管理器中.
QWidget* spacer1 = new QWidget();
spacer1->setFixedWidth(10);
QWidget* spacer2 = new QWidget();
spacer2->setFixedWidth(10);
QWidget* spacer3 = new QWidget();
spacer3->setFixedWidth(10);
layout->addWidget(spacer1,0,0);
layout->addWidget(searchEdit,0,1);
layout->addWidget(spacer2,0,2);
layout->addWidget(addFriendBtn,0,3);
layout->addWidget(spacer3,0,4);
layout->addWidget(sessionFrinedArea,1,0,1,5);
右侧布局
右侧布局使用了垂直布局管理器和水平布局管理器嵌套使用。
//会话标题标签
QLabel *sessionTitleLabel;
//会话详情按钮
QPushButton *extraBtn;
//消息展示区
MessageShowArea *messageShowArea;
//消息编辑区
MessageEditArea *messageEditArea;
最上方创建了一个QWIdget,这个widget设置了固定的高度,设置了sizePolicy
(QSizePolicy::Expanding, QSizePolicy::Fixed)
将这个Widget添加进水平布局管理器,且在这个Widget中添加了一个水平布局管理器。
这个水平布局管理器中添加了一个QLabel和一个QPushbutton。
其中这个QLabel设置了sizePolicy,QPushButton设置了固定size。
这个消息编辑区设置了固定的高度,且sizepolicy设置为了QSizePolicy::Expanding,QSizePolicy::Fixed。且添加进布局管理器中设置了对齐方式为Qt::AlignBottom。
消息展示区设置了sizepolicy为QSizePolicy::Expanding,QSizePolicy::Expanding。
消息编辑区(MessageEditArea)
消息编辑区是我们自定义的一个控件,其中包含了五个按钮和一个文本编辑区。
QPushButton* sendImageBtn;
QPushButton* sendFileBtn;
QPushButton* sendSpeechBtn;
QPushButton* showHistoryBtn;
QPushButton* sendTextBtn;
QPlainTextEdit* textEdit;
//显示"录制中..."用于提示
QLabel* tipLabel;
这里的布局使用一个垂直布局管理器,然后嵌套了一个水平布局管理器。
这个垂直布局管理器设置了对齐方式Qt::AlignTop。
这个水平布局管理器设置了对齐方式为Qt::AlignLeft | Qt::AlignVCenter。
且五个按钮都设置了固定大小。
在垂直布局管理器中添加了文本编辑器和QLabel用于录制语音提示。
这两个控件都设置sizepolicy为QSizePolicy::Expanding, QSizePolicy::Expanding。
其中QLabel为隐藏的。
这个发送按钮在布局管理器中设置了对齐方式为Qt::AlignRight | Qt::AlignVCenter。
个人信息界面(SelfInfoWidget)
这里使用网格布局管理,头像设置了固定size,在第0行第0列,站3行1列。
这里的Tag都是设置的固定大小。Label设置的固定高度和sizepolicy设置的水平延申。
最后添加一个修改按钮,设置了固定size。
另外布局的总体对齐方式为上对齐。
添加好友界面(addFriendFialog)
这里用的是网格布局管理,第一行是一个lineEdit和button。
设置setHorizontalSpacing控制搜索框和按钮的间距,设置setContentsMargins来控制和边框的距离。
搜索框设置了固定高度,sizepolicy设置了水平延申。
搜索按钮设置了固定size。
下面这个好友结果展示框则是一个QScrollArea滚动区域。他设置了sizepolicy水平垂直都延伸。添加到布局管理器中的第一行第0列,占一行9列。
单聊会话详情界(sessionDetailWidget)
使用的是网格布局管理器,其中设置了setContentsMargins来控制外边距。
同时设置对齐方式为Qt::AlignTop。
这个添加的ITem在第0行第0列,好友Item在第0行第1列。
这个删除好友在第1行第0列,占一行3列。
群聊会话详情界面(groupSessionDetailWidget)
群聊会话界面主题使用的是水平布局管理,其中第一个widge添加了一个滚动区域,在这个滚动区域里面设置了一个网格布局管理器。
这里设置主题的布局管理器是setContentsMargins(50,20,50,50);
网格布局管理器是setAlignment(Qt::AlignTop | Qt::AlignLeft);
选择好友界面(chooseFriendDialog)
选择好友界面是通过水平布局管理器来划分左右两个区域。
左侧是一个滚动区域,这个滚动区域设置了sizepolicy水平垂直都延申。滚动区域中是一个垂直布局。
右侧是一个网格布局。在这个网格布局中添加了一个QLabel,一个滚动区域和两个按钮。而在这个滚动区域中设置了一个垂直布局。
其中这两个按钮是通过添加到网格布局中的参数占据的列数进行间隔的。
用户信息界面(userInfoWidget)
网格布局管理,设置对齐方式为上对齐,设置好内边距,元素之间的间距。
QGridLayout* layout = new QGridLayout();
layout->setHorizontalSpacing(20);
layout->setVerticalSpacing(10);
layout->setContentsMargins(40,20,40,0);
layout->setAlignment(Qt::AlignTop);
头像/Tag都是设置固定size。QLabel是设置固定高度,sizepolicy设置水平延申。
三个按钮设置固定大小。
消息气泡的绘制
通过重写paintEvent方法来实现气泡的绘制,先来介绍文本消息的绘制。
- 当文本消息的宽度占父元素的%60就需要换行。
- 计算文本占一行时的总体宽度,除以父元素的%60,得到行数。
- 如果元素占一行,则宽度为求出来的总体宽度,如果站多行则宽度为父元素的%60。
- 根据行数计算高度,得到高度和宽度后就可以进行绘制了。
这里的ContentWidget是设置了sizepolicy水平垂直扩展,它里面有一个QLabel,这个label在完成绘制后会设置它的Geometry,
移动他的位置和大小。使其移动到气泡位置,大小和气泡一致。
void MessageContentLabel::paintEvent(QPaintEvent *event)
{
(void) event;
// 1. 获取到父元素的宽度
QObject* object = this->parent();
if (!object->isWidgetType()) {
// 当前这个对象的父元素不是预期的 QWidget, 此时不需要进行任何后续的绘制操作.
return;
}
QWidget* parent = dynamic_cast<QWidget*>(object);
int width = parent->width() * 0.6;
// 2. 计算当前文本, 如果是一行放置, 需要多宽.
QFontMetrics metrics(this->label->font());
int totalWidth = metrics.horizontalAdvance(this->label->text());
// 3. 计算出此处的行数是多少 (40 表示左右各有 20px 的边距)
int rows = (totalWidth / (width - 40)) + 1;
if (rows == 1) {
// 如果此时得到的行数就只有一行
width = totalWidth + 40;
}
// 4. 根据行数, 计算得到高度. (20 表示上下各有 10px 的边距)
int height = rows * (this->label->font().pixelSize() * 1.2 ) + 20;
// 5. 绘制圆角矩形和箭头
QPainter painter(this);
QPainterPath path;
// 设置 "抗锯齿"
painter.setRenderHint(QPainter::Antialiasing);
if (isLeft) {
painter.setPen(QPen(QColor(255, 255, 255)));
painter.setBrush(QColor(255, 255, 255));
// 绘制圆角矩形
painter.drawRoundedRect(10, 0, width, height, 10, 10);
// 绘制箭头
path.moveTo(10, 15);
path.lineTo(0, 20);
path.lineTo(10, 25);
path.closeSubpath(); // 绘制的线形成闭合的多边形, 才能进行使用 Brush 填充颜色.
painter.drawPath(path); // 不要忘记真正的绘制操作
this->label->setGeometry(10, 0, width, height);
} else {
painter.setPen(QPen(QColor(137, 217, 97)));
painter.setBrush(QColor(137, 217, 97));
// 圆角矩形左侧边的横坐标位置
int leftPos = this->width() - width - 10; // 10 是用来容纳 箭头 的宽度
// 圆角矩形右侧边的横坐标位置
int rightPos = this->width() - 10;
// 绘制圆角矩形
painter.drawRoundedRect(leftPos, 0, width, height, 10, 10);
// 绘制箭头
path.moveTo(rightPos, 15);
path.lineTo(rightPos + 10, 20);
path.lineTo(rightPos, 25);
path.closeSubpath();
painter.drawPath(path);
this->label->setGeometry(leftPos, 0, width, height);
}
// 6. 重新设置父元素的高度, 确保父元素足够高, 能够容纳下上述绘制的消息显示的区域
// 注意高度要涵盖之前名字和时间的 label 的高度, 以及留点冗余空间.
parent->setFixedHeight(height + 50);
}
对于图片消息正文,这里需要查看图片的宽度是否超过父元素的宽度的%60。如果超过则把图片的宽度设置为父元素%60,同时高度做一个等比缩放。最后调整图片的Geometry。
void MessageImageLabel::paintEvent(QPaintEvent *event)
{
(void) event;
// 1. 先拿到该元素的父元素, 看父元素的宽度是多少.
// 此处显示的图片宽度的上限 父元素宽度的 60% .
QObject* object = this->parent();
if (!object->isWidgetType()) {
// 这个逻辑理论上来说是不会存在的.
return;
}
QWidget* parent = dynamic_cast<QWidget*>(object);
int width = parent->width() * 0.6;
// 2. 加载二进制数据为图片对象
QImage image;
if (content.isEmpty()) {
// 此时图片的响应数据还没回来.
// 此处先拿一个 "固定默认图片" 顶替一下.
QByteArray tmpContent = loadFileToByteArray(":/resource/image/image.png");
image.loadFromData(tmpContent);
} else {
// 此处的 load 操作 QImage 能够自动识别当前图片是啥类型的 (png, jpg....)
image.loadFromData(content);
}
// 3. 针对图片进行缩放.
int height = 0;
if (image.width() > width) {
// 发现图片更宽, 就需要把图片缩放一下, 使用 width 作为实际的宽度
// 等比例缩放.
height = ((double)image.height() / image.width()) * width;
} else {
// 图片本身不太宽, 不需要缩放.
width = image.width();
height = image.height();
}
// pixmap 只是一个中间变量. QImage 不能直接转成 QIcon, 需要 QPixmap 中转一下
QPixmap pixmap = QPixmap::fromImage(image);
// imageBtn->setFixedSize(width, height);
imageBtn->setIconSize(QSize(width, height));
imageBtn->setIcon(QIcon(pixmap));
// 4. 由于图片高度是计算算出来的. 该元素的父对象的高度, 能够容纳下当前的元素.
// 此处 + 50 是为了能够容纳下 上方的 "名字" 部分. 同时留下一点 冗余 空间.
parent->setFixedHeight(height + 50);
// 5. 确定按钮所在的位置.
// 左侧消息, 和右侧消息, 要显示的位置是不同的.
if (isLeft) {
imageBtn->setGeometry(10, 0, width, height);
} else {
int leftPos = this->width() - width - 10;
imageBtn->setGeometry(leftPos, 0, width, height);
}
}
历史消息界面的消息正文
这里就是使用的网格布局,将头像/昵称和时间/消息正文/组织在一起。
其中消息正文由于不需要进行绘制,是通过基础控件实现的。对于文本消息是通过这两个参数实现的:
- contentLabel->setWordWrap(true); //文本换行
- contentLabel->adjustSize(); //label自动调整大小
对于图片消息正文则是计算图片的宽度,如果宽度大于300则强制设置成300,进行等比缩放。
随机验证码的绘制
验证码的主要是通过随机生成四个字符,加上qt的Qpainter对象实现的。
类中需要一个变量存放生成的验证码.
class VerifyCodeWidget : public QWidget
{
Q_OBJECT
public:
explicit VerifyCodeWidget(QWidget *parent = nullptr);
// 通过这个函数, 生成随机的验证码字符串
QString generateVerifyCode();
// 重新生成验证码并显示到界面上
void refreshVerifyCode();
// 检验验证码是否匹配
bool checkVerifyCode(const QString& verifyCode);
void paintEvent(QPaintEvent* event) override;
// 用户点击的时候, 刷新验证码, 并重新显示.
void mousePressEvent(QMouseEvent* event) override;
private:
// 随机数生成器
QRandomGenerator randomGenerator;
// 保存验证码的值
QString verifyCode = "";
signals:
};
- 通过重写鼠标按下事件,来生成验证码,并调用update()
- 重写paintEvent,来实现验证码的绘制。
- 通过随机值来绘制成噪点和噪线,最后绘制验证吗。