Android WebView开发tips

  • webView 优化的tips(native端):

    • 修改加载资源顺序
    • webView缓存优化(缓存累积占用手机内存问题)
    • 开启硬件加速设置开关(提升页面绘制效率,测试html中video/canvas标签)
    • webView内存泄露的问题
    • Js与native端的两种交互方式实现的原理解析
    • 打包时混淆代码需要注意的地方
  • webView加载资源顺序:

    • 首先下载html,然后解析css和js code以及静态img资源。最后执行css/js渲染工作。css渲染比较快速,但是js、jquery渲染就比较慢。

    • 倘若一个html页面里面包含很多图文资源,在webView里打开会因为渲染图片阻塞而导致显示一个大面积的空白页面,所以我们可以尝试把图片放到最后去加载。在WebSettings里有如下函数:
      public void setLoadsImagesAutomatically(boolean);

    • 其中boolean 默认为true,也就是以正常的顺序来渲染页面。比较好的方式是先渲染轻量级的文本资源,把image放到最后来渲染。在webView开始loadUrl之前设为webSettings.setLoadsImagesAutomatically(false),然后在webViewClient的回调函数onPageFinished()中恢复setLoadsImagesAutomatically(true).这样就可以避免用户跳转web页面期间显示大面积的空白内容。

    • 但是不管setLoadsImagesAutomatically设置为false或者是true,用户首次打开webView总是会遇到一个较长时间的空白加载页面,比较好的解决方法就是添加一个覆盖webView的加载页面(FrameLayout),类似一个progressBar的角色,在webChromeClient的回调函数onProgressChanged函数中的newProgress参数中判断是否(>=100)关闭加载页面从而展示已经加载完成的webView页面。

  • 缓存

    • 缓存的模式包括五个:WebSettings.LOAD_CACHE_ELSE_NETWORK 只要本地有缓存数据无论是否过期,都使用缓存数据;WebSettings.LOAD_CACHE_ONLY 只使用缓存数据;LOAD_NO_CACHE 完全不使用缓存数据;WebSettings.LOAD_DEFAULT根据Header上的cache-control来判断是否从网上加载资源.

    • 在加载html页面的时候,总会有一些“静态资源”需要加载,例如说jquery代码,一些静态的图片(icon,indexImage),我们可以在首次加载的时候把他们下载到本地来,每次判断url是否包含这些文件的keyword,从而判断是否直接构造一个WebResourceResponse来包含对应的资源返回给webView,这样尤其是针对一些大的静态资源有比较好的优化效果。

    • 另外一种缓存属于appCache,由于想要缓存整个h5页面的内容的话会导致缓存大小上升,导致手机内部存储空间不足,这个时候可以考虑使用自定义的缓存策略,以及使用sdcard内存作为文件缓存dir。通过webSetting.setAppCachePath(sdcardStoragePath)即可设置sdcard作为appCache dir。缓存的策略可以参考diskLruCache的策略来实现…这样的话每次请求资源都从缓存里去看看,如果没有的话才去发送请求加载资源,这样可以提升webView的加载效率。

    • 清除webView缓存:

      • 浏览网页产生两种缓存:网页数据;H5页面的缓存
      • 缓存文件结构图:
      • 清除的入手角度就是删除webView.db 和 webViewCache.db这两个文件。然后再去删除掉webViewCache文件夹下的所有缓存文件。
      • 如果缓存用的路径是自定义的话就去清除自定义缓存路径内的数据。
  • 开启硬件加速

    • 虽然开启硬件加速对于渲染的工作有很大的帮助但是作为代价的是需要消耗更多的RAM,所以在低配置的机型中开启硬件加速可能会导致RAM空间不足导致的闪退。所以应该在有需要的地方才开启并且在合适的地方关掉它。
    • 倘若不开启硬件加速的话,html中的类似video,canvas标签是无法实现预期的效果,譬如说video标签的视频内容(仅音频可以播放)是无法输出到页面上来的。
    • 硬件加速是分层级的,从上往下分别是: application->activity->window->view. 可以把application层级的关掉,在webView所在的activity页面中开启。这样就合理的控制住硬件加速所带来的问题。不过需要注意的是在window层,只能开启不能关闭硬件加速;在view层,只能关闭却不能开启硬件加速。
    • 也可以从webView本身来开启硬件加速,通过设置webView所在的window开启硬件加速,而该window下如果有其他的View可以把它们关掉硬件加速。做到最低消耗ram资源(个人推荐在最小层级上去控制硬件加速开关,在灵活控制的同时也将硬件加速带来的负面影响降到最低)
  • 内存泄露问题

    • Memory leak in WebView 如果在activity的xml文件中嵌入了控件,即使当前的activity退出了并且在onDestory()中执行了webView.destory()或者是将webView置空,在hprof dump文件中仍然可见android.webkit.PluginManager 持有着activity的引用。issue地址

    • 解决方案是尽量不要在xml文件中定义webView控件,而是在Java代码中去实例化出来(奇怪的是在google推出的samples里面无一例外的都是在xml中定义webView控件)。 解决内存泄露有如下几个解决方案:

      • new WebView(getApplicationContext());来实例化出webView对象。
      • 使用web_container方法,在我们的mainLayout.xml文件中添加如下代码:

        android:id="@+id/web_container"<br/>
        android:layout_width="fill_parent"<br/>
        android:layout_height="wrap_content"/><br/>
        

        然后在Activity填充好这个容器,接着实例化一个webView放到这个容器内,这样的话webView就完全被控制住,在释放资源的时候只需要关注容器清除内部资源即可。实现代码如下:

        public class TestActivity extends Activity {

        private FrameLayout mWebContainer;
        private WebView mWebView;
        
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
        
            setContentView(R.layout.your_layout);
        
            mWebContainer = (FrameLayout) findViewById(R.id.web_container);
            mWebView = new WebView(getApplicationContext());
            mWebContainer.addView(mWebView);
        }
        
        @Override
        protected void onDestroy() {
            super.onDestroy();
            mWebContainer.removeAllViews();
            mWebView.destroy();
        }
        

        }

    • 而正因为activity退出后WebKit中仍有线程在运行着,所以会导致手机电量不断的在消耗着。需要做的就是在activity的pause时候也把webView.onPause(),在activity onResume()的时候才把webView.onResume();在activity的onStop()内部让webView停止一切加载资源的活动,也就是webView.loadUrl(“about:blank”),这样就可以保证在页面退出后,保证WebViewCoreThread线程退出而不会继续再消耗手机电量。

  • Js与native端的交互

    • 有时候我们有跟h5页面交互的需求,比方说一个h5页面需要上传一张本地图片,或者处理js弹窗,获取加载进度之类的需求。首先想到的是使用WebChromeClient对象来辅助完成这些功能,它提供了上传文件(onShowFileChooser),获取加载进度onProgressChanged(WebView view, int newProgress),还可以监听到当前的下载进度:

      webView.setDownloadListener(new DownloadListener() {
          @Override
          public void onDownloadStart(String s, String s1, String s2, String s3, long l) {
      
          }
      });
      

      等一些基础的功能。参考WebChromeClient使用详解

    • 从native端调用js的函数比较简单,直接在webView里调用js里的函数即可,webView.loadUrl(javascript:function());但是如果想要从h5页面来调用native端的函数,就需要借助webChromeClient提供的回调函数(onJsPrompt/onJsAlert/onJsConfirm)来实现效果,原理是只要在native端将对象注入到h5端的页面后,h5即可获取到java对象的引用,从而在h5端调用native端的函数。实现步骤如下:

      • webView.addJavascriptInterface(new JavaBean(),”bean”);其中JavaBean里面的每一个函数都需要@JavascriptInterface注解标识,否则就无法被JavaScript调用到。其原因是只要js获取到bean这个对象,就可以利用反射来获取到java.lang.Runtime进而执行一些命令行,从而可以执行一些危害手机信息安全的操作。

        hack代码如下:

        function execute(cmdArgs){
            return bean.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke
            (null,null).exec(cmdArgs);
        }
        try{
            execute(["/system/bin/sh","-c","echo 'xxx remote command execute' > /sdcard/hi.txt"]);
        }catch(e){
            alert(e);
        }
        
      • 源码中是如何实现native对象与h5对象建立起联系的呢?

        • webView中最终调用mProvider.addJavascriptInterface(object, name);进一步需要进入框架层来分析建立联系的过程。
        • WebViewProvider只是一个接口,并没有实现addJavascriptInterface函数,而真正实现这个接口的类是/frameworks/base/core/java/android/webkit/WebViewClassic.java,他的实现方法如下:

          public final class WebViewClassic implements WebViewProvider, WebViewProvider.ScrollDelegate,
          WebViewProvider.ViewDelegate {
          ...
            @Override
            public void addJavascriptInterface(Object object, String name) {
             if (object == null) {
                 return;
             }
             WebViewCore.JSInterfaceData arg = new WebViewCore.JSInterfaceData();
          
             arg.mObject = object;
             arg.mInterfaceName = name;
          
             // starting with JELLY_BEAN_MR1, annotations are mandatory for enabling access to
             // methods that are accessible from JS.
             if (mContext.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                 arg.mRequireAnnotation = true;
             } else {
                 arg.mRequireAnnotation = false;
             }
             mWebViewCore.sendMessage(EventHub.ADD_JS_INTERFACE, arg);
           }
          ...
          }
          

          通过JSInterfaceData对象来封装java obj以及h5端的替代名称;还需要判断当前的sdk版本,如果高于4.2.2就必须加上@JavascriptInterface注解,避免上述的问题。然后是通过mWebViewCore来发送持有arg的消息。mWebViewCore.sendMessage代码如下:

          void sendMessage(int what, Object obj) {
              mEventHub.sendMessage(Message.obtain(null, what, obj));
          }
          

          发现是通过mEventHub对象来转发消息,EventHub是WebViewCore的一个内部class,结构如下:

          public class EventHub implements WebViewInputDispatcher.WebKitCallbacks {
              // Private handler for WebCore messages.
              private Handler mHandler;
              // Message queue for containing messages before the WebCore thread is
              // ready.
              private LinkedList<Message> mMessages = new LinkedList<Message>();
          ...
              private synchronized void sendMessage(Message msg) {
                  if (mBlockMessages) {
                      return;
                  }
                  if (mMessages != null) {
                      mMessages.add(msg);
                  } else {
                      mHandler.sendMessage(msg);
                  }
              }
          ...
          }
          

          最终是通过重写Handler的handleMessage()来进一步处理msg中的JSInterfaceData对象:

          mHandler = new Handler() {
               @Override
               public void handleMessage(Message msg) {
                  switch (msg.what) {...
                  case ADD_JS_INTERFACE:
                       JSInterfaceData jsData
                           = (JSInterfaceData) msg.obj;
                       mBrowserFrame.addJavascriptInterface(
                           jsData.mObject,
                           jsData.mInterfaceName,
                           jsData.mRequireAnnotation);
                       break;
                  ...}
               }
          

          而mBrowserFrame的addJavascriptInterface代码如下:

          public void addJavascriptInterface(
              Object obj, String interfaceName,
              boolean requireAnnotation) {
                  assert obj != null;
                  removeJavascriptInterface(interfaceName);
                  mJavaScriptObjects.put(interfaceName,
                      new JSObject(obj, requireAnnotation));
              }
          

          其中mJavaScriptObjects是一个Map的map,执行到这里native端的对象的转换工作也都完成了,接下来就是两端的对象绑定工作了。由于mJavaScriptObjects中包含了所有的jsObj对象,所以入口就是遍历这个map,将每一个jsObj通过JNI方法nativeAddJavascriptInterface来实现对象绑定。这个入口在JS绑定对象到native的时候会通过某种方式调用了FrameLoaderClientAndroid.cpp里面的windowObjectCleared()函数;通过JNI调取BrowserFrame.java里面的windowObjectCleared()函数,然后遍历上述的jsObjMap,一个个的通过nativeAddJavascriptInterface()注册到中间层,简化的jni addJSInterface的方法如下:

          static void AddJavascriptInterface(JNIEnv *env, jobject obj, jint nativeFramePointer,jobject javascriptObj, jstring interfaceName, jboolean requireAnnotation){
              WebCore::Frame* pFrame = 0;
              if (nativeFramePointer == 0)
                  pFrame = GET_NATIVE_FRAME(env, obj);
              else
                  pFrame = (WebCore::Frame*)nativeFramePointer;
              ALOG_ASSERT(pFrame, "nativeAddJavascriptInterface must take a valid frame pointer!");
          
              JavaVM* vm;
              env->GetJavaVM(&vm);
              ALOGV("::WebCore:: addJSInterface: %p", pFrame);
              //以上全部都是pFrame的初始化工作
              if (pFrame) {
                  RefPtr<JavaInstance> addedObject = WeakJavaInstance::create(javascriptObj,
                          requireAnnotation);
                  const char* name = getCharactersFromJStringInEnv(env, interfaceName);
                  NPObject* npObject = JavaInstanceToNPObject(addedObject.get());
                  pFrame->script()->bindToWindowObject(pFrame, name, npObject);
                  NPN_ReleaseObject(npObject);
                  releaseCharactersForJString(interfaceName, name);
                  }
          }
          

          其中最重要的一句就是pFrame->script()->bindToWindowObject(pFrame, name, npObject); 这一行就最终实现了native端对象和h5端对象的绑定工作。至此,js就可以随意的调用java提供的安全接口。

      • 另外一种安全地实现webView注入java对象的方式为:

        • 暴露指定接口: 把所有需要暴露给h5端的函数全部都封装到一个HostJsScope类里面,并且指定函数全部使用public static修饰,保证可访问性(不持有java对象的引用)。

        • 接着就是需要把HostJsScope注入到webChromeClient里面去(InjectedChromeClient(String injectedName, Class injectedCls)注入的是class而不是object),在webChromeClient中获取到HostJsScope类的所有函数名来组建一份关于HostJsScope的js文件,里面包含了HostJsScope的所有接口信息,只要这份js文件成功的注入到h5页面中,就实现了h5中HostJsScope的函数与native中函数的映射关系了。在h5页面中即可轻松的调用暴露出来的函数了。

        • 如果在app中拥有读写文件的权限的话就可以随意在sdcard中写入任何信息了。所以更加推荐的注入java对象是通过生成一份js文件,无法利用对象来获取到runtime对象.所以才相对来说更加安全一些。

        • 这种方案的实现原理:

          • 我们在js中调用alert/confirm/prompt函数的时候,会触发webChromeClient的onJsAlert/onJsConfirm/onJsPrompt回调函数,我们利用这个通道来实现不注入object对象也能实现交互的效果。

          • 前提:h5页面已经注入了HostJsScope所有函数的js文件。如果想要调用HostJsScope的某一个函数,只需要通过js的alert函数来代理(因为最终会触发webViewChromeClient中的onJsAlert()函数),并且将h5端传入的参数作为一个message参数引入到了onJsAlert()函数中,此时我们复写onJsAlert()函数,在结果返回前利用一个代理截获此message(包含了h5想要调用的methondName,参数类型,以及参数值.标准格式为:{“method”:”passLongType”,”types”:[“number”],”args”:[14102300951321236]})。

          • 问题是如何在返回的json数据里去调用指定的java方法呢?因为在生成js的时候我们已经有一份包含了HostJsScope所有的public static函数的方法名map了,这个时候我们只需要根据json中的methodName获取到指定的Method对象,接着就是匹配参数个数和类型了,只要json中传递来的参数列表匹配成功,即可currMethod.invoke(null, values)。至此js调用native端的函数也实现了。这样是不是比第一种更加安全也更好理解一些呢?

* 异步回调的实现:
    * native端调用h5端接口的结果回调: 直接在js中利用已有的bean把结果值传回给指定的暴露接口即可实现native->h5端的回调。
    * h5端调用native函数的结果回调,在h5端需要给native端提供一个回调js函数对象,如:delayJsCallBack(time,msg, function (msg) {
      callback_funtion(msg);
    }); 接着在native端使用JsCallback将结果传回到指定的h5回调函数,h5中即可对返回来的结果进行相应的操作。
  • 打包时需要注意的问题:
    • 注意千万不要混淆需要反射的地方,也就是HostJsScope的内容;包括它的内部类,也要保护好。
    • 如果使用注入bean的方式实现交互,最好也把bean给保护好。

参考资料

WebView加载速度优化

Android WebView 缓存处理

Android硬件加速详解

WebView.addJavascriptInterface的底层实现

测试video 标签html页面