编写自动化测试(如单元测试、集成测试或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应用程序编写命令行接口似乎是个疯狂的想法,但我鼓励您尝试一下,看看它如何改进您的开发和测试工作流程。试用示例应用程序并更详细地研究代码。