用 Swift Packages 發佈 XCFrameworks

Photo by Ozgu Ozden on Unsplash
Photo by Ozgu Ozden on Unsplash
Swift packages 是可重複使用的程式碼元件。它不但可以包含程式碼,還可以包含二進位 XCFrameworks。發佈 XCFrameworks 可以保護程式碼不外洩。本文章將介紹如何使用 Swift packages 發佈 XCFrameworks。

Swift packages 是可重複使用的程式碼元件。它不但可以包含程式碼,還可以包含二進位 XCFrameworks。發佈 XCFrameworks 可以保護程式碼不外洩。本文章將介紹如何使用 Swift packages 發佈 XCFrameworks。

完整程式碼可以在 下載。

建立 XCFrameworks

如果你不熟悉如何建立 XCFrameworks,請先參照以下文章。

首先我們建立一個 framework 叫 GreetingUI,並在裡面宣告 GreetingViewController,其程式碼如下。

import UIKit

public class GreetingViewController: UIViewController {
    public static func newInstance() -> GreetingViewController {
        let storyboard = UIStoryboard(name: "Greeting", bundle: Bundle(for: Self.self))
        let viewController = storyboard.instantiateViewController(withIdentifier: "GreetingViewController") as! GreetingViewController
        return viewController
    }
    
    @IBOutlet weak var greetingLabel: UILabel!
    
    public override func viewDidLoad() {
        super.viewDidLoad()

        greetingLabel.text = "Hello Wayne's Talk!"
    }
}

接下來用下面的 shell script 產生 XCFramework。它會在 archives 資料夾下產生GreetingUI.xcframework。

#!/bin/sh

PROJECT_NAME="GreetingUI"
OUTPUT_DIR="archives"

xcodebuild archive \
  -project "$PROJECT_NAME/$PROJECT_NAME.xcodeproj" \
  -scheme "$PROJECT_NAME" \
  -destination "generic/platform=iOS" \
  -archivePath "archives/iOS" \
  SKIP_INSTALL=NO \
  BUILD_LIBRARY_FOR_DISTRIBUTION=YES

xcodebuild archive \
  -project "$PROJECT_NAME/$PROJECT_NAME.xcodeproj" \
  -scheme "$PROJECT_NAME" \
  -destination "generic/platform=iOS Simulator" \
  -archivePath "archives/iOS-Simulator" \
  SKIP_INSTALL=NO \
  BUILD_LIBRARY_FOR_DISTRIBUTION=YES

xcodebuild -create-xcframework \
    -framework "$OUTPUT_DIR/iOS.xcarchive/Products/Library/Frameworks/$PROJECT_NAME.framework" \
    -framework "$OUTPUT_DIR/iOS-Simulator.xcarchive/Products/Library/Frameworks/$PROJECT_NAME.framework" \
    -output "$OUTPUT_DIR/$PROJECT_NAME.xcframework"

用 Swift Packages 發佈 XCFrameworks

如果你不熟悉如何建立 Swift packages,請先參照以下文章。

Swift packages 支援兩種方式來發佈 XCFrameworks。一種是將 XCFrameworks 和 Swift packages 放在同一個硬碟。另一種是將 XCFrameworks 與 Swift packages 放在不同的主機。

Swift Packages 指向在同一個硬碟的 XCFrameworks

建立一個 Swift package 叫 GreetingUISDK。將剛剛產生出來的 GreetingUI.xcframework 移至 GreetingUISDK 資料夾下。

在 Package.swift 中,用 .binaryTarget() 來新增一個 target,其 name 為 GreetingUI 和 path 為 GreetingUI.xcframework。然後,將 target GreetingUI 加入到 product GreetingUISDK 的 targets 裡面。

如果 GreetingUISDK 沒有包含任何程式碼的話,那我們可以移除 target GreetingSDK,且也要將它從 product GreetingUISDK 的 targets 中移除。

import PackageDescription

let package = Package(
    name: "GreetingUISDK",
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(name: "GreetingUISDK", targets: ["GreetingUISDK", "GreetingUI"]),
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(name: "GreetingUISDK", dependencies: []),
        .binaryTarget(name: "GreetingUI", path: "GreetingUI.xcframework"),
        .testTarget(name: "GreetingUISDKTests", dependencies: ["GreetingUISDK"]),
    ]
)

最後用 Git tag 建立版本 1.0.0,然後將 GreetingUISDK push 到 https://github.com/xhhuango/GreetingUISDK 後,就發佈完成了。

Swift Packages 指向 Remote XCFrameworks

我們也可以將 Swift Package 和 XCFramework 放在不同的主機。

用 zip 將 GreetingUI.xcframework 打包成 GreetingUI.xcframework.zip,再用以下指令計算 GreetingUI.xcframework.zip 的 checksum。將 GreetingUI.xcframework.zip 放到某個主機上。

% swift package compute-checksum GreetingUI.xcframework.zip
758990849603c7be90c8ff277cd0a004e649770773492cf9f526a4baad5192f9

在 Package.swift 中,用 .binaryTarget() 來新增一個 target,並且指定其 name、 GreetingUI.xcframework.zip 的 url、和剛剛計算的 checksum

import PackageDescription

let package = Package(
    name: "GreetingUISDK",
    platforms: [.iOS(.v13)],
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(name: "GreetingUISDK", targets: ["GreetingUISDK", "GreetingUI"]),
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(name: "GreetingUISDK", dependencies: []),
        .binaryTarget(
            name: "GreetingUI", 
            url: "https://your.domain.com/path/GreetingUI.xcframework.zip",
            checksum: "758990849603c7be90c8ff277cd0a004e649770773492cf9f526a4baad5192f9"),
        .testTarget(name: "GreetingUISDKTests", dependencies: ["GreetingUISDK"]),
    ]
)

最後用 Git tag 建立版本 1.1.0,然後將 GreetingUISDK push 到 https://github.com/xhhuango/GreetingUISDK 後,就發佈完成了。

加入包含 XCFrameworks 的 Swift Packages 依賴到 Apps

如果你不熟悉如何加入 Swift package 依賴到 Apps,請先參照以下文章。

建立 GreetingApp 專案,用 https://github.com/xhhuango/GreetingUISDK 加入 GreetingUISDK 依賴。

GreetingApp has GreetingUISDK dependency.
GreetingApp has GreetingUISDK dependency.

我們現在可以在 GreetingApp 中使用 GreetingUI.GreetingViewController,如下。

import UIKit
import GreetingUI

class ViewController: UIViewController {
    @IBAction func onClick(_ sender: Any) {
        let viewController = GreetingViewController.newInstance()
        present(viewController, animated: true)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

用 Swift Packages 發佈有依賴其他 Swift Packages 的 XCFrameworks

如果 XCFramework 有依賴其他 local 或 open-source Swift packages,那當你使用以上所介紹的方式的話,在編譯 App 時會發生錯誤。

假設 GreetingUI 有依賴 Toast-Swift,並且在 GreetingViewController 裡面使用 Toast-Swift,如下程式碼。

import UIKit
import Toast

public class GreetingViewController: UIViewController {
    public static func newInstance() -> GreetingViewController {
        let storyboard = UIStoryboard(name: "Greeting", bundle: Bundle(for: Self.self))
        let viewController = storyboard.instantiateViewController(withIdentifier: "GreetingViewController") as! GreetingViewController
        return viewController
    }
    
    @IBOutlet weak var greetingLabel: UILabel!
    
    @IBAction func onClick(_ sender: Any) {
        view.makeToast("Hello Wayne's Talk!")
    }
    
    public override func viewDidLoad() {
        super.viewDidLoad()

        greetingLabel.text = "Hello Wayne's Talk!"
    }
}

然後,我們產生 GreetingUI.xcframework,並用之前介紹的方式發佈到 https://github.com/xhhuango/GreetingUISDK,且建立版本 2.0.0。

在 GreetingApp 中,加入版本 2.0.0 的 GreetingUISDK 依賴,編譯時 Xcode 會顯示 No such module Toast 的錯誤。

Xcode complains "No such module Toast".
Xcode complains “No such module Toast”.

這是因為在 GreetingUI.xcframework/ios-arm64_x86_64-simulator/GreetingUI.framework/Modules/GreetingUI.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface 裡面有一行 import Toast。

// swift-interface-format-version: 1.0
// swift-compiler-version: Apple Swift version 5.7.2 (swiftlang-5.7.2.135.5 clang-1400.0.29.51)
// swift-module-flags: -target x86_64-apple-ios16.2-simulator -enable-objc-interop -enable-library-evolution -swift-version 5 -enforce-exclusivity=checked -O -module-name GreetingUI
// swift-module-flags-ignorable: -enable-bare-slash-regex
@_exported import GreetingUI
import Swift
import Toast
import UIKit
import _Concurrency
import _StringProcessing
@objc @_inheritsConvenienceInitializers @_Concurrency.MainActor(unsafe) public class GreetingViewController : UIKit.UIViewController {
  @_Concurrency.MainActor(unsafe) public static func newInstance() -> GreetingUI.GreetingViewController
  @_Concurrency.MainActor(unsafe) @objc override dynamic public func viewDidLoad()
  @_Concurrency.MainActor(unsafe) @objc override dynamic public init(nibName nibNameOrNil: Swift.String?, bundle nibBundleOrNil: Foundation.Bundle?)
  @_Concurrency.MainActor(unsafe) @objc required dynamic public init?(coder: Foundation.NSCoder)
  @objc deinit
}

當在產生 GreetingUI.xcframework 時,xcodebuild 編譯 Toast-Swift,並 statically link Toast-Swift 至 GreetingUI.xcframework,所以沒有產生 Toast-Swift 的 .swiftmodule。因此 GreetingApp 無法找到 Toast 模組。

Issue with third party dependencies inside a XCFramework through SPM 中,NeoNacho 提到兩個解決方案。

第一個方案是,在產生 GreetingUI.xcframework 時,要求 xcodebuild 不在要再 GreetingUI.xcframework/ios-arm64_x86_64-simulator/GreetingUI.framework/Modules/GreetingUI.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface 裡加上 import Toast。我們可以在 GreetingUI 裡使用 @_implementationOnly import 來 privately import Toast,如下。

但這會產生一個問題。如果使用 @_implementationOnly import Toast 的話,你就不可以在 GreetingUI 的 public interface 中使用到 Toast 的 symbols。

import UIKit
@_implementationOnly import Toast

public class GreetingViewController: UIViewController {
    public static func newInstance() -> GreetingViewController {
        let storyboard = UIStoryboard(name: "Greeting", bundle: Bundle(for: Self.self))
        let viewController = storyboard.instantiateViewController(withIdentifier: "GreetingViewController") as! GreetingViewController
        return viewController
    }
    
    @IBOutlet weak var greetingLabel: UILabel!
    
    @IBAction func onClick(_ sender: Any) {
        view.makeToast("Hello Wayne's Talk!")
    }
    
    public override func viewDidLoad() {
        super.viewDidLoad()

        greetingLabel.text = "Hello Wayne's Talk!"
    }
}

第二個方案是,將 Toast-Swift 編譯成 Toast.xcframework,然後 GreetingUI 依賴 Toast.xcframework。這樣 GreetingUI.xcframework 就不會 statically link Toast-Swift。

但這樣做的話,在發佈 GreetingUISDK 時,GreetingUISDK 必須要包含 GreetingUI.xcframework 和 Toast.xcframework。如果 GreetingUI 依賴多個 Swift packages 的話,就必須要將這些 Swift packages 一一編譯成 xcframework。發佈流程會變得複雜。

你需要將 Toast.xcframework 也移到 GreetingUISDK 資料夾下,然後修改 Package.swift 如下。

// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "GreetingUISDK",
    platforms: [.iOS(.v13)],
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library( name: "GreetingUISDK", targets: ["GreetingUISDK", "GreetingUI", "Toast"]),
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(name: "GreetingUISDK", dependencies: []),
        .binaryTarget(name: "GreetingUI", path: "GreetingUI.xcframework"),
        .binaryTarget(name: "Toast", path: "Toast.xcframework"),
        .testTarget(name: "GreetingUISDKTests", dependencies: ["GreetingUISDK"]),
    ]
)

結語

當我們發佈的產品是一個二進位 xcframework 時,我們會需要保護程式碼不外洩。目前 Swift packages 可以支援發佈二進位 xcframework。但當我們的 xcframework 有依賴其他的 Swift packages,會有 symbol references 的問題。希望未來 Xcode 會改善這部分。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Alex Alvarez on Unsplash
Read More

Dispatch Queue 教學

GCD 提供有效率的並行處理,讓我們不需要直接管理多執行緒。它的 Dispatch Queues 可以循序地(serially)或是並行地(concurrently)執行任務。我們只需要將要並行的程式當作任務提交到 dispatch queues 就可以了。
Read More
Photo by Fabian Gieske on Unsplash
Read More

SwiftUI @State & @Binding 教學

SwiftUI 推出了兩個 Property Wrapper – @State and @Binding。利用它們可以達到變數的 Two-way Binding 功能。也就是當變數的值改變時,它會重新被顯示。本章藉由製作一個 Custom View 來展示如何使用 @State 和 @Binding。
Read More
Photo by Svitlana on Unsplash
Read More

iOS:禁止螢幕截圖

基於一些理由,我們可能會想要禁止使用者對我們的 app 做螢幕截圖。然而,iOS 並沒有提供這樣的功能。所幸,我們可以利用 UITextField 來達到此效果。
Read More