Swift 程式語言

在 Swift 專案中使用 Javascript:編寫一個將 Markdown 轉為 HTML 的編輯器

在 Swift 專案中使用 Javascript:編寫一個將 Markdown 轉為 HTML 的編輯器
在 Swift 專案中使用 Javascript:編寫一個將 Markdown 轉為 HTML 的編輯器
In: Swift 程式語言

一直想寫一篇文章,關於如何將 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 中。用一個按鈕來觸發轉換動作,並調用我們的程式碼。看下圖:

Markdown to HTML

在第三部分和最後一部分,我們將演示如何傳遞帶屬性和方法的自定義類給 JavaScript Context。此外,我們還會在 JavaScript 中按照這個類的定義來創建一個對象並對其屬性進行賦值。我們最終會顯示一個 iPhone 從面世以來的設備類型列表(model 名),以及它們的最早和最晚的 OS 版本,以及它們的圖片。數據保存在一個 csv 檔案中,我們將用一個第三方庫進行解析。要獲得解析後的數據,我們將在 JavaScript 中使用我們的自定義 Swift 類,用這個類來渲染自定義對象的數據,然後將結果返回給 Swift。我們會用一個 TableView 來顯示這個列表。如下圖所示:

t59_2_iphone_devices

The above describe in general the three distinct tasks that will let us get to know the JavaScriptCore framework. As there are a lot of things wrapped up together in the package of one, we’ll have an initial menu screen that we’ll use to navigate to the proper part of the project:

t59_3_menu

為便於給你偷懶,我們提供了一個開始專案,你可以在這裡下載。當你下載完後,你就可以開始你的 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 個任務:

  1. 對我們早先宣告的 jsContext 屬性進行初始化。
  2. 加載 jssource.js 檔案,將檔案內容傳給 JavaScript 運行時,這樣它才能訪問檔案中編寫的程式碼。

BasicsViewController 中新建一個方法,初始化 jsContext 變數。方法非常簡單:

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

}

上面的第二條任務分成幾個步驟,但也非常簡單。我們先來看看一下源碼,然後在來進行討論:

func initializeJS() {
    ...

    // 指定 jssource.js 檔案路徑
    if let jsSourcePath = Bundle.main.path(forResource: "jssource", ofType: "js") {
        do {
            // 將檔案內容加載到 String 
            let jsSourceContents = try String(contentsOfFile: jsSourcePath)

            // 通過 jsContext 對象,將 jsSourceContents 中包含的腳本添加到 Javascript 運行時
            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

在混合使用 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

這一點也不有趣,但你要明白你所看到的是在 Swift 中呼叫 JS 函式所得到的結果。同時,我們通過這部分內容可以總結出這樣一個固定流程:

  1. 構建一個 JSContext 對象。
  2. 裝載 JavaScript 程式碼,計算(evaluate)它的值 (或者說將它傳遞給 JavaScript 環境)。
  3. 通過 JSContextobjectForKeyedSubscript(_:) 方法訪問 JS 函式。
  4. 呼叫 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)', 'Math.average' is undefined)

t59_6_exception

在解決這個有意製造的錯誤之前,讓我們先想一下這樣做的意義。試想,如果不能捕捉到 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

你一定在想,能夠在 Xcode 控制台中輸出任意類型的 JS 信息就好了,如果它還能像補獲異常一樣簡單就更好了。不幸的是,事情並沒有這麼簡單,這點我们将在後面說明。長話短說,JavaScriptCore 框架並不能提供一種更直接的解決方案,比如提供一個類似 JS 的 console.log() 函式。這一切只能靠依我們自己解決。但在我們準備這樣做之前,我們先來學習一點新知識。

從 JavaScript 呼叫 Swift

在前面兩部分,我們從 Swift 中呼叫了 JS,雖然我們的例子非常簡單,但也清楚地闡述了整個工作流程。我們不得不承認,僅僅是從 Swift 中處理 JS 而不能進行相反方向的處理,就像是一個硬幣只有一面一樣,因此接下來我們將討論硬幣的另一面,將 Swift 程式碼暴露給 JS。

不管我們要完成什麼樣的任務,將 Swift 程式碼傳遞給 JS 運行時的步驟總是一個非常固定的模式。這和我們在前兩部分中,通過 Swift 訪問 JS 程式碼是一樣的。在我們學習如何做之前,先大致了解一下一般流程:

  1. 創建一個塊(或者“閉包”),這個塊將“傳遞”給 JS 運行時。這些代碼將暴露給 JS,我們可以將任意需要被執行的代碼寫在這個塊中。
  2. 將塊轉換成一個 AnyObject 對象。
  3. 將這個對象賦給 JSContext,同時為它指定一個名字,以便 JS 引用。
  4. 在 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

你已經知道如何將 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

一個真實的例子

學習了如何讓 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()

    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 = "\(html)"
        self.webResults.loadHTMLString(newContent, baseURL: nil)
    }
}

這部分的工作就到此為止了。運行 App,找到 markdown 編輯器。用 markdown 語法編寫一段文本,點擊 Convert 按鈕,你會看到轉換後的 HTML 結果。我建議你在 iPad 或 iPad 模擬器時進行測試。

Markdown to HTML

自定義類和 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 檔案內容,我們還需要在這個方法中完成如下工作:

  1. 加載並計算(evaluate)papaparse.min.js 腳本。
  2. 通過 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 對象。現在需要注意的是兩個地方:

  1. 剖析後的 data 陣列是一個由字典構成的陣列,我們使用這種方式來訪問每個字典的屬性:results.data[i]["PROPERTY_NAME"]
  2. JavaScript 不支持函式中使用命名參數,因此 initializeDevice(withModel:) 轉換成 initializeDeviceWithModel()。每個參數名會以大駝峰命名法追加到方法名後面(例如,withModel 轉換成 WithModel 追加到函式名後面)

針對每種設備,模塊名在使用初始化方法創建對象的時候傳入,其他屬性值則通過賦值方式傳入。此外,真正神奇的地方是,僅僅用一個廻圈就實現了我們的目的。initializeDeviceWithModel 函式創建並返回一個 DeviceInfo 對象,用一個 deviceInfo 變量保存這個對象。我們訪問這個對象的屬性,對屬性進行賦值,這和在 Swift 中一樣簡單,更重要的是, 我們使用的結構和屬性都是在 Swift 中創建的,同時還使用了 JS 中的某個工具所提供的功能。很酷吧?!

回到 DevicesViewController.swift ,我們該使用這個剛創建的函式了。為此,我們創建了一個新方法,叫做 parseDeviceData()。用這個方法完成這 4 個工作:

  1. 將 iPhone_List.csv 文檔加載到一個字符串中,這樣我們可以將字符串傳遞給 JS 函式。
  2. 通過 jsContext 訪問 parseiPhoneList 函式,用第一步中創建的字符串呼叫這個函式。
  3. 將返回值複製到 deviceInfo 數組。
  4. 刷新 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

總結

在本文的前半部分演示了通過 JavaScriptCore 框架,我們能夠做些什麼。我們能夠輕鬆地在兩種不同的語言之間交換和使用程式碼,這種輕鬆所帶來的巨大好處,也許讓你覺得是不是該在你的下一個專案中真正使用 JavaScript 來編寫。儘管 JavaScriptCore 的功能十分強大,但仍然有一點限制防止你無限制地使用它。這個限制就是我們不能用 JavaScriptCore 框架從 JS 發送 HTTP 請求,因為根本無法做到(非常不幸)。這裡的做法是將所有的 web 請求放到 Swift 中進行,然後將收到的數據傳給 JS 環境處理。然後,這個方法只能用在自己的 JS 程式碼中,而不能用在第三方庫中,這些庫要麼我們不想、要麼無法修改(比如庫的壓縮版本,就像我們使用的 papaparse.min.js)?

說完這個遺憾的結局之後,我希望你喜歡這篇教程,並真正體會到在同一專案中使用 Swift 和 JS 的好處。如果你根本不關心 web 請求,或者擁有能夠替代的 JS 工具,或者對所用的語言非常熟悉,則不用猶疑不決。JavaScriptCore 框架絕對是讓你通向巔峰的道路。祝你編寫腳本愉快!

作為參考,你可以從 github 下載這個 Xcode 專案

譯者簡介:楊宏焱,CSDN 博客專家(個人博客 http://blog.csdn.net/kmyhy)。2009 年開始學習蘋果 iOS 開發,精通 O-C/Swift 和 Cocoa Touch 框架,開發有多個商店應用和企業 App。熱愛寫作,著有和翻譯有多本技術專著,包括:《企業級 iOS 應用實戰》、《iPhone & iPad 企業移動應用開發秘笈》、《iOS8 Swift 編程指南》,《寫給大忙人看的 Swift》、《iOS Swift game Development cookbook》等

原文Using JavaScript in Swift Projects: Building a Markdown to HTML Editor

作者
Gabriel Theodoropoulos
資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。
評論
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。