0.前言
前段时间,我的一个ios同事兼大学同学,提出了一个Hybrid开发中的优化方案,人很帅,可能是我见过最帅的程序猿了,下次有机会po他的照片。那么这个优化方案是什么呢?待会再说吧,其实我想表达的是Android和ios同事应该多沟通,其实很多设计思想都很像,可以相互借鉴对方一些优秀的点,就像现在新出的Android机和苹果机也越来越像了。
1.需求场景
就像下图(以Android为例,IOS把Activity替换成ViewControll),在Activity A中的H5 A里打开一个新的网页H5 B,是使用启动了一个Activity B,然后用其中的Webview B去加载H5 B,当用户在H5 B中进行了某些操作,需要让H5 A知道时,就会很麻烦,因为它们是被两个不同的Webview所加载。最常见的例子就是登录、支付了,用户在B页面完成登录或支付行为,返回到A时,希望H5 A刷新。这篇文章,就是用于解决这个问题:实现Hybrid中两个WebView中H5的通信。
额外说明:
1. 打开新的H5,采用了启动新的Activity B去加载,是为了提升用户的体验感,避免出现用户回退时,H5页面刷新的问题。
2. H5里采用document.addEventListener(‘visibilitychange’,function(){})是可以做到监听回到H5 A的事件,但是有些手机会无效,比如:华为KIW-AL10、魅族MX4
3. 以上方式解决的,不限于两个相邻的H5,也不限于1:1的更新,可以支持1:N的刷新,只要保证注册的名字一致,后面会讲到。
2.实现原理
实现原理是依赖于加载H5的WebView之间的通信,借助了观察者模式的思想。这里需要分两步走:
1. H5 A触发WebView A订阅某类事件,以便H5 B发布该类事件时,能够接受到。
2. H5 B发布该类事件,触发WebView A收到消息,并调用H5 A中Js方法。
额外说明:
1. 这里的订阅和发布,用安卓中的广播可以实现,但是考虑其比较“重”,这里使用Handler来实现广播功能。原理如下:为每一个WebView创建了一个Handler对象,并创建了一个全局的Handler集合,用于保存WebView的Handler对象,当初始化WebView时,把Handler放入集合中,当WebView销毁,即调用了其onDetachedFromWindow方法时,把Handler从集合中移除。当WebView A订阅消息时,把订阅名记下来,作为当前WebView对象的属性;当WebView B发布消息时,遍历全局的Handler结合,逐个发送消息,该消息中携带了订阅名、回调方法名、参数等信息,然后在WebView中Handler的handleMessage方法中,对比当前WebView的订阅消息,是否包含WebView B发布的订阅名。如果包含,则用消息中包含的回调方法名和参数,调用当前WebView的js方法,这样就完全了通信。(文字看着晕的话,我们待会举个栗子look look!)
2. 因为采用了Handler来实现广播,所以WebView A不能立即订阅H5 A传递过来的订阅名,这时候就要把该订阅名保存下来,这时候当WebView B需要发布消息的时候,msg.what可以指定一个特定的值,并用msg.obj携带订阅名等信息。WebView A中的handler只要去case到那个特定的值,然后判断msg.obj中是否与WebView中之前保存的订阅名是否匹配就可以了。
3. 订阅和发布的协议格式:
H5 A 订阅:协议名/register/订阅名
H5 B 发布:协议名/trigger/订阅名/回调方法名/参数1/参数2/参数N
以登录为例子:
订阅:josan://register/login
发布:josan://trigger/login/logined/张三/24
3.实现效果
当我们把App里的逻辑实现了以后,看看两个H5通信有多简单,只需要两步:
1.在需要刷新的H5页面完成订阅,并实现回调的Js方法
<script> function openH5B() { location.href = 'josan://register/login'; } function logined(name, age) { document.getElementById("login").innerHTML = name + ',年龄:' + age; document.getElementById("login").style.backgroundColor= "green"; } </script>
2.在另一个触发刷新的H5页面,通过协议发布事件
function triggerLogined(){
location.href = 'josan://trigger/login/logined/张三/24';
}
这样就会调用到前一个需要刷新H5的logined方法,并把张三和24作为参数传递过去。是不是很简单呢!
4.实现细节
1. H5 A触发WebView A订阅
这个页面很简单,先看第17行,这里点击进入H5 B时,触发了第9行的openH5B()方法,方法里很简单,第一行是通过协议去打开Activity B,不用关心,重点是关注第9行,这里其实就是调用了订阅的协议,其中login为订阅名。
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
<script> function openH5B() { location.href = 'josan://openSecondActivity/'; setTimeout(function () { location.href = 'josan://register/login'; }, 0); } </script>
</head>
<body>
<div id="login" style="font-size: 30px;text-align:center;background-color: red" onclick="registerLogin()">未登录</div>
<div style="font-size: 40px;text-align:center" onclick="openH5B()">进入H5 B</div>
</body>
</html>
接下来,会触发WebView A的WebViewClient中的shouldOverrideUrlLoading方法,在这里面,我们就要完成WebView A的订阅,我们先来看看客户端WebView的实现。
2. WebView A订阅
我们自定义了一个MyWebView,继承于WebView
public class MyWebView extends WebView{
private static final String TAG = "MyWebView";
/** 网页需要调用触发协议时,发送该消息 **/
public static final int TRIGGER_INTERACTIVE = 1000;
private List<String> registerKeyArr;
private Context mContext;
private MyHandler mHandler;
public MyWebView(Context context) {
this(context, null);
}
public MyWebView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MyWebView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//初始化
init();
}
/** * 初始化操作 */
private void init() {
mContext = this.getContext();
//创建一个订阅名的数组,用户保存H5注册时的注册名
registerKeyArr = new ArrayList<>();
//允许使用Js
getSettings().setJavaScriptEnabled(true);
//创建一个Handler
mHandler = new MyHandler(this);
//将创建的handler加入到集合中,以便实现广播
MessageUtils.getInstance().registerHandler(this.hashCode()+"", mHandler);
//设置WebViewClient
setWebViewClient(new WebViewClient(){
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
Log.e(TAG, "shouldOverrideUrlLoading: url:" + url);
//如果网页调用了注册协议,比如:josan://register/login
if (url.contains("josan://register/")) { //订阅协议,即H5 A中触发
//这里取出login,作为群发消息的key
String registerName = url.replace("josan://register/", "");
if (!registerKeyArr.contains(registerName)) {
//存入到当前WebView的订阅名集合中
registerKeyArr.add(registerName);
}
} else if (url.contains("josan://trigger/")) { //发布协议,即H5 B中触发
//如果网页调用了触发协议,比如:josan://trigger/login/logined
String triggerNameAndCallback = url.replace("josan://trigger/", "");
MessageUtils.getInstance().sendOverallMessage(TRIGGER_INTERACTIVE, triggerNameAndCallback);
} else if (url.contains("josan://openSecondActivity/")){
//用协议启动第二个Activity
Intent intent = new Intent(mContext, SecondActivity.class);
mContext.startActivity(intent);
} else {
view.loadUrl(url);
}
return true;
}
});
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
//反注册handler
MessageUtils.getInstance().unregisterHandler(this.hashCode()+"");
}
}
这里主要关注的是第22行,在这个构造方法里,调用了init方法,去进行了初始化,我们重点关注这个方法。注释写的很详细了,就不做过多解释了,主要关注shouldOverrideUrlLoading方法里的内容,当我们在H5 A中执行了location.href = ‘josan://register/login’;时,就会触发WebView A的WebViewClient对象的这个方法,我们在第44行用josan://register来拦截该请求,然后取出订阅名,即login,并把它存入到当前WebView A的订阅名的集合中,这样就完成了H5 A中的订阅步骤。
3. H5 B触发WebView B发布协议
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
<script> function triggerLogined(){ location.href = 'josan://trigger/login/logined/张三/24'; } </script>
</head>
<body>
<div style="font-size: 40px;text-align:center" onclick="triggerLogined()">点击登陆</div>
</body>
</html>
这里代码很简单,就是第7行,执行location.href = ‘josan://trigger/login/logined/张三/24’;去触发WebView B发布login事件,其中login为订阅名,loinged为回调方法名(是H5 A中的Js方法),张三/24为参数,这个时候,我们再看看本地WebView拦截到josan://trigger/login/logined/张三/24会执行什么操作。
4. WebView B发布事件
还是回到自定义的WebVie中的shouldOverrideUrlLoading方法
//设置WebViewClient
setWebViewClient(new WebViewClient(){
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
Log.e(TAG, "shouldOverrideUrlLoading: url:" + url);
//如果网页调用了注册协议,比如:josan://register/login
if (url.contains("josan://register/")) { //订阅协议,即H5 A中触发
//这里取出login,作为群发消息的key
String registerName = url.replace("josan://register/", "");
if (!registerKeyArr.contains(registerName)) {
//存入到当前WebView的订阅名集合中
registerKeyArr.add(registerName);
}
} else if (url.contains("josan://trigger/")) { //发布协议,即H5 B中触发
//如果网页调用了触发协议,比如:josan://trigger/login/logined
String triggerNameAndCallback = url.replace("josan://trigger/", "");
MessageUtils.getInstance().sendOverallMessage(TRIGGER_INTERACTIVE, triggerNameAndCallback);
} else if (url.contains("josan://openSecondActivity/")){
//用协议启动第二个Activity
Intent intent = new Intent(mContext, SecondActivity.class);
mContext.startActivity(intent);
} else {
view.loadUrl(url);
}
return true;
}
});
直接看第14行,当WebView B拦截到josan://trigger/login/logined/张三/24时,会先取出其中的订阅名、回调方法名以及参数,即logined/张三/24,然后调用MessageUtils中的sendOverallMessage方法实现广播功能,通知WebView A中的Handler,我们来看看消息是如何实现的。
5. Handler实现广播
public class MessageUtils {
private static Map<String, Handler> sHandlerMap;
private volatile static MessageUtils instance;
private MessageUtils() {
if (sHandlerMap == null) {
sHandlerMap = new HashMap<>();
}
}
public static MessageUtils getInstance() {
if (instance == null ) {
synchronized (MessageUtils.class) {
if (instance == null) {
instance = new MessageUtils();
}
}
}
return instance;
}
/** * 注册handler * @param key 注册handler使用的key * @param handler */
public void registerHandler(String key, Handler handler) {
sHandlerMap.put(key, handler);
}
/** * 反注册handler * @param key */
public void unregisterHandler(String key) {
if (sHandlerMap.containsKey(key)) {
sHandlerMap.remove(key);
}
}
/** * 发送全局的消息,实现类似广播的机制 */
public void sendOverallMessage(int what, Object obj) {
Set<Map.Entry<String, Handler>> set = sHandlerMap.entrySet();
for (Map.Entry<String, Handler> entry : set) {
Handler handler = entry.getValue();
Message msg = Message.obtain();
msg.what = what;
msg.obj = obj;
handler.sendMessage(msg);
}
}
}
这就是一个消息通信的管理类,其中用Map存了所有WebView的Handler对象,是在WebView的init方法中存入的,在WebView的onDetachedFromWindow方法中移除的。广播机制的原理很简单,直接看第48行,其实就是遍历注册到Map中的所有Handler,然后逐个给handler发消息,其中的消息包含了订阅名、回调方法和参数,这里的obj就是”logined/张三/24”。
6. 回调H5 A中的Js方法
因为WebView A和WebView B中的handler都注册了,所以,这两个handler都会收到该消息,这时候就需要通过步骤2中存下来的订阅名来区分,判断前WebView的Handler对象是否订阅了该消息,我们看看Handler中handleMessage方法中的逻辑,它是MyWebView中的一个静态内部类。
private static class MyHandler extends Handler {
private WeakReference<WebView> weakWebView;
public MyHandler(WebView webView) {
weakWebView = new WeakReference<WebView>(webView);
}
@Override
public void handleMessage(Message msg) {
try {
super.handleMessage(msg);
switch (msg.what) {
case TRIGGER_INTERACTIVE: //网页调用触发协议时,格式为: registerName/callbackName/params1/param2/param...
String triggerNameAndCallback = (String) msg.obj;
String[] nameAndCallbackArr = triggerNameAndCallback.split("/");
if (nameAndCallbackArr.length >= 2) {
String triggerName = nameAndCallbackArr[0];
String triggerCall = nameAndCallbackArr[1];
MyWebView webview = (MyWebView) weakWebView.get();
if (webview != null && webview.registerKeyArr.contains(triggerName)) {
String callbackUrl = "javascript:" + triggerCall + "(";
for (int i = 2; i < nameAndCallbackArr.length; i++) {
callbackUrl += "'" + nameAndCallbackArr[i] + "',";
}
if (nameAndCallbackArr.length > 2) {
callbackUrl = callbackUrl.substring(0, callbackUrl.length() - 1);
}
callbackUrl += ")";
Log.e(TAG, "callbackUrl:" + callbackUrl);
webview.loadUrl(callbackUrl);
}
}
break;
default:
break;
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
逻辑很简单,就是先从广播中取出订阅名,这里为login,然后判断当前WebView中的订阅名集合registerKeyArr中是否包含了当前发布的订阅名,显然WebView A中包含,而WebView B中不包含。所以只有WebView A中的handler会满足第20行if条件,继续执行。然后根据回调名和参数(参数可以支持多个,只要跟H5 A中定义的Js方法保持一致)来构造出回调的url,这里其实就是:javascript:logined(‘张三’,‘24‘),最后通过webview.loadUrl方法,触发H5 A 中的logined方法
7. H5 A中定义回调方法
/** * 登录成功的回调方法,用于test2.html中通过josan://trigger/login/logined触发 */
function logined(name, age) {
document.getElementById("login").innerHTML = name + ',年龄:' + age;
document.getElementById("login").style.backgroundColor= "green";
}
这里就是响应WebView A调用的loadUrl,取出其中的名字和年龄现实出来,并把按钮的背景色由原来的红色改成绿色。
这样就完成了H5 B登陆以后,刷新了H5 A页面的数据。
下面是上述实现细节的流程图:
源码地址
https://gitee.com/josan24/WebView_Interactive.git