PyQt5程序自動更新的實現(xiàn)代碼
一、背景
開發(fā)的QT工具需要給不同的部門間使用,工具版本迭代需要經(jīng)歷打包->壓縮->上傳到共享目錄->下載解壓,協(xié)作十分不爽。且PyQt5不像原生的Qt有自己的更新框架,因此需要自己實現(xiàn)一套更新邏輯。
二、需要關(guān)注的核心問題
- Qt應(yīng)用需要運行起來后,才能有可交互的界面,即優(yōu)先執(zhí)行
app = QApplication(sys.argv) - 更新時,需要替換掉有修改的模塊,但是在Qt程序運行時,某些模塊是被占用的,無法被替換刪除,因此需要在Qt程序停止后才能替換。
三、實現(xiàn)思路
- 另啟動一個監(jiān)控程序,專門負(fù)責(zé)更新。
- 通過編寫執(zhí)行.bat文件,負(fù)責(zé)Qt程序停止運行后的更新、替換、重新啟動等一系列操作。 很明顯,第二種方法看起來更簡潔,迅速。
四、具體實現(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對話框
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"檢查更新時出錯: {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實現(xiàn)可靠拷貝)
robocopy {os.path.join(root_path, 'DebugTool')} {root_path} /E
REM 啟動新版本
start {root_path}\DebugTool\DebugTool.exe --check
REM 刪除臨時文件
del "%~f0"
"""
# 將批處理腳本寫入臨時文件
bat_path = os.path.join(root_path, 'DebugTool', "update.bat")
with open(bat_path, 'w') as f:
f.write(bat_content)
# 啟動批處理腳本
os.startfile(bat_path)
# 退出當(dāng)前程序
QApplication.instance().quit()
else:
# 彈出更新失敗提示框
QMessageBox.critical(None, "更新失敗", "更新失敗,請檢查或重新打開選擇不更新")
sys.exit(0)
else:
# 正常啟動應(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):
"""獲取資源的絕對路徑,兼容開發(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)
# 驗證文件
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)
# 備份整個應(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)
# 解壓到臨時目錄
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):
"""更新版本號文件"""
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)度對話框"""
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,
"更新完成",
"更新已成功安裝,點擊OK后,即將重新啟動應(yīng)用程序(10s內(nèi))。"
)
return True
return True
except:
return False
五、總結(jié)
到此這篇關(guān)于PyQt5程序自動更新的實現(xiàn)代碼的文章就介紹到這了,更多相關(guān)PyQt5程序自動更新內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
python獲取當(dāng)前時間對應(yīng)unix時間戳的方法
這篇文章主要介紹了python獲取當(dāng)前時間對應(yīng)unix時間戳的方法,涉及Python時間操作的相關(guān)技巧,非常簡單實用,需要的朋友可以參考下2015-05-05
Python3爬蟲學(xué)習(xí)之將爬取的信息保存到本地的方法詳解
這篇文章主要介紹了Python3爬蟲學(xué)習(xí)之將爬取的信息保存到本地的方法,結(jié)合實例形式詳細(xì)分析了Python3信息爬取、文件讀寫、圖片存儲等相關(guān)操作技巧,需要的朋友可以參考下2018-12-12
Python常用標(biāo)準(zhǔn)庫詳解(pickle序列化和JSON序列化)
這篇文章主要介紹了Python常用標(biāo)準(zhǔn)庫,主要包括pickle序列化和JSON序列化模塊,通過使用場景分析給大家介紹的非常詳細(xì),需要的朋友可以參考下2022-05-05
python字典setdefault方法和get方法使用實例
這篇文章主要介紹了python字典setdefault方法和get方法使用實例,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-12-12

