自己的一个独立 macOS 项目,是关于PDF文件操作的相关功能,上线了两年多了,一直使用的 Swift 和 APPKit 原生进行的功能开发。
链接如下:Easy PDF
有用户提出需求,希望可以开发 PDF 文件转 office 文件的功能。当时看到这个需求时,搜索了一下资料,感觉难度有点大,耗时耗力就没理会。后来无意中发现了一个 Python 库,pdf2docx,可以完美实现 PDF 文件转 Word 文件的功能,但无奈的是这么厉害的功能是 Python 实现的。后来就冒出想法,可不可以在 Swift 项目中去集成 Python 开发的功能库呢,然后开始全网搜索各种资料…
通过这次功能开发实现,发现 Python 搭配 Swift 可以做很多之前没想过的一些功能,还需要待研究。
先说最终成果
成功在 Swift macOS 项目中集成了 Python3 环境,并使用 pip3 安装依赖库,通过 Swift 去调用 Python 第三方库的函数,并且通过了苹果的 App Store 的审核.
一、需要准备的资料
- Python 环境安装,Mac 系统没有 Python3 环境,建议去 Python 官网下载安装包来安装,方便简单,主要是后面可能需要重复卸载和安装,Python 官网下载地址,我是下载的 Python 3.11.0
-
支持 Swift 和 macOS 的 Python 库, Python-Apple-support,下载后解压后有两个文件夹,python-stdlib 和 Python.xcframework,下载的版本号一定要和上面下载的 Python3 安装包是同一个版本,很重要,否则会出现一些奇奇怪怪的问题。 这个 Python 库是通过这个项目编译生成的,有兴趣的可以深入研究一下,一个使用 Python 来开发 macOS 应用的开源项目 briefcase
-
PythonKit:Swift 调用 Python 函数的库,没有太多的选择
二、项目中集成 Python3 解释器
- 建一个新项目,项目中先把 PythonKit 搞进去,用 Swift Package Manager 或 cocoapods 都可以,推荐使用 Swift Package Manager,项目看起来更加清爽。
- 将 python-stdlib 和 Python.xcframework 文件拖入文件夹,选中 Copy items if needed 和 Create folder references,很重要。
- 检查 Python.xcframework 设置为 Do Not Embed
- 添加 SystemConfiguration.Framework
- 检查 python-stdlib
- 创建 Python 头文件
新建一个文件 module.modulemap,内容如下,将这个文件保存到项目中的Python.xcframework/macos_arm64_x86_64/Headers 中
module Python {umbrella header "Python.h"export *link "Python"}module Python { umbrella header "Python.h" export * link "Python" }module Python { umbrella header "Python.h" export * link "Python" }
- 添加一个 Run Script,内容如下,取消 Based on dependency analysis
set -eecho "Signing as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)"find "$CODESIGNING_FOLDER_PATH/Contents/Resources/python-stdlib/lib-dynload" -name "*.so" -exec /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der {} \;set -e echo "Signing as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)" find "$CODESIGNING_FOLDER_PATH/Contents/Resources/python-stdlib/lib-dynload" -name "*.so" -exec /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der {} \;set -e echo "Signing as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)" find "$CODESIGNING_FOLDER_PATH/Contents/Resources/python-stdlib/lib-dynload" -name "*.so" -exec /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der {} \;
- 检查 Python3 的运行环境是否已经准备好,可以先把电脑本地安装的 Python3 环境删掉测试一下,看项目中的 Python3 是否是我们自己下载的版本。
import Cocoaimport PythonKitimport Pythonclass ViewController: NSViewController {override func viewDidLoad() {super.viewDidLoad()// Python 初始化guard let stdLibPath = Bundle.main.path(forResource: "python-stdlib", ofType: nil) else { return }guard let libDynloadPath = Bundle.main.path(forResource: "python-stdlib/lib-dynload", ofType: nil) else { return }setenv("PYTHONHOME", stdLibPath, 1)setenv("PYTHONPATH", "\(stdLibPath):\(libDynloadPath)", 1)Py_Initialize()let sys = Python.import("sys")print("Python \(sys.version_info.major).\(sys.version_info.minor)")print("Python Version: \(sys.version)")print("Python Encoding: \(sys.getdefaultencoding().upper())")}}import Cocoa import PythonKit import Python class ViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() // Python 初始化 guard let stdLibPath = Bundle.main.path(forResource: "python-stdlib", ofType: nil) else { return } guard let libDynloadPath = Bundle.main.path(forResource: "python-stdlib/lib-dynload", ofType: nil) else { return } setenv("PYTHONHOME", stdLibPath, 1) setenv("PYTHONPATH", "\(stdLibPath):\(libDynloadPath)", 1) Py_Initialize() let sys = Python.import("sys") print("Python \(sys.version_info.major).\(sys.version_info.minor)") print("Python Version: \(sys.version)") print("Python Encoding: \(sys.getdefaultencoding().upper())") } }import Cocoa import PythonKit import Python class ViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() // Python 初始化 guard let stdLibPath = Bundle.main.path(forResource: "python-stdlib", ofType: nil) else { return } guard let libDynloadPath = Bundle.main.path(forResource: "python-stdlib/lib-dynload", ofType: nil) else { return } setenv("PYTHONHOME", stdLibPath, 1) setenv("PYTHONPATH", "\(stdLibPath):\(libDynloadPath)", 1) Py_Initialize() let sys = Python.import("sys") print("Python \(sys.version_info.major).\(sys.version_info.minor)") print("Python Version: \(sys.version)") print("Python Encoding: \(sys.getdefaultencoding().upper())") } }
打印出 Python 版本,说明运行环境已经准备好了
- 使用 pip3 安装第三方依赖,毕竟使用第三方依赖库才是我们的最终目的。
前提条件: Mac 电脑的本地 Python3 环境需要安装好,pip3也得安装,安装很简单,点击下载好的 python-3.11.0-macos11.pkg 直接安装就可以了,pip3 的安装方式就不说了,也没啥难点。
安装 Python 依赖库命令,以安装 pdf2docx 为例
pip3 install pdf2docx -t /Users/Desktop/EmbeddedPython/EmbeddedPython/python-stdlibpip3 install pdf2docx -t /Users/Desktop/EmbeddedPython/EmbeddedPython/python-stdlibpip3 install pdf2docx -t /Users/Desktop/EmbeddedPython/EmbeddedPython/python-stdlib
/Users/Desktop/EmbeddedPython/EmbeddedPython/python-stdlib 是项目中 python-stdlib 的路径,安装完成后 python-stdlib 文件夹中就会有第三方依赖库了。
- Python 第三方依赖库 pdf2docx 的方法调用。将转换的文件保存到下载文件夹,项目中需要设置沙盒权限。
代码示例:
import Cocoaimport Pythonimport PythonKitclass ViewController: NSViewController {override func viewDidLoad() {super.viewDidLoad()guard let stdLibPath = Bundle.main.path(forResource: "python-stdlib", ofType: nil) else { return }guard let libDynloadPath = Bundle.main.path(forResource: "python-stdlib/lib-dynload", ofType: nil) else { return }setenv("PYTHONHOME", stdLibPath, 1)setenv("PYTHONPATH", "\(stdLibPath):\(libDynloadPath)", 1)Py_Initialize()// 测试文件,可编辑的 PDF 文件let pdfFilePath = Bundle.main.path(forResource: "test", ofType: "pdf")// 将转换的 docx 文件保存到下载文件夹var url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] as URLurl = url.appendingPathComponent("test.docx")let docFilePath = url.path// 导入 pdf2docx 模块let pdf2docx = Python.import("pdf2docx")// 创建 Converterlet converter = pdf2docx.Converter(pdfFilePath)// 调用 convert 方法converter.convert(docFilePath)}}import Cocoa import Python import PythonKit class ViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() guard let stdLibPath = Bundle.main.path(forResource: "python-stdlib", ofType: nil) else { return } guard let libDynloadPath = Bundle.main.path(forResource: "python-stdlib/lib-dynload", ofType: nil) else { return } setenv("PYTHONHOME", stdLibPath, 1) setenv("PYTHONPATH", "\(stdLibPath):\(libDynloadPath)", 1) Py_Initialize() // 测试文件,可编辑的 PDF 文件 let pdfFilePath = Bundle.main.path(forResource: "test", ofType: "pdf") // 将转换的 docx 文件保存到下载文件夹 var url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] as URL url = url.appendingPathComponent("test.docx") let docFilePath = url.path // 导入 pdf2docx 模块 let pdf2docx = Python.import("pdf2docx") // 创建 Converter let converter = pdf2docx.Converter(pdfFilePath) // 调用 convert 方法 converter.convert(docFilePath) } }import Cocoa import Python import PythonKit class ViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() guard let stdLibPath = Bundle.main.path(forResource: "python-stdlib", ofType: nil) else { return } guard let libDynloadPath = Bundle.main.path(forResource: "python-stdlib/lib-dynload", ofType: nil) else { return } setenv("PYTHONHOME", stdLibPath, 1) setenv("PYTHONPATH", "\(stdLibPath):\(libDynloadPath)", 1) Py_Initialize() // 测试文件,可编辑的 PDF 文件 let pdfFilePath = Bundle.main.path(forResource: "test", ofType: "pdf") // 将转换的 docx 文件保存到下载文件夹 var url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] as URL url = url.appendingPathComponent("test.docx") let docFilePath = url.path // 导入 pdf2docx 模块 let pdf2docx = Python.import("pdf2docx") // 创建 Converter let converter = pdf2docx.Converter(pdfFilePath) // 调用 convert 方法 converter.convert(docFilePath) } }
pdf2docx 在 Python 中的使用方式,可以和上面 Swift 的调用方式进行对比
from pdf2docx import Converterpdf_file = '/path/to/test.pdf'docx_file = 'path/to/test.docx'# pdf 转 docxcv = Converter(pdf_file)cv.convert(docx_file) # 所有页面from pdf2docx import Converter pdf_file = '/path/to/test.pdf' docx_file = 'path/to/test.docx' # pdf 转 docx cv = Converter(pdf_file) cv.convert(docx_file) # 所有页面from pdf2docx import Converter pdf_file = '/path/to/test.pdf' docx_file = 'path/to/test.docx' # pdf 转 docx cv = Converter(pdf_file) cv.convert(docx_file) # 所有页面
- PDF 文件转 Word 的效果,还是很满意的。
三、关于 App Store 的审核
第一次兴致勃勃的打包提交审核,app的安装包由 5MB 变为了 120MB,也是没办法的事情,毕竟项目中集成了 Python3 运行环境和一些必须的 Python 第三方依赖库。
-
第一次被拒,回复说项目中有很多弃用的API,这个是自动触发了苹果机审扫描到的一些标识,开始我也没发现,联系了 Python-Apple-support 的开源作者,才发现了问题,直接给苹果回复,项目中没有使用弃用方法和私有API,他会重新审核。
-
第二次被拒,弃用API的问题解决了,第二次苹果回复了一大堆 Python 里面的方法名,都是带有下划线前缀的方法,被判定为是私有方法,这个不能忍啊,明显也是机器扫描的结果,也是直接回复苹果,描述一下这些方法只是 Python 函数的命名规则,然后把 Python-Apple-support 的开源链接扔给苹果,让他自己去检查这些方法是否是私有API。
-
经过两次回复,苹果最终给审核通过了
四、关于打包的bug
我的项目出现了一个未知的bug,当项目本地运行的时候,debug 和 release 都是没有问题的,但是 Archive 打包就会出现奇怪的问题,打包选择 debug,导出的 app 是正常运行的,如果选择 release 打包,运行则会崩溃,我猜测可能和 Python 的动态库签名有关,在 release 环境下打包,有些配置被 Xcode 打包优化修改了,目前还没找到出问题的地方,所以我就在 debug 模式下 Archive 提包了,也不影响使用。
五、后记
这个地方需要注意一个问题,当电脑上安装了 Python3 运行环境,然后项目中没有集成 Python3环境,这个时候项目代码中不设置 Python 路径,也是可以正常运行的,Xcode会去查找电脑本地的 Python3 解释器。但是对于需要上线的项目来说,你不能要求用户在本地安装 Python3 的运行环境,所以无奈,只能把 Python3 的解释器集成到项目里面去。