Skip to content

Latest commit

 

History

History
1685 lines (1682 loc) · 65.5 KB

NSTask Tutorial for OS X.md

File metadata and controls

1685 lines (1682 loc) · 65.5 KB

OS X NSTask教程

See a practical example of using NSTask!

查看一个使用NSTask的特别的例子!

更新笔记: 这个OS X的NSTask教程,已由 Warren Burton 更新为Swift版的了。原教程是由 Andy Pereira 撰写的。

你的Mac是由一个成熟版本的UNIX作为操作系统的,这意味着它含有大量预装的命令行工具和脚本语言。Swift、Perl、Python、Bash、Ruby以及任何你可以安装的。使用这个来创建awesome的app,难道不是很棒么?

NSTask 让你可以在你的机器上执行另一个程序,作为一个子进程,并在你的主程序运行的时候,监视它的执行状态。例如,你可以运行 ls 命令来展示一个目录的列表 - 就是从你的app中!

一个 NSTask 的很好的类比是父母和孩子的关系。一个父母可以创建一个孩子,并告诉它去做确定的事情,(理论上)孩子必须服从。 NSTask 表现为类似的方式;你启动一个“孩子”程序,给它指示,并告诉它在哪里报告任何的输出或错误。但会更好 - 它会比你一般的学步小孩更顺从 :]

NSTask 的一个很棒的用途,是用来提供命令行程序的前端GUI。命令行的程序是很有用的,但它们要求你准确地记住。它们在你机器上的什么地方,怎么来调用它们,以及你可以为它们提供什么选项或参数。在前端添加一个GUI,可以提供给用户大量的控制能力 - 无需成为一个命令行的专家(gurn)!

本教程包含一个 NSTask 示例,它会展示给你如何执行一个简单的,带有参数的命名行程序,并在它允许时,展示它的标准输出到一个text field中。最后,你就可以在你的自己的app中使用NSTask了!

注意: 这个教程假定你对Mac OS X开发和终端已有一些基本的熟悉度。如果你对于Mac编程是纯小白,请查看我们的 初学者Mac OS X开发教程系列

入门

为了把注意力直接集中在NSTask上,我已经为你创建了一个启动项目,它包含了基本的用户界面。 下载这个项目 ,并在Xcode中打开,运行项目。

这个启动的app有一个window,就像下面这样:

initial_view

这个window有一个标题“TasksProject”。它包含一个简单的GUI,它将通过调用shell脚本,来让你构建一个iOS项目,创建一个 ipa 并观察正在发生什么。

创建你的第一个NSTask

NSTask 的示例,将通过 NSTask 在后台运行一些命令程序,来build并打包一个iOS app到 ipa 文件。大多数基本的UI功能都已到位 - 你的工作的重点都在 NSTask 上。

注意: 建议你要有一个苹果iOS开发者的账号,因为你需要合适的证书和provisioning profile来创建一个 ipa 文件,它可以安装到你的一个设备上。如果你没有也不要担心,你同样可以在没有这个账号的情况下,follow整个教程。

现在你将在嵌入的,标题为“Tasks View Controller”的View Controller中工作。在这个window中的第一部分,会请求用户选择一个Xcode项目的目录。为了节省时间,你将硬编码为一个你自己的Xcode项目目录,而不是在每次测试运行你的app时,手动选择一个目录。

要这么做,回到Xcode并打开 TasksViewController.swift 。看一下注释“Window Outlets“下的property和方法:

//View Controller Outlets
@IBOutlet var outputText:NSTextView!
@IBOutlet var spinner:NSProgressIndicator!
@IBOutlet var projectPath:NSPathControl!
@IBOutlet var repoPath:NSPathControl!
@IBOutlet var buildButton:NSButton!
@IBOutlet var targetName:NSTextField!

所有的这些property都对应于 Main.storyboard 中的 Tasks View Controller Scene 。注意 projectPath property — 这是你想要改变的那一个。

打开 Main.storyboard 并单击 Project Location 这项。你会发现在对象的层级上位于第四层;它是 Stack View 的一个“孩子”。在 Attributes Inspector 中, Path Control 下,找到 Path 元素:

set_path_1b

设置 Path 为一个你的机器上包含iOS项目的目录。确保你使用的是项目的 parent 目录,而不是 .xcodeproj 文件它本身。

注意: 如果在你的你机器上没有任何的iOS项目, 就在这里下载一个简单的项目 ,并将其解压到你机器上。然后使用前面的说明来设置你应用中的 Path property。例如,如果你把这个包解压到了桌面上,你就应该设置 Path
/Users/YOUR_USERNAME_HERE/Desktop/SuperDuperApp

既然你在app中,有了一个默认的源项目路径来协助测试,你也需要使用一个默认的目的地路径。打开 Main.storyboard 并单击 Build Repository 项。

在Attributes Inspector,在 Path Control 下找到 Path 项:

set_path_2b

Path 设置成一个容易在你的机器上被找到的目录,例如Desktop。它会是这个应用所创建的 .ipa 文件将要放置的地方。

这两个 Tasks View Controller Scene 中额外的地方,你需要了解: Target Name 和一个关联的text field。

initial_view_2

  1. Target Name 为你想要build的 Target 的名称。
  2. 在Target Name下展示的文本域,将在你的项目运行时,展示 NSTask 对象的输出。

不知道你iOS项目target的名称?要找到它,在Xcode的project navigator中选择你的项目,并在 Info tab下查看 TARGETS 。下面的屏幕截图展示了,在哪里可以找到一个叫做“SuperDuperApp”的简单项目。

target_name

记住target的名称 - 你将在后面当app运行时输入它。

让我们来填充(flesh out)当按下“Build”按钮时,将要运行的代码。

准备Spinner

打开 TasksViewController.swift 并添加下列的代码到 startTask(\_:) 中:

//1.
outputText.string = ""
 
if let projectURL = projectPath.url, let repositoryURL = repoPath.url {
 
  //2.
  let projectLocation = projectURL.path
  let finalLocation = repositoryURL.path
 
  //3.
  let projectName = projectURL.lastPathComponent
  let xcodeProjectFile = projectLocation + "/\(projectName).xcodeproj"
 
  //4.
  let buildLocation = projectLocation + "/build"
 
  //5.
  var arguments:[String] = []
  arguments.append(xcodeProjectFile)
  arguments.append(targetName.stringValue)
  arguments.append(buildLocation)
  arguments.append(projectName)
  arguments.append(finalLocation)
 
  //6.
  buildButton.isEnabled = false
  spinner.startAnimation(self)
 
}

以下是对上述代码一步一步的解释:

  1. outputText 是window中的大文本框;它会包含所有来自你将要运行的脚本的输出。如果你运行这个脚本多次,你会想在每次运行前清空它,因此第一行会设置 string property(text框的内容)为一个空字符串。
  2. projectURL repositoryURL 都是 NSURL 的对象,为了将它们作为参数传你给你的 NSTask ,代码获取了这些对象的字符串的表示。
  3. 一般地,目录的名称和项目文件的名称是相同的。在包含于 projectURL 的项目目录,获取 lastPathComponent property,并添加一个“.xcodeproj”的扩展,来获取项目文件的路径。
  4. 定义当创建 ipa 文件作为 build 时,产生的中间的build文件,所将要保存在的子目录。
  5. 将参数保存到一个数组中。这个数组将传递给 NSTask ,用来在运行命令行工具时,构建 .ipa 文件。
  6. 禁用“Build”按钮,并启动一个spinner动画。

为什么要禁用“Build”按钮? NSTask 将在每次按下按钮时运行,因此app将在执行 NSTask 的时间内非常忙碌,用户会无耐心地多次点击它 - 每次都会产生一个新的build过程。这个动作避免了用户在app非常忙碌的时候创建按钮的点击事件。

运行项目,然后点击 Build 按钮。你应当看到“Build”按钮被禁用,并启动了spinner的动画:

busy1

你的app 看起来 非常得忙碌,但你知道现在实际上,它是没有做任何事的。是时候去添加 NSTask 的魔法了。

添加NSTask到TasksProject

打开 TasksViewController.swift 并添加下面的方法:

func runScript(_ arguments:[String]) {
 
  //1.
  isRunning = true
 
  //2.
  let taskQueue = DispatchQueue.global(qos: DispatchQoS.QoSClass.background)
 
  //3.
  taskQueue.async {
 
   //TESTING CODE
 
   //4.
   Thread.sleep(forTimeInterval: 2.0)
 
   //5.  
	DispatchQueue.main.async(execute: {
	  self.buildButton.isEnabled = true
	  self.spinner.stopAnimation(self)
	  self.isRunning = false
	})
 
	//TESTING CODE
  }
 
}

如果你一步步地看方法,你会看到代码做了下列的事情:

  1. 设置 isRunning true 。这会允许 Stop 按钮的使用,因为它通过 Cocoa Bindings 被绑定到了 TasksViewController isRunning property。你想要让这发生在主线程。
  2. 创建一个 DispatchQueue 来在后台的线程运行繁重的任务。
  3. DispatchQueue 中使用 async 。应用将会在主线程继续处理例如按钮点击的时间,但 NSTask 将运行在后台线程直到它完成为止。
  4. 这是一行临时的代码,让当前的线程睡眠2秒钟,来模拟一个长时间运行的task。
  5. 当完工时,重新启用 Build 按钮,停止spinner动画,并设置 isRunning false ,也就禁用了“Stop”按钮。这需要在主线程完成,因为你是在操纵UI元素。

既然你已有了一个,可以运行你的task在单独的线程中的方法,你需要在你的app 中的某个地方调用它。

仍然在 TasksViewController.swift 中,添加下列的代码到 startTask 的末尾,就在 spinner.startAnimation(self) 的后面:

runScript(arguments)

上面用你在 startTask 中构建的参数的数组,调用了 runScript 方法。

运行项目,点击 Build 按钮。你会注意到, Build 按钮变成了不可用的状态, Stop 按钮变成了可用的状态,spinner将开始动画:

busy2

当spinner的动画正在进行时,你仍然可以和应用进行交互。自己试一下 - 例如,你应当可以在spinner活动的时候,在 Target Name 框中进行输入。

过了两秒之后,spinner将会消失, Stop 按钮将会被禁用, Build 按钮则会变为可用。

注意: 如果你在应用完成睡眠之前,遇到了问题,请增加调用 sleep(forTimeInterval:) 时所传的秒数。

既然你已经解决了UI响应的问题,你就可以最终实现你对NSTask的调用了。

注意: Swift使用 Process 这个名字来调用 NSTask ,因为 Foundation 的framework在Swift 3中去掉了 NS 的前缀。然而,你在本教程中读到的会是NSTask,因为如果你想学到更多的话,它会成为最有用的搜索术语。

TasksViewController.swift 中,找到 runScript 这行,被括号括起来的 //TESTING CODE 注释这里。用下面的代码替换 taskQueue.async 块中的全部内容:

//1.
guard let path = Bundle.main.path(forResource: "BuildScript",ofType:"command") else {
  print("Unable to locate BuildScript.command")
  return
}
 
//2.
self.buildTask = Process()
self.buildTask.launchPath = path
self.buildTask.arguments = arguments
 
//3.
self.buildTask.terminationHandler = {
 
  task in
  DispatchQueue.main.async(execute: {
    self.buildButton.isEnabled = true
    self.spinner.stopAnimation(self)
    self.isRunning = false
  })
 
}
 
//TODO - Output Handling
 
//4.
self.buildTask.launch()
 
//5.
self.buildTask.waitUntilExit()

上面的代码:

  1. 获取了 BuildScript.command 脚本的路径,它包含在你的应用的bundle中。这个脚本现在还不存在 - 你将在稍后添加它。
  2. 创建一个新的 Process 对象,并把它赋给 TasksViewController buildTask property。 launchPath property是你想要运行的可执行文件的路径。将 BuildScript.command path 赋值给 Process launchPath ,然后将传递给 runScript: 的参数赋给 Process arguments property。 Process 将把参数传递给可执行文件,就像你在终端中输入它们一样。
  3. Process 有一个 terminationHandler 的property,它包含一个会在任务完成时被执行的block。它会更新UI来反映完成的状态,就像你之前做得一样。
  4. 为了运行task并执行脚本,调用 Process 对象的 launch 方法。这里同样有终止,打断,挂起或继续一个 Process 的方法。
  5. 调用 waitUntilExit ,它告诉 Process 对象block住当前线程的其它活动,直到这个task完成为止。记住,这个代码运行在后台的线程。你的UI,则运行在主线程,将始终响用户的输入。

运行你的项目;你不会看到任何 看起来 不同的事,但点击 Build 按钮并查看输出控制台。你会看到像下面这样的错误:

Unable to locate BuildScript.command

这是你刚刚添加到代码开头的guard语句中的日志。由于你至今尚未添加任何的脚本,于是就触发了 guard

看起来到了写脚本的时候了!:]

撰写一个Build Shell的脚本

在Xcode中,选择 File\New\File… ,并在 OS X 下选择 Other 的category。选择 Shell Script 并点击 Next

add script to project

将文件命名为 BuildScript.command 。在你点击 Create 之前,确保 Targets 下的 TasksProject 已被选中,就像下面这样:

target_choice

打开 BuildScript.command ,并添加下列的命令到文件末尾:

echo "*********************************"
echo "Build Started"
echo "*********************************"
 
echo "*********************************"
echo "Beginning Build Process"
echo "*********************************"
 
xcodebuild -project "${1}" -target "${2}" -sdk iphoneos -verbose CONFIGURATION_BUILD_DIR="${3}"
 
echo "*********************************"
echo "Creating IPA"
echo "*********************************"
 
/usr/bin/xcrun -verbose -sdk iphoneos PackageApplication -v "${3}/${4}.app" -o "${5}/app.ipa"

这就是你的 NSTask 将会调用的全部的build脚本。

你所看到的贯穿你的脚本的 echo 命令,将把任何传给它的文本发送到 标准输出 中,你会捕获它作为你的 NSTask 对象的一部分的返回值,并展示到你的 outputText 框中。 echo 语句可以方便地让你知道你的脚本正在做什么,因为很多命令在命令行运行时,并不会提供很多的输出。

你会注意到,除了所有的 echo 命令外,还有两个命令: xcodebuild xcrun

xcodebuild build你的应用,创建一个 .app 文件,并将其放到子目录 /build 中。回忆一下,你已在 startTask 中创建过一个引用这个目录的参数,因为你需要一个在build和打包的时候,存放中间build文件的地方。

xcrun 在命令行中运行开发者工具。这里你使用它来调用 PackageApplication ,它会把 .app 文件打包成一个 .ipa 文件。通过设置 verbose 标记,你将在标准输出中获取大量的细节信息,它们会展示在你的 outputText 框中。

xcodebuild xcrun 命令中,你会注意到所有的参数被写作了 “${1}” 而不是 $1 。这是因为你的项目的路径可能包含空格。为了处理这种情况,得到正确的路径,你必须把你的文件路径用双引号引起来。通过双引号和花括号,脚本就会恰当地解析全路径,包含空格。

这个脚本的其它地方,就是Xcode自动为你添加的那些呢?它们是什么意思?、

脚本的第一行如下所示:

#!/bin/sh

尽管它看起来像一条评论,因为那个前缀 # 。实际上这行是告诉操作系统,当执行脚本剩余的部分时,使用一个指定的 shell 。它被称作 shebang 。这个shell就是运行你的命令的解释器,无论是在脚本文件或是从命令行的界面上。

有大量不同的shell可以使用,但是它们中的大多数依附在变化的Bourne shell或C shell语法上。你的脚本指明了应当使用 sh ,它是包含在OS X中的shell之一。

如果你想要指定另一个shell来执行你的脚本,就像 bash ,你就得将第一行改变为恰当的shell的可执行文件的全路径,就像:

#!/bin/bash

在脚本中,任何你传递的参数,都是通过一个 $ 和一个数字来访问的。 $0 代表你调用的程序的名称,后面跟着的参数依次通过 $1 $2 等等来进行引用。

注意: Shell脚本存在的时间,就如同计算机一样地长,因此你可以在网上找到更多关于它的信息。例如一个简单(且相关)的开启的地方,可以访问苹果的 Shell脚本入门

现在你已经准备好从 NSTask 去调用你的脚本了,对么?

不完全是。此刻,你的脚本文件还没有 执行 的权限。你可以读和写文件,但你并不能执行它。

这意味着如果你现在运行app,当你点击Build按钮时,app就会crash。如果你想,就可以尝试一下。这在开发时并没什么大不了,你会在你的Xcode控制台中看到“launch path not accessible”的异常。

为了让它可执行,在终端中前往你的项目目录。终端默认位于你的家目录中,因此如果项目是在你的 Documents 目录下,你应该输入命令:

cd Documents/TasksProject

如果你的项目在除“Documents/TasksProject”目录的另一个目录下,你需要输入正确的项目目录的路径。为了快速地完成,从Finder中点击并拖拽项目目录到终端中。你项目的路径,将魔术般地出现在终端的窗口上!现在简单地将你的光标移动到路径的前边,输入 cd ,后跟一个空格,并点击enter。

为了确保你位于正确的地方,输入下列的命令到终端上:

ls

在生成的文件列表中,检查 BuildScript.command 。如果你并非位于正确的位置,检查你是否在终端中输入了正确的项目目录。

当确保你在正确的目录下之后,输入下列的命令到终端中:

chmod +x BuildScript.command

chmod 命令会改变脚本的权限,使它可以被你的 NSTask 对象执行。如果你尝试,在没有这些恰当权限的条件下,运行你的应用,你将看到“Launch path not accessible”的错误,就像之前一样。你只需要为每个你新添加到项目中的脚本执行一次该操作。

注意: 如果你是为自己开发,或在Mac App Store(MAS)外运送你的app,像这样地使用脚本是很简单的。然而在为MAS开发时,应用到你的app的沙盒规则将由你的脚本继承,你需要使用更复杂的技术来使用命令行程序。这些技术超出了本教程的范围。关于更多的详情,请参考末尾的链接。

清理并运行你的项目;清理的操作是必须的,因为Xcode不能获得文件权限的变化,因此也就不能将它拷贝到build仓库中。当应用打开时,输入你的测试app的target的名称,确保“Project Location”和“Build Repository”的值被正确地设置,最后点击 Build

当spinner消失的时候,你应当在期望的位置上得到了一个新的 .ipa 文件。成功!

使用输出

OK,你已经相当地熟练向命令行程序传递参数了,但对于处理命令行程序的输出呢?

为了实际地查看这个,输入下列命令到终端中,并点击Enter:

date

你应当看到生成的信息,就像这样:

Fri 19 Feb 2016 17:48:15 GMT

date 告诉你当前的日期和时间。尽管不是很明显,它的结果是被发送到了一个名叫 standard output 的频道中。

进程通常有三个用来输入和输出的频道:

  • standard input ,从调用者这里接受输入;
  • standard output ,从进程中将输出发回到调用者这里;
  • standard error ,从进程中将错误发回到调用者这里;

专业提示:你会看到这些通常被简写为 stdin stdout ,和 stderr

还有一个 pipe ,可以让你重定向输出到另一个进程的输入上。你将创建一个pipe来让你的应用看到 NSTask 运行时,进程的标准输出。

为了看到pipe在运转,确保你的电脑已打开了音量,然后在终端中输入下列命令:

date | say

点击enter,你会听到你的电脑告诉了你时间。

注意: 你的键盘上的pipe字符“|”,通常位于 \ 键这里,就在 enter/return 键的上面。

刚刚发生了:你创建了一个pipe,它将 date 的标准输出重定向到了 say 的标准输入中。你也可以向命令提供选项来和pipe交互,因此如果你想听到澳大利亚口音的日期,输入下面的命令来代替:

date | say -v karen

是的日期,宝贝!

你可以使用pipe构建一些相当长的命令链,重定向一条命令的标准输出到另一条命令的标准输入中。一旦你适应了stdin,stdout,以及管道重定向,你就可以在命令行中,使用pipe这样的工具,做一些相当复杂的事情。

到了在你的app中实现管道的时候了。

注意: NSPipe 是一个 Foundation 中的类,它在Swift 3中被称为 Pipe 。大多数文档将引用 NSPipe

打开 TasksViewController.swift 并在 runScript(\_:) 中将评论 // TODO: Output Handling 替换为下面的代码:

self.captureStandardOutputAndRouteToTextView(self.buildTask)

现在,将这个方法添加到 TasksViewController.swift 中:

func captureStandardOutputAndRouteToTextView(_ task:Process) {
 
  //1.
  outputPipe = Pipe()
  task.standardOutput = outputPipe
 
  //2.
  outputPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
 
  //3.
  NotificationCenter.default.addObserver(forName: NSNotification.Name.NSFileHandleDataAvailable, object: outputPipe.fileHandleForReading , queue: nil) {
    notification in
 
    //4.
    let output = self.outputPipe.fileHandleForReading.availableData
    let outputString = String(data: output, encoding: String.Encoding.utf8) ?? ""
 
    //5.
    DispatchQueue.main.async(execute: {
      let previousOutput = self.outputText.string ?? ""
      let nextOutput = previousOutput + "\n" + outputString
      self.outputText.string = nextOutput
 
      let range = NSRange(location:nextOutput.characters.count,length:0)
      self.outputText.scrollRangeToVisible(range)
 
    })
 
    //6.
    self.outputPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
  }
 
}

这个函数从外部的进程中采集了输出,并将它添加到了GUI的 outputText 框中。它就像下面这样地工作:

  1. 创建了一个 Pipe 并将它连接到 buildTask 的标准输出上。 Pipe 代表了你在 终端 中创建的pipe的等效的类。任何写入到 buildTask stdout 的内容,将被提供到 Pipe 对象上。
  2. Pipe 有两个property: fileHandleForReading fileHandleForWriting ,它们都是 NSFileHandle 对象。 NSFileHandle 的相关知识已经超出了这个教程的范围,但 fileHandleForReading 是用来在pipe中读取数据的。你可以调用它的 waitForDataInBackgroundAndNotify 方法,来使用一个单独的后台线程来获取有用的数据。
  3. 任何当数据可用的时候, waitForDataInBackgroundAndNotify 就会通过调用你注册在 NSNotificationCenter 中的代码块,来通知你处理 NSFileHandleDataAvailableNotification
  4. 在你的通知处理中,获取 NSData 对象形式的数据,并将它转变为一个字符串。
  5. 在主线程中,添加从上一步获取的字符串到 outputText 文本的末尾,并将文本区域滚动到 用户可以看到刚刚给出的输出的地方。这个 must 必须 在主线程执行,就想所有的UI和用户交互一样。
  6. 最后,再次调用在后台等待数据的方法。这就创造了一个循环,它将持续等待有用的数据,处理数据,再次等待有用的数据,等等。

再次运行你的应用;确保 Project Location Build Repository 是正确的,输入你的target的名称,并单击 Build

你应当在 outputText 中看到来自build进程的输出:

final window view

停止NSTask

如果你开始了一个build,然后改变主意了,会发生什么?如果它花费了太长的时间,或一些其它的事看起来出错了,或只是在这儿挂起了,导致无法继续?这些都是你想要停下你的后台task的时刻。幸运的是,这些都很容易办到。

TasksViewController.swift 中,添加下列的代码到 stopTask(\_:)

if isRunning {
  buildTask.terminate()
}

上面的代码检查了是否 NSTask 正在运行,如果是的话,调用 terminate 方法。这将停止 NSTask 在它的执行中。

运行你的app,确保全部的field配置正确,并单击 Build 按钮。然后在build完成前单击 Stop 按钮。你将看到每个事都停下了,且在你的输出目录下,没有创建新的 .ipa 文件。

从这儿去向哪里?

这里是从上面的教程中完成的 NSTask示例项目

恭喜,你已经开始变成 NSTask “忍者”(ninja)了!

在一篇简短的教程中,你学到了:

  • 怎样创建带有参数和输出pipe的 NSTasks ;以及
  • 怎样创建一个shell脚本,并从你的app中调用它!

要了解关于 NSTask 的更多信息,请访问苹果的官方文档 NSTask类参考文档

为了了解在沙盒app中,使用命令行程序的相关内容,请参考 守护进程和服务的编程向导 XPC服务API参考文档

这个教程仅处理了NSTask的stdout,但你也可以同样地使用 stdin和stderr !为了实践你的新技能,尝试使用它们。

我希望你可以喜欢这个 NSTask 教程,你会发现它在你将来的Mac OS X app中是非常有用的。如果你有任何的问题或评论,请参与我们下面的讨论!