作为iOS开发,我们的 CI 经常使用 Ruby 的命令行工具,像 fastlane, CocoaPods, Xcodeproj。
随着 Ruby 逐渐没落,维护成本逐渐上升。
通过 Swift Package Manager,使用 Apple Swift 语言建立 Command line tool,让团队中的iOS开发者更易于开发维护。
An example: Creating a xcode helper
使用 Swift Package Manager 创建一个示例, 用于查看 xcode 的 cache 文件。如图:
Creating a command-line tool
mkdir xcode-helper && cd xcode-helper
swift package init --type executable
type
- library 创建 library。
- executable. 创建命令行工具。
Build and run an executable product
命令行运行
swift run
> swift run
[3/3] Linking xcode-helper
* Build Completed!
Hello, world!
使用 Xcode 运行
swift package generate-xcodeproj
open *.xcodeproj
Adding dependencies
添加 apple/swift-argument-parser 来获取命令行参数。
vi Package.swift
.package(
url: "https://github.com/apple/swift-argument-parser",
from: "0.4.0"
)
Include "ArgumentParser" as a dependency for your executable target:
.product(name: "ArgumentParser", package: "swift-argument-parser"),
Package.swift Example:
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "xcode-helper",
dependencies: [
.package(
url: "https://github.com/apple/swift-argument-parser",
from: "0.4.0"
)
],
targets: [
.target(
name: "xcode-helper",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]),
.testTarget(
name: "xcode-helperTests",
dependencies: ["xcode-helper"]),
]
)
Installing dependencies
修改后,通过swift package update
拉取依赖
swift package update
Creating the main execution command
Sources/
,加入处理逻辑
vi Sources/xcode-helper/main.swift
import Foundation
import ArgumentParser
struct Constant {
struct App {
static let version = "0.0.1"
}
}
@discardableResult
func shell(_ command: String) -> String {
let task = Process()
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
task.arguments = ["-c", command]
task.launchPath = "/bin/zsh"
task.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)!
return output
}
struct Print {
enum Color: String {
case reset = "\u{001B}[0;0m"
case black = "\u{001B}[0;30m"
case red = "\u{001B}[0;31m"
case green = "\u{001B}[0;32m"
case yellow = "\u{001B}[0;33m"
case blue = "\u{001B}[0;34m"
case magenta = "\u{001B}[0;35m"
case cyan = "\u{001B}[0;36m"
case white = "\u{001B}[0;37m"
}
static func h3(_ items: Any..., separator: String = " ", terminator: String = "\n") {
// https://stackoverflow.com/questions/39026752/swift-extending-functionality-of-print-function
let output = items.map { "\($0)" }.joined(separator: separator)
print("\(Color.green.rawValue)\(output)\(Color.reset.rawValue)")
}
static func h6(_ verbose: Bool, _ items: Any..., separator: String = " ", terminator: String = "\n") {
if verbose {
let output = items.map { "\($0)" }.joined(separator: separator)
print("\(output)")
}
}
}
extension XcodeHelper {
enum CacheFolder: String, ExpressibleByArgument, CaseIterable {
case all
case archives
case simulators
case deviceSupport
case derivedData
case previews
case coreSimulatorCaches
}
}
fileprivate extension XcodeHelper.CacheFolder {
var paths: [String] {
switch self {
case .archives:
return ["~/Library/Developer/Xcode/Archives"]
case .simulators:
return ["~/Library/Developer/CoreSimulator/Devices"]
case .deviceSupport:
return ["~/Library/Developer/Xcode"]
case .derivedData:
return ["~/Library/Developer/Xcode/DerivedData"]
case .previews:
return ["~/Library/Developer/Xcode/UserData/Previews/Simulator Devices"]
case .coreSimulatorCaches:
return ["~/Library/Developer/CoreSimulator/Caches/dyld"]
case .all:
var paths: [String] = []
for caseValue in Self.allCases {
if caseValue != self {
paths.append(contentsOf: caseValue.paths)
}
}
return paths
}
}
static var suggestion: String {
let suggestion = Self.allCases.map { caseValue in
return caseValue.rawValue
}.joined(separator: " | ")
return "[ \(suggestion) ]"
}
}
struct XcodeHelper: ParsableCommand {
public static let configuration = CommandConfiguration(
abstract: "Xcode helper",
version: "xcode-helper version \(Constant.App.version)",
subcommands: [
Cache.self
]
)
}
extension XcodeHelper {
struct Cache: ParsableCommand {
public static let configuration = CommandConfiguration(
abstract: "Xcode cache helper",
subcommands: [
List.self
]
)
}
}
extension XcodeHelper.Cache {
struct List: ParsableCommand {
public static let configuration = CommandConfiguration(
abstract: "Show Xcode cache files"
)
@Option(name: .shortAndLong, help: "The cache folder")
private var cacheFolder: XcodeHelper.CacheFolder = .all
@Flag(name: .shortAndLong, help: "Show extra logging for debugging purposes.")
private var verbose: Bool = false
func run() throws {
Print.h3("list cache files:")
Print.h3("------------------------")
if cacheFolder == .all {
var allCases = XcodeHelper.CacheFolder.allCases
allCases.remove(at: allCases.firstIndex(of: .all)!)
handleList(allCases)
} else {
handleList([cacheFolder])
}
}
private func handleList(_ folders: [XcodeHelper.CacheFolder]) {
for folder in folders {
Print.h3(folder.rawValue)
for path in folder.paths {
let cmd = "du -hs \(path)"
Print.h6(verbose, cmd)
let output = shell(cmd)
print(output)
}
}
}
}
}
XcodeHelper.main()
Build and run an executable product
Get all targets
获取当前项目下所有的 targets。
python3 -c "\
import sys, json, subprocess;\
package_data = subprocess.Popen('swift package dump-package', shell=True, stdout=subprocess.PIPE).stdout.read().decode('utf-8');\
targets = json.loads(package_data)['targets'];\
target_names = list(map(lambda x: x['name'], targets));\
print(target_names)\
"
Start using command-line
使用 swift run
看下效果
swift run xcode-helper
Start using subcommand
为保证 xcode-helper 的扩展,实现时 cache 是子命令
swift run xcode-helper cache list
Writing Unit testing
Tests/
, 添加必要的单元测试。
vi Tests/xcode-helperTests/xcode_helperTests.swift
import XCTest
import class Foundation.Bundle
extension XCTest {
public var debugURL: URL {
let bundleURL = Bundle(for: type(of: self)).bundleURL
return bundleURL.lastPathComponent.hasSuffix("xctest")
? bundleURL.deletingLastPathComponent()
: bundleURL
}
public func AssertExecuteCommand(
command: String,
expected: String? = nil,
exitCode: Int32 = EXIT_SUCCESS,
file: StaticString = #file, line: UInt = #line) {
let splitCommand = command.split(separator: " ")
let arguments = splitCommand.dropFirst().map(String.init)
let commandName = String(splitCommand.first!)
let commandURL = debugURL.appendingPathComponent(commandName)
guard (try? commandURL.checkResourceIsReachable()) ?? false else {
XCTFail("No executable at '\(commandURL.standardizedFileURL.path)'.",
file: (file), line: line)
return
}
let process = Process()
if #available(macOS 10.13, *) {
process.executableURL = commandURL
} else {
process.launchPath = commandURL.path
}
process.arguments = arguments
let output = Pipe()
process.standardOutput = output
let error = Pipe()
process.standardError = error
if #available(macOS 10.13, *) {
guard (try? process.run()) != nil else {
XCTFail("Couldn't run command process.", file: (file), line: line)
return
}
} else {
process.launch()
}
process.waitUntilExit()
let outputData = output.fileHandleForReading.readDataToEndOfFile()
let outputActual = String(data: outputData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)
let errorData = error.fileHandleForReading.readDataToEndOfFile()
let errorActual = String(data: errorData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)
if let expected = expected {
XCTAssertEqual(expected, errorActual + outputActual)
}
XCTAssertEqual(process.terminationStatus, exitCode, file: (file), line: line)
}
}
final class xcode_helperTests: XCTestCase {
func test_Xcode_Helper_Versions() throws {
AssertExecuteCommand(command: "xcode-helper --version",
expected: "xcode-helper version 0.0.1")
}
func test_Xcode_Helper_Help() throws {
let helpText = """
OVERVIEW: Xcode helper
USAGE: xcode-helper
OPTIONS:
--version Show the version.
-h, --help Show help information.
SUBCOMMANDS:
cache Xcode cache helper
See 'xcode-helper help ' for detailed help.
"""
AssertExecuteCommand(command: "xcode-helper", expected: helpText)
AssertExecuteCommand(command: "xcode-helper -h", expected: helpText)
AssertExecuteCommand(command: "xcode-helper --help", expected: helpText)
}
}
通过 swift test
运行单元测试。
swift test
> swift test
Test Suite 'All tests' started at 2021-07-17 14:01:47.357
Test Suite 'xcode-helperPackageTests.xctest' started at 2021-07-17 14:01:47.358
Test Suite 'xcode_helperTests' started at 2021-07-17 14:01:47.358
Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Help]' started.
Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Help]' passed (0.202 seconds).
Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Versions]' started.
Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Versions]' passed (0.074 seconds).
Test Suite 'xcode_helperTests' passed at 2021-07-17 14:01:47.634.
Executed 2 tests, with 0 failures (0 unexpected) in 0.276 (0.276) seconds
Test Suite 'xcode-helperPackageTests.xctest' passed at 2021-07-17 14:01:47.634.
Executed 2 tests, with 0 failures (0 unexpected) in 0.276 (0.276) seconds
Test Suite 'All tests' passed at 2021-07-17 14:01:47.634.
Executed 2 tests, with 0 failures (0 unexpected) in 0.276 (0.277) seconds
也可以使用 Xcode Command-U
跑测试。
Installing your command line tool
测试通过,release 打包,并移至/usr/local/bin
。
swift build -c release
cp -f .build/release/xcode-helper /usr/local/bin/xcode-helper
xcode-helper --version
> xcode-helper --version
xcode-helper version 0.0.1
Demo
- https://github.com/QiuZhiFei/xcode-helper
References
- https://github.com/apple/swift-package-manager/blob/main/Documentation/Usage.md
- https://www.avanderlee.com/swift/command-line-tool-package-manager/
- https://www.swiftbysundell.com/articles/building-a-command-line-tool-using-the-swift-package-manager/
- https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html
- https://developer.apple.com/swift/blog/?id=7
- swift-argument-parser Documentation
- https://github.com/apple/swift-package-manager/blob/4fd4df4275627ebc91a0f288c38658738cd9fa0f/Sources/Commands/SwiftPackageTool.swift