PyQt5程序自動(dòng)更新的實(shí)現(xiàn)代碼
一、背景
開發(fā)的QT工具需要給不同的部門間使用,工具版本迭代需要經(jīng)歷打包->壓縮->上傳到共享目錄->下載解壓,協(xié)作十分不爽。且PyQt5不像原生的Qt有自己的更新框架,因此需要自己實(shí)現(xiàn)一套更新邏輯。
二、需要關(guān)注的核心問題
- Qt應(yīng)用需要運(yùn)行起來后,才能有可交互的界面,即優(yōu)先執(zhí)行
app = QApplication(sys.argv) - 更新時(shí),需要替換掉有修改的模塊,但是在Qt程序運(yùn)行時(shí),某些模塊是被占用的,無法被替換刪除,因此需要在Qt程序停止后才能替換。
三、實(shí)現(xiàn)思路
- 另啟動(dòng)一個(gè)監(jiān)控程序,專門負(fù)責(zé)更新。
- 通過編寫執(zhí)行.bat文件,負(fù)責(zé)Qt程序停止運(yùn)行后的更新、替換、重新啟動(dòng)等一系列操作。 很明顯,第二種方法看起來更簡(jiǎn)潔,迅速。
四、具體實(shí)現(xiàn)
# main.py
import argparse
import os
import sys
from PyQt5.QtWidgets import QApplication, QMessageBox
from packaging import version
from main import MainForm
from updates_scripts.updater import update_main
def check_is_need_update():
"""檢查應(yīng)用程序是否需要更新"""
try:
remote_path = r'\\10.10.10.10' # 我這里是共享文件服務(wù)器,根據(jù)需求自己替換
# 讀取本地版本
with open('version.bin', 'r', encoding='utf-8') as f:
local_version = f.readline().strip()
# 遍歷遠(yuǎn)程文件夾查找最新版本
max_version = version.parse('0')
update_path = ''
for root, dirs, files in os.walk(remote_path):
for file in files:
if file.startswith('DebugTool_V') and file.endswith('.zip'):
remote_version = file[file.find('_V') + 2:file.rfind('.')]
try:
remote_ver = version.parse(remote_version)
if remote_ver > max_version:
max_version = remote_ver
update_path = os.path.join(root, file)
except version.InvalidVersion:
continue
# 比較版本
local_ver = version.parse(local_version)
if max_version > local_ver:
# 必須在主線程中顯示Qt對(duì)話框
reply = QMessageBox.question(
None,
"更新提示",
f"發(fā)現(xiàn)新版本 {max_version} (當(dāng)前版本 {local_version}),是否更新?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
print("用戶選擇更新")
root_path = os.path.dirname(os.path.abspath(sys.argv[0]))
return {
'update': True,
'root_dir': root_path,
'version': f"DebugTool_V{str(max_version)}",
'remote_file': update_path
}
else:
print("用戶取消更新")
return {'update': False}
except Exception as e:
print(f"檢查更新時(shí)出錯(cuò): {str(e)}")
return {'update': False, 'error': str(e)}
if __name__ == "__main__":
# 初始化Qt應(yīng)用
app = QApplication(sys.argv)
parser = argparse.ArgumentParser()
parser.add_argument('--check', action='store_true', help='是否需要檢查更新')
args = parser.parse_args()
# 檢查更新
if not args.check:
update_info = check_is_need_update()
if not args.check and update_info.get('update'):
# 執(zhí)行更新邏輯
print(f"準(zhǔn)備更新到: {update_info['version']}")
# 這里添加您的更新邏輯
if update_main(root_dir=update_info['root_dir'], version=update_info['version'],
remote_file=update_info['remote_file']):
root_path = update_info['root_dir']
bat_content = f"""@echo off
REM 等待主程序退出
:loop
tasklist /FI "IMAGENAME eq DebugTool.exe" 2>NUL | find /I "DebugTool.exe" >NUL
if "%ERRORLEVEL%"=="0" (
timeout /t 1 /nobreak >NUL
goto loop
)
REM 刪除舊文件
del /F /Q "{root_path}\*.*"
for /D %%p in ("{root_path}\*") do (
if /I not "%%~nxp"=="DebugTool" (
if /I not "%%~nxp"=="plans" (
if /I not "%%~nxp"=="Logs" (
if /I not "%%~nxp"=="results" (
rmdir "%%p" /s /q
)
)
)
)
)
REM 拷貝新版本文件(使用robocopy實(shí)現(xiàn)可靠拷貝)
robocopy {os.path.join(root_path, 'DebugTool')} {root_path} /E
REM 啟動(dòng)新版本
start {root_path}\DebugTool\DebugTool.exe --check
REM 刪除臨時(shí)文件
del "%~f0"
"""
# 將批處理腳本寫入臨時(shí)文件
bat_path = os.path.join(root_path, 'DebugTool', "update.bat")
with open(bat_path, 'w') as f:
f.write(bat_content)
# 啟動(dòng)批處理腳本
os.startfile(bat_path)
# 退出當(dāng)前程序
QApplication.instance().quit()
else:
# 彈出更新失敗提示框
QMessageBox.critical(None, "更新失敗", "更新失敗,請(qǐng)檢查或重新打開選擇不更新")
sys.exit(0)
else:
# 正常啟動(dòng)應(yīng)用
win = MainForm()
win.show()
sys.exit(app.exec_())
updater.py
# -*- coding: utf-8 -*-
"""
Time : 2025/5/6 20:50
Author : jiaqi.wang
"""
# -*- coding: utf-8 -*-
"""
Time : 2025/5/6 9:39
Author : jiaqi.wang
"""
import json
import os
import shutil
import sys
import zipfile
from datetime import datetime
from PyQt5.QtCore import QSettings, Qt
from PyQt5.QtWidgets import (QApplication, QLabel, QMessageBox, QProgressDialog)
def get_resource_path(relative_path):
"""獲取資源的絕對(duì)路徑,兼容開發(fā)模式和打包后模式"""
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, relative_path)
return os.path.join(os.path.abspath("."), relative_path)
class UpdateConfig:
"""更新系統(tǒng)配置"""
def __init__(self):
self.settings = QSettings("DebugTool", "DebugTool")
# 修改為共享文件夾路徑
self.config = {
'update_url': r"\\10.10.10.10",
'auto_check': self.settings.value("auto_update", True, bool),
'allow_incremental': True,
'max_rollback_versions': 3,
'app_name': 'DebugTool',
'main_executable': 'DebugTool.exe' if sys.platform == 'win32' else 'DebugTool'
}
def __getitem__(self, key):
return self.config[key]
def set_auto_update(self, enabled):
self.config['auto_check'] = enabled
self.settings.setValue("auto_update", enabled)
class SharedFileDownloader:
"""從Windows共享文件夾下載文件的下載器"""
def __init__(self, config, parent=None):
self.config = config
self.parent = parent
self.temp_dir = os.path.join(os.path.expanduser("~"), "temp", f"{self.config['app_name']}_updates")
os.makedirs(self.temp_dir, exist_ok=True)
def download_update(self, update_info, progress_callback=None):
"""從共享文件夾下載更新包"""
try:
if update_info['update_type'] == 'incremental':
# 增量更新
return self.download_incremental(update_info, progress_callback)
else:
# 全量更新
return self.download_full(update_info, progress_callback)
except Exception as e:
raise Exception(f"從共享文件夾下載失敗: {str(e)}")
def download_full(self, update_info, progress_callback):
"""下載完整更新包"""
local_file = os.path.join(self.temp_dir, f"{update_info['version']}.zip")
self._copy_from_share(update_info['remote_file'], local_file, progress_callback)
return local_file
def download_incremental(self, update_info, progress_callback):
"""下載增量更新包"""
incremental = update_info['incremental']
remote_file = f"incremental/{incremental['file']}"
local_file = os.path.join(self.temp_dir, f"{update_info['version']}.zip")
self._copy_from_share(remote_file, local_file, progress_callback)
# 下載差異文件清單
remote_manifest = f"incremental/{incremental['manifest']}"
local_manifest = os.path.join(self.temp_dir, f"inc_{update_info['version']}.json")
self._copy_from_share(remote_manifest, local_manifest, None)
return local_file
def _copy_from_share(self, remote_path, local_path, progress_callback):
"""從共享文件夾復(fù)制文件"""
# 構(gòu)造完整的共享路徑
source_path = remote_path
# 確保使用UNC路徑格式
if not source_path.startswith('\\\\'):
source_path = '\\\\' + source_path.replace('/', '\\')
# 標(biāo)準(zhǔn)化路徑
source_path = os.path.normpath(source_path)
local_path = os.path.normpath(local_path)
# 確保本地目錄存在
os.makedirs(os.path.dirname(local_path), exist_ok=True)
if progress_callback:
progress_callback(0, f"準(zhǔn)備從共享文件夾 {source_path} 復(fù)制...")
QApplication.processEvents()
try:
# 獲取文件大小用于進(jìn)度顯示
total_size = os.path.getsize(source_path)
# 模擬進(jìn)度更新(文件復(fù)制是原子操作)
if progress_callback:
progress_callback(30, "正在從共享文件夾復(fù)制文件...")
QApplication.processEvents()
# 執(zhí)行文件復(fù)制
shutil.copy2(source_path, local_path)
# 驗(yàn)證文件
if os.path.getsize(local_path) != total_size:
raise Exception("文件大小不匹配,復(fù)制可能不完整")
if progress_callback:
progress_callback(100, "文件復(fù)制完成!")
except Exception as e:
# 清理可能已部分復(fù)制的文件
if os.path.exists(local_path):
try:
os.remove(local_path)
except:
pass
raise e
class UpdateApplier:
"""更新應(yīng)用器"""
def __init__(self, config, backup_dir):
self.config = config
self.backup_dir = backup_dir
os.makedirs(self.backup_dir, exist_ok=True)
def apply_update(self, update_file, update_info, progress_callback=None):
"""應(yīng)用更新"""
try:
if progress_callback:
progress_callback(10, "開始應(yīng)用更新...")
if update_info['update_type'] == 'incremental':
self._apply_incremental_update(update_file, update_info)
else:
self._apply_full_update(update_file, update_info['root_dir'])
if progress_callback:
progress_callback(90, "更新版本信息...")
self._update_version_file(update_info['version'], update_info['root_dir'])
if progress_callback:
progress_callback(100, "更新完成!")
return True
except Exception as e:
if progress_callback:
progress_callback(0, f"更新失敗: {str(e)}")
raise Exception(f"更新失敗: {str(e)}")
def _create_backup(self):
"""創(chuàng)建當(dāng)前版本的備份"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = os.path.join(self.backup_dir, f"backup_{timestamp}")
os.makedirs(backup_path, exist_ok=True)
# 備份整個(gè)應(yīng)用目錄
app_dir = r'xxx'
for item in os.listdir(app_dir):
src = os.path.join(app_dir, item)
if os.path.isfile(src):
shutil.copy2(src, os.path.join(backup_path, item))
elif os.path.isdir(src) and not item.startswith('_'):
shutil.copytree(src, os.path.join(backup_path, item))
return backup_path
def _apply_full_update(self, update_file, root_dir):
"""應(yīng)用完整更新"""
# 解壓更新包
with zipfile.ZipFile(update_file, 'r') as zip_ref:
zip_ref.extractall(root_dir)
# 解壓到臨時(shí)目錄
temp_dir = os.path.join(os.path.dirname(update_file), "temp_full_update")
with zipfile.ZipFile(update_file, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# 刪除解壓后的目錄
shutil.rmtree(os.path.dirname(os.path.dirname(update_file)))
shutil.rmtree(self.backup_dir)
def _apply_incremental_update(self, update_file, update_info):
"""應(yīng)用增量更新"""
root_dir = update_info['root_dir']
# 解析差異文件清單
manifest_file = os.path.join(
os.path.dirname(update_file),
f"inc_{update_info['version']}.json"
)
with open(manifest_file, 'r') as f:
manifest = json.load(f)
# 解壓增量包
temp_dir = os.path.join(os.path.dirname(update_file), "temp_inc_update")
with zipfile.ZipFile(update_file, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# 應(yīng)用更新
for action in manifest['actions']:
src = os.path.join(temp_dir, action['path'])
dst = os.path.join(root_dir, action['path'])
if action['type'] == 'add' or action['type'] == 'modify':
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.exists(dst):
os.remove(dst)
shutil.move(src, dst)
elif action['type'] == 'delete':
if os.path.exists(dst):
if os.path.isfile(dst):
os.remove(dst)
else:
shutil.rmtree(dst)
def _update_version_file(self, new_version, root_dir):
"""更新版本號(hào)文件"""
version_file = os.path.join(root_dir, 'version.txt')
with open(version_file, 'w') as f:
f.write(new_version)
def _rollback_update(self, backup_path):
"""回滾到備份版本"""
if not os.path.exists(backup_path):
return False
app_dir = r'xxx'
# 恢復(fù)備份
for item in os.listdir(backup_path):
src = os.path.join(backup_path, item)
dst = os.path.join(app_dir, item)
if os.path.exists(dst):
if os.path.isfile(dst):
os.remove(dst)
else:
shutil.rmtree(dst)
if os.path.isfile(src):
shutil.copy2(src, dst)
else:
shutil.copytree(src, dst)
return True
class UpdateProgressDialog(QProgressDialog):
"""更新進(jìn)度對(duì)話框"""
def __init__(self, parent=None):
super().__init__("", "取消", 0, 100, parent)
self.setWindowTitle("正在更新")
self.setWindowModality(Qt.WindowModal)
self.setFixedSize(500, 150)
self.detail_label = QLabel()
self.setLabel(self.detail_label)
def update_progress(self, value, message):
self.setValue(value)
self.detail_label.setText(message)
QApplication.processEvents()
def update_main(root_dir: str, version: str, remote_file: str):
try:
config = UpdateConfig()
update_info = {
"version": version,
"update_type": "full",
"changelog": "修復(fù)了一些bug",
'root_dir': root_dir,
'remote_file': remote_file
}
progress = UpdateProgressDialog()
downloader = SharedFileDownloader(config)
applier = UpdateApplier(config, os.path.join(os.path.expanduser("~"), f"{config['app_name']}_backups"))
# 下載更新
update_file = downloader.download_update(
update_info,
progress.update_progress
)
# 應(yīng)用更新
if progress.wasCanceled():
sys.exit(1)
success = applier.apply_update(
update_file,
update_info,
progress.update_progress
)
if success:
QMessageBox.information(
None,
"更新完成",
"更新已成功安裝,點(diǎn)擊OK后,即將重新啟動(dòng)應(yīng)用程序(10s內(nèi))。"
)
return True
return True
except:
return False
五、總結(jié)
到此這篇關(guān)于PyQt5程序自動(dòng)更新的實(shí)現(xiàn)代碼的文章就介紹到這了,更多相關(guān)PyQt5程序自動(dòng)更新內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
詳解Python中的分支和循環(huán)結(jié)構(gòu)
這篇文章主要介紹了Python中的分支和循環(huán)結(jié)構(gòu),本文通過實(shí)例代碼給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-02-02
Python編程使用*解包和itertools.product()求笛卡爾積的方法
這篇文章主要介紹了Python編程使用*解包和itertools.product()求笛卡爾積的方法,涉及Python列表轉(zhuǎn)換及itertools.product()求笛卡爾積相關(guān)操作技巧,需要的朋友可以參考下2017-12-12
Python+request+unittest實(shí)現(xiàn)接口測(cè)試框架集成實(shí)例
這篇文章主要介紹了Python+request+unittest實(shí)現(xiàn)接口測(cè)試框架集成實(shí)例,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-03-03
python將一個(gè)英文語句以單詞為單位逆序排放的方法
今天小編就為大家分享一篇python將一個(gè)英文語句以單詞為單位逆序排放的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-12-12
pyinstaller打包成無控制臺(tái)程序時(shí)運(yùn)行出錯(cuò)(與popen沖突的解決方法)
這篇文章主要介紹了pyinstaller打包成無控制臺(tái)程序時(shí)運(yùn)行出錯(cuò)(與popen沖突的解決方法),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-04-04
pyCharm 設(shè)置調(diào)試輸出窗口中文顯示方式(字符碼轉(zhuǎn)換)
這篇文章主要介紹了pyCharm 設(shè)置調(diào)試輸出窗口中文顯示方式(字符碼轉(zhuǎn)換),具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2020-06-06
Python實(shí)現(xiàn)雙人五子棋對(duì)局
這篇文章主要為大家詳細(xì)介紹了Python實(shí)現(xiàn)雙人五子棋對(duì)局,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-05-05

