浅谈Android ASM自动埋点方案实践

这段时间想到一个有趣的功能,就是在Android的代码编译期间进行一些骚操作,来达到一些日常情境下难以实现的功能,比如监听应用中的所有onClick点击时间,或者监听某些方法的运行耗时,如果在代码中一个方法一个方法修改会很蛋疼,所以想通过Gradle插件来实现在应用的编译期间进行代码插入的功能。

1、AOP的概念

其实这已经涉及到AOP(Aspect Oriented Programming),即面向切面编程,在编译期间对代码进行动态管理,以达到统一维护的目的。

AOP切面

举个栗子,Android开发我们都知道,在项目越来越大的时候,应用可能被分解为多个模块,如果你要往所有模块的方法里头加一句‘我是大傻叼'的Toast,那是不是得跪。所以最好的方式是想办法在编译的时候拿到所有方法,往方法里头怼一个Toast,这样还不会影响到运行期间性能。

2、Transform

Android打包流程

如图所示是Android打包流程,.java文件->.class文件->.dex文件,只要在红圈处拦截住,拿到所有方法进行修改完再放生就可以了,而做到这一步也不难,Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API, 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件,我们做的就是实现Transform进行.class文件遍历拿到所有方法,修改完成对原文件进行替换。

/**
 * 自动埋点追踪,遍历所有文件更换字节码
 */
public class AutoTransform extends Transform {

 @Override
 String getName() {
  return "AutoTrack"
 }
 @Override
 Set<QualifiedContent.ContentType> getInputTypes() {
  return TransformManager.CONTENT_CLASS
 }
 @Override
 Set<QualifiedContent.Scope> getScopes() {
  return TransformManager.SCOPE_FULL_PROJECT
 }

 @Override
 boolean isIncremental() {
  return false
 }
 @Override
 public void transform(
   @NonNull Context context,
   @NonNull Collection<TransformInput> inputs,
   @NonNull Collection<TransformInput> referencedInputs,
   @Nullable TransformOutputProvider outputProvider,
   boolean isIncremental) throws IOException, TransformException, InterruptedException {
   //此处会遍历所有文件
   /**遍历输入文件*/
   inputs.each { TransformInput input ->
    /**
    * 遍历jar
    */
    input.jarInputs.each { JarInput jarInput ->
     ...
    }
    /**
    * 遍历目录
    */
    input.directoryInputs.each { DirectoryInput directoryInput ->
    ...
    }
  }
}

3、Gradle插件实现

通过Transform提供的api可以遍历所有文件,但是要实现Transform的遍历操作,得通过Gradle插件来实现,关于Gradle插件的知识可以看相关博客,也可以直接看博主的项目 Luffy。编写Gradle插件可能需要一点Goovy知识,具体编写直接用java语言写也可以,Goovy是完全兼容java的,只截取插件入口部分实现PluginEntry.groovy

class PluginEntry implements Plugin<Project> {

 @Override
 void apply(Project project) {
  ...
  //使用Transform实行遍历
  def android = project.extensions.getByType(AppExtension)
  registerTransform(android)
  ...
 }

def static registerTransform(BaseExtension android) {
 AutoTransform transform = new AutoTransform()
 android.registerTransform(transform)
}

4、字节码编写

完成上面的操作以后就剩下一件事了,那就是拿到.class文件了,大家都知道.class文件是字节码格式的,操作起来难度是相当于大的,所以需要一个字节码操作库来减轻难度,那就是ASM了。

4.1、ASM简介

ASM 可以直接产生二进制的class 文件,也可以在增强既有类的功能。Java class 被存储在严格格式定义的 .class文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。

4.2、具体使用ASM

ASM框架中的核心类有以下几个:

  1. ClassReader:该类用来解析编译过的class字节码文件。
  2. ClassWriter:该类用来重新构建编译后的类,比如说修改类名、属性以及方法,甚至可以生成新的类的字节码文件。
  3. ClassVisitor:主要负责 “拜访” 类成员信息。其中包括标记在类上的注解,类的构造方法,类的字段,类的方法,静态代码块。
  4. AdviceAdapter:实现了MethodVisitor接口,主要负责 “拜访” 方法的信息,用来进行具体的方法字节码操作。
  5. ClassVisitor的全部方法如下,按一定的次序来遍历类中的成员。

ClassVisitor的全部方法如下,按一定的次序来遍历类中的成员。

ClassVisitor全部api

在ClassVisitor中根据你的条件进行判断,满足条件的类才会修改其中方法,比如要统计点击事件的话,需要实现View$OnClickListener接口的类才会遍历其中的方法进行操作。

class AutoClassVisitor extends ClassVisitor {

 AutoClassVisitor(final ClassVisitor cv) {
  super(Opcodes.ASM4, cv)
 }

 @Override
 void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {

  //进行需要满足类的条件过滤
  ...
  super.visit(version, access, name, signature, superName, interfaces)
 }

 @Override
 void visitInnerClass(String name, String outerName, String innerName, int access) {
  // 内部类信息
  ...
  super.visitInnerClass(name, outerName, innerName, access)
 }

 @Override
 MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
  // 拿到需要修改的方法,执行修改操作
  MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions)
  MethodVisitor adapter = null
  ...
  adapter = new AutoMethodVisitor(methodVisitor, access, name, desc)
  ...
  return methodVisitor
 }

 @Override
 void visitEnd() {
  //类中成员信息遍历介绍
  ...
  super.visitEnd()
 }
}

在MethodVisitor中根据对已经拿到的方法进行修改了。

MethodVisitor adapter = new AutoMethodVisitor(methodVisitor, access, name, desc) {
  boolean isAnnotation = false
  @Override
  protected void onMethodEnter() {
   super.onMethodEnter()
   //进入方法时可以插入字节码
   ...
  }

  @Override
  protected void onMethodExit(int opcode) {
   super.onMethodExit(opcode)
   //退出方法前可以插入字节码
   ...
  }

  /**
   * 需要通过注解的方式加字节码才会重写这个方法来进行条件过滤
   */
  @Override
  AnnotationVisitor visitAnnotation(String des, boolean visible) {
   ...
   return super.visitAnnotation(des, visible)
  }
 }

5、实战演练

以上就是总体的思路了,现在就通过 Luffy根据具体需求实战一下,比如说在onClick方法点击的耗时(自动埋点也是一样的道理,只不过换了插桩的方法)。

5.1、插件配置

先打包一下插件到本地仓库进行引用,在项目的根build.gradle加入插件的依赖

dependencies {
 classpath 'com.xixi.plugin:plugin:1.0.1-SNAPSHOT'
}

在app的build.gradle中

apply plugin: 'apk.move.plugin'

xiaoqingwa{
 name = "小傻逼"
 isDebug = true
 //具体配置
 matchData = [
   //是否使用注解来找对应方法
   'isAnotation': false,
   //方法的匹配,可以通过类名或者实现的接口名匹配
   'ClassFilter': [
     ['ClassName': null, 'InterfaceName':null,
      'MethodName':null, 'MethodDes':null]
   ],
   //插入的字节码,方法的执行顺序visitAnnotation->onMethodEnter->onMethodExit
   'MethodVisitor':{
    MethodVisitor methodVisitor, int access, String name, String desc ->
     MethodVisitor adapter = new AutoMethodVisitor(methodVisitor, access, name, desc) {
      boolean isAnnotation = false
      @Override
      protected void onMethodEnter() {
       super.onMethodEnter()
       //使用注解找对应方法的时候得加这个判断
      }

      @Override
      protected void onMethodExit(int opcode) {
       super.onMethodExit(opcode)
       //使用注解找对应方法的时候得加这个判断
      }

      /**
       * 需要通过注解的方式加字节码才会重写这个方法来进行条件过滤
       */
      @Override
      AnnotationVisitor visitAnnotation(String des, boolean visible) {
       return super.visitAnnotation(des, visible)
      }
     }
     return adapter
   }
 ]
}

要是使用演示的话,因为还没上传到jcenter库,所以只能本地仓库打包插件,记得要先把依赖都注释掉,插件打包完成后再启用,不然会编译不过去的。

xiaoqingwa{} 里头的配置信息先不用管,等会会讲到,主要是为了能够不修改插件进行动态更换插桩的方法。

5.2、应用测试

插件配置好了之后就可以测试一下效果了,先写一个耗时统计的工具类

TimeCache.java

/**
 * Author:xishuang
 * Date:2018.01.10
 * Des:计时类,编译器加入指定方法中
 */
public class TimeCache {
 public static Map<String, Long> sStartTime = new HashMap<>();
 public static Map<String, Long> sEndTime = new HashMap<>();

 public static void setStartTime(String methodName, long time) {
  sStartTime.put(methodName, time);
 }

 public static void setEndTime(String methodName, long time) {
  sEndTime.put(methodName, time);
 }

 public static String getCostTime(String methodName) {
  long start = sStartTime.get(methodName);
  long end = sEndTime.get(methodName);
  long dex = end - start;
  return "method: " + methodName + " cost " + dex + " ns";
 }
}

大概思路就是使用HashMap来临时保存对应方法的时间,退出方法时获取时间差。

在一个方法的前后插入时间统计的方法,这个具体的过程要怎么操作呢,因为class文件是字节码格式的,ASM也是进行字节码操作,所以必须先把插入的代码转换成字节码先。这里推荐一个字节码查看工具Java Bytecode Editor,导入.class文件就可以看到对应字节码了。

比如我们要插入的代码如下:

private void countTime() {
 TimeCache.setStartTime("newFunc", System.currentTimeMillis());

 TimeCache.setEndTime("newFunc", System.currentTimeMillis());
 Log.d("耗时", TimeCache.getCostTime("newFunc"));
}

先把.java文件编译成.class文件,用Java Bytecode Editor打开

插入代码的字节码

然后根据其用ASM提供的Api一一对应的把代码填进来加到onMethodEnter和onMethodExit中。

//方法前加入
methodVisitor.visitMethodInsn
methodVisitor.visitLdcInsn(name)
methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setStartTime", "(Ljava/lang/String;J)V", false)

//方法后加入
methodVisitor.visitLdcInsn(name)
methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setEndTime", "(Ljava/lang/String;J)V", false)
methodVisitor.visitLdcInsn("耗时")
methodVisitor.visitLdcInsn(name)
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "getCostTime", "(Ljava/lang/String;)Ljava/lang/String;", false)
methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false)

在app的build.gradle中配置得到的字节码,最后设置一下过滤条件,最终的代码如下:

build.gradle

xiaoqingwa{
 name = "小傻逼"
 isDebug = true
 //具体配置
 matchData = [
   //是否使用注解来找对应方法
   'isAnotation': false,
   //方法的匹配,可以通过类名或者实现的接口名匹配
   'ClassFilter': [
     ['ClassName': 'com.xishuang.plugintest.MainActivity', 'InterfaceName': 'android/view/View$OnClickListener',
      'MethodName':'onClick', 'MethodDes':'(Landroid/view/View;)V']
   ],
   //插入的字节码,方法的执行顺序visitAnnotation->onMethodEnter->onMethodExit
   'MethodVisitor':{
    MethodVisitor methodVisitor, int access, String name, String desc ->
     MethodVisitor adapter = new AutoMethodVisitor(methodVisitor, access, name, desc) {
      boolean isAnnotation = false
      @Override
      protected void onMethodEnter() {
       super.onMethodEnter()
       //使用注解找对应方法的时候得加这个判断
//       if (!isAnnotation){
//        return
//       }

       methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/MainActivity", "notifyInsert", "()V", false)
       methodVisitor.visitLdcInsn(name)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setStartTime", "(Ljava/lang/String;J)V", false)
      }

      @Override
      protected void onMethodExit(int opcode) {
       super.onMethodExit(opcode)
       //使用注解找对应方法的时候得加这个判断
//       if (!isAnnotation){
//        return
//       }

       methodVisitor.visitLdcInsn(name)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setEndTime", "(Ljava/lang/String;J)V", false)
       methodVisitor.visitLdcInsn("耗时")
       methodVisitor.visitLdcInsn(name)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "getCostTime", "(Ljava/lang/String;)Ljava/lang/String;", false)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false)
      }

      /**
       * 需要通过注解的方式加字节码才会重写这个方法来进行条件过滤
       */
      @Override
      AnnotationVisitor visitAnnotation(String des, boolean visible) {
//       if (des.equals("Lcom/xishuang/annotation/AutoCount;")) {
//        println "注解匹配:" + des
//        isAnnotation = true
//       }
       return super.visitAnnotation(des, visible)
      }
     }
     return adapter
   }
 ]
}

'isAnotation' 表示是否使用注解的方式找到对应方法,这里false,因为我们现在是通过具体类信息来判断的。

'ClassFilter' 表示过滤条件,其中 'ClassName''InterfaceName' 用于判断哪些类中的方法可以遍历其中的方法进行匹配修改,不满足的话就不会进行方法名匹配了,这些感兴趣的童鞋都可以改插件自定义扩展。

'MethodName''MethodDes' 是方法名和方法描述符,可以唯一确定一个方法名,满足类过滤条件的就会进行方法匹配,例如我们要统计的点击事件 onClick(View v)

意思就是继承自 android/view/View$OnClickListener 的类或者类名是 'com.xishuang.plugintest.MainActivity' 就可以进行方法的遍历,然后方法满足 onClick(View v) 就会进行代码插入操作。

设置完之后rebuild一下就可以了,可以通过日志看下具体信息, isDebug = true 可以开启日志打印。

日志

通过日志可以看到我们设置的字节码确实插桩成功,现在再看一下编译后的文件验证一下,具体位置是:app\build\intermediates\transforms\AutoTrack\debug\folders

编译后的.class文件

其中的notifyInsert()是我用来弹Toast额外调试用的,请忽略。在手机上点击一下按钮测试一下,发现确实记录下点击的耗时时间,完成。

5.3、注解匹配

除了以上的方式来查找修改的方法之外,还可以通过注解来查找,切换很简单,只需要改一下app的build.gradle文件就可以了,项目中也有栗子,添加了一个注解类。

/**
 * Author:xishuang
 * Date:2018.1.9
 * Des:时间统计注解
 */
@Target(ElementType.METHOD)
public @interface AutoCount {
}

然后在对应的方法上添加你自定义的注解

@AutoCount
 private void onClick() {
  try {
   Thread.sleep(1000);
  } catch (InterruptedException e) {
   e.printStackTrace();
  }
 }

 @AutoCount
 @Override
 public void onClick(View v) {
  if (v.getId() == R.id.button) {
   Toast.makeText(this, "我是按钮", Toast.LENGTH_SHORT).show();
  }
 }

修改一下build.gradle中的配置文件

xiaoqingwa{
 name = "小傻逼"
 isDebug = true
 //具体配置
 matchData = [
   //是否使用注解来找对应方法
   'isAnotation': true,
   //方法的匹配,可以通过类名或者实现的接口名匹配
   'ClassFilter': [
     ['ClassName': 'com.xishuang.plugintest.MainActivity', 'InterfaceName': 'android/view/View$OnClickListener',
      'MethodName':'onClick', 'MethodDes':'(Landroid/view/View;)V']
   ],
   //插入的字节码,方法的执行顺序visitAnnotation->onMethodEnter->onMethodExit
   'MethodVisitor':{
    MethodVisitor methodVisitor, int access, String name, String desc ->
     MethodVisitor adapter = new AutoMethodVisitor(methodVisitor, access, name, desc) {
      boolean isAnnotation = false
      @Override
      protected void onMethodEnter() {
       super.onMethodEnter()
       //使用注解找对应方法的时候得加这个判断
       if (!isAnnotation){
        return
       }

       methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/MainActivity", "notifyInsert", "()V", false)
       methodVisitor.visitLdcInsn(name)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setStartTime", "(Ljava/lang/String;J)V", false)
      }

      @Override
      protected void onMethodExit(int opcode) {
       super.onMethodExit(opcode)
       //使用注解找对应方法的时候得加这个判断
       if (!isAnnotation){
        return
       }

       methodVisitor.visitLdcInsn(name)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setEndTime", "(Ljava/lang/String;J)V", false)
       methodVisitor.visitLdcInsn("耗时")
       methodVisitor.visitLdcInsn(name)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "getCostTime", "(Ljava/lang/String;)Ljava/lang/String;", false)
       methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false)
      }

      /**
       * 需要通过注解的方式加字节码才会重写这个方法来进行条件过滤
       */
      @Override
      AnnotationVisitor visitAnnotation(String des, boolean visible) {
       if (des.equals("Lcom/xishuang/annotation/AutoCount;")) {
        println "注解匹配:" + des
        isAnnotation = true
       }
       return super.visitAnnotation(des, visible)
      }
     }
     return adapter
   }
 ]
}

关键代码在于把 'isAnotation' 设为 true ,然后在 visitAnnotation 方法中添加你的注解类匹配,也就是这句 des.equals("Lcom/xishuang/annotation/AutoCount;") 代码,注解类的描述符,运行效果和上面差不多,但是不会打印日志,因为通过注解来查找方法会遍历每个方法,打印信息太多电脑会爆炸。

具体的信息可以看下源码,已共享到github上,在这里讲了下大概的思路和代码框架,只是起到抛砖引玉的作用,更有趣的玩法大家可以自己修改一下插件来实现。

github地址: Luffy

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • 浅谈Android ASM自动埋点方案实践

    这段时间想到一个有趣的功能,就是在Android的代码编译期间进行一些骚操作,来达到一些日常情境下难以实现的功能,比如监听应用中的所有onClick点击时间,或者监听某些方法的运行耗时,如果在代码中一个方法一个方法修改会很蛋疼,所以想通过Gradle插件来实现在应用的编译期间进行代码插入的功能. 1.AOP的概念 其实这已经涉及到AOP(Aspect Oriented Programming),即面向切面编程,在编译期间对代码进行动态管理,以达到统一维护的目的. AOP切面 举个栗子,Andro

  • 浅谈Android应用内悬浮控件实践方案总结

    在工作中遇到一个需求,需要在整个应用的上层悬浮显示控件,目标效果如下图: 首先想到的是申请悬浮窗权限,OK~ 打开搜索引擎,映入眼帘的并不是如何申请,而是"Android 悬浮窗权限各机型各系统适配大全.Android 绕过权限显示悬浮窗...",为什么悬浮窗权限会有这么多坑呢?悬浮窗可以在桌面显示,被恶意软件用来偷偷弹广告怎么办?作为一个系统级别的特殊权限,这是它应有的高傲 - - 正确引导用户打开悬浮窗权限才是标准做法,若这就是定论的话这篇文章也没必要写了,我们绕过悬浮窗权限直接去

  • 浅谈Android插件化

    目录 一.认识插件化 1.1 插件化起源 1.2 插件化优点 1.3 与组件化的区别 二.插件化的技术难点 三.ClassLoader Injection 3.1 java 中的 ClassLoader 3.2 android 中的 ClassLoader 3.3 双亲委派机制 3.4 如何加载插件中的类 3.5 执行插件类的方法 四.Runtime Container 4.1 为什么没有注册的 Activity 不能和系统交互 4.2 运行时容器技术 4.3 字节码替换 五.Resource

  • 浅谈Android Studio JNI生成so库

    1.新建Android studio工程 2.新建class:AppKey.java.主要为了保存密钥 代码块 package com...adminapp.lib.utils.jni; /** * Created by seven on 16/9/8. */ public class AppKey { static { System.loadLibrary("AppKey"); } public static native String WechatId(); public stat

  • 浅谈Android中Service的注册方式及使用

    Service通常总是称之为"后台服务",其中"后台"一词是相对于前台而言的,具体是指其本身的运行并不依赖于用户可视的UI界面,因此,从实际业务需求上来理解,Service的适用场景应该具备以下条件: 1.并不依赖于用户可视的UI界面(当然,这一条其实也不是绝对的,如前台Service就是与Notification界面结合使用的): 2.具有较长时间的运行特性. 1.Service AndroidManifest.xml 声明 一般而言,从Service的启动方式上

  • 浅谈Android 照相机权限的声明

    最近写项目,发现在AndroidManifest.xml中声明了调用相机权限之后,打开app无法启动相机,经过一番搜查发现: 问题在于当写项目所使用的API的版本过高时(比如我所用的测试机为android 5.0,而我写这个工程所用的API为27,即android 7.0 ),导致APP无法自动向系统请求硬件调用的权限. 解决方案: 在activity初始化布局之后,加上如下代码即可: setContentView(R.layout.activity_camera); //申明一个权限 if (

  • 浅谈Android studio 生成apk文件时的 key store path 的问题

    使用Android studio生成apk文件时,Key store path 是密钥库文件地址的意思,新手菜鸟会想,我怎么知道他在哪里,其实他的地址是你来决定的. 如下图,你选择一个文件夹后,填写file name,然后点击ok就生成了. 大佬们见怪了~ 补充知识:AndroidStudio每次打开项目不自动打开上一次打开的文件.每次打包都需要重新输入key store path 最近在运行AS时,发现每次打开都要重新的打开目录及打开相关的文件,打包必须重新添加签名文件,我也是醉了. 问题原因

  • 浅谈Android Studio3.6 更新功能

    前言 下载google CodeLab的程序时,提示要更新3.6版本才能运行程序,于是更新了一下,看看有什么新功能. 界面设计工具 这次更新了一些设计工具,比如Layout Editor 和 Resource Manager. 现在,在XML或设计工具的颜色选择器中,Android Studio会在您的应用程序中填充颜色资源,以便您快速选择和替换颜色资源值. 拆分视图并放大设计编辑器 设计编辑器(例如,布局编辑器和导航编辑器)现在提供一个拆分视图,使您可以同时查看UI的"设计"视图和&

  • 浅谈 Android 7.0 多窗口分屏模式的实现

    从 Android 7.0 开始,Google 推出了一个名为"多窗口模式"的新功能,也就是我们常说的"分屏模式".那么,这个功能有什么用呢?作为开发者,我们又能做些什么? Android 7.0 添加了对同时显示多个 APP 的支持.在手持设备上,两个 APP 可以在分屏模式下并排运行. 嗯,大概就是这样: 分屏模式的适配 我们如何才能让自己的 APP 支持分屏模式呢? 若项目的 targetSDKVersion 大于等于24,那么可以在 AndroidManif

  • 浅谈Android Studio导出javadoc文档操作及问题的解决

    1.在Android studio中进行打开一个项目的文件之后,然后进行点击Android stuio中菜单中的"tools"的选项.在弹出了下拉菜单中,进行选中下拉菜单中的"Generate JavaDoc"的选项. 2.在弹出界面中 Output directory是你即将生产的javadoc文件的存储位置,图中1指示的位置:正常点击ok即可: 但是如果有异常情况 比如空指针异常或者文档乱码 java.lang.NullPointerException 或者 j

随机推荐