首页 >Swift

如何在Swift项目中使用 Javascript编写一个将Markdown转为HTML的编辑器

2017-05-11 10:15 编辑: sasukeo 分类:Swift 来源:APPCODA

一直想写一篇文章,关于如何将 Swift 和 Javascript 结合在一起,以构建强大而功能丰富的 App。这并不是我们第一次听人说要将 Javacript 代码嵌入到 iOS 专案中了,但当你读完本文后,你会感到这个过程会变得前所未有的简单,仿佛魔术一般,你只需要做很少的工作。其中的奥妙就是一个叫做 JavaScriptCore framework 的框架。

你可能会想,为什么总是有人爱用 JavaScript,为什么不用 Swift 实现所有的功能?其实这也是我想问的,这里我们陈述几条理由:

  • 那些曾经写过 web App 、已经忘记 Javascript 怎么写的 iOS 开发者,通过 JavaScriptCore 框架,就有机会再次使用他们所钟爱的语言啦。

  • 对于某些任务,很可能已经有现成的 JavaScript 库存在,它们和你要用 Swift 实现的功能其实并无区别。为什么不使用现成的呢?

  • 很可能某些任务用 JavaScript 来做会更容易一点。

  • 你可能想远程控制 App 的行为。可以将 JavaScript 程序代码放到服务器而不是 App bundle 里。这样做时需要小心,因为这很可能会导致一场灾难。

  • 可以让你的 App 更具弹性和更加强大。

  • 你有强烈的好奇心,希望在你的 iOS 项目中使用 JavaScript。

当然,除此之外,你可能还想到了更好的在 iOS 使用 JavaScript 的理由。现在,你别忙着高兴,让我们看一下需要什么必要的背景知识吧。首先,JavaScript 有它独立的运行环境,或者更明确地说,它需要在虚拟机中运行。在 JavaScriptCore 框架中,用 JSVirtualMachine 类来代表虚拟机,当然通常你不会和它打交道。在一个 App 中可以运行多个虚拟机,它们之间无法直接交换数据。

其次,你使用得最多的其实是 JSContext。这个类相对于执行 JavaScript 脚本的真实环境(context)。在一个虚拟机(JSVirtualMachine)可以存在多个 context,你可以在 context 之间传递数据。如同你在后续内容中所看到, JSContext 会将 Swift 程序代码暴露给 JavaScript,将 JavaScript 程序代码暴露给 Swift。 我们会大量使用到它,但大部分用法都是相同的。

JSContext 中的所有值都是 JSValue 对象,JSValue 类用于表示任意类型的 JavaScript 值。如果你要从 Swift 中访问 JavaScript 变量或函式,都可以用 JSValue 对象。当然也有将 JSValue 转换成特定数据类型的方法。例如,转换成字符串用 toString() 方法,转换成字典用 toDictionary() 方法 (后面会看到)。在这里有一个完整的方法列表。

我建议你阅读官方的 JavaScriptCore 框架文檔。前面所说的这些可能会让你对将要用到的工具有一个大概的了解,也有助你进一步理解后面的内容。

现在,让我们正式开始。先来看一下今天的“菜谱”都有些什么。

Demo项目概览

我们将通过一个简单的示范项目来了解 JavaScriptCore 框架极其特性,这个项目演示了如何在 Swift 中使用 JavaScript。我们将使用经典 “Hello World” 示例(我最喜欢用的例子),它会把一个字符串值保存到 JavaScript 变量中。我们首先关心的是如何从 Swift 中访问这个变量,我们不妨用 Xcode 控制台来将它打印出来。我们会连续做几个简单的例子,以逐步研究更多的特性。当然,我们不仅仅要学习如何从 JavaScript 专递值给 Swift;我们也要研究反方向的传递。因此,我们既需要写 Swift 代码也要写 JavaScript 代码。但不用担心,其实 JavaScript 并没有那么难打交道。一点也不难!注意,从这里开始所有的输出都在控制台中进行,这样我们就可以将注意力放在真正值得注意的地方。

我们已经了解了足够多的基础知识了,我们可以来研究下如何在一种语言中使用另一种语言了。

为了更真实,我们先使用第三方 JavaScript 库来试试。在项目的第二部分,我们会编写一个 MarkDown/HTML 转换器,或者说,我们会通过一个“转换器的库”来为我们干这个。我们的工作仅仅是从编辑框中(一个简单的 UITextView)搜集用户输入的 MarkDown 文本,然后将它传给 JavaScript 环境进行转换,并将 JavaScript 环境返回的 HTML 显示到一个 UIWebView 中。用一个按钮来触发转换动作,并调用我们的程序代码。看下图:

t59_1_markdown_to_html.png

在第三部分和最后一部分,我们将演示如何传递带属性和方法的自定义类给 JavaScript Context。此外,我们还会在 JavaScript 中按照这个类的定义来创建一个对象并对其属性进行赋值。我们最终会显示一个 iPhone 从面世以来的设备类型列表(model 名),以及它们的最早和最晚的 OS 版本,以及它们的图片。数据保存在一个 csv 档案中,我们将用一个第三方库进行解析。要获得解析后的数据,我们将在 JavaScript 中使用我们的自定义 Swift 类,用这个类来渲染自定义对象的数据,然后将结果返回给 Swift。我们会用一个 TableView 来显示这个列表。如下图所示:

t59_2_iphone_devices.png

以上内容大体上清晰地告诉了我们三个能让我们了解JavaScriptCore框架的任务。由于许多东西都被打包在了一起,所以我们需要一个首字母菜单界面来指引我们找到正确的项目:

t59_3_menu.png

为便于给你偷懒,我们提供了一个开始项目,你可以在这里下载。当你下载完后,你就可以开始你的 JavaScriptCore 之旅了。在本文中,我们会做几件事情,但最终会明白它们的大部分其实都是标准套路,为了实现最终目标,我们不得不重复这些套路而已。

开始出发吧!

从 Swift 中呼叫 JavaScript

就如介绍中所言,JavaScriptCore 中最主要的角色就是 JSContext 类。一个 JSContext 对象是位于 JavaScript 环境和本地 Javascript 脚本之间的桥梁。因此一开始我们就需要在 BasicsViewController 中宣告这个属性。在 BasicsViewController.swift 档案中,找到类的头部,添加如下变量:

var jsContext: JSContext!

jsContext 对象必须是一个类属性,如果你在方法体中初始化它为本地变量,那么当方法一结束你就无法访问到它了。

现在我们必须导入 JavaScriptCore 框架,在档案头部添加这句:

import JavaScriptCore

接下来要初始化 jsContext 对象,然后使用它。但在此之前,我们先写点基本的 JavaScript 程序代码。我们将在一个 jssource.js 档案中编写它们,你可以在开始项目的项目导航器中找到这个档案。我们会在里面宣告一个 “Hello World” 的字符串变量,然后实现几个简单的函式,我们将通过 iOS 来访问它们。如果你没有学过 JavaScript 也没关系,它们真的太简单了,你一眼就能够看懂。

打开 jssource.js 档案,在开头添加这个变量:

var helloWorld = "Hello World!"

在控制面板中打印这个变量是我们接下来的第一目标!

回到 BasicsViewController.swift 档案,创建一个方法来完成 2 个任务:

  • 对我们早先宣告的 jsContext 属性进行初始化。

  • 加载 jssource.js 档案,将档案内容传给 JavaScript 运行时,这样它才能访问档案中编写的程序代码。

在 BasicsViewController 中新建一个方法,初始化 jsContext 变量。方法非常简单:

func initializeJS() {
    self.jsContext = JSContext()    
 
}

上面的第二条任务分成几个步骤,但也非常简单。我们先来看看一下源码,然后在来进行讨论:

func initializeJS() {
    ...
 
    // Specify the path to the jssource.js file.
    if let jsSourcePath = Bundle.main.path(forResource: "jssource", ofType: "js") {
        do {
            // Load its contents to a String variable.
            let jsSourceContents = try String(contentsOfFile: jsSourcePath)
 
            // Add the Javascript code that currently exists in the jsSourceContents to the Javascript Runtime through the jsContext object.
            self.jsContext.evaluateScript(jsSourceContents)
        }
        catch {
            print(error.localizedDescription)
        }
    }    
 
}

源码中的注释很明白地解释了它们的意思。首先,我们指定了 jssource.js 档案路径,然后加载档案内容到 jsSourceContents 字符串中 (目前,这些内容就是你先前在 jssource.js 档案中编写的内容)。 如果成功,则接下来这句就重要了:我们用 jsContext 来“计算”这些 JavaScript 程序代码,通过这种方法我们可以立即将我们的 JS 程序代码传递到 JavaScript 环境。

接着增加一个全新的方法:

func helloWorld() {
    if let variableHelloWorld = self.jsContext.objectForKeyedSubscript("helloWorld") {
        print(variableHelloWorld.toString())
    }
}

这个方法虽然很简单,但作用可不小。这个方法的核心部分是 objectForKeyedSubscript(_:) 一句,我们通过它来访问 JavasScript 中的 hellowWorl 变量。第一条语句返回的是一个 JSValue对象(如果没有值则返回为 nil),同时把它放到 variableHelloWorld 中保存。简单说,这就完成了我们的第一个目标,因为我们在 Swift 中写了一些 JavaScript,我们可以用任何方式来处理它!我们要怎样处理这个保存着 “Hello World” 字符串的变量呢?把它输出到控制台中而已。

现在,我们在 viewDidAppear(_:) 中呼叫这两个新方法:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
 
    self.initializeJS()
    self.helloWorld()
}

运行 App,点击第一个标题为 Basics 的按钮。打开 Xcode 的控制台,我们的 “Hello World” 字样被 JavaScriptCore 框架输出到了控制台!

t59_4_hello_world.png

在混合使用 Swift 和 JavaScript 时,肯定不仅仅是为了定义几个变量,然后打印它们的值。因此,让我们来创建第一个 JavaScript 函式吧,让我们来看看要如何使用它。

我找不到其他简单的例子,因此使用下面这个函式,用于将姓和名组合成全名。在 jssource.js 档案中加入:

function getFullname(firstname, lastname) {
    return firstname + " " + lastname;
}

人名中的姓和名分别被作为函式的两个参数。保存档案,返回 BasicsViewController.swift。

在 Swift 中呼叫 JavaScript 函式有两步:

首先,询问 jsContext 要呼叫的函式名称,这会返回一个 JSValue 对象,这和我们访问 helloWorld 变量是一样的。然后,通过方法名来呼叫这个函式,将它需要的参数传入。一会你就明白了,现在先实现一个新方法:

func jsDemo1() {
    let firstname = "Mickey"
    let lastname = "Mouse"
 
    if let functionFullname = self.jsContext.objectForKeyedSubscript("getFullname") {
 
    }
}

现在,Swift 通过 functionFullname 引用了getFullname JS 函式。然后是第二步呼叫这个 JS 函式:

func jsDemo1() {
    let firstname = "Mickey"
    let lastname = "Mouse"
 
    if let functionFullname = self.jsContext.objectForKeyedSubscript("getFullname") {
        // Call the function that composes the fullname.
        if let fullname = functionFullname.call(withArguments: [firstname, lastname]) {
            print(fullname.toString())
        }
    }
}

call(withArguments:) 方法用于呼叫 getFullName 函式,并导致它的执行。call 方法只接收一个参数,这是一个任意对象类型的数组,如果函式没有参数,你可以传递一个 nil。在我们的例子中,我们传递了 firstname 和 lastname。这个方法的返回值也是一个 JSValue 对象,我们会将它打印到控制台中。在后面你会看到,方法的返回值对我们来说不一定是有意义的,因此我们也会不使用它。

现在,让我们呼叫 jsDemo1() 方法:

override func viewDidAppear(_ animated: Bool) {
    ...
 
    self.jsDemo1()
}

运行项目,会在控制面板中看到如下输出:

t59_5_output_fullname.png

这一点也不有趣,但你要明白你所看到的是在 Swift 中呼叫 JS 函式所得到的结果。同时,我们通过这部分内容可以总结出这样一个固定流程:

  • 构建一个 JSContext 对象。

  • 装载 JavaScript 程序代码,计算(evaluate)它的值 (或者说将它传递给 JavaScript 环境)。

  • 通过 JSContext 的 objectForKeyedSubscript(_:) 方法访问 JS 函式。

  • 呼叫 JS 函式,处理返回值(可选)。

处理 JavaScript 异常

在开发中,编码时现错误总是不可避免的,但错误出现必须让开发者看到,这样他们才会去解决它。如果进行 JS 和 Swift 混合编程,你怎么知道应该去哪儿调试?Swift 还是 JS?在 Swift 中对错误进行输出很容易,但我们能看到发生在 JS 端的错误吗?

幸好,JavaScriptCore 框架提供了一个在 Swift 中捕捉 JS 环境中出现的异常的方法。观察异常是一种标准程序,我们会在后面了解,但如何处理它们很显然是一件很主观的事情。

回到我们刚刚编写的程序代码,我们来修改一下 initializeJS() 方法,以捕捉 JS 运行时异常。在这个方法中,在 jsContext 初始化之后,添加如下语句:

func initializeJS() {
    self.jsContext = JSContext()
 
    // Add an exception handler.
    self.jsContext.exceptionHandler = { context, exception in
        if let exc = exception {
            print("JS Exception:", exc.toString())
        }
    }
 
    ...
}

看到了吧,exceptionHandler 是一个闭包,每当 jsContext 发生一个错误时都会呼叫这个闭包。它有两个参数:异常发生时所在的 context (即JSContext),以及异常本身。这个 exception 是一个 JSValue 对象。在这里,我们为了简单起见,仅仅将异常消息打印到控制面板。

我们来试着制造一个异常,以测试这种方法是否行得通。为此,我们必须在 jssource.js 中编写另一个 JS 函式,这个函式用一个整数数组作为参数(整数和负数),返回一个包含了这个数组中最大值、最小值和平均值的字典。

打开 jssource.js 档案,添加函式:

function maxMinAverage(values) {
    var max = Math.max.apply(null, values);
    var min = Math.min.apply(null, values);
    var average = Math.average(values);
 
    return {
        "max": max,
        "min": min,
        "average": average
    };
}

代码中的错误在于,在 Math 对象中根本没有一个 average 函式,因此这句完全不对:

var average = Math.average(values);

假设我们不知道这个情况,回到 BasicsViewController.swift,添加一个新方法:

func jsDemo2() {
    let values = [10, -5, 22, 14, -35, 101, -55, 16, 14]
 
    if let functionMaxMinAverage = self.jsContext.objectForKeyedSubscript("maxMinAverage") {
        if let results = functionMaxMinAverage.call(withArguments: [values]) {
            if let resultsDict = results.toDictionary() {
                for (key, value) in resultsDict {
                    print(key, value)
                }
            }
        }
    }
}

首先,我们创建了一个随机数字构成的数组。我们用它作为调用 maxMinAverage 方法时的参数,这个方法在 Swift 中通过 functionMaxMinAverage 对象来引用。在呼叫 call 方法时,我们将这个数组作为唯一参数传递。如果一切正常,我们会按照 Dictionary(注意 toDictionary() 方法)的方式来处理返回结果,将其中的值一一打印到控制台(maxMinAverage方法返回的是字典,因此我们同时打印了 key 和 value)

是时候测试一下了,但我们必须先呼叫这个 jsDemo2() 方法:

override func viewDidAppear(_ animated: Bool) {
    ...
 
    self.jsDemo2()
}

运行 App,我们期望打印出数组的最大、最小和平均值。

但是,我们从 JS 运行时环境得到的上一个丑陋的、非常直白的异常:

JS Exception: TypeError: Math.average is not a function. (In 'Math.average(values)',

t59_6_exception.png

在解决这个有意制造的错误之前,让我们先想一下这样做的意义。试想,如果不能捕捉到 JS 异常,则你根本不可能找出错误真正的所在。为了节省我们的时间,尤其对于大型的复杂的 App 来说,错误并不是我们有意设计的,那么两眼一抹黑地去查找错误真的是一件让人痛苦的事情。

因此,说教完之后,我们该来解决下问题了。在 jssource.js 档案中,修改 code>minMaxAverage 函式为:

function maxMinAverage(values) {
    var max = Math.max.apply(null, values);
    var min = Math.min.apply(null, values);
 
    var average = null;
    if (values.length > 0) {
        var sum = 0;
        for (var i=0; i < values.length; i++) {
            sum += values[i];
        }
 
        average = sum / values.length;
    }
 
    return {
        "max": max,
        "min": min,
        "average": average
    };
}

再次运行 App,我们得到了我们所期望的结果:

t59_7_min_max_avg_output.png

你一定在想,能够在 Xcode 控制台中输出任意类型的 JS 信息就好了,如果它还能像补获异常一样简单就更好了。不幸的是,事情并没有这么简单,这点我们将在后面说明。长话短说,JavaScriptCore 框架并不能提供一种更直接的解决方案,比如提供一个类似 JS 的 console.log() 函式。这一切只能靠依我们自己解决。但在我们准备这样做之前,我们先来学习一点新知识。

从 JavaScript 呼叫 Swift

在前面两部分,我们从 Swift 中呼叫了 JS,虽然我们的例子非常简单,但也清楚地阐述了整个工作流程。我们不得不承认,仅仅是从 Swift 中处理 JS 而不能进行相反方向的处理,就像是一个硬币只有一面一样,因此接下来我们将讨论硬币的另一面,将 Swift 程序代码暴露给 JS。

不管我们要完成什么样的任务,将 Swift 程序代码传递给 JS 运行时的步骤总是一个非常固定的模式。这和我们在前两部分中,通过 Swift 访问 JS 程序代码是一样的。在我们学习如何做之前,先大致了解一下一般流程:

  • 创建一个块(或者“闭包”),这个块将“传递”给 JS 运行时。这些代码将暴露给 JS,我们可以将任意需要被执行的代码写在这个块中。

  • 将块转换成一个 AnyObject 对象。

  • 将这个对象赋给 JSContext,同时为它指定一个名字,以便 JS 引用。

  • 在 JSContext 上计算(evaluate)这个对象。

我们将通过一个“掷骰子”游戏来演示上述步骤。我们通过一个 JS 函式生成 6 个随机数,但这次我们不在 Swift 中呼叫这个函数并获取返回值(JSValue)——这种方式我们前面已经学习过了。相反,我们会创建一个块,当 6 个随机数生成之后,让 JS 运行时调用这个块。

块的书写方式稍有一点特别。你将看到这个块实际上是一个 O-C 块,只不过是以 Swift 的写法来写的,这个块会被传递给 JavaScript 环境。

let luckyNumbersHandler: @convention(block) ([Int]) -> Void = { luckyNumbers in
 
}

重要提示: 注意参数部分,它们表示你需要 JS 程序代码执行后所返回的数据的类型。这里我们希望收到一个整数数组,也就是块的 [Int] 参数,而 luckyNumbers 则是 JS 真正返回时的值,即这个整数数组(我们将在块体中使用这个数组)。说的更清楚一点,参数的数目在圆括号中指明(比如 ([Int])),而参数名字花括号({)之后指明。

例如,假设我们创建一个块,需要提供一个字符串参数和一个字典参数。我们可以这样写:

let something: @convention(block) (String, [String: String]) -> Void = { stringValue, dictionary in
 
}

然后是第二步。创建一个新方法,将上述块转换成一个 AnyObject 对象:

func jsDemo3() {
    let luckyNumbersObject = unsafeBitCast(self.luckyNumbersHandler, to: AnyObject.self)
 
}

然后,将 luckyNumbersObject 传递给 jsContext :

func jsDemo3() {
    let luckyNumbersObject = unsafeBitCast(self.luckyNumbersHandler, to: AnyObject.self)
 
    self.jsContext.setObject(luckyNumbersObject, forKeyedSubscript: "handleLuckyNumbers" as (NSCopying & NSObjectProtocol)!)
 
}

在 forKeyedSubscript 参数中的 handleLuckyNumbers 是作为 JS 运行时使用这个块时的名字。此外 (NSCopying & NSObjectProtocol)! 后缀是必须的。

最后,用 jsContext 对象对我们的程序代码进行计算(evaluate):

func jsDemo3() {
    let luckyNumbersObject = unsafeBitCast(self.luckyNumbersHandler, to: AnyObject.self)
 
    self.jsContext.setObject(luckyNumbersObject, forKeyedSubscript: "handleLuckyNumbers" as (NSCopying & NSObjectProtocol)!)
 
    _ = self.jsContext.evaluateScript("handleLuckyNumbers")
 
}

这 3 行程序代码构成了一个标准步骤,在你自己的程序中使用时你只需要修改下名字而已。这样,我们就把我们的 Swift 程序代码传给了 JS 环境!

回到 jssource.js 档案,添加一个函式:

function generateLuckyNumbers() {
    var luckyNumbers = [];
 
    while (luckyNumbers.length != 6) {
        var randomNumber = Math.floor((Math.random() * 50) + 1);
 
        if (!luckyNumbers.includes(randomNumber)) {
            luckyNumbers.push(randomNumber);
        }
    }
 
    handleLuckyNumbers(luckyNumbers);
}

这个函数创建了 6 个从 1 到 50 之间的随机整数。这 6 个数字被放到了 luckyNumber 数组中。当 while 回圈结束,呼叫 handleLuckyNumbers (我们的 Swift 块),并将 luckyNumbers 作为参数传入块。

回到 BasicsViewController.swift 档案的 jsDemo3() 方法,呼叫上面的 JS 函式。在最后一句添加这几行:

func jsDemo3() {
    ...
 
    if let functionGenerateLuckyNumbers = self.jsContext.objectForKeyedSubscript("generateLuckyNumbers") {
        _ = functionGenerateLuckyNumbers.call(withArguments: nil)
    }
}

在进行测试之前,还需要做点事情。首先,我们必须在 luckyNumbersHandler 块中添加点内容。注意,因为在块之外无法访问块中的内容,最好的选择只能是将 6 个随机数通过通知的方式发送出来(要么,我们只能将这些数字直接返回)。让我们来看一下:

let luckyNumbersHandler: @convention(block) ([Int]) -> Void = { luckyNumbers in
    NotificationCenter.default.post(name: NSNotification.Name("didReceiveRandomNumbers"), object: luckyNumbers)
}

当然,我们必须观察这个通知,请修改 viewDidLoad() 方法为:

override func viewDidLoad() {
    super.viewDidLoad()
 
    NotificationCenter.default.addObserver(self, selector: #selector(BasicsViewController.handleDidReceiveLuckyNumbersNotification(notification:)), name: NSNotification.Name("didReceiveRandomNumbers"), object: nil)
}

我们指定了当收到通知后执行 handleDidReceiveLuckyNumbersNotification(_:) 方法,在实现 这个方法之前,我们先宣告一个数组,用于保存我们所猜的数,以便我们判断它们是否和随机数一样。在类开头添加 (你也可以用任意 6 个幸运数):

var guessedNumbers = [5, 37, 22, 18, 9, 42]

最后,实现处理方法:

func handleDidReceiveLuckyNumbersNotification(notification: Notification) {
    if let luckyNumbers = notification.object as? [Int] {
        print("\n\nLucky numbers:", luckyNumbers, "   Your guess:", guessedNumbers, "\n")
 
        var correctGuesses = 0
        for number in luckyNumbers {
            if let _ = self.guessedNumbers.index(of: number) {
                print("You guessed correctly:", number)
                correctGuesses += 1
            }
        }
 
        print("Total correct guesses:", correctGuesses)
 
        if correctGuesses == 6 {
            print("You are the big winner!!!")
        }
    }
}

上述方法打印了 6 个随机数和你要猜测的 6 个数,以及你猜中了数和猜中数的总计,如果所有数字都猜中了,还会打印一条消息(you are the big winner!)。

现在可以进行测试了,但在此之前,别忘了呼叫 jsDemo3() 方法:

override func viewDidAppear(_ animated: Bool) {
    ...
 
    self.jsDemo3()
}

运行 App 之后你将看到类似如下结果:

t59_8_random_numbers.png

你已经知道如何将 Swift 程序代码传递给 JS 了,这会带给你许多新的能力。其中一个能力就是你可以让 JS 输出内容到 Xcode 的控制台,以模拟 JS 的 console.log() 指令。和你想的一样,我们将创建一个块,以便 JS 想输出某些信息时就可以调用这个块。我们会将它传递给 JS 运行时,以便让它能够正确工作。让我们来看看吧:

首先,要完成这个任务,最基本的部分是块:

private let consoleLog: @convention(block) (String) -> Void = { logMessage in
    print("\nJS Console:", logMessage)
}

找到 initializeJS() 方法,添加下列语句:

func initializeJS() {
    ...
 
    let consoleLogObject = unsafeBitCast(self.consoleLog, to: AnyObject.self)
    self.jsContext.setObject(consoleLogObject, forKeyedSubscript: "consoleLog" as (NSCopying & NSObjectProtocol))
    _ = self.jsContext.evaluateScript("consoleLog")
}

这就完了!要测试这个块,请打开 jssource.js 档案,在 generateLuckyNumbers() 函式中呼叫 handleLuckyNumbers 之前添加这行:

function generateLuckyNumbers() {
    ...
 
    consoleLog(luckyNumbers);
 
    handleLuckyNumbers(luckyNumbers);
}

运行 App,你会看到在其它 Swift 打印的内容之前首先打印了 luckyNumbers 数组。 也就是说我们成功地模拟了 JS 的 console.log() 功能。

t59_9_console_log.png

一个真实的例子

学习了如何让 Swift 和 JS 和平共处以及在同一个 App 中同时用两种语言编写程序代码之后,我们要来看一个更加真实的例子。我们之前看过的所有简单的示例对于学习基本知识是足够了,但为什么不试试在真正的 App 能用它来做些什么呢?

因此,在这个例子里我们将学习如何将一个第三方 JS 库整合到 Swift 项目中,同时我们将应用我们到现在为止学习到的内容。为此,我们将使用 Snowdown 库,它有一个特殊功能:将 Markdown 文本转换成 HTML。你可以看这里的一个在线示例。为了不跑题,我们只进行一个基本的转换,当然你可以进行自己的扩展,使用 Snowdown 的所有选项以获得精准的结果,或者对转换过程进行更好的控制。

这部分内容将在 MDEditorViewController.swift 档案中进行,因此请打开它。开始项目中已经有了部分程序代码,我们可以将注意力集中在重要的地方。我们首先导入 JavaScriptCore 框架:

import JavaScriptCore

然后宣告一个 JSContext 属性:

var jsContext: JSContext!

和前面一样,我们用一个 initializeJS() 方法初始化 jsContext 对象、添加 JS 异常处理块、并计算我们准备使用的 JS 脚本。首先看前两个:

func initializeJS() {
    self.jsContext = JSContext()
 
    // Add an exception handler.
    self.jsContext.exceptionHandler = { context, exception in
        if let exc = exception {
            print("JS Exception:", exc.toString())
        }
    }
 
}

如果你想更有趣一点,也可以观察来自于 JS 的控制面板消息。在上面的方法当中,加入以下内容:

func initializeJS() {
    ...
 
    let consoleLogObject = unsafeBitCast(self.consoleLog, to: AnyObject.self)
    self.jsContext.setObject(consoleLogObject, forKeyedSubscript: "consoleLog" as (NSCopying & NSObjectProtocol))
    _ = self.jsContext.evaluateScript("consoleLog")
 
}

当然,别忘了为 MDEditorViewController 宣告 consoleLog 块:

let consoleLog: @convention(block) (String) -> Void = { logMessage in
    print("\nJS Console:", logMessage)
}

然后来计算(evaluate)我们等会要用到的脚本。我们会用到两个脚本:一个是我们的 jssource.js 档案,一个是 Snowdown 库。前者是一个本地档案,后者则是网络内容。下面我们用 jsContext 来计算这两者(仍然在 initializeJS() 方法中):

func initializeJS() {
    ...
 
    if let jsSourcePath = Bundle.main.path(forResource: "jssource", ofType: "js") {
        do {
            let jsSourceContents = try String(contentsOfFile: jsSourcePath)
            self.jsContext.evaluateScript(jsSourceContents)
 
 
            // Fetch and evaluate the Snowdown script.
            let snowdownScript = try String(contentsOf: URL(string: "https://cdn.rawgit.com/showdownjs/showdown/1.6.3/dist/showdown.min.js")!)
            self.jsContext.evaluateScript(snowdownScript)
        }
        catch {
            print(error.localizedDescription)
        }
    }
 
}

然后,回到 jssource.js 档案,增加一个函式:

function convertMarkdownToHTML(source) {
    var converter = new showdown.Converter();
    var htmlResult = converter.makeHtml(source);
 
    consoleLog(htmlResult);
}

这就将一个 markdown 文本转换成了 HTML。当我们呼叫 makeHTML 函式后, Snowdown 对象负责为我们完成整个转换工作。根据文文件,当我们呼叫这个函数时传入的 source 参数表示原本的 markdown 文本。注意我们也调用了在 Swift 中实现的 consoleLog 函式,把转换的结果输出到控制台中。

这个函数中忘记向我们的 App(Swift)返回转换后的 HTML 程序代码了。因此,我们需要呼叫一个新的函式,如下所示:

function convertMarkdownToHTML(source) {
    ...
 
    handleConvertedMarkdown(htmlResult);
}

handleConvertedMarkdown 函式是 Swift 暴露给 JS 的程序代码,我们还没有实现它。和 convertMarkdownToHTML 函式有关的工作就到此为止了,请保存档案,回到 MDEditorViewController.swift。

接下来我们要在 Swift 中暴露 handleConvertedMarkdown 函式给 JavaScript。首先,在 MDEditorViewController 类中重新宣告一个块:

let markdownToHTMLHandler: @convention(block) (String) -> Void = { htmlOutput in
    NotificationCenter.default.post(name: NSNotification.Name("markdownToHTMLNotification"), object: htmlOutput)
}

待会我们再来处理上面的这个通知。现在,我们需要将这个块转换成对象并将它传入 jsContext 对象并进行计算(evaluate)。这个过程你已经很熟悉了,找到 initializeJS() 方法的最后添加如下内容:

func initializeJS() {
    ...
 
    let htmlResultsHandler = unsafeBitCast(self.markdownToHTMLHandler, to: AnyObject.self)
    self.jsContext.setObject(htmlResultsHandler, forKeyedSubscript: "handleConvertedMarkdown" as (NSCopying & NSObjectProtocol))
    _ = self.jsContext.evaluateScript("handleConvertedMarkdown")
}

很好, handleConvertedMarkdown 已经为 JavaScript 准备好了。现在,让我来呼叫 initializeJS() 方法:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
 
    initializeJS()
}

接下来的任务是呼叫 JavaScript 的 convertMarkdownToHTML 函式,因此我们需要在 MDEditorViewController 类中新建一个方法, 如下所示:

func convertMarkdownToHTML() {
    if let functionConvertMarkdownToHTML = self.jsContext.objectForKeyedSubscript("convertMarkdownToHTML") {
        _ = functionConvertMarkdownToHTML.call(withArguments: [self.tvEditor.text!])
    }
}

记住 convertMarkdownToHTML 函式需要一个 markdown 文本作为参数,因此我们在呼叫它时提供了一个。写完这个方法,找到 convert(_:) IBAction 方法,对这个方法进行呼叫:

@IBAction func convert(_ sender: Any) {
    self.convertMarkdownToHTML()
}

当工具条中的 Convert 按钮被按下时,会触发 HTML 的转换。

搞定了吗?还没有。我们还需要对通知进行处理,也就是在转换完成时发送的那个通知。在 viewDidLoad() 添加一个观察者:

override func viewDidLoad() {
    super.viewDidLoad()
 33
    NotificationCenter.default.addObserver(self, selector: #selector(MDEditorViewController.handleMarkdownToHTMLNotification(notification:)), name: NSNotification.Name("markdownToHTMLNotification"), object: nil)
}

然后,实现 handleMarkdownToHTMLNotification(_:) 方法。在这个方法里,当我们收到通知,就将 HTML 加载到 WebView 中:

func handleMarkdownToHTMLNotification(notification: Notification) {
    if let html = notification.object as? String {
        let newContent = "body { background-color: cyan; } \(html)"
        self.webResults.loadHTMLString(newContent, baseURL: nil)
    }
}

这部分的工作就到此为止了。运行 App,找到 markdown 编辑器。用 markdown 语法编写一段文本,点击 Convert 按钮,你会看到转换后的 HTML 结果。我建议你在 iPad 或 iPad 仿真器时进行测试。

t59_1_markdown_to_html (1).png

自定义类和 JavaScript

前面,我们学习了如何暴露 Swift 程序代码给 JS,但 JavaScriptCore 的功能并不仅限于此。它还提供一种暴露自定义类的机制,并直接在 JS 中使用这些类的属性和函式。这就是 JSExport,它是一个协议,通过它你能够以更强大的方式来沟通 Swift 和 JS。

为了演示 JSExport 协议是如何工作和使用的,我们创建了一个自定义类 DeviceInfo。这个类用于表示一个 iOS 设备 (主要是 iPhone) ,它拥有以下属性:

  • Model name

  • Initial OS

  • Latest OS

  • Image URL

为了演示,在开始项目中有一个 iPhone_List 档案。这是一个早期发布的 csv 档案。 原始数据请参考维基百科。

我们的最终目的是传递 DeviceInfo 类及 iPhone_List.csv 中的数据到 JavaScript 运行时,然后读取 DeviceInfo 对象数组。当数据解析完成,我们读取数据并在一个 tableview 中进行显示( 我们会使用图片 URL 从网络下载 iPhone 图片)。

最后一个重要提示:我们将使用一个第三方 JS 库去剖析 cvs 档案并读取其中的数据。这个库叫做 Papa Parse,它已经包含在开始项目中了(papaparse.min.js)。你可以在这里找到它的详细介绍和文档。它是一个非常实用和强大的工具,将我们从剖析 csv 档案的工作中解脱出来。

首先,打开 DeviceInfo.swift 档案。我们将实现 DeviceInfo 类和必要的协议,这些协议使得我们能够在 JS 中使用这个类。现在,这个类还没有实现,因此请编写如下内容:

class DeviceInfo: NSObject {
    var model: String!
    var initialOS: String!
    var latestOS: String!
    var imageURL: String!
 
    init(withModel model: String) {
        super.init()
 
        self.model = model
    }
 
    class func initializeDevice(withModel: String) -> DeviceInfo {
        return DeviceInfo(withModel: withModel)
    }
 
    func concatOS() -> String {
        if let initial = initialOS {
            if let latest = latestOS {
                return initial + " - " + latest
            }
        }
 
        return ""
    }    
}

首先我们宣告了之前说到的用于描述设备的 4 个属性。然后是一个自定义初始化方法 (init(withModel:)),这个方法的参数用来指定模块名称。然后,虫咬的部分来了:

类方法 initializeDevice(withModel:) 用于在 JS 中初始化这个类,这是因为:JavaScriptCore 框架无法在 Swift 和 JavaScript 之间桥接初始化方法。也就是说,init(withModel:) 初始化方法无法被 JS 所识别,因此不得不用 initializeDevice(withModel:) 方法来实例化一个新的 DeviceInfo 实例并返回这个对象。

最后,是将最早操作系统版本、最晚操作系统版本组合成一个字符串的方法。如果这两者有任何一个为空,返回一个空字符串。

下一个重要步骤是继承 JSExport 协议,指定所有我们需要暴露给 JS 环境的方法和属性。这个协议如下所示,请将它放在类定义之前:

@objc protocol DeviceInfoJSExport: JSExport {
    var model: String! {get set}
    var initialOS: String! {get set}
    var latestOS: String! {get set}
    var imageURL: String! {get set}
 
    static func initializeDevice(withModel: String) -> DeviceInfo
}

如你所见,我们暴露了 4 个属性,但方法却将 concatOS() 去掉了。这个方法在 JS 中根本用不到,因此这里也就不用宣告了。但是,initializeDevice(withModel:) 类方法是必须的,因此我们将这个方法包含到了协议中。

现在来修改 DeviceInfo 类的头部,让它实现 DeviceInfoJSExport 协议:

class DeviceInfo: NSObject, DeviceInfoJSExport {
   ...
}

现在我们拥有了一个全新的类和 JSExport 协议扩展,让我们来看看如何使用它们。回到 DevicesViewController.swift,在 IBOutlet 属性之后宣告一个 JSContext 对象:

var jsContext: JSContext!

然后宣告一个数组:

var deviceInfo: [DeviceInfo]!

这个数组将作为 tableview 的数据源,当 JS 脚本将 iPhone_List.csv 剖析完成tableview 会将 DeviceInfo 对象显示出来。

还记得我们之前写的 initializeJS() 方法吗? 在这里我们也有这样一个方法,这和之前没有什么两样。除了初始化 jsContext 对象、加载 jssource.js 档案内容,我们还需要在这个方法中完成如下工作:

  • 加载并计算(evaluate)papaparse.min.js 脚本。

  • 通过 jsContext 对象,将 DeviceInfo 类传入 JS 运行时。

initializeJS() 方法用以下程序代码完成上述工作:

func initializeJS() {
    self.jsContext = JSContext()
 
    // Add an exception handler.
    self.jsContext.exceptionHandler = { context, exception in
        if let exc = exception {
            print("JS Exception:", exc.toString())
        }
    }
 
    // Load the PapaParse library.
    if let papaParsePath = Bundle.main.path(forResource: "papaparse.min", ofType: "js") {
        do {
            let papaParseContents = try String(contentsOfFile: papaParsePath)
            self.jsContext.evaluateScript(papaParseContents)
        }
        catch {
            print(error.localizedDescription)
        }
    }
 
    // Load the Javascript source code from the jssource.js file.
    if let jsSourcePath = Bundle.main.path(forResource: "jssource", ofType: "js") {
        do {
            let jsSourceContents = try String(contentsOfFile: jsSourcePath)
            self.jsContext.evaluateScript(jsSourceContents)
        }
        catch {
            print(error.localizedDescription)
        }
    }
 
    // Set the DeviceInfo class to the JSContext.
    self.jsContext.setObject(DeviceInfo.self, forKeyedSubscript: "DeviceInfo" as (NSCopying & NSObjectProtocol)!)
 
}

先离开一小会,回到 jssource.js。我们要添加一个新函式用于处理原始数据(即 iPhone_List.csv 档案),当它处理完成,返回一个 DeviceInfo 对象阵列,如果发生错误则返回 null,没有任何数据返回。这个函式实现如下:

function parseiPhoneList(originalData) {
    var results = Papa.parse(originalData, { header: true });
    if (results.data) {
        var deviceData = [];
 
       for (var i=0; i < results.data.length; i++) {
        var model = results.data[i]["Model"];
 
        var deviceInfo = DeviceInfo.initializeDeviceWithModel(model);
 
        deviceInfo.initialOS = results.data[i]["Initial OS"];
        deviceInfo.latestOS = results.data[i]["Latest OS"];
        deviceInfo.imageURL = results.data[i]["Image URL"];
 
        deviceData.push(deviceInfo);
    }
 
    return deviceData;
}
 
return null;
}

强烈建议你阅读一下 Para Parse 的文档中关于需要提供的参数和返回值。第一句中,我们进行了 csv 档案的剖析:

Papa.parse(originalData, { header: true })

参数 { header: true } 表明 csv 档案中的第一行是表头,表头在剖析时会自动变成返回结果中的 key。当剖析完成,results 变量中会保存有 3 个数组:

{
    data:   // array of parsed data
    errors: // array of errors
    meta:   // object with extra info
}

注意: 以上程序代码来自于 Papa Parse 文檔。

我们关注的是 data 数组,为了简单起见,我们忽略了其他两个数组。然后,我们来继续讨论 parseiPhoneList 函式。检查 data 数组是否为空是很重要的(比如发生了某个错误,或者因为 csv 文件格式不对)。如果 csv 剖析后 data 中有值,我们会初始化一个 deviceData 数组。这个数组最终会返回给 Swift,但在此之前我们必须在其中填充必要的数据。在回圈中,我们遍历 data 数组,将 data 数组中的每个对象转成一个 DeviceInfo 对象。现在需要注意的是两个地方:

  • 剖析后的 data 数组是一个由字典构成的数组,我们使用这种方式来访问每个字典的属性:results.data[i]["PROPERTY_NAME"]。

  • JavaScript 不支持函式中使用命名参数,因此 initializeDevice(withModel:) 转换成 initializeDeviceWithModel()。每个参数名会以大驼峰命名法追加到方法名后面(例如,withModel 转换成 WithModel 追加到函式名后面)

针对每种设备,模块名在使用初始化方法创建对象的时候传入,其他属性值则通过赋值方式传入。此外,真正神奇的地方是,仅仅用一个回圈就实现了我们的目的。initializeDeviceWithModel 函式创建并返回一个 DeviceInfo 对象,用一个 deviceInfo 变量保存这个对象。我们访问这个对象的属性,对属性进行赋值,这和在 Swift 中一样简单,更重要的是, 我们使用的结构和属性都是在 Swift 中创建的,同时还使用了 JS 中的某个工具所提供的功能。很酷吧?!

回到 DevicesViewController.swift ,我们该使用这个刚创建的函式了。为此,我们创建了一个新方法,叫做 parseDeviceData()。用这个方法完成这 4 个工作:

  • 将 iPhone_List.csv 文档加载到一个字符串中,这样我们可以将字符串传递给 JS 函式。

  • 通过 jsContext 访问 parseiPhoneList 函式,用第一步中创建的字符串呼叫这个函式。

  • 将返回值复制到 deviceInfo 数组。

  • 刷新 tableview ,让剖析后端数据显示在 tablview 中。

这个方法的实现如下:

func parseDeviceData() {
    if let path = Bundle.main.path(forResource: "iPhone_List", ofType: "csv") {
        do {
            let contents = try String(contentsOfFile: path)
 
            if let functionParseiPhoneList = self.jsContext.objectForKeyedSubscript("parseiPhoneList") {
                if let parsedDeviceData = functionParseiPhoneList.call(withArguments: [contents]).toArray() as? [DeviceInfo] {
                    self.deviceInfo = parsedDeviceData
                    self.tblDeviceList.reloadData()
                }
            }
 
        }
        catch {
            print(error.localizedDescription)
        }
    }
}

现在来实现 viewDidAppear(_:) 方法,呼叫这个方法和 initializeJS() 方法:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
 
    initializeJS()
    parseDeviceData()
}

最后还剩几个步骤,我们就可以大功告成了。首先,指定 tableview 要显示的行数:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return (self.deviceInfo != nil) ? self.deviceInfo.count : 0
}

然后,访问 deviceInfo 数组中的 DeviceInfo 对象,并在 tableview 的 cell 显示每种设备的细节:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "idDeviceCell") as! DeviceCell
 
    let currentDevice = self.deviceInfo[indexPath.row]
 
    cell.textLabel?.text = currentDevice.model
    cell.detailTextLabel?.text = currentDevice.concatOS()
 
    (URLSession(configuration: URLSessionConfiguration.default)).dataTask(with: URL(string: currentDevice.imageURL)!, completionHandler: { (imageData, response, error) in
        if let data = imageData {
            DispatchQueue.main.async {
                cell.imageView?.image = UIImage(data: data)
                cell.layoutSubviews()
            }
        }
    }).resume()
 
    return cell
}

除了看看是如何访问每个 DeviceInfo 对象的属性以外,我们还需要注意两件事情:我们通过 concatOS() 方法获取每种设备的由最低 iOS 版本和最高 iOS 版本组合起来的字符串,以及如何通过 imageURL 属性实时下载设备图片。

万事俱备了,让我们运行 App,点击 iPhone Devices List 按钮,csv 档案中的数据将显示在 tableview 中:

t59_2_iphone_devices (1).png

总结

在本文的前半部分演示了通过 JavaScriptCore 框架,我们能够做些什么。我们能够轻松地在两种不同的语言之间交换和使用程序代码,这种轻松所带来的巨大好处,也许让你觉得是不是该在你的下一个项目中真正使用 JavaScript 来编写。尽管 JavaScriptCore 的功能十分强大,但仍然有一点限制防止你无限制地使用它。这个限制就是我们不能用 JavaScriptCore 框架从 JS 发送 HTTP 请求,因为根本无法做到(非常不幸)。这里的做法是将所有的 web 请求放到 Swift 中进行,然后将收到的数据传给 JS 环境处理。然后,这个方法只能用在自己的 JS 程序代码中,而不能用在第三方库中,这些库要么我们不想、要么无法修改(比如库的压缩版本,就像我们使用的 papaparse.min.js)?

说完这个遗憾的结局之后,我希望你喜欢这篇教程,并真正体会到在同一项目中使用 Swift 和 JS 的好处。如果你根本不关心 web 请求,或者拥有能够替代的 JS 工具,或者对所用的语言非常熟悉,则不用犹疑不决。JavaScriptCore 框架绝对是让你通向巅峰的道路。祝你编写脚本愉快!

作为参考,你可以从 github 下载这个 Xcode 项目

搜索CocoaChina微信公众号:CocoaChina
微信扫一扫
订阅每日移动开发及APP推广热点资讯
公众号:
CocoaChina
我要投稿   收藏文章
上一篇:一篇文章帮你彻底了解 Swift 3.1 的新内容
下一篇:如何使用 CATransform3D 处理 3D 影像、制做互动立体旋转的效果 ?
我来说两句
发表评论
您还没有登录!请登录注册
所有评论(0

综合评论

相关帖子

sina weixin mail 回到顶部