分析一次有意思的需求——HTML程式碼注入

chouheiwa發表於2019-02-22

有個朋友問了我一個問題:

他們通過WKWebView,訪問了一個其他的頁面,然後希望原生獲得使用者的輸入資訊。

其實,我之前接觸WKWebView並不多,但是這個問題我覺得很有意思。這篇文章便是我解決這個問題的全部思路,與最終的解決辦法。

思路分析 與 程式碼實踐

這個問題其實很具象了,就是希望原生獲得H5的使用者輸入內容(這樣子感覺有些不地道-_-)

接下來我們就需要分析這個需求了。

首先我們先需要抓住兩個點,1個是H5,1個是原生。

所以這個問題現在被我拆分出了1個額外的問題

HTML能否和原生互動?如何進行?

這個問題其實是一個很關鍵的問題,因為我們只有實現了原生和HTML的互動,才能獲得相關資訊(這裡我們假定網頁是我們自己寫的,完全受操縱於我們自己)

於是便搜尋資料,發現WKWebView提供了一個很方便的互動渠道WKScriptMessageHandler,我們通過對WKWebView進行相關的定製操作便可以解決。

我們先建立一個工程,這裡我把整個工程命名為InjectHTML

為了防止迴圈引用,我們先構建一箇中間層ScriptHandler

import Foundation
import WebKit
// 這裡我們使用一箇中間層來解除迴圈WebView和Controller間的迴圈引用問題
class ScriptHandler: NSObject, WKScriptMessageHandler {

    weak var delegate: WKScriptMessageHandler?

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        delegate?.userContentController(userContentController, didReceive: message)
    }

    init(delegate: WKScriptMessageHandler? = nil) {
        self.delegate = delegate
    }
}
複製程式碼

在ViewController中的程式碼

import UIKit
import WebKit
class ViewController: UIViewController {
    var webview: WKWebView!

    static let scriptKey = "InjectHTML"

    override func viewDidLoad() {
        super.viewDidLoad()
        //初始化 Configuration
        let configuration = WKWebViewConfiguration()
        configuration.userContentController = WKUserContentController()
        // 給Configuration 增加一個js script處理器
        // 採用了中間層的因素,避免迴圈引用導致無法釋放問題
        configuration.userContentController.add(ScriptHandler(delegate: self), name: ViewController.scriptKey)

        webview = WKWebView(frame: view.bounds, configuration: configuration)

        view.addSubview(webview)
        // 設定導航處理器
        webview.navigationDelegate = self
        // 我們先從本地讀網頁方便自我改動測試
        let fileURL = Bundle.main.url(forResource: "index", withExtension: "html")
        // 載入網頁
        webview.loadFileURL(fileURL!, allowingReadAccessTo: fileURL!)
    }
}

extension ViewController: WKScriptMessageHandler {
    // 遵守協議
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        // 因html可能傳遞多種型別名稱,我們這裡須指定key
        if message.name == ViewController.scriptKey {
            guard let dic = message.body as? [String: String] else {
                return
            }
            // 交給真實處理解析函式
            receiveInputValue(para: dic)
        }
    }
    // 解析函式可以負責更具體的內容,因為demo,故此只是列印
    func receiveInputValue(para: [String: String]) {
        let title = para["title"] ?? "無值"

        let message = para["message"] ?? "無值"

        let `id` = para["id"] ?? "無值"

        print("title: \(title)")
        print("message: \(message)")
        print("id: \(id)")
    }
}
// 遵循導航協議,方便我們知道何時網頁載入完成
extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {

    }
}
複製程式碼

這樣子我們的ViewController 就構建完成,訪問了一個本地index.html的網頁。

我們在專案中新建一個html檔案(從外界拖入也可)

html頁面只用於測試,因此我們也就不做類似自適應之類的css樣式了

html程式碼如下:

<html>
    <head>
        <title>注入測試網頁</title>
    </head>
    <body bgcolor="#FFFFFF">
        <h1>Test for inject js</h1>
        <!--新增一個button按鈕用以測試點選事件-->
        <button onclick="onClickTest()">Test Button</button>
    </body>
    <script type="text/javascript">
        function onClickTest() {
            // 注意,這裡是需要注意的是我們在ViewController中定義的Script Name需要作為messageHandler的一個屬性
            window.webkit.messageHandlers.InjectHTML.postMessage({title: 'test title', message:'test message', id: 'test id'})
            // 若我們未註冊此名稱,則無法觸發對應回撥,postMessage中的引數可傳為任意的,但我們在原生中定義為字典了,則我們在這裡需要傳入字典
            window.webkit.messageHandlers.InjectHTMLS.postMessage({title: 'test title', message:'test message', id: 'test id'})
        }
    </script>
</html>
複製程式碼

其中對應部分均已加上註釋,接下來我們來跑一遍測試結果:

title: test title

message: test message

id: test id

當我們點選WebView上的按鈕時候,我們可以列印出來對應的js端返回結果,說明成功了。

所以,這個需求我們可以說解析了3分之1了。


接下來我們需要繼續分析了,這個需求需要我們能監控所有的輸入控制元件。

所以我們需要從網頁端剖析了,問題迴歸HTML端。

我們繼續拆解問題,我們既然要監控所有的輸入控制元件,那麼我們首先就得知道,我們能否獲得所有的輸入控制元件(甚至說,我們能否獲得頁面的所有控制元件,然後進行遍歷過濾也可以)

HTML如何獲取指定元素

這個問題,通過搜尋與詢問前端朋友得知:

HTML提供了對應的Api,直接獲取指定標籤的內容,因為我們是要獲得輸入框,輸入框在HTML中的標籤是input。所以我們獲得頁面中所有輸入框元素的方法就已經出來了

// 獲得所有input陣列
var inputs = document.getElementsByTagName("input")
複製程式碼

那麼問題就迎刃而解了,然後我們還是需要測試一下,畢竟萬一這個方法不好用怎麼辦

這裡就不在手機端進行測試,因為如果模擬器還是比較費事的,並且我們如果想列印console日誌的話,看起來也不那麼容易,因此我們將接下來的測試網頁步驟,直接挪到Google Chrome上。

這裡還是給出對應的html測試程式碼

<html>
    <head>
        <title>注入測試網頁</title>
    </head>
    <body bgcolor="#FFFFFF">
        <h1>Test for inject js</h1>
        <!--新增一個button按鈕用以測試點選事件-->
        <button onclick="onClickTest()">Test Button</button>

        <input type="text" placeholder="Test Input1">
        <input type="text" placeholder="Test Input2">
    </body>
    <script type="text/javascript">
        // 獲得所有input陣列
        var inputs = document.getElementsByTagName("input");
        // 列印input陣列
        console.log(inputs)
    </script>
</html>
複製程式碼

然後使用瀏覽器(下文瀏覽器均為Google Chrome)開啟。

我們按Command+Shift+c即可開啟頁面審查和網頁控制檯,在這裡我們看見了我們執行網頁的log,列印結果如下

log列印

說明我們獲取input成功了

接下來我們只需要進行過濾即可獲得我們需要的頁面元素了(如過濾checkbox之類的)

然後接下來新的問題來了

JS程式碼如何動態新增事件

我們已經通過js程式碼獲得input了,所以問題就變到了如何新增點選事件。畢竟原生的輸入框還有至少監聽輸入事件,或者監聽輸入完成類的,那麼JS端理應也存在。

這裡感謝菜鳥教程,查詢到了一個函式addEventListener

這個函式的功能就是給HTTP的DOM元素增加對應的事件,也就是說我們可以通過這個方法額外的增加點選事件。

同時,新增加的事件並不會覆蓋原有事件,一個元素可以擁有多個同樣的事件(如一個按鈕可以同時出發兩個onClick事件)

這個函式給了我們新的天地啊。因此我們可以給input增加事件,這裡我選擇了兩個事件,一個是input事件(輸入框文字發生變化),一個是change事件(輸入框失去焦點)

我們修改上文的html程式碼,修改結果如下:

<html>
    <head>
        <title>注入測試網頁</title>
    </head>
    <body bgcolor="#FFFFFF">
        <h1>Test for inject js</h1>
        <!--新增一個button按鈕用以測試點選事件-->
        <button onclick="onClickTest()">Test Button</button>
        <!--這裡給input增加一些回撥-->
        <input type="text" placeholder="Test Input1" onchange="onChange()" oninput="onInput()">
        <input type="text" placeholder="Test Input2" onchange="onChange()" oninput="onInput()">
    </body>
    <script type="text/javascript">
        // 這裡是為了測試原有對應事件是否會被覆蓋
        function onChange(input) {
            console.log("原有 失去焦點");
            console.log(input);
        }

        // 這裡是為了測試原有對應事件是否會被覆蓋
        function onInput(input) {
            console.log("原有 鍵盤輸入");
            console.log(input);
        }
    </script>
    <script type="text/javascript">
        function demoOnchange(input) {
            console.log("失去焦點");
            console.log(input);
        }
        function demoOnInput(input) {
            console.log("鍵盤輸入");
            console.log(input);
        }
        function demoSet() {
            var inputs=document.getElementsByTagName("input");
            for(var i=0;i < inputs.length;i++) {
                var input = inputs[i];
                // 這裡我們增加一些過濾條件,因為我們有時並不需要所有的input,這裡我只是允許了text(文字輸入框)
                if(input.type=="text") {
                    input.addEventListener('change', demoOnchange);
                    input.addEventListener('input', demoOnInput)
                }
            }
        }
        demoSet();
    </script>
</html>
複製程式碼

繼續進入瀏覽器測試網址,我們可以看到,當我們輸入的時候,同時觸發了兩個回撥,說明後期注入有效

接下來我們需要繼續考慮,我們需要能讓原生接收到對應事件啊

原生如何收到對應事件

我們在上文中已經知道了js如何呼叫原生,那麼這一步也就我們也就可以直接通過結合便可以實現了。

這一步還是更改html程式碼

<html>
    <head>
        <!--HTML頁面內容需告知為utf8,否則會出現亂碼問題-->
        <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
        <title>注入測試網頁</title>
    </head>
    <body bgcolor="#FFFFFF">
        <h1>Test for inject js</h1>
        <!--新增一個button按鈕用以測試點選事件-->
        <button onclick="onClickTest()">Test Button</button>
        <!--這裡給input增加一些回撥-->
        <input type="text" placeholder="Test Input1" onchange="onChange()" oninput="onInput()">
        <input type="text" placeholder="Test Input2" onchange="onChange()" oninput="onInput()">
    </body>
    <script type="text/javascript">
        // 這裡是為了測試原有對應事件是否會被覆蓋
        function onChange(input) {
            console.log("原有 失去焦點");
            console.log(input);
        }

        // 這裡是為了測試原有對應事件是否會被覆蓋
        function onInput(input) {
            console.log("原有 鍵盤輸入");
            console.log(input);
        }
    </script>
    <script type="text/javascript">
        function demoOnchange(input) {
            // 這裡,input是一個點選事件,target才是真正的input元素,我們可以通過此任意獲取input的相關資訊,如class,id以及其他的一些資訊等等
            window.webkit.messageHandlers.InjectHTML.postMessage({title: '輸入框失去焦點', message:input.target.value, id: input.target.id});
        }
        function demoOnInput(input) {
            window.webkit.messageHandlers.InjectHTML.postMessage({title: "輸入框正在輸入", message:input.target.value, id: input.target.id});
        }
        function demoSet() {
            var inputs=document.getElementsByTagName("input");
            for(var i=0;i < inputs.length;i++) {
                var input = inputs[i];
                // 這裡我們增加一些過濾條件,因為我們有時並不需要所有的input,這裡我只是允許了text(文字輸入框)
                if(input.type=="text") {
                    input.addEventListener('change', demoOnchange);
                    input.addEventListener('input', demoOnInput)
                }
            }
        }
        demoSet();
    </script>
</html>
複製程式碼

重新啟動剛才的專案App,在輸入框中輸入,可以看到控制檯中返回了資料

title: 輸入框正在輸入

message: 1

id:

title: 輸入框失去焦點

message: 1

id:

我們的列印出來了。到這裡我們自有網頁的測試全部通過,那麼就剩下最後一步了,如何在三方網頁上執行?

JS程式碼注入

WKWebView既然能讓js呼叫OC,那麼OC能否呼叫js程式碼呢?

答案是可以的,WKWebView提供給我們原生的方法可以動態的執行js程式碼

webview.evaluateJavaScript(someTest, completionHandler: closure)
複製程式碼

這個方法可以讓我們動態的執行由原生生成的js程式碼。

那麼我們需要執行什麼方法呢?

自然就是我們上述研究出來的獲取HTML的元素並增加事件回撥的事情啊。

這裡我們在專案中新建一個js檔案,方便我們以後修改js程式碼,名稱就叫Inject.js

從專案中的html檔案中把如下方法複製進來

function demoOnchange(input) {
    window.webkit.messageHandlers.InjectHTML.postMessage({title: "輸入框失去焦點", message:input.target.value, id: input.target.id});
}
function demoOnInput(input) {
    window.webkit.messageHandlers.InjectHTML.postMessage({title: "輸入框正在輸入", message:input.target.value, id: input.target.id});
}
function demoSet() {
    var inputs=document.getElementsByTagName("input");
    for(var i=0;i < inputs.length;i++) {
        var input = inputs[i];
                
        if(input.type=="text") {
            input.addEventListener('change', demoOnchange);
            input.addEventListener('input', demoOnInput)
        }
    }
}
demoSet();
複製程式碼

然後我們決定採用直接從專案中讀取檔案的形式將js檔案轉成字串,我們在ViewController中的

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!)

函式中加入js程式碼注入,使我們每次載入成功網頁都注入對應的js程式碼

完整函式如下:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    guard let jsString = try? String(contentsOfFile: Bundle.main.path(forResource: "Inject", ofType: "js") ?? "") else {
            // 沒有讀取出來則不執行注入
        return
    }
    // 注入語句
    webView.evaluateJavaScript(jsString, completionHandler: { _, _ in
        print("程式碼注入成功")
    })
}
複製程式碼

我們將應用程式中的index.html網頁中對應的js程式碼刪除。使這個網頁更像是一個三方網頁(不會主動支援該功能)。這裡,就不再詳述HTML程式碼了。

我們執行測試後發現,測試網頁回撥成功

三方網頁整合測試

當我們以三方網頁測試的時候,便會發現這樣或者那樣的問題。

我們以 掘金 為例,試試我們的自有指令碼。我們的js程式碼在掘金網上的登入竟然失效了。

但是作為程式,函式是具有冪等性的(在相同情況下進行無限次的操作,結果一定相同)。那麼只能是我們的環境出現了問題,而不是我們的js程式碼失敗了。

所以我們需要觀察環境究竟哪裡出現問題了。

首先在我們的自有測試網站上,輸入框是直接就存在的。但是在掘金上卻不是,它需要我們點選登入按鈕後作為彈框出現。而我們的js程式碼注入是在網頁渲染完成,因此我們接下來嘗試給我們的頁面增加一個按鈕。點選的時候再進行js程式碼注入

為了簡化程式碼,我們給ViewController加入了一個導航欄,並在導航欄右上角增加一個注入按鈕。(導航控制器通過StoryBoard增加)

我們在ViewController的viewDidLoad中增加如下程式碼:

self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "注入", style: .plain, target: self, action: #selector(injectJS))
複製程式碼

同時在類中增加如下方法

@objc func injectJS() {
    guard let jsString = try? String(contentsOfFile: Bundle.main.path(forResource: "Inject", ofType: "js") ?? "") else {
        // 沒有讀取出來則不執行注入
        return
    }
    // 注入語句
    webview.evaluateJavaScript(jsString, completionHandler: { _, _ in
        print("程式碼注入成功")
    })
}
複製程式碼

重新執行程式碼

發現頭部被擋在導航欄下面了-_-

繼續改尺寸(程式碼就不列出來了)

這陣我們點選網頁上的登入按鈕,隨後點選導航欄上注入

然後再進行輸入,我們就可以看見控制檯列印出我們正在輸入的賬戶名和密碼了-_-

如果你問我怎麼自動注入?我給的思路就是獲取到登入的元素,然後再多做一步回撥,我們可以先注入一次js再注入我們的目標js。

但是具體怎麼實現麼,不好意思,不說了。

總結

其實這篇文章的主要目的介紹如何分析具體的需求,並將其轉換為程式碼。根本在於從一個相對於具象的需求中,我們一步一步抽離問題,組成更細小的問題的組合,然後逐步的去實驗小問題的可行性,最終完成複雜問題

所有的需求均可以如此實現,只是有的我們可以實現,有的問題分析到最後發現,細節無法實現或實現起來成本過高(比如平安銀行的自動識別手機殼顏色)。

當我寫完這篇文章的demo的時候發現,我們的使用者資訊實在是太容易洩漏了,比如我的掘金賬戶和密碼,如果在別人app上的內嵌網頁登入的話,不是直接就洩漏了嗎(手動滑稽)。

ps:這篇文章可以有個別名:震驚!!!你還敢在你的手機上登入賬戶麼

本文所述demo連結:

github.com/chouheiwa/I…

參考:

菜鳥教程

結尾

本文純屬娛樂寫出,三方網站也只是以有指令碼為例注入測試。並無任何破解成分。

另外大家如果有什麼更好的建議或意見,也可以評論指出。

推廣一波個人的公眾號與部落格

公眾號

相關文章