AndFix的使用分析

前言

最近发现热修复比较火,很多文章也做了介绍。所以自己也简单的学习下。因为自己在实际项目中并没有用到。所以为了防止忘记,写成博客做成笔记,同时也帮助一些没有接触过的小伙伴能快速使用与入门。废话少说。进入主题。


热修复的概念

上面是热修复。简单解释就是在线更新。比如我们已发布的应用突然产生了严重的BUG,按照旧方法,只能能下一次版本修复后重新发布。然后用户重新去下载。这样其实给用户的体验就很不好。既浪费流量同时在重新下载的时候也会产生用户流失等等一系列影响。可能只是一个小小的BUG就到时用于的流失,显然不是我们想看到的。那么有没有上面方法可以在不发布新版本,重新下载的情况下修复BUG呢?因此热修复技术应运而生。下面我们来现在现在比较火的而修复都有那些:

图片.png

这4中热修复技术各有优缺点他们分别是微信-Tinker,QQ空间超级补丁-QZone,阿里-AndFix,美团-Robust。从图中我们也可以了解到。功能最少的是AndFix,Tinker最复杂。当然这不是决定他们好坏的标准。具体的选择还是根据自身的时机情况而定。而选择学习的话,选一个最容易和一个最复杂的。在熟练后理解其他的也会水到渠成。


AndFix

这篇文章就先来学习下AndFix。关于Tinker请参考我的另一篇文章

  • 特点: 与Tinker相比他的特点就是即生效,不过只能修复方法级别的BUG,不支持gradle。他的修复流程这里就不过多介绍了,详细见AndFix官网。用一句话总结,就是找到BUG的方法,修改后生成apatch文件并通过注解标记修复的方法。在修复时就加载修复补丁文件,完成修复。下面我们就来具体使用下。具体步骤如下:
  1. 引入依赖
        //引入AndFix热修复模块
        compile 'com.alipay.euler:andfix:0.5.0@aar'

可以看到和我们平时引用其他库基本是一样的,非常简单。

  1. 核心方法 其实不光他的引入非常简单,使用起来也是非常简单。主要的Api就4个方法。如下:
        PatchManager patchManager = new PatchManager(this); //创建PatchManager 他是Andfix的核心类
        patchManager.init(appVersion);//初始化 传入应用版本号
        patchManager.loadPatch(); //加载 官网建议我们初始化完成后就调用
        patchManager.addPatch(path); //传入指定文件 修复BUG

好了应用层的核心代码就这些了。下面就来实现一个有BUG的应用,然后利用AndFix去修复。代码如下: XML:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:context="com.example.ggxiaozhi.hotfix.MainActivity">
    
        <Button
            android:onClick="onClick"
            android:text="Bug"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
        <Button
            android:onClick="onFix"
            android:text="Fix"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
    </LinearLayout>

MainActivity代码:

    public class MainActivity extends AppCompatActivity {
    
        private static final String FILE_END = ".apatch";//规定修复补丁的文件格式是.apatch文件
        private String mPatchDir;//修复补丁的存放路径
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
            //最后的文件所在路径为storage/emulated/0/Android/data/com.example.ggxiaozhi.hotfix/cache/apatch/
            mPatchDir = getExternalCacheDir().getAbsolutePath() + "/apatch/";
            //创建文件夹
            File file = new File(mPatchDir);
            if (!file.exists()) {
                file.mkdir();
            }
    
        }
    
        private void showToast() {
            boolean isShow = false;
            String str = "存在一个BUG";
            if (isShow) {
                str = "方法BUG修复完成";
            }
            Toast.makeText(this, str, Toast.LENGTH_SHORT).show();
        }
    
        public void onClick(View view) {
            showToast();
        }
    
        public void onFix(View view) {
            AndFixPatchManager.getInstance().addPatch(getPatchName());
        }
    
        //加载修复文件的文件名
        public String getPatchName() {
            return mPatchDir.concat("andfix").concat(FILE_END);
        }
    }

代码也非常简单。这里讲解下。首先定义2个常量,一个是固定了我们加载补丁的文件格式。另一个是补丁文件所在的文件夹。这里使用的是应用的内部文件夹下的apatch文件夹。这个就是放补丁文件的文件夹位置。前面我们说了AndFix只能修复方法级的BUG,所以正常情况下点击产生BUG会Toast:存在一个BUG,当我们修复完成够就会Toast:方法BUG修复完成。道理很简单。现在我们要做的就是生成一个有问题的带签名apk(关于如何生成带签名的apk这里我就不过多介绍了)。生成后改名为old.apk,也就是存在BUG的apk。

提示:这里我们用到的AndFixPatchManager只不过是对PatchManager的简单封装:

AndFixPatchManager:

    public class AndFixPatchManager {
    
        private static AndFixPatchManager mInstance;
    
        private static PatchManager mPatchManager;
    
        public static AndFixPatchManager getInstance() {
            if (mInstance == null) {
                synchronized (AndFixPatchManager.class) {
                    if (mInstance == null) {
                        mInstance = new AndFixPatchManager();
                    }
                }
            }
            return mInstance;
        }
    
        /**
         * 初始化AndFix
         *
         * @param context 上下文
         */
        public void initPatch(Context context) {
            //初始化
            mPatchManager = new PatchManager(context);
            mPatchManager.init(Utils.getVersionName(context));
            //加载patch
            mPatchManager.loadPatch();
        }
    
        /**
         * 加载我们的Patch文件
         * @param path .patch文件  路径
         */
        public void addPatch(String path) {
            try {
                if (mInstance!=null){
                    mPatchManager.addPatch(path);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

这里没有什么难点,不过别忘了在Application中初始化调用initPatch()方法,同时要加上读写内存卡的权限

  1. 修改BUG

现在我们想修复这个BUG,那么我们修改代码如下:

        ...省略
        
       private void showToast() {
            boolean isShow = true;
            String str = "存在一个BUG";
            if (isShow) {
                str = "方法BUG修复完成";
            }
            Toast.makeText(this, str, Toast.LENGTH_SHORT).show();
        }
        
            ...省略

这里也非常简单,我们只是把isShow改成true,这样修复后就与old.apk打印的结果不同。完成修复。省略部分完全不变。

  1. 命令行生成.apatch补丁文件 准备工作已经完成了下面我们利用Andfix工具来生成.patch补丁文件。下载完成后目录如下:

图片.png 这里对应.bat是Window用来生成补丁文件。另一个是mac生成补丁文件。我这里用的是Window所以我用的是.bat(如果想直接在命令行使用别忘记配置环境变量)。同时把有BUG的old.apk文件与修复BUG后的new.apk文件同时将签名文件一起放在这个目录下。完成这些后此路径如下:

图片.png 完成这些后使用命令行输入如下:

QQ图片20180114151621.png

这里为了考虑一些基础比较差的小伙伴。来简单说明下参数: 首先有可以看到有2个命令

  1. -f 这个是用来生成.apatch补丁文件的;
  2. -m 是用来合并多个.apatch文件的。

这里我们是生成所以用到第一个命令。这里需要填入的参数分别为:

  • -a 签名文件的别名
  • -e 签名文件别名的密码
  • -f 修改BUG的.apk文件
  • -k 签名文件的路径
  • -n 签名文件的名字
  • -o 补丁文件的输出文件夹路径(没有会自动创建)
  • -p 签名文件密码
  • -t 存在BUG的.apk文件 在明白这些后我们就会在outputs文件下生成.apatch文件。在实际开发中我们可以将这个文件方法服务器上。然后用户去拉取文件下载到指定目录,或是服务器主动推到用户应用上。这里为了简便我是直接将补丁文件装到了上面我们定义的应用内部路径上的(生成的.apatch文件的名字记得要修改,因为前面我们定义了文件名为annfix.apatch文件。避免找不到)。
  1. 修复BUG

最后直接运行后点击修复Button就完成修复了。这里我用的是真机。截图有些麻烦就不给大家截图了。亲测有效。

  1. 总结 上面就是Andfix的使用。下面我们来总结下
    • 首先热修复都存在一些兼容性的问题如果选择Andfix(其他也一样)要做好兼容性的处理。我在使用时先用的小米2A(api=19)修复失败。魅蓝Note(api=21)修复成功。如果流程没有问题看看是不是机型不支持热修复
    • 只能修复方法级别的BUG。通过官网我们也已经知道了。所以它无法添加新类和新字段。.无法动态加入新功能模块,有别于dex的替换。他的思路如下:

    图片.png 但是也不是所有的方法级别的BUG都能修复。如:

image 注 此图是通过其他文章所得,具体实际情况还需要自己实践

  • 因为他本身是一个依赖,所以在混淆等操作时处理一致。

一些基本的注意点和总结目前就这些,待以后熟练后在加入一些新的要注意的点。


AndFix原理分析

说道原理分期,那我们就不得不去看下他的源码。下面我们就从上面提到的4个API入手看下。

    mPatchManager = new PatchManager(context);

我们先看下这个方法:

    public class PatchManager {
        private static final String TAG = "PatchManager";
        // patch extension
        private static final String SUFFIX = ".apatch";
        private static final String DIR = "apatch";
        private static final String SP_NAME = "_andfix_";
        private static final String SP_VERSION = "version";
    
        /**
         * context
         */
        private final Context mContext;
        /**
         * AndFix manager 真正修复的类
         */
        private final AndFixManager mAndFixManager;
        /**
         * patch directory 修复补丁路径
         */
        private final File mPatchDir;
        /**
         * patchs .apatch文件包装类
         */
        private final SortedSet<Patch> mPatchs;
        /**
         * classloaders 
         */
        private final Map<String, ClassLoader> mLoaders;
    
        /**
         * @param context
         *            context
         */
        public PatchManager(Context context) {
            mContext = context;
            mAndFixManager = new AndFixManager(mContext);
            mPatchDir = new File(mContext.getFilesDir(), DIR);
            mPatchs = new ConcurrentSkipListSet<Patch>();
            mLoaders = new ConcurrentHashMap<String, ClassLoader>();
        }
        
....
    }

首先在PatchManager类中定义了几个常量,同时在构造方法中进行初始化。下面我们接着看第二个api:

    mPatchManager.init(Utils.getVersionName(context));

PatchManager#init()方法如下:

        ...
    
    /**
         * initialize
         * 
         * @param appVersion
         *            App version
         */
        public void init(String appVersion) {
            //如果文件路径不存在  直接返回
            if (!mPatchDir.exists() && !mPatchDir.mkdirs()) {// make directory fail
                Log.e(TAG, "patch dir create error.");
                return;
                //如果不是路径 直接返回
            } else if (!mPatchDir.isDirectory()) {// not directory
                mPatchDir.delete();
                return;
            }
            //读取数据存储XML中版本号
            SharedPreferences sp = mContext.getSharedPreferences(SP_NAME,
                    Context.MODE_PRIVATE);
            String ver = sp.getString(SP_VERSION, null);
            if (ver == null || !ver.equalsIgnoreCase(appVersion)) {//如果.apatch文件中的版本号与上次不同说明进行了版本迭代 那么就删除所有的.apatch补丁文件
                cleanPatch();
                sp.edit().putString(SP_VERSION, appVersion).commit();
            } else {
            //初始化补丁文件
                initPatchs();
            }
        }
    
        private void initPatchs() {
        //得到补丁文件夹下的所有补丁文件集合
            File[] files = mPatchDir.listFiles();
            for (File file : files) {
                //遍历集合   将文件封装成Patch类
                addPatch(file);
            }
        }
    
        /**
         * add patch file
         * 
         * @param file
         * @return patch
         */
        private Patch addPatch(File file) {
            Patch patch = null;
            if (file.getName().endsWith(SUFFIX)) {//判断传入的文件是否是补丁文件格式
                try {
                    // 将文件封装成Patch类
                    patch = new Patch(file);
                    //加入集合
                    mPatchs.add(patch);
                } catch (IOException e) {
                    Log.e(TAG, "addPatch", e);
                }
            }
            //返回
            return patch;
        }
    
    //版本升级则删除补丁文件
        private void cleanPatch() {
            File[] files = mPatchDir.listFiles();
            for (File file : files) {
                mAndFixManager.removeOptFile(file);
                if (!FileUtil.deleteFile(file)) {
                    Log.e(TAG, file.getName() + " delete error.");
                }
            }
        }
        
        ...

这里省略其他代码,主要的代码都有注释。大致流程就是,在调用init()初始化的时候,先判断有没有版本更新。补丁文件与应用版本一致那么就会遍历补丁文件夹下的所有文件并封装成Patch类同时加入mPatchs集合中。那么我来看下Patch类主要封装了些神马:

    public class Patch implements Comparable<Patch> {
        private static final String ENTRY_NAME = "META-INF/PATCH.MF";
        private static final String CLASSES = "-Classes";
        private static final String PATCH_CLASSES = "Patch-Classes";
        private static final String CREATED_TIME = "Created-Time";
        private static final String PATCH_NAME = "Patch-Name";
    
        /**
         * patch file
         */
        private final File mFile;
        /**
         * name
         */
        private String mName;
        /**
         * create time
         */
        private Date mTime;
        /**
         * classes of patch key: 补丁文件名 value:修改了那些类,这些类的信息
         */
        private Map<String, List<String>> mClassesMap;
    
        public Patch(File file) throws IOException {
            mFile = file;
            init();
        }
    
        @SuppressWarnings("deprecation")
        private void init() throws IOException {
            JarFile jarFile = null;
            InputStream inputStream = null;
            try {
                //将补丁文件封装成JarFile
                jarFile = new JarFile(mFile);
                JarEntry entry = jarFile.getJarEntry(ENTRY_NAME);
                inputStream = jarFile.getInputStream(entry);
                Manifest manifest = new Manifest(inputStream);
                Attributes main = manifest.getMainAttributes();
                //获取补丁文件的name
                mName = main.getValue(PATCH_NAME);
                mTime = new Date(main.getValue(CREATED_TIME));
    
                mClassesMap = new HashMap<String, List<String>>();
                Attributes.Name attrName;
                String name;
                List<String> strings;
                //遍历补丁文件中修改了那些类
                for (Iterator<?> it = main.keySet().iterator(); it.hasNext();) {
                    attrName = (Attributes.Name) it.next();
                    name = attrName.toString();
                    if (name.endsWith(CLASSES)) {
                        strings = Arrays.asList(main.getValue(attrName).split(","));
                        //判断传入的文件格式是否是我们Andfix能够处理的格式
                        if (name.equalsIgnoreCase(PATCH_CLASSES)) {
                            mClassesMap.put(mName, strings);
                        } else {
                            mClassesMap.put(
                                    name.trim().substring(0, name.length() - 8),// remove
                                                                                // "-Classes"
                                    strings);
                        }
                    }
                }
            } finally {
                if (jarFile != null) {
                    jarFile.close();
                }
                if (inputStream != null) {
                    inputStream.close();
                }
            }
    
        }
        
        ...
    }    

Patch类中的核心代码就init()方法。将我们的补丁文件封装成jarFile格式,然后去解析。得到补丁文件中修改了BUG的类的信息。并将其放到mClassesMap集合中并对外提供方法方便其他类调用。

我们对第二个api:PatchManager#init()方法分析完成后,下面我们在对第三个API分析下:PatchManager#loadPatch():

        /**
         * load patch,call when application start
         * 
         */
        public void loadPatch() {
            mLoaders.put("*", mContext.getClassLoader());// wildcard
            Set<String> patchNames;
            List<String> classes;
            for (Patch patch : mPatchs) {
                patchNames = patch.getPatchNames();
                for (String patchName : patchNames) {
                    classes = patch.getClasses(patchName);
                    mAndFixManager.fix(patch.getFile(), mContext.getClassLoader(),
                            classes);
                }
            }
        }

可以看到这个方法的注解 加载补丁文件,在我们的Application启动的时候,这个是为什么我们在初始化的时候就调用loadPatch()方法的原因。可以看到这个就是讲我们前面在AndFix指定的目录下得到的patch文件集合进行遍历并调用mAndFixManager.fix()方法。看来这个方法才是真正修复BUG的。

    public synchronized void fix(File file, ClassLoader classLoader,
                List<String> classes) {
                //进行一些安全性的判断
            if (!mSupport) {
                return;
            }
            if (!mSecurityChecker.verifyApk(file)) {// security check fail
                return;
            }
    
            try {
                File optfile = new File(mOptDir, file.getName());
                boolean saveFingerprint = true;
                if (optfile.exists()) {
                    // need to verify fingerprint when the optimize file exist,
                    // prevent someone attack on jailbreak device with
                    // Vulnerability-Parasyte.
                    // btw:exaggerated android Vulnerability-Parasyte
                    // http://secauo.com/Exaggerated-Android-Vulnerability-Parasyte.html
                    if (mSecurityChecker.verifyOpt(optfile)) {
                        saveFingerprint = false;
                    } else if (!optfile.delete()) {
                        return;
                    }
                }
                
                //根据补丁文件创建DexFile
                final DexFile dexFile = DexFile.loadDex(file.getAbsolutePath(),
                        optfile.getAbsolutePath(), Context.MODE_PRIVATE);
    
                if (saveFingerprint) {
                    mSecurityChecker.saveOptSig(optfile);
                }
     
                //找到那些需要修复并且有注解的类
                ClassLoader patchClassLoader = new ClassLoader(classLoader) {
                    @Override
                    protected Class<?> findClass(String className)
                            throws ClassNotFoundException {
                        Class<?> clazz = dexFile.loadClass(className, this);
                        if (clazz == null
                                && className.startsWith("com.alipay.euler.andfix")) {
                            return Class.forName(className);// annotation’s class
                                                            // not found
                        }
                        if (clazz == null) {
                            throw new ClassNotFoundException(className);
                        }
                        return clazz;
                    }
                };
                Enumeration<String> entrys = dexFile.entries();
                Class<?> clazz = null;
                while (entrys.hasMoreElements()) {
                    String entry = entrys.nextElement();
                    if (classes != null && !classes.contains(entry)) {
                        continue;// skip, not need fix
                    }
                    //利用对DexFile的遍历并找到我们要修复的class文件
                    clazz = dexFile.loadClass(entry, patchClassLoader);
                    if (clazz != null) {
                        //调用这个方法区修复
                        fixClass(clazz, classLoader);
                    }
                }
            } catch (IOException e) {
                Log.e(TAG, "pacth", e);
            }
        }
    
        /**
         * fix class
         * 
         * @param clazz
         *            class
         */
        private void fixClass(Class<?> clazz, ClassLoader classLoader) {
            Method[] methods = clazz.getDeclaredMethods();
            MethodReplace methodReplace; //这个注解就是说明那个方法需要修复的注解
            String clz;
            String meth;
            for (Method method : methods) {
                methodReplace = method.getAnnotation(MethodReplace.class);
                if (methodReplace == null)
                    continue;
                clz = methodReplace.clazz();
                meth = methodReplace.method();
                if (!isEmpty(clz) && !isEmpty(meth)) {
                //进行方法替换
                    replaceMethod(classLoader, clz, meth, method);
                }
            }
        }
    
        /**
         * replace method
         * 
         * @param classLoader classloader
         * @param clz class
         * @param meth name of target method 
         * @param method source method
         */
        private void replaceMethod(ClassLoader classLoader, String clz,
                String meth, Method method) {
            try {
                String key = clz + "@" + classLoader.toString();
                Class<?> clazz = mFixedClass.get(key);
                if (clazz == null) {// class not load
                    Class<?> clzz = classLoader.loadClass(clz);
                    // initialize target class
                    clazz = AndFix.initTargetClass(clzz);
                }
                if (clazz != null) {// initialize class OK
                    mFixedClass.put(key, clazz);
                    Method src = clazz.getDeclaredMethod(meth,
                            method.getParameterTypes());
                    AndFix.addReplaceMethod(src, method);
                }
            } catch (Exception e) {
                Log.e(TAG, "replaceMethod", e);
            }
        }
    
        ...
        
        //一直跟踪会调用到下面AndFix类中的这方法
        private static native void replaceMethod(Method dest, Method src);

这个流程就是就是先找到补丁文件中要修复的类,找到类后再找到这么类中要修复的方法。如何判断哪些方法是需要修复的呢?就是通过注解。最后将这个带注解的类利用类加载去加载,最后利用native层去实现替换。由于本人能力有限,native就不去分析了。那么这个注解和这个结果到底是怎么样的呢?能不能直观的去看见呢?那么我们就从这个补丁文件入手,其实这个补丁文件核心如下:

其实补丁文件的核心就是这个dex文件

图片.png

META-INF下为:

图片.png

可以看到这与在Patch类中定义的格式是一样的。所以Patch是对补丁文件的包装成类的。

上面我们可以看到AndFix是通过注解来获取要被替换的方法,大家看AndFix的集成文档可以看到这段:How to use?Prepare two android packages, one is the online package, the other one is the package after you fix bugs by coding.Generate .apatch file by providing the two package。没错,就是在生成补丁文件的时候把发生改变的类增加了一个CF后缀,然后把对应的方法动态加上了注解,最后丢到了补丁中的dex文件中,我们反编译一下这个dex文件,看看AndFix帮我们生成的类:

图片.png

可以看到,AndFix自动帮我们加上了一个methodReplace的注解,注解里的内容就是要被替换的原类中的类名和方法。最后我们看一下native层真正做的事情。到这里也就分析结束了。


结语

这篇文章虽然对使用中的太多坑没有过多的讲解。不过对于完全没有接触过的小伙伴应该还是很有帮助的吧。从使用到原理我们都有了一定的认识。由于本人接触也没多久,这篇文章主要是记录自己的学习与帮助没有接触过的小伙伴应。如果更深入的理解还需要对.dex .class文件以及虚拟机和DVM都要有一定的理解(我也不懂!哈哈)。不过孰能生巧,在熟练使用后在去探究更深层次,会更容易理解。如果想简单了解.dex .class文件以及虚拟机和DVM请参考我的另外两个笔记。下篇我们讲讲最难的Tinker的使用与分析。下篇见

如果对热修复已经很了解了: 推荐文章 黑科技热修复的Java层实现 也可以用java层实现热修复


本人是个接触Android不就的菜鸟。很多热修复也没有研究的很透彻。如果有错误希望大家指出,不胜感激。如果对您有帮助别忘了点个赞,评个论,留下你的足迹。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Phoenix的Android之旅

观察者模式--DataBinding的原理和坑

上一次我们介绍了DataBinding的应用,不过只在应用层面描述了下,没有做深入分析。 关于DataBinding的实现原理,它的根本思想是观察者模式。 这篇...

2782
来自专栏cmazxiaoma的架构师之路

Android实战 粗略实现一个简单的C/S结构聊天室的功能

2285
来自专栏向治洪

android dataBinding详解

官方介绍地址:http://developer.android.com/intl/zh-cn/tools/data-binding/guide.html 201...

23210
来自专栏Android常用基础

Dagger2-从入门到精通(上)

最近在做项目中,用到了Dagger2,所以找了一些博客和其他的一些学习资源,算是知道如何使用了,但是对其理解还相差很远。所以这篇文章重点针对与使用,和使用中常见...

1261
来自专栏技术小黑屋

记一场 Android 技术答疑

之前在Stuq的Android课程中有幸分享了一些关于优化的问题,后期又处理了一些来自网友的问题,这里简单以文字形式做个整理.

1272
来自专栏C#

最好的.NET开源免费ZIP库DotNetZip(.NET组件介绍之三)

   在项目开发中,除了对数据的展示更多的就是对文件的相关操作,例如文件的创建和删除,以及文件的压缩和解压。文件压缩的好处有很多,主要就是在文件传输的方面,文件...

3987
来自专栏一个会写诗的程序员的博客

浅谈android hook技术浅谈android hook技术-- coding:utf-8 --print jscode author = 'gaohe'-- coding:utf-8 --pri

您当前的位置: 安全博客 > 技术研究 > 浅谈android hook技术 浅谈android hook技术 2017年03月17日 10:06 1...

7192
来自专栏Java编程技术

Spring&Mybaits数据库配置解惑

一般我们会在datasource.xml中进行如下配置,但是其中每个配置项原理和用途是什么,并不是那么清楚,如果不清楚的话,在使用时候就很有可能会遇到坑,所以下...

1232
来自专栏Android干货园

Android JS相互调用详解

版权声明:本文为博主原创文章,转载请标明出处。 https://blog.csdn.net/lyhhj/article/details/49...

2011
来自专栏白驹过隙

ZeroMQ - 三种模型的python实现

51214

扫码关注云+社区

领取腾讯云代金券