当我们使用Python开发好程序需要打包成exe时,主流的做法便是使用pyinstaller,这玩意,看似简单,其实挺麻烦的,坑比较多,特别是涉及到比较复杂的库时,另外一个麻烦的事情是,打包失败后,搜索到的很多解决方案是没有效果的。
前一段时间,我用Python开发了视频同步助手,也是用pyinstaller打包的,其中涉及到opencv-python、ffmpeg、moviepy等包,嗯,这个过程比较磨人,在我配合pyinstaller源码与其文档后,掌握了一些技巧,本文简单总结记录一下,希望对你有所帮助。
如果你项目中使用了opencv-python库,简单利用pyinstaller打包,很容易出现打包成功了,却无法运行exe的情况,如下图:
从报错细节来看,它让你检查OpenCV是否安装(Check OpenCV installation),但这其实不是报错原因,核心在这句:
native_module = importlib.import_module("cv2")
importlib库在业务型项目中是比较少使用的,其作用就是动态载入相应的库,而我们在日常的业务开发中,使用import关键字来实现库的载入。
很多Python开源项目会使用importlib来实现插件系统,值得学习,但这里却因为importlib的原因,让pyinstaller打包失败。
阅读pyinstaller文档中的【What PyInstaller Does and How It Does It】小节,可知,pyinstaller在打包时,会将项目的依赖也打包进来,但不包含下面几种情况:
实现了__import__()方法的类实例,在项目中使用时,无法被pyinstaller检测
通过importlib.import_module()方法导入的库,无法被pyinstaller检测
通过sys.path执行的逻辑,无法被pyinstaller检测
嗯,pyinstaller存在这些局限,而很多知名的库却大量出现上面的三种情况,比如Django、opencv-python。
怎么办?文档给出了4种解决方案:
通过pyinstaller命令行打包时,通过相应的配置参数,给出额外的信息
将项目修改成使用import关键字导入的形式
编写spec文件,给出额外信息,这与第1种方法相同,命令行上指定的参数,等价于spec配置文件中的配置
使用hook,实现动态替换
首先排除方法2,因为这种方式只适用于你自己的项目,而Django、opencv-python这类第三方库,改不动,改动了也不好维护。
然后排除方法1与方法3,对于简单情况,这两种方法是可以的,文本后面点也会介绍,但一些第三方库,动态导入的地方比较多,你通过写死配置的形式不太靠谱。
嗯,剩下方法4了。
什么是pyinstaller的hook?其实就是动态替换一些信息的一种方法。以opencv-python为例,开发者自己知道不同版本的opencv-python动态导入时,会导入什么地方的数据,通过hook的形式,在不改动opencv-python的基础上,动态映射成我们自己的导入方式。
pyinstaller文档中给出了hook的开发细节,但不用急着动手,pyinstaller的社区已经将一些知名库的hook都开发好了,当你安装好pyinstaller时,相应的hook库其实也安装好了,叫pyinstaller-hooks-contrib。
pyinstaller-hooks-contrib 是社区维护的pyinstaller hooks机制
我们以opencv-python为例,找到opencv-python代码动态导入的位置,如下图:
当我们打包opencv-python时,需要注意opencv-python的版本,因为不同版本的opencv-python,需要hook的位置可能会改变,我们看到pyinstaller opencv-python相关的hook代码中的注释也可以看出其版本要求:
经过多次实验,下面的版本关系可以让opencv-python成功打包。
pip uninstall pyinstaller-hooks-contrib
pip install pyinstaller-hooks-contrib==2021.3
pip uninstall pyinstaller
pip install pyinstaller==4.5.1
pip uninstall opencv-python
pip install opencv-python==4.5.4.58
但,单纯的解决版本问题,还是无法很好的使用opencv-python,我们还需要将opencv-python的完整路径告诉pyinstaller,这需要使用方法1或方法3,我个人习惯使用方法3,即利用spec配置文件的形式来给pyinstaller更多额外信息。
阅读pyinstaller文档中的【Using Spec Files】小节可知,spec文件会告诉pyinstaller打包时,如何处理被打包脚本,且spec文件实际上是可执行的python代码。
从文档可知,spec文件主要有4个用途:
当你希望将数据文件与打包程序捆绑在一起时
当你希望包含运行时库时(DLL、SO等文件)
当你希望将Python run-time options添加到可执行文件时
当您想创建一个包含合并的公共模块的多程序包时
用途3与用途4没有在实际项目中使用过,所以不讨论,我们主要来看看用途1与用途2。
我们可以使用下面命令创建spec文件:
pyi-makespec main.py
下面是【无感视频同步助手】的spec文件,相比于创建出的默认spec文件,内容多会多一些,建议你直接从我这里复制出去用。
# -*- mode: python ; coding: utf-8 -*-
import json
import os
import sys
import PyInstaller.config
# 存放最终打包成app的相对路径
buildPath = 'build'
PyInstaller.config.CONF['distpath'] = buildPath
# 存放打包成app的中间文件的相对路径
cachePath = os.path.join(buildPath, 'cache')
if not os.path.exists(cachePath):
os.makedirs(cachePath)
PyInstaller.config.CONF['workpath'] = cachePath
# icon相对路径
icoPath = os.path.join('logo.ico')
# 项目名称
appName = '无感视频同步助手'
# 版本号
version = '1.0.0'
# 对Python字节码加密
block_cipher = pyi_crypto.PyiBlockCipher(key='875650321356')
a = Analysis(['gui_main.py'],
pathex=["venv\\Lib\\site-packages\\cv2"],
binaries=[("venv\\Lib\\site-packages\\cv2\\opencv_videoio_ffmpeg453_64.dll", ".")],
datas=[('gui\\frontend', 'gui\\frontend')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name=appName,
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=icoPath)
其中:
pathex=["venv\\Lib\\site-packages\\cv2"],
便是将opencv-python完整项目的路径告诉pyinstaller,这样打包pyinstaller-python时,再配合上正确的pyinstaller与opencv-python版本,便可以打包出可正常打开的exe。
知识点:第三方库代码相关的放在pathex字段中
一切似乎很ok,但真正运行业务逻辑时,会报错:
经过加日志重打包后分析可知,它在下面位置报错:
opencv-python处理视频其实利用了ffmpeg.dll,而我们打包时,如果没有告诉pyinstaller ffmpeg.dll的位置,pyinstaller就不会将其打包进来,则会导致运行报错。
所以,spec文件中需要下面的内容:
binaries=[("venv\\Lib\\site-packages\\cv2\\opencv_videoio_ffmpeg453_64.dll", ".")],
知识点:dll、so这类动态库,要写在binaries字段中。
【无感视频同步助手】使用了html、css来做布局,这些不是python代码,对python而言,类似于image、video之类的静态资源,这类静态资源,我们需要写到spec文件的datas字段中:
datas=[('gui\\frontend', 'gui\\frontend')],
搞定opencv-python后,你可以用类似的方法来搞moviepy这个库,毕竟moviepy也是基于ffmpeg来弄的,这不简单。
嗯,不会灵活变通的话,可能会懵逼,因为moviepy有如下导入方式,且社区没有提供moviepy的hook:
moviepy的作者偷懒,直接通过exec来批量导入需要的库,不可为不骚。
怎么解决?
使用方法2,没错,将其改成使用import关键字导入的形式,但不是改moviepy的代码。我们创建moviepy_import.py文件,将需要导入的库都写进去。
然后再项目入口py文件中,import moviepy_import,解决moviepy批量导入的骚写法。
此外,moviepy打包还有另外一个问题,因为moviepy使用了imageio_ffmpeg这个库,而imageio_ffmpeg会使用ffmpeg,但我们打包时,没有将ffmpeg文件打包进去,moviepy在运行时便会报错。
浏览imageio_ffmpeg目录,发现它自己会安装对应版本的ffmpeg。
找到moviepy报错位置,其实是imageio_ffmpeg库的_utils.py文件中的get_ffmpeg_exe()方法,如下图:
其实就是找不到ffmpeg而报错,我的解决方法是手动设置一下:
嗯,目前我笔记里有记录的坑就上文中这些了,一个体会是,阅读源码和阅读文档的能力很重要,特别是资料比较少的情况。
以上,我是二两,下篇文章见。