iOS 10 by Tutorials 笔记(三)

Chapter 3: Xcode 8 Source Editor Extension

苹果在 Xcode 8 上第一次官方支持了扩展,但根据其一贯风格,范围也仅限处理一些文本,也就是说你无法定制 Xcode 的外观,你的接口是通过菜单项的方式进行交互的,而且扩展 extension 和 Xcode 之间也只允许传递文本信息。

本章我们来构建一个将文本转换为 ASCII 的扩展插件,底层的实现是基于 figlet-js,这是一段 js 代码,不过我们已经在此基础上做了一层封装,可以使用原生 Swift 代码来调用,有兴趣的可以自行去看代码。

书里提供了一个 mac 端的小程序,跑一下看看效果:

为什么要学习官方插件模式(source editor extensions)

  • 完全异步的方式,对 IDE 的性能影响小
  • 使用官方支持的接口,不会随版本的升级而死掉
  • extension 模式提供了一个纯净的接口,构建起来比较简单

因为之前的插件开发可能会依赖一些私有库,存在一些风险,参照 GhostXcode 事件,所以 Xcode 8 使用动态的库验证来增强安全性。这样以来,以前很多插件都基本被宣判了死刑。

新的插件模式虽然有很多限制,但毕竟是官方钦点,也能做点微小的事情:

  • 生成针对方法自动生成注释文档,并且包含了所有的参数、返回值、函数签名
  • 将一个文件中定义的非本地化字符串转换为本地化版本
  • 将 color 和 image 转换为新的 color 和 image 的字面量(WWDC 上有演示)
  • 在 extension block 之上创建 MARKs 注释
  • 在高亮的属性上生成一个输出调试语句
  • 清理文件中的空格

还有一点要清楚,虽然我们的主题是 iOS,但 source editor extensions 却是货真价实的 macOS 应用。

Creating a new extension

回到我们的目标,来实现一个 ASCII 的转换插件,在初始工程中添加 extension:选择 File\New\Target 然后在 macOS 栏中选择 Xcode Source Editor Extension

创建一个名为 AsciiifyComment 的 Extension,此时导航栏出现了一个新的 target 和 group,展开 group,发现提供了三个样板文件:

  • SourceEditorExtension.swift
  • SourceEditorCommand.swift
  • Info.plist

选择 AsciiifyComment build scheme 先运行跑一下,在弹出的小窗口中选择 Xcode,此时会启动一个黑色版本的 Xcode,这个黑色 Xcode 主要用来测试我们的 extensions。

创建一个 playground,保持光标停留在 playground 中,选择 Editor\Asciiify Comment \Source Editor Command 触发我们的扩展,当然目前里面还是空白,什么都不会发生

Building the Asciiify extension

打开 AsciiifyComment group 中的 Info.plist,找到 XCSourceEditorCommandName,将其值改为 Asciiify Comment

XCSourceEditorCommandIdentifier 是唯一的标识,方便稍后查询 XCSourceEditorCommandClassName 指向 source editor command 类,负责执行具体的命令

再次运行,现在查看 Editor\Asciiify Comment\Asciiify Comment,名字变成 Asciiify Comment

Exploring the command invocation

点击上面的菜单中的 Asciiify Comment 命令将会调用 SourceEditorCommand 中的 perform(with:completionHandler:) 方法,该方法带两个参数:除了 completion handler 之外,还传递了一个 XCSourceEditorCommandInvocation

XCSourceEditorCommandInvocation 这个类包含了 text buffer(缓存)和所有关于『选中』所需要的标识,下面是这个类的一些属性

  • commandIdentifier 要执行命令的标识(唯一),来自于 Info.plist 中的 XCSourceEditorCommandIdentifier
  • buffer 是 XCSourceTextBuffer 类型对象,用来缓存即将执行的命令
  • cancellationHandler 用户取消 extension 命令时执行

cancellationHandler 会阻塞主线程,所以你的 extensions 必须非常快

我们再来详细看看 buffer,它是 XCSourceTextBuffer 类型对象,用来缓存即将执行的命令,它有几个比较重要的属性:

  • lines:一个字符串(String)数组,数组中的每一个项表示单独一行
  • selections:XCSourceTextRange 对象的数组,用来表示被选中的范围

还要理解的一个类是 XCSourceTextPosition,我们可以用它来表示选中的开始和结束位置,它有两个属性:linecolumn(也就是行和列)

下面一张图很清楚地说明了一切

Build the editor command

现在我们来构建我们的命令,打开 SourceEditorCommand.swift,导入之前用 swift 封装 js 的转换类,然后创建干活的转换类(FigletRenderer)

import Figlet

let figlet = FigletRenderer()  

接着往 perform(with:completionHandler:) 方法中添加下面的代码:

let buffer = invocation.buffer  
// 1 先检查选中的区域文字是否跨行,跨行就直接丢弃,因为 FIGlets 处理不了这种
buffer.selections.forEach({ selection in  
  guard let selection = selection as? XCSourceTextRange,
    selection.start.line == selection.end.line else { return }
// 2 找出选中区域的开始和结束位置
  let line = buffer.lines[selection.start.line] as! String
  let startIndex = line.characters.index(
    line.startIndex, offsetBy: selection.start.column)
  let endIndex = line.characters.index(
    line.startIndex, offsetBy: selection.end.column)
// 3 找到选中的字符串
  let selectedText = line.substring(
    with: startIndex..<line.index(after: endIndex))
  // TODO: asciiify the text
})

既然已经有了选中的文字,那么下一步就可以交给 GIGlet 来渲染了,用下面的代码替换注释 // TODO: asciiify the text

// 1 首先检查是否渲染成功
if let asciiified = figlet.render(input: selectedText) {  
  // 2 将渲染的结果用换行 "\n" 分隔放进数组,找出之前选中的行
  let newLines = asciiified.components(separatedBy: "\n")
  let startLine = selection.start.line
  // 3 将之前选中行的文字全部移除,添加渲染后的文字
  buffer.lines.removeObject(at: startLine)
  buffer.lines.insert(
    newLines,
    at: IndexSet(startLine ..< startLine + newLines.count))
}

再次选择菜单 Editor\Asciiify Comment\Asciiify Comment 运行,竟然报错了

我们来看看报错信息,当你执行 perform(with:completionHandler:)buffer.selections 是空的。buffer 中没有 selection 或 insertion 的话,Xcode 再次掌握控制权后不知道把光标恢复到何处。

再来看下代码,当要离开 extension 时,无论你在 buffer 中 selection 的是什么,都会通过最后的 removeObject(at:) 移除。为了简单,我们将光标恢复到一个已知位置,即 buffer 的起始位置。

在 方法的末尾插入下面代码

let insertionPosition = XCSourceTextPosition(line: 0, column: 0)  
let selection = XCSourceTextRange(  
  start: insertionPosition,
  end: insertionPosition)
buffer.selections.setArray([selection])  

这样离开 extension 后,Xcode 根据 buffer.selections 将光标插入了起始位置

Adding some polish

我们继续把这个程序变得实用一点,先将注释都变成 ASCII 字符串

在 SourceEditorCommand.swift 的 perform(with:completionHandler:) 方法中找到

let newLines = asciiified.components(separatedBy: "\n")  

替换成,即每行开头添加注释 "//" 标识

let newLines = asciiified.components(separatedBy: "\n")  
  .map { "// \($0)" }

现在还有一个问题,每次执行完,光标都跳到最开始的位置,我们让它来跳到最后的位置

先在 perform(with:completionHandler:) 中添加一个 XCSourceTextRange 数组用来存储返回前选中的位置

var newSelections = [XCSourceTextRange]()  

然后在得到渲染后的字符串数组后,存储位置,即在 if let asciiified = ... 后添加下面的计算位置代码:

// 1 选中的起始行位置
let startPosition = XCSourceTextPosition(  
  line: startLine,
  column: 0)
// 2 新的结束行位置
var endLine = startLine  
if newLines.count > 0 {  
  endLine = startLine + newLines.count - 1
}
// 3 结束列的位置
var endColumn = 0  
if let lastLine = newLines.last {  
  endColumn = lastLine.characters.count
}
// 4 结束的位置
let endPosition = XCSourceTextPosition(  
  line: endLine,
  column: endColumn)
// 5 计算出新字符串(ASCII)的范围,放进数组
let selection = XCSourceTextRange(  
  start: startPosition,
  end: endPosition)
newSelections.append(selection)  

接着我们继续更新代码,如果有生成渲染好的 ASCII 码,Xcode 就会选中整个 ASCII 字符串,如果没有生成 ASCII 就移到开头位置

if newSelections.count > 0 {  
  buffer.selections.setArray(newSelections)
} else {
  let insertionPosition = XCSourceTextPosition(line: 0, column: 0)
  let selection = XCSourceTextRange(
    start: insertionPosition,
    end: insertionPosition)
  buffer.selections.setArray([selection])
}

每次都要到菜单上去点很麻烦,我们可以绑定一个快捷键

Dynamic commands

虽然已经能够正确生成 ASCII 字符串了,但我们并没有完全挖掘 FIGlet 这个库的潜力啊,这个库允许更改字体,下面来更新一下我们的 extension

source editor extensions 允许实现一个可修改、动态定义的菜单项。XCSourceEditorExtension 协议定义了一个可选属性 commandDefinitions 提供了与 Info.plist 中相同的内容

commandDefinitions 是一个字典数组,每一个字典表示一条 command,字典的 key 定义在 XCSourceEditorCommandDefinitionKey 中,其实和 Info.plist 中的数组字典是相对应的。

打开另一个文件 SourceEditorExtension.swift,添加如下代码:

var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] {  
  // 1 className 表示实际执行任务的 SourceEditorCommand 类名
  let className = SourceEditorCommand.className()
  let bundleIdentifier = Bundle(for: type(of: self)).bundleIdentifier!
  // 2 生成 bundleIdentifier.fontName 标识
  return FigletRenderer.topFonts.map {
    fontName in
    let identifier = [bundleIdentifier, fontName].joined(separator: ".")
    return [
    // 3 设置字典,nameKey 对应的值将出现在菜单栏中
      .nameKey: "Font: \(fontName)",
      .classNameKey: className,
      .identifierKey: identifier
    ] 
  }
}

切换到 SourceEditorCommand.swift 文件,添加一个私有方法

private func font(from commandIdentifier: String) -> String {  
  let bundleIdentifier = Bundle(for: type(of: self)).bundleIdentifier!
    .components(separatedBy: ".")
  let command = commandIdentifier.components(separatedBy: ".")
  if command.count == bundleIdentifier.count + 1 {
    return command.last!
} else {
    return "standard"
  }
}

command 比 bundleIdentifier 数量多一个,原因在于它的结构是 {Bundle Identifier}.{Font Name},用 .components(separatedBy: ".") 变成数组后,多了一个 FontName 元素。所以我们 return command.last! 其实返回的是 fontName

再来到 perform(with:completionHandler:) 方法一开始位置,根据 commandIdentifier 确定选中的字体

let selectedFont = font(from: invocation.commandIdentifier)  

接着修改渲染方法,添加字体参数

figlet.render(input: selectedText, withFont: selectedFont)  

运行,现在菜单中可以选字体了

我们可以切换不同字体看下效果


-EOF-

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!