当前位置: 首页 > news >正文

一起用Gradle Transform API + ASM完成代码织入呀~

本文Demo地址:https://github.com/ClericYi/Asm_Demo

前言

最近的工作内容主要其实并不是说主攻插桩,但是这一次使用Lancet插桩给项目本来带来了极大的收益,这和工程的设计相关,当初的设计就是在对抖音中一个原有组件尽可能小的修改情况下,完成我新功能的接入,方案从SPI --> 主工程Lancet --> Lancet下沉到一个自定义组件中,一次次尝试确实也是领会这个黑科技的恐怖之处了。

先了解以下当时的场景:

先比较一期和二期的优势和劣势:实践发现一期最后相较于二期的优势仅仅只有不影响主工程,而劣势主要表现在三个方面:

  1. api改动时,impl组件需要联动修改。

  2. 当时的环境决定,使用SPI方案时,会导致大量的本不需要过早获取的数据被获取了,导致运行时工程性能降低,另外还有反射在损耗性能。

但是二期方案也存在劣势,我们也说了影响主工程,而且说Lancet的生效时机需要进行把握,不可能让他全局生效因为本身就是特定情况下,全局时会影响编译速度,另外这在后期的维护上成本也有一定的增加。

以上的总结最后引出了方案三,不影响主工程,并且不需要把握生效时机,只需要某组件给出Hook点,就可以轻松完成工作。

本文只探讨怎么去实现AscpectJ这一类AOP方案的方法。

热门的插桩方案探索

浏览了一下Github上比较热门的插桩方案,看到普遍进行使用的就是AspectJ还有Lancet,而作为AspectJ他的延伸中的拓展库AspectJX,因为比较好的兼容性而受到广泛使用。

AspectJX的使用方法

AspectJX是基于 gradle android插件1.5及以上版本设计使用的。

插件引入

// root -> build.gradle
dependencies {
        classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.8'        
}
// app -> build.gradle
apply plugin: 'android-aspectjx'

如何使用

这里用的是一个他的权限请求库Android_Permission_AspectjX,注意使用过程中发现一个Bug,给作为基类的Activity套上注解时并不会生效,基类的方法是没问题的。

// 1. app --> build.gradle
compile 'com.firefly1126.permissionaspect:permissionaspect:1.0.1'
// 2. 自定义Application
onCreate(){
	PermissionCheckSDK.init(Application);
}
// 3. 使用注解的方式添加权限@NeedPermission
@NeedPermission(permissions = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
public class BActivity extends Activity {}

//作用于类的方法
@NeedPermission(permissions = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
private void startBActivity(String name, long id) {
        startActivity(new Intent(MainActivity.this, BActivity.class));
    }

非常简单的使用了两个注解就已经完成权限的申请。

这个库的一些坑

这样就已经完成库的导入了,但是查阅一些度娘的资料会发现这样的问题发生库的冲突。比如与支付宝sdk发生冲突,以下是一段用于复现代码。

PayTask alipay = new PayTask(this);

这是由于AspectJX本身造成的,默认会处理所有的二进制代码文件和库,为了提升编译效率及规避部分第三方库出现的编译兼容性问题,AspectJX提供include,exclude命令来过滤需要处理的文件及排除某些文件(包括class文件及jar文件)。当然为了解决这样的问题,开发者也提供了解决方案,也就是白名单。

aspectjx {
	//排除所有package路径中包含`android.support`的class文件及库(jar文件)
	exclude 'android.support'
    // exclude '*'
    // 关闭AspectJX功能,默认开启
    enabled false
}

Lancet的使用

文章只做涉略,更为具体的使用请查看仓库:https://github.com/eleme/lancet

  1. 插件引入

// root --> build.gradle
dependencies {
    classpath 'com.android.tools.build:gradle:3.3.2'
    classpath 'me.ele:lancet-plugin:1.0.6'
}
// build.gralde
apply plugin: 'me.ele.lancet'
dependencies {
    compileOnly 'me.ele:lancet-base:1.0.6'
}
  1. Lancet的使用

public class LancetHooker {
    @Insert(value = "eat", mayCreateSuper = true)
    @TargetClass(value = "com.example.lancet.Cat", scope = Scope.SELF)
    public void _eat() {
        ((Cat)This.get()).bark();
        //这里可以使用 this 访问当前 Cat 类的成员,仅用于Insert 方式的非静态方法的Hook中.(暂时)
        System.out.println(">>>>>>>" + this);
        Origin.callVoid();
    }

    @Insert(value = "bark", mayCreateSuper = true)
    @TargetClass(value = "com.example.lancet.Cat", scope = Scope.SELF)
    public void _bark(){
        System.out.println("调用了bark");
        Origin.callVoid();
    }
}

当定义了Hook点,并且在编译时被搜索到,最后编译完成之后的效果就会为如下所示。

public class Cat {

    class _lancet {
        private _lancet() {
        }
		// 比如调用原本调用bark的方法,会重写为调用com_example_lancet_LancetHooker__bark
        // 如果内部存在Origin.Call()这一类的方法时,会对原本的方法在自己的调用点上进行过程
        @Insert(mayCreateSuper = true, value = "bark")
        @TargetClass(scope = Scope.SELF, value = "com.example.lancet.Cat")
        static void com_example_lancet_LancetHooker__bark(Cat cat) {
            System.out.println("调用了bark");
            cat.bark$___twin___();
        }

        @Insert(mayCreateSuper = true, value = "eat")
        @TargetClass(scope = Scope.SELF, value = "com.example.lancet.Cat")
        static void com_example_lancet_LancetHooker__eat(Cat cat) {
            cat.bark();
            PrintStream printStream = System.out;
            printStream.println(">>>>>>>" + cat);
            cat.eat$___twin___();
        }
    }

    public void bark() {
        _lancet.com_example_lancet_LancetHooker__bark(this);
    }

    public void eat() {
        _lancet.com_example_lancet_LancetHooker__eat(this);
    }

    /* access modifiers changed from: private */
    public void eat$___twin___() {
        System.out.println("猫吃老鼠");
    }

    public String toString() {
        return "猫";
    }

    /* access modifiers changed from: private */
    public void bark$___twin___() {
        System.out.println("猫叫了叫");
    }
}

可以发现它的做法是对源代码进行修改,而修改的方式是建设一个静态内部类,和对应的内部方法,通过重新设置调用链来进行结果的完成,那AspectJ呢,他是否是通过这样的方式来进行完成的呢?

AspectJ是如果实现的?

权限的申请只通过几个注解就能够完成,那他是怎么做的呢?我们可以通过jadx-gui来反编译代码进行查看。

因为AspectJX默认对所有文件生效,所以是否添加注解都会被劫持,除非使用上文中的开白名单

public final class MainActivity extends BaseActivity {
    private static final /* synthetic */ JoinPoint.StaticPart ajc$tjp_0 = null;
    private HashMap _$_findViewCache;

    /* compiled from: MainActivity.kt */
    public class AjcClosure1 extends AroundClosure {
        public AjcClosure1(Object[] objArr) {
            super(objArr);
        }

        public Object run(Object[] objArr) {
            Object[] objArr2 = this.state;
            MainActivity.onCreate_aroundBody0((MainActivity) objArr2[0], (Bundle) objArr2[1], (JoinPoint) objArr2[2]);
            return null;
        }
    }

    static {
        ajc$preClinit();
    }

    private static /* synthetic */ void ajc$preClinit() {
        Factory factory = new Factory("MainActivity.kt", MainActivity.class);
        ajc$tjp_0 = factory.makeSJP(JoinPoint.METHOD_EXECUTION, (Signature) factory.makeMethodSig("4", "onCreate", "com.example.stub.MainActivity", "android.os.Bundle", "savedInstanceState", "", "void"), 12);
    }

    public void _$_clearFindViewByIdCache() {
        HashMap hashMap = this._$_findViewCache;
        if (hashMap != null) {
            hashMap.clear();
        }
    }

    public View _$_findCachedViewById(int i) {
        if (this._$_findViewCache == null) {
            this._$_findViewCache = new HashMap();
        }
        View view = (View) this._$_findViewCache.get(Integer.valueOf(i));
        if (view != null) {
            return view;
        }
        View findViewById = findViewById(i);
        this._$_findViewCache.put(Integer.valueOf(i), findViewById);
        return findViewById;
    }

    static final /* synthetic */ void onCreate_aroundBody0(MainActivity ajc$this, Bundle savedInstanceState, JoinPoint joinPoint) {
        super.onCreate(savedInstanceState);
        ajc$this.setContentView((int) R.layout.activity_main);
    }

    /* access modifiers changed from: protected */
    public void onCreate(Bundle savedInstanceState) {
        JoinPoint makeJP = Factory.makeJP(ajc$tjp_0, (Object) this, (Object) this, (Object) savedInstanceState);
        PermissionAspect.aspectOf().adviceOnActivityCreate(new AjcClosure1(new Object[]{this, savedInstanceState, makeJP}).linkClosureAndJoinPoint(69648));
    }
}

通过编译后的源码查看可以发现,你所写的代码已经被通过一些特殊的方式来进行了修改,所以我们就应该有了自己的目标了,注解 + 自动化代码修改完成任务。

如何完成自动化代码修改

这里我们首先需要借用的能力是Gradle Transform Api中的遍历,而这个功能在你创建一个Android工程的时候Android Studio已经自然而然给你集成了这一项能力。

这个Api的能力只有在Gradle Version 1.5+的时候才开放

那它的运作方式是怎么样的呢?小二,上图。

上述本是Apk完整的打包流程,但是如果使用了Transform Api将会多出我们红框中的部分。当然如果三方的.class Files的文件内存在注解也是可能会被抓住的。所以这里我们知道了一个目标是被编译过后的.class文件们,而代码的修改逻辑肯定是和我们的希望实现的逻辑有关的。

看过了上面反编译出来的一个代码修改模式,我们可以先思考一下这种代码修改可以如何去进行。比如说

public void fun(Login login){
	login.on();
}

但是我们想直接劫持这样的方法,因为这个方法它只做了一个登陆操作,但是我想做身份验证呢?如果代码中只有一处还好说,但是如果多处呢?可能我的代码就变成了如下

public void fun(Login login){
	if(login.check()) login.on();
    else login.close()
}

上述代码还是比较简单的,但是有些时候这种逻辑的重复书写是时常存在的,而且随着代码容量的增加而导致维护难度提高,如果有一天身份验证方法变了,那就凉透了。这就是插桩经常会被用到的地方 —— AOP面向切面,在代码实现时,你需要干的事情是给对应的方法加上一个注解,处理逻辑统一完成。

插桩实现

第一个环节:如何将插桩的能力植入

这里真的真的看了很多网上资料,质量参差不齐,花了整整一天时间,终于把整个东西跑起来了???? ???? ???? ,下面文章内将给出我认为最简便的创建工程的方案。

如果只是想要本地测试的话,这里给出的是最简便的方案,使用buildSrc(大小写也要一致哦!)来作为Android Library的名字可以省去99%的麻烦。

最后会在文末给一个可以用于发版使用的实现方案介绍。

那要先进入第一步,插件的使用。

为了能够引入Gradle的能力,请将仓库内的build.gradle的内容修改成如下的形式。

apply plugin: 'groovy'

dependencies {
    implementation gradleApi()//gradle sdk

    implementation 'com.android.tools.build:gradle:3.5.4'
    implementation 'com.android.tools.build:gradle-api:3.5.4'

    //ASM依赖
    implementation 'org.ow2.asm:asm:8.0'
    implementation 'org.ow2.asm:asm-util:8.0'
    implementation 'org.ow2.asm:asm-commons:8.0'
}

repositories {
    google()
    jcenter()
}

上述内容完成sync以后,就需要生成一个插件能够进行使用。

/**
 * Create by yiyonghao on 2020-08-08
 * Email: yiyonghao@bytedance.com
 */
public class AsmPlugin implements Plugin<Project> {
    @Override
    public void apply(Project project) {
        System.out.println("===========  doing  ============");
    }
}

并且在主工程的app --> build.gradle中添加语句apply plugin: com.example.buildsrc.AsmPlugin(包名.插件名)

很多工程说用Groovy来做,其实没有必要,直接Java就可以了。

如果到这一步,在build过程中能够打印出=========== doing ============这个数据,说明插件已经生效,那现在就要进入下一步,如何完成代码的插桩了。

在不引入ASM之前,整体Gradle Transform API为我们提供了什么样的能力呢?先明确目标,如果想要代码的插桩,我们一定要进行下面这样的几个步骤:

  1. 源码文件获取(可能是.class,也可能是.jar

  2. 文件修改

源码文件获取

为了获取文件的路径,我们使用的能力就是Gradle Transform API所提供的Transform类,其中的transform()方法中的变量其实已经自动为我们提供了很多他自身所具备的能力,就比如说文件遍历。

public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
        //消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
        Collection<TransformInput> inputs = transformInvocation.getInputs();
        //OutputProvider管理输出路径,如果消费型输入为空,你会发现OutputProvider == null
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

        for (TransformInput input : inputs) {
            for (JarInput jarInput : input.getJarInputs()) {
                File dest = outputProvider.getContentLocation(
                        jarInput.getFile().getAbsolutePath(),
                        jarInput.getContentTypes(),
                        jarInput.getScopes(),
                        Format.JAR);
            }
            for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
                File dest = outputProvider.getContentLocation(directoryInput.getName(),
                        directoryInput.getContentTypes(), directoryInput.getScopes(),
                        Format.DIRECTORY);
                transformDir(directoryInput.getFile(), dest); 
            }
        }
    }

通过如上的方式,就可以扫到我们的文件了,那就应该要接入第二个步骤,如何进行文件的修改?

文件修改

在上文中我从来没有提及过Gradle Transform API关于修改代码的逻辑,这是为什么呢?

还不是因为他并不提供这样专项的功能,所以这里就要引入我们经常听说的大将ASM来完成字节码的修改了。这里开始将注意点放置到我们的两个类AsmClassAdapterAsmMethodVisitor还有AsmTransform.weave()

关于ASM最最最最常涉及的是下面几个核心类。

当然我现在给出的Demo中有两个类,AsmClassAdapter就是继承了ClassVisitor用来访问Class也就是我们的一个个类,而AsmMethodVisitor就是通过ClassVisitor的数据传递然后用于访问类中存在的方法的。

private static void weave(String inputPath, String outputPath) {
        try {
        	// 。。。。。
        	// 而文件结构的访问通过ASM基于的能力来进行识别
            ClassReader cr = new ClassReader(is);
            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
            AsmClassAdapter adapter = new AsmClassAdapter(cw);
            cr.accept(adapter, 0);
            // 。。。。。
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

其实本质上就是ASM对一个文件进行分析操作以后,让我们只关注想要插入什么,以什么样的方法去进行插入,然后他会使用对应的方案对字节码进行整改。

AsmClassAdapterAsmMethodVisitor的简单实现
public class AsmClassAdapter extends ClassVisitor implements Opcodes {
    public AsmClassAdapter(ClassVisitor classVisitor) {
        super(ASM7, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        return (mv == null) ? null : new AsmMethodVisitor(mv); // 1 -->
    }
}

MethodVisitor方法对于我们而言,就是对方法的一个插桩方案。

public class AsmMethodVisitor extends MethodVisitor{
    public AsmMethodVisitor(MethodVisitor methodVisitor) {
        super(ASM7, methodVisitor);
    }

    @Override
    public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
        //方法执行之前打印
        mv.visitLdcInsn(" before method exec");
        mv.visitLdcInsn(" [ASM 测试] method in " + owner + " ,name=" + name);
        mv.visitMethodInsn(INVOKESTATIC,
                "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);
		// 原有方法
        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);

        //方法执行之后打印
        mv.visitLdcInsn(" after method exec");
        mv.visitLdcInsn(" method in " + owner + " ,name=" + name);
        mv.visitMethodInsn(INVOKESTATIC,
                "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);
    }
}

你可以实现更多类似这样的方法。而这样做过之后,我们是否已经完成了所谓了字节码的修改了呢?

第二步:文件覆盖

可能你跑不通,这里直接给出一个答案,并没有完成!!我们我们虽然会所把字节码修改了,但是你是否有完成文件的覆盖呢?

所以你能够在Demo中发现存在这样的代码,比如:

  1. weave()方法

private static void weave(String inputPath, String outputPath) {
        try {
        	// 存在新文件的创建
            FileInputStream is = new FileInputStream(inputPath);
            ClassReader cr = new ClassReader(is);
            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
            AsmClassAdapter adapter = new AsmClassAdapter(cw);
            cr.accept(adapter, 0);
            FileOutputStream fos = new FileOutputStream(outputPath);
            fos.write(cw.toByteArray());
            fos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
  1. FileUtils.copyFile(jarInput.getFile(), dest);存在jar包的位置迁移,这都是为了将新的代码进行存储

完成到这里,我们在去看一下最后生成的代码到底是什么样的。(文件路径:app --> build --> intermediates --> transform --> 包名 --> debug --> 一直到你的文件)比如说我本地生成的MainActivity.java

public class MainActivity extends AppCompatActivity {
    public MainActivity() {
        Log.i(" before method exec", " [ASM 测试] method in androidx/appcompat/app/AppCompatActivity ,name=<init>");
        super();
        Log.i(" after method exec", " method in androidx/appcompat/app/AppCompatActivity ,name=<init>");
    }

    protected void onCreate(Bundle savedInstanceState) {
        Log.i(" before method exec", " [ASM 测试] method in androidx/appcompat/app/AppCompatActivity ,name=onCreate");
        super.onCreate(savedInstanceState);
        Log.i(" after method exec", " method in androidx/appcompat/app/AppCompatActivity ,name=onCreate");
        Log.i(" before method exec", " [ASM 测试] method in com/example/asm/MainActivity ,name=setContentView");
        this.setContentView(2131361820);
        Log.i(" after method exec", " method in com/example/asm/MainActivity ,name=setContentView");
        Log.i(" before method exec", " [ASM 测试] method in android/util/Log ,name=e");
        Log.e("aa", "aa");
        Log.i(" after method exec", " method in android/util/Log ,name=e");
    }
}

如果说你觉得好麻烦啊,那你也可以使用一个插件ASM Bytecode Outline的工具来完成插桩后代码的查看

每一个方法最后都被我们插入了我们要插入的代码,那ok,说明离我们通过注解来进行插桩的目标已经迈出了一大步。

如何通过注解完成

既然要用注解来完成事件,那这个时候我们就创建一个注解,但是请注意其中的@Retention注解写法,是需要在编译期的时候进行生效的。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface ASM {}

然后你可以在MainActivity.java中加入方法,并加上这个注解。那接下来的事情是什么呢?想必就是扫到这个注解了,也就是使用了visitAnnotation()的方法。

@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
        return super.visitAnnotation(descriptor, visible);
    }

但是纵观继承过来的方法,很显然并不能说它本身并不能去修改这个注解所对应的方法,所以我们最后的妥协只能是通过加入标示符号,当要进行方法插入的时候告诉visitMethodInsn()我这段代码他是需要去进行插入的。

@Override
    public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
        if(ANNOTATION_TRACK_METHOD.equals(descriptor)) isMatch = true;
        return super.visitAnnotation(descriptor, visible);
    }

visitMethodInsn()这个方法在插入之前需要先进行判定,如此需要才进行插桩。以下就是插桩之后的结果:

public class MainActivity extends AppCompatActivity {
    public MainActivity() {
    }

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        this.setContentView(2131361820);
        Log.e("aa", "aa");
    }

    @Cat
    public void fun() {
        Log.d("tag", "onCreate start");
        Log.d("tag", "onCreate end");
    }

    @ASM
    public void fun1() {
    }
}

发布一个可以给别人用的插件

这个时候你不要在去在意Module的名字了,定义你想要的名字。为了方便起见,可以选择先拷贝一份之前buildSrc中写好的代码。既然是要发布,那我们首先要干的事情就是使用Gradle进行upload操作了。

// 在你新设置的Module --> build.gradle中加入以下代码,你可以diy
uploadArchives {
    repositories.mavenDeployer {
        repository(url: uri('../repo'))
        pom.groupId = 'com.example.asm'
        pom.artifactId = 'asm_plugin'
        pom.version = '1.0.0'
    }
}

但是这个时候发布了并且在主工程进行引入的话,其实还是找不到我们的Plugin插件的。

因为他还需要一步操作,创建如下的目录,这是为了让我们发布的文件能够被发现

implementation-class = com.example.asm_plugin.AsmPlugin // 插件在包中位置给出

最后在root --> build.gralde中引入repo,就可以像buildSrc一样生效了。

buildscript {
    repositories {
        google()
        jcenter()
        maven {
            url uri("repo")
        }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.4'
        classpath 'com.example.asm:asm_plugin:1.0.0'
    }
}

参考资料

  • Android aop AspectJX与第三方库冲突的解决方案:https://www.jianshu.com/p/3899f0431895

  • 和我一起用 ASM 实现编译期字节码织入:https://juejin.im/post/6844904040438972429

  • Android全埋点解决方案之ASM:https://www.sensorsdata.cn/blog/20181206-9/


技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。

推荐阅读:

音视频面试基础题

OpenGL ES 学习资源分享

一文读懂 YUV 的采样与格式

OpenGL 之 GPUImage 源码分析

推荐几个堪称教科书级别的 Android 音视频入门项目

觉得不错,点个在看呗~

相关文章:

  • 用shader做一个柿子颜色的过场动画
  • 只需跟着Google学android:ViewModel篇
  • 我用 OpenGL 实现了那些年流行的相机滤镜
  • 有必要去研究Handler和Binder么?
  • 音视频交流群又来啦~~~
  • Android 手机如何拍摄RAW图
  • 华为手机刷微博体验更好?技术角度的分析和思考
  • 播放视频时如何调整音频的音量
  • 视频码控:CBR、VBR和ABR
  • OpenGL ES 相机 LUT 滤镜
  • Android 11 正式发布 | 开发者们的舞台已就绪
  • 刚刚,鸿蒙 OS 2.0 发布!HarmonyOS 将正式开源!
  • 如何给 FFmpeg 添加自定义 Codec 编码器
  • FFmpeg从入门到精通——进阶篇,SEI那些事儿
  • iOS音频采集技术解读:如何实现男女变声的音效?
  • 实现windows 窗体的自己画,网上摘抄的,学习了
  • 【个人向】《HTTP图解》阅后小结
  • el-input获取焦点 input输入框为空时高亮 el-input值非法时
  • Java多态
  • Laravel Mix运行时关于es2015报错解决方案
  • mac修复ab及siege安装
  • Map集合、散列表、红黑树介绍
  • Object.assign方法不能实现深复制
  • PAT A1050
  • 测试开发系类之接口自动化测试
  • 搭建gitbook 和 访问权限认证
  • 短视频宝贝=慢?阿里巴巴工程师这样秒开短视频
  • 关于使用markdown的方法(引自CSDN教程)
  • 开源SQL-on-Hadoop系统一览
  • 力扣(LeetCode)56
  • 你真的知道 == 和 equals 的区别吗?
  • 推荐一款sublime text 3 支持JSX和es201x 代码格式化的插件
  • 学习Vue.js的五个小例子
  • 积累各种好的链接
  • 摩拜创始人胡玮炜也彻底离开了,共享单车行业还有未来吗? ...
  • ​云纳万物 · 数皆有言|2021 七牛云战略发布会启幕,邀您赴约
  • !! 2.对十份论文和报告中的关于OpenCV和Android NDK开发的总结
  • # 手柄编程_北通阿修罗3动手评:一款兼具功能、操控性的电竞手柄
  • #中的引用型是什么意识_Java中四种引用有什么区别以及应用场景
  • (c语言)strcpy函数用法
  • (NO.00004)iOS实现打砖块游戏(九):游戏中小球与反弹棒的碰撞
  • (NSDate) 时间 (time )比较
  • (Redis使用系列) SpringBoot中Redis的RedisConfig 二
  • (SpringBoot)第二章:Spring创建和使用
  • (过滤器)Filter和(监听器)listener
  • (删)Java线程同步实现一:synchronzied和wait()/notify()
  • (四)搭建容器云管理平台笔记—安装ETCD(不使用证书)
  • (一一四)第九章编程练习
  • (转) SpringBoot:使用spring-boot-devtools进行热部署以及不生效的问题解决
  • (转)树状数组
  • *p=a是把a的值赋给p,p=a是把a的地址赋给p。
  • .net 4.0发布后不能正常显示图片问题
  • .NET Core 2.1路线图
  • .net core Swagger 过滤部分Api
  • .net MySql