Android与H5交互框架实践(上)

为啥要搞Hybrid,趋势!网上找了很多资料,最适合自己项目的类似下面的项目,自己比较笨,所以把别人的项目从头到尾敲了一遍,对此在其基础上加上自己的理解还有遇到的一些问题着重讨论并且加以解决。很感谢这个项目,链接如下。
https://github.com/quickhybrid/quickhybrid-android
先看下网上实现H5交互有哪些方式,再看看优劣。
常见的网上几种方式(图很模糊将就一下)

Android与H5交互框架实践(上)_第1张图片
图片1.png

如上图所示,Js端调用Android4.2以下用的注入的方式,这个我很老的一篇文章说过,实现起来很简单,用法也比较简单,但是不太利于交互api比较多的情况,加之也有泄露的可能,还有目前手机系统基本都在5.0以上,所以没有采用这种方式。
那排除上述的方式,无论是Andoird调用Js还是Js调用Android,个人总结就两个字,“拦截”!
先着重讨论下Android调用Js。图上说的很明白,通过Client拦截Url,这里有两个Client。一个是WebViewClient,一个是WebChromeClient。其实没啥区别,一个通过WebViewClient的shouldOverrideUrlLoading()去拦截,一个通过WebChromeClient的onJsPrompt()去拦截。WebChromeClient里面的回调更全面一点,功能多点,我的项目里面是两个都要用到的后面会解释。
我的项目里和H5那边约定拦截的是Prompt方法,也就是说Js端调用他们promt的方法后,我们这边会有响应。

WebChromeClient里面这个方法
public boolean onJsPrompt(WebView webView, String url, String message, String defaultValue, JsPromptResult result)

这个方法里面,其余的参数啥意思,传什么自己去了解,我这边就不说了,你想办法让JS端传递一个消息过来,这样你会在String message这个里面拿到穿过来的值,既然能拿到这个值,那么做的事情就相当多了。你可以传递参数,传递方法名,传递任何你们约定好的东西,Android再去做解析,做对应的操作。我这里写个伪代码,有个注意事项。

        @Override
        public boolean onJsPrompt(WebView webView, String url, String message, String defaultValue, JsPromptResult result){
            Uri uri = Uri.parse(message);
            try {
               //自己去解析
            } catch (Exception e) {
                e.printStackTrace();
            }
            //这里如果不给JS端返回结果,Js是无法继续下去的,类似于一直等待Andorid的消息,会阻塞
            result.confirm(null);
            return true;
        }

代码里面这样写Uri uri = Uri.parse(message);quickhybrid这个项目是自定义的协议,所以传递过来的Message其实是一个类似于http://xxx/xxx/xxx/xx````这样的东西。那这样的话,可以根据url传带不同字段,去进行处理。quickhybrid根据uri.getSCHEM()获得协议的名字,做对应拦截,比如你的协议是nicole://auth/xxxxxx....那么我这边只对nicole的url进行相关的解析,其余的不处理。

//几个方法自行了解下,对解析url很有帮助
uri.getAuthority  
uri.getPath
uri.getPathSegments() 
uri.getQueryParameter
uri.getQuery();

好了到这里,我觉得咱们已经可以去交互了,但是正式的项目有很多交互的api,还有的方法有回调,怎么处理?划分模块!类似于通过url协议里面把
“模块名称”,"方法名称","H5的回调的id传递过来"(这个回调的id暂时不用看)。划分模块真的很重要,偌大的一个项目,比如一个二维码扫描的功能,
h5那边,想做到调用只需要xxx.util.scan(xxx代表那边内置的封装的对象,不用了解)util就是util模块,scan就是Android就是方法名。

也就是协议里类似于这样
nicole://util:callbackId01/scan/参数的Json
nicole协议名称
util模块名
callbackId01回调名称
scan方法名
参数的Json Js的通过Json传递方法需要的参数

对相应的解析后,我们就需要反射本地的类拿到方法,然后进行处理。
我这边处理大致是这样的:

一个类JsBridgeEngine里面核心是包含
一个 Map> jsModuleMethodsMap
key值是模块的别名(这里为什么要定义一个别名,其实是js端和android端同时开发的,字段没定义好,所以要在本地产生一个映射。)
别名其实是为了拿到里面的那个Map,里面的那个Map的key是真正的模块名即Class,Value是需要反射执行的Method对象。
然后反射创建Class对象,invoke执行Method对象。

说的有点抽象,下面放上部分代码
util模块

@ModuleName("util")
public class JsUtil implements JsModuleApi {
    /**
     * 二维码扫描
     */
    public void scanQRCode(WebView webView, String json, JavaCallback methodCallback, JsViewInterface jsViewInterface) {
        //伪代码
        QRCodeScanActivity.startQRCodeScanner(webView.getContext(), new OnQRScanListenerImpl() {
            @Override
            public void onScanQRCodeSuccess(String result) {
                HashMap params = new HashMap<>(2);
                params.put("resultData", result);
                methodCallback.onSuccess(webView, params);
            }
        });
    }
}

上面ModuleName是我自己定义的注解,为了是将JsUtil和util绑定起来。JsModuleApi是自己定义的一个空接口,所有的模块都要实现它,这样为了反射的时候能有一个实例化对象去执行方法。
大家都知道反射需要Class,怎么拿到Class?我之前在xml里面配置每个类的路径,然后根据Js传递过来的模块名去拿,后来想想在xml里面配置太麻烦,所以通过反射某个包名下面的所有类,然后放到map里面。这里放上相关代码。

public class ClassUtils {
    private static final String EXTRACTED_NAME_EXT = ".classes";
    private static final int VM_WITH_MULTIDEX_VERSION_MAJOR = 2;
    private static final int VM_WITH_MULTIDEX_VERSION_MINOR = 1;
    private static final String PREFS_FILE = "multidex.version";
    private static final String KEY_DEX_NUMBER = "dex.number";
    private static final String SECONDARY_FOLDER_NAME = "code_cache" + File.separator + "secondary-dexes";
    private static final String EXTRACTED_SUFFIX = ".zip";

    private static SharedPreferences getMultiDexPreferences(Context context) {
        return context.getSharedPreferences(PREFS_FILE, Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB ? Context.MODE_PRIVATE : Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS);
    }

    /**
     * 通过指定包名,扫描包下面包含的所有的ClassName
     */
    public static List getFileNameByPackageName(Context context, String packageName) throws PackageManager.NameNotFoundException, IOException {
        List classNames = new ArrayList<>();
        for (String path : getSourcePaths(context)) {
            DexFile dexfile;
            if (path.endsWith(EXTRACTED_SUFFIX)) {
                //NOT use new DexFile(path), because it will throw "permission error in /data/dalvik-cache"
                dexfile = DexFile.loadDex(path, path + ".tmp", 0);
            } else {
                dexfile = new DexFile(path);
            }
            Enumeration dexEntries = dexfile.entries();
            while (dexEntries.hasMoreElements()) {
                String className = dexEntries.nextElement();
                if (className.contains(packageName)) {
                    String[] strings = className.split("\\$");
                    String outerClassName = strings[0];
                    CLog.d("outerClassName" + outerClassName);
                    if (!classNames.contains(outerClassName)) {
                        classNames.add(outerClassName);
                    }
                }
            }
        }
        CLog.d("Scan " + classNames.size() + " classes by packageName <" + packageName + ">");
        CLog.d("List" + classNames.toString());
        return classNames;
    }

    /**
     * get all the dex path
     *
     * @param context the application context
     * @return all the dex path
     * @throws PackageManager.NameNotFoundException
     * @throws IOException
     */
    public static List getSourcePaths(Context context) throws PackageManager.NameNotFoundException, IOException {
        ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
        File sourceApk = new File(applicationInfo.sourceDir);

        List sourcePaths = new ArrayList<>();
        sourcePaths.add(applicationInfo.sourceDir); //add the default apk path

        //the prefix of extracted file, ie: test.classes
        String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;

        if (!isVMMultidexCapable()) {
            //the total dex numbers
            int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1);
            File dexDir = new File(applicationInfo.dataDir, SECONDARY_FOLDER_NAME);

            for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
                //for each dex file, ie: test.classes2.zip, test.classes3.zip...
                String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
                File extractedFile = new File(dexDir, fileName);
                if (extractedFile.isFile()) {
                    sourcePaths.add(extractedFile.getAbsolutePath());
                    //we ignore the verify zip part
                } else {
                    throw new IOException("Missing extracted secondary dex file '" + extractedFile.getPath() + "'");
                }
            }
        }

        return sourcePaths;
    }

    /**
     * Identifies if the current VM has a native support for multidex, meaning there is no need for
     * additional installation by this library.
     *
     * @return true if the VM handles multidex
     */
    private static boolean isVMMultidexCapable() {
        boolean isMultidexCapable = false;
        String vmName = null;

        try {
            if (isYunOS()) {
                vmName = "'YunOS'";
                isMultidexCapable = Integer.valueOf(System.getProperty("ro.build.version.sdk")) >= 21;
            } else {
                vmName = "'Android'";
                String versionString = System.getProperty("java.vm.version");
                if (versionString != null) {
                    Matcher matcher = Pattern.compile("(\\d+)\\.(\\d+)(\\.\\d+)?").matcher(versionString);
                    if (matcher.matches()) {
                        try {
                            int major = Integer.parseInt(matcher.group(1));
                            int minor = Integer.parseInt(matcher.group(2));
                            isMultidexCapable = (major > VM_WITH_MULTIDEX_VERSION_MAJOR)
                                    || ((major == VM_WITH_MULTIDEX_VERSION_MAJOR)
                                    && (minor >= VM_WITH_MULTIDEX_VERSION_MINOR));
                        } catch (NumberFormatException ignore) {
                            // let isMultidexCapable be false
                        }
                    }
                }
            }
        } catch (Exception ignore) {

        }
        return isMultidexCapable;
    }

    /**
     * 判断系统是否为YunOS系统
     */
    private static boolean isYunOS() {
        try {
            String version = System.getProperty("ro.yunos.version");
            String vmName = System.getProperty("java.vm.name");
            return (vmName != null && vmName.toLowerCase().contains("lemur"))
                    || (version != null && version.trim().length() > 0);
        } catch (Exception ignore) {
            return false;
        }
    }
}

上面扫描的时候会出现一定的问题,不要在Android instance run情况下去扫描,那样不会扫描出来的。
JsBridge核心代码

public final class JsBridgeEngine {

    /**
     * 所有api包下面的类的集合
     */
    private static List jsApiPackNameList;

    /**
     * moduleName 映射 Class
     */
    private static Map> jsModuleClassMap;


    /**
     * 存放方法的
     */
    private static Map> jsModuleMethodsMap = new HashMap<>();


    public static void init(Context context) {
        try {
            jsApiPackNameList = ClassUtils.getFileNameByPackageName(context, JsConstants.API_PACK_NAME);
            jsModuleClassMap = getModuleClassMap(jsApiPackNameList);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


    /**
     * 注册模块
     *
     * @param moduleName 模块名称
     */
    public static void register(String moduleName) throws JsException {
        Class clazz = jsModuleClassMap.get(moduleName);
        if (clazz == null) {
            throw new JsException(String.format("注册失败,Js传过来的模块名%s称有误,找不到模块", moduleName));
        }
        if (!jsModuleMethodsMap.containsKey(moduleName)) {
            jsModuleMethodsMap.put(moduleName, getModuleMethods(clazz));
        }
    }

    /**
     * 反射执行模块里面的方法
     */
    public static void callJavaMethod(WebView webView, String message, JsViewInterface jsViewInterface) throws Exception {
        Uri uri = Uri.parse(message);

        String authority = uri.getAuthority();
        if (TextUtils.isEmpty(authority)) {
            return;
        }

        //解析的操作xxxxxxxxxxxxxx这里不贴了,一顿操作拿到moduleName

        Class clazz = jsModuleClassMap.get(moduleName);
        JsModuleApi jsModuleApi = clazz.newInstance();
        Map methodMap = jsModuleMethodsMap.get(moduleName);
        if (methodMap == null) {
            throw new JsException(String.format("没有注册%s的模块", moduleName));
        }
        Method method = methodMap.get(methodName);
        if (method == null) {
            throw new JsException(String.format("没有%s的方法", methodName));
        }
        method.invoke(jsModuleApi, webView, decode, new JavaCallback(callBackId), jsViewInterface);
    }


    /**
     * 获得class模块里面所有的方法
     *
     * @param clazz
     * @return map key是模块名  value 是方法
     */
    private static Map getModuleMethods(Class clazz) {
        HashMap methodsMap = new HashMap<>();
        Method[] methods = clazz.getDeclaredMethods();
        for (Method method : methods) {
            String methodName = method.getName();
            Class[] parameters = method.getParameterTypes();
            if (null != parameters && parameters.length == 4) {
                if (parameters[0] == WebView.class &&
                        parameters[1] == String.class && parameters[2] == JavaCallback.class && parameters[3] == JsViewInterface.class) {
                    methodsMap.put(methodName, method);
                }
            }
        }
        return methodsMap;
    }

    /**
     * 获得本地所有JsApi的模块,但是还没有实例化
     */
    private static Map getModuleClassMap(List jsApiPackNameList) throws Exception {
        HashMap> jsModuleClassMap = new HashMap<>();
        for (String classPath : jsApiPackNameList) {
            Class clazz = null;
            try {
                clazz = (Class) Class.forName(classPath);
            } catch (Exception e) {
                CLog.debug("class not found");
                continue;
            }
            ModuleName annotation = clazz.getAnnotation(ModuleName.class);
            if (annotation == null) {
                throw new JsException(String.format("请设置%s的ModuleName", clazz.getSimpleName()));
            }

            String key = annotation.value();
            if (TextUtils.isEmpty(key)) {
                throw new JsException(String.format("%s的ModuleName的值不能为空", clazz.getSimpleName()));
            }

            if (jsModuleClassMap.containsKey(key)) {
                throw new JsException(String.format("%s的ModuleName的%s不能重复", clazz.getSimpleName(), key));
            }
            jsModuleClassMap.put(key, clazz);
        }
        return jsModuleClassMap;
    }

    public static List getJsApiPackNameList() {
        return jsApiPackNameList;
    }

    public static Map> getJsModuleClassMap() {
        return jsModuleClassMap;
    }

    /**
     * 只有初始化了才会被加入进来
     */
    public static Map> getJsModuleMethodsMap() {
        return jsModuleMethodsMap;
    }
}

上面的操作都执行后,基本在webView拦截就这样操作就可以了

      @Override
        public boolean onJsPrompt(WebView webView, String url, String message, String defaultValue, JsPromptResult result) {
            Uri uri = Uri.parse(message);
            try {
                   //省略一些代码
                    JsBridgeEngine.callJavaMethod(webView, message, ((JsViewInterface) webView.getContext()));  
            } catch (Exception e) {
                e.printStackTrace();
            }
            result.confirm(null);
            return true;
        }

这样便完成了js端调用Android端的操作。(有的核心代码没有贴出来,很多都是伪代码,思路应该还算清晰吧-_-!)
太长了,分几个篇章写吧,下次分享下,回调的处理,还有资源本地化的处理,因为WebView网速的问题,所以某些CSS,Js文件放本地是很有必要的。另外还有一些乱七八糟的问题,会接下来和大家分享。

说说题外话,看了看之前自己的,虽然发表的不多,其实自己好多写了一半,都没发布,一是自己比较菜,写不了什么高深的东西,二是自己比较懒。接下来一段时间自己准备把以前的没写完的都写好,知识是越学越多,忘得也快,真的需要记录下来,自己还需要继续努力。

你可能感兴趣的:(Android与H5交互框架实践(上))