越來越發現把自己的思路理清楚並且能夠給別人講明白是件很困難的事情,技術博客不等於小說、散文,不能天馬行空,思維必須嚴謹,思路必須清晰。
寫技術博客要隨時審視自己的思路是不是還連貫,要不斷返回去看代碼,再從代碼理清思路。有時還要查找資料來把某個點解釋清楚,當然其中會有很多自己不太清楚的地方,所以也希望博友能一起交流、討論,給指出不正確的地方,博客確實是一個互相進步的很好的手段。
好了,不多說了,進入正題。
昨天講解了DroidGap類,DroidGap繼承Activity,在DroidGap的init()初始化方法中設置了WebView,今天主要講解CordovaWebView類以及CordovaWebviewClient類和CordovaChromeClient類。
CordovaWebView類
public class CordovaWebView extends WebView {...}
CordovaWebView類繼承WebView類,在上一篇博文中已經對WebView類做了簡要的介紹。PhoneGap針對不同平台的WebView做了擴展和封裝,使WebView這個組件變成可訪問設備本地API的強大瀏覽器,所以開發人員在PhoneGap框架下可通過JavaScript訪問設備本地API。
可以說,CordovaWebView是整個PhoneGap的核心組件。
1 /** 2 * Constructor. 3 * 4 * @param context 5 */ 6 public CordovaWebView(Context context) { 7 super(context); 8 if (CordovaInterface.class.isInstance(context)) 9 { 10 this.cordova = (CordovaInterface) context; 11 } 12 else 13 { 14 Log.d(TAG, "Your activity must implement CordovaInterface to work"); 15 } 16 this.loadConfiguration(); 17 this.setup(); 18 }
CordovaWebView類的構造函數中,需要傳入Context,並且Context必須是CordovaInterface的實現類(這里需要特別注意)。構造函數里調用loadConfiguration()和Setup()方法。在CordovaWebView類的構造函數重載方法中,還有setWebChromeClient和setWebViewClient對CordovaWebView的設置,原因在上篇最后也有講到,這兩個類的具體講解在下面。

1 /** 2 * Load Cordova configuration from res/xml/cordova.xml. 3 * Approved list of URLs that can be loaded into DroidGap 4 * <access origin="http://server regexp" subdomains="true" /> 5 * Log level: ERROR, WARN, INFO, DEBUG, VERBOSE (default=ERROR) 6 * <log level="DEBUG" /> 7 */ 8 private void loadConfiguration() { 9 int id = getResources().getIdentifier("cordova", "xml", this.cordova.getActivity().getPackageName()); 10 // int id = getResources().getIdentifier("cordova", "xml", this.cordova.getPackageName()); 11 if (id == 0) { 12 LOG.i("CordovaLog", "cordova.xml missing. Ignoring..."); 13 return; 14 } 15 XmlResourceParser xml = getResources().getXml(id); 16 int eventType = -1; 17 while (eventType != XmlResourceParser.END_DOCUMENT) { 18 if (eventType == XmlResourceParser.START_TAG) { 19 String strNode = xml.getName(); 20 if (strNode.equals("access")) { 21 String origin = xml.getAttributeValue(null, "origin"); 22 String subdomains = xml.getAttributeValue(null, "subdomains"); 23 if (origin != null) { 24 this.addWhiteListEntry(origin, (subdomains != null) && (subdomains.compareToIgnoreCase("true") == 0)); 25 } 26 } 27 else if (strNode.equals("log")) { 28 String level = xml.getAttributeValue(null, "level"); 29 LOG.i("CordovaLog", "Found log level %s", level); 30 if (level != null) { 31 LOG.setLogLevel(level); 32 } 33 } 34 else if (strNode.equals("preference")) { 35 String name = xml.getAttributeValue(null, "name"); 36 String value = xml.getAttributeValue(null, "value"); 37 38 LOG.i("CordovaLog", "Found preference for %s=%s", name, value); 39 Log.d("CordovaLog", "Found preference for " + name + "=" + value); 40 41 // Save preferences in Intent 42 this.cordova.getActivity().getIntent().putExtra(name, value); 43 } 44 } 45 try { 46 eventType = xml.next(); 47 } catch (XmlPullParserException e) { 48 e.printStackTrace(); 49 } catch (IOException e) { 50 e.printStackTrace(); 51 } 52 } 53 54 // Init preferences 55 if ("true".equals(this.getProperty("useBrowserHistory", "false"))) { 56 this.useBrowserHistory = true; 57 } 58 else { 59 this.useBrowserHistory = false; 60 } 61 62 if ("true".equals(this.getProperty("fullscreen", "false"))) { 63 this.cordova.getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); 64 this.cordova.getActivity().getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); 65 } 66 }
loadConfiguration()的作用是從res/xml/cordova.xml文件中加載Cordova配置信息,包括允許加載本地網頁等,xml文件沒幾行,不細說。

1 /** 2 * Initialize webview. 3 */ 4 @SuppressWarnings("deprecation") 5 private void setup() { 6 7 this.setInitialScale(0); 8 this.setVerticalScrollBarEnabled(false); 9 this.requestFocusFromTouch(); 10 11 // Enable JavaScript 12 WebSettings settings = this.getSettings(); 13 settings.setJavaScriptEnabled(true); 14 settings.setJavaScriptCanOpenWindowsAutomatically(true); 15 settings.setLayoutAlgorithm(LayoutAlgorithm.NORMAL); 16 17 //Set the nav dump for HTC 18 settings.setNavDump(true); 19 20 // Enable database 21 settings.setDatabaseEnabled(true); 22 String databasePath = this.cordova.getActivity().getApplicationContext().getDir("database", Context.MODE_PRIVATE).getPath(); 23 settings.setDatabasePath(databasePath); 24 25 // Enable DOM storage 26 settings.setDomStorageEnabled(true); 27 28 // Enable built-in geolocation 29 settings.setGeolocationEnabled(true); 30 31 //Start up the plugin manager 32 try { 33 this.pluginManager = new PluginManager(this, this.cordova); 34 } catch (Exception e) { 35 // TODO Auto-generated catch block 36 e.printStackTrace(); 37 } 38 }
setup()方法初始化WebView配置,包括設置WebView初始化視圖大小、是否允許垂直滾動條、觸摸焦點信息。
因為PhoneGap的強大之處在於可以在Web端直接調用底層API,包括照相機、指南針、GPS等設備,其實現這些功能是通過JavaScript與底層交互實現的,所以在接下來設置settings.setJavaScriptEnabled(true)也就理所當然的事情了。
然后設置了數據庫database、DOM storage和地理位置應用geolocation等信息,這里也不細講。
需要着重提到的一點是最后PhoneGap插件的初始化。沒錯,PhoneGap插件的初始化是在這里進行的。PhoneGap插件管理機制是PhoneGap實現跨平台的基礎。pluginManager類在后面也會講到。
在上篇中講到了DroidGap類loadUrl()方法,我們可以把它看成是整個PhoneGap應用的入口函數。實際DroidGap類的loadUrl()方法調用了CordovaWebView類的loadUrl()方法:

1 /** 2 * Load the url into the webview. 3 * 4 * @param url 5 */ 6 @Override 7 public void loadUrl(String url) { 8 if (url.equals("about:blank") || url.startsWith("javascript:")) { 9 this.loadUrlNow(url); 10 } 11 else { 12 13 String initUrl = this.getProperty("url", null); 14 15 // If first page of app, then set URL to load to be the one passed in 16 if (initUrl == null || (this.urls.size() > 0)) { 17 this.loadUrlIntoView(url); 18 } 19 // Otherwise use the URL specified in the activity's extras bundle 20 else { 21 this.loadUrlIntoView(initUrl); 22 } 23 } 24 }
代碼不多,也比較好理解,自己看吧,不細講了。O(∩_∩)O~

1 /** 2 * Load the url into the webview. 3 * 4 * @param url 5 */ 6 public void loadUrlIntoView(final String url) { 7 LOG.d(TAG, ">>> loadUrl(" + url + ")"); 8 9 this.url = url; 10 if (this.baseUrl == null) { 11 int i = url.lastIndexOf('/'); 12 if (i > 0) { 13 this.baseUrl = url.substring(0, i + 1); 14 } 15 else { 16 this.baseUrl = this.url + "/"; 17 } 18 19 // this.pluginManager.init(); 20 21 if (!this.useBrowserHistory) { 22 this.urls.push(url); 23 } 24 } 25 26 // Create a timeout timer for loadUrl 27 final CordovaWebView me = this; 28 final int currentLoadUrlTimeout = me.loadUrlTimeout; 29 final int loadUrlTimeoutValue = Integer.parseInt(this.getProperty("loadUrlTimeoutValue", "20000")); 30 31 // Timeout error method 32 final Runnable loadError = new Runnable() { 33 public void run() { 34 me.stopLoading(); 35 LOG.e(TAG, "CordovaWebView: TIMEOUT ERROR!"); 36 if (viewClient != null) { 37 viewClient.onReceivedError(me, -6, "The connection to the server was unsuccessful.", url); 38 } 39 } 40 }; 41 42 // Timeout timer method 43 final Runnable timeoutCheck = new Runnable() { 44 public void run() { 45 try { 46 synchronized (this) { 47 wait(loadUrlTimeoutValue); 48 } 49 } catch (InterruptedException e) { 50 e.printStackTrace(); 51 } 52 53 // If timeout, then stop loading and handle error 54 if (me.loadUrlTimeout == currentLoadUrlTimeout) { 55 me.cordova.getActivity().runOnUiThread(loadError); 56 } 57 } 58 }; 59 60 // Load url 61 this.cordova.getActivity().runOnUiThread(new Runnable() { 62 public void run() { 63 Thread thread = new Thread(timeoutCheck); 64 thread.start(); 65 me.loadUrlNow(url); 66 } 67 }); 68 }
這里需要細講一下。Android WebView里並沒有對加載超時的處理,PhoneGap自己實現了加載超時處理的方法。雖然看不看這里並不會影響對PhoneGap整個機制的理解,但拿出來講一下對以后可能也會很有用處。
從代碼的第26行注釋看起,CordovaWebViewClient類有一個屬性LoadUrlTimeout,這里不要被其定義為int類型迷惑,實際這個變量完全可以是boolean布爾值,因為它只有兩個值:0和1。在頁面開始加載時currentLoadUrlTimeout初始化為me.loadUrlTimeout(這個值初始化為0),在頁面加載完成(注意:這里CordovaWebViewClient派上用場了),即CordovaWebViewClient類里OnpageFinished()方法中appView.LoadUrlTimeout被置成1。定義了loadError和runOnUiThread兩個線程。然后起了一個在Timeout之后執行的線程,具體執行那個線程就看頁面是否加載完成,即OnpageFinished()方法中appView.LoadUrlTimeout是否被置成了1。
CordovaWebViewClient類
CordovaWebViewClient類主要處理各種通知、請求事件的,重寫了WebView類的一些方法,這里主要講解其中onPageStarted()和onPageFinished()兩個方法。

1 /** 2 * Notify the host application that a page has started loading. 3 * This method is called once for each main frame load so a page with iframes or framesets will call onPageStarted 4 * one time for the main frame. This also means that onPageStarted will not be called when the contents of an 5 * embedded frame changes, i.e. clicking a link whose target is an iframe. 6 * 7 * @param view The webview initiating the callback. 8 * @param url The url of the page. 9 */ 10 @Override 11 public void onPageStarted(WebView view, String url, Bitmap favicon) { 12 // Clear history so history.back() doesn't do anything. 13 // So we can reinit() native side CallbackServer & PluginManager. 14 if (!this.appView.useBrowserHistory) { 15 view.clearHistory(); 16 this.doClearHistory = true; 17 } 18 19 // Create callback server and plugin manager 20 if (this.appView.callbackServer == null) { 21 this.appView.callbackServer = new CallbackServer(); 22 this.appView.callbackServer.init(url); 23 } 24 else { 25 this.appView.callbackServer.reinit(url); 26 } 27 28 // Broadcast message that page has loaded 29 this.appView.postMessage("onPageStarted", url); 30 }
從文檔注釋我們可以看出,onPageStarted方法用於通知主應用程序Web頁面開始加載了。其中在頁面加載時比較重要的一步是創建CallbackServer對象並初始化。(CallbackServer類可是實現插件異步執行的重頭戲啊,好緊張啊,寫了這么久CallbackServer終於出現了啊o(╯□╰)o)

1 /** 2 * Notify the host application that a page has finished loading. 3 * This method is called only for main frame. When onPageFinished() is called, the rendering picture may not be updated yet. 4 * 5 * 6 * @param view The webview initiating the callback. 7 * @param url The url of the page. 8 */ 9 @Override 10 public void onPageFinished(WebView view, String url) { 11 super.onPageFinished(view, url); 12 LOG.d(TAG, "onPageFinished(" + url + ")"); 13 14 /** 15 * Because of a timing issue we need to clear this history in onPageFinished as well as 16 * onPageStarted. However we only want to do this if the doClearHistory boolean is set to 17 * true. You see when you load a url with a # in it which is common in jQuery applications 18 * onPageStared is not called. Clearing the history at that point would break jQuery apps. 19 */ 20 if (this.doClearHistory) { 21 view.clearHistory(); 22 this.doClearHistory = false; 23 } 24 25 // Clear timeout flag 26 this.appView.loadUrlTimeout++; 27 28 // Try firing the onNativeReady event in JS. If it fails because the JS is 29 // not loaded yet then just set a flag so that the onNativeReady can be fired 30 // from the JS side when the JS gets to that code. 31 if (!url.equals("about:blank")) { 32 this.appView.loadUrl("javascript:try{ cordova.require('cordova/channel').onNativeReady.fire();}catch(e){_nativeReady = true;}"); 33 this.appView.postMessage("onNativeReady", null); 34 } 35 36 // Broadcast message that page has loaded 37 this.appView.postMessage("onPageFinished", url); 38 39 // Make app visible after 2 sec in case there was a JS error and Cordova JS never initialized correctly 40 if (this.appView.getVisibility() == View.INVISIBLE) { 41 Thread t = new Thread(new Runnable() { 42 public void run() { 43 try { 44 Thread.sleep(2000); 45 cordova.getActivity().runOnUiThread(new Runnable() { 46 public void run() { 47 appView.postMessage("spinner", "stop"); 48 } 49 }); 50 } catch (InterruptedException e) { 51 } 52 } 53 }); 54 t.start(); 55 } 56 57 // Shutdown if blank loaded 58 if (url.equals("about:blank")) { 59 if (this.appView.callbackServer != null) { 60 this.appView.callbackServer.destroy(); 61 } 62 appView.postMessage("exit", null); 63 } 64 }
上文已經提到在onPageFinished方法中會執行this.appView.loadUrlTimeout++,定時器loadUrlTimeout的值被置為1,表明頁面加載完成。如果沒有執行到此,說明加載超時,在CordovaWebView類的loadUrlIntoView()方法中判斷if(currentLoadUrlTimeout ==me.loadUrlTimeout),則執行loadError線程。
CordovaChromeClient類
WebChromeClient是輔助WebView處理Javascript的對話框,網站圖標,網站title,加載進度等,一般的WebView可以不設置ChromeClient,比如你的WebView不需要處理JavaScript腳本。前面已經提到為什么PhoneGap要設置ChromeClient,不再多說。
引用某位大俠的話說,關於Java/JS互調,在android sdk文檔中,也有用JsInterface和loadUrl做到交互的示例。PhoneGap並沒有選擇用JsInterface,而是使用攔截prompt這種hack做法。
對JavaScript消息的攔截是在onJsPrompt()方法中實現並處理的。

1 /** 2 * Tell the client to display a prompt dialog to the user. 3 * If the client returns true, WebView will assume that the client will 4 * handle the prompt dialog and call the appropriate JsPromptResult method. 5 * 6 * Since we are hacking prompts for our own purposes, we should not be using them for 7 * this purpose, perhaps we should hack console.log to do this instead! 8 * 9 * @param view 10 * @param url 11 * @param message 12 * @param defaultValue 13 * @param result 14 */ 15 @Override 16 public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { 17 18 // Security check to make sure any requests are coming from the page initially 19 // loaded in webview and not another loaded in an iframe. 20 boolean reqOk = false; 21 if (url.startsWith("file://") || url.indexOf(this.appView.baseUrl) == 0 || this.appView.isUrlWhiteListed(url)) { 22 reqOk = true; 23 } 24 25 // Calling PluginManager.exec() to call a native service using 26 // prompt(this.stringify(args), "gap:"+this.stringify([service, action, callbackId, true])); 27 if (reqOk && defaultValue != null && defaultValue.length() > 3 && defaultValue.substring(0, 4).equals("gap:")) { 28 JSONArray array; 29 try { 30 array = new JSONArray(defaultValue.substring(4)); 31 String service = array.getString(0); 32 String action = array.getString(1); 33 String callbackId = array.getString(2); 34 boolean async = array.getBoolean(3); 35 String r = this.appView.pluginManager.exec(service, action, callbackId, message, async); 36 result.confirm(r); 37 } catch (JSONException e) { 38 e.printStackTrace(); 39 } 40 } 41 42 // Polling for JavaScript messages 43 else if (reqOk && defaultValue != null && defaultValue.equals("gap_poll:")) { 44 String r = this.appView.callbackServer.getJavascript(); 45 result.confirm(r); 46 } 47 48 // Do NO-OP so older code doesn't display dialog 49 else if (defaultValue != null && defaultValue.equals("gap_init:")) { 50 result.confirm("OK"); 51 } 52 53 // Calling into CallbackServer 54 else if (reqOk && defaultValue != null && defaultValue.equals("gap_callbackServer:")) { 55 String r = ""; 56 if (message.equals("usePolling")) { 57 r = "" + this.appView.callbackServer.usePolling(); 58 } 59 else if (message.equals("restartServer")) { 60 this.appView.callbackServer.restartServer(); 61 } 62 else if (message.equals("getPort")) { 63 r = Integer.toString(this.appView.callbackServer.getPort()); 64 } 65 else if (message.equals("getToken")) { 66 r = this.appView.callbackServer.getToken(); 67 } 68 result.confirm(r); 69 } 70 71 // Show dialog 72 else { 73 final JsPromptResult res = result; 74 AlertDialog.Builder dlg = new AlertDialog.Builder(this.cordova.getActivity()); 75 dlg.setMessage(message); 76 final EditText input = new EditText(this.cordova.getActivity()); 77 if (defaultValue != null) { 78 input.setText(defaultValue); 79 } 80 dlg.setView(input); 81 dlg.setCancelable(false); 82 dlg.setPositiveButton(android.R.string.ok, 83 new DialogInterface.OnClickListener() { 84 public void onClick(DialogInterface dialog, int which) { 85 String usertext = input.getText().toString(); 86 res.confirm(usertext); 87 } 88 }); 89 dlg.setNegativeButton(android.R.string.cancel, 90 new DialogInterface.OnClickListener() { 91 public void onClick(DialogInterface dialog, int which) { 92 res.cancel(); 93 } 94 }); 95 dlg.create(); 96 dlg.show(); 97 } 98 return true; 99 }
onJsPrompt到底攔截了哪些信息呢?
從傳入參數來說,比較重要的有兩個:message和defaultValue。
message字符串存放了插件的應用信息,如Camera插件的圖片質量、圖片是否可編輯,圖片返回類型等。
defaultValue字符串存放了插件信息:service(如Camera)、action(如getPicture())、callbackId、async等。
攔截到這些信息后就會調用this.appView.pluginManager.exec(service, action, callbackId, message, async)去執行。
當然onJsPrompt不只實現插件調用信息,還有JavaScript polling輪詢信息、CallbackServer調用信息等處理。
呃,今天寫到這里吧,歡迎大家批評指正,也可以相互交流。