級別: ★★☆☆☆
標簽:「HTML代碼注入」「WKScriptMessageHandler」「iOS與JS交互」
作者: chouheiwa
審校: QiShare團隊
有個朋友問了我一個問題:
他們通過WKWebView,訪問了一個其他的頁面,然后希望原生獲得用戶的輸入信息。
其實,我之前接觸WKWebView并不多,但是這個問題我覺得很有意思。這篇文章便是我解決這個問題的全部思路,與最終的解決辦法。
思路分析 與 代碼實踐
這個問題其實很具象了,就是希望原生獲得H5的用戶輸入內(nèi)容。
接下來我們就需要分析這個需求了。
首先我們先需要抓住兩個點,1個是H5,1個是原生。
所以這個問題現(xiàn)在被我拆分出了1個額外的問題。
HTML能否和原生交互?如何進行?
這個問題其實是一個很關鍵的問題,因為我們只有實現(xiàn)了原生和HTML的交互,才能獲得相關信息。(這里我們假定網(wǎng)頁是我們自己寫的,完全受操縱于我們自己)
于是便搜尋資料,發(fā)現(xiàn)WKWebView提供了一個很方便的交互渠道WKScriptMessageHandler,我們通過對WKWebView進行相關的定制操作便可以解決。
我們先創(chuàng)建一個工程,這里我把整個工程命名為InjectHTML。
為了防止循環(huán)引用,我們先構建一個中間層ScriptHandler。
import Foundation
import WebKit
// 這里我們使用一個中間層來解除循環(huán)WebView和Controller間的循環(huán)引用問題
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處理器
// 采用了中間層的因素,避免循環(huán)引用導致無法釋放問題
configuration.userContentController.add(ScriptHandler(delegate: self), name: ViewController.scriptKey)
webview = WKWebView(frame: view.bounds, configuration: configuration)
view.addSubview(webview)
// 設置導航處理器
webview.navigationDelegate = self
// 我們先從本地讀網(wǎng)頁方便自我改動測試
let fileURL = Bundle.main.url(forResource: "index", withExtension: "html")
// 加載網(wǎng)頁
webview.loadFileURL(fileURL!, allowingReadAccessTo: fileURL!)
}
}
extension ViewController: WKScriptMessageHandler {
// 遵守協(xié)議
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
// 因html可能傳遞多種類型名稱,我們這里須指定key
if message.name == ViewController.scriptKey {
guard let dic = message.body as? [String: String] else {
return
}
// 交給真實處理解析函數(shù)
receiveInputValue(para: dic)
}
}
// 解析函數(shù)可以負責更具體的內(nèi)容,因為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)")
}
}
// 遵循導航協(xié)議,方便我們知道何時網(wǎng)頁加載完成
extension ViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
}
}
這樣子我們的ViewController就構建完成,訪問了一個本地index.html的網(wǎng)頁。
我們在項目中新建一個html文件(從外界拖入也可),
html頁面只用于測試,因此我們也就不做類似自適應之類的css樣式了。
html代碼如下:
<html>
<head>
<title>注入測試網(wǎng)頁</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'})
// 若我們未注冊此名稱,則無法觸發(fā)對應回調(diào),postMessage中的參數(shù)可傳為任意的,但我們在原生中定義為字典了,則我們在這里需要傳入字典
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了。
接下來我們需要繼續(xù)分析了,這個需求需要我們能監(jiān)控所有的輸入控件。
所以我們需要從網(wǎng)頁端剖析了,問題回歸HTML端。
我們繼續(xù)拆解問題,我們既然要監(jiān)控所有的輸入控件,那么我們首先就得知道,我們能否獲得所有的輸入控件(甚至說,我們能否獲得頁面的所有控件,然后進行遍歷過濾也可以)。
HTML如何獲取指定元素
這個問題,通過搜索與詢問前端朋友得知:
HTML提供了對應的API,直接獲取指定標簽的內(nèi)容,因為我們是要獲得輸入框,輸入框在HTML中的標簽是input。所以我們獲得頁面中所有輸入框元素的方法就已經(jīng)出來了。
// 獲得所有input數(shù)組
var inputs = document.getElementsByTagName("input")
那么問題就迎刃而解了,然后我們還是需要測試一下,畢竟萬一這個方法不好用怎么辦?
這里就不在手機端進行測試,因為模擬器還是比較費事的,并且我們?nèi)绻氪蛴onsole日志的話,看起來也不那么容易,因此我們將接下來的測試網(wǎng)頁步驟,直接挪到Google Chrome上。
這里還是給出對應的html測試代碼:
<html>
<head>
<title>注入測試網(wǎng)頁</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數(shù)組
var inputs = document.getElementsByTagName("input");
// 打印input數(shù)組
console.log(inputs)
</script>
</html>
然后使用瀏覽器(下文瀏覽器均為Google Chrome)打開。
我們按Command+Shift+c即可打開頁面審查和網(wǎng)頁控制臺,在這里我們看見了我們執(zhí)行網(wǎng)頁的log,打印結果如下:
說明我們獲取input成功了。
接下來我們只需要進行過濾即可獲得我們需要的頁面元素了(如過濾checkbox之類的)。
然后接下來新的問題來了。
JS代碼如何動態(tài)添加事件
我們已經(jīng)通過js代碼獲得input了,所以問題就變到了如何添加點擊事件。畢竟原生的輸入框還有至少監(jiān)聽輸入事件,或者監(jiān)聽輸入完成類的,那么JS端理應也存在。
這里感謝菜鳥教程,查詢到了一個函數(shù)addEventListener。
這個函數(shù)的功能就是給HTTP的DOM元素增加對應的事件,也就是說我們可以通過這個方法額外的增加點擊事件。
同時,新增加的事件并不會覆蓋原有事件,一個元素可以擁有多個同樣的事件(如一個按鈕可以同時出發(fā)兩個onClick事件)。
這個函數(shù)給了我們新的天地啊。因此我們可以給input增加事件,這里我選擇了兩個事件,一個是input事件(輸入框文字發(fā)生變化),一個是change事件(輸入框失去焦點)。
我們修改上文的html代碼,修改結果如下:
<html>
<head>
<title>注入測試網(wǎng)頁</title>
</head>
<body bgcolor="#FFFFFF">
<h1>Test for inject js</h1>
<!--添加一個button按鈕用以測試點擊事件-->
<button onclick="onClickTest()">Test Button</button>
<!--這里給input增加一些回調(diào)-->
<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>
繼續(xù)進入瀏覽器測試網(wǎng)址,我們可以看到,當我們輸入的時候,同時觸發(fā)了兩個回調(diào),說明后期注入有效。
接下來我們需要繼續(xù)考慮,我們需要能讓原生接收到對應事件。
原生如何收到對應事件?
我們在上文中已經(jīng)知道了js如何調(diào)用原生,那么這一步我們就可以直接實現(xiàn)了。
這一步還是更改html代碼:
<html>
<head>
<!--HTML頁面內(nèi)容需告知為utf8,否則會出現(xiàn)亂碼問題-->
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<title>注入測試網(wǎng)頁</title>
</head>
<body bgcolor="#FFFFFF">
<h1>Test for inject js</h1>
<!--添加一個button按鈕用以測試點擊事件-->
<button onclick="onClickTest()">Test Button</button>
<!--這里給input增加一些回調(diào)-->
<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,在輸入框中輸入,可以看到控制臺中返回了數(shù)據(jù):
title: 輸入框正在輸入
message: 1
id:
title: 輸入框失去焦點
message: 1
id:
我們的打印出來了。到這里我們自有網(wǎng)頁的測試全部通過,那么就剩下最后一步了,如何在三方網(wǎng)頁上執(zhí)行?
JS代碼注入
WKWebView既然能讓js調(diào)用OC,那么OC能否調(diào)用js代碼呢?
答案是可以的,WKWebView提供給我們原生的方法可以動態(tài)的執(zhí)行js代碼。
webview.evaluateJavaScript(someTest, completionHandler: closure)
這個方法可以讓我們動態(tài)的執(zhí)行由原生生成的js代碼。
那么我們需要執(zhí)行什么方法呢?
自然就是我們上述研究出來的獲取HTML的元素并增加事件回調(diào)的事情啊。
這里我們在項目中新建一個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!)
函數(shù)中加入js代碼注入,使我們每次加載成功網(wǎng)頁都注入對應的js代碼。
完整函數(shù)如下:
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard let jsString = try? String(contentsOfFile: Bundle.main.path(forResource: "Inject", ofType: "js") ?? "") else {
// 沒有讀取出來則不執(zhí)行注入
return
}
// 注入語句
webView.evaluateJavaScript(jsString, completionHandler: { _, _ in
print("代碼注入成功")
})
}
我們將應用程序中的index.html網(wǎng)頁中對應的js代碼刪除。使這個網(wǎng)頁更像是一個三方網(wǎng)頁(不會主動支持該功能)。這里,就不再詳述HTML代碼了。
我們執(zhí)行測試后發(fā)現(xiàn),測試網(wǎng)頁回調(diào)成功。
三方網(wǎng)頁集成測試
當我們以三方網(wǎng)頁測試的時候,便會發(fā)現(xiàn)這樣或者那樣的問題。
我們以 掘金 為例,試試我們的自有腳本。我們的js代碼在掘金網(wǎng)上的登錄竟然失效了。
但是作為程序,函數(shù)是具有冪等性的(在相同情況下進行無限次的操作,結果一定相同)。那么只能是我們的環(huán)境出現(xiàn)了問題,而不是我們的js代碼失敗了。
所以我們需要觀察環(huán)境究竟哪里出現(xiàn)問題了。
首先在我們的自有測試網(wǎng)站上,輸入框是直接就存在的。但是在掘金上卻不是,它需要我們點擊登錄按鈕后作為彈框出現(xiàn)。而我們的js代碼注入是在網(wǎng)頁渲染完成,因此我們接下來嘗試給我們的頁面增加一個按鈕。點擊的時候再進行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 {
// 沒有讀取出來則不執(zhí)行注入
return
}
// 注入語句
webview.evaluateJavaScript(jsString, completionHandler: { _, _ in
print("代碼注入成功")
})
}
重新運行代碼
這陣我們點擊網(wǎng)頁上的登錄按鈕,隨后點擊導航欄上注入
然后再進行輸入,我們就可以看見控制臺打印出我們正在輸入的賬戶名和密碼了-_-
如果你問我怎么自動注入?我給的思路就是獲取到登錄的元素,然后再多做一步回調(diào),我們可以先注入一次js再為我們的目標注入js。
總結
其實這篇文章的主要目的介紹如何分析具體的需求,并將其轉換為代碼。根本在于從一個相對于具象的需求中,我們一步一步抽離問題,組成更細小的問題的組合,然后逐步的去實驗小問題的可行性,最終完成復雜問題。
所有的需求均可以如此實現(xiàn),只是有的我們可以實現(xiàn),有的問題分析到最后發(fā)現(xiàn),細節(jié)無法實現(xiàn)或實現(xiàn)起來成本過高(比如平安銀行的自動識別手機殼顏色)。
當我寫完這篇文章的demo的時候發(fā)現(xiàn),我們的用戶信息實在是太容易泄漏了,比如我的掘金賬戶和密碼,如果在別人app上的內(nèi)嵌網(wǎng)頁登錄的話,不是直接就泄漏了嗎(手動滑稽)。
ps:這篇文章可以有個別名:震驚!??!你還敢在你的手機上登錄賬戶么
本文所述demo鏈接:
https://github.com/chouheiwa/InjectJSDemo
推薦文章:
iOS 常用調(diào)試方法:LLDB命令
iOS 常用調(diào)試方法:斷點
iOS 常用調(diào)試方法:靜態(tài)分析
iOS消息轉發(fā)
iOS 自定義拖拽式控件:QiDragView
iOS 自定義卡片式控件:QiCardView
iOS Wireshark抓包
iOS Charles抓包