软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

Hybrid App消息桥接(Android)

以前写过一篇 Hybrid App体验(Android),但是未动手验证Native和Web之间的双向消息通信。今天起床之后,想在Web中启动Android相机与振动通知玩玩,于是开始实现双向的通信,技术特点还是JSBridge。

比较坑的是Android权限管理,几个Android版本还不一样。新版本要调用相机拍照与保存,需要动态申请权限,还需要提供Provider才能给Intent传递文件路径,同时,想在getExternalFilesDir()之外创建文件困难重重。最后,为了避免消耗时间太大,只能放弃保存功能。也许在当前Activity中获取到相机数据之后,自己保存到JPG文件还更加方便。

代码仓库,见 android-webview-1

1. Web侧消息消费订阅与发送

我们主要实现addNativeMessageHandlerremoveNativeMessageHandlersendMessageToNative这3个API函数。

再看看nativeMessageHandler函数,我们约定msgId === '' 时为全局中间件注册的消息ID。其中,使用责任链模式 next()调用多个Handler,且可终止。

// Handle message for Android Native

import Util from './util';

export interface MessageHandlerNext {
  (): void
}

export interface MessageHandler {
  (msgId: string, payload: any, next?: MessageHandlerNext)
}

interface MessageHandlersMapType {
  [msgId: string]: MessageHandler[]
}

const messageHandlersMap : MessageHandlersMapType = {
};

function addNativeMessageHandler(msgId: string, handler: MessageHandler): void {
  if (!Util.isFunction(handler)) {
    throw new Error('Message handler must be a function');
  }

  const handlers = messageHandlersMap[msgId];

  if (handlers) {
    const index = handlers.indexOf(handler);

    if (index === -1) {
      handlers.push(handler);
    }

    return;
  }

  messageHandlersMap[msgId] = [handler];
}

function removeNativeMessageHandler(msgId: string, handler: MessageHandler): void {
  const handlers = messageHandlersMap[msgId];

  if (handlers) {
    const index = handlers.indexOf(handler);

    if (index !== -1) {
      handlers.splice(index, 1);
    }
  }
}

function nativeMessageHandler(msgId: string, payload: string | null | undefined): void {
  const handlers = [...(messageHandlersMap[''] || []), ...(messageHandlersMap[msgId] || [])];

  if (handlers.length === 0) {
    return;
  }

  let payloadObj = payload;

  if (payload && typeof payload === 'string') {
    try {
      payloadObj = JSON.parse(payload);
    } catch (e) {
      console.error(`[ERROR]: Native Message "${msgId}", invalid JSON payload `, e);
    }
  }

  let i = 0;

  const next = () => {
    if (i >= handlers.length) {
      return;
    }

    const handler = handlers[i];
    i += 1;

    const argLen = handler.length;

    if (argLen < 3) {
      handler(msgId, payloadObj);
      next();
    } else {
      handler(msgId, payloadObj, next);
    }
  };

  next();
}

function logMessageToNative(msgId: string, payload: string): void {
  console.info(`[INFO] Message "${msgId}" to native, payload: ${payload}`);
}

const msgLogger = {
  sendMessage: logMessageToNative,
};

function sendMessageToNative(msgId: string, payload?: string): void {
  const injectedNative = (window as any).injectedNative || msgLogger;
  injectedNative.sendMessage(msgId, payload || '');
}

export {
  addNativeMessageHandler,
  removeNativeMessageHandler,
  nativeMessageHandler as default,
  sendMessageToNative,
};

最后,把nativeMessageHandler添加到window变量中。

Object.assign(window, {
  nativeMessageHandler,
});

1.1. Web侧消息应用举例

触发Android Toast:

function toastShow() {
  sendMessageToNative('toast_show', 'Text from home page');
}

消费Android定时器消息:

let nativeTick = 0;

function globalTickHandler(msgId: string, payload: any, next?: MessageHandlerNext) {
  nativeTick += 1;
  next();
}

addNativeMessageHandler('native_tick', globalTickHandler);

function AppExample(): JSX.Element {
  const [count, setCount] = useState(nativeTick);
  const [once] = useState(0);

  useEffect(() => {
    const tickHandler = (msgId: string, payload) => {
      setCount(nativeTick);
    };

    addNativeMessageHandler('native_tick', tickHandler);
    return () => removeNativeMessageHandler('native_tick', tickHandler);
  }, [once]);

  return (
    <p>
        Native tick:
        {' '}
        {count}
    </p>
  );
}

2. Android侧消息消费与发送

Android侧的消息消费就简单实现,没有像Web侧那样考虑可扩展性。先看看class JavaScriptInjectedNative ,我们添加一个msgHandler接口来处理消息:

public class JavaScriptInjectedNative {
    private JsBridgeMsgHandler msgHandler;

    public void SetJsBridgeMsgHandler(JsBridgeMsgHandler handler) {
        msgHandler= handler;
    }

    @JavascriptInterface
    public String sendMessage(String msgId, String payload) {
        if (msgHandler != null) {
            return msgHandler.onJsBridgeMsg(msgId, payload);
        }
        return "Native processed: " + msgId + (payload != null ? ", " + payload : "");
    }
}

MainActivity中就实现该接口:

public class MainActivity extends AppCompatActivity implements JsBridgeMsgHandler {

    private void initWebView() {
        ...
        ...
        JavaScriptInjectedNative injectedNative = new JavaScriptInjectedNative();
        injectedNative.SetJsBridgeMsgHandler(this);
        webView.addJavascriptInterface(injectedNative, "injectedNative");
    }

    @Override
    public String onJsBridgeMsg(String msgId, String payload) {
        if (msgId == null) {
            return "Empty msg id";
        }

        switch (msgId) {
            case "toast_show":
                toastShow(payload);
                break;
            case "camera_open":
                cameraOpen();
                break;
            case "vibrator_notify":
                vibratorNotify();
                break;
            case "event_round":
                toastShow("Event round 1");
                sendMessageToWeb("event_round_back", "");
                break;
            default:
                break;
        }

        return "Message done by Main.";
    }

    private void sendMessageToWeb(String msgId, String payload) {
        String jsContent = "if (this.nativeMessageHandler) { this.nativeMessageHandler("
                + Util.toJsString(msgId) + "," + Util.toJsString(payload) + ")}";
        Log.i("MsgToWeb", "javascript:" + jsContent);

        webView.post(new Runnable() {
            @Override
            public void run() {
                if (Build.VERSION.SDK_INT >= 19 /*Might need 21*/) {
                    webView.evaluateJavascript("javascript:" + jsContent, null);
                }else {
                    webView.loadUrl("javascript:" + jsContent);
                }
            }
        });
    }
}

sendMessageToWeb()函数中,对高版本优先使用webView.evaluateJavascript(),避免页面刷新。因WebView执行JavaScript时只能在WebView的UI线程中执行,所以,使用webView.post()发送只可执行对象。

2.1. Android侧消息应用举例

使用振动通知:

public class MainActivity extends AppCompatActivity implements JsBridgeMsgHandler {
    private void vibratorNotify() {
        String[] permissions = new String[]{
            Manifest.permission.VIBRATE,
        };

        requestPermissions(permissions);
        Vibrator vibrator = (Vibrator) getSystemService(Service.VIBRATOR_SERVICE);

        if (!vibrator.hasVibrator()) {
            toastShow("No vibrator");
            return;
        }

        vibrator.cancel();
        vibrator.vibrate(new long[]{100, 200, 100, 200}, -1);
    }
}

定时发送Tick消息:

public class MainActivity extends AppCompatActivity implements JsBridgeMsgHandler {
    private Timer tickTimer;

    private void startTick() {
        tickTimer = new Timer();
        TimerTask task = new TimerTask() {
            @Override
            public void run() {
                sendMessageToWeb("native_tick", "");
            }
        };

        tickTimer.schedule(task, 1000, 1000);
    }

    private void closeTick() {
        tickTimer.cancel();
    }
}

3. 注意事项

因我们使用android_asset本地静态资源代替HTTP资源,需要注意以下事项:

  • 把Web侧构建的dist目录复制到app/src/main/assets目录中。
  • React Router中需要使用HashRouter
  • Public URL需要改成相当index.html的路径,如"./",见 web/script/build.js。
  • 使用本地静态资源服务之后,HTTP请求不能跨域访问后端,这时需要考虑使用JsBridge技术让Android侧处理HTTP请求。

使用android_asset技术优化资源加载,会有新的问题待解决,:)

开始浏览页面:

webView.loadUrl("file:///android_asset/index.html");

Home Page Tick Count