这个软件是基于我半年多前写的一个小小小软件(https://www.bilibili.com/video/BV1zN411Q7u4)的一个大更新,不过其实相当于重新写了一个就是了www
完整代码我已经开源到gitee和github上了,并且软件的使用方法已经发到b站,链接在文末,欢迎大家一起学习讨论。
软件有日志都很常见了,既可以了解到现在程序运行在哪,也可以很方便地定位到出错代码。
写在所有代码之前作为全局变量也是为了各个代码模块方便调用。
''' 日志对象 ''' if not os.path.exists('log'): os.mkdir('log') def creatLogger(): logger = logging.getLogger('mylogger') logger.setLevel(logging.DEBUG) # 全部日志处理器 rf_handler = logging.handlers.TimedRotatingFileHandler('log/all.log', when='midnight', interval=1, backupCount=7, atTime=datetime.time(0, 0, 0, 0)) rf_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) # error日志处理器 f_handler = logging.FileHandler('log/error.log') f_handler.setLevel(logging.ERROR) f_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(filename)s[:%(lineno)d] - %(message)s")) # 加入logger logger.addHandler(rf_handler) logger.addHandler(f_handler) # 返回设置好的logger对象 return logger # 实例化 logger = creatLogger()
获取b站直播弹幕用到了websocket协议,使用到了aiowebsocket这个类。详情可以看我上一篇文章(AioWebSocket实现python异步接收B站直播弹幕),已经讲得挺详细的了(大概),将文章中的代码封装成一个类就可以拿来用了,这里就只列出函数定义头了。
class BiliSocket(): def __init__(self): async def startup(self,roomid): def getRealRoomid(self,url): async def check2close(self): def close(self): async def sendHeartBeat(self, websocket): async def receDM(self, websocket): def printDM(self, data): def DANMU_handle(self,data):
所不同的是,我新加入了几个函数。
async def check2close(self): ''' 循环判断关闭标志位closeFlag是否为真 若为真则抛出异常来结束该子线程 一定要设置休眠否则占据资源导致卡死 ''' while True: await asyncio.sleep(0.2) if self.__closeFlag == True: raise KeyboardInterrupt def close(self): '''通过设置标志位来结束线程''' self.__closeFlag = True
定义一个标志位__closeFlag,子线程通过扫描它来判断自己是否该退出,若是则抛出异常来退出该线程。完美解决!
前端方面用到的是pyqt5。之前我也用过tkinter(python自带的一个gui库)来实现过类似的应用,那是我半年前写的。但我发现tkinter功能太少,界面太简陋,不好操作,所以换成了比较多人用的gui,也就是pyqt5。但qt资料大多是c++的,转换到python不是一件容易事,对于我这种新手来说真的很不容易(所以为什么要用python来写啊哈哈)。
首先安装
pip install PyQt5 pip install PyQt5-tools
导入pyqt5模块(这是懒人一键全部导入,也可以一个一个模块导入)
其他模块就不赘述了,用到再导入就好。
from PyQt5 import QtCore from PyQt5.QtCore import * from PyQt5.QtGui import * from PyQt5.QtWidgets import * from PyQt5.uic import loadUi
在pyqt5安装目录下可以找到qt designer,这个图形化软件可以帮助我们设计出想要的窗口。
有时候,多个窗口之间要互相传递信息,这时候可以利用qt的信号和槽来实现。
举个例子
# 定义一种信号,两个参数 类型分别是: 整数 和 字符串 # 调用 emit方法 发信号时,传入参数 必须是这里指定的 参数类型 text_print = QtCore.pyqtSignal(int, str)
发送信号
text_print.emit(1,'abc')
接收信号
def printFun(num,string): print(num,string) text_print.connect(printFun)
而我很多时候需要传递不同种参数,需要多个信号变量,所以直接定义一个信号类并实例化,作为全局变量。这样就可以让不同的类之间相互交流了,不是继承自QObject的类也可以用。
''' 自定义信号源对象类型,一定要继承自 QObject ''' class MySignals(QObject): # 定义一种信号,两个参数 类型分别是: 整数 和 字符串 # 调用 emit方法 发信号时,传入参数 必须是这里指定的 参数类型 text_print = QtCore.pyqtSignal(int, str) # 还可以定义其他种类的信号 my_Signal = QtCore.pyqtSignal(str) new_comment = QtCore.pyqtSignal(bool,str) otherChange = QtCore.pyqtSignal(str,str) sizeChange = QtCore.pyqtSignal(str,int) fontChange = QtCore.pyqtSignal(QFont) # 实例化 global_ms = MySignals()
主窗口比较简单,简单写写就行。
class MainWindow(QMainWindow): def __init__(self, parent=None): super(MainWindow, self).__init__(parent) loadUi('ui/MainWindow.ui', self) # 标题、图标 self.setWindowTitle('主窗口') self.setWindowIcon(QIcon(iconPath)) # 禁止最大化按钮 self.setWindowFlags(Qt.WindowMinimizeButtonHint|Qt.WindowCloseButtonHint) # ui按钮链接 self.Ui_init() def Ui_init(self): self.openSettingWinButton.triggered.connect(lambda: global_ms.my_Signal.emit('setting')) self.openAboutWinButton.triggered.connect(lambda: global_ms.my_Signal.emit('about')) self.startButton.clicked.connect(lambda: global_ms.my_Signal.emit('start')) self.closeButton.clicked.connect(lambda: global_ms.my_Signal.emit('close'))
设置窗口主要是对展示窗口进行一些参数的设置,比如文字大小、颜色、字体等。重点和难点应该是参数发生变化后如何与展示窗口进行交流并及时调整。
class ConfigWindow(QWidget): def __init__(self, parent=None): super(ConfigWindow, self).__init__(parent) loadUi('ui/ConfigWindow.ui', self) # 标题、图标 self.setWindowTitle('设置') self.setWindowIcon(QIcon(iconPath))
这里我采用的是(QLineEdit+QPushButton )和QSpinBox 实现参数的改变。将QLineEdit设置为只读,当用户点击按钮修改参数时,程序会修改QLineEdit的内容;QSpinBox 则是直接改变内容的。当QLineEdit或QSpinBox的值发生变化时,发出信号并传递需要修改的参数,展示信号接收后作出相应操作。
下面的是窗口初始化部分代码,将QLineEdit或QSpinBox的值发生变化时与函数绑定,将参数发射出去。(接收部分详见展示窗口)
# 发送者id颜色 self.userColorView.setText(initusercolor) self.userColorButton.clicked.connect(self.getUserColor) self.userColorView.textChanged.connect(self.UserColorChange # 字体大小 self.charactersSize.setValue(initsize) self.charactersSize.valueChanged.connect(self.getSize)
def getUserColor(self): c = QColorDialog.getColor() colorName = c.name() # getColor对话框点击取消时会返回缺省值#000000 # 为避免这种情况直接ban掉#000000,未找到更好方案 if colorName != '#000000': # print(colorName) self.userColorView.setText(colorName) else: msg_box = QMessageBox(QMessageBox.Warning, '提示', '#000000禁止设置,黑色请尝试#000001等') msg_box.exec_() def UserColorChange(self,colorName): self.usercolor = colorName global_ms.otherChange.emit('userColor',colorName) def getSize(self,size): self.size = size global_ms.sizeChange.emit('charactersSize',self.size)
设置窗口还有一个功能,就是将参数导入、导出,不用每次都手动设置。因为我不熟悉配置文件的操作,不知道我这种存储方式用那种文件比较合适,所以用了我最熟悉的excel表。如果各位有什么好推荐的话欢迎向我提出。
一开始我是打算打开一个文件浏览框让用户选择路径和文件名,但发现这样不好操作,因为做不到将用户设置的路径保存下来,下次自动导入(不可能为了它新建一个文件来保存吧,这样也不保险)。最保险的方法就是自己在代码里设定好路径,每次到这个路径下去找就行了。
''' 配置文件路径 ''' iconPath = os.getcwd() + r'\config\icon.jpg' settingPath = os.getcwd() + r'\config\BiliComment.xlsx'
def GetOpenXlsxPath(self): logger.info('***尝试导入') if os.path.exists(settingPath): self.ImportXlsx() else: logger.warning('***文件不存在') msg_box = QMessageBox(QMessageBox.Warning, '提示', f'{settingPath}不存在!请先导出配置') msg_box.exec_() def GetSaveXlsxPath(self): logger.info('***尝试导出') self.SaveXlsx() def ImportXlsx(self): # 从文件中读取参数 try: self.listWidget1.clear() self.listWidget2.clear() self.listWidget3.clear() self.listWidget4.clear() # 只读模式下读取速度更快,但没有columns这个属性 wb = openpyxl.load_workbook(settingPath) ws = wb.active for column in ws.columns: if column[0].value == 'white': self.ImportAdd(column,self.listWidget1,whiteUser) elif column[0].value == 'black': self.ImportAdd(column,self.listWidget2,blackUser) elif column[0].value == 'kw': self.ImportAdd(column,self.listWidget3,keywords) elif column[0].value == 'filterKW': self.ImportAdd(column,self.listWidget4,filterKWs) elif column[0].value == 'size': self.charactersSize.setValue(column[1].value) self.LineNumber.setValue(column[2].value) self.LineHeight.setValue(column[3].value) elif column[0].value == 'color': self.userColorView.setText(column[1].value) self.comColorView.setText(column[2].value) elif column[0].value == 'font': self.fontComboBox.setCurrentFont(QFont(column[1].value)) elif column[0].value == 'background': filePath = column[1].value if filePath != None and os.path.exists(filePath): self.backgroundView.setText(filePath) else: logger.warning('图片修改失败。原因:图片不存在') msg_box = QMessageBox(QMessageBox.Warning, '提示', '图片修改失败。原因:图片不存在') msg_box.exec_() else: continue logger.info('***导入成功') msg_box = QMessageBox(QMessageBox.Warning, '提示', f'导入成功: {settingPath}') msg_box.exec_() except Exception as e: logger.error(u'***导入时出现异常:{}'.format(e)) msg_box = QMessageBox(QMessageBox.Warning, '警告', '导入时出现异常') msg_box.exec_() def ImportAdd(self,column,listWidget,List): for cell in column[1:]: value = str(cell.value) if value != 'None': listWidget.addItem(value) List.append(value)
展示窗口是最复杂的,花了我很长时间找资料和解决办法。
class DisplayWindow(QDockWidget): def __init__(self, parent=None): super(DisplayWindow, self).__init__(parent) loadUi('ui/DisplayWindow.ui', self) self.setWindowFlags(Qt.WindowStaysOnTopHint|Qt.FramelessWindowHint|Qt.Tool) # 置顶、无边框、隐藏任务栏 self.setAttribute(Qt.WA_TranslucentBackground) # 窗体背景透明 self.setMouseTracking(True) # 设置widget鼠标跟踪
# 中间层初始化 self.comWidget = QWidget() self.gridLayout.addWidget(self.comWidget, 0, 26, 11, 11) self.comVerticalLayout = QVBoxLayout(self.comWidget) self.comVerticalLayout.setObjectName("comVerticalLayout") # 添加textEdit用于显示弹幕,并初始化 self.textEdit = QPlainTextEdit(self.comWidget) self.textEdit.setReadOnly(True) self.textEdit.setUndoRedoEnabled(False) self.textEdit.setMaximumBlockCount(initlinenum) font = QFont(initfont) font.setWordSpacing(20) self.textEdit.setFont(font) self.textEdit.setStyleSheet( f"font-size:{initsize}px;border: none; background-color: transparent; font-weight: bold;") self.comVerticalLayout.addWidget(self.textEdit) # 指针 self.FontFormat = QTextCharFormat() self.BlockFormat = QTextBlockFormat() self.tc = self.textEdit.textCursor()
将弹幕加入QPlainTextEdit时我发现只能在主线程中操作,如果在子线程中会有显示延迟的情况。不过这也没有什么办法
def addComment(self,bool,msg): ''' 这里必须在主线程里添加,否则显示会延迟,但消息多时可能会导致窗口无响应。 装在列表里for循坏也不行,目前还没找到更好的方法 ''' if bool: # 设置发送者id样式 self.FontFormat.setForeground(self.Userfont) self.textEdit.mergeCurrentCharFormat(self.FontFormat) self.textEdit.appendPlainText("".join(msg.split(': ')[0]) + ": ") # 设置弹幕内容样式 self.FontFormat.setForeground(self.Comfont) self.textEdit.mergeCurrentCharFormat(self.FontFormat) self.tc.movePosition(QTextCursor.End) self.textEdit.insertPlainText("".join(msg.split(': ')[1])) # 刷新,可有可无 QApplication.processEvents()
def _initDrag(self): # 初始化部分 # 设置鼠标跟踪判断扳机默认值 self._move_drag = False self._corner_drag = False self._bottom_drag = False self._right_drag = False # 判断鼠标位置切换鼠标手势 self.cornerLabel.setCursor(Qt.SizeFDiagCursor) self.bottomLabel.setCursor(Qt.SizeVerCursor) self.rightLabel.setCursor(Qt.SizeHorCursor) def resizeEvent(self, QResizeEvent): # 自定义窗口调整大小事件 # 改变窗口大小的三个坐标范围 ran = 30 self._right_rect = [QPoint(x, y) for x in range(self.width() - ran, self.width() ) for y in range(1, self.height()-ran)] self._bottom_rect = [QPoint(x, y) for x in range(1, self.width() - ran) for y in range(self.height() - ran, self.height())] self._corner_rect = [QPoint(x, y) for x in range(self.width() - ran, self.width() ) for y in range(self.height() - ran, self.height() )] def mousePressEvent(self, event): # 重写鼠标点击的事件 if (event.button() == Qt.LeftButton) and (event.pos() in self._corner_rect): # 鼠标左键点击右下角边界区域 self._corner_drag = True event.accept() elif (event.button() == Qt.LeftButton) and (event.pos() in self._right_rect): # 鼠标左键点击右侧边界区域 self._right_drag = True event.accept() elif (event.button() == Qt.LeftButton) and (event.pos() in self._bottom_rect): # 鼠标左键点击下侧边界区域 self._bottom_drag = True event.accept() elif (event.button() == Qt.LeftButton) and (event.y() < self.height()-30): # 鼠标左键点击其他位置 self._move_drag = True self.move_DragPosition = event.globalPos() - self.pos() event.accept() def mouseMoveEvent(self, QMouseEvent): # 当鼠标左键点击不放及满足点击区域的要求后,分别实现不同的窗口调整 # 没有定义左方和上方相关的5个方向,主要是因为实现起来不难,但是效果很差,拖放的时候窗口闪烁,再研究研究是否有更好的实现 if Qt.LeftButton and self._right_drag: # 右侧调整窗口宽度 self.resize(QMouseEvent.pos().x(), self.height()) QMouseEvent.accept() elif Qt.LeftButton and self._bottom_drag: # 下侧调整窗口高度 self.resize(self.width(), QMouseEvent.pos().y()) QMouseEvent.accept() elif Qt.LeftButton and self._corner_drag: # 右下角同时调整高度和宽度 self.resize(QMouseEvent.pos().x(), QMouseEvent.pos().y()) QMouseEvent.accept() elif Qt.LeftButton and self._move_drag: # 其他位置拖放窗口位置 self.move(QMouseEvent.globalPos() - self.move_DragPosition) QMouseEvent.accept() def mouseReleaseEvent(self, QMouseEvent): # 鼠标释放后,各扳机复位 self._move_drag = False self._corner_drag = False self._bottom_drag = False self._right_drag = False
MainWindow.setWindowOpacity(0.85) # 设置窗口透明度 MainWindow.setAttribute(QtCore.Qt.WA_TranslucentBackground) # 设置窗口背景透明
但这些都只能改变整个窗口包括控件透明度。但我需要的是仅仅只是背景的改变。于是我想到,在文字控件的下面再铺一层QLabel,让这个QLabel显示图片,并且自己在ps里制作几张不同透明度的图片,分别让他显示出来:
搞定!窗口初始化代码如下:
# 底层label,设置背景图片 self.backLabel = QLabel() self.backLabel.setObjectName("backLabel") pix = QPixmap(initbackground) self.backLabel.setPixmap(pix) self.backLabel.setScaledContents(True) self.gridLayout.addWidget(self.backLabel, 0, 26, 11, 11)
注意一定要铺在上层文字控件的同一位置(0, 26, 11, 11),否则这个Label会被挤到一边去。也正是这个原因我在qt designer里弄不到理想的效果,所以只能自己在代码里实现了。
后续要改变透明度的话,只需要将图片路径传进QPixmap再调用setPixmap就可以了。除了白底图片,其他任意图片也是可以的。
4. 【信号连接】
初始化
def SignalConnect_init(self): global_ms.new_comment.connect(self.addComment) global_ms.sizeChange.connect(self.modifySize) global_ms.fontChange.connect(self.modifyFont) global_ms.otherChange.connect(self.modifyOther)
以下代码以modifySize()为例:
def modifySize(self,type,value): '''多线程减轻主线程压力,防止窗口无响应''' def aa(): try: if type == 'charactersSize': self.textEdit.setStyleSheet( f"font-size:{value}px;font-weight:bold;border: none; background-color: transparent;") logger.info('修改 文字大小 成功') elif type == 'lineNum': self.textEdit.setMaximumBlockCount(value) logger.info('修改 行数 成功') elif type == 'lineHeight': self.BlockFormat.setLineHeight(value, QTextBlockFormat.FixedHeight) self.tc.setBlockFormat(self.BlockFormat) self.textEdit.setTextCursor(self.tc) logger.info('修改 行高 成功') except Exception as e: logger.error(u'修改参数失败:{}'.format(e)) threading.Thread(target=aa).start()
添加弹幕因为显示问题不得不在主线程中进行,但修改参数没有这些问题,所以在子线程中修改就行,可以减少主线程压力,防止窗口无响应。
托盘这里我采用的方法是继承QObject类。因为像MainWindow他有一个菜单栏QMenu,那我只要模仿他,创建一个QMenu,再将动作QAction加进去,就变成了一个托盘菜单选项了。
class TUOPAN(QObject): def __init__(self): super(TUOPAN, self).__init__() self.Ui_init() def Ui_init(self): # -------------------- 托盘开始 ---------------- # 在系统托盘处显示图标 self.tp = QSystemTrayIcon(self) self.tp.setIcon(QIcon('./config/icon.jpg')) # 设置系统托盘图标的菜单 self.a1 = QAction('&主窗口', triggered=lambda:global_ms.my_Signal.emit('MainWindow')) self.a2 = QAction('&开始/更新', triggered=lambda:global_ms.my_Signal.emit('start')) self.a3 = QAction('&关闭', triggered=lambda:global_ms.my_Signal.emit('close')) self.a4 = QAction('&设置', triggered=lambda:global_ms.my_Signal.emit('setting')) self.a5 = QAction('&退出', triggered=lambda:global_ms.my_Signal.emit('exit')) # 直接退出可以用qApp.quit self.tpMenu = QMenu() self.tpMenu.addAction(self.a1) self.tpMenu.addAction(self.a2) self.tpMenu.addAction(self.a3) self.tpMenu.addAction(self.a4) self.tpMenu.addAction(self.a5) self.tp.setContextMenu(self.tpMenu) # 点击活动连接到函数处理 self.tp.activated.connect(self.act) # 不调用show不会显示系统托盘 self.tp.show() # -------------------- 托盘结束 ------------------ def act(self, reason): # 鼠标点击icon传递的信号会带有一个整形的值,1是表示单击右键,2是双击,3是单击左键,4是用鼠标中键点击 if reason == 2: global_ms.my_Signal.emit('MainWindow')
上面已经完成了各个功能模块的类的代码,接下来只需要把他们联系起来
class BiliDanmuji(): def __init__(self): # 主要对象 self.app = QApplication(sys.argv) self.app.setQuitOnLastWindowClosed(False) # 最小化托盘用,关闭所有窗口也不结束程序 self.scan() # 扫描文件夹,若不存在报错 self.w = MainWindow() # 主窗口 self.settingWindow = ConfigWindow() # 设置窗口 self.display = DisplayWindow() # 弹幕展示窗口 self.about = AboutInfo() # 关于窗口 self.tuopan = TUOPAN() # 托盘对象 # 爬虫对象 self.bilisocket = BiliSocket() # 开始运行、弹幕窗口隐藏标志 self.startFlag = False self.isHide = True # 接受子窗口传回来的信号 然后调用主界面的函数 global_ms.my_Signal.connect(self.SignalHandle) # 写日志 logger.info('----------------------初始化成功-----------------------')
点击(开始/更新)按钮执行的函数:先判断输入房间号是否合法;然后判断是否已经开始,若已经开始则为更新效果(先执行关闭操作再重新开始)。将获取弹幕的操作放到子线程。
def start_run(self): try: # 获取房间号 text = self.w.roomidEdit.text() if text != '' and text.isdigit(): if self.startFlag == False: self.startFlag = True else: print(self.SocketTread.is_alive()) if self.SocketTread.is_alive(): self.close_com() self.display.clearWindow() self.bilisocket.comList.clear() self.display.show() self.isHide = False loop = asyncio.new_event_loop() self.SocketTread = threading.Thread(target=self.asyncTreadfun, args=(loop, text), name='SocketTread') self.SocketTread.daemon = True # 守护线程 self.SocketTread.start() logger.info(f'开启/更新成功,当前房间:{text}') else: self.startFlag = False logger.info('输入房间号有误') msg_box = QMessageBox(QMessageBox.Warning, '提示', '请输入正确房间号') msg_box.exec_() except Exception as e: self.startFlag = False self.isHide = True logger.error(u'开启/更新出错:{}'.format(e)) def asyncTreadfun(self,new_loop,roomid): try: asyncio.set_event_loop(new_loop) self.loop = asyncio.get_event_loop() task = asyncio.ensure_future(self.bilisocket.startup(roomid)) self.loop.run_until_complete(asyncio.wait([task])) except RuntimeError as e: logger.warning(u'loop循环未完成退出(若是关闭时为正常现象)。错误信息:{}'.format(e))
点击(关闭)按钮执行的函数:调用BiliSocket类的自定义close方法,并且等待它抛出异常,从而达到结束线程的效果。
def close_com(self): if self.startFlag == True: self.display.hide() self.isHide = True try: logger.info('///尝试关闭循环') self.bilisocket.close() # 等待抛出异常,抛出后线程自动结束 time.sleep(0.3) logger.info(f'loop状态:{self.loop.is_running()},{self.loop.is_closed()} / ' f'SocketTread状态:{self.SocketTread.is_alive()}') logger.info('///循环关闭成功') except NotImplementedError as e: logger.error(u'///关闭循环出错:{}'.format(e))
接收信号并处理
def SignalHandle(self,value): if value == 'closeWin': print('子窗口被关闭') elif value == 'MainWindow': self.w.show() elif value == 'start': self.start_run() elif value == 'close': self.close_com() elif value == 'setting': self.settingWindow.show() elif value == 'about': self.about.show() elif value == 'WebSocketError': self.close_com() msg_box = QMessageBox(QMessageBox.Warning, '警告', '获取弹幕失败') msg_box.exec_() elif value == 'exit': self.quitApp()
退出软件
def quitApp(self): print('托盘关闭') if self.isHide == False: self.close_com() # 关闭窗体程序 QCoreApplication.instance().quit() self.tuopan.tp.setVisible(False) logger.info('----------------------程序正常退出-----------------------') sys.exit(0)
写上循环指令
def run(self): try: # 显示主窗口,开始处理窗口事件 self.w.show() sys.exit(self.app.exec_()) except: logger.critical('**********************程序异常退出************************')
最后,只需要在其他地方实例化这个类,并且调用它的run方法,就可以运行整个程序啦!
danmuji = BiliDanmuji() danmuji.run()
b站教程:https://www.bilibili.com/video/BV1LP4y177sa
gitee:https://gitee.com/huihui486/bilibili-danmuji
github:https://github.com/huihui486/bilibili-danmuji