为iOS应用程序编写命令行接口

737 阅读10分钟

编写自动化测试(如单元测试、集成测试或UI测试)是一种很好的方法,可以使用可重复的步骤来确保应用程序按照我们预期的方式工作。但是在某些情况下,自动化测试并不能解决问题。

一些移动应用程序的行为有一些微妙之处,只能通过将设备握在手中--就像用户所做的那样--并观察事物的运行情况来进行评估。当某些东西不按预期工作时,重复手工测试或回到已知的初始状态进行调试可能会很痛苦。

在iOS应用程序中创建一个更好的调试和迭代环境有无数种方法,比如使用启动参数、环境变量,或者在应用程序内部有一个内部设置或调试菜单,这样就可以在应用程序中进行调整。我认为每个航运应用程序都应该包括这些,因为它们大大改善了开发过程。

但是,即使有了所有这些选项,我仍然认为还有一个空间:命令行接口。是的,您正确地阅读了它:我为IOS应用程序.

白勺

由于应用程序名为ChibiStudio,所以我决定调用这个命令行工具chibictl(它的发音是“chee bee cee cee el”,埃丽卡).

是怎么做的?

我们无法在常规IOS设备上编写或运行命令行工具(目前,FB7555034)。此外,拥有一个iOS应用程序的命令行接口的全部意义是在Mac上运行它,这样您就不需要与设备本身进行交互了。

因此,需要有一种方法在Mac和IOS设备(或模拟器)之间来回发送数据。也许有一些方法可以使用有线闪电连接,我们也可以在设备上旋转一个套接字或HTTP服务器,但是我决定使用MultipeerConnectivity框架。

该框架允许iOS、Mac和tvOS--遗憾的是--设备可以通过WiFi、点对点WiFi或蓝牙与附近的设备进行通信。最酷的一点是,底层的通信是为您抽象的,因此没有必要担心底层的网络位。

尽管MultipeerConnectivity提供了一个有点高级别的API,但在我以前使用它的经验中,我注意到在启动和运行它时往往涉及到相当多的样板。因此,我决定多点工具包库,这使您可以非常容易地在设备之间建立通信,如下所示:

// Create a transceiver (make sure you store it somewhere, like a property)
let transceiver = MultipeerTransceiver()

// Start it up!
transceiver.resume()

// Configure message receivers
transceiver.receive(SomeCodableThing.self) { payload in
	print("Got my thing! \(payload)")
}

// Broadcast message to peers
let payload = SomeEncodableThing()
transceiver.broadcast(payload)

我最喜欢的一点是,它允许您发送和接收任何符合Codable协议,为要在对等点之间传输的每种类型的实体注册要调用的特定闭包。

作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:1012951431 不管你是小白还是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!

另附上一份各好友收集的大厂面试题,进群可自行下载!

实例:APP端

下面的代码片段来自利斯特示例应用程序。这是一个非常简单的应用程序,用SwiftUI编写,其中包括一个用于与其数据交互的CLI工具。

由于使用MultipeerKit在Mac和IOS设备或模拟器之间交换数据很容易,所以我决定将CLI可以发送的每一个命令表示为struct.

下面是一个示例,显示表示“Add Item”命令的结构:

struct AddItemCommand: Hashable, Codable {
    let title: String
}

这是一个非常简单的命令,只有一个属性-要添加的列表项的标题。有些命令甚至不需要任何参数,但定义为structs符合Codable只是为了让我receive他们用MultipeerTransceiver.

不接受输入的此类命令的一个示例是ListItemsCommand:

struct ListItemsCommand: Hashable, Codable { }

为了响应命令行工具发送的这些命令,我实现了一个CLIReceiver,它注册了一个MultipeerTransceiver使用服务类型listrctl(CLI工具的名称)。

在收发器就绪后,我可以为应用程序支持的每个命令注册处理程序:

final class CLIReceiver {

    // ...
    
    func start() {
        transceiver.receive(AddItemCommand.self, using: response(handleAddItem))
        transceiver.receive(ListItemsCommand.self, using: response(handleListItems))
        transceiver.receive(DumpDatabaseCommand.self, using: response(handleDumpDatabase))
        transceiver.receive(ReplaceDatabaseCommand.self, using: response(handleReplaceDatabase))

        transceiver.resume()
    }

}

注意每个命令处理函数是如何由response功能。这就是接收方将数据发送回命令行工具的方式。为了做到这一点,我定义了另一个模型,CLIResponse,这实际上是一个enum:

enum CLIResponse: Hashable {
    case message(String)
    case data(Data)
}

为什么是明灯?我不希望每个命令都有特定的响应类型,因为这会使实现变得非常复杂。我发现我只需要两种类型的响应:一条描述发生了什么的消息,或者为CLI用户返回人类可读的内容,或者一些二进制数据,然后CLI会写到一个文件中。因此,我决定使用带关联值的枚举:String或Data.

使这个枚举符合Codable协议需要自定义init(from decoder: Decoder)和encode(to encoder: Encoder)实现非常简单,但为了简洁起见,我不会将它们包括在这里--查看示例应用程序以获得完整的实现。

回到那个response包装器,如下所示:

private func response<T: Codable>(_ handler: @escaping (T) -> CLIResponse) -> (T) -> Void {
    return { [weak self] (command: T) in
        let result = handler(command)

        self?.transceiver.broadcast(result)
    }
}

它所做的就是调用handler函数,该函数返回CLIResponse。然后,它向所有连接的对等点广播该响应--在本例中,只是运行命令行工具的设备。这是我发现的将处理程序实现为接受特定命令的函数的最佳方法。struct作为输入并返回CLIResponse作为输出,将多点通信留给接收机本身,而不必在任何地方重复广播呼叫。

每个命令处理程序本身的实现方式完全取决于应用程序本身。下面是我如何实现“列表项”命令处理程序的一个示例:

private func handleListItems(_ command: ListItemsCommand) -> CLIResponse {
    let list = app.store.items.map { item in
        "\(item.done ? "[✓]" : "[ ]" ) \(item.title)"
    }.joined(separator: "\n")

    return .message(list)
}

在这种情况下,app只是返回AppDelegate它有自己的属性,这是应用程序使用的数据存储。像这样访问应用程序委托并不一定是应用程序代码中的一个好做法,但由于这是一个内部的、仅用于调试的功能,所以我不认为这是一个大问题。

说到内部和调试,应用程序中包含的与CLI工具相关的所有代码都在#if DEBUG和#endif,以确保它在上传到AppStore时从未包含在应用程序中。在我的一些应用程序中,我做的另一件事就是有一个单独的InternalConfiguration允许我将应用程序的内部版本(包括所有调试工具)上传到TestAir,供内部测试人员使用。

实例:CLI侧

这是对命令接收器是如何在应用程序中实现的概述。现在,让我们看看如何实现命令行工具本身。第一步是向Xcode项目添加一个新目标,选择命令行工具当出现提示时,从新的目标表中删除。我给中情局打了电话listrctl.

所有命令模型都必须包括在IOS应用程序目标和CLI目标中,因为两者都需要使用它们。此外,多点工具包必须包含在CLI的“框架和库”中。

因为这是一个命令行工具,它的任务之一就是解析传递给它的参数。幸运的是,苹果最近发布了ArgumentParser库,这大大简化了这个任务,所以我决定将它用于命令行工具。

就像应用程序有一个CLIReceiver,命令行工具具有CLITransmitter-从技术上讲,它们都是收发机,因为它们都可以发送和接收数据。发射机负责向附近的IOS设备或模拟器实例发送命令,并接收CLIResponse由iOS应用程序发送的结构。

不过,也有一个问题:我正在编写一个命令行工具,它本质上是同步的--它一直运行到得到结果,然后停止--但是与此同时,我正在通过MultipeerKit处理MultipeerConnectivity,这是一个高度异步的过程。

这意味着发射机必须等待,直到它看到一个连接的设备,发送命令,等待回复回来,然后最后向CLI用户显示结果并终止其进程。

我是如何做到这一点的:

final class CLITransmitter {

    static let current = CLITransmitter()

    static let serviceType = "listrctl"

    private lazy var transceiver: MultipeerTransceiver = {
        var config = MultipeerConfiguration.default

        config.serviceType = Self.serviceType

        return MultipeerTransceiver(configuration: config)
    }()

    var outputPath: String!

    func start() {
        transceiver.receive(CLIResponse.self) { [weak self] command in
            guard let self = self else { return }

            switch command {
            case .message(let message):
                print(message)
            case .data(let data):
                self.handleDataReceived(data)
            }

            exit(0)
        }

        transceiver.resume()
    }

    private func handleDataReceived(_ data: Data) {
        try! data.write(to: URL(fileURLWithPath: outputPath))
        outputPath = nil
    }

    private let queue = DispatchQueue(label: "CLITransmitter")

    private func requirementsMet(with peers: [Peer]) -> Bool {
        !peers.filter({ $0.isConnected }).isEmpty
    }

    func send<T: Encodable>(_ command: T) {
        queue.async {
            let sema = DispatchSemaphore(value: 0)

            self.transceiver.availablePeersDidChange = { peers in
                guard self.requirementsMet(with: peers) else { return }

                sema.signal()
            }

            _ = sema.wait(timeout: .now() + 20)

            DispatchQueue.main.async {
                self.transceiver.broadcast(command)
            }
        }

        CFRunLoopRun()
    }

}

如您在start方法,发射机为CLIResponse类型。当一个回应出现时,如果它只是一个String,它只是打印到控制台,如果它是Data,它被写入由path财产。完成后,通过调用exit(0).

这个send方法是最有趣的方法。它立即分派到一个单独的队列,然后设置一个信号量在该队列中等待,直到找到符合条件的设备。在示例应用程序中,当前连接到运行CLI的设备的任何远程设备都符合条件。一旦满足了条件,它就会向所有连接的设备广播该命令。值得注意的是,默认情况下,MultipeerKit将自动建立到附近对等点的连接,因此我不需要手动进行任何操作。

这个CFRunLoopRun()之后调用只会保持主runloop活动,这是所有多点机器完成其工作所必需的。

这个main.swiftlistrctl中的文件使用ArgumentParser提供的API定义命令。以下是一段节选:

CLITransmitter.current.start()

struct ListrCTL: ParsableCommand {
    
    static let configuration = CommandConfiguration(
        commandName: "listrctl",
        abstract: "Interfaces with Listr running on a device or simulator.",
        subcommands: [
            Item.self,
            Store.self
    ])

    // ...

    struct Store: ParsableCommand {

        static let configuration = CommandConfiguration(
            commandName: "store",
            abstract: "Manipulate the data store.",
            subcommands: [
                Dump.self,
                Replace.self
            ]
        )

        struct Dump: ParsableCommand {

            static let configuration = CommandConfiguration(
                commandName: "dump",
                abstract: "Dumps the contents of the store as a property list."
            )

            @Argument(help: "The output path.")
            var path: String

            func run() throws {
                CLITransmitter.current.outputPath = path

                send(DumpDatabaseCommand())
            }

        }

        // ...
    }
}

ListrCTL.main()

CLI的这一部分几乎感觉是声明性的,因为它只是定义了可以调用的命令,它们将所有的主要工作推迟到CLITransmitter.

我可以打电话send从命令内部run方法,因为有了这个扩展,我做了:

extension ParsableCommand {
    func send<T: Encodable>(_ command: T) {
        CLITransmitter.current.send(command)
    }
}

结语 就这样!我知道为iOS应用程序编写命令行接口似乎是个疯狂的想法,但我鼓励您尝试一下,看看它如何改进您的开发和测试工作流程。试用示例应用程序并更详细地研究代码。

翻译地址