7. 在 iOS 上使用 Python

作者:

Russell Keith-Magee (2024-03)

iOS 上的 Python 与桌面平台上的 Python 不同。在桌面平台上,Python 通常作为系统资源安装,可供该计算机的任何用户使用。然后,用户通过运行 python 可执行文件并在交互式提示符下输入命令或运行 Python 脚本来与 Python 交互。

在 iOS 上,没有作为系统资源安装的概念。唯一的软件分发单元是“应用程序”。也没有控制台可以运行 python 可执行文件或与 Python REPL 交互。

因此,在 iOS 上使用 Python 的唯一方法是在嵌入模式下 - 即,通过编写原生 iOS 应用程序,并使用 libPython 嵌入 Python 解释器,并使用 Python 嵌入 API 调用 Python 代码。完整的 Python 解释器、标准库和所有 Python 代码随后打包成一个独立的捆绑包,可以通过 iOS App Store 分发。

如果您是第一次尝试用 Python 编写 iOS 应用程序,那么 BeeWareKivy 等项目将提供更易于上手的使用体验。这些项目管理与运行 iOS 项目相关的复杂性,因此您只需要处理 Python 代码本身。

7.1. iOS 上的 Python 运行时

7.1.1. iOS 版本兼容性

最低支持的 iOS 版本在编译时指定,使用 --host 选项到 configure。默认情况下,在为 iOS 编译时,Python 将使用最低支持的 iOS 版本 13.0 进行编译。要使用不同的最低 iOS 版本,请将版本号作为 --host 参数的一部分提供 - 例如,--host=arm64-apple-ios15.4-simulator 将编译一个部署目标为 15.4 的 ARM64 模拟器构建。

7.1.2. 平台标识

在 iOS 上执行时,sys.platform 将报告为 ios。无论应用程序是在模拟器上运行还是在物理设备上运行,都会在 iPhone 或 iPad 上返回此值。

有关特定运行时环境的信息,包括 iOS 版本、设备型号以及设备是否为模拟器,可以使用 platform.ios_ver() 获取。platform.system() 将根据设备报告 iOSiPadOS

os.uname() 报告内核级详细信息;它将报告名称 Darwin

7.1.3. 标准库可用性

Python 标准库在 iOS 上有一些明显的遗漏和限制。有关详细信息,请参阅 iOS 的 API 可用性指南

7.1.4. 二进制扩展模块

iOS 作为一个平台的一个显着区别是,App Store 分发对应用程序的打包施加了严格的要求。其中一个要求管理着如何分发二进制扩展模块。

iOS App Store 要求 iOS 应用程序中的所有二进制模块都必须是动态库,包含在具有适当元数据的框架中,并存储在打包应用程序的 Frameworks 文件夹中。每个框架只能有一个二进制文件,并且在 Frameworks 文件夹之外不能有可执行的二进制材料。

这与通常的 Python 分发二进制文件的方法相冲突,通常的方法允许从 sys.path 上的任何位置加载二进制扩展模块。为了确保符合 App Store 政策,iOS 项目必须对任何 Python 包进行后处理,将 .so 二进制模块转换为具有适当元数据和签名的独立框架。有关如何执行此后处理的详细信息,请参阅 将 Python 添加到您的项目 指南。

为了帮助 Python 在新位置发现二进制文件,sys.path 上的原始 .so 文件被替换为 .fwork 文件。此文件是一个文本文件,其中包含框架二进制文件相对于应用程序捆绑包的位置。为了允许框架解析回原始位置,该框架必须包含一个 .origin 文件,该文件包含 .fwork 文件相对于应用程序捆绑包的位置。

例如,考虑以下导入的情况 from foo.bar import _whiz,其中 _whiz 由二进制模块 sources/foo/bar/_whiz.abi3.so 实现,其中 sources 是在 sys.path 上注册的位置,相对于应用程序捆绑包。此模块必须作为 Frameworks/foo.bar._whiz.framework/foo.bar._whiz 分发(从模块的完整导入路径创建框架名称),并且在 .framework 目录中包含一个 Info.plist 文件,用于将二进制文件标识为框架。foo.bar._whiz 模块将在原始位置用一个 sources/foo/bar/_whiz.abi3.fwork 标记文件表示,其中包含路径 Frameworks/foo.bar._whiz/foo.bar._whiz。该框架还将包含 Frameworks/foo.bar._whiz.framework/foo.bar._whiz.origin,其中包含 .fwork 文件的路径。

在 iOS 上运行时,Python 解释器将安装一个 AppleFrameworkLoader,它可以读取和导入 .fwork 文件。一旦导入,二进制模块的 __file__ 属性将报告为 .fwork 文件的位置。但是,加载的模块的 ModuleSpec 将报告 origin 为框架文件夹中二进制文件的位置。

7.1.5. 编译器存根二进制文件

Xcode 不会为 iOS 公开显式编译器;相反,它使用一个 xcrun 脚本,该脚本解析为完整的编译器路径(例如,xcrun --sdk iphoneos clang 以获取 iPhone 设备的 clang)。但是,使用此脚本会产生两个问题

  • xcrun 的输出包括特定于计算机的路径,导致 sysconfig 模块无法在用户之间共享;以及

  • 这会导致 CC/ CPP/ LD/ AR 定义中包含空格。许多 C 生态系统工具都假设你可以通过第一个空格拆分命令行来获取编译器可执行文件的路径;当使用 xcrun 时,情况并非如此。

为了避免这些问题,Python 提供了这些工具的存根。这些存根是围绕底层 xcrun 工具的 shell 脚本包装器,分布在与已编译的 iOS 框架一起分发的 bin 文件夹中。这些脚本是可重定位的,并且始终会解析到适当的本地系统路径。通过将这些脚本包含在框架随附的 bin 文件夹中,sysconfig 模块的内容对于最终用户编译自己的模块变得有用。为 iOS 编译第三方 Python 模块时,应确保这些存根二进制文件在你的路径中。

7.2. 在 iOS 上安装 Python

7.2.1. 用于构建 iOS 应用程序的工具

为 iOS 构建需要使用 Apple 的 Xcode 工具。强烈建议你使用最新稳定的 Xcode 版本。这将需要使用最新(或第二最新)发布的 macOS 版本,因为 Apple 不会为较旧的 macOS 版本维护 Xcode。Xcode 命令行工具不足以进行 iOS 开发;你需要一个完整的 Xcode 安装。

如果你想在 iOS 模拟器上运行代码,还需要安装 iOS 模拟器平台。首次运行 Xcode 时,系统会提示你选择 iOS 模拟器平台。或者,你可以从 Xcode 设置面板的“平台”选项卡中添加 iOS 模拟器平台。

7.2.2. 将 Python 添加到 iOS 项目

可以使用 Swift 或 Objective C 将 Python 添加到任何 iOS 项目。以下示例将使用 Objective C;如果你使用 Swift,你可能会发现像 PythonKit 这样的库很有帮助。

将 Python 添加到 iOS Xcode 项目

  1. 构建或获取 Python XCFramework。有关如何构建 Python XCFramework 的详细信息,请参阅 iOS/README.rst(在 CPython 源代码发行版中)中的说明。至少,你需要一个支持 arm64-apple-ios 的构建,以及 arm64-apple-ios-simulatorx86_64-apple-ios-simulator 中的一个。

  2. XCframework 拖到你的 iOS 项目中。在以下说明中,我们将假设你已将 XCframework 放入项目的根目录中;但是,你可以通过根据需要调整路径来使用你想要的任何其他位置。

  3. iOS/Resources/dylib-Info-template.plist 文件拖到你的项目中,并确保它与应用程序目标相关联。

  4. 将你的应用程序代码作为文件夹添加到你的 Xcode 项目中。在以下说明中,我们将假设你的用户代码位于项目根目录中名为 app 的文件夹中;你可以通过根据需要调整路径来使用任何其他位置。确保此文件夹与你的应用程序目标相关联。

  5. 通过选择 Xcode 项目的根节点,然后在出现的侧边栏中选择目标名称来选择应用程序目标。

  6. 在“常规”设置的“框架、库和嵌入内容”下,添加 Python.xcframework,并选择“嵌入并签名”。

  7. 在“构建设置”选项卡中,修改以下内容

    • 构建选项

      • 用户脚本沙盒:否

      • 启用可测试性:是

    • 搜索路径

      • 框架搜索路径:$(PROJECT_DIR)

      • 标头搜索路径:"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers"

    • Apple Clang - 警告 - 所有语言

      • 框架标头中带引号的包含:否

  8. 添加一个构建步骤,将 Python 标准库复制到你的应用程序中。在“构建阶段”选项卡中, “嵌入框架”步骤之前,但在“复制捆绑资源”步骤之后添加新的“运行脚本”构建步骤。将该步骤命名为“安装特定于目标的 Python 标准库”,禁用“基于依赖关系分析”复选框,并将脚本内容设置为

    set -e
    
    mkdir -p "$CODESIGNING_FOLDER_PATH/python/lib"
    if [ "$EFFECTIVE_PLATFORM_NAME" = "-iphonesimulator" ]; then
        echo "Installing Python modules for iOS Simulator"
        rsync -au --delete "$PROJECT_DIR/Python.xcframework/ios-arm64_x86_64-simulator/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
    else
        echo "Installing Python modules for iOS Device"
        rsync -au --delete "$PROJECT_DIR/Python.xcframework/ios-arm64/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
    fi
    

    请注意,XCframework 中模拟器“切片”的名称可能不同,具体取决于你的 XCFramework 支持的 CPU 架构。

  9. 添加第二个构建步骤,将标准库中的二进制扩展模块处理为“框架”格式。在你在步骤 8 中添加的步骤之后直接添加一个名为“准备 Python 二进制模块”的“运行脚本”构建步骤。它还应取消选中“基于依赖关系分析”,并包含以下脚本内容

    set -e
    
    install_dylib () {
        INSTALL_BASE=$1
        FULL_EXT=$2
    
        # The name of the extension file
        EXT=$(basename "$FULL_EXT")
        # The location of the extension file, relative to the bundle
        RELATIVE_EXT=${FULL_EXT#$CODESIGNING_FOLDER_PATH/}
        # The path to the extension file, relative to the install base
        PYTHON_EXT=${RELATIVE_EXT/$INSTALL_BASE/}
        # The full dotted name of the extension module, constructed from the file path.
        FULL_MODULE_NAME=$(echo $PYTHON_EXT | cut -d "." -f 1 | tr "/" ".");
        # A bundle identifier; not actually used, but required by Xcode framework packaging
        FRAMEWORK_BUNDLE_ID=$(echo $PRODUCT_BUNDLE_IDENTIFIER.$FULL_MODULE_NAME | tr "_" "-")
        # The name of the framework folder.
        FRAMEWORK_FOLDER="Frameworks/$FULL_MODULE_NAME.framework"
    
        # If the framework folder doesn't exist, create it.
        if [ ! -d "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER" ]; then
            echo "Creating framework for $RELATIVE_EXT"
            mkdir -p "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER"
            cp "$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
            plutil -replace CFBundleExecutable -string "$FULL_MODULE_NAME" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
            plutil -replace CFBundleIdentifier -string "$FRAMEWORK_BUNDLE_ID" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
        fi
    
        echo "Installing binary for $FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
        mv "$FULL_EXT" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
        # Create a placeholder .fwork file where the .so was
        echo "$FRAMEWORK_FOLDER/$FULL_MODULE_NAME" > ${FULL_EXT%.so}.fwork
        # Create a back reference to the .so file location in the framework
        echo "${RELATIVE_EXT%.so}.fwork" > "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME.origin"
     }
    
     PYTHON_VER=$(ls -1 "$CODESIGNING_FOLDER_PATH/python/lib")
     echo "Install Python $PYTHON_VER standard library extension modules..."
     find "$CODESIGNING_FOLDER_PATH/python/lib/$PYTHON_VER/lib-dynload" -name "*.so" | while read FULL_EXT; do
        install_dylib python/lib/$PYTHON_VER/lib-dynload/ "$FULL_EXT"
     done
    
     # Clean up dylib template
     rm -f "$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist"
    
     echo "Signing frameworks as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)..."
     find "$CODESIGNING_FOLDER_PATH/Frameworks" -name "*.framework" -exec /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" ${OTHER_CODE_SIGN_FLAGS:-} -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der "{}" \;
    
  10. 添加 Objective C 代码以初始化并在嵌入模式下使用 Python 解释器。你应该确保

  • UTF-8 模式(PyPreConfig.utf8_mode已启用

  • 缓冲 stdio(PyConfig.buffered_stdio已禁用

  • 写入字节码(PyConfig.write_bytecode已禁用

  • 信号处理程序(PyConfig.install_signal_handlers已启用

  • 系统日志记录(PyConfig.use_system_logger已启用(可选,但强烈建议);

  • 解释器的 PYTHONHOME 配置为指向你应用程序捆绑包的 python 子文件夹;并且

  • 解释器的 PYTHONPATH 包括

    • 你应用程序捆绑包的 python/lib/python3.X 子文件夹,

    • 你应用程序捆绑包的 python/lib/python3.X/lib-dynload 子文件夹,以及

    • 你应用程序捆绑包的 app 子文件夹

可以使用 [[NSBundle mainBundle] resourcePath] 确定你应用程序的捆绑包位置。

这些说明的步骤 8、9 和 10 假设你有一个名为 app 的纯 Python 应用程序代码的单个文件夹。如果你的应用程序中有第三方二进制模块,则需要一些额外的步骤

  • 你需要确保任何包含第三方二进制文件的文件夹要么与应用程序目标相关联,要么作为步骤 8 的一部分复制进来。步骤 8 还应清除任何不适合特定构建目标平台的二进制文件(即,如果你正在构建以模拟器为目标的应用程序,则删除所有设备二进制文件)。

  • 任何包含第三方二进制文件的文件夹都必须由步骤 9 处理成框架形式。可以复制和调整处理 lib-dynload 文件夹的 install_dylib 的调用以实现此目的。

  • 如果你使用单独的文件夹来存放第三方包,请确保该文件夹包含在步骤 10 中的 PYTHONPATH 配置中。

7.2.3. 测试 Python 包

CPython 源代码树包含一个 测试平台项目,用于在 iOS 模拟器上运行 CPython 测试套件。此测试平台也可以用作在 iOS 上运行你的 Python 库测试套件的测试平台项目。

在构建或获取 iOS XCFramework 后(有关详细信息,请参阅 iOS/README.rst),通过运行以下命令创建 Python iOS 测试平台项目的克隆:

$ python iOS/testbed clone --framework <path/to/Python.xcframework> --app <path/to/module1> --app <path/to/module2> app-testbed

你需要修改 iOS/testbed 引用以指向 CPython 源代码树中的该目录;使用 --app 标志指定的任何文件夹都将复制到克隆的测试平台项目中。生成的测试平台将在 app-testbed 文件夹中创建。在此示例中,module1module2 将在运行时成为可导入的模块。如果你的项目有其他依赖项,可以将它们安装到 app-testbed/iOSTestbed/app_packages 文件夹中(使用 pip install --target app-testbed/iOSTestbed/app_packages 或类似命令)。

然后,您可以使用 app-testbed 文件夹来运行您的应用程序的测试套件。例如,如果 module1.tests 是您测试套件的入口点,您可以运行

$ python app-testbed run -- module1.tests

这等同于在桌面 Python 构建上运行 python -m module1.tests-- 之后的所有参数都将传递给 testbed,就像它们是桌面机器上 python -m 的参数一样。

您还可以通过运行以下命令在 Xcode 中打开 testbed 项目

$ open app-testbed/iOSTestbed.xcodeproj

这将允许您使用完整的 Xcode 工具套件进行调试。

7.3. App Store 合规性

将应用程序分发到第三方 iOS 设备的唯一机制是将应用程序提交到 iOS App Store;提交分发的应用程序必须通过 Apple 的应用程序审核流程。此过程包括一组自动验证规则,这些规则会检查提交的应用程序包是否存在问题代码。

Python 标准库包含一些已知会违反这些自动规则的代码。虽然这些违规行为似乎是误报,但 Apple 的审核规则无法质疑;因此,必须修改 Python 标准库才能使应用程序通过 App Store 审核。

Python 源代码树包含一个补丁文件,它将删除所有已知会导致 App Store 审核过程出现问题的代码。在为 iOS 构建时,会自动应用此补丁。